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
@@ -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