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.
|
/// The tap file inside the mission container.
|
||||||
pub fn tap_file() -> String {
|
pub fn tap_file() -> String {
|
||||||
format!("{TAP_DIR}/tools.jsonl")
|
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
|
/// 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.
|
/// silently given up parsing — left no trace in the mission.
|
||||||
pub tool_gate: Option<ToolGateOutcome>,
|
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.
|
/// The tool gate's own record of a phase.
|
||||||
@@ -946,6 +950,18 @@ async fn run_inside(
|
|||||||
}
|
}
|
||||||
None => tools,
|
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() {
|
if tap_dir.is_some() && tools.is_empty() {
|
||||||
// A tap that installed and drained nothing is the silent case: the
|
// 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
|
// phase looks the same as it did before the tap existed. Say so, or the
|
||||||
@@ -1078,6 +1094,7 @@ async fn run_inside(
|
|||||||
rootfs,
|
rootfs,
|
||||||
cli_version,
|
cli_version,
|
||||||
tool_gate,
|
tool_gate,
|
||||||
|
taint_hosts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ mod tests {
|
|||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
tool_gate: None,
|
tool_gate: None,
|
||||||
|
taint_hosts: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,6 +632,7 @@ mod tests {
|
|||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
tool_gate: None,
|
tool_gate: None,
|
||||||
|
taint_hosts: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -666,6 +668,7 @@ mod tests {
|
|||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
tool_gate: None,
|
tool_gate: None,
|
||||||
|
taint_hosts: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,6 +699,7 @@ mod tests {
|
|||||||
rootfs: None,
|
rootfs: None,
|
||||||
cli_version: None,
|
cli_version: None,
|
||||||
tool_gate: None,
|
tool_gate: None,
|
||||||
|
taint_hosts: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -682,6 +682,21 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
.await;
|
.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;
|
let tools = crate::container_tool_hooks::drain(&docker, &container).await;
|
||||||
if tools.is_empty() {
|
if tools.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -1854,6 +1869,19 @@ async fn launch_microvm_phase(
|
|||||||
// vocabulary: `gate.inert` when it gave up parsing and allowed
|
// vocabulary: `gate.inert` when it gave up parsing and allowed
|
||||||
// calls unchecked, `gate.denied` per call it refused. The guest
|
// calls unchecked, `gate.denied` per call it refused. The guest
|
||||||
// wrote both files from day one; this is the first reader.
|
// 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 let Some(g) = &o.tool_gate {
|
||||||
if g.inert > 0 {
|
if g.inert > 0 {
|
||||||
crate::mission_events::record(
|
crate::mission_events::record(
|
||||||
|
|||||||
@@ -216,6 +216,41 @@ pub fn bounded_input(input: &Value) -> Value {
|
|||||||
Value::Object(out)
|
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
|
/// The hook script. Copies stdin verbatim to the tap file and gets out of the
|
||||||
/// way.
|
/// 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
|
/// 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
|
/// image we do not rebuild for a parser change, with no way to tell a parse
|
||||||
/// failure from a quiet turn.
|
/// 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 {
|
pub fn hook_script(dir: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"#!/bin/sh\n\
|
"#!/bin/sh\n\
|
||||||
# The tool tap. See cm-api/src/vm_tool_tap.rs.\n\
|
# The tool tap. See cm-api/src/vm_tool_tap.rs.\n\
|
||||||
mkdir -p {dir} 2>/dev/null\n\
|
mkdir -p {dir} 2>/dev/null\n\
|
||||||
# `cat` of stdin, appended whole. One JSON object per line, because\n\
|
# Held once, because stdin can be read once and two things need it.\n\
|
||||||
# Claude Code hands the hook one event per invocation.\n\
|
payload=$(cat)\n\
|
||||||
cat >> {dir}/tools.jsonl 2>/dev/null\n\
|
# Appended whole. One JSON object per line, because Claude Code hands\n\
|
||||||
printf '\\n' >> {dir}/tools.jsonl 2>/dev/null\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\
|
# 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.
|
/// The settings document for the guest, carrying **every** hook at once.
|
||||||
///
|
///
|
||||||
/// This function exists because the alternative — each feature writing its own
|
/// 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.
|
/// tar: the tar lands in `/mission/repo`, which is exactly where this must not.
|
||||||
pub fn install_command(dir: &str) -> String {
|
pub fn install_command(dir: &str) -> String {
|
||||||
format!(
|
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",
|
&& printf '%s' {script} > {dir}/tap.sh && chmod +x {dir}/tap.sh",
|
||||||
dir = dir,
|
dir = dir,
|
||||||
|
taint = TAINT_FILE,
|
||||||
script = shell_quote(&hook_script(dir)),
|
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)]
|
#[cfg(test)]
|
||||||
mod three_hook_tests {
|
mod three_hook_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ intersection.
|
|||||||
**Staging.**
|
**Staging.**
|
||||||
1. Taint extraction in the tap, writing the file. Nothing enforced. Inspect on
|
1. Taint extraction in the tap, writing the file. Nothing enforced. Inspect on
|
||||||
real missions: what does it actually collect?
|
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.
|
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
|
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.
|
tier it is defence in depth behind an allow-list that already holds.
|
||||||
|
|||||||
Reference in New Issue
Block a user