Repository navigation
fix(lsp): re-check physical containment after server startup, right before didOpen (#1268) - #1412
Conversation
…efore didOpen (#1268) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocument directory, allow case-only renames (#1412 review a) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olves to itself (#1268, #1412 review b) A pathless document's check returned early when the virtual directory was missing, without checking the root; a root renamed away and replaced by a symlink out then let didOpen name root/.agent-code-lsp/... outside. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
OWNER-APPROVED: (B6 proxy) narrowing accepted. #1412 narrows the LSP open window (authorization, a root-identity check, and a physical re-check before every didOpen) and is Refs #1268, not Fixes: a path-based LSP server re-resolves paths itself, so a local directory swap after the last check (or during server startup) cannot be closed from main. #1268 stays open for an isolation design. |
…1268, #1412 review c) A pathless document's check validated .agent-code-lsp/ but not the virtual-<hash>.<ext> leaf; a leaf symlink created in advance resolved outside the root with no timing window. The leaf must be absent or a regular contained file; the name comes from the manager's own builder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review disposition (head 8c2aaf0). Refs #1268, OWNER-APPROVED narrowing
Accepted limit: LSP is path-based, and the server re-resolves paths itself. A local directory swap after the last check, or during server startup, cannot be closed from main. #1268 stays open for an isolation design. Residual: the callback's IPC wiring has no committed test (feasible with the existing Electron mock; left out under the freeze). Gate at 8c2aaf0: |
The #1268 root-identity re-check (realpath(root) === root) refused every IPC open in lspDocumentOrdering.test.ts, whose fake '/repo' root does not exist. Production stays strict; the tests now use a realpath'd mkdtemp root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI failure at 8c2aaf0, cause and fix (0659b36): 9 tests in 🤖 Generated with Claude Code |
de52c5a
into
integration/batch-2026-09-27-r
Refs #1268. OWNER-APPROVED (B6 proxy, 2026-09-27): narrowing accepted. This PR NARROWS the LSP open window; it does not close #1268 (see the limit below).
Plan:
docs/plans/2026-09-27-lsp-open-containment.md.Problem
authorizeContext(src/main/ipc/lsp.ts) validated the physical target and returned the checked relative path.LspManager.openDocumentNowthen awaited server startup, which can take seconds on a cold spawn. Only after that did it build a lexicalfile://URI and senddidOpen. If a directory on the path was swapped for a symlink to an outside directory in that window, the server was handed a URI whose realpath is outside the root. A fresh open at that moment was correctly refused, so only the in-flight one escaped.What merges
OpenDocumentParams.assertPhysicalTarget: a callback from the authorizing caller. The manager awaits it inside the per-document queue, after server startup and immediately before everydidOpen, including shared joins (review a). A refusal fails open like every other LSP failure (returns false: no LSP for that document) and names nothing to the server.lspPhysicalTargetAssertion(context)(src/main/ipc/lsp.ts), passed by both open paths (lsp:open-document,lsp:reopen-document):resolveInsideRoot+validateExistingTarget(no symlink, canonical inside the root), a regular file;.agent-code-lspdirectory is not a symlink out, AND that thevirtual-<hash>.<ext>leafdidOpennames is absent or a regular file inside the root (review c: a leaf symlink created in advance escaped with no timing window). The name comes from the manager's ownlspVirtualDocumentName. A pathless open without it is refused;The limit (why
Refs, notFixes)LSP is path-based: the language server re-resolves every path itself, later, and the server starts in the root path (
cwd,rootUri). A local actor who can rename directories inside the project can still swap a path between the last check here and the server's own read, or during server startup (review b, findings 1 and 3). No check in main can close that. Closing it needs isolation (descriptor passing, which LSP has no concept of, or a sandboxed server), which is a different design. This PR narrows the window: authorization, a re-check before everydidOpen, and a root-identity check.Tests
src/main/ipc/lspPhysicalTarget.test.ts, real filesystem, LSP open: physical containment is checked before server startup, not when the URI is used (TOCTOU) #1268's probe:src/a.ts, then swapsrcfor a symlink to an outside dir → refused;lspManager.test.ts:initializedand beforedidOpen;lspDocumentOrdering.test.ts: its IPC tests now open under a real, canonical temp root. They used a made-up/repo, which the new root-identity check (realpath(root) === root) correctly refuses, so CI failed 9 of them at8c2aaf07. Production stays strict.Residuals:
Gates:
npx tsc -b+src/main/ipc+src/main/lspManager.test.ts+src/main/lspDocumentOrdering.test.ts+src/main/lspServerGeneration.test.ts+src/main/lspServerCreation.system.test.tspass at0659b362(19 files, 176 tests).🤖 Generated with Claude Code