feat(microvm): the tool gate's denials and inert marker reach the mission record
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:
co-authored by
Claude Opus 5
parent
507d7444d1
commit
9fc904a056
@@ -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.
|
/// 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";
|
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.
|
/// 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";
|
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)`.
|
/// 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.
|
/// `None` if the probe failed — which is a fact worth seeing, not a zero.
|
||||||
pub cli_version: Option<String>,
|
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.
|
/// Boot a VM, run the phase in it, collect the result, and destroy it.
|
||||||
@@ -897,6 +936,27 @@ async fn run_inside(
|
|||||||
None
|
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
|
// 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
|
// 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.
|
// only difference from a solo run would be the tokens it did not spend.
|
||||||
@@ -985,6 +1045,7 @@ async fn run_inside(
|
|||||||
tools,
|
tools,
|
||||||
rootfs,
|
rootfs,
|
||||||
cli_version,
|
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 CLI's `--agents` schema takes `tools` as an array. 2.1.276 refused
|
||||||
/// the string form with "verifier.tools: Invalid input" and exited before
|
/// the string form with "verifier.tools: Invalid input" and exited before
|
||||||
/// a single API call; every earlier CLI ignored the definition silently.
|
/// a single API call; every earlier CLI ignored the definition silently.
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
|
tool_gate: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +629,7 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
|
tool_gate: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,6 +664,7 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
|
tool_gate: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,6 +694,7 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
|
tool_gate: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1780,6 +1780,37 @@ async fn launch_microvm_phase(
|
|||||||
// contract exists to prevent.
|
// contract exists to prevent.
|
||||||
if let Ok(o) = &outcome {
|
if let Ok(o) = &outcome {
|
||||||
record_vm_tools(&pool2, mission_id, phase_id, run_id, &o.tools, &[]).await;
|
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
|
// 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
|
// reported. Persisted on the run so "which image and version did this
|
||||||
|
|||||||
Reference in New Issue
Block a user