1use std::sync::Arc;
2
3use crate::message::{AgentMessage, StopReason};
4use crate::protocol::{Request, SessionId};
5use crate::tool::ToolOutput;
6
7pub 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 async fn transform_tool_input(&self, _call: &mut ToolCall) {}
20
21 async fn before_tool_call(&self, _call: &ToolCall) -> Option<String> {
24 None
25 }
26
27 async fn after_tool_call(&self, _call: &ToolCall, _out: &mut ToolOutput) {}
30
31 async fn before_inbound(
37 &self,
38 _from: Option<&SessionId>,
39 _request: &mut Request,
40 ) -> Option<String> {
41 None
42 }
43
44 async fn transform_context(&self, _msgs: &mut Vec<AgentMessage>) {}
46
47 async fn should_stop_after_turn(&self, _ctx: &[AgentMessage]) -> bool {
50 false
51 }
52
53 async fn after_run(&self, _ctx: &[AgentMessage], _stop: StopReason) {}
57}
58
59pub const MUTATING_TOOLS: &[&str] = &["edit", "edits", "write", "replace", "ast_edit"];
63
64#[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
84pub 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 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
117const MUTATING_VERBS: &[&str] = &[
120 "rm", "rmdir", "mv", "cp", "mkdir", "touch", "chmod", "chown", "chgrp", "ln", "dd", "truncate",
121 "tee", "patch",
122];
123
124const MUTATING_GIT: &[&str] = &[
126 "add", "commit", "checkout", "reset", "clean", "apply", "merge", "rebase", "push", "stash",
127 "restore", "rm", "mv",
128];
129
130fn 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
141fn looks_mutating(cmd: &str) -> bool {
143 if cmd.contains('>') {
146 return true;
147 }
148 cmd.split([';', '&', '|', '\n']).any(segment_mutates)
149}
150
151fn 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
170fn 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}
191pub struct ReadOnlyHooks;
224
225impl ReadOnlyHooks {
226 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 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 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}
262pub struct BashRiskHooks;
265
266impl BashRiskHooks {
267 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 async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
285 if call.name != "bash" {
286 return None; }
288 let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
292 bash_risk_reason(cmd)
293 }
294}
295
296const DEVICE_PREFIXES: &[&str] = &["sd", "hd", "nvme", "vd", "disk", "mmcblk"];
302
303const ROOT_TARGETS: &[&str] = &["/", "~", "$HOME", "${HOME}"];
307
308const RM_RECURSIVE: &[char] = &['r', 'R'];
310
311const FORK_BOMB: &str = ":(){:|:&};:";
315
316fn 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
334fn 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
364fn 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
373fn 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
387fn 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
402fn 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
412fn 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
427fn 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
435fn 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
449fn is_root_target(s: &str) -> bool {
454 let raw = s.trim_matches(|c| c == '"' || c == '\'');
455 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
468fn root_glob(root: &str) -> String {
471 if root.ends_with('/') {
472 format!("{root}*")
473 } else {
474 format!("{root}/*")
475 }
476}
477
478fn 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#[derive(Clone, Default)]
499pub struct HooksSet(Vec<Arc<dyn Hooks>>);
500
501impl HooksSet {
502 pub fn new() -> Self {
503 Self::default()
504 }
505
506 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 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 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 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 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 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 #[tokio::test]
828 async fn bash_risk_blocks_catastrophic() {
829 let hooks = BashRiskHooks::new();
830 for cmd in [
831 "dd if=/dev/zero of=/dev/sda",
833 "echo x > /dev/sda",
834 "echo x >>/dev/nvme0n1",
835 "mkfs.ext4 /dev/sdb",
837 "wipefs -a /dev/sdb",
838 "shred -n1 /dev/sda",
839 "rm -rf /",
841 "rm -fr ~",
842 "rm -R /*",
843 "rm -rf ~/*",
844 "rm -rf $HOME/*",
845 "rm -rf ${HOME}/*",
846 ":(){ :|:& };:",
848 ":(){:|:&};:",
849 "chmod -R 777 /",
851 "chmod -R 777 /*",
852 "chown --recursive me ~",
853 "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 /", "dd if=/dev/zero of=/tmp/f bs=1M count=1",
876 "dd if=/dev/sda of=/tmp/img", "shred build/x",
878 "chmod 777 /", "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 let hooks = BashRiskHooks::new();
901 assert!(hooks.before_tool_call(&bash_call("rm -rf /")).await.is_some());
902
903 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 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 "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}