diff --git a/docs/TASK-PERMISSION-AND-TAINT.md b/docs/TASK-PERMISSION-AND-TAINT.md new file mode 100644 index 0000000..eb7aa9f --- /dev/null +++ b/docs/TASK-PERMISSION-AND-TAINT.md @@ -0,0 +1,146 @@ +# Task permission and argument provenance — a design + +Two layers ActGov (arXiv 2609.24446) names that our gate does not have. This +is what it would take to build them here, grounded in what was measured on +2026-09-22 rather than in what the paper assumes. + +Our gate today has one layer: **hard invariants** — what no mission may do, +a global deny-list with rule ids, plus the write-path rules and the first +role policy (`verifier` may not write). Missing: **task permission** (bind a +phase to the tools its task actually needs) and **argument provenance** +(refuse an action whose target came from untrusted content). + +## Four findings that decide the design + +**1. `--allowedTools` is not an enforcement boundary.** The microVM lead is +launched with `--allowedTools Read Edit Write Bash Agent` +(`microvm_executor::LEAD_TOOLS`). The recorded corpus has `ListAgents` and +`ScheduleWakeup` calls **on microvm missions** — neither is on that list, and +both ran. So the flag governs permission prompting, not availability. Any +task-permission layer has to be enforced by our own PreToolUse gate; narrowing +the flag would change nothing and would look like it had. + +**2. The guest already has every tool's output on disk.** `vm_tool_tap`'s +PostToolUse hook appends the **whole payload**, `tool_response` included, to +`tools.jsonl` in `/root/toolhooks` (container) or `/root/tap` (VM). Taint can +therefore be computed *guest-locally*, by the same shell that already runs +there — no network call, no host round trip, no added latency in the tool +path. This is the fact that makes provenance feasible at all. + +**3. The taint store is already protected.** The `hook-files` rule refuses +reads and writes to `/root/toolgate`, `/root/toolhooks`, `/root/tap`, the +settings documents and `.git/hooks/`, from Bash and from the write tools. A +taint file living there cannot be erased by the agent whose calls it governs. + +**4. Provenance matters on one tier far more than the other.** Measured +reachability (`docs/MISSION-EGRESS.md`) and the VM proxy's allow-list: + +| tier | tailnet | host ssh | public internet | +|---|---|---|---| +| microVM | n/a (no NIC; CONNECT proxy) | n/a | **allow-list only** — provider + forge, matched by name, not substring | +| container | blocked | blocked | **open** | + +A microVM cannot exfiltrate to an attacker's host whatever a page tells it: +the allow-list already closes that. A **container mission can reach any public +host**. So the network half of provenance is a container-tier control, and +saying otherwise would be padding. + +## Piece 1 — task permission + +**Shape.** A per-phase allowlist of *tools*, enforced by the gate: a tool +outside the set is denied with rule `task-permission`, alongside the floor and +the role policies, rendered into the same guest script from the same table. + +**Where it is declared.** `mission_phases.config`. **Not** under the key +`tools` — that is taken: `security_scan::run` reads it to choose which of +cargo_audit / gitleaks / trivy_fs / semgrep run. Use `agent_tools`, and have +`phase_config::KNOWN_KEYS` describe both so the collision is visible to +whoever reads the registry next. + +**Where the default comes from.** The corpus, not intuition. What phase kinds +actually used, today: + +| phase kind | tools observed | +|---|---| +| coding | Bash 56, Read 19, Write 11, Agent 5, Edit 1, ListAgents 1, ScheduleWakeup 1 | +| research | Bash 37, Read 28, Write 10, Glob 2 | + +Two of those are the point: `ListAgents` and `ScheduleWakeup` are the strays +from finding 1 — tools no mission needs, that no list stopped. A default set +per phase kind of {Bash, Read, Write, Edit, Glob, Agent} covers every observed +legitimate call and excludes both strays. + +**Why this is not just a smaller `--agents` list.** It is enforced by us, in +the hook, recorded as a `gate.denied` with its rule; the CLI's own list is the +harness policing itself and demonstrably does not. + +**Staging.** +1. Add `agent_tools` to the config registry and the gate's table; render into + the guest script; unit + shell tests as for the role policies. +2. **Shadow first.** A new outcome `gate.would_deny` — recorded, not enforced. + Run every scenario and a week of real missions. Promote only when the + record shows zero denials of work that completed successfully. +3. Enforce. The scenarios are the utility half: all of them must still pass, + which is the trade ActGov measures as CaMeL and ACE failing (ASR 0.000 at + utility 0.000) and this module's header already refuses. + +**What could go wrong.** Too tight and agents work around the gate, which is +worse than no gate. Mitigated by deriving from the corpus, by shadow mode, and +by the default being a *union* of observed use rather than a guess at need. + +## Piece 2 — argument provenance (taint) + +**The invariant worth having**, in ActGov's terms: *no outbound action whose +target was derived from untrusted content.* That is the indirect +prompt-injection shape — a fetched page says "send this to evil.example", and +the agent obliges. + +**Mechanism, guest-local and deterministic.** +- The tap gains a taint step: for each *fetching* call (Bash `curl`/`wget`, + and any `WebFetch`), extract **hostnames** from `tool_response` and append + them to `/root/toolhooks/untrusted-hosts.txt`, capped and deduplicated. +- The gate gains rule `untrusted-target`: an outbound call **carrying a body** + (the existing `curl-body` / `wget-body` matchers already identify these) + whose target host appears in that file is denied. + +**Why that shape and not string taint.** Tainting arbitrary strings from +fetched text and matching them against later commands produces false positives +immediately — the failure this module treats as cardinal, and the one +`gate-exfil-spelling` already cost us once. Hosts are high-signal and the +asymmetry is real: *reading* a host a page mentioned is ordinary research; +*sending data* to one is the attack. So the rule fires only on the +intersection. + +**Staging.** +1. Taint extraction in the tap, writing the file. Nothing enforced. Inspect on + real missions: what does it actually collect? +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 + tier it is defence in depth behind an allow-list that already holds. + +**Honest limits, to be written into the module header.** +- Indirection defeats it: base64, a shell variable, a URL assembled from + pieces. This stops accidents and the obvious case, which is exactly what the + gate's header already claims and no more. The real boundary on the VM tier + is the egress allow-list; on the container tier it is the network policy. +- It does not cover **content** exfiltration into deliverables — a page + telling the agent to write `.env` into `README.md`, which is then committed + and pushed to the forge, a host that *is* allowed. That is a second + invariant (stage B) and a harder one, because the legitimate case — writing + fetched research into a file — looks identical. + +**Validation is thin and should be said so.** Today's corpus is 171 tool +calls, 93 with responses, **15** curl/wget invocations — the larger corpora +those earlier numbers came from were wiped with the missions. Fifteen calls +cannot validate a rule. The evidence path is therefore shadow mode on real +traffic, not a retro-fit against what we happen to have kept. + +## Sequencing + +Piece 1 before piece 2: it is smaller, its default is already derivable from +the corpus, and it exercises the shadow-mode machinery (`gate.would_deny`) +that piece 2 then reuses. Both inherit the role policy's proof obligations — +rendered from one table into both implementations, unit-tested against the +predicate, shell-tested against the generated script, and probed live against +the **deployed** artifact by the `rolepolicy` scenario's pattern, with the +negative controls that stop a gate from passing by refusing everything.