docs: a grounded design for task permission and argument provenance
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:
co-authored by
Claude Opus 5
parent
70dbee662d
commit
2d1f3954e8
@@ -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.
|
||||
Reference in New Issue
Block a user