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")