Skip to content

[arch] A registry tool that no agent declares is silently unreachable — three times in four days, and six more are live right now #444

Description

@serge-ivo

A tool can be shipped complete and reachable by nobody, and nothing in the tree notices

This has now happened three times in four days and, measured today, six more are live right
now
— one of them unreachable through every path, the delete_record shape, undetected.

# tool(s) shipped by found by
#415 github_list_pulls, github_read_pull #401 (6f6e42f) a human asking an agent "what PRs are open?"
#279 transfer_conversation 697f605 a human asking a Lead to transfer them
delete_record its own handler + test reading agent-do-tools.ts

Each was fixed one migration at a time. Nothing was added that would catch the fourth, and the
fourth already exists.

The mechanism, and why it is silent

capabilities.tools is an authoritative allowlist, not an addition to a default
(workers/api/src/agent-do-tools.ts:187-190):

if (declared?.length) {
    set = new Set<string>(BASE);
    for (const name of declared) if (CREATOR_SELECTABLE_TOOLS.has(name)) set.add(name);
}

So there are two ways for a working tool to be unreachable, and they need different guards:

  1. Declared by nobody. A connector tool is not in FULL, so it reaches an agent only through
    capabilities.tools. Shipping it into connectors/registry.ts is complete and correct work;
    nothing connects that to any agent's declaration. A registry addition is inert until a migration
    says otherwise.
  2. In no catalog group at all. CREATOR_SELECTABLE_TOOLS is built from TOOL_CATALOG, and
    FULL is the union of the groups. A name with a definition and a handler but no group is
    dropped from buildAgentToolDefinitions, refused as a text-embedded call, and silently
    dropped from an explicit capabilities.tools declaration. That is delete_record's note at
    agent-do-tools.ts:81-91 — and it is still true of another tool today.

Two individually-correct decisions compose into it: an authoritative allowlist is exactly what makes
third-party tool scope safe (#58/#51), and adding a tool to the registry is the normal way to add
one. Neither is wrong. There is just no edge between them.

The failure mode is what makes it serious. Nothing throws. No test fails. GET /v1/instances/:id/tools reports not_declared on a page nobody has open. The agent has no tool for
the request, so it answers conversationally — and convo.ts:205-216 already records what that
looks like when it happened for a different reason: the reply confirmed an action nobody
performed
. For #279 that means a Lead telling a hands-free user, who is not looking at a screen,
that they have been transferred, while every subsequent sentence goes to the agent they asked to
leave.


Measured today, against production and main @ 0b4afd0

Denominator: GET /v1/agents/my/agents (39 agents, every seeded catalog agent among them),
config.capabilities.tools parsed from each row. Numerator: CREATOR_SELECTABLE_TOOLS
computed from TOOL_CATALOG by importing the real module.

Catalog tools that reach an agent only by declaration, and that no agent declares:

tool connector shipped verdict
set_direction supervision 2026-08-07, 32bb7b0 (#330) live gap — a Lead cannot propose a direction
mcp_list_resources mcp 2026-08-06, 4062a07 (#263/#264) live gapmcp-client declares only mcp_list_tools, mcp_call_tool
mcp_read_resource mcp same live gap
mcp_list_prompts mcp same live gap
mcp_get_prompt mcp same live gap
github_workflow_runs github d35eefb (#85/#88) live gapcoder-repo reads issues and pulls, not runs
delete_record (collections) by design: COLLECTIONS_DESTRUCTIVE, opt-in, nobody opted in
browser_navigate / _snapshot / _act browser by design: BROWSER_TOOLS_ENABLED, first-party (#103)
whatsapp_send_message, instagram_send_dm meta by design: inert until Meta review
sheets_read, sheets_append google_sheets by design: inert until the OAuth scope lands

set_direction is the sharpest one. #330's whole design is "the agent may PROPOSE, only the owner
SETS"
— the handler is careful enough to hardcode setBy: "agent" with a comment explaining why it
must never be "user". The proposing half has never been callable. And migration 0107, written
yesterday to fix #279, re-set coder-lead's entire $.capabilities object and did not
include it — the exact "a restated object is one typo from losing delegation" risk
coder2-parity.test.ts guards, except a guard for names it already knows cannot see a new one.

And the second leg is live too: store_file

store_file has a definition (lib/tools.ts:110-121), a working handler that writes to R2
(lib/tools.ts:370-390), and membership in no TOOL_CATALOG group. Measured by importing the
real toolNamesFor / buildAgentToolDefinitions:

capabilities                     store_file in tool set   in defs handed to the model
(none — the FULL default)                 false                    false
{ tools: ["store_file"] }                 false                    false
CREATOR_SELECTABLE_TOOLS.has("store_file") = false

Identical to delete_record before it was grouped: unreachable through every path, including an
explicit declaration, which is dropped without a word. (Whether the right answer is to group it or
to delete it is a judgement — upload_file looks like its successor — but "silently inert" is not
one of the two.)


Where the guard belongs

I checked both candidates named in the report:

  • scripts/check-agents-allowlist.mjs — no. Despite the name it is about monorepo folders: it
    fails when a directory appears under platform/agents/ outside the Tier-0 set (CI guard: block new platform/agents/ folders outside the Tier-0 allowlist #49/Epic: agents live outside the monorepo — enforce the platform-only boundary #50). It never
    reads a tool.
  • coder2-parity.test.ts — partly, and only the wrong direction. It has three assertions of the
    forward kind — a declared name exists in the registry (:110), a declared name is in
    CREATOR_SELECTABLE_TOOLS (:216, "the trap under the trap"), and 0107's four new ones. All ask
    "is what this agent declares real?" None can ask "is what the platform provides declared?", and
    its denominator is two slugs. Its effectiveDeclared() helper (:44-58) — which walks every
    migration in filename order and keeps the last statement writing $.capabilities[.tools] for a
    slug — is exactly the machinery a platform-wide guard needs, and is the reason to put the new
    guard next to it rather than to invent a second migration parser.

Recommendation: a new test file, workers/api/src/lib/tool-reachability.test.ts, sitting beside
coder2-parity.test.ts and reusing its migration walk. Two assertions, one per leg:

Leg 1 — every tool the platform provides is in the catalog. For each def in registryToolDefs()
that carries a connector (the discriminator undeclaredToolRefusal already uses — step-library
tools have none, which is why they are exempt and stay exempt automatically), assert it appears in
some TOOL_CATALOG group. Same for AGENT_TOOLS + STORAGE_TOOLS, against a named exception set:
find_confirmation_link (permission-gated by design), submit_job_application (legacy, superseded),
and — until it is decided — store_file. This leg fails today, which is how you know it works.

Leg 2 — every declaration-only catalog tool is declared by someone. Compute
CREATOR_SELECTABLE_TOOLS − FULL (the tools no default grants), collect every capabilities.tools
array written by any migration, and fail on the difference — unless the name is in an explicit
UNREACHABLE_BY_DESIGN map of name → reason. The map is the deliverable as much as the assertion:
it turns "nobody declares it" from an accident into a written decision, and the six live gaps above
are precisely the names that would have no honest entry.

Both legs are pure and offline — module imports plus readdirSync over migrations/ — so they
cost nothing in CI and cannot flake.

Why a test and not a lint script

scripts/*.mjs guards are regexes over the tree; this needs the resolved catalog, because the
question is "what does toolNamesFor do with this name", not "does this string appear". Importing
the real module is what makes the check true rather than approximately true — the same reasoning
tmux-operator-seed.test.ts:9-16 gives for resolving its seed through the real registry instead of
restating it. And scripts/**/*.test.mjs is already in the vitest unit glob, so a script would end
up needing a test of its own anyway.


What to do, cheapest first

An adjacent hole this measurement exposed, stated but not fixed here

The guard's denominator is migrations, but mcp-client, local-repo-chat, lead-outreach-tj6qrr,
facebook-friend-confirmer and others are live D1 rows with no seed migration. A guard over
migrations cannot see them, so it would report mcp_list_tools as undeclared (it is declared, on a
row that exists only in production). Two honest options: exclude live-only agents from leg 2's
numerator and say so, or seed them. I would do the former now and treat the latter as #67's job —
but the limitation must be written into the test, or the first person to hit it will weaken the
assertion instead of the data.

Alternatives considered and rejected

Acceptance criteria

  • With store_file removed from the exception set, leg 1 fails and names it.
  • Adding a new tool to any connector in connectors/registry.ts, and nothing else, makes leg 2
    fail with the tool's name and the sentence "declared by no seeded agent".
  • Adding it to UNREACHABLE_BY_DESIGN with a reason, or to one agent's declaration, makes it pass.
  • GET /v1/instances/<coder-lead instance>/tools reports allowed:true for set_direction, and
    asking the Lead to "set FWS platform's direction" produces a tool.call in agent_trace.
  • Neither leg reads production.

Regression risk

  • The exception map is the risk. A guard whose escape hatch is one line is a guard people
    suppress. Requiring a non-empty reason string is the whole mitigation, and it is worth stating in
    the file header that adding an entry is a decision to be reviewed like any other.
  • MAX_DECLARED_TOOLS is 40 (lib/agent-capabilities.ts:343) and silently truncates. coder-lead
    goes 9 → 10, coder-repo 9 → 10, mcp-client 2 → 6. No risk, but it is the cap that fails quietly.
  • 0108 restating $.capabilities drops any surfaceOptions it does not restate — the trap
    coder2-parity.test.ts:186-198 was written for. coder-lead and mcp-client carry none;
    coder-repo carries {repos:"single",drive:false,copilot:false} and must restate it.
  • Granting the four MCP read tools changes what routes/tools.ts:763 reports as
    listEnabled/readEnabled in the connection-test summary — that is the point, but it is a visible
    change to an existing panel.

Files: workers/api/src/agent-do-tools.ts:81-108,187-217, workers/api/src/lib/tool-registry.ts,
workers/api/src/lib/connectors/registry.ts, workers/api/src/lib/tool-refusal.ts:44-72,
workers/api/src/lib/tools.ts:110-121,370-390, workers/api/src/lib/coder2-parity.test.ts:44-58,216,
workers/api/src/lib/connectors/supervision.ts:686-726, workers/api/migrations/0107_coder_lead_transfer_conversation.sql,
scripts/check-agents-allowlist.mjs.
Related: #415, #279 (the two prior instances), #58/#51 (why the allowlist is
authoritative), #330 (set_direction), #263/#264 (the MCP read tools), #438 (the
"no test executes X" blind-spot argument, of which this is a third instance), #67 (agents that
exist only in D1).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions