Skip to content

Commit bf6bcb6

Browse files
authored
Merge pull request gi-dellav#178 from xavierforge/feat/hook-system
feat(hooks): external-command hooks at agent lifecycle points
2 parents 78ec8fd + a24bb59 commit bf6bcb6

69 files changed

Lines changed: 4774 additions & 140 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ multithread = ["tokio/rt-multi-thread"]
3030
multimodal = ["rig/image"]
3131
pdf = ["multimodal", "rig/pdf"]
3232
advisor = []
33+
hooks = []
3334

3435
[dependencies]
3536
rig = { version = "0.39", features = ["rmcp"] }

docs/COMMANDS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ the first line. When a prompt with `%%mode=last_user_mode` is activated,
6767
the mode reverts to whatever was last set explicitly by `/mode` or
6868
startup config. See Prompts & Themes below.
6969

70+
## Hooks
71+
72+
Requires the `hooks` feature (default-off; see [CONFIG.md](CONFIG.md#hooks)).
73+
74+
| Command | Description |
75+
| ------- | ----------- |
76+
| `/hooks` | Show whether a hook dispatcher is installed and, if so, each configured event with its handler count. |
77+
78+
Run `zerostack --hooks-test <tool> [--hooks-test-input <json>]` from the
79+
shell (not a slash command) to dry-run `PreToolUse` hooks for a tool without
80+
starting a session or making a model call. See
81+
[CONFIG.md](CONFIG.md#hooks) for the full hooks configuration reference.
82+
7083
## Prompts & Themes
7184

7285
| Command | Description |

docs/CONFIG.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,130 @@ Accepted top-level keys:
189189
| `acp_port` | integer | TCP bind port for ACP server mode (equivalent to `--acp-port`, default: 7243). |
190190
| `colors` | object | Background color overrides for the TUI. See the colors section below. |
191191

192+
## Hooks
193+
194+
Requires the `hooks` Cargo feature, which is **default-off** — a prebuilt
195+
binary or package must have been compiled with `--features hooks` (or
196+
`--all-features`) for any of this to apply. When the feature isn't compiled
197+
in, none of the flags, files, or `/hooks`/`--hooks-test` commands below exist.
198+
199+
Hooks let external commands observe or gate agent behavior at defined points
200+
(a tool call, a user prompt, the agent finishing a turn, a session
201+
starting/ending, a subagent starting/stopping), using the same
202+
`settings.json` shape, stdin envelope, and exit-code/stdout-JSON contract as
203+
Claude Code, so an existing CC hooks setup is largely compatible (see the
204+
`$CLAUDE_PROJECT_DIR` caveat below for the one script-level change some
205+
setups need).
206+
207+
### Config file locations and precedence
208+
209+
Hook config lives in a `settings.json` (JSON, not `config.toml`/`.yaml`) at up
210+
to three locations, loaded and merged in this order:
211+
212+
| Location | Trust |
213+
| -------- | ----- |
214+
| `~/.config/zerostack/settings.json` (global; on macOS `~/Library/Application Support/zerostack/settings.json`; on Windows `%APPDATA%\zerostack\settings.json`, experimental) | Trusted by default |
215+
| `.zerostack/settings.json` (project, relative to CWD) | **Not** trusted by default — see Trust model below |
216+
| `/etc/zerostack/managed-settings.json` (Linux) / `/Library/Application Support/zerostack/managed-settings.json` (macOS) / `C:\ProgramData\zerostack\managed-settings.json` (Windows, experimental) — admin-controlled | Always trusted; unaffected by `disableAllHooks` |
217+
218+
Each file may have a top-level `hooks` object (keyed by event name) and a
219+
top-level `disableAllHooks: true` boolean. `disableAllHooks` (from the global
220+
or project file) or the `--no-hooks` CLI flag suppresses every non-managed
221+
hook; managed hooks still run regardless. A missing or invalid file is not an
222+
error — it just contributes nothing.
223+
224+
**Compatible with Claude Code's `.claude/settings.json`**: zerostack does not
225+
read that file directly, but its own `settings.json` uses the identical
226+
`hooks` schema, so copying or symlinking the `hooks` key from
227+
`.claude/settings.json` into `.zerostack/settings.json` works as-is. Scripts
228+
themselves may still need a change: zerostack sets `$ZEROSTACK_PROJECT_DIR`
229+
rather than `$CLAUDE_PROJECT_DIR`, so a script that reads the latter must be
230+
updated to read the former.
231+
232+
### Handler schema
233+
234+
```json
235+
{
236+
"hooks": {
237+
"PreToolUse": [
238+
{
239+
"matcher": "Bash|Write",
240+
"hooks": [
241+
{ "type": "command", "command": "./guard.sh", "timeout": 30 }
242+
]
243+
}
244+
]
245+
}
246+
}
247+
```
248+
249+
| Field | Type | Description |
250+
| ----- | ---- | ----------- |
251+
| `type` | string | Only `"command"` is supported. |
252+
| `command` | string | Shell command, run via `sh -c` on Unix (`powershell -Command` on Windows, experimental). Receives the stdin envelope as JSON; `$ZEROSTACK_PROJECT_DIR` is set in its environment. |
253+
| `args` | array of strings | When present, bypasses the shell entirely: `command` is executed directly as the program with `args` as its argv (no shell metacharacter expansion). |
254+
| `timeout` | integer (seconds) | Per-hook timeout; the whole process group is killed on expiry. Default: 60. |
255+
| `async` | boolean | When `true`, the hook runs in the background and its decision is ignored. Default: `false`. |
256+
| `if` | string | A shell command evaluated (with the same stdin envelope) before the handler runs; the handler only runs if it exits `0`. Fails closed: a broken/unparseable/timed-out condition still runs the handler, with a warning. |
257+
| `once` | boolean | Runs the handler at most once per event per session; later matches are skipped. |
258+
259+
`matcher` (on the handler group, not the handler) follows Claude Code
260+
semantics: omitted, `""`, or `"*"` matches every tool; a bare name or a
261+
`|`/`,`-separated list is an exact case-insensitive match after tool-name
262+
normalization (e.g. `Bash``bash`, `Glob``find_files`, `Edit|Write`
263+
matches zerostack's `write` tool); anything else is treated as a regex.
264+
Invalid regexes are reported at load time.
265+
266+
### Events
267+
268+
`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`,
269+
`Stop`, `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`.
270+
`PreCompact` and `Notification` are not currently implemented.
271+
272+
Only `PreToolUse` is permission-blockable by default. A handler's stdout JSON
273+
may set `"permissionDecision"` to `"deny"`, `"ask"`, `"allow"`, or omit it
274+
(defer to the normal permission system). `deny` always blocks, holding even
275+
under `--yolo`. `ask` forces an interactive confirmation regardless of
276+
permission mode, and escalates to deny in non-interactive contexts (`-p`,
277+
`--loop`) where no confirmation is possible. `allow` suppresses the
278+
interactive prompt for that one call only — it can never override a deny
279+
from a rule, security mode, managed policy, or another hook. `PreToolUse` may
280+
also set `"updatedInput"` to rewrite the tool's arguments before it runs, and
281+
`PostToolUse` may set `"result"` to rewrite the model-visible output.
282+
283+
`UserPromptSubmit` and `SubagentStart` can set `"additionalContext"` to
284+
prepend text to the prompt. `Stop` and `SubagentStop` can set
285+
`"decision": "block"` with a `"reason"` to force the agent (or subagent) to
286+
continue instead of finishing, using `reason` as the next instruction; `Stop`
287+
gives up after 8 consecutive blocks without progress.
288+
289+
Any handler can also signal via **exit code** instead of JSON: exit `0` means
290+
no objection, exit `2` blocks (for blockable events) with stderr as the
291+
reason, and any other exit code is a non-blocking error. Exit `2` combined
292+
with stdout JSON is a mixed-channel warning — the JSON is ignored.
293+
294+
### Trust model
295+
296+
Project-level hook handlers (`.zerostack/settings.json` — global and managed
297+
hooks are trusted automatically) require interactive confirmation the first
298+
time they'd run, keyed by a hash of the handler's definition (event +
299+
matcher + command/args/timeout/etc.); changing the definition changes the
300+
hash and requires re-confirmation. Confirmations persist to
301+
`$XDG_DATA_HOME/zerostack/trusted-hooks.json` (a user-level file, so child
302+
processes/orchestrated subagents sharing it inherit trust automatically). In
303+
headless contexts (`-p`, `--loop`) an unconfirmed project hook is skipped
304+
with a warning rather than prompting.
305+
306+
### Global switches
307+
308+
| Flag | Effect |
309+
| ---- | ------ |
310+
| `--no-hooks` | Disables all non-managed hooks for this run. |
311+
| `disableAllHooks: true` (in global or project `settings.json`) | Same effect, via config. |
312+
| `--hooks-test <tool> [--hooks-test-input <json>]` | Dry-runs `PreToolUse` for `tool` against the loaded/trust-filtered dispatcher and prints the merged verdict/reason/`updatedInput`, then exits — no session, agent, or API key required. |
313+
314+
See [COMMANDS.md](COMMANDS.md#hooks) for the `/hooks` slash command.
315+
192316
## Mid-turn compaction
193317

194318
By default zerostack only compacts the conversation *between* turns, after a

src/agent/builder.rs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -225,21 +225,29 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
225225
)),
226226
]);
227227

228-
let mut builder = builder.tools(base_tools.into_vec());
228+
let mut all_tools: Vec<Box<dyn rig::tool::ToolDyn>> = base_tools.into_vec();
229229

230230
#[cfg(feature = "subagents")]
231231
if cfg.task_enabled.unwrap_or(true) {
232232
use crate::extras::subagents::task_tool::TaskTool;
233-
builder = builder.tool(TaskTool::new(permission.clone(), ask_tx.clone()));
233+
all_tools.push(Box::new(TaskTool::new(permission.clone(), ask_tx.clone())));
234234
}
235235

236236
#[cfg(feature = "memory")]
237237
{
238238
use crate::extras::memory::{MemoryRead, MemorySearch, MemoryWrite};
239-
builder = builder
240-
.tool(MemoryWrite::new(permission.clone(), ask_tx.clone()))
241-
.tool(MemoryRead::new(permission.clone(), ask_tx.clone()))
242-
.tool(MemorySearch::new(permission.clone(), ask_tx.clone()));
239+
all_tools.push(Box::new(MemoryWrite::new(
240+
permission.clone(),
241+
ask_tx.clone(),
242+
)));
243+
all_tools.push(Box::new(MemoryRead::new(
244+
permission.clone(),
245+
ask_tx.clone(),
246+
)));
247+
all_tools.push(Box::new(MemorySearch::new(
248+
permission.clone(),
249+
ask_tx.clone(),
250+
)));
243251
}
244252

245253
#[cfg(feature = "mcp")]
@@ -253,22 +261,21 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
253261
let mcp_tools = manager
254262
.collect_tools(permission.clone(), ask_tx.clone())
255263
.await;
256-
if !mcp_tools.is_empty() {
257-
let dyn_tools: Vec<Box<dyn rig::tool::ToolDyn>> = mcp_tools
258-
.into_iter()
259-
.map(|t| Box::new(t) as Box<dyn rig::tool::ToolDyn>)
260-
.collect();
261-
builder = builder.tools(dyn_tools);
264+
for t in mcp_tools {
265+
all_tools.push(Box::new(t) as Box<dyn rig::tool::ToolDyn>);
262266
}
263267
}
264268

265269
#[cfg(feature = "advisor")]
266270
if crate::extras::advisor::with_config(|c| c.enabled) {
267271
use crate::extras::advisor::AdvisorTool;
268-
builder = builder.tool(AdvisorTool::new());
272+
all_tools.push(Box::new(AdvisorTool::new()));
269273
}
270274

271-
builder.build()
275+
#[cfg(feature = "hooks")]
276+
let all_tools = crate::extras::hooks::wrap_from_global(all_tools, permission.clone());
277+
278+
builder.tools(all_tools).build()
272279
}
273280
}
274281

0 commit comments

Comments
 (0)