Three residuals from #404 and #402 — the ceiling opens when there is no authority, refuses a target it permits, and is declared by nobody
#404 shipped the capability-constraint gate and #402 added the binding half. Both are live and both
are right in the case they were built for. Three things sit just outside their acceptance lists.
1. The gate is SKIPPED when there is no authority — the opposite of every neighbouring gate
workers/api/src/lib/tool-registry.ts:467-486:
if (tool.connector && CONNECTOR_CONSTRAINTS[tool.connector]) {
const authority = consentInstanceOf({ instanceId: ctx.instanceId ?? "", … });
if (authority) {
…
const gated = enforceConstraints(tool, spec, callInput);
if (!gated.ok) return { name, content: gated.refusal, success: false };
}
}
No authority → the whole block is skipped and the call runs unconstrained. Compare the two gates
immediately above it, in the same function:
- write-consent (
:432-441) resolves the same authority and passes it to
hasConsent(env, authority || undefined, …), which returns false → refused. Fail-closed, and
the comment says so: "Fail-closed — no connector, no instance context, or no consent → refused."
- the constraint read itself (
:471-482) fails closed on a throw, with the reasoning spelled
out: "A ceiling that cannot be read cannot be honoured, and a boundary that opens when its store
hiccups is not a boundary."
So the same function refuses when the ceiling can't be read, and waves through when there's nobody
to read it for. Neither direction is tested — tool-registry.test.ts has no case for a ctx without
an instanceId.
There is a live second door to the same place. agent-think.ts:945,962 passes
instanceId: state.agentId — for the agent-TEMPLATE surfaces (/v1/agents/:id/chat, the public
trial) that is an agent id, not an instance id. connectorConstraintsForInstance joins
agent_instances i JOIN agents a ON a.id = i.agent_id WHERE i.id = ?1 and returns undefined when
no row matches (lib/agent-capabilities.ts:561-584), and enforceConstraints with spec === undefined returns the input unchanged. So a creator's own trial chat of a kitty-operator is not
subject to the ceiling that agent declares — while the write-consent gate, on the identical input,
refuses. Practically inert today (that surface has no runner bound), but it is the rule not
applying rather than the rule permitting.
What to do. Decide the posture once and write it down, then make both paths match it. My
recommendation: fail closed, i.e. a connector that HAS a constraint vocabulary and a call with no
resolvable authority is refused, with the same wording as the unreadable-spec branch. It costs
nothing today (every real surface has an authority) and it means the gate cannot be reached by a
future call path that forgets to pass one — which is exactly how the #402 disclosure happened.
Whichever way it goes, add the two tests (no authority, authority that is not an instance), and
say in the comment why.
2. A multi-value ceiling refuses a target it explicitly permits
enforceConstraints checks a prefixed target first (surface-options.ts:463-476) and then falls
through to the backend argument rule, which does not know what the prefix just told it.
Measured, running enforceConstraints + parseConstraintSpec from main directly:
spec {"backends":["tmux"]} input {"target":"tmux:main"}
→ OK input={"target":"tmux:main","backend":"tmux"} ← narrowed, correct
spec {"backends":["tmux","kitty"]} input {"target":"tmux:main"}
→ REFUSED "…`terminal.backends` (tmux, kitty): `all` is not available to this
agent — pass `backend` explicitly — one of tmux, kitty." ← the bug
spec {"backends":["tmux","kitty"]} input {"target":"iterm2:1:1:1"}
→ REFUSED "…names a terminal backend this agent may not use." ← correct
spec {"backends":["tmux","kitty"]} input {"backend":"tmux","target":"main"}
→ OK ← correct
The refused call named tmux unambiguously, in the argument the tool actually acts on, and tmux
is permitted. The message is also wrong twice over: it says all is not available when the caller
never used the wildcard, and it asks for a value the caller already supplied in another field.
Mechanism. The prefix loop continues on a permitted prefix (:474) instead of recording it, so
the later branch sees backend absent, and allowed.length !== 1 so it cannot narrow. The
single-value case works only because narrowing to allowed[0] happens to be the right answer there.
Fix. When a prefixArgs value carries a permitted prefix, treat that as the named value: narrow
def.arg to it and skip the "pass it explicitly" branch. Keep the refusal for a prefix outside the
ceiling (already correct) and for a genuinely unspecified call (also correct).
Reachability, stated honestly. No agent in production declares a multi-value ceiling — the only
two declarations are single-valued (kitty-operator → ["kitty"], iterm-operator →
["iterm2"], migration 0104), and parseConstraintSpec drops a list covering the whole
vocabulary. So this is latent: it fires the first time a creator declares a 2-of-3 subset, which
the update route accepts today. That is a cheap fix now and a confusing refusal later.
3. targets: "single" has never been exercised end to end
#402's criterion 2 is built, routed and unit-tested at the dispatcher — the binding parses
(parseConstraintSpec), narrows (narrowConstraintSpec), is writable
(PUT /v1/instances/:id/terminal-target, routes/instances-terminal.ts) and is enforced
(enforceBinding in runRegistryTool). Nothing declares it.
Verified against production (GET /v1/agents/my/agents, 39 agents): exactly three carry
surfaceOptions at all —
kitty-operator draft {"terminal": {"backends": ["kitty"]}}
iterm-operator draft {"terminal": {"backends": ["iterm2"]}}
coder-repo published {"coding": {"repos": "single", "drive": false, "copilot": false}}
— and migration 0104 says so deliberately: "Neither row is given targets: "single": these are
backend test agents, not named single-pane operators." That was the right call for those two rows.
The consequence is that no single-declaring agent has ever run: nobody has bound a target through
the route, seen the "you must bind one" refusal, or watched a bound target survive a dispatch.
What to do. Give one agent the declaration and drive it once. A named single-pane operator is
the shape #402 describes; the cheapest honest version is a draft agent (like the two above) with
{"terminal": {"backends": ["tmux"], "targets": "single"}}, then:
bind via PUT …/terminal-target, call terminal_capture with and without a target, and confirm
(a) unbound + single → refused rather than guessing a pane, (b) bound → the bound pane, (c) a
different pane → refused. Record the four outcomes on the ticket. Fixing 2 first is worth it:
this exercise is the natural place it would have been noticed.
Alternatives considered and rejected
- Fail OPEN on no authority, and document it. Defensible — an unauthenticated caller reaches no
runner anyway — but it makes this the only permission-shaped check in the function with that
posture, and the value of a gate is that its posture is predictable. If the owner prefers it,
it must be asserted by a test, not left as an if.
- Canonicalise a prefixed target into
backend at the route/tool boundary instead of in the
gate. Rejected: instances-terminal.ts deliberately stores what the owner typed
("rewriting the owner's input would only make the stored config disagree with what they set"),
and the gate must be safe against a config written by something other than that handler.
- Refuse multi-value ceilings entirely (allow only a single backend). Rejected:
narrowConstraintSpec
and the whole vocabulary are built around a subset, and "two of three" is a legitimate thing for a
creator to mean.
- Ship a
targets: "single" declaration on tmux-operator. Rejected for 0104's own reason:
since 0099 it declares tmux_* tools whose connector is tmux, which has no constraint
vocabulary, so the field would be dropped by the sanitiser on the next write.
Acceptance criteria
Regression risk
- Fail-closed on missing authority is a behaviour change for any caller that has been getting away
with it. Grep every runRegistryTool call site for a ctx without instanceId before flipping it —
today they all have one, and that must be re-checked, not assumed.
enforceConstraints returns the input byte-identically when nothing is declared
(surface-options.ts:443-445, asserted in surface-options.test.ts). Narrowing on a prefix must
not disturb that: it only applies when a ceiling exists AND a prefix arg named a permitted value.
matchesBoundTarget's rule that two different prefixes never match must survive any change to the
prefix handling — that is what stops a command being redirected onto another backend instead of
refused.
Files: workers/api/src/lib/tool-registry.ts:418-486, workers/api/src/lib/surface-options.ts:380-505,
workers/api/src/lib/agent-capabilities.ts:556-584, workers/api/src/agent-think.ts:941-965,
workers/api/src/routes/instances-terminal.ts, workers/api/migrations/0104_operator_backend_ceilings.sql,
workers/api/src/lib/surface-options.test.ts, workers/api/src/lib/operator-backend-ceiling.test.ts.
Related: #404 (the gate), #402 (the binding), #403/#99 (the tools-as-declaration
route this complements), #185 (executor-not-asker, which the authority resolution implements).
Three residuals from #404 and #402 — the ceiling opens when there is no authority, refuses a target it permits, and is declared by nobody
#404 shipped the capability-constraint gate and #402 added the binding half. Both are live and both
are right in the case they were built for. Three things sit just outside their acceptance lists.
1. The gate is SKIPPED when there is no authority — the opposite of every neighbouring gate
workers/api/src/lib/tool-registry.ts:467-486:No authority → the whole block is skipped and the call runs unconstrained. Compare the two gates
immediately above it, in the same function:
:432-441) resolves the same authority and passes it tohasConsent(env, authority || undefined, …), which returns false → refused. Fail-closed, andthe comment says so: "Fail-closed — no connector, no instance context, or no consent → refused."
:471-482) fails closed on a throw, with the reasoning spelledout: "A ceiling that cannot be read cannot be honoured, and a boundary that opens when its store
hiccups is not a boundary."
So the same function refuses when the ceiling can't be read, and waves through when there's nobody
to read it for. Neither direction is tested —
tool-registry.test.tshas no case for a ctx withoutan
instanceId.There is a live second door to the same place.
agent-think.ts:945,962passesinstanceId: state.agentId— for the agent-TEMPLATE surfaces (/v1/agents/:id/chat, the publictrial) that is an agent id, not an instance id.
connectorConstraintsForInstancejoinsagent_instances i JOIN agents a ON a.id = i.agent_id WHERE i.id = ?1and returnsundefinedwhenno row matches (
lib/agent-capabilities.ts:561-584), andenforceConstraintswithspec === undefinedreturns the input unchanged. So a creator's own trial chat of akitty-operatoris notsubject to the ceiling that agent declares — while the write-consent gate, on the identical input,
refuses. Practically inert today (that surface has no runner bound), but it is the rule not
applying rather than the rule permitting.
What to do. Decide the posture once and write it down, then make both paths match it. My
recommendation: fail closed, i.e. a connector that HAS a constraint vocabulary and a call with no
resolvable authority is refused, with the same wording as the unreadable-spec branch. It costs
nothing today (every real surface has an authority) and it means the gate cannot be reached by a
future call path that forgets to pass one — which is exactly how the #402 disclosure happened.
Whichever way it goes, add the two tests (
no authority,authority that is not an instance), andsay in the comment why.
2. A multi-value ceiling refuses a target it explicitly permits
enforceConstraintschecks a prefixed target first (surface-options.ts:463-476) and then fallsthrough to the
backendargument rule, which does not know what the prefix just told it.Measured, running
enforceConstraints+parseConstraintSpecfrommaindirectly:The refused call named
tmuxunambiguously, in the argument the tool actually acts on, andtmuxis permitted. The message is also wrong twice over: it says
allis not available when the callernever used the wildcard, and it asks for a value the caller already supplied in another field.
Mechanism. The prefix loop
continues on a permitted prefix (:474) instead of recording it, sothe later branch sees
backendabsent, andallowed.length !== 1so it cannot narrow. Thesingle-value case works only because narrowing to
allowed[0]happens to be the right answer there.Fix. When a
prefixArgsvalue carries a permitted prefix, treat that as the named value: narrowdef.argto it and skip the "pass it explicitly" branch. Keep the refusal for a prefix outside theceiling (already correct) and for a genuinely unspecified call (also correct).
Reachability, stated honestly. No agent in production declares a multi-value ceiling — the only
two declarations are single-valued (
kitty-operator→["kitty"],iterm-operator→["iterm2"], migration0104), andparseConstraintSpecdrops a list covering the wholevocabulary. So this is latent: it fires the first time a creator declares a 2-of-3 subset, which
the update route accepts today. That is a cheap fix now and a confusing refusal later.
3.
targets: "single"has never been exercised end to end#402's criterion 2 is built, routed and unit-tested at the dispatcher — the binding parses
(
parseConstraintSpec), narrows (narrowConstraintSpec), is writable(
PUT /v1/instances/:id/terminal-target,routes/instances-terminal.ts) and is enforced(
enforceBindinginrunRegistryTool). Nothing declares it.Verified against production (
GET /v1/agents/my/agents, 39 agents): exactly three carrysurfaceOptionsat all —— and migration
0104says so deliberately: "Neither row is giventargets: "single": these arebackend test agents, not named single-pane operators." That was the right call for those two rows.
The consequence is that no
single-declaring agent has ever run: nobody has bound a target throughthe route, seen the "you must bind one" refusal, or watched a bound target survive a dispatch.
What to do. Give one agent the declaration and drive it once. A named single-pane operator is
the shape #402 describes; the cheapest honest version is a draft agent (like the two above) with
{"terminal": {"backends": ["tmux"], "targets": "single"}}, then:bind via
PUT …/terminal-target, callterminal_capturewith and without a target, and confirm(a) unbound +
single→ refused rather than guessing a pane, (b) bound → the bound pane, (c) adifferent pane → refused. Record the four outcomes on the ticket. Fixing 2 first is worth it:
this exercise is the natural place it would have been noticed.
Alternatives considered and rejected
runner anyway — but it makes this the only permission-shaped check in the function with that
posture, and the value of a gate is that its posture is predictable. If the owner prefers it,
it must be asserted by a test, not left as an
if.backendat the route/tool boundary instead of in thegate. Rejected:
instances-terminal.tsdeliberately stores what the owner typed("rewriting the owner's input would only make the stored config disagree with what they set"),
and the gate must be safe against a config written by something other than that handler.
narrowConstraintSpecand the whole vocabulary are built around a subset, and "two of three" is a legitimate thing for a
creator to mean.
targets: "single"declaration ontmux-operator. Rejected for0104's own reason:since
0099it declarestmux_*tools whose connector istmux, which has no constraintvocabulary, so the field would be dropped by the sanitiser on the next write.
Acceptance criteria
documented posture of the neighbouring gates.
enforceConstraints({backends:["tmux","kitty"]}, {target:"tmux:main"})→ ok, withbackendnarrowed totmux; the three other measured cases above are unchanged.targets: "single", and the four end-to-end outcomes are recorded on thisissue.
Regression risk
with it. Grep every
runRegistryToolcall site for a ctx withoutinstanceIdbefore flipping it —today they all have one, and that must be re-checked, not assumed.
enforceConstraintsreturns the input byte-identically when nothing is declared(
surface-options.ts:443-445, asserted insurface-options.test.ts). Narrowing on a prefix mustnot disturb that: it only applies when a ceiling exists AND a prefix arg named a permitted value.
matchesBoundTarget's rule that two different prefixes never match must survive any change to theprefix handling — that is what stops a command being redirected onto another backend instead of
refused.
Files:
workers/api/src/lib/tool-registry.ts:418-486,workers/api/src/lib/surface-options.ts:380-505,workers/api/src/lib/agent-capabilities.ts:556-584,workers/api/src/agent-think.ts:941-965,workers/api/src/routes/instances-terminal.ts,workers/api/migrations/0104_operator_backend_ceilings.sql,workers/api/src/lib/surface-options.test.ts,workers/api/src/lib/operator-backend-ceiling.test.ts.Related: #404 (the gate), #402 (the binding), #403/#99 (the tools-as-declaration
route this complements), #185 (executor-not-asker, which the authority resolution implements).