Files
clawmates/crates/cm-api/src/vm_tool_tap.rs
T
Omar SobhandClaude Opus 5.5 597e76b261
deploy / test (push) Successful in 5m7s
deploy / build (push) Successful in 5m50s
feat(taint): stage 1 of argument provenance — the tap records hosts fetched content named
The "derived from untrusted content" half of ActGov's invariant (no outbound
action whose target came from untrusted content). Observed only; no rule
reads it yet.

- the tap runs a node extractor only when a payload could be a fetch
  (WebFetch, WebSearch, curl/wget in command position) and appends the
  response's URL hosts, minus the agent's own target, to
  untrusted-hosts.txt beside the tap — a path hook-files already protects
- capped at 500, deduplicated, and the tap still always exits 0
- both tiers drain it per finished phase into a taint.hosts event
- shell-tested against the generated hook with the real node; the test caught
  `grep -r curl docs` being read as a fetch

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-22 18:27:07 -05:00

832 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! What the agent did inside a microVM, taken from Claude Code's own hooks.
//!
//! The microVM tier had no action channel at all: a phase ran, a diff came
//! back, and everything between was invisible. The seam is the same one
//! [`crate::vm_stop_gate`] proved works in this image — `PostToolUse` fires
//! under `claude -p`, measured, not read off documentation.
//!
//! # The observer must not become a participant
//!
//! The hook `exit 0`s unconditionally. A `PostToolUse` hook that exits non-zero
//! feeds its stderr back to the model, so a tap with a bug would start
//! *instructing* the agent it exists to watch — and the resulting transcript
//! would look like a model that lost the plot rather than a broken hook.
//!
//! # Never inside the repository
//!
//! Everything lives under `/root`. `/mission/repo` is collected and diffed, so
//! a tap file written there would arrive in the user's delivered patch as
//! though an agent had authored it — the same rule, and the same reason, as the
//! stop gate's [`crate::vm_stop_gate::GATE_DIR`].
use serde_json::{json, Value};
/// Where the tap writes in the guest. Under `/root`, never the repo.
pub const TAP_DIR: &str = "/root/tap";
/// The file the hook appends to, one JSON object per line.
pub const TAP_FILE: &str = "/root/tap/tools.jsonl";
/// The single settings document the guest agent runs with.
///
/// One path, because there is only ever one writer — see [`guest_settings`].
pub const SETTINGS_PATH: &str = "/root/guest-settings.json";
/// Read the tap out of the guest, before collection destroys the VM.
///
/// `|| true` so a phase whose agent called no tools — or where the hook never
/// fired — reads as empty rather than as a failed probe. The difference between
/// those two is the histogram in the log, not an error here.
pub const DRAIN_PROBE: &str = "cat /root/tap/tools.jsonl 2>/dev/null || true";
/// Read the tap from line `from` onward, so a repeated drain returns only what
/// is new.
///
/// A cursor rather than a re-read: the live drain runs every few seconds
/// against a file the agent is still appending to, and re-sending the whole
/// file each pass would record every tool call once per poll — a phase would
/// finish with its early files weighted by how long it ran.
///
/// `tail -n +N` is 1-based on the FIRST line to print, so `from` is a line
/// count already consumed and the probe asks for `from + 1`.
pub fn drain_from(from: usize) -> String {
format!("tail -n +{} {TAP_FILE} 2>/dev/null || true", from + 1)
}
/// How far a drain advanced the cursor — the number of LINES it consumed.
///
/// Counts every line, including blank ones, and that is the whole point. The
/// hook appends the event and then a newline of its own, so the tap is
/// `{json}\n\n{json}\n\n…` and `parse` skips the blanks. Advancing the cursor
/// by the number of PARSED events instead would leave it short by one line per
/// event, and `tail -n +N` would hand back events already recorded — every one
/// of them written again on the next poll, with nothing anywhere reporting it.
pub fn consumed_lines(raw: &str) -> usize {
raw.lines().count()
}
/// One observed tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Observed {
pub tool: String,
/// The path the tool's **input** named, if any. From JSON, never prose.
pub path: Option<String>,
/// Claude Code's session id for the `claude -p` invocation this call
/// happened inside.
///
/// One invocation is one turn is one agent, so this is the only thing in
/// the payload that separates one agent's actions from another's. The tap
/// is per-CONTAINER and every role in a phase shares one, so without this
/// the whole phase arrives as an undifferentiated stream.
pub session: Option<String>,
/// What a **command** produced, bounded by [`bounded_response`].
///
/// Only for tools that run something. `Read`'s response is the file it just
/// read and `Write`'s is a restatement of what was written — both are
/// already knowable from the arguments and the delivered diff, and storing
/// them would double the largest write path in the system for nothing.
///
/// A command's OUTCOME is different: it is the only place a failing test
/// run is visible. Without it "did this phase go red before it went green"
/// cannot be answered from anything — not from tool order (in Rust the
/// unit test lives in the file under test, so one `Edit` adds both), and
/// not from the repository either, because `tdd-red-green-refactor` says
/// in so many words to "commit the RED-to-GREEN pair as one commit".
pub response: Value,
/// The subagent that made this call, when it was not the turn's own agent.
///
/// Claude Code's `Agent` tool spawns a subagent that runs its own tools,
/// and those calls DO reach this hook — measured against the real binary,
/// which is the good news, because it means nothing is invisible. What they
/// carry is the PARENT's `session_id`, so [`Observed::session`] cannot tell
/// them apart and attribution silently credits the parent for work a
/// subagent did.
///
/// The payload has always said so: `agent_type` and `agent_id` are present
/// on a subagent's call and absent on the parent's. This parser read past
/// them. Two production missions spawned twelve subagents to fetch web
/// pages, and every tool call they made was recorded as the parent's with
/// nothing anywhere reporting the difference.
pub subagent: Option<String>,
/// Which subagent instance, so several running under one turn stay apart.
pub subagent_id: Option<String>,
/// The tool's arguments, bounded by [`bounded_input`].
///
/// Kept because the tool NAME alone answers almost nothing. A phase that
/// recorded `Bash × 6` is indistinguishable from one that ran the test
/// suite six times, one that pushed to a branch it was told not to, and
/// one that queried an API a skill forbids. The argument is where the
/// behaviour is, and until now this parser read it, took the path out of
/// it, and dropped the rest on the floor.
pub input: Value,
}
/// How much of one argument string is worth keeping.
///
/// A shell command longer than this is a heredoc or a generated payload; its
/// first half still carries the verb, which is what any check reads.
const MAX_ARG_LEN: usize = 512;
/// Argument keys whose value is a file BODY rather than a description of an
/// action.
///
/// Dropped to a byte count rather than truncated. These carry whole source
/// files — `mission_events` is already the largest write path on a coding
/// phase, and storing every `Write` twice (once in the event, once in the
/// delivered diff) buys nothing: no check reads the body, and the diff is the
/// authority on what was written anyway.
const BODY_KEYS: [&str; 4] = ["content", "new_string", "old_string", "edits"];
/// Tools whose response is an outcome rather than a restatement.
const RESPONSE_TOOLS: [&str; 1] = ["Bash"];
/// How much of a command's output to keep.
const MAX_OUTPUT_LEN: usize = 600;
/// Shrink a command's response, keeping the **end** of its output.
///
/// The opposite of [`bounded_input`], and deliberately so. An argument's
/// meaning is at the start — the verb of the command. A command's meaning is at
/// the END: `cargo test` prints hundreds of lines and then `test result: ok` or
/// `test result: FAILED`, and a head-biased truncation would keep the noise and
/// throw away the verdict, which is the one thing being stored for.
pub fn bounded_response(tool: &str, response: &Value) -> Value {
if !RESPONSE_TOOLS.contains(&tool) {
return Value::Null;
}
let Some(obj) = response.as_object() else {
return Value::Null;
};
let mut out = serde_json::Map::new();
for key in ["stdout", "stderr", "interrupted"] {
match obj.get(key) {
Some(Value::String(s)) if s.len() > MAX_OUTPUT_LEN => {
let start = s
.char_indices()
.map(|(i, _)| i)
.find(|i| *i >= s.len().saturating_sub(MAX_OUTPUT_LEN))
.unwrap_or(0);
out.insert(key.into(), Value::String(format!("[truncated]…{}", &s[start..])));
}
Some(v) => {
out.insert(key.into(), v.clone());
}
None => {}
}
}
Value::Object(out)
}
/// Shrink a tool's arguments to something safe to store on every call.
///
/// Bounded rather than whitelisted on purpose. A whitelist of "interesting"
/// keys silently drops the one argument that matters the first time a tool
/// grows a new field, and the loss is invisible — the event still looks
/// complete. Bounding keeps every key and says, in the record itself, where it
/// stopped.
pub fn bounded_input(input: &Value) -> Value {
let Some(obj) = input.as_object() else {
return Value::Null;
};
let mut out = serde_json::Map::new();
for (k, v) in obj {
if BODY_KEYS.contains(&k.as_str()) {
let bytes = match v {
Value::String(s) => s.len(),
other => other.to_string().len(),
};
out.insert(k.clone(), json!({ "omitted_bytes": bytes }));
continue;
}
match v {
Value::String(s) if s.len() > MAX_ARG_LEN => {
let cut = s
.char_indices()
.map(|(i, _)| i)
.take_while(|i| *i <= MAX_ARG_LEN)
.last()
.unwrap_or(0);
out.insert(k.clone(), Value::String(format!("{}…[truncated]", &s[..cut])));
}
other => {
out.insert(k.clone(), other.clone());
}
}
}
Value::Object(out)
}
/// Hosts named in content the agent FETCHED, one per line, beside the tap.
///
/// Stage 1 of argument provenance (docs/TASK-PERMISSION-AND-TAINT.md): the
/// invariant is "no outbound action whose target was derived from untrusted
/// content", and this file is the "derived from untrusted content" half. It is
/// written in the guest because the gate that will read it runs in the guest,
/// and it lives in the tap's directory because the gate's `hook-files` rule
/// already refuses every write there, so the agent it governs cannot erase it.
///
/// Nothing reads it to refuse anything yet. It is drained into
/// `taint.hosts` so what it actually collects can be inspected on real
/// missions before a rule is built on it.
pub const TAINT_FILE: &str = "untrusted-hosts.txt";
/// How many hosts the file may hold. A page with thousands of links must not
/// turn the tap into the largest write in the guest; past the cap, new hosts
/// are dropped, and the host-side record says how many it saw.
pub const MAX_TAINT_HOSTS: usize = 500;
/// Reads one `PostToolUse` event on stdin and prints the hosts its RESPONSE
/// names that its INPUT did not, one per line.
///
/// Only fetching calls count: `WebFetch`, `WebSearch`, and a `Bash` command
/// with `curl` or `wget` in COMMAND position — at the start, or after `;`,
/// `&`, `|`, `(`, a backtick or `$(`. Anywhere else it is an argument:
/// `grep -r curl docs` searches for the word, and counting it as a fetch was
/// the first thing the shell test caught. Hosts, not strings — tainting arbitrary
/// text and matching it against later commands fires on ordinary research
/// immediately, the cardinal failure for this module. A host the agent itself
/// put in the command is its own choice, not the page's, and is excluded.
///
/// Silent on any error, like the gate's extractor: the caller ignores output
/// it cannot use, and the tap never exits non-zero.
pub const NODE_TAINT: &str = r#"let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const n=String(j.tool_name||"");const t=j.tool_input||{};const cmd=String(t.command||"");const fetching=n==="WebFetch"||n==="WebSearch"||(n==="Bash"&&/(^|[;&|(`]|\$\()\s*(curl|wget)\s/.test(cmd));if(!fetching)return;const r=j.tool_response;const text=typeof r==="string"?r:(r&&typeof r==="object"&&n==="Bash")?String(r.stdout||"")+"\n"+String(r.stderr||""):JSON.stringify(r||"");const own=(cmd+" "+String(t.url||"")+" "+String(t.query||"")).toLowerCase();const seen=new Set();const re=/\bhttps?:\/\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)/gi;let m;while((m=re.exec(text))!==null){const h=m[1].toLowerCase();if(!own.includes(h))seen.add(h)}for(const h of seen)process.stdout.write(h+"\n")}catch(e){}})"#;
/// The hook script. Copies stdin verbatim to the tap file and gets out of the
/// way.
///
/// The parsing happens host-side, on purpose: a `jq` or `sed` pipeline in the
/// guest would need the tool's JSON schema baked into a shell script, inside an
/// image we do not rebuild for a parser change, with no way to tell a parse
/// failure from a quiet turn.
///
/// One exception, and it is guest-side because its reader will be: the taint
/// step ([`TAINT_FILE`]). It runs only when the payload could be a fetch — a
/// `node` spawn on every `Read` would tax the hottest path in the guest for
/// nothing — and every failure in it is swallowed, so the tap still exits 0.
pub fn hook_script(dir: &str) -> String {
format!(
"#!/bin/sh\n\
# The tool tap. See cm-api/src/vm_tool_tap.rs.\n\
mkdir -p {dir} 2>/dev/null\n\
# Held once, because stdin can be read once and two things need it.\n\
payload=$(cat)\n\
# Appended whole. One JSON object per line, because Claude Code hands\n\
# the hook one event per invocation.\n\
printf '%s\\n\\n' \"$payload\" >> {dir}/tools.jsonl 2>/dev/null\n\
# Taint: hosts a FETCHED page named. Cheap prefilter first.\n\
case \"$payload\" in\n\
\x20 *'\"WebFetch\"'*|*'\"WebSearch\"'*|*curl*|*wget*)\n\
\x20 printf '%s' \"$payload\" | node -e {taint} 2>/dev/null | while IFS= read -r h; do\n\
\x20 [ -n \"$h\" ] || continue\n\
\x20 grep -qxF \"$h\" {dir}/{file} 2>/dev/null && continue\n\
\x20 [ \"$(cat {dir}/{file} 2>/dev/null | grep -c '')\" -lt {cap} ] || break\n\
\x20 printf '%s\\n' \"$h\" >> {dir}/{file} 2>/dev/null\n\
\x20 done\n\
\x20 ;;\n\
esac\n\
# ALWAYS zero. A non-zero PostToolUse hook talks back to the model.\n\
exit 0\n",
taint = shell_quote(NODE_TAINT),
file = TAINT_FILE,
cap = MAX_TAINT_HOSTS,
)
}
/// Read the taint file. Not cleared: it is state the gate will consult for
/// the rest of the mission, not a log to be consumed.
pub fn taint_probe(dir: &str) -> String {
format!("cat {dir}/{TAINT_FILE} 2>/dev/null || true")
}
/// Parse a drained taint file into hosts, dropping blanks.
pub fn parse_taint(raw: &str) -> Vec<String> {
raw.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
/// The settings document for the guest, carrying **every** hook at once.
///
/// This function exists because the alternative — each feature writing its own
/// `settings.json` — is a silent clobber. The stop gate wrote the whole
/// document; a tap that did the same would erase the gate, and a coding phase
/// would then complete having written nothing, which is the exact failure the
/// gate exists to catch. One writer, one document, one test that both hooks
/// survive it.
///
/// `None` for any part means that hook is simply absent.
pub fn guest_settings(
gate_dir: Option<&str>,
tap_dir: Option<&str>,
tool_gate_dir: Option<&str>,
) -> Value {
let mut hooks = serde_json::Map::new();
if let Some(dir) = gate_dir {
hooks.insert(
"Stop".into(),
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/stop-gate.sh") }] }]),
);
}
if let Some(dir) = tap_dir {
hooks.insert(
"PostToolUse".into(),
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/tap.sh") }] }]),
);
}
if let Some(dir) = tool_gate_dir {
// PRE-execution, unlike the tap above. Composed here rather than
// written by `vm_tool_gate` itself for the same reason everything else
// is: one writer, one document.
hooks.insert("PreToolUse".into(), crate::vm_tool_gate::settings_hook(dir));
}
json!({ "hooks": Value::Object(hooks) })
}
/// One shell command that installs the tap.
///
/// Written by `printf` through an exec rather than injected with the workspace
/// tar: the tar lands in `/mission/repo`, which is exactly where this must not.
pub fn install_command(dir: &str) -> String {
format!(
"mkdir -p {dir} && rm -f {dir}/tools.jsonl {dir}/{taint} \
&& printf '%s' {script} > {dir}/tap.sh && chmod +x {dir}/tap.sh",
dir = dir,
taint = TAINT_FILE,
script = shell_quote(&hook_script(dir)),
)
}
/// Write the composed settings document.
pub fn settings_command(path: &str, settings: &Value) -> String {
format!("printf '%s' {} > {path}", shell_quote(&settings.to_string()))
}
/// Parse a drained tap.
///
/// Tolerant by construction: the file is appended to by a shell hook in a VM
/// that may be killed mid-write, so a truncated last line is expected and is
/// skipped rather than failing the whole drain. Losing the last tool call of a
/// phase costs one orb; losing all of them because of it would cost the tier.
pub fn parse(raw: &str) -> Vec<Observed> {
raw.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.filter_map(|line| {
let v: Value = serde_json::from_str(line).ok()?;
// Only tool events. The same hook file would carry others if the
// settings ever install one, and a `hook_event_name` we do not
// recognise must not be read as a tool named "".
let tool = v
.get("tool_name")
.or_else(|| v.get("toolName"))
.and_then(Value::as_str)?
.trim()
.to_string();
if tool.is_empty() {
return None;
}
let input = v
.get("tool_input")
.or_else(|| v.get("toolInput"))
.cloned()
.unwrap_or(Value::Null);
let response = v
.get("tool_response")
.or_else(|| v.get("toolResponse"))
.cloned()
.unwrap_or(Value::Null);
Some(Observed {
path: crate::mission_events::tool_path(&input),
input: bounded_input(&input),
response: bounded_response(&tool, &response),
session: v
.get("session_id")
.or_else(|| v.get("sessionId"))
.and_then(Value::as_str)
.map(str::to_string),
// Absent on the turn agent's own calls, present on a
// subagent's. That absence IS the signal, so an empty string
// must read as "not a subagent" rather than as one named "".
subagent: non_empty(&v, "agent_type", "agentType"),
subagent_id: non_empty(&v, "agent_id", "agentId"),
tool,
})
})
.collect()
}
/// A string field under either spelling, treating empty as missing.
fn non_empty(v: &Value, snake: &str, camel: &str) -> Option<String> {
v.get(snake)
.or_else(|| v.get(camel))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
/// Single-quote for `sh`. Local copy, same rule as the stop gate's — these two
/// modules deliberately share no code, so neither can break the other.
pub fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
/// The gate and the tap must BOTH survive one settings document.
///
/// This is the whole reason `guest_settings` exists. Two writers each
/// producing a whole `settings.json` is not a merge conflict — the second
/// simply wins, no error, and the loser's hook never runs. When the loser is
/// the stop gate, a coding phase completes having written nothing: the exact
/// failure the gate was built to catch.
#[test]
fn both_hooks_survive_one_settings_document() {
let s = guest_settings(Some("/root/gate"), Some(TAP_DIR), None);
let hooks = s.get("hooks").expect("hooks");
assert_eq!(
hooks["Stop"][0]["hooks"][0]["command"],
json!("/root/gate/stop-gate.sh"),
"the stop gate must survive the tap being installed"
);
assert_eq!(
hooks["PostToolUse"][0]["hooks"][0]["command"],
json!("/root/tap/tap.sh")
);
}
/// Either half absent leaves the other exactly as it was.
#[test]
fn one_hook_alone_is_a_valid_document() {
let gate_only = guest_settings(Some("/root/gate"), None, None);
assert!(gate_only["hooks"].get("Stop").is_some());
assert!(gate_only["hooks"].get("PostToolUse").is_none());
let tap_only = guest_settings(None, Some(TAP_DIR), None);
assert!(tap_only["hooks"].get("Stop").is_none());
assert!(tap_only["hooks"].get("PostToolUse").is_some());
}
/// The observer must never talk back to the model.
#[test]
fn the_hook_always_exits_zero() {
let s = hook_script(TAP_DIR);
assert!(s.contains("exit 0"));
// No conditional exits at all: a `PostToolUse` hook that exits non-zero
// feeds stderr back to the agent, so the tap would become an instruction.
assert!(!s.contains("exit 1") && !s.contains("exit 2"), "{s}");
}
/// Nothing the tap writes may land in the delivered tree.
#[test]
fn the_tap_never_writes_into_the_repository() {
assert!(TAP_DIR.starts_with("/root/"));
assert!(TAP_FILE.starts_with("/root/"));
assert!(SETTINGS_PATH.starts_with("/root/"));
let cmd = install_command(TAP_DIR);
assert!(!cmd.contains("/mission/repo"), "{cmd}");
assert!(!hook_script(TAP_DIR).contains("/mission/repo"));
}
/// A real `PostToolUse` payload gives up its tool and its path — and a
/// truncated final line does not take the rest of the phase with it.
#[test]
fn a_drained_tap_parses_and_tolerates_a_torn_last_line() {
let raw = concat!(
r#"{"hook_event_name":"PostToolUse","tool_name":"Edit","#,
r#""tool_input":{"file_path":"/mission/repo/src/a.rs"}}"#,
"\n",
r#"{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}"#,
"\n",
"\n",
// The VM was destroyed mid-write.
r#"{"hook_event_name":"PostToolUse","tool_name":"Wri"#,
);
assert_eq!(
parse(raw),
vec![
Observed {
tool: "Edit".into(),
path: Some("/mission/repo/src/a.rs".into()),
input: json!({"file_path": "/mission/repo/src/a.rs"}),
session: None,
subagent: None,
subagent_id: None,
response: Value::Null,
},
Observed {
tool: "Bash".into(),
path: None,
input: json!({"command": "ls"}),
session: None,
subagent: None,
subagent_id: None,
response: Value::Null,
},
]
);
}
/// A subagent's tool calls reach this hook carrying the PARENT's session
/// id, so the only thing that separates them is `agent_type`/`agent_id`.
///
/// Measured against claude 2.1.246: spawning one subagent and having it run
/// `echo SUB` produced three `PostToolUse` events on one session id — the
/// parent's `Agent` call, the parent's own `Bash`, and the subagent's
/// `Bash` — and only the last carried an `agent_type`. Reading past those
/// fields is what made twelve production subagent spawns indistinguishable
/// from the work of the agents that spawned them.
#[test]
fn a_subagents_call_is_told_apart_from_its_parents() {
let raw = concat!(
r#"{"tool_name":"Agent","session_id":"s1","tool_input":{"prompt":"fetch it"}}"#,
"\n",
r#"{"tool_name":"Bash","session_id":"s1","tool_input":{"command":"echo PARENT"}}"#,
"\n",
r#"{"tool_name":"Bash","session_id":"s1","agent_id":"a0b8","agent_type":"general-purpose","#,
r#""tool_input":{"command":"echo SUB"}}"#,
);
let got = parse(raw);
assert_eq!(got.len(), 3);
assert!(
got.iter().all(|o| o.session.as_deref() == Some("s1")),
"a subagent shares its parent's session id — that is the whole problem"
);
assert_eq!(got[0].subagent, None, "the parent spawned it; it did not run inside it");
assert_eq!(got[1].subagent, None);
assert_eq!(got[2].subagent.as_deref(), Some("general-purpose"));
assert_eq!(got[2].subagent_id.as_deref(), Some("a0b8"));
}
/// An empty `agent_type` must read as "the turn's own agent", not as a
/// subagent whose name happens to be blank.
#[test]
fn a_blank_agent_type_is_not_a_subagent() {
let raw = r#"{"tool_name":"Bash","session_id":"s1","agent_type":" ","tool_input":{"command":"ls"}}"#;
assert_eq!(parse(raw)[0].subagent, None);
}
/// The command survives the parse.
///
/// The regression this guards is the one that made the first container-tier
/// measurement unusable: six `Bash` calls were recorded and not one of them
/// said what it ran, so every behavioural question — did it run the tests,
/// did it commit, did it call the API a skill forbids — was unanswerable
/// from a record that looked complete.
#[test]
fn the_argument_is_what_carries_the_behaviour() {
let raw = concat!(
r#"{"tool_name":"Bash","tool_input":{"command":"cargo nextest run -p cm-api"}}"#,
"\n",
);
let got = parse(raw);
assert_eq!(got[0].input["command"], json!("cargo nextest run -p cm-api"));
}
/// A file body is counted, not stored; everything else survives bounded.
#[test]
fn bodies_are_dropped_and_long_arguments_are_marked() {
let long = "x".repeat(MAX_ARG_LEN + 50);
let got = bounded_input(&json!({
"file_path": "/mission/repo/src/a.rs",
"content": "fn main() {}",
"command": long,
}));
assert_eq!(got["file_path"], json!("/mission/repo/src/a.rs"));
assert_eq!(
got["content"],
json!({"omitted_bytes": 12}),
"a file body is stored in the delivered diff already; the event only \
needs to say how big it was"
);
let cmd = got["command"].as_str().expect("command kept");
assert!(cmd.ends_with("…[truncated]"), "{cmd}");
assert!(
cmd.len() < MAX_ARG_LEN + 40,
"a bounded argument must actually be bounded: {}",
cmd.len()
);
}
/// Truncation must not split a multi-byte character.
///
/// `&s[..cut]` on a byte index inside a UTF-8 sequence panics, and the
/// panic would land in the drain — losing a whole phase's tap to a command
/// that happened to contain an emoji or an em dash.
#[test]
fn truncation_respects_character_boundaries() {
let long = "é".repeat(MAX_ARG_LEN);
let got = bounded_input(&json!({ "command": long }));
assert!(got["command"].as_str().unwrap().ends_with("…[truncated]"));
}
/// Exactly one place in the tree writes the guest settings document.
///
/// The unit test above proves `guest_settings` composes correctly; it says
/// nothing about whether anyone bypasses it. A second `> …settings.json`
/// anywhere is the silent clobber itself, and it would pass every other
/// test in this file.
#[test]
fn nothing_else_writes_the_guest_settings() {
for (name, src) in [
("vm_stop_gate.rs", include_str!("vm_stop_gate.rs")),
("microvm_executor.rs", include_str!("microvm_executor.rs")),
] {
assert!(
!src.contains("> {d}/settings.json") && !src.contains("settings.json\","),
"{name} writes a settings document of its own; compose it through \
vm_tool_tap::guest_settings instead"
);
}
// And the one legitimate writer is this module's own helper.
let exec = include_str!("microvm_executor.rs");
assert_eq!(
exec.matches("vm_tool_tap::settings_command").count(),
1,
"the settings document must be written exactly once per turn"
);
}
/// The cursor must not re-read what it already returned.
///
/// Off by one here is not a crash, it is a double-count: `tail -n +1` and
/// `tail -n +2` both return output, and the wrong one quietly records every
/// early tool call once per poll.
#[test]
fn the_drain_cursor_asks_for_what_it_has_not_seen() {
assert!(drain_from(0).contains("tail -n +1 "));
assert!(drain_from(3).contains("tail -n +4 "));
assert!(drain_from(0).contains(TAP_FILE));
}
/// The cursor counts LINES, not events.
///
/// The hook writes the event and then a newline of its own, so a two-event
/// tap is four lines. Advancing by parsed-event count would leave the
/// cursor two lines short, `tail` would return both events again, and the
/// live drain would re-record everything it had already recorded — growing
/// worse the longer the turn ran, and silent throughout.
#[test]
fn the_cursor_counts_lines_not_events() {
let raw = concat!(
r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#,
"\n\n",
r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#,
"\n\n",
);
assert_eq!(parse(raw).len(), 2, "two events");
assert_eq!(consumed_lines(raw), 4, "…written across four lines");
}
/// An event that is not a tool call is not a tool named "".
#[test]
fn a_non_tool_event_is_skipped() {
assert!(parse(r#"{"hook_event_name":"SessionStart","session_id":"x"}"#).is_empty());
assert!(parse(r#"{"tool_name":" ","tool_input":{}}"#).is_empty());
}
}
#[cfg(test)]
mod taint_tests {
use super::*;
/// Run the GENERATED hook, with the real `node`, on each payload in turn.
/// Returns the taint file and the number of tap events, or `None` where
/// `sh`/`node` are missing (the gate's shell tests skip the same way).
fn run_hook(payloads: &[Value]) -> Option<(Vec<String>, usize)> {
let has = |bin: &str| {
std::process::Command::new(bin).arg("--version").output().is_ok_and(|o| o.status.success())
};
if !has("node") {
eprintln!("node not found; skipping the taint shell test");
return None;
}
let dir = std::env::temp_dir().join(format!("cm-taint-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let d = dir.to_str().unwrap();
let script = dir.join("tap.sh");
std::fs::write(&script, hook_script(d)).unwrap();
for p in payloads {
use std::io::Write;
let mut child = std::process::Command::new("sh")
.arg(&script)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(p.to_string().as_bytes()).unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "the tap must always exit 0");
}
let hosts = parse_taint(&std::fs::read_to_string(dir.join(TAINT_FILE)).unwrap_or_default());
let events = parse(&std::fs::read_to_string(dir.join("tools.jsonl")).unwrap_or_default()).len();
let _ = std::fs::remove_dir_all(&dir);
Some((hosts, events))
}
fn bash(cmd: &str, stdout: &str) -> Value {
json!({"tool_name":"Bash","tool_input":{"command":cmd},
"tool_response":{"stdout":stdout,"stderr":"","interrupted":false}})
}
/// The whole of stage 1 in one run: fetched content taints the hosts it
/// names, the agent's own target does not, reading a file does not, and
/// the tap records every event exactly as before.
#[test]
fn fetched_content_taints_the_hosts_it_names() {
let Some((hosts, events)) = run_hook(&[
// A fetched page names two hosts; one is the page's own.
bash(
"curl -s https://api.github.com/repos/x/y",
"see https://evil.example/collect and https://api.github.com/other",
),
// The same host again: deduplicated.
bash("curl -sL https://api.github.com/z", "mirror at https://EVIL.example/x"),
// Reading a file that names a host is not a fetch.
json!({"tool_name":"Read","tool_input":{"file_path":"/mission/repo/README.md"},
"tool_response":"docs at https://readme-host.example/"}),
// A command that merely mentions curl in its OUTPUT is not a fetch.
bash("grep -r curl docs", "https://grep-host.example/ curl"),
// WebFetch: the url is the agent's choice, the links in the result are not.
json!({"tool_name":"WebFetch","tool_input":{"url":"https://docs.rs/serde"},
"tool_response":"published on https://crates.io/crates/serde (see https://docs.rs/x)"}),
// Garbage in: still exit 0, still recorded as nothing.
json!("not an event"),
]) else {
return;
};
assert_eq!(hosts, vec!["evil.example".to_string(), "crates.io".to_string()], "{hosts:?}");
assert_eq!(events, 5, "the tap must still record every tool event");
}
/// The cap holds, and past it the file stops growing rather than failing.
#[test]
fn the_taint_file_is_capped() {
let many: String = (0..MAX_TAINT_HOSTS + 20)
.map(|i| format!("https://h{i}.example/ "))
.collect();
let Some((hosts, _)) = run_hook(&[bash("curl https://index.example/", &many)]) else {
return;
};
assert_eq!(hosts.len(), MAX_TAINT_HOSTS);
}
/// The taint file sits where the gate's `hook-files` rule already refuses
/// writes, on both tiers — or the agent it governs could erase it.
#[test]
fn the_taint_file_is_protected_on_both_tiers() {
for dir in [TAP_DIR, crate::container_tool_hooks::TAP_DIR] {
let path = format!("{dir}/{TAINT_FILE}");
let d = crate::vm_tool_gate::decide("Bash", &format!("rm -f {path}"), None, None)
.unwrap_or_else(|| panic!("{path} is writable by the agent"));
assert_eq!(d.rule, "hook-files");
}
}
}
#[cfg(test)]
mod three_hook_tests {
use super::*;
/// All three hooks must survive one document.
///
/// The tap and the stop gate already shared it; the pre-execution gate is
/// the third, and the clobber this function exists to prevent gets more
/// likely with each one. A missing `Stop` lets a phase finish having
/// written nothing; a missing `PreToolUse` runs every command unchecked.
#[test]
fn the_document_carries_the_stop_gate_the_tap_and_the_pre_execution_gate() {
let s = guest_settings(
Some("/root/gate"),
Some(TAP_DIR),
Some(crate::vm_tool_gate::GUEST_DIR),
);
let hooks = s["hooks"].as_object().expect("hooks object");
assert!(hooks.contains_key("Stop"), "stop gate lost");
assert!(hooks.contains_key("PostToolUse"), "tap lost");
assert!(hooks.contains_key("PreToolUse"), "pre-execution gate lost");
assert_eq!(hooks.len(), 3, "an unexpected hook appeared: {hooks:?}");
// And the pre-execution hook points at the gate's own script, not the
// tap's — pointing PreToolUse at tap.sh would exit 0 on everything and
// read as a gate that allows all.
let cmd = s["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.expect("command");
assert!(cmd.ends_with("tool-gate.sh"), "wrong script: {cmd}");
}
/// The gate alone must still produce a usable document.
#[test]
fn the_gate_can_be_installed_without_the_others() {
let s = guest_settings(None, None, Some(crate::vm_tool_gate::GUEST_DIR));
assert_eq!(s["hooks"].as_object().unwrap().len(), 1);
assert!(s["hooks"]["PreToolUse"].is_array());
}
}