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:
  # 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.expand_path("~/.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

Instance Method Summary collapse

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 = 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: 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.

Returns:

  • (String)

    path to the created hooks.yml

Raises:

  • (ArgumentError)


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).

Returns:

  • (Result)

    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.message}")
  result
end