Skip to content

fix(mcp): an agent cannot close its own pane, workspace or window (0228 C S1) - #534

Closed
nt-nerdtechnic wants to merge 7 commits into
mainfrom
feat/0228-c1-self-scope
Closed

nt-nerdtechnic wants to merge 7 commits into
mainfrom
feat/0228-c1-self-scope

Conversation

@nt-nerdtechnic

@nt-nerdtechnic nt-nerdtechnic commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Slice S1 of DESIGN-C (cross-workspace child panes): an agent can never close its own pane, its own workspace or its own window. Refused at the tool/backend side and again in the window. Includes the review #534 fixes and review #541 MED-1.

The self scope (backend, from the credential only)

_self_scope / _self_scope_refusal in mcp_server/server.py:

  • pane: the id the caller's token names, followed through re-attach aliases.
  • workspace: the registry's record for that pane. Compared by folder identity (os.stat → (st_dev, st_ino), _same_directory). A case variant on macOS/Windows, a symlink, a trailing slash or ~ all match. String compare is used only when a path cannot be stat'ed.
  • windows, the union of:
    • the window mirroring the caller;
    • every window mirroring any pane of the caller's workspace;
    • every main window that holds the caller's workspace, even with none of its panes in it. Windows now announce what they hold in ui.invoke.ready (new optional workspaces field, re-sent on change; a detached group window sends []).

When a path cannot be stat'ed (a folder that is gone), the fallback string compare is case-folded on macOS and Windows.

Case variants are stopped by the backend. The window only knows its own spelling of a path, so its second check compares case-folded strings and cannot see symlinks. The backend's stat comparison catches case variants and symlinks. Tests: test_a_case_variant_of_your_own_workspace_is_still_yours and test_a_case_variant_registration_still_counts_its_window_as_yours; they run on this APFS volume and skip on a case-sensitive one.

Inventory: registered commands that act on the caller's own window, workspace or pane

Main-window command registry (App.vue), reachable through ui_invoke. Editor, Git and plugin windows do not answer ui.invoke.request.

Command What it does Guard
ui.pane.close kills the named pane backend + window: self-close when the named pane (or an alias of it) is the caller
ui.pane.reclaim ends the CLI, keeps the card backend + window: the caller's id is taken out of the batch and returned as a refused row (reason: "self"), kept on an error reply too
workbench.action.closeWindow (⌘⇧W) window.close(), which tears down that window's PTYs backend: ui_invoke answers ui_dedicated_tool and points at window_close for ANY window, so closing someone else's window also leaves a record (R2 MED-1). The self check (self-window-close when it lands in one of the caller's windows) stays in _self_scope_refusal. Window: refused when it hosts the caller or holds its workspace
workbench.action.closeActivePane (⌘W) closes the focused pane window: self-close while the focused pane is the caller (only the window knows focus)
workbench.action.rebuildFocusedPane kills and resumes the focused pane window: self-close while the focused pane is the caller
ui.pipeline.reset onKillAll(): ends EVERY pane in the window, manual ones included, with no confirmation and no run needed backend: self-close in both pipeline_reset and ui_invoke when the request lands in one of the caller's windows (_lands_in_own_window, which now also reads announced held workspaces). Window: refused whenever it hosts the caller (R2 HIGH-1)
ui.pipeline.abort / restart / restart_from tear down or restart the run's slot panes window: self-close when the caller is a pipeline slot pane in that window (review LOW-1). Window only: the backend does not know slot membership
ui.window.close / ui.workspace.close (registered by S4) close a window / a workspace ui_invoke answers ui_dedicated_tool; only window_close / workspace_close (S4) may do it, because they record the action and show which pane did it. Those tools call _self_scope_refusal, which refuses self-window-close / self-workspace-close

Checked and not guarded, because none of them closes or ends the caller:

  • workbench.action.reloadWindow: the PTYs live in the backend and are reattached.
  • ui.workspace.switch: switching does not disturb panes.
  • ui.pane.interrupt: aborts a turn, closes nothing.
  • ui.pane.focus, ui.pane.place, ui.settings.close, workbench.action.closeModal and the open* / focus* / zoom* commands.

The existing cli_close_agent (self-close) and cli_reclaim_agent (self-reclaim) are unchanged.

Contract left for S4 (review MED-3): S4 must use exactly the arg names path and windowId, and must either require path or treat an empty one as the caller's own workspace. This is recorded as a TODO in _self_scope_refusal and as a skipped test in test_mcp_self_scope.py.

Red → green

Every behaviour was red first. Each round's tests were run against the previous commit with the source change reverted (git apply -R):

Round Backend Renderer
Round 1 (initial) 9 red 6 red
Round 2 (#534 review) 5 red 6 red
Round 3 (held windows, dedicated tools) 3 red 1 red
Round 4 (R2: reset, closeWindow, fallback casefold) 4 red 1 red

All green now.

Local verification (targeted only)

  • safe-pytest.sh <wt> -k "test_mcp_self_scope or test_plan_mcp_ui_invoke or test_plan_mcp_reclaim_agent or close_agent or test_share_mcp_tools or pipeline or ui_invoke_ready or window_id" -n 2 → 322 passed, 1 skipped (the S4 TODO), fake-security calls 0. This includes the golden tools/list test.
  • That run had one failure, test_plan_mcp_subdir_workspace::test_pipeline_window_requests_address_the_panes_own_workspace: its pane resets its own window's pipeline, which is now refused by design. The test now asserts self-close for that step; re-run → 27 passed together with the self-scope file.
  • pnpm vitest run src/renderer/src/composables/__tests__/useUiActionBus.test.ts --maxWorkers 1 → 67/67.
  • pnpm typecheck:web → 0 errors.
  • ruff → clean.

RULE ZERO

  • Host and external callers: no self scope, so behaviour is as on main.
  • Acting on another pane, workspace or window: same path as main. Locked by the …still_goes_through / …not_refused tests and the existing ui_invoke, reclaim and pipeline suites.
  • No tool description changed (golden test green).
  • Wire changes only add fields: the request gains caller_workspace_path; ui.invoke.ready gains optional workspaces.
  • Intended behaviour changes:
    • a pane cannot close, reclaim or rebuild itself through ui_invoke;
    • ui_invoke no longer runs ui.window.close / ui.workspace.close (neither exists on main yet), nor workbench.action.closeWindow (use window_close);
    • a pane cannot reset the pipeline of a window it is in (that ended every pane there, itself included).

Protection check

  • Credential, account-switching and quota paths (credential_*, quota_*, usage_*): diff vs origin/main is empty.
  • No function deleted. The diff's only removed def lines are a signature change and two tests turned from async to sync under the same names.
  • Signature changes:
    • _self_scope_refusal(caller, action, args) → (caller, action, args, workspace_path="") (new in this PR).
  • Added:
    • backend: _same_directory, _lands_in_own_window, _DEDICATED_CLOSE_ACTIONS, app.main_window_sessions(), Session.held_workspaces;
    • renderer bus options hostsPane, focusedPaneIds, isPipelineSlot, heldWorkspaces (all optional).
  • No new limits.

Known boundary (documented, unchanged)

A same-user process that reads another pane's token can act as that pane (architecture.md, "Trust boundaries"). These guards stop argument-level and tool-level self-close, not token theft.

Security-related: second review requested after this round.

🤖 Generated with Claude Code

ui_invoke("ui.pane.close", {paneId: <own>}) closed the caller's own pane:
only cli_close_agent refused self. Add one self-scope check derived from
the caller's credential alone (pane id from its token, workspace from the
registry, windows mirroring that workspace's panes) and apply it to every
close route ui_invoke reaches: ui.pane.close -> self-close,
ui.workspace.close -> self-workspace-close, ui.window.close ->
self-window-close. A ui.pane.reclaim batch drops the caller's own pane and
reports it refused (reason "self"), reclaiming the rest.

The window repeats the same rule in useUiActionBus using caller_pane_id and
the new caller_workspace_path field, which the backend fills from the
registry, never from args.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nt-nerdtechnic and others added 3 commits October 21, 2026 01:55
…folders not strings

HIGH-1: workbench.action.closeWindow is reachable through ui_invoke and runs
window.close() in the window that answers, which for a pane caller is its
own. Refused in the backend whenever the request lands in one of the
caller's windows, and again in the window (hosts the caller or holds its
workspace).

MED-1: workbench.action.closeActivePane and rebuildFocusedPane act on the
focused pane; the window refuses them while that pane is the caller.

MED-2: own workspace is compared by folder identity (st_dev, st_ino), so a
case variant on a case-insensitive volume is still the caller's own; the
window compares case-folded.

MED-3: the S4 contract for an empty ui.workspace.close path is recorded as a
TODO comment and a skipped test.

LOW: a pipeline slot cannot abort, reset, restart or restart_from its own
run (window side); a reclaim batch keeps the caller's refused row when the
window answers with an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… only via their tools

- A main window holding the caller's workspace with none of its panes in it
  (they all sit in detached group windows) is now part of the caller's own
  windows in the backend too. Windows announce the workspaces they hold with
  ui.invoke.ready (new optional `workspaces` field, re-announced on change;
  [] from a detached group window).
- ui_invoke answers ui.window.close / ui.workspace.close with
  ui_dedicated_tool: only window_close / workspace_close record the action
  and tell the user which pane did it (review #541 MED-1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ndow; closeWindow via window_close

- ui.pipeline.reset ends every pane in the window that runs it (onKillAll),
  manual panes included and without a run. Refused for a pane caller when
  it lands in one of the caller's windows: in pipeline_reset and ui_invoke
  (backend, via _lands_in_own_window) and in the window whenever it hosts
  the caller.
- workbench.action.closeWindow joins the dedicated close actions: ui_invoke
  answers ui_dedicated_tool and points at window_close, so closing anyone's
  window leaves a record.
- _lands_in_own_window also reads the workspaces windows announce;
  _same_directory falls back to a case-folded compare on macOS/Windows for a
  folder that cannot be stat'ed.

test_pipeline_window_requests_address_the_panes_own_workspace now expects
the reset from a pane of that window to be refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nt-nerdtechnic added a commit that referenced this pull request Oct 20, 2026
- Merge resolution with #534 R2: S1 now owns app.main_window_sessions()
  (S3 had added one of the same name); the S3 copy is dropped and the
  window helpers keep only sessions with a page-load id. ui_invoke's
  close actions are S1's ui_dedicated_tool now, so the S3/S4a pane check
  and name stamping for them are gone (ui.window.focus keeps its stamp).
- MED-2: window_close refuses the last main window off macOS
  ("last-window"), on the tool side and again in main
  (agentCloseRefusal, asked by the window through window:canCloseSelf
  before it lets go of anything, and at window:closeSelf).
- LOW-1: the 55s deadline answers only its own confirm dialog.
- Tests: last window (off macOS / on macOS), a main window holding the
  caller's workspace with none of its panes is the caller's own, and the
  ui_invoke route points at the tools.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nt-nerdtechnic and others added 3 commits October 21, 2026 05:35
…n sys.platform

CI's test_osplat ratchet (TestNoScatteredPlatformBranches) failed on
macOS and Windows: _same_directory read sys.platform in mcp_server/server.py.
The decision now sits behind a new osplat seam, paths_ignore_case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ge-load id

Same definition as DESIGN-C S3 (#535) uses, so the two slices share one
function instead of defining it twice (test_module_level_duplicate_defs).
Every caller here already skips a window without an id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nt-nerdtechnic added a commit that referenced this pull request Oct 20, 2026
…indows

- #534 and #535 each added app.main_window_sessions(); with both merged
  test_module_level_duplicate_defs failed. #534 now carries the window_id
  filter #535 used; the second copy is dropped here (C2 drops it from #535
  when #534 lands).
- test_plugin_agent_tools_proxy: MAIN_TOOLS_LIST_SHA256 re-pinned at this
  tip for cli_open_agent's description and `window` parameter (DESIGN-C
  S2). With main's cli_open_agent entry the digest is the previous pin.
- test_a_tilde_path_is_expanded_before_it_is_used: expanduser reads
  USERPROFILE on Windows, so the test sets it next to HOME.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@nt-nerdtechnic
nt-nerdtechnic marked this pull request as ready for review October 21, 2026 02:06
nt-nerdtechnic added a commit that referenced this pull request Oct 21, 2026
…28 C S1) (#534)

Squash-merged from origin/pr-534.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@nt-nerdtechnic

Copy link
Copy Markdown
Collaborator Author

Squash-merged into main as 5ca9fc8.

nt-nerdtechnic added a commit that referenced this pull request Oct 21, 2026
…ssions

- Merges main at #534 (5ca9fc8). CHANGELOG: both sides' entries kept.
- #534 and this branch each added app.main_window_sessions() (identical
  since ef110ff); git does not see two new definitions as a conflict, so
  the merged module defined it twice. This branch's copy is dropped and
  #534's is kept: app.py now equals main.
- ko-KR (new on main, #553) gets this branch's one key,
  msg.window-focused-by.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nt-nerdtechnic added a commit that referenced this pull request Oct 21, 2026
#534, #535, #541 and #546 landed as squashes, so their commits on this
branch and on main differ while their content is the same. Merged with -X
ours (conflicting hunks were that shared content, kept as this branch has it
with S4b on top), then checked against origin/main: the only differences are
S4b's own 21 files. Two hunks were redone by hand: CHANGELOG had lost five
of main's entries and is rebuilt from main's file plus the S4b entry; the
golden cli_open_agent entry is regenerated from the live server.

Co-Authored-By: Claude Opus 5.5 (1M context) <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