use std::sync::Arc;
use crate::message::{AgentMessage, StopReason};
use crate::protocol::{Request, SessionId};
use crate::tool::ToolOutput;
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[async_trait::async_trait]
pub trait Hooks: Send + Sync {
async fn transform_tool_input(&self, _call: &mut ToolCall) {}
async fn before_tool_call(&self, _call: &ToolCall) -> Option<String> {
None
}
async fn after_tool_call(&self, _call: &ToolCall, _out: &mut ToolOutput) {}
async fn before_inbound(
&self,
_from: Option<&SessionId>,
_request: &mut Request,
) -> Option<String> {
None
}
async fn transform_context(&self, _msgs: &mut Vec<AgentMessage>) {}
async fn should_stop_after_turn(&self, _ctx: &[AgentMessage]) -> bool {
false
}
async fn after_run(&self, _ctx: &[AgentMessage], _stop: StopReason) {}
}
pub const MUTATING_TOOLS: &[&str] = &["edit", "edits", "write", "replace", "ast_edit"];
#[derive(Clone, Default)]
pub struct PlanModeHandle(Arc<std::sync::atomic::AtomicBool>);
impl PlanModeHandle {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self) -> bool {
self.0.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn set(&self, on: bool) {
self.0.store(on, std::sync::atomic::Ordering::Relaxed);
}
}
pub struct PlanModeHooks {
plan: PlanModeHandle,
}
impl PlanModeHooks {
pub fn new(plan: PlanModeHandle) -> Self {
Self { plan }
}
}
#[async_trait::async_trait]
impl Hooks for PlanModeHooks {
async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
if !self.plan.get() {
return None;
}
if MUTATING_TOOLS.contains(&call.name.as_str()) {
return Some(format!(
"plan mode: `{}` is disabled — you are planning, not executing",
call.name
));
}
if call.name == "bash" {
return plan_bash_reason(call);
}
None
}
}
const MUTATING_VERBS: &[&str] = &[
"rm", "rmdir", "mv", "cp", "mkdir", "touch", "chmod", "chown", "chgrp", "ln", "dd", "truncate",
"tee", "patch",
];
const MUTATING_GIT: &[&str] = &[
"add", "commit", "checkout", "reset", "clean", "apply", "merge", "rebase", "push", "stash",
"restore", "rm", "mv",
];
fn plan_bash_reason(call: &ToolCall) -> Option<String> {
let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
looks_mutating(cmd).then(|| {
"plan mode: `bash` mutation refused — you are planning, not executing".to_string()
})
}
fn looks_mutating(cmd: &str) -> bool {
if cmd.contains('>') {
return true;
}
cmd.split([';', '&', '|', '\n']).any(segment_mutates)
}
fn segment_mutates(seg: &str) -> bool {
let mut words = seg
.split_whitespace()
.map(|w| w.trim_matches(|c| c == '"' || c == '\''));
let Some(lead) = words.next() else {
return false;
};
let rest: Vec<&str> = words.collect();
if MUTATING_VERBS.contains(&lead) {
return true;
}
match lead {
"sed" => rest.iter().any(|w| w.starts_with("-i")),
"git" => matches!(rest.first().copied(), Some(g) if MUTATING_GIT.contains(&g)),
_ => mutating_build_command(lead, &rest),
}
}
fn mutating_build_command(lead: &str, rest: &[&str]) -> bool {
match lead {
"cargo" => matches!(
rest.first().copied(),
Some("fix" | "fmt" | "install" | "add" | "remove")
),
"npm" | "pnpm" | "yarn" => matches!(
rest.first().copied(),
Some("install" | "ci" | "add" | "remove" | "update")
),
"go" => {
matches!(rest.first().copied(), Some("get"))
|| (matches!(rest.first().copied(), Some("mod"))
&& matches!(rest.get(1).copied(), Some("tidy")))
}
"pip" => matches!(rest.first().copied(), Some("install")),
_ => false,
}
}
pub struct ReadOnlyHooks;
impl ReadOnlyHooks {
pub fn new() -> Self {
Self
}
}
impl Default for ReadOnlyHooks {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl Hooks for ReadOnlyHooks {
async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
if MUTATING_TOOLS.contains(&call.name.as_str()) {
return Some(format!("read-only worker: `{}` is disabled", call.name));
}
if call.name == "bg" {
return Some("read-only worker: `bg` can run a mutating command".into());
}
if call.name == "bash" {
let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
if looks_mutating(cmd) {
return Some("read-only worker: `bash` mutation refused".into());
}
}
None
}
}
pub struct BashRiskHooks;
impl BashRiskHooks {
pub fn new() -> Self {
Self
}
}
impl Default for BashRiskHooks {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl Hooks for BashRiskHooks {
async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
if call.name != "bash" {
return None; }
let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
bash_risk_reason(cmd)
}
}
const DEVICE_PREFIXES: &[&str] = &["sd", "hd", "nvme", "vd", "disk", "mmcblk"];
const ROOT_TARGETS: &[&str] = &["/", "~", "$HOME", "${HOME}"];
const RM_RECURSIVE: &[char] = &['r', 'R'];
const FORK_BOMB: &str = ":(){:|:&};:";
fn bash_risk_reason(cmd: &str) -> Option<String> {
let label = device_write(cmd)
.or_else(|| mkfs_or_wipe(cmd))
.or_else(|| recursive_root_delete(cmd))
.or_else(|| forkbomb(cmd))
.or_else(|| recursive_perm_root(cmd))?;
Some(format!(
"catastrophic shell command (`{label}`) — this would destroy the system; \
run it outside wcode if you really mean it"
))
}
fn device_write(cmd: &str) -> Option<String> {
for seg in scan_segments(cmd) {
let lead = seg.first().map(String::as_str).unwrap_or("");
let dd_write = lead == "dd"
&& seg.iter().skip(1).any(|w| {
w.strip_prefix("of=")
.is_some_and(|dev| is_device_path(dev, DEVICE_PREFIXES))
});
let redirect = seg.iter().enumerate().any(|(i, w)| {
let target = if let Some(t) = redirect_target(w) {
Some(t)
} else if matches!(w.as_str(), ">" | ">>") {
seg.get(i + 1).map(String::as_str)
} else {
None
};
target.is_some_and(|t| is_device_path(t, DEVICE_PREFIXES))
});
if dd_write || redirect {
return Some(seg.join(" "));
}
}
None
}
fn redirect_target(w: &str) -> Option<&str> {
let rest = w.trim_start_matches(|c: char| c.is_ascii_digit());
let after = rest.strip_prefix(">>").or_else(|| rest.strip_prefix('>'))?;
(!after.is_empty()).then_some(after)
}
fn mkfs_or_wipe(cmd: &str) -> Option<String> {
for seg in scan_segments(cmd) {
let Some(lead) = seg.first() else { continue };
let formats = lead == "mkfs" || lead.starts_with("mkfs.");
let wipes = lead == "wipefs" || lead == "shred";
if (formats || wipes) && seg.iter().skip(1).any(|w| is_device_path(w, DEVICE_PREFIXES)) {
return Some(seg.join(" "));
}
}
None
}
fn recursive_root_delete(cmd: &str) -> Option<String> {
for seg in scan_segments(cmd) {
let Some(lead) = seg.first() else { continue };
if lead == "rm"
&& seg.iter().skip(1).any(|w| is_recursive_flag(w))
&& seg.iter().skip(1).any(|w| is_root_target(w))
{
return Some(seg.join(" "));
}
}
None
}
fn forkbomb(cmd: &str) -> Option<String> {
let squeezed: String = cmd.chars().filter(|c| !c.is_whitespace()).collect();
if squeezed.contains(FORK_BOMB) {
Some(FORK_BOMB.to_string())
} else {
None
}
}
fn recursive_perm_root(cmd: &str) -> Option<String> {
for seg in scan_segments(cmd) {
let Some(lead) = seg.first() else { continue };
if (lead == "chmod" || lead == "chown")
&& seg.iter().skip(1).any(|w| is_recursive_flag(w))
&& seg.iter().skip(1).any(|w| is_root_target(w))
{
return Some(seg.join(" "));
}
}
None
}
fn is_device_path(s: &str, prefixes: &[&str]) -> bool {
let t = s.trim_matches(|c| c == '"' || c == '\'');
t.strip_prefix("/dev/")
.is_some_and(|rest| prefixes.iter().any(|p| rest.starts_with(p)))
}
fn is_recursive_flag(w: &str) -> bool {
if w == "--recursive" {
return true;
}
match w.strip_prefix('-') {
Some(rest) if !rest.is_empty() && !rest.starts_with('-') => {
rest.chars().any(|c| RM_RECURSIVE.contains(&c))
}
_ => false,
}
}
fn is_root_target(s: &str) -> bool {
let raw = s.trim_matches(|c| c == '"' || c == '\'');
let trimmed = raw.trim_end_matches('/');
let t = if trimmed.is_empty() && raw.starts_with('/') {
"/"
} else {
trimmed
};
ROOT_TARGETS
.iter()
.any(|root| t == *root || t == root_glob(root).as_str())
}
fn root_glob(root: &str) -> String {
if root.ends_with('/') {
format!("{root}*")
} else {
format!("{root}/*")
}
}
fn scan_segments(cmd: &str) -> Vec<Vec<String>> {
cmd.split([';', '&', '|', '\n'])
.map(|seg| {
seg.split_whitespace()
.map(|w| w.trim_matches(|c| c == '"' || c == '\'').to_string())
.collect::<Vec<String>>()
})
.filter(|seg| !seg.is_empty())
.collect()
}
#[derive(Clone, Default)]
pub struct HooksSet(Vec<Arc<dyn Hooks>>);
impl HooksSet {
pub fn new() -> Self {
Self::default()
}
pub fn one(hook: Arc<dyn Hooks>) -> Self {
Self(vec![hook])
}
pub fn push(&mut self, hook: Arc<dyn Hooks>) {
self.0.push(hook);
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub async fn transform_tool_input(&self, call: &mut ToolCall) {
for hook in &self.0 {
hook.transform_tool_input(call).await;
}
}
pub async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
for hook in &self.0 {
if let Some(reason) = hook.before_tool_call(call).await {
return Some(reason);
}
}
None
}
pub async fn before_inbound(
&self,
from: Option<&SessionId>,
request: &mut Request,
) -> Option<String> {
for hook in &self.0 {
if let Some(reason) = hook.before_inbound(from, request).await {
return Some(reason);
}
}
None
}
pub async fn after_tool_call(&self, call: &ToolCall, out: &mut ToolOutput) {
for hook in &self.0 {
hook.after_tool_call(call, out).await;
}
}
pub async fn transform_context(&self, msgs: &mut Vec<AgentMessage>) {
for hook in &self.0 {
hook.transform_context(msgs).await;
}
}
pub async fn should_stop_after_turn(&self, ctx: &[AgentMessage]) -> bool {
for hook in &self.0 {
if hook.should_stop_after_turn(ctx).await {
return true;
}
}
false
}
pub async fn after_run(&self, ctx: &[AgentMessage], stop: StopReason) {
for hook in &self.0 {
hook.after_run(ctx, stop).await;
}
}
}
impl FromIterator<Arc<dyn Hooks>> for HooksSet {
fn from_iter<T: IntoIterator<Item = Arc<dyn Hooks>>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct RecordingHooks {
transforms: std::sync::atomic::AtomicUsize,
outputs: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl Hooks for RecordingHooks {
async fn transform_tool_input(&self, call: &mut ToolCall) {
self.transforms
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if let Some(obj) = call.arguments.as_object_mut() {
obj.insert("n".into(), serde_json::json!(1));
}
}
async fn after_tool_call(&self, _call: &ToolCall, out: &mut ToolOutput) {
self.outputs
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
out.output.push('!');
}
}
struct BlockingHooks;
#[async_trait::async_trait]
impl Hooks for BlockingHooks {
async fn before_tool_call(&self, _call: &ToolCall) -> Option<String> {
Some("blocked".into())
}
}
#[tokio::test]
async fn empty_set_is_noop() {
let set = HooksSet::default();
let mut call = ToolCall {
id: "1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "ls" }),
};
set.transform_tool_input(&mut call).await;
assert!(set.before_tool_call(&call).await.is_none());
set.after_tool_call(&call, &mut ToolOutput::default()).await;
assert!(!set.should_stop_after_turn(&[]).await);
}
#[tokio::test]
async fn all_hooks_run_and_mutations_compose() {
let a = Arc::new(RecordingHooks::default());
let b = Arc::new(RecordingHooks::default());
let set = HooksSet::from_iter(vec![a.clone() as Arc<dyn Hooks>, b.clone()]);
let mut call = ToolCall {
id: "1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "ls" }),
};
set.transform_tool_input(&mut call).await;
assert_eq!(call.arguments["n"], 1, "composition kept the mutation");
let mut out = ToolOutput {
output: "x".into(),
..ToolOutput::default()
};
set.after_tool_call(&call, &mut out).await;
assert_eq!(out.output, "x!!", "both hooks patched the output");
assert_eq!(a.transforms.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(b.transforms.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn first_blocking_hook_wins() {
let set = HooksSet::from_iter(vec![
Arc::new(BlockingHooks) as Arc<dyn Hooks>,
Arc::new(RecordingHooks::default()),
]);
let call = ToolCall {
id: "1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "ls" }),
};
assert_eq!(
set.before_tool_call(&call).await.as_deref(),
Some("blocked")
);
}
struct GatingHooks;
#[async_trait::async_trait]
impl Hooks for GatingHooks {
async fn before_inbound(
&self,
_from: Option<&SessionId>,
request: &mut Request,
) -> Option<String> {
let Request::Notify { content } = request else {
return None;
};
if content == "drop me" {
return Some("not allowed".into());
}
content.push_str(" (seen)");
None
}
}
#[tokio::test]
async fn before_inbound_gates_and_rewrites() {
let set = HooksSet::one(Arc::new(GatingHooks));
let mut kept = Request::Notify {
content: "hello".into(),
};
assert!(set.before_inbound(None, &mut kept).await.is_none());
assert_eq!(
kept,
Request::Notify {
content: "hello (seen)".into()
}
);
let mut dropped = Request::Notify {
content: "drop me".into(),
};
assert_eq!(
set.before_inbound(None, &mut dropped).await.as_deref(),
Some("not allowed")
);
let mut plain = Request::Cancel;
assert!(set.before_inbound(None, &mut plain).await.is_none());
}
#[derive(Default)]
struct RunLog {
calls: std::sync::Mutex<Vec<usize>>,
}
#[async_trait::async_trait]
impl Hooks for RunLog {
async fn after_run(&self, ctx: &[AgentMessage], _stop: StopReason) {
self.calls.lock().unwrap().push(ctx.len());
}
}
#[tokio::test]
async fn after_run_runs_every_hook() {
let a = Arc::new(RunLog::default());
let b = Arc::new(RunLog::default());
let set = HooksSet::from_iter(vec![a.clone() as Arc<dyn Hooks>, b.clone()]);
let ctx = vec![AgentMessage::user_text("hi")];
set.after_run(&ctx, StopReason::Stop).await;
assert_eq!(*a.calls.lock().unwrap(), vec![1]);
assert_eq!(*b.calls.lock().unwrap(), vec![1]);
}
fn tool_call(name: &str) -> ToolCall {
ToolCall {
id: "1".into(),
name: name.into(),
arguments: serde_json::json!({}),
}
}
fn bash_call(cmd: &str) -> ToolCall {
ToolCall {
id: "1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": cmd }),
}
}
#[tokio::test]
async fn read_only_blocks_every_mutating_tool_and_bg() {
let hooks = ReadOnlyHooks::new();
for name in MUTATING_TOOLS.iter().copied().chain(std::iter::once("bg")) {
let reason = hooks.before_tool_call(&tool_call(name)).await;
assert!(
reason.is_some_and(|r| r.starts_with("read-only worker: ")),
"{name}"
);
}
}
#[tokio::test]
async fn read_only_blocks_mutating_bash() {
let hooks = ReadOnlyHooks::new();
for cmd in [
"rm -rf build",
"mv a b",
"echo hi > f",
"git commit -m x",
"cargo fmt",
] {
assert!(
hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
"{cmd}"
);
}
}
#[tokio::test]
async fn read_only_allows_read_tools_todo_message_and_benign_bash() {
let hooks = ReadOnlyHooks::new();
for name in [
"read",
"grep",
"find",
"ast_search",
"session_search",
"webfetch",
"todo",
"message",
"peers",
"task",
] {
assert!(
hooks.before_tool_call(&tool_call(name)).await.is_none(),
"{name}"
);
}
for cmd in ["ls -la", "git status", "cat x", "cargo build"] {
assert!(
hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
"{cmd}"
);
}
}
#[tokio::test]
async fn bash_risk_blocks_catastrophic() {
let hooks = BashRiskHooks::new();
for cmd in [
"dd if=/dev/zero of=/dev/sda",
"echo x > /dev/sda",
"echo x >>/dev/nvme0n1",
"mkfs.ext4 /dev/sdb",
"wipefs -a /dev/sdb",
"shred -n1 /dev/sda",
"rm -rf /",
"rm -fr ~",
"rm -R /*",
"rm -rf ~/*",
"rm -rf $HOME/*",
"rm -rf ${HOME}/*",
":(){ :|:& };:",
":(){:|:&};:",
"chmod -R 777 /",
"chmod -R 777 /*",
"chown --recursive me ~",
"rm -rf ~/",
"rm -rf $HOME/",
"rm -rf ${HOME}/",
"rm -rf /*/",
"chmod -R 777 //",
] {
assert!(
hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
"`{cmd}` should be blocked"
);
}
}
#[tokio::test]
async fn bash_risk_allows_benign() {
let hooks = BashRiskHooks::new();
for cmd in [
"rm -rf build",
"rm -rf /tmp/x",
"rm -r build",
"rm -f /", "dd if=/dev/zero of=/tmp/f bs=1M count=1",
"dd if=/dev/sda of=/tmp/img", "shred build/x",
"chmod 777 /", "ls -la",
"cargo build",
] {
assert!(
hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
"`{cmd}` should be allowed"
);
}
}
#[tokio::test]
async fn bash_risk_ignores_non_bash_tools() {
let hooks = BashRiskHooks::new();
assert!(hooks.before_tool_call(&tool_call("read")).await.is_none());
assert!(hooks.before_tool_call(&tool_call("edit")).await.is_none());
}
#[tokio::test]
async fn bash_risk_is_independent_of_plan_mode() {
let hooks = BashRiskHooks::new();
assert!(hooks.before_tool_call(&bash_call("rm -rf /")).await.is_some());
let off = HooksSet::from_iter([
Arc::new(BashRiskHooks::new()) as Arc<dyn Hooks>,
Arc::new(PlanModeHooks::new(PlanModeHandle::new())),
]);
assert!(off.before_tool_call(&bash_call("rm -rf /")).await.is_some());
let plan = PlanModeHandle::new();
plan.set(true);
let on = HooksSet::from_iter([
Arc::new(BashRiskHooks::new()) as Arc<dyn Hooks>,
Arc::new(PlanModeHooks::new(plan)),
]);
let reason = on.before_tool_call(&bash_call("rm -rf /")).await;
assert!(
reason
.as_deref()
.is_some_and(|r| r.starts_with("catastrophic shell command")),
"the risk gate's reason must win over plan mode's, got {reason:?}"
);
}
#[tokio::test]
async fn plan_mode_blocks_every_mutating_tool() {
let plan = PlanModeHandle::new();
plan.set(true);
let hooks = PlanModeHooks::new(plan);
for name in MUTATING_TOOLS {
let reason = hooks.before_tool_call(&tool_call(name)).await;
assert!(reason.is_some(), "{name} must be blocked in plan mode");
}
}
#[tokio::test]
async fn plan_mode_allows_read_tools_and_todo() {
let plan = PlanModeHandle::new();
plan.set(true);
let hooks = PlanModeHooks::new(plan);
for name in [
"read",
"grep",
"find",
"ast_search",
"session_search",
"todo",
"task",
] {
let reason = hooks.before_tool_call(&tool_call(name)).await;
assert!(reason.is_none(), "{name} must be allowed in plan mode");
}
}
#[tokio::test]
async fn plan_mode_off_blocks_nothing() {
let hooks = PlanModeHooks::new(PlanModeHandle::new());
assert!(hooks.before_tool_call(&tool_call("edit")).await.is_none());
assert!(hooks.before_tool_call(&bash_call("rm -rf x")).await.is_none());
}
#[tokio::test]
async fn bash_gate_blocks_mutations_and_allows_reads() {
let plan = PlanModeHandle::new();
plan.set(true);
let hooks = PlanModeHooks::new(plan);
for cmd in [
"rm -rf build",
"mv a b",
"cp a b",
"mkdir d",
"sed -i s/x/y/ f",
"tee f",
"echo hi > f",
"echo hi >> f",
"cat f | tee g",
"dd if=/dev/zero of=f",
"git add .",
"git commit -m x",
"git checkout -- .",
"git push",
"git stash",
"cargo fmt",
"cargo fix",
"cargo add serde",
"npm install",
"pnpm ci",
"yarn add x",
"go mod tidy",
"go get x",
"pip install foo",
"chmod +x f",
"patch < p.diff",
] {
assert!(
hooks.before_tool_call(&bash_call(cmd)).await.is_some(),
"`{cmd}` should be blocked"
);
}
for cmd in [
"ls -la",
"cat x",
"git status",
"git log --oneline",
"rg foo",
"cargo build",
"cargo test",
"cargo check",
"cargo run",
"make",
"tsc --noEmit",
"sed 's/x/y/' f",
] {
assert!(
hooks.before_tool_call(&bash_call(cmd)).await.is_none(),
"`{cmd}` should be allowed"
);
}
}
}