docs: a grounded design for task permission and argument provenance
deploy / test (push) Successful in 5m2s
deploy / build (push) Successful in 1m0s

Four measurements decide the shape, all taken today:

1. --allowedTools is not an enforcement boundary. ListAgents and
   ScheduleWakeup ran on microvm missions whose list is Read Edit Write
   Bash Agent. The flag governs prompting, not availability, so any
   task-permission layer must be enforced by our own gate.
2. The guest already has every tool's output on disk (the tap appends the
   whole payload, tool_response included), so taint is computable
   guest-locally with no network call and no added latency.
3. The taint store would already be protected — the hook-files rule
   refuses reads and writes to /root/toolhooks from both Bash and the
   write tools.
4. Provenance is a CONTAINER-tier control. A microVM reaches only the
   provider and the forge through a name-matched CONNECT allow-list; a
   container reaches any public host. Saying it matters equally on both
   would be padding.

Task permission: a per-phase "agent_tools" key (NOT "tools", which
security_scan already owns), defaulted from what phase kinds actually
used, enforced by the gate. Provenance: taint hostnames out of fetched
responses, deny an outbound call WITH A BODY whose target is one of them
— the asymmetry being that reading a host a page mentioned is research
and sending data to it is the attack. String taint is rejected outright
as a false-positive generator, which is this module's cardinal sin.

Both ship in shadow (gate.would_deny) first, because today's corpus is
171 tool calls and 15 curl invocations and cannot validate a rule.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-22 09:50:50 -05:00
co-authored by Claude Opus 5
parent 70dbee662d
commit 2d1f3954e8
+146
View File
@@ -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.