use std::path::PathBuf;
use crate::domain::{ApprovalKind, ToolOutcome};
use crate::providers::{ApprovalBroker, ApprovalDecision, allowlist_key};
use crate::runtime::{
ActionRequest, NewApproval, PolicyDecision, PolicyEngine, RiskClass, RuntimeStore,
create_checkpoint_for_task, run_plugin_hooks,
};
use super::super::ctx::ExecContext;
pub enum Gate {
Proceed { risk: RiskClass },
Block(ToolOutcome),
}
pub async fn gate_external(
ctx: &ExecContext,
tool: &'static str,
category: crate::runtime::ToolCategory,
summary: String,
args: &serde_json::Value,
) -> Option<ToolOutcome> {
gate_external_inner(ctx, tool, category, summary, args, false).await
}
pub async fn gate_external_mcp(
ctx: &ExecContext,
summary: String,
args: &serde_json::Value,
read_only_hint: bool,
) -> Option<ToolOutcome> {
gate_external_inner(
ctx,
"mcp_proxy",
crate::runtime::ToolCategory::Mcp,
summary,
args,
read_only_hint,
)
.await
}
async fn gate_external_inner(
ctx: &ExecContext,
tool: &'static str,
category: crate::runtime::ToolCategory,
summary: String,
args: &serde_json::Value,
mcp_read_only_hint: bool,
) -> Option<ToolOutcome> {
let mut request = ActionRequest::new(tool, category, summary);
request.command = action_detail(tool, args);
request.mcp_read_only_hint = mcp_read_only_hint;
let pending = serde_json::json!({ "tool": tool, "args": args });
match gate(ctx, request, &[], pending, false, false).await {
Gate::Block(outcome) => Some(outcome),
Gate::Proceed { .. } => None,
}
}
fn action_detail(tool: &str, args: &serde_json::Value) -> Option<String> {
fn clip(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let end = s.floor_char_boundary(max);
format!("{}…", &s[..end])
}
let s = |k: &str| args.get(k).and_then(|v| v.as_str());
let i = |k: &str| args.get(k).and_then(|v| v.as_i64());
match tool {
"type_text" => Some(format!("type_text {:?}", clip(s("text")?, 200))),
"press_key" => Some(format!("press_key {}", s("key").or_else(|| s("keys"))?)),
"click" | "mouse_move" => match (i("x"), i("y")) {
(Some(x), Some(y)) => Some(format!("{tool} ({x}, {y})")),
_ => None,
},
"scroll" => {
let dir = s("direction").unwrap_or("");
Some(
format!("scroll {dir} {}", i("amount").unwrap_or(0))
.trim()
.to_string(),
)
},
"web_fetch" => Some(format!("web_fetch {}", clip(s("url")?, 200))),
"mcp_proxy" => {
let server = s("server_name").unwrap_or("?");
let name = s("tool_name").unwrap_or("?");
let arg_preview = args
.get("arguments")
.filter(|a| !a.is_null())
.map(|a| clip(&a.to_string(), 200))
.unwrap_or_default();
Some(format!("mcp {server}__{name}({arg_preview})"))
},
"web_search" => {
let queries: Vec<String> = if let Some(q) = s("query") {
vec![q.to_string()]
} else if let Some(arr) = args.get("queries").and_then(|v| v.as_array()) {
arr.iter()
.filter_map(|e| e.get("query").and_then(|v| v.as_str()))
.map(str::to_string)
.collect()
} else {
Vec::new()
};
if queries.is_empty() {
None
} else {
Some(clip(&format!("web_search {}", queries.join(" | ")), 200))
}
},
"agent" => {
Some(format!("agent: {}", clip(s("prompt")?, 200)))
},
_ => None,
}
}
pub async fn gate(
ctx: &ExecContext,
request: ActionRequest,
checkpoint_paths: &[PathBuf],
pending_action: serde_json::Value,
replayable: bool,
scratch_contained: bool,
) -> Gate {
let decision = PolicyEngine::new(ctx.safety_mode)
.with_overrides(ctx.config.safety.overrides.clone())
.with_external_writes(ctx.config.safety.external_writes)
.with_system_installs(ctx.config.safety.system_installs)
.decide(&request);
let decision = if ctx.plan_file.is_some() {
apply_plan_profile(ctx, &request, decision)
} else {
decision
};
if scratch_contained
&& let PolicyDecision::Ask { risk, .. } | PolicyDecision::Classify { risk, .. } = decision
&& scratch_downgrade_eligible(risk)
{
return Gate::Proceed { risk };
}
match decision {
PolicyDecision::Allow { risk, .. } => Gate::Proceed { risk },
PolicyDecision::Ask { risk, checkpoint } => {
if let Some(broker) = &ctx.approval {
inline_decision(ctx, broker, &request, risk, None).await
} else if !replayable {
if ctx.config.safety.allow_untrusted_headless_tools {
tracing::debug!(
tool = %request.tool,
"policy Ask on non-replayable tool; proceeding (--allow-untrusted-tools)",
);
Gate::Proceed { risk }
} else {
Gate::Block(ToolOutcome::error(
format!(
"{} requires approval, but this is a headless run with no approval UI. \
Re-run with --allow-untrusted-tools, or use a safety mode of auto/full_access.",
request.summary
),
0.0,
))
}
} else {
block_for_approval(
ctx,
&request,
checkpoint,
checkpoint_paths,
pending_action,
risk,
None,
)
}
},
PolicyDecision::Classify { risk, checkpoint } => {
let verdict = match &ctx.classifier {
Some(classifier) => {
let vreq = crate::providers::VetRequest {
tool: request.tool.clone(),
summary: request.summary.clone(),
command: request.command.clone(),
path: request.path.clone(),
intent: ctx.intent.clone(),
workdir: ctx.workdir.display().to_string(),
turn: ctx.turn,
token: ctx.token.clone(),
};
classifier.vet(&vreq).await
},
None => crate::providers::VetVerdict::escalate("no Auto-mode classifier available"),
};
if verdict.allow {
Gate::Proceed { risk }
} else if let Some(broker) = &ctx.approval {
inline_decision(ctx, broker, &request, risk, Some(verdict.reason)).await
} else if replayable {
block_for_approval(
ctx,
&request,
checkpoint,
checkpoint_paths,
pending_action,
risk,
Some(verdict.reason),
)
} else {
Gate::Block(ToolOutcome::error(
format!(
"{} blocked by Auto-mode safety review: {}",
request.summary, verdict.reason
),
0.0,
))
}
},
PolicyDecision::Deny { reason, .. } => Gate::Block(ToolOutcome::error(
format!("{} blocked by policy: {}", request.summary, reason),
0.0,
)),
}
}
fn plan_deny(risk: RiskClass) -> PolicyDecision {
PolicyDecision::Deny {
risk,
reason: format!(
"{} is active — planning only; capture this change in the plan \
instead of performing it now",
crate::runtime::PLAN_DENIAL_MARKER
),
}
}
fn plan_level_decision(level: crate::app::PlanPermLevel, risk: RiskClass) -> PolicyDecision {
use crate::app::PlanPermLevel as L;
match level {
L::Allow => PolicyDecision::Allow {
risk,
checkpoint: false,
},
L::Auto => PolicyDecision::Classify {
risk,
checkpoint: false,
},
L::Ask => PolicyDecision::Ask {
risk,
checkpoint: false,
},
L::Deny => plan_deny(risk),
}
}
fn apply_plan_profile(
ctx: &ExecContext,
request: &ActionRequest,
decision: PolicyDecision,
) -> PolicyDecision {
use crate::app::PlanPermLevel as L;
use crate::runtime::ToolCategory as C;
let perms = ctx.plan_permissions;
match decision {
PolicyDecision::Deny { risk, reason }
if reason.starts_with(crate::runtime::READ_ONLY_DENIAL_MARKER) =>
{
let plan_file = ctx.plan_file.as_deref().expect("plan mode ctx");
let plan_file_edit = request.category == C::Edit
&& request
.path
.as_deref()
.is_some_and(|p| is_plan_file_path(&ctx.workdir, p, plan_file));
if plan_file_edit {
PolicyDecision::Allow {
risk,
checkpoint: false,
}
} else if request.category == C::Memory {
plan_level_decision(perms.memory, risk)
} else if request
.command
.as_deref()
.is_some_and(crate::runtime::is_plan_safe_build_command)
{
plan_level_decision(perms.builds, risk)
} else {
plan_deny(risk)
}
},
PolicyDecision::Allow { risk, .. }
if request.category == C::Web && perms.web != L::Allow =>
{
plan_level_decision(perms.web, risk)
},
other => other,
}
}
fn is_plan_file_path(workdir: &std::path::Path, raw: &str, plan_file: &std::path::Path) -> bool {
fn normalize(p: &std::path::Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {},
Component::ParentDir => {
out.pop();
},
other => out.push(other.as_os_str()),
}
}
out
}
let p = std::path::Path::new(raw);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
workdir.join(p)
};
normalize(&abs) == normalize(plan_file)
}
fn scratch_downgrade_eligible(risk: RiskClass) -> bool {
matches!(
risk,
RiskClass::ReadOnly
| RiskClass::LowMutation
| RiskClass::FileMutation
| RiskClass::ShellMutation
)
}
async fn inline_decision(
ctx: &ExecContext,
broker: &ApprovalBroker,
request: &ActionRequest,
risk: RiskClass,
classifier_reason: Option<String>,
) -> Gate {
let key = allowlist_key(&request.tool, request.command.as_deref());
if !key.is_empty() && broker.is_allowlisted(&key) {
return Gate::Proceed { risk };
}
let kind = if classifier_reason.is_some() {
ApprovalKind::Classify
} else {
approval_kind(request.category)
};
let prompt = format_approval_body(request, classifier_reason.as_deref());
let decision = broker
.request(
&ctx.token,
ctx.turn,
ctx.call_id,
request.tool.clone(),
risk.as_str().to_string(),
kind,
prompt,
key,
)
.await;
match decision {
ApprovalDecision::Approve | ApprovalDecision::ApproveAlways => Gate::Proceed { risk },
ApprovalDecision::Deny => Gate::Block(ToolOutcome::error(
format!("{} — denied by you", request.summary),
0.0,
)),
}
}
fn format_approval_body(request: &ActionRequest, classifier_reason: Option<&str>) -> String {
use crate::runtime::ToolCategory as C;
let mut body = if let Some(cmd) = &request.command {
match request.category {
C::Shell | C::Git | C::Process => format!("$ {}", cmd),
_ => cmd.clone(),
}
} else if let Some(path) = &request.path {
format!("{} ({})", path, request.summary)
} else {
request.summary.clone()
};
if let Some(reason) = classifier_reason {
body.push_str(&format!("\n\nAuto-review flagged this: {}", reason));
}
body
}
fn approval_kind(category: crate::runtime::ToolCategory) -> ApprovalKind {
use crate::runtime::ToolCategory as C;
match category {
C::Edit => ApprovalKind::FileMutation,
C::Shell | C::Git | C::Process => ApprovalKind::Shell,
C::Web | C::Network | C::ExternalDirectory => ApprovalKind::Web,
C::Mcp => ApprovalKind::Mcp,
C::Subagent => ApprovalKind::Subagent,
C::ComputerUse => ApprovalKind::ComputerUse,
C::Read | C::Memory => ApprovalKind::Shell,
}
}
#[allow(clippy::too_many_arguments)]
fn block_for_approval(
ctx: &ExecContext,
request: &ActionRequest,
checkpoint: bool,
checkpoint_paths: &[PathBuf],
pending_action: serde_json::Value,
risk: RiskClass,
classifier_reason: Option<String>,
) -> Gate {
let checkpoint_id = if checkpoint && ctx.config.safety.checkpoint_on_mutation {
match create_checkpoint_for_task(
&ctx.workdir,
checkpoint_paths,
Some(pending_action.clone()),
ctx.checkpoint_origin(),
) {
Ok(manifest) => Some(manifest.id),
Err(error) => {
return Gate::Block(ToolOutcome::error(
format!(
"{} checkpoint failed before approval: {}",
request.summary, error
),
0.0,
));
},
}
} else {
None
};
let args_summary = request
.command
.clone()
.or_else(|| request.path.clone())
.unwrap_or_else(|| request.summary.clone());
let pending_action_json = serde_json::to_string(&pending_action).ok();
let tool = request.tool.clone();
let risk_str = risk.as_str().to_string();
let proposed_action = match &classifier_reason {
Some(reason) => format!("{} [auto-review: {}]", request.summary, reason),
None => request.summary.clone(),
};
let approval_id = RuntimeStore::open_default()
.and_then(|store| {
let approval = store.approvals().create(NewApproval {
task_id: ctx.task_id.clone(),
proposed_action: proposed_action.clone(),
risk_classification: risk_str.clone(),
policy_decision: "ask".to_string(),
args_summary: Some(args_summary),
checkpoint_id: checkpoint_id.clone(),
pending_action_json,
})?;
if let Some(checkpoint_id) = checkpoint_id.as_deref() {
let _ = store
.checkpoints()
.set_approval(checkpoint_id, &approval.id);
}
let _ = run_plugin_hooks(
"approval_requested",
&serde_json::json!({
"id": approval.id.clone(),
"task_id": approval.task_id.clone(),
"tool": tool,
"risk": risk_str,
"checkpoint_id": checkpoint_id.clone(),
}),
);
Ok(approval)
})
.map(|approval| approval.id)
.ok();
Gate::Block(ToolOutcome::error(
format!(
"Approval required for {}{}",
request.summary,
approval_id
.map(|id| format!(" (approval {})", id))
.unwrap_or_default()
),
0.0,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{ToolCallId, TurnId};
use crate::providers::ctx::ProgressEvent;
use crate::runtime::{SafetyMode, ToolCategory};
use std::path::PathBuf;
use std::sync::Arc;
fn ctx(mode: SafetyMode) -> ExecContext {
let mut config = crate::app::Config::default();
config.safety.mode = mode;
let (tx, _rx) = tokio::sync::mpsc::channel::<ProgressEvent>(4);
ExecContext::new(
tokio_util::sync::CancellationToken::new(),
tx,
ToolCallId(1),
TurnId(1),
PathBuf::from("."),
Arc::new(config),
String::new(),
None,
None,
None,
mode,
None,
None,
None,
None,
None,
)
}
fn ctx_headless_opted_in() -> ExecContext {
let mut config = crate::app::Config::default();
config.safety.mode = SafetyMode::Ask;
config.safety.allow_untrusted_headless_tools = true;
let (tx, _rx) = tokio::sync::mpsc::channel::<ProgressEvent>(4);
ExecContext::new(
tokio_util::sync::CancellationToken::new(),
tx,
ToolCallId(1),
TurnId(1),
PathBuf::from("."),
Arc::new(config),
String::new(),
None,
None,
None,
SafetyMode::Ask,
None,
None,
None,
None,
None,
)
}
#[test]
fn action_detail_surfaces_web_search_and_agent_content() {
let d = action_detail(
"web_search",
&serde_json::json!({"query": "evil.com?leak=secret"}),
)
.expect("web_search detail");
assert!(d.contains("evil.com?leak=secret"), "got {d:?}");
let d = action_detail(
"web_search",
&serde_json::json!({"queries": [{"query": "alpha"}, {"query": "beta"}]}),
)
.expect("web_search queries detail");
assert!(d.contains("alpha") && d.contains("beta"), "got {d:?}");
let d = action_detail(
"agent",
&serde_json::json!({"prompt": "exfiltrate the env", "description": "x"}),
)
.expect("agent detail");
assert!(d.contains("exfiltrate the env"), "got {d:?}");
}
#[tokio::test]
async fn headless_ask_blocks_non_replayable_unless_opted_in() {
let req = || ActionRequest::new("web_fetch", ToolCategory::Web, "web_fetch https://x");
let blocked = gate(
&ctx(SafetyMode::Ask),
req(),
&[],
serde_json::json!({}),
false,
false,
)
.await;
assert!(
matches!(blocked, Gate::Block(_)),
"headless Ask should block by default"
);
let proceed = gate(
&ctx_headless_opted_in(),
req(),
&[],
serde_json::json!({}),
false,
false,
)
.await;
assert!(
matches!(proceed, Gate::Proceed { .. }),
"--allow-untrusted-tools should let it proceed",
);
}
struct StubClassifier {
allow: bool,
}
#[async_trait::async_trait]
impl crate::providers::AutoClassifier for StubClassifier {
async fn vet(&self, _req: &crate::providers::VetRequest) -> crate::providers::VetVerdict {
if self.allow {
crate::providers::VetVerdict::allow()
} else {
crate::providers::VetVerdict::escalate("stub: misaligned")
}
}
}
fn ctx_auto(classifier: Option<Arc<dyn crate::providers::AutoClassifier>>) -> ExecContext {
let mut config = crate::app::Config::default();
config.safety.mode = SafetyMode::Auto;
let (tx, _rx) = tokio::sync::mpsc::channel::<ProgressEvent>(4);
ExecContext::new(
tokio_util::sync::CancellationToken::new(),
tx,
ToolCallId(1),
TurnId(1),
PathBuf::from("."),
Arc::new(config),
String::new(),
None,
None,
None,
SafetyMode::Auto,
Some("fetch the changelog".to_string()),
classifier,
None,
None,
None,
)
}
#[tokio::test]
async fn readonly_blocks_external_tools() {
let ctx = ctx(SafetyMode::ReadOnly);
for (tool, cat) in [
("mcp_proxy", ToolCategory::Mcp),
("click", ToolCategory::ComputerUse),
("memory", ToolCategory::Memory),
] {
assert!(
gate_external(&ctx, tool, cat, tool.to_string(), &serde_json::json!({}))
.await
.is_some(),
"ReadOnly must block {tool}",
);
}
assert!(
gate_external(
&ctx,
"agent",
ToolCategory::Subagent,
"subagent: explore".to_string(),
&serde_json::json!({"prompt": "map the crates"}),
)
.await
.is_none(),
"ReadOnly must allow subagent spawn",
);
}
#[tokio::test]
async fn readonly_allows_web_reads() {
let ctx = ctx(SafetyMode::ReadOnly);
for (tool, summary) in [
("web_search", "web_search rust release notes"),
("web_fetch", "web_fetch https://example.com/docs"),
] {
assert!(
gate_external(
&ctx,
tool,
ToolCategory::Web,
summary.to_string(),
&serde_json::json!({}),
)
.await
.is_none(),
"ReadOnly must allow {tool}",
);
}
}
#[tokio::test]
async fn memory_writes_ungated_except_readonly() {
for mode in [SafetyMode::Ask, SafetyMode::Auto, SafetyMode::FullAccess] {
let ctx = ctx(mode);
assert!(
gate_external(
&ctx,
"memory",
ToolCategory::Memory,
"memory remember".to_string(),
&serde_json::json!({"action": "remember"}),
)
.await
.is_none(),
"memory must proceed without approval in {mode:?}",
);
}
let ctx = ctx(SafetyMode::ReadOnly);
assert!(
gate_external(
&ctx,
"memory",
ToolCategory::Memory,
"memory remember".to_string(),
&serde_json::json!({"action": "remember"}),
)
.await
.is_some(),
"read-only must block memory writes",
);
}
#[tokio::test]
async fn full_access_allows_external_tools() {
let ctx = ctx(SafetyMode::FullAccess);
assert!(
gate_external(
&ctx,
"web_fetch",
ToolCategory::Web,
"web_fetch".to_string(),
&serde_json::json!({}),
)
.await
.is_none()
);
}
#[tokio::test]
async fn auto_classifier_allow_proceeds() {
let ctx = ctx_auto(Some(Arc::new(StubClassifier { allow: true })));
assert!(
gate_external(
&ctx,
"web_fetch",
ToolCategory::Web,
"web_fetch".to_string(),
&serde_json::json!({}),
)
.await
.is_none(),
"ALLOW verdict should let the action proceed",
);
}
#[tokio::test]
async fn auto_classifier_escalate_blocks() {
let ctx = ctx_auto(Some(Arc::new(StubClassifier { allow: false })));
assert!(
gate_external(
&ctx,
"web_fetch",
ToolCategory::Web,
"web_fetch".to_string(),
&serde_json::json!({}),
)
.await
.is_some(),
"ESCALATE verdict should block a non-replayable tool",
);
}
#[tokio::test]
async fn auto_without_classifier_fails_safe() {
let ctx = ctx_auto(None);
assert!(
gate_external(
&ctx,
"web_fetch",
ToolCategory::Web,
"web_fetch".to_string(),
&serde_json::json!({}),
)
.await
.is_some(),
"missing classifier must fail safe (block), not allow",
);
}
fn ctx_with_broker(broker: crate::providers::ApprovalBroker) -> ExecContext {
let mut config = crate::app::Config::default();
config.safety.mode = SafetyMode::Ask;
let (tx, _rx) = tokio::sync::mpsc::channel::<ProgressEvent>(4);
ExecContext::new(
tokio_util::sync::CancellationToken::new(),
tx,
ToolCallId(7),
TurnId(1),
PathBuf::from("."),
Arc::new(config),
String::new(),
None,
None,
None,
SafetyMode::Ask,
None,
None,
Some(broker),
None,
None,
)
}
fn shell_request(cmd: &str) -> ActionRequest {
let mut req = ActionRequest::new("execute_command", ToolCategory::Shell, cmd);
req.command = Some(cmd.to_string());
req
}
fn ctx_plan() -> ExecContext {
let mut c = ctx(SafetyMode::ReadOnly);
c.workdir = PathBuf::from("/repo");
c.plan_file = Some(PathBuf::from("/repo/.mermaid/plans/x.md"));
c
}
fn edit_request(path: &str) -> ActionRequest {
let mut req = ActionRequest::new(
"write_file",
ToolCategory::Edit,
format!("write_file {path}"),
);
req.path = Some(path.to_string());
req
}
#[tokio::test]
async fn plan_mode_exempts_only_the_plan_file_from_the_edit_deny() {
for path in [
"/repo/.mermaid/plans/x.md",
".mermaid/plans/x.md",
"./.mermaid/plans/../plans/x.md",
] {
let g = gate(
&ctx_plan(),
edit_request(path),
&[],
serde_json::json!({}),
true,
false,
)
.await;
assert!(
matches!(g, Gate::Proceed { .. }),
"plan file spelling {path:?} must be writable"
);
}
for path in ["src/main.rs", "/repo/.mermaid/plans/../../src/main.rs"] {
match gate(
&ctx_plan(),
edit_request(path),
&[],
serde_json::json!({}),
true,
false,
)
.await
{
Gate::Block(outcome) => assert!(
outcome.model_content.contains(&format!(
"blocked by policy: {}",
crate::runtime::PLAN_DENIAL_MARKER
)),
"plan denial must carry the plan signature for {path:?}: {:?}",
outcome.model_content
),
Gate::Proceed { .. } => panic!("{path:?} must not be writable in plan mode"),
}
}
}
#[tokio::test]
async fn plan_mode_allows_memory_and_safe_builds_but_floors_the_rest() {
assert!(
gate_external(
&ctx_plan(),
"memory",
ToolCategory::Memory,
"memory remember".to_string(),
&serde_json::json!({"action": "remember"}),
)
.await
.is_none(),
"plan mode must allow memory writes",
);
let g = gate(
&ctx_plan(),
shell_request("cargo test policy"),
&[],
serde_json::json!({}),
true,
false,
)
.await;
assert!(
matches!(g, Gate::Proceed { .. }),
"plan mode must allow known-safe builds"
);
match gate(
&ctx_plan(),
shell_request("touch src/main.rs"),
&[],
serde_json::json!({}),
true,
false,
)
.await
{
Gate::Block(outcome) => {
assert!(
outcome.model_content.contains(&format!(
"blocked by policy: {}",
crate::runtime::PLAN_DENIAL_MARKER
)),
"got {:?}",
outcome.model_content
);
},
Gate::Proceed { .. } => panic!("mutations must not run in plan mode"),
}
match gate(
&ctx_plan(),
shell_request("rm -rf /"),
&[],
serde_json::json!({}),
true,
false,
)
.await
{
Gate::Block(outcome) => assert!(
!outcome
.model_content
.contains(crate::runtime::PLAN_DENIAL_MARKER),
"destructive deny must not be rewritten: {:?}",
outcome.model_content
),
Gate::Proceed { .. } => panic!("destructive commands must never run"),
}
}
#[tokio::test]
async fn plan_profile_strict_denies_the_default_carve_outs() {
let mut c = ctx_plan();
c.plan_permissions = crate::app::PlanPermissions::strict();
assert!(
gate_external(
&c,
"memory",
ToolCategory::Memory,
"memory remember".to_string(),
&serde_json::json!({"action": "remember"}),
)
.await
.is_some(),
"strict profile must deny memory writes",
);
match gate(
&c,
shell_request("cargo test policy"),
&[],
serde_json::json!({}),
true,
false,
)
.await
{
Gate::Block(outcome) => assert!(
outcome
.model_content
.contains(crate::runtime::PLAN_DENIAL_MARKER),
"got {:?}",
outcome.model_content
),
Gate::Proceed { .. } => panic!("strict profile must deny builds"),
}
assert!(
gate_external(
&c,
"web_fetch",
ToolCategory::Web,
"web_fetch https://example.com".to_string(),
&serde_json::json!({"url": "https://example.com"}),
)
.await
.is_some(),
"strict profile must deny web reads while planning",
);
let g = gate(
&c,
edit_request("/repo/.mermaid/plans/x.md"),
&[],
serde_json::json!({}),
true,
false,
)
.await;
assert!(matches!(g, Gate::Proceed { .. }));
}
#[tokio::test]
async fn inline_ask_approve_proceeds() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
let broker = crate::providers::ApprovalBroker::new(tx);
let ctx = ctx_with_broker(broker.clone());
let handle = tokio::spawn(async move {
gate(
&ctx,
shell_request("npm test"),
&[],
serde_json::json!({}),
true,
false,
)
.await
});
let call_id = match rx.recv().await.expect("approval requested") {
crate::domain::Msg::ApprovalRequested { call_id, .. } => call_id,
other => panic!("expected ApprovalRequested, got {other:?}"),
};
broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
assert!(matches!(handle.await.unwrap(), Gate::Proceed { .. }));
}
#[tokio::test]
async fn inline_ask_deny_blocks() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
let broker = crate::providers::ApprovalBroker::new(tx);
let ctx = ctx_with_broker(broker.clone());
let handle = tokio::spawn(async move {
gate(
&ctx,
shell_request("rm -rf node_modules"),
&[],
serde_json::json!({}),
true,
false,
)
.await
});
let call_id = match rx.recv().await.expect("approval requested") {
crate::domain::Msg::ApprovalRequested { call_id, .. } => call_id,
other => panic!("expected ApprovalRequested, got {other:?}"),
};
broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
assert!(matches!(handle.await.unwrap(), Gate::Block(_)));
}
#[tokio::test]
async fn inline_allowlisted_skips_prompt() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
let broker = crate::providers::ApprovalBroker::new(tx);
let ctx1 = ctx_with_broker(broker.clone());
let b1 = broker.clone();
let h1 = tokio::spawn(async move {
gate(
&ctx1,
shell_request("npm run build"),
&[],
serde_json::json!({}),
true,
false,
)
.await
});
let id = match rx.recv().await.expect("first prompt") {
crate::domain::Msg::ApprovalRequested { call_id, .. } => call_id,
other => panic!("got {other:?}"),
};
b1.resolve(id, crate::providers::ApprovalDecision::ApproveAlways);
assert!(matches!(h1.await.unwrap(), Gate::Proceed { .. }));
let ctx2 = ctx_with_broker(broker.clone());
let g2 = gate(
&ctx2,
shell_request("npm run build"),
&[],
serde_json::json!({}),
true,
false,
)
.await;
assert!(
matches!(g2, Gate::Proceed { .. }),
"the identical allowlisted command should skip the prompt"
);
assert!(rx.try_recv().is_err(), "no second prompt should be sent");
}
#[tokio::test]
async fn scratch_containment_downgrades_eligible_asks() {
let ctx = ctx(SafetyMode::Ask);
let g = gate(
&ctx,
edit_request("/scratch/notes.txt"),
&[],
serde_json::json!({}),
true,
true,
)
.await;
assert!(
matches!(g, Gate::Proceed { .. }),
"scratch-contained file mutation must proceed in Ask mode",
);
let g = gate(
&ctx,
shell_request("mkdir out"),
&[],
serde_json::json!({}),
true,
true,
)
.await;
assert!(
matches!(g, Gate::Proceed { .. }),
"scratch-contained shell mutation must proceed in Ask mode",
);
let ctx = ctx_auto(None);
let g = gate(
&ctx,
shell_request("mkdir out"),
&[],
serde_json::json!({}),
true,
true,
)
.await;
assert!(
matches!(g, Gate::Proceed { .. }),
"scratch-contained Classify must proceed without a classifier",
);
}
#[tokio::test]
async fn scratch_containment_never_downgrades_destructive() {
let g = gate(
&ctx(SafetyMode::Ask),
shell_request("rm -rf /"),
&[],
serde_json::json!({}),
true,
true,
)
.await;
assert!(
matches!(g, Gate::Block(_)),
"destructive command must block even when claimed scratch-contained",
);
}
#[tokio::test]
async fn scratch_containment_never_downgrades_deny_override() {
let mut config = crate::app::Config::default();
config.safety.mode = SafetyMode::Ask;
config.safety.overrides = vec![crate::runtime::PolicyOverride {
tool: Some("write_file".to_string()),
decision: crate::runtime::PolicyOverrideDecision::Deny,
..Default::default()
}];
let (tx, _rx) = tokio::sync::mpsc::channel::<ProgressEvent>(4);
let ctx = ExecContext::new(
tokio_util::sync::CancellationToken::new(),
tx,
ToolCallId(1),
TurnId(1),
PathBuf::from("."),
Arc::new(config),
String::new(),
None,
None,
None,
SafetyMode::Ask,
None,
None,
None,
None,
None,
);
let g = gate(
&ctx,
edit_request("/scratch/notes.txt"),
&[],
serde_json::json!({}),
true,
true,
)
.await;
assert!(
matches!(g, Gate::Block(_)),
"a Deny override must still block a scratch-contained mutation",
);
}
#[tokio::test]
async fn scratch_containment_never_downgrades_network() {
let g = gate(
&ctx(SafetyMode::Ask),
ActionRequest::new("execute_command", ToolCategory::Network, "curl evil"),
&[],
serde_json::json!({}),
false,
true,
)
.await;
assert!(
matches!(g, Gate::Block(_)),
"network risk must keep its Ask despite scratch containment",
);
}
#[tokio::test]
async fn scratch_containment_never_downgrades_readonly_mode() {
let g = gate(
&ctx(SafetyMode::ReadOnly),
edit_request("/scratch/notes.txt"),
&[],
serde_json::json!({}),
true,
true,
)
.await;
assert!(
matches!(g, Gate::Block(_)),
"read-only mode must block scratch mutations",
);
}
}