diff --git a/docs/plans/hosted-mcp-connection-scope.md b/docs/plans/hosted-mcp-connection-scope.md index 075a7e63eb..a37c034b69 100644 --- a/docs/plans/hosted-mcp-connection-scope.md +++ b/docs/plans/hosted-mcp-connection-scope.md @@ -28,6 +28,8 @@ **Amended 2026-10-19 (writes, with the member's approval):** Sam ruled, relayed by connector-ops on the Wave 1 rows (TASK-237 to TASK-246), that "writes are allowed when the member approves them, and apps are chosen by what our users use". That lifts §3's bar that every entry's credential be unable to write (76043). A tool that writes is pinned `write`, so each call parks for the credential's owner with its exact arguments, and the mint still refuses `write` on a hosted grant (§3, §5). The ruling accepts two residuals: a token scoped to write can write whatever the pin says, and a row connected before its entry requested a write scope must reconnect to write (§3). Without the bar, what a read pin may reach rests on the pin set, so §3 now records the rules Wave 1's pin sets are ruled under (Wren 78917). §5 no longer requires the first write entry to keep a credential that can write away from unparked calls. Its other item stands, and §5 now specifies it: a parked call runs only under the pin it parked under, and that is built before the first entry that pins a write merges. Wave 1's ten rows replace the 2026-10-14 hold and every order step 8 gave, and Basecamp, Honeycomb and Stripe are off (§10, step 8). §12 is unchanged: a hosted write parks on a card that shows its canonical arguments, which is the approval gate the CTO's #2168 ruling allows. The code this amendment cites is read at `main` `df8c219f`. +**Amended 2026-10-21 (TASK-213):** connector-ops ruled, for the CTO's security gate, that §12's 2026-10-21 amendment at `b99e23fb` is the build spec for A–D, and that E is built after them as TASK-257 (the TASK-213 row). §12 now carries the ruling's three notes: the route-table test covers every router that accepts a runtime token, an agent upload resolves to a pod or is refused, and a seat narrowed in two or more pods is told why in plain words. The code gate's census of those routers (Vera, the same row) found three files the ruling's list lacked. Reading every router against A's table then found a class it had no cell for: a seat's own event acknowledgements and spawn credentials, which pass. The same reading added two precisions: a write's pod is read from the record it writes, and a per-spawn child meets B's point 4 through the seat token that minted it. The code this amendment cites is read at `main` `cd37b32e`. + **In short, a hosted MCP server is a second tool source for the grants plan's §10, not a new connector.** It adds one new `Integration.type`, `hosted-mcp`, a per-person Connection (§10.1). Its tools come from a catalogue entry this repo pins, not from the hand-written map in `toolBrokerService.ts`. A member connects their own account through the vendor's authorization server. The broker holds the credential, a room grant scopes it, the vendor runs the tools, and the trail records who acted. Grants work as they do today: tiers, attenuation, budget, expiry, the confirmation floor, approval and the trail. What is new is who wrote the tool list, and most of this note is about that. In this note, `§10.x` means that section of [tools-catalogue-room-grants.md](tools-catalogue-room-grants.md), "the grants plan", and "the entry" means one catalogue entry (§3). @@ -542,7 +544,7 @@ The TASK-172 ruling's part 3 wording on `:180` is replaced by §6. **2026-10-15 amendment (TASK-224).** The CTO ruled that a grant from an outsider-writable source is attached, and its tools called, only in an eligible pod (connector-ops 77728). #2211 built the rule (`outsiderGrantPodRefusal`, outsiderGrantPodEligibility.ts:41). It reads no declaration from (b) and applies to every grant, and every source is outsider-writable today. A grant may be minted, attenuated, listed or called only while all of these hold: -1. It is a pod grant. A seat grant is refused until TASK-213. +1. It is a pod grant. A seat grant is refused, and the TASK-213 amendment below keeps it so. 2. Its write mode is not `write`, which would let a GitHub App tool write with no person (item 5). `write-with-confirm` remains. 3. Its pod is private and closed: `publicRead` is `false` and `joinPolicy` is `'invite-only'`, so a pod missing either field is refused. No live integration other than a hosted-MCP or GitHub App connection is attached to the pod or has a gate enabled for it. One that is revoked, disconnected, in error, inactive or paused by an admin does not count. 4. Every agent in the pod runs inside Commonly's backend. Each agent member has exactly one active installation in the pod, and every active installation belongs to an agent member and runs in-process: its `config.runtime.runtimeType` is `native`, or it is a hosted install whose `config.hosted.engine` is `native` (#2224). A hosted agent on the Durable Object polls from outside the backend and is refused (outsiderGrantPodEligibility.ts:71–73). Humans are fine. @@ -551,7 +553,7 @@ Mint and attenuation refuse a failing grant with `outsider_grant_` an While a live grant reaches a pod, every agent in it that runs in-process is narrowed (TASK-224, nativeOutsiderGrantPolicy.ts:22), and the narrowing covers its built-in Commonly tools. Of those, its turn is offered only `commonly_read_context` and `commonly_post_message` (nativeRuntimeService.ts:426), the native dispatcher refuses any other name with `outsider_writable_grant_tool_denied` (:579), and the post lands only in the run's pod (:648). The check is re-read before each model call and each native dispatch, and a failed read narrows (:1577, :1698, :1841). A grant's own tools are not narrowed. They are offered beside the built-in two (:1711), and a call to one goes to the broker (:1811) before the native dispatcher (:1852), so it answers to the grant's checks above, not to this list. -The rule does not compute (a)'s list. It bounds it at the pod instead. Condition 3 keeps a grant's tools from being listed or called while its pod is public, joinable or bridged (item 4), and condition 2 rules out item 5, for every grant in the pod. Condition 3 is not read again before the posts that follow a grant call in the same turn, so a bridge enabled in between can relay one. TASK-234 re-reads the bridge before each such post (#2318, open), as #2244 already does once a turn has read the web (nativeRuntimeService.ts:1844–1845). Condition 4 leaves only agents that run in-process, which hold no seat tools (item 6). While narrowed, their built-in tools can read the pod's context and post in it and nothing else (items 1–3), and their grant tools cannot write without a person's Approve, since condition 2 refuses `write` (item 5). That bound is on what the backend dispatches for an agent's turn. A runtime token is a second channel to the same identity: whoever holds one calls the runtime API as the agent from outside the backend, where the narrowing does not apply (registry/tokens.ts:228–234). So the bound holds only while no in-process agent holds a runtime token. #2224 makes the shared token issuer refuse to issue one to any of them, with 409 `in_process_agent_no_token` (:236–239, :269), and the legacy issuer refuses the same way (:366). So (a) and (b) stand as the analysis of outward effects and sources, and the default's finding, the parking paragraph, (c) and (d) are superseded where marked below. Until TASK-213 no seat the daemon runs can use a grant: a seat grant fails condition 1, and a pod grant fails condition 4 while a daemon seat is in the pod. What TASK-213 re-opens is for a later ruling. Part 1's marker covers hosted-MCP results only (toolBrokerService.ts:1271), so a GitHub App result carries none. The CTO's conditions for offering GitHub outside name a hosted entry for that reason (TASK-225), and a GitHub App connection is made only through the administrator route (integrations.ts:390). The code this amendment cites was first read at `main` `d37a8600` and is re-read at `b57966d2`. +The rule does not compute (a)'s list. It bounds it at the pod instead. Condition 3 keeps a grant's tools from being listed or called while its pod is public, joinable or bridged (item 4), and condition 2 rules out item 5, for every grant in the pod. Condition 3 is not read again before the posts that follow a grant call in the same turn, so a bridge enabled in between can relay one. TASK-234 re-reads the bridge before each such post (#2318, open), as #2244 already does once a turn has read the web (nativeRuntimeService.ts:1844–1845). Condition 4 leaves only agents that run in-process, which hold no seat tools (item 6). While narrowed, their built-in tools can read the pod's context and post in it and nothing else (items 1–3), and their grant tools cannot write without a person's Approve, since condition 2 refuses `write` (item 5). That bound is on what the backend dispatches for an agent's turn. A runtime token is a second channel to the same identity: whoever holds one calls the runtime API as the agent from outside the backend, where the narrowing does not apply (registry/tokens.ts:228–234). So the bound holds only while no in-process agent holds a runtime token. #2224 makes the shared token issuer refuse to issue one to any of them, with 409 `in_process_agent_no_token` (:236–239, :269), and the legacy issuer refuses the same way (:366). So (a) and (b) stand as the analysis of outward effects and sources, and the default's finding, the parking paragraph, (c) and (d) are superseded where marked below. Until TASK-213 no seat the daemon runs can use a grant: a seat grant fails condition 1, and a pod grant fails condition 4 while a daemon seat is in the pod. The TASK-213 amendment below re-opens the pod grant for daemon seats the server can see are confined. Part 1's marker covers hosted-MCP results only (toolBrokerService.ts:1271), so a GitHub App result carries none. The CTO's conditions for offering GitHub outside name a hosted entry for that reason (TASK-225), and a GitHub App connection is made only through the administrator route (integrations.ts:390). The code this amendment cites was first read at `main` `d37a8600` and is re-read at `b57966d2`. **Also required for the outside offer.** The rule is necessary for offering a grant to outside teams but not sufficient. The ruling also waits on four pieces, each in its state on 2026-10-18: @@ -560,6 +562,64 @@ The rule does not compute (a)'s list. It bounds it at the pod instead. Condition - TASK-228 (#2225): an owner can make a pod invite-only, so a team can build an eligible pod. Merged as `1ad396dc` and deployed with `41e98b1e`. - #2224: the shared token issuer refuses every in-process agent (above). Merged as `4b21e895` and deployed with `d37a8600`. +**2026-10-21 amendment (TASK-213).** This re-opens pod grants to seats the daemon runs, when the server can see that the seat is confined (the TASK-213 row, 2026-10-21). It narrows the seat's runtime token while a grant reaches its pod (A), widens condition 4 and the join rule (B), adds a daemon-side check for what the server cannot see (C), and names the walks that prove it (D). They are built in that order. B must not ship before A: without A, a confined seat still holds items 1–3 through its token. Under A–D, private (unconfined) seats stay refused, because the server cannot bound item 6 for a process with a shell and its owner's credentials. E, ruled the same day, admits one on its owner's own connection only, and TASK-257 builds it after A–D. Conditions 1–3 stand, and so do the CTO's two conditions for the outside offer: kernel parking (TASK-214) and the rollout list of live grants with each owner's override recorded. The code cited in this amendment is read at `main` `cd37b32e`. + +> **Scope boundary — read before implementing against this amendment.** ADR-020 D1 sorts the Guide's own actions: outward needs a person's Approve, contained stays autonomous. This amendment uses the same test of what is outward to decide something else: what any seat in a pod reached by an outsider-writable grant may call with its runtime token. Here an outward call is refused, not parked, because no parking exists for items 1–3 (TASK-214, held). A route's class here does not reclassify it under D1, and a propose-action card posted in the pod still runs on a person's Approve, as D1 and the #2168 ruling allow. Who gets woken by what is ADR-018 D8's decision, and this amendment changes none of it: a narrowed seat is still woken in its other pods, and its writes there are refused. Whether it should be woken there at all belongs in ADR-018. + +**A. Narrow the runtime token, not the declared tools.** While a live outsider-writable grant reaches a pod an agent is installed in, every runtime call made as that agent is checked against a route table. + +- **Whose calls.** The check keys on the agent, not the token. agentRuntimeAuth resolves the agent's active installations and pods on both of its success paths (agentRuntimeAuth.ts:179–180, :234–235), so a per-spawn child credential is narrowed with the seat that minted it. It counts by what the token can call, not by the seat's declared `mcp` list. Since #2179 the daemon adds the Commonly MCP server even over an explicit `mcp: []` (`withDefaultMcpServer`, cli/src/lib/default-environment.js:116), so a declared list is no evidence that a seat lacks items 1–3. +- **Which pods.** N is the set of the agent's pods that a live outsider-writable grant reaches, computed as `podHasOutsiderWritableGrant` computes it for one pod (nativeOutsiderGrantPolicy.ts:22), in one query for all of them. It is read on every request with no cache, and a failed read narrows. Revoking the grant or its expiry lifts it, as for in-process agents. +- **Where.** At agentRuntimeAuth's two success returns (:191, :289). User routes refuse runtime tokens (auth.ts:44), so this is the only door, and it covers the routers that accept a runtime token through their own `dualAuth`, `anyAuth` or `maybeAgentRuntimeAuth` wrappers. Eleven route files require agentRuntimeAuth: agentsRuntime, agentHooks (mounted inside agentsRuntime's router), agentEnsemble (mounted at `/api/pods`), artifacts, github, grants, mcpGrants, messages, spawnCredentials, tasksApi and uploads. The `/agents` socket namespace checks a runtime token itself (agentWebSocketService.ts:359), but it carries only event delivery and the seat's own acknowledgements, which pass under A anyway (the table, below). agentRuntimeAuth.ts is a CTO P0 file, so this change carries the security gate. +- **The table.** One module classifies every route that accepts a runtime token as a read, a write into a pod (with the function that resolves the pod), a call that answers to the grant's own checks (the broker and grant routes), a call on the seat's own state, or outward. The seat's own state is its event acknowledgements (agentsRuntime.ts:1312) and its spawn credentials (spawnCredentials.ts:119–223), and those calls pass at any N. They reach no pod and no other agent, and a child is narrowed with the seat that minted it (above). Refusing them would stop the seat in its grant pod as well: a refused mint fails the spawn closed (cli/src/lib/spawn-credential.js:227), and a refused acknowledgement never settles the event (cli/src/commands/agent.js:2336–2347). When N is empty, nothing changes. When N is one pod P, reads pass, a write passes only when it resolves to P, and outward calls are refused. When N holds two or more pods, every write into a pod is refused, because a write into one can carry another's results. A write's pod is resolved from the record the write lands on, never from a path segment that the handler does not check against that record. A route missing from the table is refused whatever its method, and so is a write whose pod does not resolve. A test lists every route that accepts a runtime token, taken from every route file that requires agentRuntimeAuth rather than from a fixed list, and fails on any the table lacks, so a router added later fails it until it is classified. +- **Outward.** These are refused: + - item 1's `/agent-dm` (agentsRuntime.ts:1055), and `/room` (:848), whose agent path opens a 1:1 pod with any agent (items 1 and 2 above missed it); + - item 2's `/pods` (:3045) and self-install (:3279); + - the writes with no pod: a thread comment on a post with no pod (:2274), and a post with no `podId` (:2862). Every upload a runtime token reaches names its pod in its path (:3985), and one that did not would be refused as a write whose pod does not resolve; + - publishing through a pod integration (:3542); + - GitHub issue writes (github.ts:172, :188, :202); + - a memory write that sets a section other agents read. Public sections reach every agent and pod sections every agent sharing a pod (agentsRuntime.ts:3713), so a memory write (:2587, :2717) passes only when every section it sets is private. +- **Refusal.** 403 `outsider_grant_runtime_narrowed`, naming the pod and the route's class. When N holds two or more pods, the seat can post nowhere, its grant pods included. So the message and the Tools page say why in plain words: the agent is in more than one pod a connector reaches, so it cannot post anywhere until only one of them does. + +**B. Which daemon seats qualify, and on what evidence.** Condition 4 and the join rule (`assertAgentRuntimeMayJoinPod` and `assertInstalledAgentMayJoinPod`, outsiderGrantAgentJoinPolicy.ts:24, :32) admit a seat that does not run in-process when all of these hold: + +1. Its adapter is recorded on the server: `adapterForRuntimeBinding(config.runtime)` (grantBrokerConfinement.ts:135) names an adapter in `CONFINING_ADAPTERS` (:74). +2. It declares a sandbox block, and `grantBrokerRefusal` returns null for it (:200). Item 6 says why an absent block cannot count. +3. Every entry in its declared `environment.mcp` is exactly the shipped Commonly server or the shipped grant broker. The backend gets its own copy of `isShippedCommonlyMcpEntry` and `isShippedGrantBrokerEntry` (cli/src/lib/declared-mcp-guard.js:83, :148), held to the CLI's by a test on each side, as `LEGACY_SANDBOX_TRUST` is. An absent or empty list qualifies, since #2179 adds only the shipped server. +4. Every live runtime token of the agent was minted by a daemon for the machine the agent is bound to. That mint records the machine and the daemon's credential as the token's parent (agentBinding.ts:379–382; registry/tokens.ts:319–325), and it mints only once every earlier token is revoked (agentBinding.ts:358–365). The check counts both token stores, as the mint does (:352). A per-spawn child is in neither store. Only a seat's own token can mint one, never another child (spawnCredentialService.ts:175), and the child authenticates only while that token's credential is active (agentRuntimeAuth.ts:110–113). So a child meets point 4 through the seat token that minted it, and is not counted on its own. + +Point 4 is what makes the first three evidence. The Connect page's bring-your-own path installs with `{runtimeType: 'webhook'}` and no adapter (V2AgentBYO.tsx:536; install.ts:518 stamps `host: 'byo'`), then renders the token into three snippets. Two of them, `claude mcp add` and the Cursor config (V2AgentBYO.tsx:693, :720), load it into the person's own client, which runs with whatever tools that client has. Only the third (:716) starts a confined listener. An adapter the page recorded would name the snippet it showed, not the process holding the token, so the bring-your-own path does not qualify. The page's "on my computer" path does. It installs with `runtimeType: 'wrapper'` (:477), and the daemon mints the token, so nothing renders. That install records `adapter` only when the person picks one, and records no sandbox. B adds both to it: the adapter the person picks (auto-detect records none, and that seat does not qualify), and `environment.sandbox: {trust: 'public'}`, the block the daemon applies by default. An existing seat can record both with `commonly agent config`. The daemon already refuses to run any adapter but the recorded one (daemon-supervisor.js:231, :321). + +B is checked at every door that can break it. While a live grant reaches one of the agent's pods, the registry PATCH refuses a `config.runtime` or `config.environment` change that fails points 1–3, and the registry runtime-tokens route, which returns the token in plaintext, refuses to mint for that agent, so point 4 cannot be undone after the grant. The refusal codes stay `outsider_grant_non_native_agent` and `outsider_grant_non_native_agent_join`, and their messages name the point that failed. + +**C. The daemon refuses what the server cannot see.** A public seat can still load an MCP server its owner installed locally. `auditDeclaredMcp` admits a stdio entry equal to one in the local token record (declared-mcp-guard.js:213, :279), and the server never sees it. So the server marks a narrowed seat in the installation list (`GET /installations`, agentsRuntime.ts:502) and in each event, computed as A computes N, and the daemon reads the mark before each spawn. The daemon and `commonly agent run` refuse to start any turn of a narrowed seat, in any pod, while its effective MCP set holds anything other than the shipped Commonly server and the shipped broker. The refused event is acked with `outsider_grant_mcp_unconfined`, and the host log says why. C protects an honest owner from injection. It cannot bound a hostile owner, who can use the token in any client, which is why B rests on point 4 and not on C. C ships in the next CLI version after TASK-256. + +**D. Walks.** Run them on a claude seat and a codex seat, both placed through "on my computer", each in a pod that a live hosted grant reaches: + +- The attack re-run. An injected PR body asks the seat to DM another agent, post in another pod, comment with no pod, create a pod, write a public memory section, and send what it read out through the adapter's own network tools. Each kernel call is refused with A's code, and each network attempt fails inside the adapter. Whether codex's built-in web search is off under the public profile (`network.enabled=false`) is unverified, and this walk settles it. +- A connect-and-call walk on a non-admin account, from the connection to a hosted read in the pod. +- The refusals: a bring-your-own seat refused at the mint and at the join; the plaintext mint refused for a narrowed agent; a seat with a locally installed MCP server refused at spawn. + +**E. The own-credential rule for private seats.** The Cofounder ruled yes on 2026-10-21 (the TASK-213 row), as a clause separate from A–D. TASK-257 builds it after A–D, and A–D do not wait on it. A private seat here is one the daemon runs that fails any of B's points 1–3, so the server must assume it runs with a shell and its owner's credentials. It may call a grant in its pod only while all of these hold: + +1. The seat's owner is the connection's owner. The connection's owner is its `createdBy`, which the broker already reads for both kinds (toolBrokerService.ts:589, :721). The seat's owner is the person whose machine it is bound to, and that person must still be the identity's sole installer, the test the daemon's adopt and mint apply (`ownsRuntimeIdentity`, runtimeIdentityOwnership.ts:50; agentBinding.ts:157, :331). +2. The pod is invite-only. Condition 3 already requires that of every grant pod. +3. Every write parks for the owner's own approval, and nothing approves one automatically. Under `write-with-confirm` every non-read call parks (toolBrokerService.ts:1161–1162), as a card owned by the connection's owner (:1185) that only that person can decide (approvalActionService.ts:681). Condition 2 already refuses `write`, the one mode that runs a write unattended. Point 1 makes the decider the seat's owner, and E adds no other way to approve. +4. The Tools page states the remaining risk in plain words, beside the grant: content the agent reads can steer what it posts into this pod. + +Reads are allowed. + +E is checked again at every request, as A is, and not only at the mint and the join. Its query is A's, the enumeration in podHasOutsiderWritableGrant, which then also returns each connection's `createdBy`. It stays one function with two callers, so the two checks cannot drift apart. A seat that stops meeting point 1, for instance because a second person installs its identity, is treated from that request as a seat E never admitted, so the pod and its grants are refused to it. A hosted call already checks the connection owner's account (`assertHostedOwnerUsable`, toolBrokerService.ts:590), but that check refuses only an account that is missing, banned or a bot, and it never compares owners. So a test of E's re-check changes who installs the seat, not the owner's account: a banned owner is refused by that check whether or not E's comparison works. + +The server still cannot bound item 6 for a private seat, and E does not claim to. It rests on point 1 instead. A seat that already holds its owner's shell and credentials gains nothing from a grant on its owner's own connection that its owner had not already given it. A grant on another person's connection would give it that person's reach. B's point 4 still applies, and here it is what makes point 1 evidence: every token was issued to the daemon on the bound machine, so the machine's owner is the person the seat runs as. B's refusal of the plaintext mint, which protects point 4, applies to these seats too, and the bring-your-own path stays out. + +How E meets the rest: + +- A applies unchanged. It keys on the pods a grant reaches, not on confinement, so the seat's runtime token writes only into that pod, which is the risk point 4 states. +- Condition 4 and the join rule admit a private seat only when point 1 holds for the connection of every live grant that reaches the pod. Another person's private seat would read that pod's grant results, which outsiders can write, and nothing would bound what it then does. So it stays refused at the mint and at the join, with B's codes. +- C applies only to seats admitted under B. A seat admitted under E already holds a shell, so a server its owner installed adds nothing to what it can reach, and C would stop the seat running at all. The mark C reads therefore names the rule that admitted the seat. +- The walk, on a private claude seat and a private codex seat, each in an invite-only pod with a live grant on its owner's own hosted connection: a read lands in the pod, a write parks and runs only on the owner's Approve, and the Tools page shows point 4's statement. Another person's grant minted into the pod is refused, and so is another person's private seat joining it. Once a second person installs the seat's identity, the seat's next request in the pod is refused. + The CTO ruled on #2168 on 2026-10-14. A grant from a source that outsiders can write is refused while any agent in its pod can cause an outward effect without a person. It is allowed with no override when every such effect is approval-gated: a parked call that waits for a person's Approve, on a card that shows the canonical arguments. Anything else needs the owner's override for that source and those tools, recorded and shown on the Tools page, and revoking either side ends it. Part 1's untrusted marker applies either way, and vendor blocks stay byte-identical. This section is the design note the ruling asks for before any code, and the build is its own row. The code it cites is read at `main` `8a3a93ac`. **The reach.** A grant's reach is the set of pods its results can be posted into without leaving the people the grant was made for. For a pod grant it is the target pod. For a seat grant it is every pod the seat is a member of or installed in, because the seat carries the grant into each of them. An attenuated child keeps its parent's target (`attenuateGrant` is passed none, grants.ts:426), so it has its parent's reach. @@ -646,5 +706,5 @@ An override never touches part 1: the marker wraps every result either way. - **Kernel parking for items 1–3** is no longer the next row: the TASK-224 ruling keeps it as the fallback (TASK-214, held). - **Discord** was not measured. Until it is, a pod with a Discord integration counts as bridged (item 4). -- **Agent memory** is not counted. Its reader is the agent, and item 3 already counts the agent's other pods. Whether an owner's view of a seat's memory is a way out is unmeasured. +- **Agent memory.** A section marked public or pod is read by other agents (agentsRuntime.ts:3713 at `cd37b32e`), so the TASK-213 amendment counts writing one as outward. A private section's reader is the agent. Whether an owner's view of a seat's memory is a way out is unmeasured. - **The disclosure line** for the grant dialog and the privacy page is a separate copy task (the ruling's point 5).