feat(taint): stage 1 of argument provenance — the tap records hosts fetched content named
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:
co-authored by
Claude Opus 5.5
parent
509b7ceb89
commit
597e76b261
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user