1use std::path::PathBuf;
31
32use crate::domain::{ApprovalKind, ToolOutcome};
33use crate::providers::{ApprovalBroker, ApprovalDecision, allowlist_key};
34use crate::runtime::{
35 ActionRequest, NewApproval, PolicyDecision, PolicyEngine, RiskClass, RuntimeStore,
36 create_checkpoint_for_task, run_plugin_hooks,
37};
38
39use super::super::ctx::ExecContext;
40
41pub enum Gate {
43 Proceed { risk: RiskClass, plan_write: bool },
50 Block(ToolOutcome),
52}
53
54pub async fn gate_external(
63 ctx: &ExecContext,
64 tool: &'static str,
65 category: crate::runtime::ToolCategory,
66 summary: String,
67 args: &serde_json::Value,
68) -> Option<ToolOutcome> {
69 gate_external_inner(ctx, tool, category, summary, args, false).await
70}
71
72pub async fn gate_external_mcp(
76 ctx: &ExecContext,
77 summary: String,
78 args: &serde_json::Value,
79 read_only_hint: bool,
80) -> Option<ToolOutcome> {
81 gate_external_inner(
82 ctx,
83 "mcp_proxy",
84 crate::runtime::ToolCategory::Mcp,
85 summary,
86 args,
87 read_only_hint,
88 )
89 .await
90}
91
92async fn gate_external_inner(
93 ctx: &ExecContext,
94 tool: &'static str,
95 category: crate::runtime::ToolCategory,
96 summary: String,
97 args: &serde_json::Value,
98 mcp_read_only_hint: bool,
99) -> Option<ToolOutcome> {
100 if matches!(
101 category,
102 crate::runtime::ToolCategory::Web | crate::runtime::ToolCategory::Network
103 ) && matches!(ctx.config.safety.network, crate::app::NetworkPolicy::Deny)
104 {
105 return Some(ToolOutcome::error(
106 format!(
107 "{tool} blocked because network access is disabled (safety.network = \"deny\" / --no-network)"
108 ),
109 0.0,
110 ));
111 }
112 let mut request = ActionRequest::new(tool, category, summary);
113 request.command = action_detail(tool, args);
119 request.arguments = Some(args.clone());
120 request.mcp_read_only_hint = mcp_read_only_hint;
121 let pending = serde_json::json!({ "tool": tool, "args": args });
122 match gate(ctx, request, &[], pending, false, false).await {
126 Gate::Block(outcome) => Some(outcome),
127 Gate::Proceed { .. } => None,
128 }
129}
130
131fn action_detail(tool: &str, args: &serde_json::Value) -> Option<String> {
135 let s = |k: &str| args.get(k).and_then(|v| v.as_str());
136 let i = |k: &str| args.get(k).and_then(|v| v.as_i64());
137 match tool {
138 "type_text" => Some(format!("type_text {:?}", s("text")?)),
139 "press_key" => Some(format!("press_key {}", s("key").or_else(|| s("keys"))?)),
140 "click" | "mouse_move" => match (i("x"), i("y")) {
141 (Some(x), Some(y)) => Some(format!("{tool} ({x}, {y})")),
142 _ => None,
143 },
144 "scroll" => {
145 let dir = s("direction").unwrap_or("");
146 Some(
147 format!("scroll {dir} {}", i("amount").unwrap_or(0))
148 .trim()
149 .to_string(),
150 )
151 },
152 "web_fetch" => Some(format!("web_fetch {}", s("url")?)),
153 "mcp_proxy" => {
154 let server = s("server_name").unwrap_or("?");
155 let name = s("tool_name").unwrap_or("?");
156 let arg_preview = args
157 .get("arguments")
158 .filter(|a| !a.is_null())
159 .map(serde_json::Value::to_string)
160 .unwrap_or_default();
161 Some(format!("mcp {server}__{name}({arg_preview})"))
162 },
163 "web_search" => {
164 let queries: Vec<String> = if let Some(q) = s("query") {
169 vec![q.to_string()]
170 } else if let Some(arr) = args.get("queries").and_then(|v| v.as_array()) {
171 arr.iter()
172 .filter_map(|e| e.get("query").and_then(|v| v.as_str()))
173 .map(str::to_string)
174 .collect()
175 } else {
176 Vec::new()
177 };
178 if queries.is_empty() {
179 None
180 } else {
181 Some(format!("web_search {}", queries.join(" | ")))
182 }
183 },
184 "agent" => {
185 Some(format!("agent: {}", s("prompt")?))
189 },
190 _ => None,
191 }
192}
193
194pub async fn gate(
207 ctx: &ExecContext,
208 request: ActionRequest,
209 checkpoint_paths: &[PathBuf],
210 pending_action: serde_json::Value,
211 replayable: bool,
212 scratch_contained: bool,
213) -> Gate {
214 let decision = PolicyEngine::new(ctx.safety_mode)
217 .with_overrides(ctx.config.safety.overrides.clone())
218 .with_external_writes(ctx.config.safety.external_writes)
219 .with_system_installs(ctx.config.safety.system_installs)
220 .decide(&request);
221
222 let (decision, plan_write) = if ctx.plan_file.is_some() {
233 apply_plan_profile(ctx, &request, decision)
234 } else {
235 (decision, false)
236 };
237
238 let decision = match decision {
243 PolicyDecision::Ask { risk, .. }
244 if ctx.plan_file.is_none()
245 && ctx.safety_mode == crate::runtime::SafetyMode::ReadOnly
246 && request.category == crate::runtime::ToolCategory::Web
247 && ctx.config.safety.allow_readonly_web =>
248 {
249 PolicyDecision::Allow {
250 risk,
251 checkpoint: false,
252 }
253 },
254 other => other,
255 };
256
257 if scratch_contained
262 && let PolicyDecision::Ask { risk, .. } | PolicyDecision::Classify { risk, .. } = decision
263 && scratch_downgrade_eligible(risk)
264 {
265 return Gate::Proceed {
266 risk,
267 plan_write: false,
268 };
269 }
270
271 match decision {
272 PolicyDecision::Allow { risk, .. } => Gate::Proceed { risk, plan_write },
273 PolicyDecision::Ask { risk, checkpoint } => {
274 if let Some(broker) = &ctx.approval {
275 inline_decision(ctx, broker, &request, risk, None).await
280 } else if !replayable {
281 if ctx.config.safety.allow_untrusted_headless_tools {
286 tracing::debug!(
287 tool = %request.tool,
288 "policy Ask on non-replayable tool; proceeding (--allow-untrusted-tools)",
289 );
290 Gate::Proceed { risk, plan_write }
291 } else {
292 Gate::Block(ToolOutcome::error(
293 format!(
294 "{} requires approval, but this is a headless run with no approval UI. \
295 Re-run with --allow-untrusted-tools, or use a safety mode of auto/full_access.",
296 request.summary
297 ),
298 0.0,
299 ))
300 }
301 } else {
302 block_for_approval(
303 ctx,
304 &request,
305 checkpoint,
306 checkpoint_paths,
307 pending_action,
308 risk,
309 None,
310 )
311 }
312 },
313 PolicyDecision::Classify { risk, checkpoint } => {
314 let verdict = match &ctx.classifier {
317 Some(classifier) => {
318 let vreq = crate::providers::VetRequest {
319 tool: request.tool.clone(),
320 summary: request.summary.clone(),
321 command: request.command.clone(),
322 path: request.path.clone(),
323 arguments: request.arguments.clone(),
324 intent: ctx.intent.clone(),
325 workdir: ctx.workdir.display().to_string(),
326 turn: ctx.turn,
327 token: ctx.token.clone(),
328 };
329 classifier.vet(&vreq).await
330 },
331 None => crate::providers::VetVerdict::escalate("no Auto-mode classifier available"),
332 };
333 if verdict.allow {
334 Gate::Proceed { risk, plan_write }
335 } else if let Some(broker) = &ctx.approval {
336 inline_decision(ctx, broker, &request, risk, Some(verdict.reason)).await
338 } else if replayable {
339 block_for_approval(
341 ctx,
342 &request,
343 checkpoint,
344 checkpoint_paths,
345 pending_action,
346 risk,
347 Some(verdict.reason),
348 )
349 } else {
350 Gate::Block(ToolOutcome::error(
352 format!(
353 "{} blocked by Auto-mode safety review: {}",
354 request.summary, verdict.reason
355 ),
356 0.0,
357 ))
358 }
359 },
360 PolicyDecision::Deny { reason, .. } => Gate::Block(ToolOutcome::error(
361 format!("{} blocked by policy: {}", request.summary, reason),
362 0.0,
363 )),
364 }
365}
366
367fn plan_deny(risk: RiskClass, plan_file: &std::path::Path) -> PolicyDecision {
375 PolicyDecision::Deny {
376 risk,
377 reason: format!(
378 "{} is active — planning only. Capture this change in the plan file at {} \
379 instead of performing it now: write_file or apply_patch on that exact path \
380 are the allowed mutations (a shell redirect writing ONLY that file also \
381 works). When the plan is complete, call exit_plan_mode",
382 crate::runtime::PLAN_DENIAL_MARKER,
383 plan_file.display(),
384 ),
385 }
386}
387
388fn plan_level_decision(
392 level: crate::app::PlanPermLevel,
393 risk: RiskClass,
394 plan_file: &std::path::Path,
395) -> PolicyDecision {
396 use crate::app::PlanPermLevel as L;
397 match level {
398 L::Allow => PolicyDecision::Allow {
399 risk,
400 checkpoint: false,
401 },
402 L::Auto => PolicyDecision::Classify {
403 risk,
404 checkpoint: false,
405 },
406 L::Ask => PolicyDecision::Ask {
407 risk,
408 checkpoint: false,
409 },
410 L::Deny => plan_deny(risk, plan_file),
411 }
412}
413
414fn apply_plan_profile(
427 ctx: &ExecContext,
428 request: &ActionRequest,
429 decision: PolicyDecision,
430) -> (PolicyDecision, bool) {
431 use crate::runtime::ToolCategory as C;
432 let perms = ctx.plan_permissions;
433 match decision {
434 PolicyDecision::Deny { risk, reason }
435 if reason.starts_with(crate::runtime::READ_ONLY_DENIAL_MARKER) =>
436 {
437 let plan_file = ctx.plan_file.as_deref().expect("plan mode ctx");
438 let action_dir = request.resolve_dir(&ctx.workdir);
443 let plan_file_edit = request.category == C::Edit
444 && request
445 .path
446 .as_deref()
447 .is_some_and(|p| crate::runtime::is_plan_file_path(&ctx.workdir, p, plan_file));
448 if plan_file_edit {
449 (
451 PolicyDecision::Allow {
452 risk,
453 checkpoint: false,
454 },
455 true,
456 )
457 } else if request.category == C::Memory {
458 (plan_level_decision(perms.memory, risk, plan_file), false)
459 } else if request
460 .command
461 .as_deref()
462 .is_some_and(|c| crate::runtime::is_plan_file_only_write(c, action_dir, plan_file))
463 {
464 (
468 PolicyDecision::Allow {
469 risk,
470 checkpoint: false,
471 },
472 true,
473 )
474 } else if request
475 .command
476 .as_deref()
477 .is_some_and(crate::runtime::is_plan_safe_build_command)
478 {
479 (plan_level_decision(perms.builds, risk, plan_file), false)
480 } else {
481 (plan_deny(risk, plan_file), false)
482 }
483 },
484 PolicyDecision::Allow { risk, .. }
485 | PolicyDecision::Ask { risk, .. }
486 | PolicyDecision::Classify { risk, .. }
487 if request.category == C::Web =>
488 {
489 let plan_file = ctx.plan_file.as_deref().expect("plan mode ctx");
490 (plan_level_decision(perms.web, risk, plan_file), false)
491 },
492 other => (other, false),
493 }
494}
495
496fn scratch_downgrade_eligible(risk: RiskClass) -> bool {
504 matches!(
505 risk,
506 RiskClass::ReadOnly
507 | RiskClass::LowMutation
508 | RiskClass::FileMutation
509 | RiskClass::ShellMutation
510 )
511}
512
513async fn inline_decision(
518 ctx: &ExecContext,
519 broker: &ApprovalBroker,
520 request: &ActionRequest,
521 risk: RiskClass,
522 classifier_reason: Option<String>,
523) -> Gate {
524 let key = allowlist_key(&request.tool, request.command.as_deref());
525 if !key.is_empty() && broker.is_allowlisted(&key) {
532 return Gate::Proceed {
533 risk,
534 plan_write: false,
535 };
536 }
537 let kind = if classifier_reason.is_some() {
538 ApprovalKind::Classify
539 } else {
540 approval_kind(request.category)
541 };
542 let prompt = format_approval_body(request, classifier_reason.as_deref());
543 let decision = broker
544 .request(
545 &ctx.token,
546 ctx.turn,
547 ctx.call_id,
548 request.tool.clone(),
549 risk.as_str().to_string(),
550 kind,
551 prompt,
552 key,
553 )
554 .await;
555 match decision {
556 ApprovalDecision::Approve | ApprovalDecision::ApproveAlways => Gate::Proceed {
557 risk,
558 plan_write: false,
559 },
560 ApprovalDecision::Deny => Gate::Block(ToolOutcome::error(
561 format!("{} — denied by you", request.summary),
562 0.0,
563 )),
564 }
565}
566
567fn format_approval_body(request: &ActionRequest, classifier_reason: Option<&str>) -> String {
570 fn clip_preview(value: &str) -> String {
571 const MAX_BYTES: usize = 200;
572 if value.len() <= MAX_BYTES {
573 return value.to_string();
574 }
575 let end = value.floor_char_boundary(MAX_BYTES);
576 format!("{}…", &value[..end])
577 }
578
579 use crate::runtime::ToolCategory as C;
580 let redacted_detail = request.arguments.as_ref().and_then(|arguments| {
581 let mut safe = arguments.clone();
582 crate::utils::redact_json(&mut safe);
583 action_detail(&request.tool, &safe)
584 });
585 let modal_detail = redacted_detail.as_ref().or(request.command.as_ref());
586 let mut body = if let Some(cmd) = modal_detail {
587 match request.category {
591 C::Shell | C::Git | C::Process => format!("$ {}", cmd),
592 _ => clip_preview(cmd),
593 }
594 } else if let Some(path) = &request.path {
595 format!("{} ({})", path, request.summary)
596 } else {
597 match request.category {
598 C::Shell | C::Git | C::Process => request.summary.clone(),
599 _ => clip_preview(&request.summary),
600 }
601 };
602 if let Some(reason) = classifier_reason {
603 body.push_str(&format!("\n\nAuto-review flagged this: {}", reason));
604 }
605 body
606}
607
608fn approval_kind(category: crate::runtime::ToolCategory) -> ApprovalKind {
609 use crate::runtime::ToolCategory as C;
610 match category {
611 C::Edit => ApprovalKind::FileMutation,
612 C::Shell | C::Git | C::Process => ApprovalKind::Shell,
613 C::Web | C::Network | C::ExternalDirectory => ApprovalKind::Web,
614 C::Mcp => ApprovalKind::Mcp,
615 C::Subagent => ApprovalKind::Subagent,
616 C::ComputerUse => ApprovalKind::ComputerUse,
617 C::Read | C::Memory => ApprovalKind::Shell,
620 }
621}
622
623#[allow(clippy::too_many_arguments)]
627fn block_for_approval(
628 ctx: &ExecContext,
629 request: &ActionRequest,
630 checkpoint: bool,
631 checkpoint_paths: &[PathBuf],
632 pending_action: serde_json::Value,
633 risk: RiskClass,
634 classifier_reason: Option<String>,
637) -> Gate {
638 let checkpoint_id = if checkpoint && ctx.config.safety.checkpoint_on_mutation {
639 match create_checkpoint_for_task(
640 &ctx.workdir,
641 checkpoint_paths,
642 Some(pending_action.clone()),
643 ctx.checkpoint_origin(),
644 ) {
645 Ok(manifest) => Some(manifest.id),
646 Err(error) => {
647 return Gate::Block(ToolOutcome::error(
648 format!(
649 "{} checkpoint failed before approval: {}",
650 request.summary, error
651 ),
652 0.0,
653 ));
654 },
655 }
656 } else {
657 None
658 };
659
660 let args_summary = request
661 .command
662 .clone()
663 .or_else(|| request.path.clone())
664 .unwrap_or_else(|| request.summary.clone());
665 let pending_action_json = serde_json::to_string(&pending_action).ok();
666 let tool = request.tool.clone();
667 let risk_str = risk.as_str().to_string();
668
669 let proposed_action = match &classifier_reason {
670 Some(reason) => format!("{} [auto-review: {}]", request.summary, reason),
671 None => request.summary.clone(),
672 };
673
674 let approval_id = RuntimeStore::open_default()
675 .and_then(|store| {
676 let approval = store.approvals().create(NewApproval {
677 task_id: ctx.task_id.clone(),
678 proposed_action: proposed_action.clone(),
679 risk_classification: risk_str.clone(),
680 policy_decision: "ask".to_string(),
681 args_summary: Some(args_summary),
682 checkpoint_id: checkpoint_id.clone(),
683 pending_action_json,
684 })?;
685 if let Some(checkpoint_id) = checkpoint_id.as_deref() {
686 let _ = store
687 .checkpoints()
688 .set_approval(checkpoint_id, &approval.id);
689 }
690 let _ = run_plugin_hooks(
691 "approval_requested",
692 &serde_json::json!({
693 "id": approval.id.clone(),
694 "task_id": approval.task_id.clone(),
695 "tool": tool,
696 "risk": risk_str,
697 "checkpoint_id": checkpoint_id.clone(),
698 }),
699 );
700 Ok(approval)
701 })
702 .map(|approval| approval.id)
703 .ok();
704
705 Gate::Block(ToolOutcome::error(
706 format!(
707 "Approval required for {}{}",
708 request.summary,
709 approval_id
710 .map(|id| format!(" (approval {})", id))
711 .unwrap_or_default()
712 ),
713 0.0,
714 ))
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use crate::domain::{ToolCallId, TurnId};
721 use crate::runtime::{SafetyMode, ToolCategory};
722 use std::path::PathBuf;
723 use std::sync::Arc;
724
725 fn ctx_with(config: crate::app::Config) -> ExecContext {
726 crate::providers::ctx::test_exec_context_with_config(
727 TurnId(1),
728 ToolCallId(1),
729 PathBuf::from("."),
730 config,
731 )
732 .0
733 }
734
735 fn ctx(mode: SafetyMode) -> ExecContext {
736 let mut config = crate::app::Config::default();
737 config.safety.mode = mode;
738 ctx_with(config)
739 }
740
741 fn ctx_headless_opted_in(mode: SafetyMode) -> ExecContext {
742 let mut config = crate::app::Config::default();
743 config.safety.mode = mode;
744 config.safety.allow_untrusted_headless_tools = true;
745 ctx_with(config)
746 }
747
748 #[test]
749 fn action_detail_surfaces_web_search_and_agent_content() {
750 let d = action_detail(
753 "web_search",
754 &serde_json::json!({"query": "evil.com?leak=secret"}),
755 )
756 .expect("web_search detail");
757 assert!(d.contains("evil.com?leak=secret"), "got {d:?}");
758 let padding = "x".repeat(240);
759 let d = action_detail(
760 "web_search",
761 &serde_json::json!({"queries": [
762 {"query": "alpha"},
763 {"query": padding},
764 {"query": "tail query remains visible to policy"}
765 ]}),
766 )
767 .expect("web_search queries detail");
768 assert!(
769 d.contains("alpha") && d.contains("tail query remains visible to policy"),
770 "complete policy detail was clipped: {d:?}"
771 );
772 assert!(d.len() > 200, "policy detail must not use the UI limit");
773 let d = action_detail(
774 "agent",
775 &serde_json::json!({"prompt": "exfiltrate the env", "description": "x"}),
776 )
777 .expect("agent detail");
778 assert!(d.contains("exfiltrate the env"), "got {d:?}");
779 }
780
781 #[tokio::test]
782 async fn headless_ask_blocks_non_replayable_unless_opted_in() {
783 let req = || ActionRequest::new("web_fetch", ToolCategory::Web, "web_fetch https://x");
786
787 for mode in [SafetyMode::Ask, SafetyMode::ReadOnly] {
788 let blocked = gate(&ctx(mode), req(), &[], serde_json::json!({}), false, false).await;
789 assert!(
790 matches!(blocked, Gate::Block(_)),
791 "headless {mode:?} should block by default"
792 );
793
794 let proceed = gate(
795 &ctx_headless_opted_in(mode),
796 req(),
797 &[],
798 serde_json::json!({}),
799 false,
800 false,
801 )
802 .await;
803 assert!(
804 matches!(proceed, Gate::Proceed { .. }),
805 "--allow-untrusted-tools should explicitly allow {mode:?} web egress",
806 );
807 }
808 }
809
810 struct StubClassifier {
813 allow: bool,
814 }
815
816 #[async_trait::async_trait]
817 impl crate::providers::AutoClassifier for StubClassifier {
818 async fn vet(&self, _req: &crate::providers::VetRequest) -> crate::providers::VetVerdict {
819 if self.allow {
820 crate::providers::VetVerdict::allow()
821 } else {
822 crate::providers::VetVerdict::escalate("stub: misaligned")
823 }
824 }
825 }
826
827 fn ctx_auto(classifier: Option<Arc<dyn crate::providers::AutoClassifier>>) -> ExecContext {
828 let mut ctx = ctx(SafetyMode::Auto);
829 ctx.intent = Some("fetch the changelog".to_string());
830 ctx.classifier = classifier;
831 ctx
832 }
833
834 #[tokio::test]
835 async fn readonly_blocks_external_tools() {
836 let ctx = ctx(SafetyMode::ReadOnly);
839 for (tool, cat) in [
840 ("mcp_proxy", ToolCategory::Mcp),
841 ("click", ToolCategory::ComputerUse),
842 ("memory", ToolCategory::Memory),
843 ] {
844 assert!(
845 gate_external(&ctx, tool, cat, tool.to_string(), &serde_json::json!({}))
846 .await
847 .is_some(),
848 "ReadOnly must block {tool}",
849 );
850 }
851 assert!(
855 gate_external(
856 &ctx,
857 "agent",
858 ToolCategory::Subagent,
859 "subagent: explore".to_string(),
860 &serde_json::json!({"prompt": "map the crates"}),
861 )
862 .await
863 .is_none(),
864 "ReadOnly must allow subagent spawn",
865 );
866 }
867
868 #[tokio::test]
869 async fn readonly_web_egress_fails_closed_without_approval_ui() {
870 let ctx = ctx(SafetyMode::ReadOnly);
871 for (tool, summary) in [
872 ("web_search", "web_search rust release notes"),
873 ("web_fetch", "web_fetch https://example.com/docs"),
874 ] {
875 assert!(
876 gate_external(
877 &ctx,
878 tool,
879 ToolCategory::Web,
880 summary.to_string(),
881 &serde_json::json!({}),
882 )
883 .await
884 .is_some(),
885 "ReadOnly must require approval for {tool}",
886 );
887 }
888 }
889
890 #[tokio::test]
891 async fn readonly_web_explicit_user_opt_in_proceeds() {
892 let mut context = ctx(SafetyMode::ReadOnly);
893 Arc::make_mut(&mut context.config).safety.allow_readonly_web = true;
894 assert!(
895 gate_external(
896 &context,
897 "web_fetch",
898 ToolCategory::Web,
899 "web_fetch example".to_string(),
900 &serde_json::json!({"url": "https://example.com"}),
901 )
902 .await
903 .is_none(),
904 "explicit user/session opt-in should allow ReadOnly web egress"
905 );
906 }
907
908 #[tokio::test]
909 async fn global_network_deny_blocks_web_even_in_full_access() {
910 let mut context = ctx(SafetyMode::FullAccess);
911 let safety = &mut Arc::make_mut(&mut context.config).safety;
912 safety.network = crate::app::NetworkPolicy::Deny;
913 safety.allow_readonly_web = true;
914 for tool in ["web_fetch", "web_search"] {
915 let blocked = gate_external(
916 &context,
917 tool,
918 ToolCategory::Web,
919 tool.to_string(),
920 &serde_json::json!({"url": "https://example.com"}),
921 )
922 .await;
923 assert!(blocked.is_some(), "network deny must block {tool}");
924 }
925 }
926
927 #[tokio::test]
928 async fn memory_writes_ungated_except_readonly() {
929 for mode in [SafetyMode::Ask, SafetyMode::Auto, SafetyMode::FullAccess] {
933 let ctx = ctx(mode);
934 assert!(
935 gate_external(
936 &ctx,
937 "memory",
938 ToolCategory::Memory,
939 "memory remember".to_string(),
940 &serde_json::json!({"action": "remember"}),
941 )
942 .await
943 .is_none(),
944 "memory must proceed without approval in {mode:?}",
945 );
946 }
947 let ctx = ctx(SafetyMode::ReadOnly);
948 assert!(
949 gate_external(
950 &ctx,
951 "memory",
952 ToolCategory::Memory,
953 "memory remember".to_string(),
954 &serde_json::json!({"action": "remember"}),
955 )
956 .await
957 .is_some(),
958 "read-only must block memory writes",
959 );
960 }
961
962 #[tokio::test]
963 async fn full_access_allows_external_tools() {
964 let ctx = ctx(SafetyMode::FullAccess);
965 assert!(
966 gate_external(
967 &ctx,
968 "web_fetch",
969 ToolCategory::Web,
970 "web_fetch".to_string(),
971 &serde_json::json!({}),
972 )
973 .await
974 .is_none()
975 );
976 }
977
978 #[tokio::test]
979 async fn auto_classifier_allow_proceeds() {
980 let ctx = ctx_auto(Some(Arc::new(StubClassifier { allow: true })));
982 assert!(
983 gate_external(
984 &ctx,
985 "web_fetch",
986 ToolCategory::Web,
987 "web_fetch".to_string(),
988 &serde_json::json!({}),
989 )
990 .await
991 .is_none(),
992 "ALLOW verdict should let the action proceed",
993 );
994 }
995
996 #[tokio::test]
997 async fn auto_classifier_escalate_blocks() {
998 let ctx = ctx_auto(Some(Arc::new(StubClassifier { allow: false })));
1000 assert!(
1001 gate_external(
1002 &ctx,
1003 "web_fetch",
1004 ToolCategory::Web,
1005 "web_fetch".to_string(),
1006 &serde_json::json!({}),
1007 )
1008 .await
1009 .is_some(),
1010 "ESCALATE verdict should block a non-replayable tool",
1011 );
1012 }
1013
1014 #[tokio::test]
1015 async fn auto_without_classifier_fails_safe() {
1016 let ctx = ctx_auto(None);
1019 assert!(
1020 gate_external(
1021 &ctx,
1022 "web_fetch",
1023 ToolCategory::Web,
1024 "web_fetch".to_string(),
1025 &serde_json::json!({}),
1026 )
1027 .await
1028 .is_some(),
1029 "missing classifier must fail safe (block), not allow",
1030 );
1031 }
1032
1033 fn ctx_with_broker_mode(
1034 mode: SafetyMode,
1035 broker: crate::providers::ApprovalBroker,
1036 ) -> ExecContext {
1037 let mut ctx = ctx(mode);
1038 ctx.call_id = ToolCallId(7);
1039 ctx.approval = Some(broker);
1040 ctx
1041 }
1042
1043 fn ctx_with_broker(broker: crate::providers::ApprovalBroker) -> ExecContext {
1045 ctx_with_broker_mode(SafetyMode::Ask, broker)
1046 }
1047
1048 #[tokio::test]
1049 async fn readonly_web_uses_non_allowlistable_one_shot_approval() {
1050 let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
1051 let broker = crate::providers::ApprovalBroker::new(tx);
1052 let context = ctx_with_broker_mode(SafetyMode::ReadOnly, broker.clone());
1053 let handle = tokio::spawn(async move {
1054 gate_external(
1055 &context,
1056 "web_fetch",
1057 ToolCategory::Web,
1058 "web_fetch example".to_string(),
1059 &serde_json::json!({"url": "https://example.com"}),
1060 )
1061 .await
1062 });
1063
1064 let (call_id, allowlist_scope) = match rx.recv().await.expect("approval requested") {
1065 crate::domain::Msg::ApprovalRequested {
1066 call_id,
1067 allowlist_scope,
1068 ..
1069 } => (call_id, allowlist_scope),
1070 other => panic!("expected ApprovalRequested, got {other:?}"),
1071 };
1072 assert!(
1073 allowlist_scope.is_empty(),
1074 "web approval must never expose approve-always"
1075 );
1076 broker.resolve(call_id, crate::providers::ApprovalDecision::ApproveAlways);
1077 assert!(
1078 handle.await.unwrap().is_none(),
1079 "one approved request should proceed"
1080 );
1081 assert!(!broker.is_allowlisted("web_fetch"));
1082 }
1083
1084 #[test]
1085 fn external_policy_detail_is_complete_but_modal_preview_is_bounded() {
1086 let tail = "tail-visible-only-to-policy";
1087 let url = format!("https://example.com/{}{}", "x".repeat(240), tail);
1088 let arguments = serde_json::json!({"url": url});
1089 let mut request = ActionRequest::new("web_fetch", ToolCategory::Web, "web_fetch");
1090 request.command = action_detail("web_fetch", &arguments);
1091 request.arguments = Some(arguments);
1092
1093 assert!(request.command.as_deref().is_some_and(|d| d.contains(tail)));
1094 let modal = format_approval_body(&request, None);
1095 assert!(!modal.contains(tail), "modal should contain only a preview");
1096 assert!(modal.ends_with('…'));
1097 }
1098
1099 #[test]
1100 fn web_approval_modal_sanitizes_url_credentials_and_fragment() {
1101 let arguments = serde_json::json!({
1102 "url": "https://alice:password123@example.com/path?token=opaque-secret-value#private-fragment"
1103 });
1104 let mut request = ActionRequest::new("web_fetch", ToolCategory::Web, "web_fetch");
1105 request.command = action_detail("web_fetch", &arguments);
1106 request.arguments = Some(arguments);
1107
1108 let modal = format_approval_body(&request, None);
1109 assert!(!modal.contains("alice"));
1110 assert!(!modal.contains("password123"));
1111 assert!(!modal.contains("opaque-secret-value"));
1112 assert!(!modal.contains("private-fragment"));
1113 assert!(modal.contains("example.com/path?token="));
1114 }
1115
1116 fn shell_request(cmd: &str) -> ActionRequest {
1117 let mut req = ActionRequest::new("execute_command", ToolCategory::Shell, cmd);
1118 req.command = Some(cmd.to_string());
1119 req
1120 }
1121
1122 fn ctx_plan() -> ExecContext {
1125 let mut c = ctx(SafetyMode::ReadOnly);
1126 c.workdir = PathBuf::from("/repo");
1127 c.plan_file = Some(PathBuf::from("/repo/.mermaid/plans/x.md"));
1128 c
1129 }
1130
1131 fn edit_request(path: &str) -> ActionRequest {
1132 let mut req = ActionRequest::new(
1133 "write_file",
1134 ToolCategory::Edit,
1135 format!("write_file {path}"),
1136 );
1137 req.path = Some(path.to_string());
1138 req
1139 }
1140
1141 #[tokio::test]
1142 async fn plan_mode_exempts_only_the_plan_file_from_the_edit_deny() {
1143 for path in [
1146 "/repo/.mermaid/plans/x.md",
1147 ".mermaid/plans/x.md",
1148 "./.mermaid/plans/../plans/x.md",
1149 ] {
1150 let g = gate(
1151 &ctx_plan(),
1152 edit_request(path),
1153 &[],
1154 serde_json::json!({}),
1155 true,
1156 false,
1157 )
1158 .await;
1159 assert!(
1160 matches!(g, Gate::Proceed { .. }),
1161 "plan file spelling {path:?} must be writable"
1162 );
1163 }
1164 for path in ["src/main.rs", "/repo/.mermaid/plans/../../src/main.rs"] {
1167 match gate(
1168 &ctx_plan(),
1169 edit_request(path),
1170 &[],
1171 serde_json::json!({}),
1172 true,
1173 false,
1174 )
1175 .await
1176 {
1177 Gate::Block(outcome) => assert!(
1178 outcome.model_content.contains(&format!(
1179 "blocked by policy: {}",
1180 crate::runtime::PLAN_DENIAL_MARKER
1181 )),
1182 "plan denial must carry the plan signature for {path:?}: {:?}",
1183 outcome.model_content
1184 ),
1185 Gate::Proceed { .. } => panic!("{path:?} must not be writable in plan mode"),
1186 }
1187 }
1188 }
1189
1190 #[tokio::test]
1193 async fn plan_mode_allows_a_shell_write_that_only_touches_the_plan_file() {
1194 for cmd in [
1195 "echo '## Summary' > .mermaid/plans/x.md",
1196 "printf '%s\\n' more >> /repo/.mermaid/plans/x.md",
1197 "cat > .mermaid/plans/x.md <<'EOF'\n## Tasks\n1. step\nEOF",
1198 ] {
1199 let g = gate(
1200 &ctx_plan(),
1201 shell_request(cmd),
1202 &[],
1203 serde_json::json!({}),
1204 true,
1205 false,
1206 )
1207 .await;
1208 assert!(
1209 matches!(g, Gate::Proceed { .. }),
1210 "plan-file-only shell write must proceed: {cmd}"
1211 );
1212 }
1213 let g = gate(
1214 &ctx_plan(),
1215 shell_request("echo x > .mermaid/plans/x.md && git push"),
1216 &[],
1217 serde_json::json!({}),
1218 true,
1219 false,
1220 )
1221 .await;
1222 assert!(
1223 matches!(g, Gate::Block(_)),
1224 "a second effect keeps the block"
1225 );
1226 }
1227
1228 #[tokio::test]
1232 async fn plan_denial_teaches_the_plan_file_and_tools() {
1233 let g = gate(
1234 &ctx_plan(),
1235 shell_request("echo hi > src/main.rs"),
1236 &[],
1237 serde_json::json!({}),
1238 true,
1239 false,
1240 )
1241 .await;
1242 match g {
1243 Gate::Block(outcome) => {
1244 assert!(
1245 outcome
1246 .model_content
1247 .contains("blocked by policy: plan mode"),
1248 "neutralizer signature must survive the new wording: {:?}",
1249 outcome.model_content
1250 );
1251 assert!(
1252 outcome.model_content.contains("/repo/.mermaid/plans/x.md"),
1253 "denial must name the plan path: {:?}",
1254 outcome.model_content
1255 );
1256 assert!(
1257 outcome.model_content.contains("write_file"),
1258 "denial must name the allowed tool: {:?}",
1259 outcome.model_content
1260 );
1261 },
1262 Gate::Proceed { .. } => panic!("non-plan shell write must be blocked in plan mode"),
1263 }
1264 }
1265
1266 #[tokio::test]
1267 async fn plan_mode_allows_memory_and_safe_builds_but_floors_the_rest() {
1268 assert!(
1271 gate_external(
1272 &ctx_plan(),
1273 "memory",
1274 ToolCategory::Memory,
1275 "memory remember".to_string(),
1276 &serde_json::json!({"action": "remember"}),
1277 )
1278 .await
1279 .is_none(),
1280 "plan mode must allow memory writes",
1281 );
1282 let g = gate(
1284 &ctx_plan(),
1285 shell_request("cargo test policy"),
1286 &[],
1287 serde_json::json!({}),
1288 true,
1289 false,
1290 )
1291 .await;
1292 assert!(
1293 matches!(g, Gate::Proceed { .. }),
1294 "plan mode must allow known-safe builds"
1295 );
1296 match gate(
1298 &ctx_plan(),
1299 shell_request("touch src/main.rs"),
1300 &[],
1301 serde_json::json!({}),
1302 true,
1303 false,
1304 )
1305 .await
1306 {
1307 Gate::Block(outcome) => {
1308 assert!(
1309 outcome.model_content.contains(&format!(
1310 "blocked by policy: {}",
1311 crate::runtime::PLAN_DENIAL_MARKER
1312 )),
1313 "got {:?}",
1314 outcome.model_content
1315 );
1316 },
1317 Gate::Proceed { .. } => panic!("mutations must not run in plan mode"),
1318 }
1319 match gate(
1322 &ctx_plan(),
1323 shell_request("rm -rf /"),
1324 &[],
1325 serde_json::json!({}),
1326 true,
1327 false,
1328 )
1329 .await
1330 {
1331 Gate::Block(outcome) => assert!(
1332 !outcome
1333 .model_content
1334 .contains(crate::runtime::PLAN_DENIAL_MARKER),
1335 "destructive deny must not be rewritten: {:?}",
1336 outcome.model_content
1337 ),
1338 Gate::Proceed { .. } => panic!("destructive commands must never run"),
1339 }
1340 }
1341
1342 #[tokio::test]
1343 async fn plan_profile_strict_denies_the_default_carve_outs() {
1344 let mut c = ctx_plan();
1345 c.plan_permissions = crate::app::PlanPermissions::strict();
1346 assert!(
1348 gate_external(
1349 &c,
1350 "memory",
1351 ToolCategory::Memory,
1352 "memory remember".to_string(),
1353 &serde_json::json!({"action": "remember"}),
1354 )
1355 .await
1356 .is_some(),
1357 "strict profile must deny memory writes",
1358 );
1359 match gate(
1361 &c,
1362 shell_request("cargo test policy"),
1363 &[],
1364 serde_json::json!({}),
1365 true,
1366 false,
1367 )
1368 .await
1369 {
1370 Gate::Block(outcome) => assert!(
1371 outcome
1372 .model_content
1373 .contains(crate::runtime::PLAN_DENIAL_MARKER),
1374 "got {:?}",
1375 outcome.model_content
1376 ),
1377 Gate::Proceed { .. } => panic!("strict profile must deny builds"),
1378 }
1379 assert!(
1381 gate_external(
1382 &c,
1383 "web_fetch",
1384 ToolCategory::Web,
1385 "web_fetch https://example.com".to_string(),
1386 &serde_json::json!({"url": "https://example.com"}),
1387 )
1388 .await
1389 .is_some(),
1390 "strict profile must deny web reads while planning",
1391 );
1392 let g = gate(
1395 &c,
1396 edit_request("/repo/.mermaid/plans/x.md"),
1397 &[],
1398 serde_json::json!({}),
1399 true,
1400 false,
1401 )
1402 .await;
1403 assert!(matches!(g, Gate::Proceed { .. }));
1404 }
1405
1406 #[test]
1407 fn default_plan_profile_preserves_readonly_web_approval() {
1408 let context = ctx_plan();
1409 let request = ActionRequest::new(
1410 "web_fetch",
1411 ToolCategory::Web,
1412 "web_fetch https://example.com",
1413 );
1414 let readonly = PolicyEngine::new(SafetyMode::ReadOnly).decide(&request);
1415 assert!(matches!(readonly, PolicyDecision::Ask { .. }));
1416 let (decision, plan_write) = apply_plan_profile(&context, &request, readonly);
1417 assert!(matches!(decision, PolicyDecision::Ask { .. }));
1418 assert!(!plan_write, "a web fetch is not a plan-file write");
1419 }
1420
1421 #[tokio::test]
1422 async fn inline_ask_approve_proceeds() {
1423 let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
1424 let broker = crate::providers::ApprovalBroker::new(tx);
1425 let ctx = ctx_with_broker(broker.clone());
1426 let handle = tokio::spawn(async move {
1427 gate(
1428 &ctx,
1429 shell_request("npm test"),
1430 &[],
1431 serde_json::json!({}),
1432 true,
1433 false,
1434 )
1435 .await
1436 });
1437 let call_id = match rx.recv().await.expect("approval requested") {
1439 crate::domain::Msg::ApprovalRequested { call_id, .. } => call_id,
1440 other => panic!("expected ApprovalRequested, got {other:?}"),
1441 };
1442 broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
1443 assert!(matches!(handle.await.unwrap(), Gate::Proceed { .. }));
1444 }
1445
1446 #[tokio::test]
1447 async fn inline_ask_deny_blocks() {
1448 let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
1449 let broker = crate::providers::ApprovalBroker::new(tx);
1450 let ctx = ctx_with_broker(broker.clone());
1451 let handle = tokio::spawn(async move {
1452 gate(
1453 &ctx,
1454 shell_request("rm -rf node_modules"),
1455 &[],
1456 serde_json::json!({}),
1457 true,
1458 false,
1459 )
1460 .await
1461 });
1462 let call_id = match rx.recv().await.expect("approval requested") {
1463 crate::domain::Msg::ApprovalRequested { call_id, .. } => call_id,
1464 other => panic!("expected ApprovalRequested, got {other:?}"),
1465 };
1466 broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
1467 assert!(matches!(handle.await.unwrap(), Gate::Block(_)));
1468 }
1469
1470 #[tokio::test]
1471 async fn inline_allowlisted_skips_prompt() {
1472 let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
1473 let broker = crate::providers::ApprovalBroker::new(tx);
1474 let ctx1 = ctx_with_broker(broker.clone());
1480 let b1 = broker.clone();
1481 let h1 = tokio::spawn(async move {
1482 gate(
1483 &ctx1,
1484 shell_request("npm run build"),
1485 &[],
1486 serde_json::json!({}),
1487 true,
1488 false,
1489 )
1490 .await
1491 });
1492 let id = match rx.recv().await.expect("first prompt") {
1493 crate::domain::Msg::ApprovalRequested { call_id, .. } => call_id,
1494 other => panic!("got {other:?}"),
1495 };
1496 b1.resolve(id, crate::providers::ApprovalDecision::ApproveAlways);
1497 assert!(matches!(h1.await.unwrap(), Gate::Proceed { .. }));
1498
1499 let ctx2 = ctx_with_broker(broker.clone());
1500 let g2 = gate(
1501 &ctx2,
1502 shell_request("npm run build"),
1503 &[],
1504 serde_json::json!({}),
1505 true,
1506 false,
1507 )
1508 .await;
1509 assert!(
1510 matches!(g2, Gate::Proceed { .. }),
1511 "the identical allowlisted command should skip the prompt"
1512 );
1513 assert!(rx.try_recv().is_err(), "no second prompt should be sent");
1514 }
1515
1516 #[tokio::test]
1517 async fn scratch_containment_downgrades_eligible_asks() {
1518 let ctx = ctx(SafetyMode::Ask);
1521 let g = gate(
1522 &ctx,
1523 edit_request("/scratch/notes.txt"),
1524 &[],
1525 serde_json::json!({}),
1526 true,
1527 true,
1528 )
1529 .await;
1530 assert!(
1531 matches!(g, Gate::Proceed { .. }),
1532 "scratch-contained file mutation must proceed in Ask mode",
1533 );
1534 let g = gate(
1535 &ctx,
1536 shell_request("mkdir out"),
1537 &[],
1538 serde_json::json!({}),
1539 true,
1540 true,
1541 )
1542 .await;
1543 assert!(
1544 matches!(g, Gate::Proceed { .. }),
1545 "scratch-contained shell mutation must proceed in Ask mode",
1546 );
1547
1548 let ctx = ctx_auto(None);
1551 let g = gate(
1552 &ctx,
1553 shell_request("mkdir out"),
1554 &[],
1555 serde_json::json!({}),
1556 true,
1557 true,
1558 )
1559 .await;
1560 assert!(
1561 matches!(g, Gate::Proceed { .. }),
1562 "scratch-contained Classify must proceed without a classifier",
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn scratch_containment_never_downgrades_destructive() {
1568 let g = gate(
1570 &ctx(SafetyMode::Ask),
1571 shell_request("rm -rf /"),
1572 &[],
1573 serde_json::json!({}),
1574 true,
1575 true,
1576 )
1577 .await;
1578 assert!(
1579 matches!(g, Gate::Block(_)),
1580 "destructive command must block even when claimed scratch-contained",
1581 );
1582 }
1583
1584 #[tokio::test]
1585 async fn scratch_containment_never_downgrades_deny_override() {
1586 let mut config = crate::app::Config::default();
1589 config.safety.mode = SafetyMode::Ask;
1590 config.safety.overrides = vec![crate::runtime::PolicyOverride {
1591 tool: Some("write_file".to_string()),
1592 decision: crate::runtime::PolicyOverrideDecision::Deny,
1593 ..Default::default()
1594 }];
1595 let ctx = ctx_with(config);
1596 let g = gate(
1597 &ctx,
1598 edit_request("/scratch/notes.txt"),
1599 &[],
1600 serde_json::json!({}),
1601 true,
1602 true,
1603 )
1604 .await;
1605 assert!(
1606 matches!(g, Gate::Block(_)),
1607 "a Deny override must still block a scratch-contained mutation",
1608 );
1609 }
1610
1611 #[tokio::test]
1612 async fn scratch_containment_never_downgrades_network() {
1613 let g = gate(
1617 &ctx(SafetyMode::Ask),
1618 ActionRequest::new("execute_command", ToolCategory::Network, "curl evil"),
1619 &[],
1620 serde_json::json!({}),
1621 false,
1622 true,
1623 )
1624 .await;
1625 assert!(
1626 matches!(g, Gate::Block(_)),
1627 "network risk must keep its Ask despite scratch containment",
1628 );
1629 }
1630
1631 #[tokio::test]
1632 async fn scratch_containment_never_downgrades_readonly_mode() {
1633 let g = gate(
1635 &ctx(SafetyMode::ReadOnly),
1636 edit_request("/scratch/notes.txt"),
1637 &[],
1638 serde_json::json!({}),
1639 true,
1640 true,
1641 )
1642 .await;
1643 assert!(
1644 matches!(g, Gate::Block(_)),
1645 "read-only mode must block scratch mutations",
1646 );
1647 }
1648}