Files
clawmates/docs/TASK-PERMISSION-AND-TAINT.md
T
Omar SobhandClaude Opus 5 2d1f3954e8
deploy / test (push) Successful in 5m2s
deploy / build (push) Successful in 1m0s
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
2026-09-22 09:50:50 -05:00

7.8 KiB

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.