Skip to main content

mermaid_cli/providers/tool/
policy_gate.rs

1//! Central safety-policy gate shared by every tool that can mutate the
2//! workspace, touch the network, drive the desktop, or spawn work.
3//!
4//! Before v0.7.x the `PolicyEngine` was only consulted by `execute_command`
5//! and the filesystem mutators; `web_*`, `mcp`, `subagent`, and the
6//! computer-use tools ran their bodies with no policy check at all, so
7//! `SafetyMode::ReadOnly` silently failed to block them. This module is the
8//! single choke point: every dangerous tool builds an [`ActionRequest`] and
9//! calls [`gate`] before acting.
10//!
11//! `replayable` distinguishes the two enforcement shapes:
12//!
13//! - **Replayable tools** (`execute_command`, file mutators): an `Ask` (or an
14//!   escalated Auto-mode `Classify`) decision creates a checkpoint + an
15//!   approval row and BLOCKS, returning an "approval required" outcome. The
16//!   action is later re-run out-of-band by [`crate::runtime::approve_and_replay`].
17//! - **Non-replayable tools** (`web_*`, `mcp`, `subagent`, computer-use):
18//!   there is no checkpoint/replay path, so an `Ask` decision resolves
19//!   inline when an approval broker is bound and otherwise fails closed
20//!   unless the run opted in via `--allow-untrusted-tools`. `ReadOnly` denies
21//!   mutations and control actions, permits inherited-safety subagent spawn,
22//!   and requires one-shot approval for externally observable Web egress.
23//!
24//! `Auto` mode resolves a `PolicyDecision::Classify` here by awaiting the
25//! injected `AutoClassifier` (in `ctx.classifier`): aligned ⇒ proceed,
26//! otherwise escalate — a replayable tool to a human approval, a non-replayable
27//! tool to a block (it can't be replayed). A missing classifier or any vet
28//! failure fails safe to escalate. This is why `gate` is `async`.
29
30use 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
41/// Result of consulting the policy for a tool action.
42pub enum Gate {
43    /// The tool may run. `risk` is the classified risk (callers that take
44    /// their own post-approval checkpoint, like `execute_command`, use it to
45    /// decide whether to snapshot). `plan_write` records that the approval
46    /// came from plan mode's plan-file carve-out, so the caller can stamp
47    /// `ToolRunMetadata::plan_file_written` instead of guessing from the tool
48    /// name.
49    Proceed { risk: RiskClass, plan_write: bool },
50    /// The tool must NOT run; return this outcome verbatim to the model.
51    Block(ToolOutcome),
52}
53
54/// Convenience for non-replayable tools (`web_*`, `mcp`, `subagent`,
55/// computer-use): consult the policy and return `Some(outcome)` when the
56/// action is blocked (e.g. `ReadOnly`/`Deny` override), or `None` to proceed.
57/// These tools have no checkpoint/replay path: an `Ask` decision resolves
58/// inline when an approval broker is bound and otherwise fails closed unless
59/// `--allow-untrusted-tools`. `ReadOnly` blocks mutations/control actions,
60/// allows inherited-safety subagent spawn, and asks once for Web egress. Call
61/// this at the very top of `execute()`.
62pub 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
72/// MCP variant of [`gate_external`]: carries the server-advertised
73/// `readOnlyHint` so the policy's external-writes floor can tell read-shaped
74/// calls from write-shaped ones. Pass `false` when the hint is unknown.
75pub 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    // Surface a concrete, content-bearing detail (the text being typed, the URL
114    // being fetched, the MCP server__tool + args). Without this the Auto-mode
115    // classifier and the human approval prompt see only the tool name and a
116    // generic summary — so they can't actually vet *what* the action does
117    // (#29, #30, #31).
118    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    // `scratch_contained` is always false here: external actions (network,
123    // desktop, MCP, subagents) act OUTSIDE the filesystem, so scratchpad
124    // containment can never be proven for them.
125    match gate(ctx, request, &[], pending, false, false).await {
126        Gate::Block(outcome) => Some(outcome),
127        Gate::Proceed { .. } => None,
128    }
129}
130
131/// Build the complete content-bearing detail for policy matching and the Auto
132/// classifier. This value is deliberately not clipped: presentation limits are
133/// applied only when rendering an approval modal.
134fn 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            // Surface the real query text so the Auto classifier and the human
165            // approval modal can catch exfiltration-via-query (`evil.com?leak=
166            // <secret>`) — not just a count. Handles both the single-`query` and
167            // `queries[]` shapes `web.rs` accepts (#30).
168            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            // The subagent prompt is model-authored and can carry injection or
186            // exfiltration; surface it so the reviewer vets the real task, not
187            // the short label (#31).
188            Some(format!("agent: {}", s("prompt")?))
189        },
190        _ => None,
191    }
192}
193
194/// Consult the safety policy for `request`. See the module docs for the
195/// `replayable` semantics.
196///
197/// `scratch_contained` is true when the caller has PROVEN the action touches
198/// only the session scratchpad (`ResolvedInRoot::in_scratchpad` for the file
199/// mutators, `command_provably_in_scratch` for the exec tool). Scratch files
200/// are session-private and ephemeral, so — like durable memory in
201/// `PolicyEngine::decide` — an `Ask`/`Classify` on an eligible risk class is
202/// downgraded to proceed. The downgrade never touches `Deny` (a user `Deny`
203/// override, read-only mode, and the destructive hard-deny all still block)
204/// and never applies to risks that act beyond the filesystem
205/// ([`scratch_downgrade_eligible`]).
206pub 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    // Build from the LIVE session mode (Shift+Tab / `/safety` take effect
215    // immediately), not the static config snapshot.
216    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    // Plan mode: the reducer floors `ctx.safety_mode` to `ReadOnly` while a
223    // plan is being drafted, so the engine's mode-default deny covers
224    // everything — then the per-category profile decides how far each
225    // carve-out opens. Keying on the deny REASON (the read-only marker)
226    // keeps the precedence ladder intact: a user `Deny` override and the
227    // destructive hard-deny carry different reasons and still win.
228    // `plan_write` is the FACT that this action's approval WAS the plan-file
229    // carve-out — recorded here, where the reason is still known, so callers
230    // never have to re-derive it from the tool name (see
231    // `ToolRunMetadata::plan_file_written`).
232    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    // Explicit user/session opt-in restores unattended public-web reads in
239    // ReadOnly. It may only soften the mode-generated Ask: global network deny
240    // is enforced before this function, while policy/plan Deny decisions remain
241    // untouched. Project config cannot set this flag.
242    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    // Scratchpad downgrade: an Ask/Classify on a proven scratch-only action
258    // proceeds without a prompt. Deny is deliberately not matched — it falls
259    // through to the arm below and blocks, so plan mode's read-only floor (a
260    // Deny) keeps blocking scratch mutations while a plan is being drafted.
261    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                // Interactive: prompt the user inline. This works for
276                // replayable AND non-replayable tools — approval runs the
277                // action now, so no out-of-band replay is needed (fixes the
278                // old non-replayable bypass).
279                inline_decision(ctx, broker, &request, risk, None).await
280            } else if !replayable {
281                // Headless non-replayable (web/mcp/subagent/computer_use): no
282                // checkpoint/replay path, so an Ask can't be satisfied
283                // out-of-band. Fail closed by default — only proceed when the
284                // run explicitly opted in via `--allow-untrusted-tools`.
285                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            // Auto mode: an LLM vets the borderline action against the user's
315            // intent. Aligned ⇒ proceed; otherwise escalate (fail-safe).
316            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                // Interactive: escalate to an inline prompt carrying the reason.
337                inline_decision(ctx, broker, &request, risk, Some(verdict.reason)).await
338            } else if replayable {
339                // Headless: escalate to a human approval the user can replay.
340                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                // Non-replayable + headless: block with the reason for the model.
351                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
367/// The plan-flavored teaching denial. Its reason starts with
368/// [`crate::runtime::PLAN_DENIAL_MARKER`] so the history neutralizer can
369/// retire it once plan mode ends — and it must name the escape hatch:
370/// without the plan path and the allowed tools in the error, models
371/// generalize "writes are blocked" and doom-loop through shell probes
372/// instead of calling `write_file` (observed for 7+ minutes on a real
373/// session).
374fn 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
388/// Map one profile level onto a policy decision. `checkpoint: false`
389/// throughout — nothing in plan mode mutates the tree, so there is nothing
390/// to snapshot.
391fn 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
414/// Apply the plan permission profile on top of the read-only floor's
415/// decision: soften the mode-default deny per category (plan file, memory,
416/// known-safe builds), and apply the explicit Web permission over the floor's
417/// default — including ReadOnly's one-shot approval, which the profile may
418/// tighten or relax. Override denies and the destructive hard-deny carry
419/// different reasons and pass through untouched.
420///
421/// The returned flag is `true` when the allowance came from the plan-file
422/// carve-out — either spelling, `write_file`/`apply_patch` on the plan path or
423/// a shell redirect that provably writes only it. Callers stamp it onto the
424/// outcome so nothing downstream has to re-derive "was that a plan write?"
425/// from the tool name.
426fn 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            // Command-relative paths resolve against the directory the action
439            // actually runs in (an explicit `working_dir`), not the project
440            // root — otherwise the carve-out approves a write that lands
441            // somewhere else. `Edit` paths are already project-rooted.
442            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                // Authoring the plan IS plan mode — not a profile category.
450                (
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                // The shell spelling of plan authoring (`echo … > plan.md`,
465                // `cat > plan.md <<'EOF'`) — same exemption as the Edit
466                // path above, same no-checkpoint rationale.
467                (
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
496/// Risk classes whose `Ask`/`Classify` may be downgraded to proceed when the
497/// action is proven scratch-contained. File and shell mutations confined to
498/// the scratchpad can only touch session-private throwaway files; everything
499/// stronger acts beyond the filesystem and keeps its normal gating:
500/// `Network` can exfiltrate regardless of cwd, `Process`/`ExternalAccess`
501/// control things outside any directory, and `Destructive` is hard-denied
502/// upstream anyway.
503fn 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
513/// Interactive approval: check the session "don't ask again" allowlist, else
514/// prompt the user (parking the tool task) and map their answer to a `Gate`.
515/// Approval runs the action inline, so the tool's own Proceed-path checkpoint
516/// covers restorability — no DB approval row / replay needed.
517async 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    // An empty key marks a non-allowlistable action — always prompt, never
526    // match a stored entry (#6, #31).
527    // `plan_write: false` throughout this function: the plan-file carve-out
528    // resolves to `Allow` in `apply_plan_profile` and never reaches an
529    // approval path, so anything approved here is by definition not a plan
530    // write.
531    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
567/// Build the modal body: the concrete command/path being run, plus any
568/// Auto-review reason. Kept here so the render layer stays dumb.
569fn 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        // A `$ ` prefix reads as "shell command"; only use it for actual shell
588        // categories. Computer-use / MCP details (`type_text "…"`, `mcp s__t(…)`)
589        // render verbatim so the prompt isn't misleading (#30, #31).
590        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        // Read and Memory ⇒ Allow/Deny in `decide`, so neither reaches
618        // approval; keep the match total.
619        C::Read | C::Memory => ApprovalKind::Shell,
620    }
621}
622
623/// Take a checkpoint (when configured), record an approval row, and return a
624/// blocking "approval required" outcome. Mirrors the pre-existing inline logic
625/// from `exec.rs`/`filesystem.rs` so behavior is unchanged for those tools.
626#[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    // When the escalation came from the Auto-mode classifier, its reason —
635    // recorded on the approval so the user sees *why* it was flagged.
636    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        // #30/#31: the classifier + approval modal must see the real content,
751        // not just a count/label.
752        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        // #3: web/mcp/subagent/computer_use on an Ask decision with no approval
784        // UI is blocked by default, allowed only with the opt-in flag.
785        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    /// Stub classifier with a fixed verdict — drives the `Classify` path
811    /// without a real model call.
812    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        // C1/H1/H2: mutations and control tools remain denied in ReadOnly.
837        // Web is tested separately because it takes the one-shot Ask path.
838        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        // Subagent spawn is the exception: the child inherits the live
852        // read_only mode and every child tool call is re-gated, so the spawn
853        // itself is allowed — read-only fan-out is the tool's core use.
854        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        // The load-bearing "no modal" guarantee: memory is Allowed in ask /
930        // auto / full, so gate_external returns None (proceed) and the
931        // approval broker is never consulted. Only read-only blocks it.
932        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        // Auto + classifier says ALLOW ⇒ a borderline external tool proceeds.
981        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        // Auto + classifier says ESCALATE ⇒ a non-replayable tool is blocked.
999        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        // Auto but no classifier bound ⇒ fail safe (escalate ⇒ block), never
1017        // silently allow.
1018        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    /// Build an `Ask`-mode ctx with an inline-approval broker bound.
1044    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    /// Plan-mode ctx: the reducer floors the mode to ReadOnly and stamps the
1123    /// plan file; mirror both here.
1124    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        // The exact plan file passes — absolute, workdir-relative, and a
1144        // lexically-normalizable spelling of the same path.
1145        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        // Any other file — including a `..` smuggle THROUGH the plans dir —
1165        // is denied with the plan-flavored reason the neutralizer keys on.
1166        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    /// The shell spelling of plan authoring is allowed; anything with a
1191    /// second effect keeps the plan denial.
1192    #[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    /// The plan denial is a TEACHING error: it must name the plan file and
1229    /// the tools that can write it (the escape hatch), while still starting
1230    /// with the exact signature the history neutralizer keys on.
1231    #[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        // Memory writes: allowed while planning (exploration feeds memory)
1269        // even though bare ReadOnly denies them.
1270        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        // Known-safe build: allowed.
1283        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        // Arbitrary mutation: denied with the plan-flavored reason.
1297        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        // The destructive hard-deny outranks the plan carve-outs and keeps
1320        // its own reason (no plan marker — it is not mode-dependent).
1321        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        // Memory: default-allow flips to the plan deny.
1347        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        // Builds: default-allow flips to the plan deny.
1360        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        // Web: the read-only floor asks; the strict profile tightens to deny.
1380        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        // The plan file stays writable regardless — authoring the plan IS
1393        // plan mode.
1394        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        // Observe the prompt, then approve it.
1438        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        // Approve-always once so the key is allowlisted, then the SAME command
1475        // must proceed WITHOUT emitting another prompt. (#6: execute_command
1476        // keys on the full normalized command, so only an identical command is
1477        // cleared — a different-argument command re-prompts; that distinction is
1478        // covered by the `allowlist_key` unit tests.)
1479        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        // Ask mode with NO broker: an un-downgraded replayable Ask would go to
1519        // `block_for_approval`, so a Proceed here proves the downgrade fired.
1520        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        // Auto mode with NO classifier: an un-downgraded Classify fails safe
1549        // to escalate, so a Proceed proves the Classify downgrade too.
1550        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        // The destructive hard-deny outranks everything, scratchpad included.
1569        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        // A user-configured Deny override yields PolicyDecision::Deny, which
1587        // the downgrade deliberately never touches.
1588        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        // Network risk is not scratch-eligible: exfiltration doesn't care
1614        // about the cwd. Headless non-replayable Ask fails closed, proving
1615        // the Ask was NOT downgraded to a Proceed.
1616        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        // Read-only mode denies mutations outright; Deny is never downgraded.
1634        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}