feat(microvm): the tool gate's denials and inert marker reach the mission record
deploy / test (push) Failing after 1m36s
deploy / build (push) Skipped

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-18 22:40:26 -05:00
co-authored by Claude Opus 5
parent 507d7444d1
commit 9fc904a056
3 changed files with 112 additions and 0 deletions
+77
View File
@@ -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<String>,
/// 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<ToolGateOutcome>,
}
/// 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<String>,
/// 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.
@@ -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,
})
}
}
+31
View File
@@ -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::<serde_json::Value>(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