Skip to content

feat(mcp): per-account and per-node opt-out for the subshell MCP - #486

Open
theogravity wants to merge 10 commits into
mainfrom
worktree-mcp-opt-out
Open

theogravity wants to merge 10 commits into
mainfrom
worktree-mcp-opt-out

Conversation

@theogravity

@theogravity theogravity commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What

Two switches stop Subshell from wiring the MCP into panes, and off wins on either. An account's Preferences card is that person declining the tool surface and its per-turn context cost. A node's overview card is the machine's owner declining it, enforced on the machine itself, not just skipped by the plane.

  • Both default ON: MCP wiring predates its own switch, so an upgrade strips comms from nobody. (This deliberately inverts the opt-in polarity of the ssh/preview gates beside it; docs/security.md §6 accounts for it.)
  • A disabled pane's token is minted {report: [write]} instead of the full map. The sibling surface docs/security.md §4 accounts for is gone from the key, not merely unexposed; harness hooks and token self-extension still work, because every subshell report verb but exit now rides a token extend in parallel, so a hook-live pane never reaches the 7-day wall without an MCP child to arm the periodic one.
  • Node-off is enforced three ways on the machine: a local mirror file, a launch that strips wiring material from a frame that still carries it, and a refusing subshell mcp verb. A 403 at identity registration became fatal in the MCP child, so a host-registered harness exits with a clear notice rather than serving on a report-only key.
  • One dialect caveat: opencode and pi ship their child-roster reporter inside the MCP registration, so switched-off panes of those dialects report no roster; hook-driven dialects keep reporting.
  • Reach is new launches and restarts; running panes keep their live MCP child.
  • Protocol 27 + MIN_NODE_VERSION 3.2.0 land in the same commit as the agent's own set_mcp_enabled command. Release cut must dispatch app=all.

Verification

  • bun run verify:changed plus per-package suites through the review rounds: @internal/server 5035 pass / 0 fail, @internal/node 1322, mcp-core 140, web 3204, docs 184, protocol, and the cross-subshell e2e set. lint:design, lint:lockfile, verify-types: green. CI's full lane set is green on the final revision (14 checks, all pass).
  • test:cli passed every scenario except server-update.sh, which refuses to run on a host with a per-user Subshell Server service installed (this dev box has one; that's the guard documented in .claude/rules/verification.md, CI runs the scenario).
  • Live check on the e2e stack with real Claude Code panes (throwaway driver, not committed): a baseline pane's own tool metadata names the subshell MCP server; with the account switch off a new pane reports none AND still lights its attention bell (the report-only token working end to end); with the node (local) row off, a new pane likewise reports none. The stale-frame drop line on a real agent node cannot be staged against local (no mirror file there); it's pinned by the node launch tests and the scripted-node push suites.

Six review rounds (fan-out over protocol/node, server-api, and docs/UI slices, the last two a fresh full pass and its confirming re-verification) fixed, among others: the inverted reach claim in §6's refusal chain, an over-claimed roster dialect (oh-my-pi never had one), the "whole API reach" oversell against §3's public bearer reads, a false self-extension claim for disabled panes (answered by the extend-at-report behavior above), the stale token bullet in the server's AGENTS.md, the protocol reference page still saying 26, a disabled switch with no keyboard-reachable reason (now the preview card's composition), and pins for all four report doors, the extend door's ownership-only shape, and both new cards (including the absent-reads-ALLOWED polarity). Rounds 5-6 found the subtler ones: the report-verb token extend rides every pane's reports, not just the switched-off one's (three docs corrected); the exit report parks the row with status running and the harness-session door deliberately still records a parked row (a round-4 comment had it backwards); "never serves" became "never acts", since a disabled child with an unreachable plane enumerates inert tools; the protocol reference gained set_mcp_enabled; and the two account cards' cloned state machine became one shared useBooleanSetting hook. The final fresh full pass returned 0 MAJOR / 0 MINOR on every slice, confirmed against the fixing commit.

Fixes that verification found along the way: migration 0073 registered with the backup ledger (LATEST_BACKUP_MIGRATION + strip set), the remote-manager fixture runs runAuthMigrations like a real boot so a nodes delete resolves its FK graph, and the scoped-key route cell counts audit delta rather than emptiness.

Docs: configure/security/troubleshooting and nodes/manage pages, the protocol reference, docs/security.md §4/§6/§10, the rules summary, both app AGENTS.md deltas, and the server audit inventory. Changeset: @internal/server minor, @internal/node patch, @internal/docs patch.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

theogravity and others added 9 commits November 1, 2026 00:53
Two switches stop wiring the subshell MCP into panes; off wins on either.
An account's Preferences card is the person declining the tool surface and
its context cost; a node's overview card is the machine's owner declining
it, enforced on the machine itself (mirror file, launch-time strip, and a
refusing 'subshell mcp' verb), so a host-registered harness stops with a
clear notice instead of serving anyway.

Both default ON, which inverts the ssh and preview gates beside them: MCP
wiring predates its own switch, so an untouched row reads allowed and an
upgrade strips comms from nobody. A disabled pane's token is minted
{report: [write]} instead of the full map, which drops the sibling surface
docs/security.md §4 accounts for, while the harness hooks (attention, exit,
harness-session, subagents/report) and token self-extension keep working.
A 403 at identity registration is now fatal in the MCP child: on a
narrowed token, serving anyway would make the off switch a lie.

Protocol 27 (set_mcp_enabled frame, ready.mcpEnabled, mcp_enabled event)
raises MIN_NODE_VERSION to 3.2.0 in the same commit as the agent, so
server and node ship together; the release cuts as app=all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surface/docs slice (all MINORs and NITs): the account switch lives under
Account, not Settings; a misled agent, not a mislead one; the configure
page names the refusing process (the MCP child, not the harness); repoint
the dead services/ token citation; label the manage-page link by its
destination.

Protocol/node slice (NITs): name set_mcp_enabled in the wired-command
inventory; a mirror unlinked mid-read answers ABSENT (the allowed answer)
rather than refusing; the version-pin comment states the pre-27 hazard the
right way round.

Server slice: an audit row only records a change that happened, so the
setNodeMcpEnabled trail waits on a confirmed UPDATE. And one honest caveat
the review surfaced: opencode, pi and oh-my-pi ship their roster reporter
inside the MCP registration, so a switched-off pane of those dialects
loads no bridge and reports no roster. The door stays open for it; nothing
walks through. Documented at the tokens factory, the route, the AGENTS
inventory, security.md, and the rules summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…one syscall

The module had no test file while its ssh and preview twins did, which left
the round-1 ENOENT branch and the corrupt-refuses rule unpinned. Clone of
preview-flags.test.ts, with the inversion cells spelled out: absent ALLOWS,
off stays a visible kind (a fold into absent would un-say every owner's
refusal), an unreadable file announces the refusal it acts on, and a plane
push is memoized as reported so nothing echoes back.

The read itself loses the existsSync probe: readFileSync answers the
existence question, ENOENT is the allowed absent, and the probe/read pair it
replaced was two syscalls racing over one answer. The dangling-symlink
asymmetry the first review noted goes with it; both now read absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…each

Round-2 review: name which refusal fires where (the command refuses on a
mirrored node; identity registration is the account-only or never-mirrored
path), drop the false claim that a report-only key can still read its own
log (it asks for the subshells scope), and stop the Preferences card JSDoc
promising a roster the registration-coupled dialects never report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cts; pin the new arms

Round-2 review on the control plane:

- A disabled pane launches no MCP child, so nothing armed the 7-day extend
  and its hooks would quietly 401 once the TTL ran out under a pane in
  daily use. Each report verb now rides the extend in parallel, capped
  below the main POST's budget so no hook's worst case grows, skipped on
  . The claim the switch made always deserved.
- oh-my-pi reports no roster at ANY switch position; its extension is an
  empty manifest stub. The caveat (and the restore path it promised)
  belongs to opencode and pi only, in all six places I had spread it.
- setNodeMcpEnabled's absent-row guard and the ws-handler's mcp arms had
  no regression pins; both twins do. Added the ghost-row cell and four
  capture cells walking ready.mcpEnabled and the mcp_enabled event into
  the recording hook, including the silence-reads-ALLOWED inversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mark the main POST handled the moment it exists, so an injected transport
that throws at creation cannot strand a rejection in a hook process; the
extend-rides-every-verb loop now covers all four REPORT_VERBS members plus
needs_attention, so a future verb that forgets the extend fails a cell;
and the goes-quiet comment states the real healing path for a hand-deleted
off-mirror (the next ready's silence re-pushes the row) rather than the
default-row-only framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pins the review asked for: a report-only pane's extend-token acceptance at
the HTTP boundary (a future scope-annotation on that door would otherwise
401 disabled panes' hooks a week in, silently); two card test files for
the new cards, per the precedent each card's own JSDoc cites - the account
card's inert-Unknown/fallback-on/rollback trio, and the node card's
absent-reads-ALLOWED polarity (a triple-equals-true edit fails it),
owner-only refusal copy, failed-write state.

Docs accuracy: AGENTS.md's subshell-token bullet caught up to the switch
(TTL story, report-only map, transfers in the quoted literal - stale since
the transfers feature); section 6's reach claim now names the section 3
public bearer reads instead of overselling; section 4's map gained the
transfers member; the extend wording says every report but exit, and notes
derived keys meet the route's execute grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The node's MCP switch now explains itself like the preview card beside it:
one reason string feeds the tooltip, the focusable wrapper, and the gold
line, including a Saving reason while a write is in flight (the bare switch
was tab-past-and-never-know). The owner cell pins the aria wiring, which a
bare-switch edit fails.

Docs accuracy: the protocol reference page still said 26 while the branch
shipped 27 (the 25-to-26 bump commit updated this same sentence, and this
adds the ready-field clause the page's enumeration asks for); configure
says what opencode and pi additionally lose when switched off, since their
roster reporter rides the registration; the node's own AGENTS.md gains the
mirror, the launch drop, and the refusing verb, per the subsystem-rules
ruling that node gates live beside node code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The review caught that the acceptance was pinned for only two of the four
report routes: a per-file revert of the session or roster door to the old
requirePerm would silently 403 an opted-out pane's hooks (runReport swallows
failures as a lost report) with every other suite green. The cell now POSTs
each door with the report-only key, exit last because it writes the death
row, and its comment said every verb rather than every verb but exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@theogravity
theogravity force-pushed the worktree-mcp-opt-out branch 2 times, most recently from c4d33fb to d6186b5 Compare November 1, 2026 09:28
Fresh-eyes full passes on all three slices came back 0 MAJOR with ten
smaller items, all quote-verified and fixed here.

Accuracy: the token-lifetime story in three docs said the report-verb
extend is the switched-off pane's substitute, but it rides every pane's
reports; the four-door comment inherited a clause from round 4 that
round 5 disproved (exit parks the row with status running, and the
session door deliberately still records a parked row; only the roster
door 409s); security section 6's "never serves" became "never acts",
since a disabled child with an unreachable plane enumerates inert tools;
the extend budget comment now states its real inequality (within every
per-verb budget, equal at the fast one); the node-view polarity comment
names the default-deny switches instead of "the two above"; the
mcp-availability param cites the edit-grantee restart that actually
ships; and the refused-mirror notice admits a corrupt file reads the
same as an off one.

Reference page: set_mcp_enabled joins the command table (the only
set_* command the bump added and the page lacked), and the ready
enumeration names all three mirror states.

Structure: the account cards' duplicated probe/optimistic/rollback state
machine is now one hook, useBooleanSetting, run by the notifications
master switch and the MCP card alike, per the dedup rule their own test
comments cited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant