diff --git a/docs/architecture/debugMCPServer.md b/docs/architecture/debugMCPServer.md index eaef45b..aeb3362 100644 --- a/docs/architecture/debugMCPServer.md +++ b/docs/architecture/debugMCPServer.md @@ -43,7 +43,10 @@ extension. To avoid debugging the wrong workspace when several windows are open: - **Every window** starts a loopback `ControlServer` (`src/controlServer.ts`) that runs debug operations against *its own* `DebuggingHandler`, and advertises its workspace folders (plus control port + token) in a shared file registry - (`src/utils/workspaceRegistry.ts`). + (`src/utils/workspaceRegistry.ts`). The registry lives under VS Code's per-user + extension storage rather than the system temporary directory. On POSIX systems its + directory and entries are restricted to the owning user (`0700` and `0600`), and + entries are replaced atomically so a partial credential file is never exposed. - **One window** wins the public MCP port and becomes the **router**. Its per-MCP-session handler is a `RoutingDebuggingHandler` (`src/routingDebuggingHandler.ts`) that resolves the target window from the request's `workingDirectory`/`fileFullPath` and forwards the diff --git a/package-lock.json b/package-lock.json index e4b30ad..e740608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "debugmcpextension", - "version": "2.3.6", + "version": "2.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "debugmcpextension", - "version": "2.3.6", + "version": "2.3.7", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index a15f38c..e460776 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "debugmcpextension", "displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More", "description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.", - "version": "2.3.6", + "version": "2.3.7", "publisher": "ozzafar", "author": { "name": "Oz Zafar", diff --git a/src/extension.ts b/src/extension.ts index a33c180..cfb1f3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. import * as vscode from 'vscode'; +import * as path from 'path'; import { randomUUID } from 'node:crypto'; import { DebugMCPServer } from './debugMCPServer'; import { DebuggingExecutor, ConfigurationManager, DebuggingHandler } from '.'; @@ -72,7 +73,8 @@ export async function activate(context: vscode.ExtensionContext) { controlServer = new ControlServer(localHandler, controlToken); const controlPort = await controlServer.start(); - registry = new WorkspaceRegistry(); + const registryDir = path.join(context.globalStorageUri.fsPath, 'window-registry'); + registry = new WorkspaceRegistry(process.pid, registryDir); const registerSelf = () => { registry?.register({ controlPort, diff --git a/src/test/workspaceRegistry.test.ts b/src/test/workspaceRegistry.test.ts index bd9179d..f955c74 100644 --- a/src/test/workspaceRegistry.test.ts +++ b/src/test/workspaceRegistry.test.ts @@ -52,6 +52,33 @@ suite('WorkspaceRegistry', () => { assert.strictEqual(entries[0].pid, process.pid); }); + test('register stores the token in an owner-only registry on POSIX', function () { + if (process.platform === 'win32') { + this.skip(); + } + + const reg = new WorkspaceRegistry(process.pid, dir); + reg.register({ controlPort: 1234, controlToken: 'secret', workspaceFolders: [], name: 'A' }); + + assert.strictEqual(fs.statSync(dir).mode & 0o777, 0o700); + assert.strictEqual(fs.statSync(path.join(dir, `window-${process.pid}.json`)).mode & 0o777, 0o600); + }); + + test('heartbeat repairs an overly permissive registry file on POSIX', function () { + if (process.platform === 'win32') { + this.skip(); + } + + const reg = new WorkspaceRegistry(process.pid, dir); + reg.register({ controlPort: 1234, controlToken: 'secret', workspaceFolders: [], name: 'A' }); + const file = path.join(dir, `window-${process.pid}.json`); + fs.chmodSync(file, 0o644); + + reg.heartbeat(); + + assert.strictEqual(fs.statSync(file).mode & 0o777, 0o600); + }); + test('unregister removes the entry', () => { const reg = new WorkspaceRegistry(process.pid, dir); reg.register({ controlPort: 1, controlToken: 't', workspaceFolders: [], name: 'A' }); diff --git a/src/utils/workspaceRegistry.ts b/src/utils/workspaceRegistry.ts index 14c8d5b..debf963 100644 --- a/src/utils/workspaceRegistry.ts +++ b/src/utils/workspaceRegistry.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; +import { randomUUID } from 'node:crypto'; import { logger } from './logger'; /** One VS Code window's advertisement in the shared registry. */ @@ -15,11 +15,10 @@ export interface WindowRegistration { updatedAt: number; } -/** Default directory holding one JSON file per live window. */ -const DEFAULT_REGISTRY_DIR = path.join(os.tmpdir(), 'debugmcp-registry'); - /** Entries not refreshed within this window are considered stale. */ const STALE_MS = 60_000; +const REGISTRY_DIR_MODE = 0o700; +const REGISTRY_FILE_MODE = 0o600; /** Best-effort pid liveness check (EPERM means the process exists). */ function isProcessAlive(pid: number): boolean { @@ -53,30 +52,33 @@ export class WorkspaceRegistry { private readonly registryDir: string; private readonly filePath: string; - constructor(private readonly pid: number = process.pid, registryDir: string = DEFAULT_REGISTRY_DIR) { + constructor(private readonly pid: number = process.pid, registryDir: string) { this.registryDir = registryDir; this.filePath = path.join(this.registryDir, `window-${this.pid}.json`); + this.ensureSecureDirectory(); } /** Write (or overwrite) this window's registration. */ public register(reg: Omit): void { try { - fs.mkdirSync(this.registryDir, { recursive: true }); const entry: WindowRegistration = { ...reg, pid: this.pid, updatedAt: Date.now() }; - fs.writeFileSync(this.filePath, JSON.stringify(entry), 'utf8'); + this.writeEntry(entry); } catch (error) { logger.error('Failed to write DebugMCP registry entry', error); + throw error; } } /** Refresh `updatedAt` so other windows don't prune this one. */ public heartbeat(): void { try { - const entry = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as WindowRegistration; + const entry = this.readEntry(this.filePath); entry.updatedAt = Date.now(); - fs.writeFileSync(this.filePath, JSON.stringify(entry), 'utf8'); - } catch { - // Entry missing — caller re-registers on change. + this.writeEntry(entry); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to refresh DebugMCP registry entry', error); + } } } @@ -87,6 +89,7 @@ export class WorkspaceRegistry { /** All live windows, pruning dead-pid and stale entries as a side effect. */ public list(): WindowRegistration[] { + this.ensureSecureDirectory(); let files: string[]; try { files = fs.readdirSync(this.registryDir); @@ -100,7 +103,7 @@ export class WorkspaceRegistry { } const full = path.join(this.registryDir, file); try { - const entry = JSON.parse(fs.readFileSync(full, 'utf8')) as WindowRegistration; + const entry = this.readEntry(full); const isStale = Date.now() - entry.updatedAt > STALE_MS; if (!isProcessAlive(entry.pid) || (isStale && entry.pid !== this.pid)) { this.tryUnlink(full); @@ -144,6 +147,60 @@ export class WorkspaceRegistry { return best; } + private ensureSecureDirectory(): void { + fs.mkdirSync(this.registryDir, { recursive: true, mode: REGISTRY_DIR_MODE }); + const stats = fs.lstatSync(this.registryDir); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`DebugMCP registry path is not a secure directory: ${this.registryDir}`); + } + this.assertOwnedByCurrentUser(stats, this.registryDir); + if (process.platform !== 'win32') { + fs.chmodSync(this.registryDir, REGISTRY_DIR_MODE); + } + } + + private readEntry(full: string): WindowRegistration { + const stats = fs.lstatSync(full); + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`DebugMCP registry entry is not a regular file: ${full}`); + } + this.assertOwnedByCurrentUser(stats, full); + if (process.platform !== 'win32') { + fs.chmodSync(full, REGISTRY_FILE_MODE); + } + return JSON.parse(fs.readFileSync(full, 'utf8')) as WindowRegistration; + } + + private writeEntry(entry: WindowRegistration): void { + this.ensureSecureDirectory(); + const temporaryPath = path.join( + this.registryDir, + `.${path.basename(this.filePath)}.${randomUUID()}.tmp` + ); + try { + fs.writeFileSync(temporaryPath, JSON.stringify(entry), { + encoding: 'utf8', + flag: 'wx', + mode: REGISTRY_FILE_MODE + }); + if (process.platform !== 'win32') { + fs.chmodSync(temporaryPath, REGISTRY_FILE_MODE); + } + fs.renameSync(temporaryPath, this.filePath); + if (process.platform !== 'win32') { + fs.chmodSync(this.filePath, REGISTRY_FILE_MODE); + } + } finally { + this.tryUnlink(temporaryPath); + } + } + + private assertOwnedByCurrentUser(stats: fs.Stats, full: string): void { + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) { + throw new Error(`DebugMCP registry path is not owned by the current user: ${full}`); + } + } + private tryUnlink(full: string): void { try { fs.unlinkSync(full);