diff --git a/crates/cm-api/src/container_tool_hooks.rs b/crates/cm-api/src/container_tool_hooks.rs index 47b1da2..9878ec4 100644 --- a/crates/cm-api/src/container_tool_hooks.rs +++ b/crates/cm-api/src/container_tool_hooks.rs @@ -46,7 +46,16 @@ pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(30); /// Returns the settings path on success. `None` means the container runs /// without hooks — logged, never fatal. pub async fn install(docker: &Docker, container: &str) -> Option { - let script = build_install_script(); + install_with(docker, container, None).await +} + +/// As [`install`], carrying a phase's task policy into the gate. +pub async fn install_with( + docker: &Docker, + container: &str, + task: Option<&crate::vm_tool_gate::TaskPolicy>, +) -> Option { + let script = build_install_script(task); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { @@ -66,7 +75,7 @@ pub async fn install(docker: &Docker, container: &str) -> Option { /// Composed here rather than by each hook module writing its own file: two /// writers of one `settings.json` is a silent clobber, and the microVM tier /// already learned that the expensive way. -fn build_install_script() -> String { +fn build_install_script(task: Option<&crate::vm_tool_gate::TaskPolicy>) -> String { let settings = crate::vm_tool_tap::guest_settings( None, Some(TAP_DIR), @@ -82,7 +91,7 @@ fn build_install_script() -> String { cat > {settings_path} <<'CM_SETTINGS_EOF'\n{settings}\nCM_SETTINGS_EOF\n", hooks = HOOK_DIR, tap = TAP_DIR, - gate = crate::vm_tool_gate::hook_script(HOOK_DIR), + gate = crate::vm_tool_gate::hook_script_with(HOOK_DIR, task), tap_script = crate::vm_tool_tap::hook_script(TAP_DIR), settings_path = SETTINGS_PATH, settings = settings, @@ -189,6 +198,9 @@ pub const GATE_INERT: &str = "gate.inert"; /// One call the gate refused. `detail` is the hook event with `rule` set /// beside it — see [`crate::vm_tool_gate::denial_detail`]. Both tiers. pub const GATE_DENIED: &str = "gate.denied"; +/// One call a task policy would have refused while it was in shadow. Same +/// detail shape as [`GATE_DENIED`]; the difference is that it RAN. +pub const GATE_WOULD_DENY: &str = "gate.would_deny"; /// Write the install outcome into the mission record. pub async fn record_install( @@ -263,6 +275,27 @@ pub async fn drain_denied(docker: &Docker, container: &str) -> Vec { } } +/// The gate's shadow record: calls a policy WOULD have refused, had it been +/// enforcing. Drained exactly like [`drain_denied`] and recorded as +/// [`GATE_WOULD_DENY`], because a shadow mode whose output nobody reads is +/// an off switch with extra steps. +pub async fn drain_would_deny(docker: &Docker, container: &str) -> Vec { + let file = format!("{HOOK_DIR}/{}", crate::vm_tool_gate::WOULD_DENY_FILE); + let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true"); + let argv = vec!["sh".to_string(), "-lc".to_string(), script]; + match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await + { + Ok(out) => out + .stdout + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(), + Err(_) => Vec::new(), + } +} + /// The tap file inside the mission container. pub fn tap_file() -> String { format!("{TAP_DIR}/tools.jsonl") @@ -314,7 +347,7 @@ mod tests { #[test] fn every_hook_command_is_a_file_the_installer_writes() { let settings = crate::vm_tool_tap::guest_settings(None, Some(TAP_DIR), Some(HOOK_DIR)); - let script = build_install_script(); + let script = build_install_script(None); let hooks = settings["hooks"].as_object().expect("hooks"); assert!(!hooks.is_empty(), "no hooks at all"); @@ -336,7 +369,7 @@ mod tests { #[test] fn the_script_writes_both_hooks_and_the_settings_document() { - let s = build_install_script(); + let s = build_install_script(None); assert!(s.contains("tool-gate.sh"), "the pre-execution gate is missing"); assert!(s.contains("tap.sh"), "the tool tap is missing"); assert!(s.contains(SETTINGS_PATH), "the settings document is missing"); @@ -354,7 +387,7 @@ mod tests { assert!(HOOK_DIR.starts_with("/root/")); assert!(SETTINGS_PATH.starts_with("/root/")); assert!(TAP_DIR.starts_with("/root/")); - assert!(!build_install_script().contains("/mission/repo")); + assert!(!build_install_script(None).contains("/mission/repo")); } /// The two halves must stay together. @@ -452,7 +485,7 @@ mod tests { return; } let tmp = std::env::temp_dir().join(format!("cm-install-{}.sh", std::process::id())); - std::fs::write(&tmp, build_install_script()).unwrap(); + std::fs::write(&tmp, build_install_script(None)).unwrap(); let out = std::process::Command::new("bash") .arg("-n") .arg(&tmp) diff --git a/crates/cm-api/src/microvm_executor.rs b/crates/cm-api/src/microvm_executor.rs index 211db31..5ff0771 100644 --- a/crates/cm-api/src/microvm_executor.rs +++ b/crates/cm-api/src/microvm_executor.rs @@ -319,25 +319,43 @@ const SETTINGS_PROBE: &str = "claude --help 2>&1 | grep -q -- '--settings' && ec /// month while the container tier moved to 2.1.276, and nothing recorded either. const CLI_VERSION_PROBE: &str = "claude --version 2>/dev/null | head -c 80"; -/// Read the tool gate's denials and inert marker out of the guest, in one -/// exec. The marker is a line count of "gave up" events; the denials are the -/// gate's own JSONL. A missing file is an empty section, not an error. +/// Read the tool gate's denials, its shadow record and the inert marker out +/// of the guest, in one exec. The marker is a line count of "gave up" +/// events; the other two are the gate's own JSONL. A missing file is an +/// empty section, not an error. +/// +/// The two JSONL sections are separated by [`WOULD_MARKER`] rather than by +/// shape: both are JSON objects with the same keys, and telling them apart +/// by content would mean a refusal and a call that RAN could be confused — +/// which is the one distinction shadow mode exists to make. fn tool_gate_probe(dir: &str) -> String { format!( - "echo INERT=$(wc -l < {dir}/{inert} 2>/dev/null || echo 0); cat {dir}/{denied} 2>/dev/null", + "echo INERT=$(wc -l < {dir}/{inert} 2>/dev/null || echo 0); cat {dir}/{denied} 2>/dev/null; echo {marker}; cat {dir}/{would} 2>/dev/null", inert = crate::vm_tool_gate::INERT_FILE, denied = crate::vm_tool_gate::DENIED_FILE, + would = crate::vm_tool_gate::WOULD_DENY_FILE, + marker = WOULD_MARKER, ) } +const WOULD_MARKER: &str = "--CM-WOULD-DENY--"; + /// Parse [`tool_gate_probe`]'s output. fn parse_tool_gate_probe(out: &str) -> ToolGateOutcome { let mut o = ToolGateOutcome::default(); + let mut shadow = false; for line in out.lines() { + let line = line.trim(); if let Some(n) = line.strip_prefix("INERT=") { o.inert = n.trim().parse().unwrap_or(0); - } else if !line.trim().is_empty() { - o.denied.push(line.trim().to_string()); + } else if line == WOULD_MARKER { + shadow = true; + } else if !line.is_empty() { + if shadow { + o.would_deny.push(line.to_string()); + } else { + o.denied.push(line.to_string()); + } } } o @@ -456,6 +474,10 @@ pub struct VmOutcome { pub struct ToolGateOutcome { /// Raw `denied.jsonl` lines — each one a call the gate refused. pub denied: Vec, + /// Calls a task policy WOULD have refused, had it been enforcing. These + /// ran. Kept apart from `denied` because the difference between "was + /// refused" and "would have been refused" is the whole of shadow mode. + pub would_deny: Vec, /// Times the gate could not parse its input and allowed the call anyway. pub inert: u32, } @@ -485,6 +507,7 @@ pub async fn run_phase_in_vm(hub: &NodeHub, p: VmPhase<'_>) -> Result { /// Claude Code `Stop` hook inside the guest. `None` leaves the turn exactly /// as it was. pub gate: Option<&'a crate::vm_stop_gate::StopGate>, + /// The tools this phase's agents may use at all, installed with the + /// gate. `None` keeps the gate exactly as it was — no task policy, the + /// floor and the role policies only. + pub task_policy: Option<&'a crate::vm_tool_gate::TaskPolicy>, /// Which node of a composed graph this VM is running, if any. `None` is the /// solo path, where the phase is one VM and the id needs no further /// qualification. Part of the vm id, so the nodes of one phase-iteration @@ -603,6 +630,8 @@ async fn run_inside( // `VmPhase::tap_sink` — `Some` means the sink owns recording and the // returned `tools` is empty. tap_sink: Option<&tokio::sync::mpsc::UnboundedSender>>, + // The tools this phase may use at all, baked into the gate at install. + task_policy: Option<&crate::vm_tool_gate::TaskPolicy>, ) -> Result { // An agent CLI cannot reach its API without the tunnel, and a turn without // egress does not fail — it hangs, or reports a network error the operator @@ -767,7 +796,10 @@ async fn run_inside( false => None, true => match vm .exec( - &crate::vm_tool_gate::install_command(crate::vm_tool_gate::GUEST_DIR), + &crate::vm_tool_gate::install_command_with( + crate::vm_tool_gate::GUEST_DIR, + task_policy, + ), None, 60, &[], @@ -1190,12 +1222,32 @@ mod tests { let g = parse_tool_gate_probe(out); assert_eq!(g.inert, 2); assert_eq!(g.denied.len(), 2); + assert!(g.would_deny.is_empty(), "no marker, no shadow section"); assert!(g.denied[1].contains("rm -rf")); let none = parse_tool_gate_probe("INERT=0\n"); assert_eq!(none, ToolGateOutcome::default()); // The probe reads both files from the gate's own directory. let cmd = tool_gate_probe(crate::vm_tool_gate::GUEST_DIR); assert!(cmd.contains("/root/toolgate/inert") && cmd.contains("/root/toolgate/denied.jsonl"), "{cmd}"); + assert!(cmd.contains("/root/toolgate/would-deny.jsonl"), "{cmd}"); + } + + /// A refusal and a call that merely WOULD have been refused must never + /// be read as each other: both are JSON objects with the same keys, so + /// the marker is what separates them. + #[test] + fn the_probe_keeps_refusals_apart_from_the_shadow_record() { + let out = format!( + "INERT=1\n{denied}\n{WOULD_MARKER}\n{would}\n{would}", + denied = r#"{"rule":"force-push"}"#, + would = r#"{"rule":"task-permission"}"#, + ); + let g = parse_tool_gate_probe(&out); + assert_eq!(g.inert, 1); + assert_eq!(g.denied.len(), 1, "{g:?}"); + assert!(g.denied[0].contains("force-push")); + assert_eq!(g.would_deny.len(), 2, "{g:?}"); + assert!(g.would_deny.iter().all(|l| l.contains("task-permission"))); } /// The CLI's `--agents` schema takes `tools` as an array. 2.1.276 refused diff --git a/crates/cm-api/src/microvm_turn_executor.rs b/crates/cm-api/src/microvm_turn_executor.rs index a930dc6..3c4cbba 100644 --- a/crates/cm-api/src/microvm_turn_executor.rs +++ b/crates/cm-api/src/microvm_turn_executor.rs @@ -191,6 +191,7 @@ impl TurnExecutor for MicroVmTurnExecutor { let outcome = self .vms .run(VmPhase { + task_policy: None, // Every node of a composed graph streams to the same outer run, // which is the one the operator is watching. run_id: Some(self.run_id), diff --git a/crates/cm-api/src/mission_runtime.rs b/crates/cm-api/src/mission_runtime.rs index bdcef09..710e2e3 100644 --- a/crates/cm-api/src/mission_runtime.rs +++ b/crates/cm-api/src/mission_runtime.rs @@ -649,7 +649,18 @@ impl MissionRuntimeProvisioner { // Re-install on reuse: the container outlives the server // process, and a hook that exists only on first creation is a // hook that quietly disappears after a redeploy. - let hooks = crate::container_tool_hooks::install(&self.docker, &name).await; + let hooks = crate::container_tool_hooks::install_with( + &self.docker, + &name, + // A container serves every phase of the mission, so the + // policy installed here is the mission-wide default. A + // phase's own `agent_tools` is honoured on the microVM + // tier, where the VM is per-phase; narrowing per phase + // here would need a re-install between phases and is not + // done. + Some(&crate::vm_tool_gate::TaskPolicy::default_shadow()), + ) + .await; let pairing_code = self.mint_pairing_code(&name).await; return Ok(EnsuredContainer { endpoint: endpoint_url(&name), @@ -865,7 +876,18 @@ impl MissionRuntimeProvisioner { // Gate and observe the tools claude runs inside its own subprocess. // Best-effort by design: a phase that runs unhooked still delivers, and // failing the launch to protect telemetry would be the wrong trade. - let hooks = crate::container_tool_hooks::install(&self.docker, &name).await; + let hooks = crate::container_tool_hooks::install_with( + &self.docker, + &name, + // A container serves every phase of the mission, so the + // policy installed here is the mission-wide default. A + // phase's own `agent_tools` is honoured on the microVM + // tier, where the VM is per-phase; narrowing per phase + // here would need a re-install between phases and is not + // done. + Some(&crate::vm_tool_gate::TaskPolicy::default_shadow()), + ) + .await; Ok(EnsuredContainer { endpoint: endpoint_url(&name), diff --git a/crates/cm-api/src/phase_config.rs b/crates/cm-api/src/phase_config.rs index 4d8fcc2..3956eb5 100644 --- a/crates/cm-api/src/phase_config.rs +++ b/crates/cm-api/src/phase_config.rs @@ -53,6 +53,17 @@ pub const KNOWN_KEYS: &[KnownKey] = &[ phase that changes no files still completes; also vm_stop_gate::\ StopGate::for_phase, where it drops the in-loop delivery check", }, + KnownKey { + key: "agent_tools", + read_by: "vm_tool_gate::TaskPolicy::for_phase — the tools this phase's \ + agents may use at all, enforced by the PreToolUse gate. \ + Absent means the default work surface (files, commands, \ + search, web, delegation); the platform-control tools are \ + never in it. DISTINCT from `tools` below, which is \ + security_scan's scanner list — two keys, two meanings, and \ + they are next to each other here so nobody conflates them. \ + Shadow unless CLAWMATES_TASK_PERMISSION=enforce.", + }, KnownKey { key: "tools", read_by: "security_scan::run — gates which of cargo_audit / gitleaks / \ diff --git a/crates/cm-api/src/phase_runner.rs b/crates/cm-api/src/phase_runner.rs index 7410e7d..23c1dda 100644 --- a/crates/cm-api/src/phase_runner.rs +++ b/crates/cm-api/src/phase_runner.rs @@ -667,6 +667,21 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> { ) .await; } + // The shadow record. A policy that is not enforcing still says what + // it would have done, and that is the only evidence that decides + // whether it is safe to enforce. + for line in crate::container_tool_hooks::drain_would_deny(&docker, &container).await { + crate::mission_events::record( + pool, + crate::mission_events::MissionEvent::new( + mission_id, + crate::container_tool_hooks::GATE_WOULD_DENY, + ) + .phase(phase_id) + .detail(crate::vm_tool_gate::denial_detail(&line)), + ) + .await; + } let tools = crate::container_tool_hooks::drain(&docker, &container).await; if tools.is_empty() { continue; @@ -1442,6 +1457,9 @@ async fn launch_phase( p.team_engine, crate::vm_stop_gate::StopGate::for_phase(kind, p.config), has_repo, + // Shadow unless the deployment says otherwise: the first weeks + // produce a record of what WOULD have been refused, not refusals. + crate::vm_tool_gate::TaskPolicy::for_phase(p.config), ) .await; } @@ -1690,6 +1708,9 @@ async fn launch_microvm_phase( // workspace at the same guest path instead of a checkout — see // `VmPhase::has_repo`. has_repo: bool, + // The tools this phase's agents may use at all, from its own config. + // Built by the caller, which is where the phase config lives. + task_policy: crate::vm_tool_gate::TaskPolicy, ) -> Result<(), String> { record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await; sqlx::query( @@ -1762,6 +1783,7 @@ async fn launch_microvm_phase( crate::microvm_executor::run_phase_in_vm( &hub, crate::microvm_executor::VmPhase { + task_policy: Some(&task_policy), // Attribution for live output: this is the run a browser // subscribes to for this phase. run_id: Some(run_id), @@ -1846,6 +1868,19 @@ async fn launch_microvm_phase( ) .await; } + for line in &g.would_deny { + crate::mission_events::record( + &pool2, + crate::mission_events::MissionEvent::new( + mission_id, + crate::container_tool_hooks::GATE_WOULD_DENY, + ) + .phase(phase_id) + .run(run_id) + .detail(crate::vm_tool_gate::denial_detail(line)), + ) + .await; + } for line in &g.denied { let detail = crate::vm_tool_gate::denial_detail(line); crate::mission_events::record( diff --git a/crates/cm-api/src/vm_tool_gate.rs b/crates/cm-api/src/vm_tool_gate.rs index ae34ff9..50ea7dc 100644 --- a/crates/cm-api/src/vm_tool_gate.rs +++ b/crates/cm-api/src/vm_tool_gate.rs @@ -274,6 +274,101 @@ const ROLE_POLICIES: &[RolePolicy] = &[RolePolicy { /// deny a file whose CONTENTS mention a denied string. const WRITE_TOOLS: &[&str] = &["write", "edit", "multiedit", "notebookedit"]; +/// The tools a phase's agents may use at all. +/// +/// ActGov's task-permission layer (arXiv 2609.24446): bind the work to the +/// tools it needs and refuse the rest. The honest day-one version is +/// narrower than the paper's, because our corpus cannot yet justify a +/// per-task minimum — 171 recorded tool calls total. What it CAN justify is +/// the line this draws: the **work** surface is allowed, the **platform +/// control** surface is not. +/// +/// That line is not theoretical. `ListAgents` and `ScheduleWakeup` were both +/// called by microVM missions whose `--allowedTools` is +/// `Read Edit Write Bash Agent` — neither is on that list and both ran, +/// because the flag governs permission prompting and not availability. A +/// mission agent enumerating the platform's agents or scheduling itself a +/// wake-up is outside any mission's business, and nothing stopped it. +/// +/// `enforce` is false by default: the gate records what it WOULD have +/// refused and allows the call, because a policy tightened on a guess and +/// enforced on day one is how an agent learns to work around the gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskPolicy { + /// Lowercased tool names, as the extractor prints them. + pub allowed: Vec, + pub enforce: bool, +} + +/// The work surface. Everything Claude Code offers for reading, writing, +/// searching, running commands, fetching, delegating and bookkeeping. +pub const DEFAULT_AGENT_TOOLS: &[&str] = &[ + // Files and search. + "read", "write", "edit", "multiedit", "notebookedit", "glob", "grep", "ls", + // Commands. + "bash", "bashoutput", "killshell", + // The web, which the tiers bound separately (a VM reaches only its + // provider and the forge; a container's network policy is the boundary). + "webfetch", "websearch", + // Delegation and its bookkeeping. + "agent", "task", "todowrite", "exitplanmode", + // Skills, however they are delivered. + "skill", "toolsearch", "readmcpresourcetool", "listmcpresourcestool", +]; + +/// Where the gate records a call it would have refused under a policy that +/// is not being enforced yet. Drained like [`DENIED_FILE`], into +/// `gate.would_deny`, so shadow mode produces evidence rather than opinion. +pub const WOULD_DENY_FILE: &str = "would-deny.jsonl"; + +/// The rule id a task-permission refusal carries. +pub const TASK_PERMISSION_RULE: &str = "task-permission"; + +const TASK_PERMISSION_REASON: &str = "Refusing a tool this phase was not given. \ + The phase's work is done with files, commands, search and delegation; this \ + tool reaches the platform itself rather than the task. If the task genuinely \ + needs it, say so in your output instead of working around it."; + +impl TaskPolicy { + /// The default policy for a phase: the work surface, in shadow. + pub fn default_shadow() -> Self { + TaskPolicy { + allowed: DEFAULT_AGENT_TOOLS.iter().map(|t| t.to_string()).collect(), + enforce: false, + } + } + + /// Read `config.agent_tools` when the phase names its own set, and + /// `CLAWMATES_TASK_PERMISSION=enforce` to stop shadowing. + /// + /// **Not `config.tools`** — `security_scan::run` already owns that key + /// to choose which scanners run, and quietly giving one key two + /// meanings is how a phase ends up enforcing a scanner list as a tool + /// policy. + pub fn for_phase(config: &serde_json::Value) -> Self { + let mut p = Self::default_shadow(); + if let Some(list) = config.get("agent_tools").and_then(|v| v.as_array()) { + let named: Vec = list + .iter() + .filter_map(|v| v.as_str()) + .map(|t| t.trim().to_ascii_lowercase()) + .filter(|t| !t.is_empty()) + .collect(); + if !named.is_empty() { + p.allowed = named; + } + } + p.enforce = std::env::var("CLAWMATES_TASK_PERMISSION") + .map(|v| v.trim().eq_ignore_ascii_case("enforce")) + .unwrap_or(false); + p + } + + fn permits(&self, tool: &str) -> bool { + self.allowed.iter().any(|t| t == tool) + } +} + /// A decision, with the rule that made it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Denial { @@ -319,8 +414,28 @@ pub fn decide( argument: &str, file_path: Option<&str>, role: Option<&str>, +) -> Option { + decide_with(tool, argument, file_path, role, None) +} + +/// As [`decide`], against a phase's task policy as well as the floor. +/// Returns the denial an ENFORCING policy would produce; a shadow policy's +/// caller records it instead of acting on it. +pub fn decide_with( + tool: &str, + argument: &str, + file_path: Option<&str>, + role: Option<&str>, + task: Option<&TaskPolicy>, ) -> Option { let tool = tool.to_ascii_lowercase(); + // Task permission first: a tool the phase was never given is refused + // whatever it was about to do with it. + if let Some(t) = task { + if !t.permits(&tool) { + return Some(Denial { rule: TASK_PERMISSION_RULE, reason: TASK_PERMISSION_REASON }); + } + } // A role's own limits come first: they are narrower than the floor by // construction, and the reason names the role rather than the action. if let Some(role) = role.map(str::trim).filter(|r| !r.is_empty()) { @@ -435,6 +550,16 @@ const TEXT_TOOLS: &[&str] = &[ /// every tool call in the phase, which is precisely what a `case`-syntax bug /// did here before a test ran the script under a real shell. pub fn hook_script(dir: &str) -> String { + hook_script_with(dir, None) +} + +/// As [`hook_script`], carrying a phase's task policy. +/// +/// The policy is baked into the script at install time rather than read +/// from a file at call time: the guest already refuses to let the agent +/// touch `/root/toolhooks`, and a policy the guest could re-read is a +/// policy the guest could be persuaded to re-read from somewhere else. +pub fn hook_script_with(dir: &str, task: Option<&TaskPolicy>) -> String { // The denial body, shared by every rule so the shell and the reason stay // together in one place. // Each denial line is `{"rule":"","payload":}`, so the @@ -536,6 +661,32 @@ pub fn hook_script(dir: &str) -> String { } } + // Task permission. In shadow the call is RECORDED and allowed; the file + // is drained into `gate.would_deny` so a week of real traffic says + // whether the set is right before anything is refused on it. + let task_check = match task { + None => String::new(), + Some(t) => { + let allowed = t.allowed.join("|"); + let body = if t.enforce { + deny(TASK_PERMISSION_RULE, TASK_PERMISSION_REASON, " ") + } else { + format!( + " printf '{{\"rule\":\"{rule}\",\"payload\":%s}}\\n' \"$payload\" >> {dir}/{file} 2>/dev/null\n \x20 ;;\n", + rule = TASK_PERMISSION_RULE, + dir = dir, + file = WOULD_DENY_FILE, + ) + }; + format!( + "case \"$tool\" in\n\ + \x20 {allowed}) ;;\n\ + \x20 *)\n{body}\ + esac\n" + ) + } + }; + // One `case` per role policy, rendered from the same table the Rust // predicate reads, so the guest and the host cannot disagree about who // may do what. @@ -574,6 +725,8 @@ pub fn hook_script(dir: &str) -> String { cmd=$(printf '%s\\n' \"$info\" | sed -n 2p)\n\ path=$(printf '%s\\n' \"$info\" | sed -n 3p)\n\ role=$(printf '%s\\n' \"$info\" | sed -n 4p)\n\ + # Was this phase given this tool at all?\n\ + {task_check}\ # A role's own limits. Empty role = the lead's own call, which no\n\ # role policy binds.\n\ {role_checks}\ @@ -615,6 +768,7 @@ pub fn hook_script(dir: &str) -> String { exit 0\n", extract = NODE_EXTRACT, inert = INERT_FILE, + task_check = task_check, role_checks = role_checks, write_tools = WRITE_TOOLS.join("|"), protected = PROTECTED_PATHS @@ -658,9 +812,14 @@ pub fn settings_hook(dir: &str) -> Value { /// One shell command that installs the gate. pub fn install_command(dir: &str) -> String { + install_command_with(dir, None) +} + +/// As [`install_command`], carrying a phase's task policy. +pub fn install_command_with(dir: &str, task: Option<&TaskPolicy>) -> String { format!( "mkdir -p {dir} && cat > {dir}/tool-gate.sh <<'CM_GATE_EOF'\n{}\nCM_GATE_EOF\nchmod +x {dir}/tool-gate.sh", - hook_script(dir) + hook_script_with(dir, task) ) } @@ -786,6 +945,46 @@ mod tests { assert_eq!(ids.len(), RULES.len()); } + /// The work surface passes; the platform-control surface does not. + /// The two named tools are the ones microVM missions actually called + /// with neither on `--allowedTools`. + #[test] + fn task_permission_allows_work_and_refuses_platform_control() { + let p = TaskPolicy::default_shadow(); + for tool in ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "Agent", "WebFetch", "Skill"] { + assert!( + decide_with(tool, "x", None, None, Some(&p)).is_none(), + "{tool} is ordinary work and must pass" + ); + } + for tool in ["ListAgents", "ScheduleWakeup", "CronCreate", "SendMessage", "EndConversation"] { + let d = decide_with(tool, "", None, None, Some(&p)) + .unwrap_or_else(|| panic!("{tool} reaches the platform and must be refused")); + assert_eq!(d.rule, TASK_PERMISSION_RULE); + } + // A phase may name its own, narrower set. + let narrow = TaskPolicy::for_phase(&serde_json::json!({"agent_tools": ["Read", "Grep"]})); + assert!(decide_with("Read", "", None, None, Some(&narrow)).is_none()); + assert_eq!( + decide_with("Bash", "ls", None, None, Some(&narrow)).unwrap().rule, + TASK_PERMISSION_RULE + ); + // `tools` is security_scan's key and must not be read as this one. + let scanners = TaskPolicy::for_phase(&serde_json::json!({"tools": ["cargo_audit"]})); + assert!(decide_with("Bash", "cargo test", None, None, Some(&scanners)).is_none()); + // No policy at all is the behaviour every existing caller has. + assert!(decide_with("ListAgents", "", None, None, None).is_none()); + } + + /// Task permission is checked before the floor: a tool the phase never + /// had is refused for that reason, not for what it was about to do. + #[test] + fn a_tool_the_phase_never_had_is_refused_as_such() { + let p = TaskPolicy::default_shadow(); + let d = decide_with("Bash", "git push --force origin main", None, None, Some(&p)).unwrap(); + assert_eq!(d.rule, "force-push", "bash IS permitted, so the floor decides"); + } + /// A role policy binds that role's subagent and nobody else. #[test] fn the_verifier_may_not_write_and_the_lead_is_untouched() { @@ -892,6 +1091,10 @@ mod shell_tests { /// Run the hook with `payload` on stdin. Returns (exit code, stderr). fn run(payload: &str) -> (i32, String) { + run_with(payload, None) + } + + fn run_with(payload: &str, task: Option<&TaskPolicy>) -> (i32, String) { // Unique per invocation: these tests run in parallel and each removes // its directory afterwards, so a shared path has them deleting the // script out from under each other. @@ -900,7 +1103,7 @@ mod shell_tests { let dir = std::env::temp_dir().join(format!("cm-gate-{}-{seq}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let script = dir.join("tool-gate.sh"); - std::fs::write(&script, hook_script(&dir.to_string_lossy())).unwrap(); + std::fs::write(&script, hook_script_with(&dir.to_string_lossy(), task)).unwrap(); let mut child = Command::new("sh") .arg(&script) @@ -917,8 +1120,10 @@ mod shell_tests { .unwrap(); let out = child.wait_with_output().expect("wait"); let denied = std::fs::read_to_string(dir.join(DENIED_FILE)).unwrap_or_default(); + let would = std::fs::read_to_string(dir.join(WOULD_DENY_FILE)).unwrap_or_default(); let _ = std::fs::remove_dir_all(&dir); LAST_DENIED.with(|d| *d.borrow_mut() = denied); + LAST_WOULD_DENY.with(|d| *d.borrow_mut() = would); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stderr).to_string(), @@ -934,6 +1139,13 @@ mod shell_tests { LAST_DENIED.with(|d| d.borrow().clone()) } + thread_local! { + static LAST_WOULD_DENY: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; + } + fn last_would_deny() -> String { + LAST_WOULD_DENY.with(|d| d.borrow().clone()) + } + /// The record of a denial names the rule and carries the whole event, /// as one JSON object per line the host can parse without guessing. #[test] @@ -948,6 +1160,33 @@ mod shell_tests { assert_eq!(v["payload"]["tool_input"]["command"], "git push --force origin main"); } + /// Task permission in the generated shell, both ways round. Shadow + /// RECORDS and allows — the whole point of the mode — and enforcing + /// refuses. Ordinary work passes in both. + #[test] + fn the_shell_shadows_then_enforces_task_permission() { + let stray = r#"{"tool_name":"ListAgents","tool_input":{}}"#; + let work = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test"}}"#; + + let shadow = TaskPolicy::default_shadow(); + let (code, _) = run_with(stray, Some(&shadow)); + assert_eq!(code, 0, "shadow must ALLOW, or it is not shadow"); + let rec: serde_json::Value = serde_json::from_str(last_would_deny().trim()) + .unwrap_or_else(|e| panic!("would-deny line is not JSON ({e}): {}", last_would_deny())); + assert_eq!(rec["rule"], TASK_PERMISSION_RULE); + assert_eq!(rec["payload"]["tool_name"], "ListAgents"); + assert!(last_denied().is_empty(), "shadow must not write a denial"); + assert_eq!(run_with(work, Some(&shadow)).0, 0); + + let enforcing = TaskPolicy { enforce: true, ..TaskPolicy::default_shadow() }; + let (code, stderr) = run_with(stray, Some(&enforcing)); + assert_eq!(code, 2, "enforcing must refuse: {stderr}"); + assert!(stderr.contains("not given"), "{stderr}"); + let rec: serde_json::Value = serde_json::from_str(last_denied().trim()).unwrap(); + assert_eq!(rec["rule"], TASK_PERMISSION_RULE); + assert_eq!(run_with(work, Some(&enforcing)).0, 0, "work still works under enforcement"); + } + /// The role policy, in the shell that actually runs in the guest: the /// verifier's write is refused with its rule recorded, the lead's /// identical write is allowed, and the verifier's reads and tests are