Class: Clacky::ShellHookLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/shell_hook_loader.rb

Overview

Loads declarative, shell-based hooks from ~/.clacky/hooks.yml and registers them on a HookManager. Each hook runs an external command rather than Ruby in the agent process, which keeps user-authored hooks sandboxed and safe.

hooks.yml format:

hooks:
  before_tool_use:
    - name: guard            # optional label for logs
      command: "~/.clacky/hook-scripts/guard.sh"
      timeout: 10            # optional, seconds (default 10)
  on_complete:
    - command: "notify-send done"

Runtime contract (per invocation):

- The event payload is passed to the command as JSON on STDIN.
- exit 0  → allow (default).
- exit 2  → deny; STDOUT becomes the denial reason. Only meaningful for
            before_tool_use, which the agent checks for {action: :deny}.
- any other exit / timeout / crash → logged, treated as allow (a broken
  hook must never wedge the agent).

Defined Under Namespace

Classes: Result

Constant Summary collapse

DEFAULT_PATH =
File.expand_path("~/.clacky/hooks.yml")
DEFAULT_TIMEOUT =
10
DENY_EXIT_CODE =
2

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path: DEFAULT_PATH) ⇒ ShellHookLoader

Returns a new instance of ShellHookLoader.



83
84
85
# File 'lib/clacky/shell_hook_loader.rb', line 83

def initialize(path: DEFAULT_PATH)
  @path = path
end

Class Method Details

.load_into(hook_manager, path: DEFAULT_PATH) ⇒ Object



37
38
39
# File 'lib/clacky/shell_hook_loader.rb', line 37

def self.load_into(hook_manager, path: DEFAULT_PATH)
  new(path: path).load_into(hook_manager)
end

.scaffold(path: DEFAULT_PATH) ⇒ String

Create a starter hooks.yml plus an example guard script. Idempotent-ish: raises if hooks.yml already exists so we never clobber user config.

Returns:

  • (String)

    path to the created hooks.yml

Raises:

  • (ArgumentError)


44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/clacky/shell_hook_loader.rb', line 44

def self.scaffold(path: DEFAULT_PATH)
  raise ArgumentError, "hooks file already exists: #{path}" if File.exist?(path)

  dir = File.dirname(path)
  scripts_dir = File.join(dir, "hook-scripts")
  FileUtils.mkdir_p(scripts_dir)

  guard = File.join(scripts_dir, "deny-example.sh")
  File.write(guard, <<~SH)
    #!/usr/bin/env bash
    # Example before_tool_use hook.
    # Reads the event JSON on STDIN; exit 2 to DENY, exit 0 to ALLOW.
    # STDOUT on exit 2 becomes the denial reason shown to the agent.
    payload="$(cat)"
    # Example: deny any terminal command containing "rm -rf /"
    if echo "$payload" | grep -q 'rm -rf /'; then
      echo "blocked dangerous command"
      exit 2
    fi
    exit 0
  SH
  FileUtils.chmod("+x", guard)

  File.write(path, <<~YAML)
    # Declarative shell hooks. Each command receives the event payload as JSON
    # on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
    # Events: #{HookManager::HOOK_EVENTS.join(", ")}
    hooks:
      before_tool_use:
        - name: deny-example
          command: "#{guard}"
          timeout: 10
    #  on_complete:
    #    - command: "echo task finished"
  YAML

  path
end

Instance Method Details

#load_into(hook_manager) ⇒ Result

Returns counts of registered hooks and skipped (with reasons).

Returns:

  • (Result)

    counts of registered hooks and skipped (with reasons)



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/clacky/shell_hook_loader.rb', line 88

def load_into(hook_manager)
  result = Result.new(registered: [], skipped: [])
  return result unless File.exist?(@path)

  doc = YAMLCompat.load_file(@path) || {}
  events = doc["hooks"] || {}

  events.each do |event_name, specs|
    event = event_name.to_sym
    Array(specs).each do |spec|
      register_one(hook_manager, event, spec, result)
    end
  end

  result
rescue StandardError => e
  Clacky::Logger.error("[ShellHookLoader] Failed to load #{@path}: #{e.message}")
  result
end