Measured against production and main @ 8bf125c4 on 2026-08-21.
What the owner sees
Settings → Permissions & Connections renders, in one card, in this order:
- Tools — captioned "Everything this agent is allowed to do." (
ToolPermissions.tsx:120-122)
- Agent write access — the connector consent checkboxes.
- ☐ "Allow this agent to read my inbox for sign-in links & codes" (
SettingsTab.tsx:588-598)
Measured in WebKit at 420px against proagentstore.online, on a Repo Coder instance: the Gmail checkbox's bounding box top is 31px below the write-access checkbox's bottom (4856.17 vs 4825.17). Same card, same heading, no separator.
Ticking (3) really does give the agent a tool that reads the owner's Gmail. Panel (1), which claims to list everything the agent is allowed to do, never mentions it — before or after.
The mechanism: two places compute "this agent's tools" and only one knows about the flag
The chat runtime applies the permission grant. workers/api/src/agent-think.ts:827-828:
const allowedToolNames = toolNamesFor(capabilities);
if (state.permissions?.email === true) allowedToolNames.add("find_confirmation_link");
…and again for what the model is shown, workers/api/src/agent-do-tools.ts:282:
// Permission-gated tools are only offered to the model when the user granted them.
if (opts?.emailEnabled) enabled.add("find_confirmation_link");
The listing does not. workers/api/src/lib/instance-tool-policy.ts:214-219:
const declared = toolNamesFor(capabilities);
...
const isDeclared = declared.has(t.name);
const reason: ToolPolicyReason = !isDeclared ? "not_declared" : ...
toolNamesFor (agent-do-tools.ts:226) takes capabilities and nothing else. find_confirmation_link is deliberately excluded from CREATOR_SELECTABLE_TOOLS — agent-do-tools.ts:266 says so and gives the reason — so it can never be declared, so this resolver can only ever return not_declared for it, whatever the owner has granted.
The console then drops the row entirely: listedTools keeps allowed || disabled (store/console/src/lib/toolPolicy.ts:~131), and a not_declared row is neither.
Measured on the one instance where the flag is actually on
Across all 43 instances of the operator account, exactly one has permissions.email === true: Job Application Assistant (51a3404e-9f78-4dbf-89b1-7a6976ce52c3). Live GET /v1/instances/51a3404e…/tools:
{"name":"find_confirmation_link","allowed":false,"disabled":false,"reason":"not_declared","reach":"internet"}
- rows the console lists: 39
find_confirmation_link among them: no
- rows it reports as reaching outside the platform:
fetch_url, submit_job_application, read_terminal, send_to_cli — the mailbox reader is not one of them
So on the single agent in this account that has been granted access to the owner's mail, the platform's authoritative answer to "what can this agent do" says that tool is not this agent's. The only thing on screen that says otherwise is a checkbox whose production label claims a narrow purpose ("sign-in links & codes").
This is the same defect the resolver was already fixed for once. instance-tool-policy.ts:189-193:
"eleven BASE names with no registry entry — write_memory, fetch_url, create_task and the rest — were invisible to an operator auditing what an agent may do, while the chat ran them."
Same shape, one tool later, on the mailbox.
Why the obvious fix is the wrong one
The first thing that suggests itself is to stop rendering the checkbox where Gmail is irrelevant. It is well supported on the surface: showsEmail = showsConnector(emailStatus) (SettingsTab.tsx:400) consults only the account, while its two neighbours use showsFileConnector(…, connectorPolicy, …) (:401-402) and additionally consult the per-instance verdict — the #352/#353 narrowing that Gmail never received. Measured: the checkbox renders on 43 of 43 instances, and GET /v1/instances/:id/connectors returns gmail: {allowed:false, reason:"no_tools"} on 43 of 43.
Do not do it. The comment at SettingsTab.tsx:576-583 is right, and it is right for a reason that outlives the connector work:
"…disabled rather than hidden while the account is disconnected, because agent-think.ts still offers find_confirmation_link on this flag alone: hiding the checkbox would remove the only control that turns off something still set."
Verified: agent-think.ts:828 adds the tool for any instance whose flag is on, irrespective of capabilities.tools. The connector policy's no_tools verdict is about the seven gmail_* connector tools (#711/#713/#716) and says nothing about this built-in. Gating the checkbox on it would hide the off-switch for a live capability on every instance that has it — a permission you cannot revoke, which is strictly worse than the confusion being fixed.
The checkbox is not in the wrong place. The listing above it is wrong.
What to do — cheapest first
1. Make the listing tell the truth (the fix). Give resolveToolPolicy the same input the chat runtime has:
export function resolveToolPolicy(
capabilities: AgentCapabilities,
disabledTools: readonly string[] = [],
tools: ReadonlyArray<PolicyInput> = allToolPolicyInputs(),
consentedConnectors: readonly string[] = [],
grantedByPermission: readonly string[] = [], // ← new
)
const declared = new Set([...toolNamesFor(capabilities), ...grantedByPermission]). The resolver stays pure; routes/tools.ts resolves permissions.email from the instance DO — lib/connectors/gmail.ts already has the proven fail-closed helper (emailPermitted, reads https://agent/state, treats every uncertainty as not permitted) — and passes ["find_confirmation_link"] when it is on.
Once the row is allowed:true, the existing UI does the rest for free: it appears in the Tools list with its own description and its own off-switch, reachesOutside counts it, and toolScopeSummary stops being able to assert a reach negative over it.
2. Give the not_declared case an honest reason. With the flag off, find_confirmation_link reads not_declared, which means "belongs to some other agent" and is false — it belongs to this agent as soon as one checkbox is ticked. Either add a reason value (needs_permission) or leave it out of the listing entirely; the current answer is the one thing it should not be.
3. Collapse the three call sites. toolNamesFor(capabilities, { emailEnabled }) would let agent-think.ts:828, agent-do-tools.ts:282 and the resolver derive one set from one rule. Three copies of "and also this tool if the flag is on" is why the third one was forgotten. Worth doing as part of (1); not worth blocking (1) on.
4. Separate the two controls visually. They are 31px apart under one heading and read as one control — an owner asked about "gmail / write access" directly above "read my inbox" reasonably concludes they are the same switch. A rule or a sub-heading between the connector consents and the per-agent permission is enough; there is no need to move either.
Relationship to work already in flight
Acceptance criteria
Regression risk
- Hiding the checkbox is the regression, and it is the fix a reader will reach for first. It would remove the only off-switch for
find_confirmation_link on every instance holding the permission. Caught by the last acceptance criterion above; worth a comment at the change site, because the argument is not local to it.
- A DO state read now sits in the
/tools path. It must fail closed and must not fail the request: an unreadable state means "not permitted" (as emailPermitted already does), not a 500 — ToolPermissions.tsx:41-53 records why a dropped read on this panel is the worst possible silent failure.
allowed:true also un-gates the tool for POST /v1/instances/:id/tools/:name. Check whether the invoker gates on the same policy; if it does, this becomes a real reach change (the owner invoking their own permitted tool — defensible, but it should be a decision, not a side effect). find_confirmation_link carries invocableBy — confirm it stays ["chat"].
Open question for the owner
Should find_confirmation_link become an ordinary declarable tool now that Gmail is a first-class connector with seven declared tools? agent-do-tools.ts:266 argues it should not — "it is granted by what the agent IS" — and that argument was written when it was the only Gmail tool in existence. It is now the only one that bypasses capabilities.tools, which means a creator's declaration cannot describe an agent's full mailbox reach. I would keep it as-is for this ticket (the fix above is smaller and unblocks the copy already in flight) and treat the question as belonging with #718's grant-model work.
Measured against production and
main@8bf125c4on 2026-08-21.What the owner sees
Settings → Permissions & Connections renders, in one card, in this order:
ToolPermissions.tsx:120-122)SettingsTab.tsx:588-598)Measured in WebKit at 420px against
proagentstore.online, on a Repo Coder instance: the Gmail checkbox's bounding box top is 31px below the write-access checkbox's bottom (4856.17vs4825.17). Same card, same heading, no separator.Ticking (3) really does give the agent a tool that reads the owner's Gmail. Panel (1), which claims to list everything the agent is allowed to do, never mentions it — before or after.
The mechanism: two places compute "this agent's tools" and only one knows about the flag
The chat runtime applies the permission grant.
workers/api/src/agent-think.ts:827-828:…and again for what the model is shown,
workers/api/src/agent-do-tools.ts:282:The listing does not.
workers/api/src/lib/instance-tool-policy.ts:214-219:toolNamesFor(agent-do-tools.ts:226) takes capabilities and nothing else.find_confirmation_linkis deliberately excluded fromCREATOR_SELECTABLE_TOOLS—agent-do-tools.ts:266says so and gives the reason — so it can never bedeclared, so this resolver can only ever returnnot_declaredfor it, whatever the owner has granted.The console then drops the row entirely:
listedToolskeepsallowed || disabled(store/console/src/lib/toolPolicy.ts:~131), and anot_declaredrow is neither.Measured on the one instance where the flag is actually on
Across all 43 instances of the operator account, exactly one has
permissions.email === true: Job Application Assistant (51a3404e-9f78-4dbf-89b1-7a6976ce52c3). LiveGET /v1/instances/51a3404e…/tools:{"name":"find_confirmation_link","allowed":false,"disabled":false,"reason":"not_declared","reach":"internet"}find_confirmation_linkamong them: nofetch_url,submit_job_application,read_terminal,send_to_cli— the mailbox reader is not one of themSo on the single agent in this account that has been granted access to the owner's mail, the platform's authoritative answer to "what can this agent do" says that tool is not this agent's. The only thing on screen that says otherwise is a checkbox whose production label claims a narrow purpose ("sign-in links & codes").
This is the same defect the resolver was already fixed for once.
instance-tool-policy.ts:189-193:Same shape, one tool later, on the mailbox.
Why the obvious fix is the wrong one
The first thing that suggests itself is to stop rendering the checkbox where Gmail is irrelevant. It is well supported on the surface:
showsEmail = showsConnector(emailStatus)(SettingsTab.tsx:400) consults only the account, while its two neighbours useshowsFileConnector(…, connectorPolicy, …)(:401-402) and additionally consult the per-instance verdict — the #352/#353 narrowing that Gmail never received. Measured: the checkbox renders on 43 of 43 instances, andGET /v1/instances/:id/connectorsreturnsgmail: {allowed:false, reason:"no_tools"}on 43 of 43.Do not do it. The comment at
SettingsTab.tsx:576-583is right, and it is right for a reason that outlives the connector work:Verified:
agent-think.ts:828adds the tool for any instance whose flag is on, irrespective ofcapabilities.tools. The connector policy'sno_toolsverdict is about the sevengmail_*connector tools (#711/#713/#716) and says nothing about this built-in. Gating the checkbox on it would hide the off-switch for a live capability on every instance that has it — a permission you cannot revoke, which is strictly worse than the confusion being fixed.The checkbox is not in the wrong place. The listing above it is wrong.
What to do — cheapest first
1. Make the listing tell the truth (the fix). Give
resolveToolPolicythe same input the chat runtime has:const declared = new Set([...toolNamesFor(capabilities), ...grantedByPermission]). The resolver stays pure;routes/tools.tsresolvespermissions.emailfrom the instance DO —lib/connectors/gmail.tsalready has the proven fail-closed helper (emailPermitted, readshttps://agent/state, treats every uncertainty as not permitted) — and passes["find_confirmation_link"]when it is on.Once the row is
allowed:true, the existing UI does the rest for free: it appears in the Tools list with its own description and its own off-switch,reachesOutsidecounts it, andtoolScopeSummarystops being able to assert a reach negative over it.2. Give the
not_declaredcase an honest reason. With the flag off,find_confirmation_linkreadsnot_declared, which means "belongs to some other agent" and is false — it belongs to this agent as soon as one checkbox is ticked. Either add a reason value (needs_permission) or leave it out of the listing entirely; the current answer is the one thing it should not be.3. Collapse the three call sites.
toolNamesFor(capabilities, { emailEnabled })would letagent-think.ts:828,agent-do-tools.ts:282and the resolver derive one set from one rule. Three copies of "and also this tool if the flag is on" is why the third one was forgotten. Worth doing as part of (1); not worth blocking (1) on.4. Separate the two controls visually. They are 31px apart under one heading and read as one control — an owner asked about "gmail / write access" directly above "read my inbox" reasonably concludes they are the same switch. A rule or a sub-heading between the connector consents and the per-agent permission is enough; there is no need to move either.
Relationship to work already in flight
gmail.modifydeclared but never requested) and [design] Gmail is granted all-or-nothing in practice: no read-only connect, no way to elevate later, and "decline it at Google's screen" is stated nowhere the user will see it #718 (all-or-nothing Gmail grant) own the OAuth grant layer. Not this.Acceptance criteria
permissions.email === true,GET /v1/instances/:id/toolsreportsfind_confirmation_linkasallowed:true, reason:"ok", and the console lists it with its description and an off-switch.reasonis notnot_declared.toolScopeSummaryon an otherwise platform-only agent with the flag on no longer asserts "no tool that reaches outside the platform".gmail_*tool, and still renders while the account is disconnected.agent-think.tsputs inallowedToolNamesappears asallowed:trueinresolveToolPolicyfor the same inputs.Regression risk
find_confirmation_linkon every instance holding the permission. Caught by the last acceptance criterion above; worth a comment at the change site, because the argument is not local to it./toolspath. It must fail closed and must not fail the request: an unreadable state means "not permitted" (asemailPermittedalready does), not a 500 —ToolPermissions.tsx:41-53records why a dropped read on this panel is the worst possible silent failure.allowed:truealso un-gates the tool forPOST /v1/instances/:id/tools/:name. Check whether the invoker gates on the same policy; if it does, this becomes a real reach change (the owner invoking their own permitted tool — defensible, but it should be a decision, not a side effect).find_confirmation_linkcarriesinvocableBy— confirm it stays["chat"].Open question for the owner
Should
find_confirmation_linkbecome an ordinary declarable tool now that Gmail is a first-class connector with seven declared tools?agent-do-tools.ts:266argues it should not — "it is granted by what the agent IS" — and that argument was written when it was the only Gmail tool in existence. It is now the only one that bypassescapabilities.tools, which means a creator's declaration cannot describe an agent's full mailbox reach. I would keep it as-is for this ticket (the fix above is smaller and unblocks the copy already in flight) and treat the question as belonging with #718's grant-model work.