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", "replace", "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/// `/dev/` device-name prefixes. A write *target* — `dd of=<dev>`, a `>`/`>>`
297/// redirect into a device, or an `mkfs*`/`wipefs`/`shred` operand — whose
298/// basename starts with one of these is catastrophic. Deliberately blunt:
299/// `/dev/sdafoo` would also match. Acceptable: an FP merely yields a reason;
300/// the guardrail never silently allows a real device.
301const DEVICE_PREFIXES: &[&str] = &["sd", "hd", "nvme", "vd", "disk", "mmcblk"];
302
303/// Targets that make a RECURSIVE delete/perm change catastrophic. Compared
304/// quote-trimmed, with a trailing `/*` glob folding onto its root (`~/*` -> `~`,
305/// `/*` -> `/`).
306const ROOT_TARGETS: &[&str] = &["/", "~", "$HOME", "${HOME}"];
307
308/// `rm` letters that mark a delete recursive (`-r`, `-rf`, `-fr`, `-R`).
309const RM_RECURSIVE: &[char] = &['r', 'R'];
310
311/// Canonical fork bomb, whitespace-free. [`forkbomb`] strips ALL ASCII
312/// whitespace and tests `contains` — covers `:(){ :|:& };:`, `:(){:|:&};:`, and a
313/// bomb hidden after a `;`/`&&` separator.
314const FORK_BOMB: &str = ":(){:|:&};:";
315
316/// Map a `bash` command to a refusal reason, or `None` to allow. Tries each
317/// predicate in turn; the first hit supplies a verbatim `label`.
318///
319/// The reason must NOT start with `refused: ` — the loop already prefixes
320/// `blocked: ` (loop_.rs:STqt9), and the `({label})` + "run it outside wcode"
321/// shape mirrors plan mode's tone.
322fn bash_risk_reason(cmd: &str) -> Option<String> {
323    let label = device_write(cmd)
324        .or_else(|| mkfs_or_wipe(cmd))
325        .or_else(|| recursive_root_delete(cmd))
326        .or_else(|| forkbomb(cmd))
327        .or_else(|| recursive_perm_root(cmd))?;
328    Some(format!(
329        "catastrophic shell command (`{label}`) — this would destroy the system; \
330         run it outside wcode if you really mean it"
331    ))
332}
333
334/// (1) Device write: `dd … of=/dev/<dev>` or a `>`/`>>` redirect into
335/// `/dev/<dev>`. Returns the offending segment as the label, or `None`.
336///
337/// Only `of=` counts for `dd` — `dd if=/dev/sda of=/tmp/img` (read a device into
338/// a file) stays allowed.
339fn device_write(cmd: &str) -> Option<String> {
340    for seg in scan_segments(cmd) {
341        let lead = seg.first().map(String::as_str).unwrap_or("");
342        let dd_write = lead == "dd"
343            && seg.iter().skip(1).any(|w| {
344                w.strip_prefix("of=")
345                    .is_some_and(|dev| is_device_path(dev, DEVICE_PREFIXES))
346            });
347        let redirect = seg.iter().enumerate().any(|(i, w)| {
348            let target = if let Some(t) = redirect_target(w) {
349                Some(t)
350            } else if matches!(w.as_str(), ">" | ">>") {
351                seg.get(i + 1).map(String::as_str)
352            } else {
353                None
354            };
355            target.is_some_and(|t| is_device_path(t, DEVICE_PREFIXES))
356        });
357        if dd_write || redirect {
358            return Some(seg.join(" "));
359        }
360    }
361    None
362}
363
364/// The redirect target baked into a single token, if any: `>/dev/sda`,
365/// `>>/dev/sda`, `2>/dev/sda`, … A bare `>`/`>>` (target in the *next* token)
366/// yields `None` — the caller handles that case.
367fn redirect_target(w: &str) -> Option<&str> {
368    let rest = w.trim_start_matches(|c: char| c.is_ascii_digit());
369    let after = rest.strip_prefix(">>").or_else(|| rest.strip_prefix('>'))?;
370    (!after.is_empty()).then_some(after)
371}
372
373/// (2) `mkfs` / `mkfs.<fs>` / `wipefs` / `shred` whose operand is a device path.
374/// `shred -n1 /dev/sda` blocks; `shred build/x` is allowed.
375fn mkfs_or_wipe(cmd: &str) -> Option<String> {
376    for seg in scan_segments(cmd) {
377        let Some(lead) = seg.first() else { continue };
378        let formats = lead == "mkfs" || lead.starts_with("mkfs.");
379        let wipes = lead == "wipefs" || lead == "shred";
380        if (formats || wipes) && seg.iter().skip(1).any(|w| is_device_path(w, DEVICE_PREFIXES)) {
381            return Some(seg.join(" "));
382        }
383    }
384    None
385}
386
387/// (3) `rm` with a recursive flag AND a root/home target (`rm -rf /`, `rm -fr ~`,
388/// `rm -R /*`, `rm -rf $HOME/*`).
389fn recursive_root_delete(cmd: &str) -> Option<String> {
390    for seg in scan_segments(cmd) {
391        let Some(lead) = seg.first() else { continue };
392        if lead == "rm"
393            && seg.iter().skip(1).any(|w| is_recursive_flag(w))
394            && seg.iter().skip(1).any(|w| is_root_target(w))
395        {
396            return Some(seg.join(" "));
397        }
398    }
399    None
400}
401
402/// (4) Fork bomb (`:(){ :|:& };:` and spacing variants). See [`FORK_BOMB`].
403fn forkbomb(cmd: &str) -> Option<String> {
404    let squeezed: String = cmd.chars().filter(|c| !c.is_whitespace()).collect();
405    if squeezed.contains(FORK_BOMB) {
406        Some(FORK_BOMB.to_string())
407    } else {
408        None
409    }
410}
411
412/// (5) `chmod`/`chown` with `-R`/`--recursive` on a root/home target
413/// (`chmod -R 777 /`, `chown -R me ~`).
414fn recursive_perm_root(cmd: &str) -> Option<String> {
415    for seg in scan_segments(cmd) {
416        let Some(lead) = seg.first() else { continue };
417        if (lead == "chmod" || lead == "chown")
418            && seg.iter().skip(1).any(|w| is_recursive_flag(w))
419            && seg.iter().skip(1).any(|w| is_root_target(w))
420        {
421            return Some(seg.join(" "));
422        }
423    }
424    None
425}
426
427/// True iff `s` (quote-trimmed) is `/dev/` immediately followed by one of
428/// `prefixes`.
429fn is_device_path(s: &str, prefixes: &[&str]) -> bool {
430    let t = s.trim_matches(|c| c == '"' || c == '\'');
431    t.strip_prefix("/dev/")
432        .is_some_and(|rest| prefixes.iter().any(|p| rest.starts_with(p)))
433}
434
435/// Whether a token is a recursive flag: `--recursive`, or a short cluster
436/// (`-r`, `-rf`, `-fr`, `-Rf`) carrying an `r`/`R`.
437fn is_recursive_flag(w: &str) -> bool {
438    if w == "--recursive" {
439        return true;
440    }
441    match w.strip_prefix('-') {
442        Some(rest) if !rest.is_empty() && !rest.starts_with('-') => {
443            rest.chars().any(|c| RM_RECURSIVE.contains(&c))
444        }
445        _ => false,
446    }
447}
448
449/// True iff `s` (quote-trimmed) names a root/home dir — `/`, `~`, `$HOME`,
450/// `${HOME}` — with an optional trailing `/` or `/*` glob. Both fold onto the
451/// root (see [`root_glob`]), so `rm -rf ~/`, `rm -rf ~/*`, and `rm -rf //` are
452/// all as catastrophic as `rm -rf ~` / `/`.
453fn is_root_target(s: &str) -> bool {
454    let raw = s.trim_matches(|c| c == '"' || c == '\'');
455    // A trailing `/` (or several) does not change the target — `~/` is `~`,
456    // `//` is `/` — so collapse it before comparing; keep a bare `/` intact.
457    let trimmed = raw.trim_end_matches('/');
458    let t = if trimmed.is_empty() && raw.starts_with('/') {
459        "/"
460    } else {
461        trimmed
462    };
463    ROOT_TARGETS
464        .iter()
465        .any(|root| t == *root || t == root_glob(root).as_str())
466}
467
468/// The `/*`-glob spelling of a root target: `~` -> `~/*`, but `/` -> `/*` (it
469/// already ends in the separator).
470fn root_glob(root: &str) -> String {
471    if root.ends_with('/') {
472        format!("{root}*")
473    } else {
474        format!("{root}/*")
475    }
476}
477
478/// Split a command into segments on `;`/`&`/`|`/newline, then each segment into
479/// whitespace tokens with surrounding quotes trimmed. Self-contained —
480/// deliberately NOT `segment_mutates`.
481fn scan_segments(cmd: &str) -> Vec<Vec<String>> {
482    cmd.split([';', '&', '|', '\n'])
483        .map(|seg| {
484            seg.split_whitespace()
485                .map(|w| w.trim_matches(|c| c == '"' || c == '\'').to_string())
486                .collect::<Vec<String>>()
487        })
488        .filter(|seg| !seg.is_empty())
489        .collect()
490}
491/// Ordered set of hook implementations run at every hook point.
492///
493/// Adding a hook is just pushing another [`Hooks`] impl: `transform_*` and
494/// `after_tool_call` run for every hook in order, `before_tool_call` and
495/// `should_stop_after_turn` short-circuit on the first hit. An empty set is
496/// the default (every hook method is a no-op), so an agent without extra
497/// hooks typically passes `HooksSet::default()`.
498#[derive(Clone, Default)]
499pub struct HooksSet(Vec<Arc<dyn Hooks>>);
500
501impl HooksSet {
502    pub fn new() -> Self {
503        Self::default()
504    }
505
506    /// A set with a single hook — the common case for one integration
507    /// (e.g. `HooksSet::one(Arc::new(RtkHooks::new(rtk)))`).
508    pub fn one(hook: Arc<dyn Hooks>) -> Self {
509        Self(vec![hook])
510    }
511
512    pub fn push(&mut self, hook: Arc<dyn Hooks>) {
513        self.0.push(hook);
514    }
515
516    pub fn is_empty(&self) -> bool {
517        self.0.is_empty()
518    }
519
520    pub async fn transform_tool_input(&self, call: &mut ToolCall) {
521        for hook in &self.0 {
522            hook.transform_tool_input(call).await;
523        }
524    }
525
526    /// First `Some(reason)` wins: a blocking hook prevents execution entirely
527    /// (and therefore any later `after_tool_call`).
528    pub async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
529        for hook in &self.0 {
530            if let Some(reason) = hook.before_tool_call(call).await {
531                return Some(reason);
532            }
533        }
534        None
535    }
536
537    /// First `Some(reason)` wins: a dropping hook prevents delivery (the request
538    /// is not serviced). `request` may have been rewritten in place by earlier
539    /// hooks — the rewrite is what would be delivered.
540    pub async fn before_inbound(
541        &self,
542        from: Option<&SessionId>,
543        request: &mut Request,
544    ) -> Option<String> {
545        for hook in &self.0 {
546            if let Some(reason) = hook.before_inbound(from, request).await {
547                return Some(reason);
548            }
549        }
550        None
551    }
552
553    pub async fn after_tool_call(&self, call: &ToolCall, out: &mut ToolOutput) {
554        for hook in &self.0 {
555            hook.after_tool_call(call, out).await;
556        }
557    }
558
559    pub async fn transform_context(&self, msgs: &mut Vec<AgentMessage>) {
560        for hook in &self.0 {
561            hook.transform_context(msgs).await;
562        }
563    }
564
565    /// `true` ends the run; evaluated after the tool loop completes.
566    pub async fn should_stop_after_turn(&self, ctx: &[AgentMessage]) -> bool {
567        for hook in &self.0 {
568            if hook.should_stop_after_turn(ctx).await {
569                return true;
570            }
571        }
572        false
573    }
574
575    /// Runs every hook's `after_run` in order, once per run.
576    pub async fn after_run(&self, ctx: &[AgentMessage], stop: StopReason) {
577        for hook in &self.0 {
578            hook.after_run(ctx, stop).await;
579        }
580    }
581}
582
583impl FromIterator<Arc<dyn Hooks>> for HooksSet {
584    fn from_iter<T: IntoIterator<Item = Arc<dyn Hooks>>>(iter: T) -> Self {
585        Self(iter.into_iter().collect())
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[derive(Default)]
594    struct RecordingHooks {
595        transforms: std::sync::atomic::AtomicUsize,
596        outputs: std::sync::atomic::AtomicUsize,
597    }
598
599    #[async_trait::async_trait]
600    impl Hooks for RecordingHooks {
601        async fn transform_tool_input(&self, call: &mut ToolCall) {
602            self.transforms
603                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
604            if let Some(obj) = call.arguments.as_object_mut() {
605                obj.insert("n".into(), serde_json::json!(1));
606            }
607        }
608        async fn after_tool_call(&self, _call: &ToolCall, out: &mut ToolOutput) {
609            self.outputs
610                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
611            out.output.push('!');
612        }
613    }
614
615    struct BlockingHooks;
616    #[async_trait::async_trait]
617    impl Hooks for BlockingHooks {
618        async fn before_tool_call(&self, _call: &ToolCall) -> Option<String> {
619            Some("blocked".into())
620        }
621    }
622
623    #[tokio::test]
624    async fn empty_set_is_noop() {
625        let set = HooksSet::default();
626        let mut call = ToolCall {
627            id: "1".into(),
628            name: "bash".into(),
629            arguments: serde_json::json!({ "command": "ls" }),
630        };
631        set.transform_tool_input(&mut call).await;
632        assert!(set.before_tool_call(&call).await.is_none());
633        set.after_tool_call(&call, &mut ToolOutput::default()).await;
634        assert!(!set.should_stop_after_turn(&[]).await);
635    }
636
637    #[tokio::test]
638    async fn all_hooks_run_and_mutations_compose() {
639        let a = Arc::new(RecordingHooks::default());
640        let b = Arc::new(RecordingHooks::default());
641        let set = HooksSet::from_iter(vec![a.clone() as Arc<dyn Hooks>, b.clone()]);
642
643        let mut call = ToolCall {
644            id: "1".into(),
645            name: "bash".into(),
646            arguments: serde_json::json!({ "command": "ls" }),
647        };
648        set.transform_tool_input(&mut call).await;
649        assert_eq!(call.arguments["n"], 1, "composition kept the mutation");
650
651        let mut out = ToolOutput {
652            output: "x".into(),
653            ..ToolOutput::default()
654        };
655        set.after_tool_call(&call, &mut out).await;
656        assert_eq!(out.output, "x!!", "both hooks patched the output");
657        assert_eq!(a.transforms.load(std::sync::atomic::Ordering::SeqCst), 1);
658        assert_eq!(b.transforms.load(std::sync::atomic::Ordering::SeqCst), 1);
659    }
660
661    #[tokio::test]
662    async fn first_blocking_hook_wins() {
663        let set = HooksSet::from_iter(vec![
664            Arc::new(BlockingHooks) as Arc<dyn Hooks>,
665            Arc::new(RecordingHooks::default()),
666        ]);
667        let call = ToolCall {
668            id: "1".into(),
669            name: "bash".into(),
670            arguments: serde_json::json!({ "command": "ls" }),
671        };
672        assert_eq!(
673            set.before_tool_call(&call).await.as_deref(),
674            Some("blocked")
675        );
676    }
677
678    struct GatingHooks;
679
680    #[async_trait::async_trait]
681    impl Hooks for GatingHooks {
682        async fn before_inbound(
683            &self,
684            _from: Option<&SessionId>,
685            request: &mut Request,
686        ) -> Option<String> {
687            let Request::Notify { content } = request else {
688                return None;
689            };
690            if content == "drop me" {
691                return Some("not allowed".into());
692            }
693            content.push_str(" (seen)");
694            None
695        }
696    }
697
698    #[tokio::test]
699    async fn before_inbound_gates_and_rewrites() {
700        let set = HooksSet::one(Arc::new(GatingHooks));
701
702        let mut kept = Request::Notify {
703            content: "hello".into(),
704        };
705        assert!(set.before_inbound(None, &mut kept).await.is_none());
706        assert_eq!(
707            kept,
708            Request::Notify {
709                content: "hello (seen)".into()
710            }
711        );
712
713        let mut dropped = Request::Notify {
714            content: "drop me".into(),
715        };
716        assert_eq!(
717            set.before_inbound(None, &mut dropped).await.as_deref(),
718            Some("not allowed")
719        );
720
721        // A local command is never gated by the inbound policy.
722        let mut plain = Request::Cancel;
723        assert!(set.before_inbound(None, &mut plain).await.is_none());
724    }
725
726    #[derive(Default)]
727    struct RunLog {
728        calls: std::sync::Mutex<Vec<usize>>,
729    }
730
731    #[async_trait::async_trait]
732    impl Hooks for RunLog {
733        async fn after_run(&self, ctx: &[AgentMessage], _stop: StopReason) {
734            self.calls.lock().unwrap().push(ctx.len());
735        }
736    }
737
738    #[tokio::test]
739    async fn after_run_runs_every_hook() {
740        let a = Arc::new(RunLog::default());
741        let b = Arc::new(RunLog::default());
742        let set = HooksSet::from_iter(vec![a.clone() as Arc<dyn Hooks>, b.clone()]);
743
744        let ctx = vec![AgentMessage::user_text("hi")];
745        set.after_run(&ctx, StopReason::Stop).await;
746
747        assert_eq!(*a.calls.lock().unwrap(), vec![1]);
748        assert_eq!(*b.calls.lock().unwrap(), vec![1]);
749    }
750
751    fn tool_call(name: &str) -> ToolCall {
752        ToolCall {
753            id: "1".into(),
754            name: name.into(),
755            arguments: serde_json::json!({}),
756        }
757    }
758
759    fn bash_call(cmd: &str) -> ToolCall {
760        ToolCall {
761            id: "1".into(),
762            name: "bash".into(),
763            arguments: serde_json::json!({ "command": cmd }),
764        }
765    }
766
767    #[tokio::test]
768    async fn read_only_blocks_every_mutating_tool_and_bg() {
769        let hooks = ReadOnlyHooks::new();
770        for name in MUTATING_TOOLS.iter().copied().chain(std::iter::once("bg")) {
771            let reason = hooks.before_tool_call(&tool_call(name)).await;
772            assert!(
773                reason.is_some_and(|r| r.starts_with("read-only worker: ")),
774                "{name}"
775            );
776        }
777    }
778
779    #[tokio::test]
780    async fn read_only_blocks_mutating_bash() {
781        let hooks = ReadOnlyHooks::new();
782        for cmd in [
783            "rm -rf build",
784            "mv a b",
785            "echo hi > f",
786            "git commit -m x",
787            "cargo fmt",
788        ] {
789            assert!(
790                hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
791                "{cmd}"
792            );
793        }
794    }
795
796    #[tokio::test]
797    async fn read_only_allows_read_tools_todo_message_and_benign_bash() {
798        let hooks = ReadOnlyHooks::new();
799        for name in [
800            "read",
801            "grep",
802            "find",
803            "ast_search",
804            "session_search",
805            "webfetch",
806            "todo",
807            "message",
808            "peers",
809            "task",
810        ] {
811            assert!(
812                hooks.before_tool_call(&tool_call(name)).await.is_none(),
813                "{name}"
814            );
815        }
816        for cmd in ["ls -la", "git status", "cat x", "cargo build"] {
817            assert!(
818                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
819                "{cmd}"
820            );
821        }
822    }
823    // The gate is plan-mode-INDEPENDENT: no handle/flag is set up; a bare
824    // `BashRiskHooks::new()` drives it (`bash_call` (ucUhO) / `tool_call`
825    // (JHwEE) are the helpers above).
826
827    #[tokio::test]
828    async fn bash_risk_blocks_catastrophic() {
829        let hooks = BashRiskHooks::new();
830        for cmd in [
831            // (1) device writes — dd only for its `of=`, plus `>`/`>>` redirects
832            "dd if=/dev/zero of=/dev/sda",
833            "echo x > /dev/sda",
834            "echo x >>/dev/nvme0n1",
835            // (2) mkfs / wipefs / shred on a device
836            "mkfs.ext4 /dev/sdb",
837            "wipefs -a /dev/sdb",
838            "shred -n1 /dev/sda",
839            // (3) recursive root/home delete, incl. the `/*` fold
840            "rm -rf /",
841            "rm -fr ~",
842            "rm -R /*",
843            "rm -rf ~/*",
844            "rm -rf $HOME/*",
845            "rm -rf ${HOME}/*",
846            // (4) fork bomb (spacing variants)
847            ":(){ :|:& };:",
848            ":(){:|:&};:",
849            // (5) recursive perm/owner of root, incl. the fold
850            "chmod -R 777 /",
851            "chmod -R 777 /*",
852            "chown --recursive me ~",
853            // (3)/(5) a trailing slash folds onto its root too
854            "rm -rf ~/",
855            "rm -rf $HOME/",
856            "rm -rf ${HOME}/",
857            "rm -rf /*/",
858            "chmod -R 777 //",
859        ] {
860            assert!(
861                hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
862                "`{cmd}` should be blocked"
863            );
864        }
865    }
866
867    #[tokio::test]
868    async fn bash_risk_allows_benign() {
869        let hooks = BashRiskHooks::new();
870        for cmd in [
871            "rm -rf build",
872            "rm -rf /tmp/x",
873            "rm -r build",
874            "rm -f /", // non-recursive delete of / is not this gate's target
875            "dd if=/dev/zero of=/tmp/f bs=1M count=1",
876            "dd if=/dev/sda of=/tmp/img", // read a device into a file: allowed
877            "shred build/x",
878            "chmod 777 /", // non-recursive perm change: allowed
879            "ls -la",
880            "cargo build",
881        ] {
882            assert!(
883                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
884                "`{cmd}` should be allowed"
885            );
886        }
887    }
888
889    #[tokio::test]
890    async fn bash_risk_ignores_non_bash_tools() {
891        let hooks = BashRiskHooks::new();
892        assert!(hooks.before_tool_call(&tool_call("read")).await.is_none());
893        assert!(hooks.before_tool_call(&tool_call("edit")).await.is_none());
894    }
895
896    #[tokio::test]
897    async fn bash_risk_is_independent_of_plan_mode() {
898        // The risk gate never consults the plan flag: it fires whether or not a
899        // `PlanModeHooks` is present / on. Pins the ordering claim in the wiring.
900        let hooks = BashRiskHooks::new();
901        assert!(hooks.before_tool_call(&bash_call("rm -rf /")).await.is_some());
902
903        // In a set beside an OFF plan hook, the risk gate still blocks.
904        let off = HooksSet::from_iter([
905            Arc::new(BashRiskHooks::new()) as Arc<dyn Hooks>,
906            Arc::new(PlanModeHooks::new(PlanModeHandle::new())),
907        ]);
908        assert!(off.before_tool_call(&bash_call("rm -rf /")).await.is_some());
909
910        // Beside an ON plan hook (which would ALSO block `rm -rf /`, with its own
911        // "plan mode: …" reason), the risk gate is FIRST in the set, so ITS reason
912        // wins — pins the ratified ordering.
913        let plan = PlanModeHandle::new();
914        plan.set(true);
915        let on = HooksSet::from_iter([
916            Arc::new(BashRiskHooks::new()) as Arc<dyn Hooks>,
917            Arc::new(PlanModeHooks::new(plan)),
918        ]);
919        let reason = on.before_tool_call(&bash_call("rm -rf /")).await;
920        assert!(
921            reason
922                .as_deref()
923                .is_some_and(|r| r.starts_with("catastrophic shell command")),
924            "the risk gate's reason must win over plan mode's, got {reason:?}"
925        );
926    }
927
928    #[tokio::test]
929    async fn plan_mode_blocks_every_mutating_tool() {
930        let plan = PlanModeHandle::new();
931        plan.set(true);
932        let hooks = PlanModeHooks::new(plan);
933        for name in MUTATING_TOOLS {
934            let reason = hooks.before_tool_call(&tool_call(name)).await;
935            assert!(reason.is_some(), "{name} must be blocked in plan mode");
936        }
937    }
938
939    #[tokio::test]
940    async fn plan_mode_allows_read_tools_and_todo() {
941        let plan = PlanModeHandle::new();
942        plan.set(true);
943        let hooks = PlanModeHooks::new(plan);
944        for name in [
945            "read",
946            "grep",
947            "find",
948            "ast_search",
949            "session_search",
950            "todo",
951            "task",
952        ] {
953            let reason = hooks.before_tool_call(&tool_call(name)).await;
954            assert!(reason.is_none(), "{name} must be allowed in plan mode");
955        }
956    }
957
958    #[tokio::test]
959    async fn plan_mode_off_blocks_nothing() {
960        let hooks = PlanModeHooks::new(PlanModeHandle::new());
961        assert!(hooks.before_tool_call(&tool_call("edit")).await.is_none());
962        assert!(hooks.before_tool_call(&bash_call("rm -rf x")).await.is_none());
963    }
964
965    #[tokio::test]
966    async fn bash_gate_blocks_mutations_and_allows_reads() {
967        let plan = PlanModeHandle::new();
968        plan.set(true);
969        let hooks = PlanModeHooks::new(plan);
970        for cmd in [
971            "rm -rf build",
972            "mv a b",
973            "cp a b",
974            "mkdir d",
975            "sed -i s/x/y/ f",
976            "tee f",
977            "echo hi > f",
978            "echo hi >> f",
979            "cat f | tee g",
980            "dd if=/dev/zero of=f",
981            "git add .",
982            "git commit -m x",
983            "git checkout -- .",
984            "git push",
985            "git stash",
986            "cargo fmt",
987            "cargo fix",
988            "cargo add serde",
989            "npm install",
990            "pnpm ci",
991            "yarn add x",
992            "go mod tidy",
993            "go get x",
994            "pip install foo",
995            "chmod +x f",
996            "patch < p.diff",
997        ] {
998            assert!(
999                hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
1000                "`{cmd}` should be blocked"
1001            );
1002        }
1003        for cmd in [
1004            "ls -la",
1005            "cat x",
1006            "git status",
1007            "git log --oneline",
1008            "rg foo",
1009            "cargo build",
1010            "cargo test",
1011            "cargo check",
1012            "cargo run",
1013            "make",
1014            "tsc --noEmit",
1015            // A non-mutating sed (no `-i`) and a `>`-free command.
1016            "sed 's/x/y/' f",
1017        ] {
1018            assert!(
1019                hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
1020                "`{cmd}` should be allowed"
1021            );
1022        }
1023    }
1024}