Class: Clacky::ShellHookLoader
- Inherits:
-
Object
- Object
- Clacky::ShellHookLoader
- 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:
# Simple protocol — no `type` (or `type: command`). exit-code driven.
- name: guard # optional label for logs
command: "~/.clacky/hook-scripts/guard.sh"
timeout: 10 # optional, seconds (default 10)
# Rewrite protocol — `type: rewrite`. Rich JSON stdin/stdout, supports
# matcher, updatedInput rewrite. (before_tool_use only)
- type: rewrite
# matcher must be the CANONICAL tool name the agent dispatches
# (e.g. `terminal`), NOT an alias (`bash`/`shell`/`exec`): the
# agent resolves aliases to canonical names BEFORE firing hooks,
# so `matcher: bash` would never match. "*" or omitted = all tools.
matcher: terminal
command: "~/.clacky/hook-scripts/rewrite.sh"
timeout: 30 # optional, seconds (default 60)
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). Rewrite hooks parse STDOUT as hookSpecificOutput.
- exit 2 → deny; STDOUT (simple) or stderr/stdout (rewrite) is the reason.
Only before_tool_use is checked for {action: :deny}.
- any other exit / timeout / crash → logged, treated as allow (a broken
hook must never wedge the agent).
Rewrite entries chain in config order: each applies its updatedInput IN PLACE on the tool call (complete replacement — no merge); the first deny stops the chain.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- DEFAULT_PATH =
File.("~/.clacky/hooks.yml")
- DEFAULT_TIMEOUT =
10- REWRITE_DEFAULT_TIMEOUT =
60- DENY_EXIT_CODE =
2- MAX_OUTPUT_BYTES =
Cap on bytes buffered per stream: before_tool_use fires on every tool call, so a runaway/malicious hook could otherwise OOM the agent.
8 * 1024 * 1024
Class Method Summary collapse
-
.load_into(hook_manager, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil, path: DEFAULT_PATH) ⇒ Object
The context procs supply rewrite-protocol context at run time; optional so simple-only callers (e.g.
clacky hook_verify) can omit them. -
.scaffold(path: DEFAULT_PATH) ⇒ String
Create a starter hooks.yml plus an example guard script.
Instance Method Summary collapse
-
#initialize(path: DEFAULT_PATH, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil) ⇒ ShellHookLoader
constructor
A new instance of ShellHookLoader.
-
#load_into(hook_manager) ⇒ Result
Counts of registered hooks and skipped (with reasons).
Constructor Details
#initialize(path: DEFAULT_PATH, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil) ⇒ ShellHookLoader
Returns a new instance of ShellHookLoader.
109 110 111 112 113 114 |
# File 'lib/clacky/shell_hook_loader.rb', line 109 def initialize(path: DEFAULT_PATH, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil) @path = path @session_id_fn = session_id_fn @cwd_fn = cwd_fn @permission_mode_fn = end |
Class Method Details
.load_into(hook_manager, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil, path: DEFAULT_PATH) ⇒ Object
The context procs supply rewrite-protocol context at run time; optional so
simple-only callers (e.g. clacky hook_verify) can omit them.
56 57 58 59 |
# File 'lib/clacky/shell_hook_loader.rb', line 56 def self.load_into(hook_manager, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil, path: DEFAULT_PATH) new(path: path, session_id_fn: session_id_fn, cwd_fn: cwd_fn, permission_mode_fn: ) .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.
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/clacky/shell_hook_loader.rb', line 64 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. # Add `type: rewrite` to a before_tool_use entry to use the rich JSON # protocol (updatedInput rewrite, matcher). # Events: #{HookManager::HOOK_EVENTS.join(", ")} hooks: before_tool_use: - name: deny-example command: "#{guard}" timeout: 10 # - type: rewrite # matcher: terminal # command: "~/.clacky/hook-scripts/rewrite.sh" # timeout: 30 # 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).
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/clacky/shell_hook_loader.rb', line 117 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.}") result end |