diff --git a/crates/cm-api/src/container_tool_hooks.rs b/crates/cm-api/src/container_tool_hooks.rs index 9878ec4..e22e293 100644 --- a/crates/cm-api/src/container_tool_hooks.rs +++ b/crates/cm-api/src/container_tool_hooks.rs @@ -296,6 +296,38 @@ pub async fn drain_would_deny(docker: &Docker, container: &str) -> Vec { } } +/// Hosts named in fetched content, as the tap recorded them. One event per +/// finished phase, carrying the whole list: stage 1 of argument provenance +/// is observation only, and this is what gets inspected before any rule is +/// built on it. +pub const TAINT_HOSTS: &str = "taint.hosts"; + +/// Read the tap's taint file. NOT cleared, unlike every drain above: it is +/// the state a future `untrusted-target` rule consults for the rest of the +/// mission, so each phase's event is the set known when that phase ended. +pub async fn drain_taint(docker: &Docker, container: &str) -> Vec { + let argv = vec![ + "sh".to_string(), + "-lc".to_string(), + crate::vm_tool_tap::taint_probe(TAP_DIR), + ]; + match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await + { + Ok(out) => crate::vm_tool_tap::parse_taint(&out.stdout), + Err(_) => Vec::new(), + } +} + +/// The detail of a [`TAINT_HOSTS`] event. +pub fn taint_detail(hosts: &[String], tier: &str) -> serde_json::Value { + serde_json::json!({ + "hosts": hosts, + "count": hosts.len(), + "capped": hosts.len() >= crate::vm_tool_tap::MAX_TAINT_HOSTS, + "tier": tier, + }) +} + /// The tap file inside the mission container. pub fn tap_file() -> String { format!("{TAP_DIR}/tools.jsonl") diff --git a/crates/cm-api/src/microvm_executor.rs b/crates/cm-api/src/microvm_executor.rs index 5ff0771..f86d73f 100644 --- a/crates/cm-api/src/microvm_executor.rs +++ b/crates/cm-api/src/microvm_executor.rs @@ -467,6 +467,10 @@ pub struct VmOutcome { /// them out of a VM until 2026-09-18, so a denial — or a gate that had /// silently given up parsing — left no trace in the mission. pub tool_gate: Option, + /// Hosts named in content the agent fetched — the tap's taint file + /// ([`crate::vm_tool_tap::TAINT_FILE`]). Empty when no tap was installed or + /// nothing was fetched. Observed only: no rule reads it yet. + pub taint_hosts: Vec, } /// The tool gate's own record of a phase. @@ -946,6 +950,18 @@ async fn run_inside( } None => tools, }; + // The taint file, from the same directory and for the same reason: /root + // goes with the VM. + let taint_hosts = match tap_dir { + None => Vec::new(), + Some(dir) => match vm.exec(&crate::vm_tool_tap::taint_probe(dir), None, 60, &[]).await { + Ok(o) => crate::vm_tool_tap::parse_taint(&o.stdout), + Err(e) => { + eprintln!("microvm_executor: taint probe failed on {}: {e}", vm.vm_id()); + Vec::new() + } + }, + }; if tap_dir.is_some() && tools.is_empty() { // A tap that installed and drained nothing is the silent case: the // phase looks the same as it did before the tap existed. Say so, or the @@ -1078,6 +1094,7 @@ async fn run_inside( rootfs, cli_version, tool_gate, + taint_hosts, }) } diff --git a/crates/cm-api/src/microvm_turn_executor.rs b/crates/cm-api/src/microvm_turn_executor.rs index 3c4cbba..155dae5 100644 --- a/crates/cm-api/src/microvm_turn_executor.rs +++ b/crates/cm-api/src/microvm_turn_executor.rs @@ -433,6 +433,7 @@ mod tests { rootfs: None, cli_version: None, tool_gate: None, + taint_hosts: Vec::new(), }) } } @@ -631,6 +632,7 @@ mod tests { rootfs: None, cli_version: None, tool_gate: None, + taint_hosts: Vec::new(), }) } } @@ -666,6 +668,7 @@ mod tests { rootfs: None, cli_version: None, tool_gate: None, + taint_hosts: Vec::new(), }) } } @@ -696,6 +699,7 @@ mod tests { rootfs: None, cli_version: None, tool_gate: None, + taint_hosts: Vec::new(), }) } } diff --git a/crates/cm-api/src/phase_runner.rs b/crates/cm-api/src/phase_runner.rs index 3b1cf4a..ab85262 100644 --- a/crates/cm-api/src/phase_runner.rs +++ b/crates/cm-api/src/phase_runner.rs @@ -682,6 +682,21 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> { ) .await; } + // What fetched content named. Observed, not enforced — see + // docs/TASK-PERMISSION-AND-TAINT.md, piece 2, stage 1. + let hosts = crate::container_tool_hooks::drain_taint(&docker, &container).await; + if !hosts.is_empty() { + crate::mission_events::record( + pool, + crate::mission_events::MissionEvent::new( + mission_id, + crate::container_tool_hooks::TAINT_HOSTS, + ) + .phase(phase_id) + .detail(crate::container_tool_hooks::taint_detail(&hosts, "container")), + ) + .await; + } let tools = crate::container_tool_hooks::drain(&docker, &container).await; if tools.is_empty() { continue; @@ -1854,6 +1869,19 @@ async fn launch_microvm_phase( // vocabulary: `gate.inert` when it gave up parsing and allowed // calls unchecked, `gate.denied` per call it refused. The guest // wrote both files from day one; this is the first reader. + if !o.taint_hosts.is_empty() { + crate::mission_events::record( + &pool2, + crate::mission_events::MissionEvent::new( + mission_id, + crate::container_tool_hooks::TAINT_HOSTS, + ) + .phase(phase_id) + .run(run_id) + .detail(crate::container_tool_hooks::taint_detail(&o.taint_hosts, "microvm")), + ) + .await; + } if let Some(g) = &o.tool_gate { if g.inert > 0 { crate::mission_events::record( diff --git a/crates/cm-api/src/vm_tool_tap.rs b/crates/cm-api/src/vm_tool_tap.rs index e713fbe..e4fd140 100644 --- a/crates/cm-api/src/vm_tool_tap.rs +++ b/crates/cm-api/src/vm_tool_tap.rs @@ -216,6 +216,41 @@ pub fn bounded_input(input: &Value) -> Value { 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. /// @@ -223,20 +258,55 @@ pub fn bounded_input(input: &Value) -> Value { /// 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\ - # `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\ + # 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" + 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 @@ -280,9 +350,10 @@ pub fn guest_settings( /// 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 \ + "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)), ) } @@ -619,6 +690,105 @@ mod tests { } } +#[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::*; diff --git a/docs/TASK-PERMISSION-AND-TAINT.md b/docs/TASK-PERMISSION-AND-TAINT.md index eb7aa9f..fe4513c 100644 --- a/docs/TASK-PERMISSION-AND-TAINT.md +++ b/docs/TASK-PERMISSION-AND-TAINT.md @@ -114,6 +114,16 @@ intersection. **Staging.** 1. Taint extraction in the tap, writing the file. Nothing enforced. Inspect on real missions: what does it actually collect? + + **Built 2026-09-22.** `vm_tool_tap::NODE_TAINT` runs in the tap only when the + payload could be a fetch; the file is `untrusted-hosts.txt` in the tap's own + directory (covered by `hook-files` on both tiers, tested), capped at + `MAX_TAINT_HOSTS` = 500, drained per finished phase into `taint.hosts`. + Rules fixed by its shell test: `curl`/`wget` count only in command position + (`grep -r curl docs` is not a fetch), and a host that also appears in the + agent's own command or WebFetch `url` is the agent's choice, not the + page's. Known gap: `curl -o page.html` then `Read page.html` taints + nothing. The fetched body never passes through a fetching call's response. 2. `untrusted-target` in shadow (`gate.would_deny`), same as piece 1. 3. Enforce on the **container tier**, where public egress is open. On the VM tier it is defence in depth behind an allow-list that already holds.