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", "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
296pub struct SleepRefusalHooks;
301
302impl SleepRefusalHooks {
303 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 async fn before_tool_call(&self, call: &ToolCall) -> Option<String> {
323 if call.name != "bash" {
324 return None; }
326 let cmd = call.arguments.get("command").and_then(|c| c.as_str())?;
327 sleep_poll_reason(cmd)
328 }
329}
330
331fn 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
348const DEVICE_PREFIXES: &[&str] = &["sd", "hd", "nvme", "vd", "disk", "mmcblk"];
354
355const ROOT_TARGETS: &[&str] = &["/", "~", "$HOME", "${HOME}"];
359
360const RM_RECURSIVE: &[char] = &['r', 'R'];
362
363const FORK_BOMB: &str = ":(){:|:&};:";
367
368fn 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
386fn 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
416fn 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
425fn 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
439fn 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
454fn 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
464fn 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
479fn 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
487fn 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
501fn is_root_target(s: &str) -> bool {
506 let raw = s.trim_matches(|c| c == '"' || c == '\'');
507 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
520fn root_glob(root: &str) -> String {
523 if root.ends_with('/') {
524 format!("{root}*")
525 } else {
526 format!("{root}/*")
527 }
528}
529
530fn 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#[derive(Clone, Default)]
551pub struct HooksSet(Vec<Arc<dyn Hooks>>);
552
553impl HooksSet {
554 pub fn new() -> Self {
555 Self::default()
556 }
557
558 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 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 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 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 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 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 #[tokio::test]
880 async fn bash_risk_blocks_catastrophic() {
881 let hooks = BashRiskHooks::new();
882 for cmd in [
883 "dd if=/dev/zero of=/dev/sda",
885 "echo x > /dev/sda",
886 "echo x >>/dev/nvme0n1",
887 "mkfs.ext4 /dev/sdb",
889 "wipefs -a /dev/sdb",
890 "shred -n1 /dev/sda",
891 "rm -rf /",
893 "rm -fr ~",
894 "rm -R /*",
895 "rm -rf ~/*",
896 "rm -rf $HOME/*",
897 "rm -rf ${HOME}/*",
898 ":(){ :|:& };:",
900 ":(){:|:&};:",
901 "chmod -R 777 /",
903 "chmod -R 777 /*",
904 "chown --recursive me ~",
905 "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 /", "dd if=/dev/zero of=/tmp/f bs=1M count=1",
928 "dd if=/dev/sda of=/tmp/img", "shred build/x",
930 "chmod 777 /", "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 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 let hooks = BashRiskHooks::new();
985 assert!(hooks.before_tool_call(&bash_call("rm -rf /")).await.is_some());
986
987 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 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 "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}