Skip to content

worker: allowlist spec.tools, and admit the four launcher network tools - #61

Merged
thegeorgepu merged 5 commits into
mainfrom
fix/worker-tools-allowlist
Sep 5, 2026
Merged

worker: allowlist spec.tools, and admit the four launcher network tools#61
thegeorgepu merged 5 commits into
mainfrom
fix/worker-tools-allowlist

Conversation

@thegeorgepu

Copy link
Copy Markdown
Contributor

Read this as a widening with a dormant guard attached — not as hardening

The commit subjects say "refuse ... outside the allowlist", which is true but is not the part that takes effect. Stating the net effect plainly, because independent review found the subjects mislead a skimming reviewer:

On merge and deploy, the fleet gets strictly more child-agent capability and no new restriction in force.

  • HEADER_KEYS (vinci/worker/task.mjs) has no tools entry, so the prose-envelope path — the only path in service, 769 of 769 real dispatches — always falls through to run.mjs's default CSV. This PR moves that default from 7 tools to 11.
  • The new SUPPORTED_TOOLS refusal is reachable only via materializeEnvelope, i.e. the digest/pinned-contract path, which requires a contract registry production does not configure (VGC_CONTRACT_REGISTRY_DIR unset; the live server returns the "no contract registry" 404 branch).

So the guard is correct and pre-positions for the registry being enabled, but it restricts nothing today. Merge on the widening's merits.

What changed

  1. SUPPORTED_TOOLS — a frozen allowlist in task.mjs, refusing any spec.tools entry outside it as tool_unsupported. Before this, spec.tools was validated for shape only: any non-empty string passed, so a spec could name any tool the launcher registers. The adjacent requiredCapabilities field was already fail-closed against a frozen list; this gives tools the same posture.
  2. The four launcher network tools (web_search, web_fetch, web_answer, library_docs) admitted to both lists together — run.mjs's default and SUPPORTED_TOOLS — which must stay in lockstep.
  3. Docs corrected in three places that had drifted, including a README.md line claiming no network tool was in the allowlist and a lease.mjs comment carrying a stale copy of the seven-tool CSV.

tool_unsupported is deliberately not tool_not_granted. Containment (checkValidatedExecutionSpecWithinOrder) asks whether this work order granted the tool; this list asks whether this worker supports it at all. Different questions, different codes, both must pass.

Authorization

Recorded on the bus as msg_de1a219d (corrected by msg_02bb0a87) — external, timestamped, repudiable. An earlier revision asserted the authorization in code comments; review correctly called that self-attestation, and 56f79f9d replaces it with the citation.

The stated rationale is worth reading in full, because the premise it was first offered on does not hold: only web_search and web_answer are Brave-backed, library_docs is Context7, and web_fetch is a direct arbitrary-URL fetch. The change rests on a different argument — the unattended child already holds bash with no egress allowlist, so admitting web_fetch adds an attributable, SSRF-guarded path to reach it already had. It does not add reach.

Evidence

Every guard assertion was mutation-tested; the ones worth naming:

  • The lockstep pin was vacuous and is now behavioural. It asserted runSource.includes(csv) — a whole-file substring check that a comment satisfies. Confirmed by executing the decoy: reverting run.mjs's live fallback to the old 7 tools while leaving the correct 11-tool literal in a comment left the suite printing PASS. b903c0ee replaces it with a test that puts a stub vinci on PATH and asserts the argv runVinci actually spawns. The identical decoy now fails on the intended assertion, printing the real spawned --tools value. This also gives runVinci its first test of any kind — it was imported by zero tests repo-wide.
  • Ordering control. Containment refuses tool_not_granted at step 3.5, before this guard. Every negative fixture therefore uses a work order that grants the tool it then refuses, and asserts the code is exactly tool_unsupported and explicitly not any earlier guard's — otherwise the test would never reach the mechanism it claims to cover.
  • Anti-vacuity. Negative examples were re-pointed off web_fetch (now admitted) onto orchestrate/spawn_helper, plus a loop asserting each refused example is absent from SUPPORTED_TOOLS, so a future widening cannot silently hollow them out.
  • Worker group: 46/46, unchanged from baseline.

Review

Two independent lanes reviewed this and disagreed. One returned PASS / FINDINGS: NONE and specifically certified the lockstep pin as sound without ever running a divergence. The other returned GO-WITH-FIXES and ran ten mutations, one of which left the suite green. I settled it by executing the mutation myself: the second lane was right. The fixes in b903c0ee and 56f79f9d are the result.

Not in scope

Issue #60 — two confirmed pre-existing SSRF bypasses in web_fetch's guard (IPv4-mapped IPv6 branch is dead code after WHATWG URL normalization; redirects followed with no per-hop revalidation). Both reproduced against the real predicate. They are not introduced here and do not gate this PR: low marginal risk while the child holds bash, but genuinely exploitable on any surface shipping web_fetch without it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid

thegeorgepu and others added 5 commits September 4, 2026 22:02
`spec.tools` becomes the `--tools` CSV handed to the unattended `vinci -p`
agent (run.mjs:1210), and task.mjs validated it for SHAPE ONLY: any non-empty
string passed. The launcher registers ~30 extension tools (web_search,
web_fetch, web_answer, library_docs, advisor, convene_council, orchestrate,
spawn_helper, ...), so any name a spec carried would have been forwarded
verbatim.

Add SUPPORTED_TOOLS, frozen, containing exactly the seven tools run.mjs
already falls back to (read, grep, find, ls, bash, edit, write), and refuse
anything else as `tool_unsupported` — the same fail-closed posture the
adjacent requiredCapabilities field has against SUPPORTED_CAPABILITIES. This
only NARROWS what a work order may request down to what the default already
grants; no tool is enabled anywhere, and run.mjs's default string is
unchanged.

SCOPE: this is hardening of a path not yet in service, not the closure of a
live hole. `spec.tools` is populated only by the digest handoff form; the
prose envelope form has no `tools` header (HEADER_KEYS), so on the path
actually in use `envelope.tools` is undefined and run.mjs falls back to the
seven-tool default regardless. The digest path requires a contract registry
that production does not configure. The guard becomes load-bearing when that
registry is enabled.

`tool_unsupported` is deliberately distinct from `tool_not_granted`:
containment (within-order.mjs, vendored from vinci-gpu-control's
check_within_order) asks whether THIS ORDER granted tool:<name> and runs
earlier at step 3.5; this asks whether THIS WORKER supports it at all. Both
must pass.

Tests: vinci/test/worker-tools-allowlist.mjs (auto-discovered by run.sh's
worker-*.mjs glob). Because containment refuses first, every negative fixture
uses an order that GRANTS the tool being refused — otherwise the test would
pin `execution_exceeds_contract` and never reach this guard. Covers the
negative, an ordering control asserting exactly which guard answered,
positive reachability for the subset and the full seven, mixed lists in both
orders, and the edge inputs, each pinned at the layer that actually decides
it (malformed and duplicate entries are refused a layer earlier by
digest.mjs). Duplicates take no new rule (digest.mjs already refuses
`duplicate_entry`); case matching is exact, so "READ" is refused.

Mutation control: with the allowlist loop removed, the negative test fails
with "expected a refusal (tool_unsupported), got an envelope" — the spec is
ADMITTED, which is the intended assertion failing for the intended reason.
Restored from an out-of-repo copy (sha256 verified); 46/46 worker tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid
Step 5 stated the seven-tool list as if it were fixed; it is the fallback
run.mjs uses when a spec names no tools, and with SUPPORTED_TOOLS in place a
spec may narrow it but not widen it. Say that.

The Network Access bullet claimed "no network tools" under a heading about
network access, while the clean-room gaps section two hundred lines up says
"No network allowlist. The child can reach anything the box can reach." Both
are true and together they read as a containment claim that does not exist:
`bash` is in the allowlist, so withholding web_fetch removes the attributable
path to the network, not access to it. Mark it as a tool boundary explicitly
and point at the gap rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid
Add web_search, web_fetch, web_answer and library_docs to BOTH lists that
define the worker's --tools set, as one change, so they stay in lockstep:

  - run.mjs:1210 default CSV -> 11 entries
  - task.mjs SUPPORTED_TOOLS -> the same 11, same order

The launcher (vinci/bin/vinci) has always registered these unconditionally
via vinci/extensions/vinci-search.ts; only the --tools allowlist was hiding
them. This is an allowlist edit, not an integration, and it is authorized by
the repo owner.

The guard has to stay discriminating, so the test moved with it rather than
merely going green. Every negative fixture in worker-tools-allowlist.mjs was
spelled `web_fetch`; each one would now assert a refusal for an ADMITTED tool
and prove nothing. They are re-pointed at `orchestrate` and `spawn_helper` —
registered by the launcher, still outside this allowlist — and a new loop
asserts each refused example is genuinely absent from SUPPORTED_TOOLS, so a
future widening cannot hollow them out the same way. The ordering control is
preserved: the work order still GRANTS tool:<name> so containment's
`tool_not_granted` (step 3.5) cannot answer first, the refusal code is pinned
to exactly `tool_unsupported`, and the granted/ungranted pair is now run on a
second name so it is not a property of one string.

New positive-reachability cases prove each of the four is now ADMITTED
through the same entry point (materializeEnvelope) and carried into the
envelope. Their names are LITERALS, not `[...SUPPORTED_TOOLS]`: a case that
iterates the list under test can only agree with it and would stay green if
the four were removed again.

Mutation control: reverting the four from SUPPORTED_TOOLS only, leaving
run.mjs at 11, fails the cross-file lockstep pin, and (driven past it) fails
the new positive cases with materializeEnvelope refusing web_search as
`tool_unsupported`. Restored and verified byte-identical by sha256 against an
out-of-repo copy.

README.md's claim that none of the network tools are in the allowlist is now
false and is corrected; the adjacent point is kept and sharpened — this is a
TOOL boundary and never was a network boundary, since `bash` is in the set
and there is no egress allowlist, so admitting these adds an attributable
path rather than a capability the child lacked. lease.mjs's declaration
comment listing the fixed spawn tool set is updated for the same reason.

Worker test group: 46/46 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid
The lockstep pin between task.mjs's SUPPORTED_TOOLS and run.mjs's `--tools`
fallback was carried by `runSource.includes(`"${DEFAULT_CSV}"`)`. `includes`
is satisfied by the literal appearing anywhere in the file, a comment
included, so it pinned a string in a file rather than the value the worker
hands to the agent.

Executed mutation: reverting the live fallback at run.mjs:1210 to the old
seven-tool CSV while leaving the correct eleven-tool literal in a comment
above `runVinci` left the suite printing PASS. That is the value production
actually uses — the prose-envelope form carries no `tools` header, so the
fallback branch is taken on every prose task.

Replaced with a behavioural pin. `resolveBin("vinci")` is a bare PATH scan at
spawn time, so a stub `vinci` first on PATH is the executable `runVinci`
really launches; the stub records its argv and the assertions read that.

  A. fallback   — no `tools` on the envelope: --tools is the eleven-tool CSV,
                  and its split deep-equals SUPPORTED_TOOLS.
  B. narrowing  — `tools: ["read","bash"]`: --tools is exactly "read,bash".
                  Its value differs from A's, which is the instrument's
                  positive control: the recording tracks the envelope rather
                  than echoing a constant.
  C/D. one tool, the explicit full set, and five degenerate `tools` fields
       ([], null, undefined, string, object) that must all fall back rather
       than launch an empty or malformed CSV.

This also gives `runVinci` its first test of any kind — it was imported by
zero tests repo-wide, so the parameterisation path had no coverage at all.
The source-text check survives only as a clearly-labelled secondary smoke,
now anchored on `const tools =` so a comment cannot satisfy it.

Controls: mutation 1 (7-tool live CSV + 11-tool comment) fails case A on the
recorded argv while the old assertion still evaluates true; mutation 2 (drop
the narrowing branch) fails case B and leaves A passing, so the two cases
discriminate different mechanisms. run.mjs restored from an out-of-repo copy,
sha256 56788c6d3a52ffec1d67699af27c197608f0e4dd9db752221c7d900ffee71402
before and after. Worker group 46/46, unchanged from baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid
Independent review flagged that "on the repo owner's authorization" appeared three
times inside this diff as prose the author typed, with nothing outside the diff
corroborating it — while being the sole justification for the one change that alters
live fleet behaviour. Citing the artifact that needs the authorization is not evidence.

Both in-code claims now cite the decision recorded on the bus (msg_de1a219d, corrected
by msg_02bb0a87), which is external, timestamped, addressed to the room, and can be
repudiated by the person who made it. The commit message for the widening itself
(d1563bb) carries the same wording and is left alone rather than rewritten, since
rewriting history to improve my own citation would defeat the point.

No behavioural change: comments and documentation only. Worker group still 46/46.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid
@thegeorgepu
thegeorgepu merged commit 1981158 into main Sep 5, 2026
6 checks passed
@thegeorgepu
thegeorgepu deleted the fix/worker-tools-allowlist branch September 5, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant