# 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.