//! 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, } /// 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. 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\ # `cat` of stdin, appended whole. One JSON object per line, because\n\ # Claude Code hands the hook one event per invocation.\n\ cat >> {dir}/tools.jsonl 2>/dev/null\n\ printf '\\n' >> {dir}/tools.jsonl 2>/dev/null\n\ # ALWAYS zero. A non-zero PostToolUse hook talks back to the model.\n\ exit 0\n" ) } /// 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 either half means that hook is simply absent. pub fn guest_settings(gate_dir: Option<&str>, tap_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") }] }]), ); } 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 \ && printf '%s' {script} > {dir}/tap.sh && chmod +x {dir}/tap.sh", dir = dir, script = q(&hook_script(dir)), ) } /// Write the composed settings document. pub fn settings_command(path: &str, settings: &Value) -> String { format!("printf '%s' {} > {path}", q(&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); Some(Observed { path: crate::mission_events::tool_path(&input), tool, }) }) .collect() } /// 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. fn q(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)); 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); 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)); 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()) }, Observed { tool: "Bash".into(), path: None }, ] ); } /// 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()); } }