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, plan_write: bool },
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> {
if matches!(
category,
crate::runtime::ToolCategory::Web | crate::runtime::ToolCategory::Network
) && matches!(ctx.config.safety.network, crate::app::NetworkPolicy::Deny)
{
return Some(ToolOutcome::error(
format!(
"{tool} blocked because network access is disabled (safety.network = \"deny\" / --no-network)"
),
0.0,
));
}
let mut request = ActionRequest::new(tool, category, summary);
request.command = action_detail(tool, args);
request.arguments = Some(args.clone());
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> {
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 {:?}", s("text")?)),
"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 {}", s("url")?)),
"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(serde_json::Value::to_string)
.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(format!("web_search {}", queries.join(" | ")))
}
},
"agent" => {
Some(format!("agent: {}", s("prompt")?))
},
_ => 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, plan_write) = if ctx.plan_file.is_some() {
apply_plan_profile(ctx, &request, decision)
} else {
(decision, false)
};
let decision = match decision {
PolicyDecision::Ask { risk, .. }
if ctx.plan_file.is_none()
&& ctx.safety_mode == crate::runtime::SafetyMode::ReadOnly
&& request.category == crate::runtime::ToolCategory::Web
&& ctx.config.safety.allow_readonly_web =>
{
PolicyDecision::Allow {
risk,
checkpoint: false,
}
},
other => other,
};
if scratch_contained
&& let PolicyDecision::Ask { risk, .. } | PolicyDecision::Classify { risk, .. } = decision
&& scratch_downgrade_eligible(risk)
{
return Gate::Proceed {
risk,
plan_write: false,
};
}
match decision {
PolicyDecision::Allow { risk, .. } => Gate::Proceed { risk, plan_write },
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, plan_write }
} 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(),
arguments: request.arguments.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, plan_write }
} 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, plan_file: &std::path::Path) -> PolicyDecision {
PolicyDecision::Deny {
risk,
reason: format!(
"{} is active — planning only. Capture this change in the plan file at {} \
instead of performing it now: write_file or apply_patch on that exact path \
are the allowed mutations (a shell redirect writing ONLY that file also \
works). When the plan is complete, call exit_plan_mode",
crate::runtime::PLAN_DENIAL_MARKER,
plan_file.display(),
),
}
}
fn plan_level_decision(
level: crate::app::PlanPermLevel,
risk: RiskClass,
plan_file: &std::path::Path,
) -> 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, plan_file),
}
}
fn apply_plan_profile(
ctx: &ExecContext,
request: &ActionRequest,
decision: PolicyDecision,
) -> (PolicyDecision, bool) {
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 action_dir = request.resolve_dir(&ctx.workdir);
let plan_file_edit = request.category == C::Edit
&& request
.path
.as_deref()
.is_some_and(|p| crate::runtime::is_plan_file_path(&ctx.workdir, p, plan_file));
if plan_file_edit {
(
PolicyDecision::Allow {
risk,
checkpoint: false,
},
true,
)
} else if request.category == C::Memory {
(plan_level_decision(perms.memory, risk, plan_file), false)
} else if request
.command
.as_deref()
.is_some_and(|c| crate::runtime::is_plan_file_only_write(c, action_dir, plan_file))
{
(
PolicyDecision::Allow {
risk,
checkpoint: false,
},
true,
)
} else if request
.command
.as_deref()
.is_some_and(crate::runtime::is_plan_safe_build_command)
{
(plan_level_decision(perms.builds, risk, plan_file), false)
} else {
(plan_deny(risk, plan_file), false)
}
},
PolicyDecision::Allow { risk, .. }
| PolicyDecision::Ask { risk, .. }
| PolicyDecision::Classify { risk, .. }
if request.category == C::Web =>
{
let plan_file = ctx.plan_file.as_deref().expect("plan mode ctx");
(plan_level_decision(perms.web, risk, plan_file), false)
},
other => (other, false),
}
}
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,
plan_write: false,
};
}
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,
plan_write: false,
},
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 {
fn clip_preview(value: &str) -> String {
const MAX_BYTES: usize = 200;
if value.len() <= MAX_BYTES {
return value.to_string();
}
let end = value.floor_char_boundary(MAX_BYTES);
format!("{}…", &value[..end])
}
use crate::runtime::ToolCategory as C;
let redacted_detail = request.arguments.as_ref().and_then(|arguments| {
let mut safe = arguments.clone();
crate::utils::redact_json(&mut safe);
action_detail(&request.tool, &safe)
});
let modal_detail = redacted_detail.as_ref().or(request.command.as_ref());
let mut body = if let Some(cmd) = modal_detail {
match request.category {
C::Shell | C::Git | C::Process => format!("$ {}", cmd),
_ => clip_preview(cmd),
}
} else if let Some(path) = &request.path {
format!("{} ({})", path, request.summary)
} else {
match request.category {
C::Shell | C::Git | C::Process => request.summary.clone(),
_ => clip_preview(&request.summary),
}
};
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::runtime::{SafetyMode, ToolCategory};
use std::path::PathBuf;
use std::sync::Arc;
fn ctx_with(config: crate::app::Config) -> ExecContext {
crate::providers::ctx::test_exec_context_with_config(
TurnId(1),
ToolCallId(1),
PathBuf::from("."),
config,
)
.0
}
fn ctx(mode: SafetyMode) -> ExecContext {
let mut config = crate::app::Config::default();
config.safety.mode = mode;
ctx_with(config)
}
fn ctx_headless_opted_in(mode: SafetyMode) -> ExecContext {
let mut config = crate::app::Config::default();
config.safety.mode = mode;
config.safety.allow_untrusted_headless_tools = true;
ctx_with(config)
}
#[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 padding = "x".repeat(240);
let d = action_detail(
"web_search",
&serde_json::json!({"queries": [
{"query": "alpha"},
{"query": padding},
{"query": "tail query remains visible to policy"}
]}),
)
.expect("web_search queries detail");
assert!(
d.contains("alpha") && d.contains("tail query remains visible to policy"),
"complete policy detail was clipped: {d:?}"
);
assert!(d.len() > 200, "policy detail must not use the UI limit");
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");
for mode in [SafetyMode::Ask, SafetyMode::ReadOnly] {
let blocked = gate(&ctx(mode), req(), &[], serde_json::json!({}), false, false).await;
assert!(
matches!(blocked, Gate::Block(_)),
"headless {mode:?} should block by default"
);
let proceed = gate(
&ctx_headless_opted_in(mode),
req(),
&[],
serde_json::json!({}),
false,
false,
)
.await;
assert!(
matches!(proceed, Gate::Proceed { .. }),
"--allow-untrusted-tools should explicitly allow {mode:?} web egress",
);
}
}
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 ctx = ctx(SafetyMode::Auto);
ctx.intent = Some("fetch the changelog".to_string());
ctx.classifier = classifier;
ctx
}
#[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_web_egress_fails_closed_without_approval_ui() {
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_some(),
"ReadOnly must require approval for {tool}",
);
}
}
#[tokio::test]
async fn readonly_web_explicit_user_opt_in_proceeds() {
let mut context = ctx(SafetyMode::ReadOnly);
Arc::make_mut(&mut context.config).safety.allow_readonly_web = true;
assert!(
gate_external(
&context,
"web_fetch",
ToolCategory::Web,
"web_fetch example".to_string(),
&serde_json::json!({"url": "https://example.com"}),
)
.await
.is_none(),
"explicit user/session opt-in should allow ReadOnly web egress"
);
}
#[tokio::test]
async fn global_network_deny_blocks_web_even_in_full_access() {
let mut context = ctx(SafetyMode::FullAccess);
let safety = &mut Arc::make_mut(&mut context.config).safety;
safety.network = crate::app::NetworkPolicy::Deny;
safety.allow_readonly_web = true;
for tool in ["web_fetch", "web_search"] {
let blocked = gate_external(
&context,
tool,
ToolCategory::Web,
tool.to_string(),
&serde_json::json!({"url": "https://example.com"}),
)
.await;
assert!(blocked.is_some(), "network deny must block {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_mode(
mode: SafetyMode,
broker: crate::providers::ApprovalBroker,
) -> ExecContext {
let mut ctx = ctx(mode);
ctx.call_id = ToolCallId(7);
ctx.approval = Some(broker);
ctx
}
fn ctx_with_broker(broker: crate::providers::ApprovalBroker) -> ExecContext {
ctx_with_broker_mode(SafetyMode::Ask, broker)
}
#[tokio::test]
async fn readonly_web_uses_non_allowlistable_one_shot_approval() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::domain::Msg>(8);
let broker = crate::providers::ApprovalBroker::new(tx);
let context = ctx_with_broker_mode(SafetyMode::ReadOnly, broker.clone());
let handle = tokio::spawn(async move {
gate_external(
&context,
"web_fetch",
ToolCategory::Web,
"web_fetch example".to_string(),
&serde_json::json!({"url": "https://example.com"}),
)
.await
});
let (call_id, allowlist_scope) = match rx.recv().await.expect("approval requested") {
crate::domain::Msg::ApprovalRequested {
call_id,
allowlist_scope,
..
} => (call_id, allowlist_scope),
other => panic!("expected ApprovalRequested, got {other:?}"),
};
assert!(
allowlist_scope.is_empty(),
"web approval must never expose approve-always"
);
broker.resolve(call_id, crate::providers::ApprovalDecision::ApproveAlways);
assert!(
handle.await.unwrap().is_none(),
"one approved request should proceed"
);
assert!(!broker.is_allowlisted("web_fetch"));
}
#[test]
fn external_policy_detail_is_complete_but_modal_preview_is_bounded() {
let tail = "tail-visible-only-to-policy";
let url = format!("https://example.com/{}{}", "x".repeat(240), tail);
let arguments = serde_json::json!({"url": url});
let mut request = ActionRequest::new("web_fetch", ToolCategory::Web, "web_fetch");
request.command = action_detail("web_fetch", &arguments);
request.arguments = Some(arguments);
assert!(request.command.as_deref().is_some_and(|d| d.contains(tail)));
let modal = format_approval_body(&request, None);
assert!(!modal.contains(tail), "modal should contain only a preview");
assert!(modal.ends_with('…'));
}
#[test]
fn web_approval_modal_sanitizes_url_credentials_and_fragment() {
let arguments = serde_json::json!({
"url": "https://alice:password123@example.com/path?token=opaque-secret-value#private-fragment"
});
let mut request = ActionRequest::new("web_fetch", ToolCategory::Web, "web_fetch");
request.command = action_detail("web_fetch", &arguments);
request.arguments = Some(arguments);
let modal = format_approval_body(&request, None);
assert!(!modal.contains("alice"));
assert!(!modal.contains("password123"));
assert!(!modal.contains("opaque-secret-value"));
assert!(!modal.contains("private-fragment"));
assert!(modal.contains("example.com/path?token="));
}
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_a_shell_write_that_only_touches_the_plan_file() {
for cmd in [
"echo '## Summary' > .mermaid/plans/x.md",
"printf '%s\\n' more >> /repo/.mermaid/plans/x.md",
"cat > .mermaid/plans/x.md <<'EOF'\n## Tasks\n1. step\nEOF",
] {
let g = gate(
&ctx_plan(),
shell_request(cmd),
&[],
serde_json::json!({}),
true,
false,
)
.await;
assert!(
matches!(g, Gate::Proceed { .. }),
"plan-file-only shell write must proceed: {cmd}"
);
}
let g = gate(
&ctx_plan(),
shell_request("echo x > .mermaid/plans/x.md && git push"),
&[],
serde_json::json!({}),
true,
false,
)
.await;
assert!(
matches!(g, Gate::Block(_)),
"a second effect keeps the block"
);
}
#[tokio::test]
async fn plan_denial_teaches_the_plan_file_and_tools() {
let g = gate(
&ctx_plan(),
shell_request("echo hi > src/main.rs"),
&[],
serde_json::json!({}),
true,
false,
)
.await;
match g {
Gate::Block(outcome) => {
assert!(
outcome
.model_content
.contains("blocked by policy: plan mode"),
"neutralizer signature must survive the new wording: {:?}",
outcome.model_content
);
assert!(
outcome.model_content.contains("/repo/.mermaid/plans/x.md"),
"denial must name the plan path: {:?}",
outcome.model_content
);
assert!(
outcome.model_content.contains("write_file"),
"denial must name the allowed tool: {:?}",
outcome.model_content
);
},
Gate::Proceed { .. } => panic!("non-plan shell write must be blocked 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 { .. }));
}
#[test]
fn default_plan_profile_preserves_readonly_web_approval() {
let context = ctx_plan();
let request = ActionRequest::new(
"web_fetch",
ToolCategory::Web,
"web_fetch https://example.com",
);
let readonly = PolicyEngine::new(SafetyMode::ReadOnly).decide(&request);
assert!(matches!(readonly, PolicyDecision::Ask { .. }));
let (decision, plan_write) = apply_plan_profile(&context, &request, readonly);
assert!(matches!(decision, PolicyDecision::Ask { .. }));
assert!(!plan_write, "a web fetch is not a plan-file write");
}
#[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 ctx = ctx_with(config);
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",
);
}
}