feat(taint): stage 1 of argument provenance — the tap records hosts fetched content named
deploy / test (push) Successful in 5m7s
deploy / build (push) Successful in 5m50s

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]>
This commit is contained in:
Omar Sobh
2026-09-22 18:27:07 -05:00
co-authored by Claude Opus 5.5
parent 509b7ceb89
commit 597e76b261
6 changed files with 267 additions and 6 deletions
+32
View File
@@ -296,6 +296,38 @@ pub async fn drain_would_deny(docker: &Docker, container: &str) -> Vec<String> {
}
}
/// 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<String> {
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")
+17
View File
@@ -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<ToolGateOutcome>,
/// 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<String>,
}
/// 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,
})
}
@@ -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(),
})
}
}
+28
View File
@@ -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(
+176 -6
View File
@@ -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<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
@@ -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<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::*;