You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
So there are two ways for a working tool to be unreachable, and they need different guards:
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.
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:
live gap — coder-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 noTOOL_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.)
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
Leg 1 of the guard (it is ~15 lines and fails on store_file today).
Decide store_file: group it under files, or delete the definition and the handler. Not
"leave it".
Leg 2 of the guard, with the UNREACHABLE_BY_DESIGN map seeded from the four
by-design rows in the table above.
Update coder2-parity.test.ts's 0107 block to assert set_direction survives, the same
way 0067/0070 are asserted, if the Lead gets it.
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.
A runtime warning when a catalog tool has no declarer. Rejected: it fires in a log nobody reads,
on a schedule nobody controls, and the whole class is defined by being invisible at runtime. The
check belongs where the mistake is made — the commit that adds the tool.
Assert on the live API instead of on migrations. Rejected: it makes CI depend on production
data, so a green build would mean "the operator account happens to be configured right today".
Migrations are the repo's own claim about the catalog and are the thing a PR can be wrong about.
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).
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_recordshape, undetected.github_list_pulls,github_read_pull6f6e42f)transfer_conversation697f605delete_recordagent-do-tools.tsEach 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.toolsis an authoritative allowlist, not an addition to a default(
workers/api/src/agent-do-tools.ts:187-190):So there are two ways for a working tool to be unreachable, and they need different guards:
FULL, so it reaches an agent only throughcapabilities.tools. Shipping it intoconnectors/registry.tsis complete and correct work;nothing connects that to any agent's declaration. A registry addition is inert until a migration
says otherwise.
CREATOR_SELECTABLE_TOOLSis built fromTOOL_CATALOG, andFULLis the union of the groups. A name with a definition and a handler but no group isdropped from
buildAgentToolDefinitions, refused as a text-embedded call, and silentlydropped from an explicit
capabilities.toolsdeclaration. That isdelete_record's note atagent-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/toolsreportsnot_declaredon a page nobody has open. The agent has no tool forthe request, so it answers conversationally — and
convo.ts:205-216already records what thatlooks 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@0b4afd0Denominator:
GET /v1/agents/my/agents(39 agents, every seeded catalog agent among them),config.capabilities.toolsparsed from each row. Numerator:CREATOR_SELECTABLE_TOOLScomputed from
TOOL_CATALOGby importing the real module.Catalog tools that reach an agent only by declaration, and that no agent declares:
set_direction32bb7b0(#330)mcp_list_resources4062a07(#263/#264)mcp-clientdeclares onlymcp_list_tools,mcp_call_toolmcp_read_resourcemcp_list_promptsmcp_get_promptgithub_workflow_runsd35eefb(#85/#88)coder-reporeads issues and pulls, not runsdelete_recordCOLLECTIONS_DESTRUCTIVE, opt-in, nobody opted inbrowser_navigate/_snapshot/_actBROWSER_TOOLS_ENABLED, first-party (#103)whatsapp_send_message,instagram_send_dmsheets_read,sheets_appendset_directionis the sharpest one. #330's whole design is "the agent may PROPOSE, only the ownerSETS" — the handler is careful enough to hardcode
setBy: "agent"with a comment explaining why itmust never be
"user". The proposing half has never been callable. And migration0107, writtenyesterday to fix #279, re-set
coder-lead's entire$.capabilitiesobject and did notinclude it — the exact "a restated object is one typo from losing delegation" risk
coder2-parity.test.tsguards, except a guard for names it already knows cannot see a new one.And the second leg is live too:
store_filestore_filehas a definition (lib/tools.ts:110-121), a working handler that writes to R2(
lib/tools.ts:370-390), and membership in noTOOL_CATALOGgroup. Measured by importing thereal
toolNamesFor/buildAgentToolDefinitions:Identical to
delete_recordbefore it was grouped: unreachable through every path, including anexplicit declaration, which is dropped without a word. (Whether the right answer is to group it or
to delete it is a judgement —
upload_filelooks like its successor — but "silently inert" is notone 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: itfails 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 neverreads a tool.
coder2-parity.test.ts— partly, and only the wrong direction. It has three assertions of theforward kind — a declared name exists in the registry (
:110), a declared name is inCREATOR_SELECTABLE_TOOLS(:216, "the trap under the trap"), and0107'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 everymigration in filename order and keeps the last statement writing
$.capabilities[.tools]for aslug — 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 besidecoder2-parity.test.tsand 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 discriminatorundeclaredToolRefusalalready uses — step-librarytools have none, which is why they are exempt and stay exempt automatically), assert it appears in
some
TOOL_CATALOGgroup. Same forAGENT_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 everycapabilities.toolsarray written by any migration, and fail on the difference — unless the name is in an explicit
UNREACHABLE_BY_DESIGNmap ofname → 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
readdirSyncovermigrations/— so theycost nothing in CI and cannot flake.
Why a test and not a lint script
scripts/*.mjsguards are regexes over the tree; this needs the resolved catalog, because thequestion is "what does
toolNamesFordo with this name", not "does this string appear". Importingthe real module is what makes the check true rather than approximately true — the same reasoning
tmux-operator-seed.test.ts:9-16gives for resolving its seed through the real registry instead ofrestating it. And
scripts/**/*.test.mjsis already in the vitest unit glob, so a script would endup needing a test of its own anyway.
What to do, cheapest first
store_filetoday).store_file: group it underfiles, or delete the definition and the handler. Not"leave it".
UNREACHABLE_BY_DESIGNmap seeded from the fourby-design rows in the table above.
0107's shape(re-set the full
$.capabilitiesobject, oneUPDATE … WHERE slug = '…'per agent,idempotent; next free number is 0108). My recommendations, each arguable and each the
owner's call:
-
coder-lead+=set_direction— [design] Two-level work model — the Lead owns epics (direction), subordinates own tasks (execution) #330 built it for this agent and nothing else can use it.-
mcp-client+=mcp_list_resources,mcp_read_resource,mcp_list_prompts,mcp_get_prompt— allscope:"read", so no new consent surface. Notemcp-clientis alive D1 row and not seeded by any migration, which is its own finding (see below).
-
coder-repo+=github_workflow_runs— it already answers "what's broken"; [bug] Chat tells the agent a repo is "needs_attention" and drops the sentence saying why — #405's diagnosis never reaches the model #416/[bug] A local repo is marked "ready" without anyone checking it exists — an empty checkout reports success, and the agent invents the code it cannot see #405 areabout exactly that.
coder2-parity.test.ts's0107block to assertset_directionsurvives, the sameway
0067/0070are asserted, if the Lead gets it.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-confirmerand others are live D1 rows with no seed migration. A guard overmigrations cannot see them, so it would report
mcp_list_toolsas undeclared (it is declared, on arow 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
connector. Rejected in [bug] #401's github_list_pulls / github_read_pull are unreachable by EVERY agent — no seeded capabilities.tools declares them, so the Pulls panel works and no agent can answer "what PRs are open?" #415 and still wrong: it inverts what the allowlist is for and would have
handed
github_workflow_runsto agents that never asked. It also would not have caughtstore_fileor
set_direction.capabilities.toolsadditive to the surface default. This is the root, and changing it isa security regression: the allowlist being authoritative is what lets a third-party creator's scope
be trusted (Epic: first-party exemplary agents → mature the platform → open to third parties (demand-gated) #58). Not a candidate.
on a schedule nobody controls, and the whole class is defined by being invisible at runtime. The
check belongs where the mistake is made — the commit that adds the tool.
data, so a green build would mean "the operator account happens to be configured right today".
Migrations are the repo's own claim about the catalog and are the thing a PR can be wrong about.
Acceptance criteria
store_fileremoved from the exception set, leg 1 fails and names it.connectors/registry.ts, and nothing else, makes leg 2fail with the tool's name and the sentence "declared by no seeded agent".
UNREACHABLE_BY_DESIGNwith a reason, or to one agent's declaration, makes it pass.GET /v1/instances/<coder-lead instance>/toolsreportsallowed:trueforset_direction, andasking the Lead to "set FWS platform's direction" produces a
tool.callinagent_trace.Regression risk
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_TOOLSis 40 (lib/agent-capabilities.ts:343) and silently truncates.coder-leadgoes 9 → 10,
coder-repo9 → 10,mcp-client2 → 6. No risk, but it is the cap that fails quietly.0108restating$.capabilitiesdrops anysurfaceOptionsit does not restate — the trapcoder2-parity.test.ts:186-198was written for.coder-leadandmcp-clientcarry none;coder-repocarries{repos:"single",drive:false,copilot:false}and must restate it.routes/tools.ts:763reports aslistEnabled/readEnabledin the connection-test summary — that is the point, but it is a visiblechange 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).