Skip to content

The tool list omits the one tool that reads the owner's mail: find_confirmation_link is granted by a checkbox 31px below a panel that says it is "not declared" #721

Description

@serge-ivo

Measured against production and main @ 8bf125c4 on 2026-08-21.

What the owner sees

Settings → Permissions & Connections renders, in one card, in this order:

  1. Tools — captioned "Everything this agent is allowed to do." (ToolPermissions.tsx:120-122)
  2. Agent write access — the connector consent checkboxes.
  3. "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_TOOLSagent-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

  • With permissions.email === true, GET /v1/instances/:id/tools reports find_confirmation_link as allowed:true, reason:"ok", and the console lists it with its description and an off-switch.
  • With the flag off, its reason is not not_declared.
  • toolScopeSummary on an otherwise platform-only agent with the flag on no longer asserts "no tool that reaches outside the platform".
  • The Gmail permission checkbox still renders on every instance where the flag can be set, including agents that declare no gmail_* tool, and still renders while the account is disconnected.
  • A test asserts the containment the resolver's own header already names, extended to permission grants: everything agent-think.ts puts in allowedToolNames appears as allowed:true in resolveToolPolicy for the same inputs.

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.

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

    P1: blocks external usersMust be true before someone who is not the owner can run an agent (#68)connectorsConnector + tool frameworkfrontendFrontend / UI worksecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions