//! 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, /// 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, /// 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, /// Which subagent instance, so several running under one turn stay apart. pub subagent_id: Option, /// 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 { 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 { 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 { 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, 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()); } }