Skip to main content

wcode_harness/
hooks.rs

1use std::sync::Arc;
2
3use crate::message::{AgentMessage, StopReason};
4use crate::protocol::{Request, SessionId};
5use crate::tool::ToolOutput;
6
7/// Loop-local view of `ContentBlock::ToolCall`.
8pub struct ToolCall {
9    pub id: String,
10    pub name: String,
11    pub arguments: serde_json::Value,
12}
13
14#[async_trait::async_trait]
15pub trait Hooks: Send + Sync {
16    /// May mutate the tool call's arguments before it is executed (e.g. to
17    /// rewrite a bash command through `rtk`). Mutations are what actually
18    /// executes, so `before_tool_call`/`after_tool_call` observe the rewrite.
19    async fn transform_tool_input(&self, _call: &mut ToolCall) {}
20
21    /// Some(reason) = blocked: tool is not executed; the reason is fed back
22    /// to the LLM as an error ToolResult (`blocked: {reason}`).
23    async fn before_tool_call(&self, _call: &ToolCall) -> Option<String> {
24        None
25    }
26
27    /// Runs after every tool execution; may mutate the output before it is
28    /// recorded and sent to the LLM.
29    async fn after_tool_call(&self, _call: &ToolCall, _out: &mut ToolOutput) {}
30
31    /// Policy for an inbound peer message (an A2A `Notify`/`Interrupt`/`Wake`):
32    /// the mirror of [`Hooks::before_tool_call`]. May rewrite the request in
33    /// place (the rewrite is what is delivered); `Some(reason)` drops it — the
34    /// reason is returned to the sender when it is an `ask`. A message dropped
35    /// here never reaches the context or starts a turn.
36    async fn before_inbound(
37        &self,
38        _from: Option<&SessionId>,
39        _request: &mut Request,
40    ) -> Option<String> {
41        None
42    }
43
44    /// Runs at every turn start, after steering is drained, before streaming.
45    async fn transform_context(&self, _msgs: &mut Vec<AgentMessage>) {}
46
47    /// Checked after tool execution completes; true ends the run with
48    /// StopReason::Stop.
49    async fn should_stop_after_turn(&self, _ctx: &[AgentMessage]) -> bool {
50        false
51    }
52
53    /// Runs once when a `run` finishes, with the final context and why it
54    /// stopped. The A2A auto-report rides this (a worker forwards its result to
55    /// its orchestrator); a no-op by default.
56    async fn after_run(&self, _ctx: &[AgentMessage], _stop: StopReason) {}
57}
58
59/// The workspace-mutating tools blocked in plan mode (D3). Read-only tools
60/// (`read`/`grep`/`find`/`ast_search`/`session_search`) and the plan-recording
61/// `todo` (and the team tools `task`/`peers`/`message`) are deliberately absent.
62pub const MUTATING_TOOLS: &[&str] = &["edit", "edits", "write", "ast_edit"];
63
64/// A cheap, cloneable on/off switch shared between the [`PlanModeHooks`] (read
65/// from the run task) and the toggle that flips it (the actor, via
66/// `Agent::set_plan_mode`). `Default` is off.
67#[derive(Clone, Default)]
68pub struct PlanModeHandle(Arc<std::sync::atomic::AtomicBool>);
69
70impl PlanModeHandle {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    pub fn get(&self) -> bool {
76        self.0.load(std::sync::atomic::Ordering::Relaxed)
77    }
78
79    pub fn set(&self, on: bool) {
80        self.0.store(on, std::sync::atomic::Ordering::Relaxed);
81    }
82}
83
84/// Plan-mode enforcement (D3/D4): while the mode is on, block workspace-mutating
85/// tools and mutating `bash` commands. A no-op when the mode is off.
86pub struct PlanModeHooks {
87    plan: PlanModeHandle,
88}
89
90impl PlanModeHooks {
91    pub fn new(plan: PlanModeHandle) -> Self {
92        Self { plan }
93    }
94}
95
96#[async_trait::async_trait]
97impl Hooks for PlanModeHooks {
98    /// In plan mode: block a workspace-mutating tool (by name), else gate `bash`
99    /// by the read heuristic. A no-op when plan mode is off.
100    async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
101        if !self.plan.get() {
102            return None;
103        }
104        if MUTATING_TOOLS.contains(&call.name.as_str()) {
105            return Some(format!(
106                "plan mode: `{}` is disabled — you are planning, not executing",
107                call.name
108            ));
109        }
110        if call.name == "bash" {
111            return plan_bash_reason(call);
112        }
113        None
114    }
115}
116
117/// Shell verbs whose presence as a segment's leading word blocks `bash` in plan
118/// mode.
119const MUTATING_VERBS: &[&str] = &[
120    "rm", "rmdir", "mv", "cp", "mkdir", "touch", "chmod", "chown", "chgrp", "ln", "dd", "truncate",
121    "tee", "patch",
122];
123
124/// `git` subcommands that mutate the index, history, or the remote.
125const MUTATING_GIT: &[&str] = &[
126    "add", "commit", "checkout", "reset", "clean", "apply", "merge", "rebase", "push", "stash",
127    "restore", "rm", "mv",
128];
129
130/// D4 — the bash read-gate. Raw extraction mirrors `rtk.rs:112-118`
131/// (`call.arguments.get("command").and_then(|c| c.as_str())`); a STRING SCAN for
132/// obvious mutators — a guardrail, NOT a sandbox (it both over- and under-blocks;
133/// see the FP/FN table in `docs/plan-mode.md` §2.4).
134fn plan_bash_reason(call: &ToolCall) -> Option<String> {
135    let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
136    looks_mutating(cmd).then(|| {
137        "plan mode: `bash` mutation refused — you are planning, not executing".to_string()
138    })
139}
140
141/// Whether a shell command looks like it mutates the workspace.
142fn looks_mutating(cmd: &str) -> bool {
143    // Any output redirection (`>`/`>>`) mutates a file (or a device). `| tee`
144    // is caught by `tee` in the segment scan below.
145    if cmd.contains('>') {
146        return true;
147    }
148    cmd.split([';', '&', '|', '\n']).any(segment_mutates)
149}
150
151/// Whether one shell segment (a command between separators) mutates.
152fn segment_mutates(seg: &str) -> bool {
153    let mut words = seg
154        .split_whitespace()
155        .map(|w| w.trim_matches(|c| c == '"' || c == '\''));
156    let Some(lead) = words.next() else {
157        return false;
158    };
159    let rest: Vec<&str> = words.collect();
160    if MUTATING_VERBS.contains(&lead) {
161        return true;
162    }
163    match lead {
164        "sed" => rest.iter().any(|w| w.starts_with("-i")),
165        "git" => matches!(rest.first().copied(), Some(g) if MUTATING_GIT.contains(&g)),
166        _ => mutating_build_command(lead, &rest),
167    }
168}
169
170/// Package-manager / build subcommands that mutate the source tree or lockfiles
171/// (as opposed to read-only builds like `cargo build`/`make`/`tsc`).
172fn mutating_build_command(lead: &str, rest: &[&str]) -> bool {
173    match lead {
174        "cargo" => matches!(
175            rest.first().copied(),
176            Some("fix" | "fmt" | "install" | "add" | "remove")
177        ),
178        "npm" | "pnpm" | "yarn" => matches!(
179            rest.first().copied(),
180            Some("install" | "ci" | "add" | "remove" | "update")
181        ),
182        "go" => {
183            matches!(rest.first().copied(), Some("get"))
184                || (matches!(rest.first().copied(), Some("mod"))
185                    && matches!(rest.get(1).copied(), Some("tidy")))
186        }
187        "pip" => matches!(rest.first().copied(), Some("install")),
188        _ => false,
189    }
190}
191// ---- bash command-risk gate (Tier-2; docs/gap-analysis-jcode.md §3) ---------
192//
193// Doctrine fit: code, not config — ALWAYS ON, no knob, no `HooksConfig` field.
194//
195// WHY BESIDE `PlanModeHooks`: both are kernel `Hooks` policies that gate `bash`.
196// Unlike plan mode this one carries NO handle and NO flag (`PlanModeHandle` has
197// no analogue here) — it is unconditionally active, so a unit struct suffices.
198//
199// SEAM: `Hooks::before_tool_call` -> `Some(reason)` blocks. The loop converts it
200// to `Planned::fail(id, name, format!("blocked: {reason}"))`, so the model
201// receives `blocked: catastrophic shell command (…) …` as a tool error and can
202// adapt. This is the exact plan-mode block path.
203//
204// ARG EXTRACTION mirrors `plan_bash_reason` (hooks.rs:cVnkO): skip unless
205// `call.name == "bash"`; then `call.arguments.get("command").and_then(|c| c.as_str())`.
206//
207// NOT REUSED: `looks_mutating` / `segment_mutates`. They are PRIVATE and test
208// *any* mutation (`rm -rf build`, `cargo fmt`, any `>` redirect) — the wrong
209// predicate for a catastrophic-only guard. This gate is SELF-CONTAINED: it does
210// NOT share a tokenizer with plan mode. Rationale: the two policies have OPPOSITE
211// FP budgets (plan mode over-blocks by design; this one must under-block to stay
212// a guardrail), so a shared scanner would have to satisfy both. Duplicating ~30
213// lines of splitting is cheaper — and keeps the FP tables independent.
214//
215// SCOPE: catastrophic ONLY — irreversible system loss (jcode's crate exists
216// because a user lost a home dir; issue #604). NOT a sandbox. Default = ALLOW
217// (`None`); block only on a clear match. Over-blocking is the lesser evil here,
218// but keep the subset tight.
219
220/// Enforced read-only (D1): while installed, refuse the workspace-mutating tools
221/// and mutating `bash`/`bg`. A guardrail, NOT a sandbox (the same FP/FN budget as
222/// plan mode's bash gate — docs/plan-mode.md §2.4). Stateless: a unit struct.
223pub struct ReadOnlyHooks;
224
225impl ReadOnlyHooks {
226    /// Kept for symmetry with `BashRiskHooks::new` (hooks.rs:zmKr5), so the CLI
227    /// wiring reads `Arc::new(ReadOnlyHooks::new())` like every other built-in.
228    pub fn new() -> Self {
229        Self
230    }
231}
232
233impl Default for ReadOnlyHooks {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239#[async_trait::async_trait]
240impl Hooks for ReadOnlyHooks {
241    /// `Some(reason)` — the loop shows `blocked: {reason}`, so the reason starts
242    /// `read-only worker: ` — for: any `MUTATING_TOOLS` name (hooks.rs:osi4C);
243    /// `bg` (background `bash` can mutate); `bash` whose `command` satisfies the
244    /// private `looks_mutating` (hooks.rs:SsKd8). Everything else passes.
245    async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
246        if MUTATING_TOOLS.contains(&call.name.as_str()) {
247            return Some(format!("read-only worker: `{}` is disabled", call.name));
248        }
249        if call.name == "bg" {
250            return Some("read-only worker: `bg` can run a mutating command".into());
251        }
252        if call.name == "bash" {
253            // Extraction mirrors plan_bash_reason (hooks.rs:cVnkO).
254            let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
255            if looks_mutating(cmd) {
256                return Some("read-only worker: `bash` mutation refused".into());
257            }
258        }
259        None
260    }
261}
262/// Always-on catastrophic-shell guard. Stateless — a unit struct is the entire
263/// type.
264pub struct BashRiskHooks;
265
266impl BashRiskHooks {
267    /// Kept for symmetry with `PlanModeHooks::new`, so the wiring in the CLI
268    /// reads `Arc::new(BashRiskHooks::new())` like every other built-in hook.
269    pub fn new() -> Self {
270        Self
271    }
272}
273
274impl Default for BashRiskHooks {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280#[async_trait::async_trait]
281impl Hooks for BashRiskHooks {
282    /// `Some(reason)` iff this is a `bash` call whose command is catastrophic;
283    /// `None` (allow) otherwise — the hot path.
284    async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
285        if call.name != "bash" {
286            return None; // every non-bash tool is untouched
287        }
288        // Mirrors plan_bash_reason's extraction (hooks.rs:cVnkO). A missing /
289        // non-string `command` (malformed call, or args already rewritten by an
290        // earlier hook) => allow.
291        let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
292        bash_risk_reason(cmd)
293    }
294}
295
296/// ROOT-ONLY guard: refuses a bare `sleep` (a polling wait) issued by the
297/// workflow orchestrator. A worker's report lands as a `Wake` only once the
298/// root ENDS its turn, so a `sleep` blocks the very message it is waiting for.
299/// Mirrors `BashRiskHooks` — stateless, a unit struct is the whole type.
300pub struct SleepRefusalHooks;
301
302impl SleepRefusalHooks {
303    /// Kept for symmetry with `BashRiskHooks::new`, so the wiring in the CLI
304    /// reads `Arc::new(SleepRefusalHooks::new())` like every other built-in.
305    pub fn new() -> Self {
306        Self
307    }
308}
309
310impl Default for SleepRefusalHooks {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[async_trait::async_trait]
317impl Hooks for SleepRefusalHooks {
318    /// `Some(reason)` iff this is a `bash` call whose first non-empty
319    /// `scan_segments` segment leads with `sleep`; `None` (allow) otherwise.
320    /// A missing / non-string `command` => allow (the same extraction as
321    /// `ReadOnlyHooks` / `BashRiskHooks`).
322    async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
323        if call.name != "bash" {
324            return None; // every non-bash tool is untouched
325        }
326        let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
327        sleep_poll_reason(cmd)
328    }
329}
330
331/// The first non-empty `scan_segments` segment's lead token == `sleep` -> the
332/// refusal reason; `None` to allow. The reason is generic (no `Wake` proper
333/// noun) and must NOT start with `refused: ` — the loop prefixes `blocked: `.
334/// It tells the model to END ITS TURN: the `Wake` a poll waits for can only
335/// arrive once the turn ends, so sleeping blocks the very message it waits for.
336fn sleep_poll_reason(cmd: &str) -> Option<String> {
337    let lead = scan_segments(cmd).into_iter().next()?.into_iter().next()?;
338    if lead != "sleep" {
339        return None;
340    }
341    Some(
342        "sleep-polling is not allowed — end your turn now; \
343         the next message will wake you when it arrives"
344            .to_string(),
345    )
346}
347
348/// `/dev/` device-name prefixes. A write *target* — `dd of=<dev>`, a `>`/`>>`
349/// redirect into a device, or an `mkfs*`/`wipefs`/`shred` operand — whose
350/// basename starts with one of these is catastrophic. Deliberately blunt:
351/// `/dev/sdafoo` would also match. Acceptable: an FP merely yields a reason;
352/// the guardrail never silently allows a real device.
353const DEVICE_PREFIXES: &[&str] = &["sd", "hd", "nvme", "vd", "disk", "mmcblk"];
354
355/// Targets that make a RECURSIVE delete/perm change catastrophic. Compared
356/// quote-trimmed, with a trailing `/*` glob folding onto its root (`~/*` -> `~`,
357/// `/*` -> `/`).
358const ROOT_TARGETS: &[&str] = &["/", "~", "$HOME", "${HOME}"];
359
360/// `rm` letters that mark a delete recursive (`-r`, `-rf`, `-fr`, `-R`).
361const RM_RECURSIVE: &[char] = &['r', 'R'];
362
363/// Canonical fork bomb, whitespace-free. [`forkbomb`] strips ALL ASCII
364/// whitespace and tests `contains` — covers `:(){ :|:& };:`, `:(){:|:&};:`, and a
365/// bomb hidden after a `;`/`&&` separator.
366const FORK_BOMB: &str = ":(){:|:&};:";
367
368/// Map a `bash` command to a refusal reason, or `None` to allow. Tries each
369/// predicate in turn; the first hit supplies a verbatim `label`.
370///
371/// The reason must NOT start with `refused: ` — the loop already prefixes
372/// `blocked: ` (loop_.rs:STqt9), and the `({label})` + "run it outside wcode"
373/// shape mirrors plan mode's tone.
374fn bash_risk_reason(cmd: &str) -> Option<String> {
375    let label = device_write(cmd)
376        .or_else(|| mkfs_or_wipe(cmd))
377        .or_else(|| recursive_root_delete(cmd))
378        .or_else(|| forkbomb(cmd))
379        .or_else(|| recursive_perm_root(cmd))?;
380    Some(format!(
381        "catastrophic shell command (`{label}`) — this would destroy the system; \
382         run it outside wcode if you really mean it"
383    ))
384}
385
386/// (1) Device write: `dd … of=/dev/<dev>` or a `>`/`>>` redirect into
387/// `/dev/<dev>`. Returns the offending segment as the label, or `None`.
388///
389/// Only `of=` counts for `dd` — `dd if=/dev/sda of=/tmp/img` (read a device into
390/// a file) stays allowed.
391fn device_write(cmd: &str) -> Option<String> {
392    for seg in scan_segments(cmd) {
393        let lead = seg.first().map(String::as_str).unwrap_or("");
394        let dd_write = lead == "dd"
395            && seg.iter().skip(1).any(|w| {
396                w.strip_prefix("of=")
397                    .is_some_and(|dev| is_device_path(dev, DEVICE_PREFIXES))
398            });
399        let redirect = seg.iter().enumerate().any(|(i, w)| {
400            let target = if let Some(t) = redirect_target(w) {
401                Some(t)
402            } else if matches!(w.as_str(), ">" | ">>") {
403                seg.get(i + 1).map(String::as_str)
404            } else {
405                None
406            };
407            target.is_some_and(|t| is_device_path(t, DEVICE_PREFIXES))
408        });
409        if dd_write || redirect {
410            return Some(seg.join(" "));
411        }
412    }
413    None
414}
415
416/// The redirect target baked into a single token, if any: `>/dev/sda`,
417/// `>>/dev/sda`, `2>/dev/sda`, … A bare `>`/`>>` (target in the *next* token)
418/// yields `None` — the caller handles that case.
419fn redirect_target(w: &str) -> Option<&str> {
420    let rest = w.trim_start_matches(|c: char| c.is_ascii_digit());
421    let after = rest.strip_prefix(">>").or_else(|| rest.strip_prefix('>'))?;
422    (!after.is_empty()).then_some(after)
423}
424
425/// (2) `mkfs` / `mkfs.<fs>` / `wipefs` / `shred` whose operand is a device path.
426/// `shred -n1 /dev/sda` blocks; `shred build/x` is allowed.
427fn mkfs_or_wipe(cmd: &str) -> Option<String> {
428    for seg in scan_segments(cmd) {
429        let Some(lead) = seg.first() else { continue };
430        let formats = lead == "mkfs" || lead.starts_with("mkfs.");
431        let wipes = lead == "wipefs" || lead == "shred";
432        if (formats || wipes) && seg.iter().skip(1).any(|w| is_device_path(w, DEVICE_PREFIXES)) {
433            return Some(seg.join(" "));
434        }
435    }
436    None
437}
438
439/// (3) `rm` with a recursive flag AND a root/home target (`rm -rf /`, `rm -fr ~`,
440/// `rm -R /*`, `rm -rf $HOME/*`).
441fn recursive_root_delete(cmd: &str) -> Option<String> {
442    for seg in scan_segments(cmd) {
443        let Some(lead) = seg.first() else { continue };
444        if lead == "rm"
445            && seg.iter().skip(1).any(|w| is_recursive_flag(w))
446            && seg.iter().skip(1).any(|w| is_root_target(w))
447        {
448            return Some(seg.join(" "));
449        }
450    }
451    None
452}
453
454/// (4) Fork bomb (`:(){ :|:& };:` and spacing variants). See [`FORK_BOMB`].
455fn forkbomb(cmd: &str) -> Option<String> {
456    let squeezed: String = cmd.chars().filter(|c| !c.is_whitespace()).collect();
457    if squeezed.contains(FORK_BOMB) {
458        Some(FORK_BOMB.to_string())
459    } else {
460        None
461    }
462}
463
464/// (5) `chmod`/`chown` with `-R`/`--recursive` on a root/home target
465/// (`chmod -R 777 /`, `chown -R me ~`).
466fn recursive_perm_root(cmd: &str) -> Option<String> {
467    for seg in scan_segments(cmd) {
468        let Some(lead) = seg.first() else { continue };
469        if (lead == "chmod" || lead == "chown")
470            && seg.iter().skip(1).any(|w| is_recursive_flag(w))
471            && seg.iter().skip(1).any(|w| is_root_target(w))
472        {
473            return Some(seg.join(" "));
474        }
475    }
476    None
477}
478
479/// True iff `s` (quote-trimmed) is `/dev/` immediately followed by one of
480/// `prefixes`.
481fn is_device_path(s: &str, prefixes: &[&str]) -> bool {
482    let t = s.trim_matches(|c| c == '"' || c == '\'');
483    t.strip_prefix("/dev/")
484        .is_some_and(|rest| prefixes.iter().any(|p| rest.starts_with(p)))
485}
486
487/// Whether a token is a recursive flag: `--recursive`, or a short cluster
488/// (`-r`, `-rf`, `-fr`, `-Rf`) carrying an `r`/`R`.
489fn is_recursive_flag(w: &str) -> bool {
490    if w == "--recursive" {
491        return true;
492    }
493    match w.strip_prefix('-') {
494        Some(rest) if !rest.is_empty() && !rest.starts_with('-') => {
495            rest.chars().any(|c| RM_RECURSIVE.contains(&c))
496        }
497        _ => false,
498    }
499}
500
501/// True iff `s` (quote-trimmed) names a root/home dir — `/`, `~`, `$HOME`,
502/// `${HOME}` — with an optional trailing `/` or `/*` glob. Both fold onto the
503/// root (see [`root_glob`]), so `rm -rf ~/`, `rm -rf ~/*`, and `rm -rf //` are
504/// all as catastrophic as `rm -rf ~` / `/`.
505fn is_root_target(s: &str) -> bool {
506    let raw = s.trim_matches(|c| c == '"' || c == '\'');
507    // A trailing `/` (or several) does not change the target — `~/` is `~`,
508    // `//` is `/` — so collapse it before comparing; keep a bare `/` intact.
509    let trimmed = raw.trim_end_matches('/');
510    let t = if trimmed.is_empty() && raw.starts_with('/') {
511        "/"
512    } else {
513        trimmed
514    };
515    ROOT_TARGETS
516        .iter()
517        .any(|root| t == *root || t == root_glob(root).as_str())
518}
519
520/// The `/*`-glob spelling of a root target: `~` -> `~/*`, but `/` -> `/*` (it
521/// already ends in the separator).
522fn root_glob(root: &str) -> String {
523    if root.ends_with('/') {
524        format!("{root}*")
525    } else {
526        format!("{root}/*")
527    }
528}
529
530/// Split a command into segments on `;`/`&`/`|`/newline, then each segment into
531/// whitespace tokens with surrounding quotes trimmed. Self-contained —
532/// deliberately NOT `segment_mutates`.
533fn scan_segments(cmd: &str) -> Vec<Vec<String>> {
534    cmd.split([';', '&', '|', '\n'])
535        .map(|seg| {
536            seg.split_whitespace()
537                .map(|w| w.trim_matches(|c| c == '"' || c == '\'').to_string())
538                .collect::<Vec<String>>()
539        })
540        .filter(|seg| !seg.is_empty())
541        .collect()
542}
543/// Ordered set of hook implementations run at every hook point.
544///
545/// Adding a hook is just pushing another [`Hooks`] impl: `transform_*` and
546/// `after_tool_call` run for every hook in order, `before_tool_call` and
547/// `should_stop_after_turn` short-circuit on the first hit. An empty set is
548/// the default (every hook method is a no-op), so an agent without extra
549/// hooks typically passes `HooksSet::default()`.
550#[derive(Clone, Default)]
551pub struct HooksSet(Vec<Arc<dyn Hooks>>);
552
553impl HooksSet {
554    pub fn new() -> Self {
555        Self::default()
556    }
557
558    /// A set with a single hook — the common case for one integration
559    /// (e.g. `HooksSet::one(Arc::new(RtkHooks::new(rtk)))`).
560    pub fn one(hook: Arc<dyn Hooks>) -> Self {
561        Self(vec![hook])
562    }
563
564    pub fn push(&mut self, hook: Arc<dyn Hooks>) {
565        self.0.push(hook);
566    }
567
568    pub fn is_empty(&self) -> bool {
569        self.0.is_empty()
570    }
571
572    pub async fn transform_tool_input(&self, call: &mut ToolCall) {
573        for hook in &self.0 {
574            hook.transform_tool_input(call).await;
575        }
576    }
577
578    /// First `Some(reason)` wins: a blocking hook prevents execution entirely
579    /// (and therefore any later `after_tool_call`).
580    pub async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
581        for hook in &self.0 {
582            if let Some(reason) = hook.before_tool_call(call).await {
583                return Some(reason);
584            }
585        }
586        None
587    }
588
589    /// First `Some(reason)` wins: a dropping hook prevents delivery (the request
590    /// is not serviced). `request` may have been rewritten in place by earlier
591    /// hooks — the rewrite is what would be delivered.
592    pub async fn before_inbound(
593        &self,
594        from: Option<&SessionId>,
595        request: &mut Request,
596    ) -> Option<String> {
597        for hook in &self.0 {
598            if let Some(reason) = hook.before_inbound(from, request).await {
599                return Some(reason);
600            }
601        }
602        None
603    }
604
605    pub async fn after_tool_call(&self, call: &ToolCall, out: &mut ToolOutput) {
606        for hook in &self.0 {
607            hook.after_tool_call(call, out).await;
608        }
609    }
610
611    pub async fn transform_context(&self, msgs: &mut Vec<AgentMessage>) {
612        for hook in &self.0 {
613            hook.transform_context(msgs).await;
614        }
615    }
616
617    /// `true` ends the run; evaluated after the tool loop completes.
618    pub async fn should_stop_after_turn(&self, ctx: &[AgentMessage]) -> bool {
619        for hook in &self.0 {
620            if hook.should_stop_after_turn(ctx).await {
621                return true;
622            }
623        }
624        false
625    }
626
627    /// Runs every hook's `after_run` in order, once per run.
628    pub async fn after_run(&self, ctx: &[AgentMessage], stop: StopReason) {
629        for hook in &self.0 {
630            hook.after_run(ctx, stop).await;
631        }
632    }
633}
634
635impl FromIterator<Arc<dyn Hooks>> for HooksSet {
636    fn from_iter<T: IntoIterator<Item = Arc<dyn Hooks>>>(iter: T) -> Self {
637        Self(iter.into_iter().collect())
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[derive(Default)]
646    struct RecordingHooks {
647        transforms: std::sync::atomic::AtomicUsize,
648        outputs: std::sync::atomic::AtomicUsize,
649    }
650
651    #[async_trait::async_trait]
652    impl Hooks for RecordingHooks {
653        async fn transform_tool_input(&self, call: &mut ToolCall) {
654            self.transforms
655                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
656            if let Some(obj) = call.arguments.as_object_mut() {
657                obj.insert("n".into(), serde_json::json!(1));
658            }
659        }
660        async fn after_tool_call(&self, _call: &ToolCall, out: &mut ToolOutput) {
661            self.outputs
662                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
663            out.output.push('!');
664        }
665    }
666
667    struct BlockingHooks;
668    #[async_trait::async_trait]
669    impl Hooks for BlockingHooks {
670        async fn before_tool_call(&self, _call: &ToolCall) -> Option<String> {
671            Some("blocked".into())
672        }
673    }
674
675    #[tokio::test]
676    async fn empty_set_is_noop() {
677        let set = HooksSet::default();
678        let mut call = ToolCall {
679            id: "1".into(),
680            name: "bash".into(),
681            arguments: serde_json::json!({ "command": "ls" }),
682        };
683        set.transform_tool_input(&mut call).await;
684        assert!(set.before_tool_call(&call).await.is_none());
685        set.after_tool_call(&call, &mut ToolOutput::default()).await;
686        assert!(!set.should_stop_after_turn(&[]).await);
687    }
688
689    #[tokio::test]
690    async fn all_hooks_run_and_mutations_compose() {
691        let a = Arc::new(RecordingHooks::default());
692        let b = Arc::new(RecordingHooks::default());
693        let set = HooksSet::from_iter(vec![a.clone() as Arc<dyn Hooks>, b.clone()]);
694
695        let mut call = ToolCall {
696            id: "1".into(),
697            name: "bash".into(),
698            arguments: serde_json::json!({ "command": "ls" }),
699        };
700        set.transform_tool_input(&mut call).await;
701        assert_eq!(call.arguments["n"], 1, "composition kept the mutation");
702
703        let mut out = ToolOutput {
704            output: "x".into(),
705            ..ToolOutput::default()
706        };
707        set.after_tool_call(&call, &mut out).await;
708        assert_eq!(out.output, "x!!", "both hooks patched the output");
709        assert_eq!(a.transforms.load(std::sync::atomic::Ordering::SeqCst), 1);
710        assert_eq!(b.transforms.load(std::sync::atomic::Ordering::SeqCst), 1);
711    }
712
713    #[tokio::test]
714    async fn first_blocking_hook_wins() {
715        let set = HooksSet::from_iter(vec![
716            Arc::new(BlockingHooks) as Arc<dyn Hooks>,
717            Arc::new(RecordingHooks::default()),
718        ]);
719        let call = ToolCall {
720            id: "1".into(),
721            name: "bash".into(),
722            arguments: serde_json::json!({ "command": "ls" }),
723        };
724        assert_eq!(
725            set.before_tool_call(&call).await.as_deref(),
726            Some("blocked")
727        );
728    }
729
730    struct GatingHooks;
731
732    #[async_trait::async_trait]
733    impl Hooks for GatingHooks {
734        async fn before_inbound(
735            &self,
736            _from: Option<&SessionId>,
737            request: &mut Request,
738        ) -> Option<String> {
739            let Request::Notify { content } = request else {
740                return None;
741            };
742            if content == "drop me" {
743                return Some("not allowed".into());
744            }
745            content.push_str(" (seen)");
746            None
747        }
748    }
749
750    #[tokio::test]
751    async fn before_inbound_gates_and_rewrites() {
752        let set = HooksSet::one(Arc::new(GatingHooks));
753
754        let mut kept = Request::Notify {
755            content: "hello".into(),
756        };
757        assert!(set.before_inbound(None, &mut kept).await.is_none());
758        assert_eq!(
759            kept,
760            Request::Notify {
761                content: "hello (seen)".into()
762            }
763        );
764
765        let mut dropped = Request::Notify {
766            content: "drop me".into(),
767        };
768        assert_eq!(
769            set.before_inbound(None, &mut dropped).await.as_deref(),
770            Some("not allowed")
771        );
772
773        // A local command is never gated by the inbound policy.
774        let mut plain = Request::Cancel;
775        assert!(set.before_inbound(None, &mut plain).await.is_none());
776    }
777
778    #[derive(Default)]
779    struct RunLog {
780        calls: std::sync::Mutex<Vec<usize>>,
781    }
782
783    #[async_trait::async_trait]
784    impl Hooks for RunLog {
785        async fn after_run(&self, ctx: &[AgentMessage], _stop: StopReason) {
786            self.calls.lock().unwrap().push(ctx.len());
787        }
788    }
789
790    #[tokio::test]
791    async fn after_run_runs_every_hook() {
792        let a = Arc::new(RunLog::default());
793        let b = Arc::new(RunLog::default());
794        let set = HooksSet::from_iter(vec![a.clone() as Arc<dyn Hooks>, b.clone()]);
795
796        let ctx = vec![AgentMessage::user_text("hi")];
797        set.after_run(&ctx, StopReason::Stop).await;
798
799        assert_eq!(*a.calls.lock().unwrap(), vec![1]);
800        assert_eq!(*b.calls.lock().unwrap(), vec![1]);
801    }
802
803    fn tool_call(name: &str) -> ToolCall {
804        ToolCall {
805            id: "1".into(),
806            name: name.into(),
807            arguments: serde_json::json!({}),
808        }
809    }
810
811    fn bash_call(cmd: &str) -> ToolCall {
812        ToolCall {
813            id: "1".into(),
814            name: "bash".into(),
815            arguments: serde_json::json!({ "command": cmd }),
816        }
817    }
818
819    #[tokio::test]
820    async fn read_only_blocks_every_mutating_tool_and_bg() {
821        let hooks = ReadOnlyHooks::new();
822        for name in MUTATING_TOOLS.iter().copied().chain(std::iter::once("bg")) {
823            let reason = hooks.before_tool_call(&tool_call(name)).await;
824            assert!(
825                reason.is_some_and(|r| r.starts_with("read-only worker: ")),
826                "{name}"
827            );
828        }
829    }
830
831    #[tokio::test]
832    async fn read_only_blocks_mutating_bash() {
833        let hooks = ReadOnlyHooks::new();
834        for cmd in [
835            "rm -rf build",
836            "mv a b",
837            "echo hi > f",
838            "git commit -m x",
839            "cargo fmt",
840        ] {
841            assert!(
842                hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
843                "{cmd}"
844            );
845        }
846    }
847
848    #[tokio::test]
849    async fn read_only_allows_read_tools_todo_message_and_benign_bash() {
850        let hooks = ReadOnlyHooks::new();
851        for name in [
852            "read",
853            "grep",
854            "find",
855            "ast_search",
856            "session_search",
857            "webfetch",
858            "todo",
859            "message",
860            "peers",
861            "task",
862        ] {
863            assert!(
864                hooks.before_tool_call(&tool_call(name)).await.is_none(),
865                "{name}"
866            );
867        }
868        for cmd in ["ls -la", "git status", "cat x", "cargo build"] {
869            assert!(
870                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
871                "{cmd}"
872            );
873        }
874    }
875    // The gate is plan-mode-INDEPENDENT: no handle/flag is set up; a bare
876    // `BashRiskHooks::new()` drives it (`bash_call` (ucUhO) / `tool_call`
877    // (JHwEE) are the helpers above).
878
879    #[tokio::test]
880    async fn bash_risk_blocks_catastrophic() {
881        let hooks = BashRiskHooks::new();
882        for cmd in [
883            // (1) device writes — dd only for its `of=`, plus `>`/`>>` redirects
884            "dd if=/dev/zero of=/dev/sda",
885            "echo x > /dev/sda",
886            "echo x >>/dev/nvme0n1",
887            // (2) mkfs / wipefs / shred on a device
888            "mkfs.ext4 /dev/sdb",
889            "wipefs -a /dev/sdb",
890            "shred -n1 /dev/sda",
891            // (3) recursive root/home delete, incl. the `/*` fold
892            "rm -rf /",
893            "rm -fr ~",
894            "rm -R /*",
895            "rm -rf ~/*",
896            "rm -rf $HOME/*",
897            "rm -rf ${HOME}/*",
898            // (4) fork bomb (spacing variants)
899            ":(){ :|:& };:",
900            ":(){:|:&};:",
901            // (5) recursive perm/owner of root, incl. the fold
902            "chmod -R 777 /",
903            "chmod -R 777 /*",
904            "chown --recursive me ~",
905            // (3)/(5) a trailing slash folds onto its root too
906            "rm -rf ~/",
907            "rm -rf $HOME/",
908            "rm -rf ${HOME}/",
909            "rm -rf /*/",
910            "chmod -R 777 //",
911        ] {
912            assert!(
913                hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
914                "`{cmd}` should be blocked"
915            );
916        }
917    }
918
919    #[tokio::test]
920    async fn bash_risk_allows_benign() {
921        let hooks = BashRiskHooks::new();
922        for cmd in [
923            "rm -rf build",
924            "rm -rf /tmp/x",
925            "rm -r build",
926            "rm -f /", // non-recursive delete of / is not this gate's target
927            "dd if=/dev/zero of=/tmp/f bs=1M count=1",
928            "dd if=/dev/sda of=/tmp/img", // read a device into a file: allowed
929            "shred build/x",
930            "chmod 777 /", // non-recursive perm change: allowed
931            "ls -la",
932            "cargo build",
933        ] {
934            assert!(
935                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
936                "`{cmd}` should be allowed"
937            );
938        }
939    }
940
941    #[tokio::test]
942    async fn sleep_poll_blocks_bare_sleep() {
943        let hooks = SleepRefusalHooks::new();
944        for cmd in ["sleep 5", "sleep 30", "sleep 5 && echo done"] {
945            let reason = hooks.before_tool_call(&bash_call(cmd)).await;
946            assert!(reason.is_some(), "`{cmd}` should be blocked");
947            let r = reason.unwrap();
948            // The loop prefixes `blocked: `, so the reason itself must NOT.
949            assert!(
950                r.starts_with("sleep") && !r.starts_with("refused: "),
951                "`{cmd}` reason contract: {r}"
952            );
953        }
954    }
955
956    #[tokio::test]
957    async fn sleep_poll_allows_benign_and_non_bash() {
958        let hooks = SleepRefusalHooks::new();
959        for cmd in ["ls", "cargo build", "echo hi", "echo sleep"] {
960            assert!(
961                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
962                "`{cmd}` should be allowed"
963            );
964        }
965        for name in ["read", "edit", "task"] {
966            assert!(
967                hooks.before_tool_call(&tool_call(name)).await.is_none(),
968                "{name}"
969            );
970        }
971    }
972
973    #[tokio::test]
974    async fn bash_risk_ignores_non_bash_tools() {
975        let hooks = BashRiskHooks::new();
976        assert!(hooks.before_tool_call(&tool_call("read")).await.is_none());
977        assert!(hooks.before_tool_call(&tool_call("edit")).await.is_none());
978    }
979
980    #[tokio::test]
981    async fn bash_risk_is_independent_of_plan_mode() {
982        // The risk gate never consults the plan flag: it fires whether or not a
983        // `PlanModeHooks` is present / on. Pins the ordering claim in the wiring.
984        let hooks = BashRiskHooks::new();
985        assert!(hooks.before_tool_call(&bash_call("rm -rf /")).await.is_some());
986
987        // In a set beside an OFF plan hook, the risk gate still blocks.
988        let off = HooksSet::from_iter([
989            Arc::new(BashRiskHooks::new()) as Arc<dyn Hooks>,
990            Arc::new(PlanModeHooks::new(PlanModeHandle::new())),
991        ]);
992        assert!(off.before_tool_call(&bash_call("rm -rf /")).await.is_some());
993
994        // Beside an ON plan hook (which would ALSO block `rm -rf /`, with its own
995        // "plan mode: …" reason), the risk gate is FIRST in the set, so ITS reason
996        // wins — pins the ratified ordering.
997        let plan = PlanModeHandle::new();
998        plan.set(true);
999        let on = HooksSet::from_iter([
1000            Arc::new(BashRiskHooks::new()) as Arc<dyn Hooks>,
1001            Arc::new(PlanModeHooks::new(plan)),
1002        ]);
1003        let reason = on.before_tool_call(&bash_call("rm -rf /")).await;
1004        assert!(
1005            reason
1006                .as_deref()
1007                .is_some_and(|r| r.starts_with("catastrophic shell command")),
1008            "the risk gate's reason must win over plan mode's, got {reason:?}"
1009        );
1010    }
1011
1012    #[tokio::test]
1013    async fn plan_mode_blocks_every_mutating_tool() {
1014        let plan = PlanModeHandle::new();
1015        plan.set(true);
1016        let hooks = PlanModeHooks::new(plan);
1017        for name in MUTATING_TOOLS {
1018            let reason = hooks.before_tool_call(&tool_call(name)).await;
1019            assert!(reason.is_some(), "{name} must be blocked in plan mode");
1020        }
1021    }
1022
1023    #[tokio::test]
1024    async fn plan_mode_allows_read_tools_and_todo() {
1025        let plan = PlanModeHandle::new();
1026        plan.set(true);
1027        let hooks = PlanModeHooks::new(plan);
1028        for name in [
1029            "read",
1030            "grep",
1031            "find",
1032            "ast_search",
1033            "session_search",
1034            "todo",
1035            "task",
1036        ] {
1037            let reason = hooks.before_tool_call(&tool_call(name)).await;
1038            assert!(reason.is_none(), "{name} must be allowed in plan mode");
1039        }
1040    }
1041
1042    #[tokio::test]
1043    async fn plan_mode_off_blocks_nothing() {
1044        let hooks = PlanModeHooks::new(PlanModeHandle::new());
1045        assert!(hooks.before_tool_call(&tool_call("edit")).await.is_none());
1046        assert!(hooks.before_tool_call(&bash_call("rm -rf x")).await.is_none());
1047    }
1048
1049    #[tokio::test]
1050    async fn bash_gate_blocks_mutations_and_allows_reads() {
1051        let plan = PlanModeHandle::new();
1052        plan.set(true);
1053        let hooks = PlanModeHooks::new(plan);
1054        for cmd in [
1055            "rm -rf build",
1056            "mv a b",
1057            "cp a b",
1058            "mkdir d",
1059            "sed -i s/x/y/ f",
1060            "tee f",
1061            "echo hi > f",
1062            "echo hi >> f",
1063            "cat f | tee g",
1064            "dd if=/dev/zero of=f",
1065            "git add .",
1066            "git commit -m x",
1067            "git checkout -- .",
1068            "git push",
1069            "git stash",
1070            "cargo fmt",
1071            "cargo fix",
1072            "cargo add serde",
1073            "npm install",
1074            "pnpm ci",
1075            "yarn add x",
1076            "go mod tidy",
1077            "go get x",
1078            "pip install foo",
1079            "chmod +x f",
1080            "patch < p.diff",
1081        ] {
1082            assert!(
1083                hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
1084                "`{cmd}` should be blocked"
1085            );
1086        }
1087        for cmd in [
1088            "ls -la",
1089            "cat x",
1090            "git status",
1091            "git log --oneline",
1092            "rg foo",
1093            "cargo build",
1094            "cargo test",
1095            "cargo check",
1096            "cargo run",
1097            "make",
1098            "tsc --noEmit",
1099            // A non-mutating sed (no `-i`) and a `>`-free command.
1100            "sed 's/x/y/' f",
1101        ] {
1102            assert!(
1103                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
1104                "`{cmd}` should be allowed"
1105            );
1106        }
1107    }
1108}