From 9fc904a0565ceaf0db705a54f68634747b629447 Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Fri, 18 Sep 2026 22:40:26 -0500 Subject: [PATCH] feat(microvm): the tool gate's denials and inert marker reach the mission record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vm_tool_gate writes denied.jsonl for every call it refuses and an `inert` marker each time it cannot parse its input and lets the call through. The guest has written both since the gate existed; nothing read them out of a VM. A denial, or a gate that had quietly stopped checking, left no trace — the same shape the container tier closed with drain_inert on 09-14. The executor probes both files (one exec, while /root still exists) into VmOutcome.tool_gate; launch_microvm_phase records them on the mission as the container tier's `gate.inert` (with the count) and `gate.denied` (one event per refused call, the gate's own JSON as the detail). Absent gate is None, not zero — "no gate" and "a gate that refused nothing" are different facts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz --- crates/cm-api/src/microvm_executor.rs | 77 ++++++++++++++++++++++ crates/cm-api/src/microvm_turn_executor.rs | 4 ++ crates/cm-api/src/phase_runner.rs | 31 +++++++++ 3 files changed, 112 insertions(+) diff --git a/crates/cm-api/src/microvm_executor.rs b/crates/cm-api/src/microvm_executor.rs index ec6b2e3..211db31 100644 --- a/crates/cm-api/src/microvm_executor.rs +++ b/crates/cm-api/src/microvm_executor.rs @@ -319,6 +319,30 @@ 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. +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", + inert = crate::vm_tool_gate::INERT_FILE, + denied = crate::vm_tool_gate::DENIED_FILE, + ) +} + +/// Parse [`tool_gate_probe`]'s output. +fn parse_tool_gate_probe(out: &str) -> ToolGateOutcome { + let mut o = ToolGateOutcome::default(); + for line in out.lines() { + 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()); + } + } + o +} + /// How many times the stop gate refused to let the agent finish. const BLOCKS_PROBE: &str = "cat /root/gate/blocks 2>/dev/null || echo 0"; @@ -419,6 +443,21 @@ pub struct VmOutcome { /// The guest's own `claude --version`, e.g. `2.1.276 (Claude Code)`. /// `None` if the probe failed — which is a fact worth seeing, not a zero. pub cli_version: Option, + /// What the PreToolUse gate refused (`denied.jsonl` lines) and whether it + /// ever went inert. `None` when no tool gate was installed. The guest + /// wrote these files from the first day the gate existed; nothing read + /// them out of a VM until 2026-09-18, so a denial — or a gate that had + /// silently given up parsing — left no trace in the mission. + pub tool_gate: Option, +} + +/// The tool gate's own record of a phase. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct ToolGateOutcome { + /// Raw `denied.jsonl` lines — each one a call the gate refused. + pub denied: Vec, + /// Times the gate could not parse its input and allowed the call anyway. + pub inert: u32, } /// Boot a VM, run the phase in it, collect the result, and destroy it. @@ -897,6 +936,27 @@ async fn run_inside( None } }; + // The tool gate's confession, while /root is still there to read. + let tool_gate = match tool_gate_dir { + None => None, + Some(dir) => match vm.exec(&tool_gate_probe(dir), None, 60, &[]).await { + Ok(p) => Some(parse_tool_gate_probe(&p.stdout)), + Err(e) => { + eprintln!("microvm_executor: tool gate probe failed on {}: {e}", vm.vm_id()); + None + } + }, + }; + if let Some(g) = &tool_gate { + if g.inert > 0 { + eprintln!( + "microvm_executor: the tool gate in {} went INERT {} time(s) — those calls were \ + allowed unchecked", + vm.vm_id(), + g.inert + ); + } + } // Teammates, when a team was asked for. A team mission that formed no team is // silently solo otherwise — it would still deliver, still look fine, and the // only difference from a solo run would be the tokens it did not spend. @@ -985,6 +1045,7 @@ async fn run_inside( tools, rootfs, cli_version, + tool_gate, }) } @@ -1121,6 +1182,22 @@ mod tests { } } + /// The gate's files come out of the guest as one exec: an INERT= count line + /// and the raw denied.jsonl. A missing file is an empty section. + #[test] + fn the_tool_gate_probe_parses_denials_and_the_inert_count() { + let out = "INERT=2\n{\"tool\":\"Bash\",\"reason\":\"exfil\"}\n\n{\"tool\":\"Bash\",\"reason\":\"rm -rf\"}\n"; + let g = parse_tool_gate_probe(out); + assert_eq!(g.inert, 2); + assert_eq!(g.denied.len(), 2); + 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}"); + } + /// The CLI's `--agents` schema takes `tools` as an array. 2.1.276 refused /// the string form with "verifier.tools: Invalid input" and exited before /// a single API call; every earlier CLI ignored the definition silently. diff --git a/crates/cm-api/src/microvm_turn_executor.rs b/crates/cm-api/src/microvm_turn_executor.rs index aec6096..a930dc6 100644 --- a/crates/cm-api/src/microvm_turn_executor.rs +++ b/crates/cm-api/src/microvm_turn_executor.rs @@ -431,6 +431,7 @@ mod tests { tools: Vec::new(), rootfs: None, cli_version: None, + tool_gate: None, }) } } @@ -628,6 +629,7 @@ mod tests { tools: Vec::new(), rootfs: None, cli_version: None, + tool_gate: None, }) } } @@ -662,6 +664,7 @@ mod tests { tools: Vec::new(), rootfs: None, cli_version: None, + tool_gate: None, }) } } @@ -691,6 +694,7 @@ mod tests { tools: Vec::new(), rootfs: None, cli_version: None, + tool_gate: None, }) } } diff --git a/crates/cm-api/src/phase_runner.rs b/crates/cm-api/src/phase_runner.rs index c9738ad..7150763 100644 --- a/crates/cm-api/src/phase_runner.rs +++ b/crates/cm-api/src/phase_runner.rs @@ -1780,6 +1780,37 @@ async fn launch_microvm_phase( // contract exists to prevent. if let Ok(o) = &outcome { record_vm_tools(&pool2, mission_id, phase_id, run_id, &o.tools, &[]).await; + // The gate's own record, on the mission, in the container tier's + // vocabulary: `gate.inert` when it gave up parsing and allowed + // calls unchecked, `gate.denied` per call it refused. The guest + // wrote both files from day one; this is the first reader. + if let Some(g) = &o.tool_gate { + if g.inert > 0 { + crate::mission_events::record( + &pool2, + crate::mission_events::MissionEvent::new( + mission_id, + crate::container_tool_hooks::GATE_INERT, + ) + .phase(phase_id) + .run(run_id) + .detail(serde_json::json!({ "occurrences": g.inert, "tier": "microvm" })), + ) + .await; + } + for line in &g.denied { + let detail = serde_json::from_str::(line) + .unwrap_or_else(|_| serde_json::json!({ "raw": line })); + crate::mission_events::record( + &pool2, + crate::mission_events::MissionEvent::new(mission_id, "gate.denied") + .phase(phase_id) + .run(run_id) + .detail(detail), + ) + .await; + } + } } // What actually ran: the rootfs the node booted and the CLI the guest // reported. Persisted on the run so "which image and version did this