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]>
832 lines
37 KiB
Rust
832 lines
37 KiB
Rust
//! 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());
|
||
}
|
||
}
|