Skip to content

Commit 316abe2

Browse files
fix(safe-write-json,mcp-hub): publish json through the guarded writer and confine the hub reads
Unit 2 of the split plan for pull 1913, stacked on unit 1 and recorded on the unit tracking issue that carries the plan. It ports the JSON publish layer and the MCP call sites unchanged from the pull head acc49b9, with their specs, and re-adds the export keyword on resolvePublishTarget and canonicalDirKey together with the consumers that need them: safeWriteJson imports canonicalDirKey and calls resolvePublishTarget when it resolves and re-checks its lock key, and the three McpHub call sites pass the confine argument. Unit 1 declares those two names without export because nothing in unit 1 imports them, so the tree at this commit is byte-for-byte the tree of the pull being split.
1 parent bb89f14 commit 316abe2

7 files changed

Lines changed: 1685 additions & 32 deletions

File tree

‎src/services/file-safety/safeWriteText.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ async function _restoreDaclWindows(
192192
* the resolved path: the commit is a rename onto the referent, and a rename
193193
* across filesystems fails with EXDEV.
194194
*/
195-
async function resolvePublishTarget(absoluteFilePath: string): Promise<string> {
195+
export async function resolvePublishTarget(absoluteFilePath: string): Promise<string> {
196196
return fs.realpath(absoluteFilePath).catch(async (error: unknown) => {
197197
if (getErrorCode(error) !== "ENOENT") throw error
198198
// ENOENT also covers a dangling symlink, which must never be written through.
@@ -217,7 +217,7 @@ async function resolvePublishTarget(absoluteFilePath: string): Promise<string> {
217217
* the file exists when the key is computed, and two writers take two locks.
218218
*/
219219

220-
async function canonicalDirKey(absoluteFilePath: string): Promise<string> {
220+
export async function canonicalDirKey(absoluteFilePath: string): Promise<string> {
221221
const dirPath = path.dirname(absoluteFilePath)
222222
// Walk up to the nearest ancestor that EXISTS, canonicalize that, and re-join the
223223
// components that are not there yet. Falling back to the unresolved spelling of the

‎src/services/mcp/McpHub.ts‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,9 @@ export class McpHub {
630630
}
631631

632632
// Get project-level MCP configuration path
633-
private async getProjectMcpPath(): Promise<string | null> {
634-
const workspacePath = this.providerRef.deref()?.cwd ?? getWorkspacePath()
633+
private async getProjectMcpPath(
634+
workspacePath: string | undefined = this.providerRef.deref()?.cwd ?? getWorkspacePath(),
635+
): Promise<string | null> {
635636
const projectMcpDir = path.join(workspacePath, ".roo")
636637
const projectMcpPath = path.join(projectMcpDir, "mcp.json")
637638

@@ -643,6 +644,41 @@ export class McpHub {
643644
}
644645
}
645646

647+
/**
648+
* The root a project-scoped MCP write has to stay inside.
649+
*
650+
* safeWriteJson resolves the publish target with realpath before staging beside it, so a
651+
* repository that ships .roo/mcp.json as a symlink to a file OUTSIDE the workspace would
652+
* have that outside file replaced as soon as the user edits a project MCP setting or
653+
* allowlist. Passing the workspace root as confineTo makes that write fail closed instead.
654+
* Global writes are deliberately unconstrained: they target the user's own settings
655+
* directory, which is not under the workspace.
656+
*
657+
* DECISION AND LIMIT - these three call sites carry credentials (MCP server env and
658+
* headers), so the limit of the control they pass is stated here rather than left to the
659+
* option doc: confineTo decides twice from the resolved publish target and stops at its
660+
* final component. If a workspace replaces an ANCESTOR directory - .roo, or the workspace
661+
* directory itself - with a link after the last check, the credential write follows that
662+
* link and can still land outside the workspace; see the confineTo doc in
663+
* src/utils/safeWriteJson.ts for the two outcomes of that window. The control is kept
664+
* anyway, not dropped, because the case a committed tree can actually plant is a link at
665+
* the settings file itself, and that is the case this option does refuse. Closing the
666+
* ancestor window needs a handle-relative no-follow publish, which this runtime does not
667+
* offer, and the alternative reading - removing the control from the credential path -
668+
* would give up the refusal that works today to close a window no unit can close here.
669+
*/
670+
private confineForMcpWrite(
671+
source: "global" | "project",
672+
// The root chosen by the same write: reading it twice lets a workspace refresh between the
673+
// path lookup and this check reject a valid project write with a confinement error.
674+
workspacePath: string | undefined = this.providerRef.deref()?.cwd ?? getWorkspacePath(),
675+
): string | undefined {
676+
if (source !== "project") {
677+
return undefined
678+
}
679+
return workspacePath
680+
}
681+
646682
// Initialize project-level MCP servers
647683
private async initializeProjectMcpServers(): Promise<void> {
648684
await this.initializeMcpServers("project")
@@ -2038,10 +2074,12 @@ export class McpHub {
20382074
configUpdate: Record<string, any>,
20392075
source: "global" | "project" = "global",
20402076
): Promise<void> {
2041-
// Determine which config file to update
2077+
// Determine which config file to update. The write root is captured once and reused by
2078+
// the confinement check below, so a workspace refresh cannot split the two reads.
20422079
let configPath: string
2080+
const writeRoot = source === "project" ? (this.providerRef.deref()?.cwd ?? getWorkspacePath()) : undefined
20432081
if (source === "project") {
2044-
const projectMcpPath = await this.getProjectMcpPath()
2082+
const projectMcpPath = await this.getProjectMcpPath(writeRoot)
20452083
if (!projectMcpPath) {
20462084
throw new Error("Project MCP configuration file not found")
20472085
}
@@ -2099,7 +2137,10 @@ export class McpHub {
20992137
}
21002138
this.isProgrammaticUpdate = true
21012139
try {
2102-
await safeWriteJson(configPath, updatedConfig, { prettyPrint: true })
2140+
await safeWriteJson(configPath, updatedConfig, {
2141+
prettyPrint: true,
2142+
confineTo: this.confineForMcpWrite(source, writeRoot),
2143+
})
21032144
} finally {
21042145
// Reset flag after watcher debounce period (non-blocking)
21052146
this.flagResetTimer = setTimeout(() => {
@@ -2143,10 +2184,12 @@ export class McpHub {
21432184
// Determine config file based on server source
21442185
const isProjectServer = serverSource === "project"
21452186
let configPath: string
2187+
const writeRoot =
2188+
serverSource === "project" ? (this.providerRef.deref()?.cwd ?? getWorkspacePath()) : undefined
21462189

21472190
if (isProjectServer) {
21482191
// Get project MCP config path
2149-
const projectMcpPath = await this.getProjectMcpPath()
2192+
const projectMcpPath = await this.getProjectMcpPath(writeRoot)
21502193
if (!projectMcpPath) {
21512194
throw new Error("Project MCP configuration file not found")
21522195
}
@@ -2184,7 +2227,10 @@ export class McpHub {
21842227
mcpServers: config.mcpServers,
21852228
}
21862229

2187-
await safeWriteJson(configPath, updatedConfig, { prettyPrint: true })
2230+
await safeWriteJson(configPath, updatedConfig, {
2231+
prettyPrint: true,
2232+
confineTo: this.confineForMcpWrite(serverSource, writeRoot),
2233+
})
21882234

21892235
// Update server connections with the correct source
21902236
await this.updateServerConnections(config.mcpServers, serverSource)
@@ -2342,9 +2388,10 @@ export class McpHub {
23422388

23432389
// Determine the correct config path based on the source
23442390
let configPath: string
2391+
const writeRoot = source === "project" ? (this.providerRef.deref()?.cwd ?? getWorkspacePath()) : undefined
23452392
if (source === "project") {
23462393
// Get project MCP config path
2347-
const projectMcpPath = await this.getProjectMcpPath()
2394+
const projectMcpPath = await this.getProjectMcpPath(writeRoot)
23482395
if (!projectMcpPath) {
23492396
throw new Error("Project MCP configuration file not found")
23502397
}
@@ -2393,7 +2440,10 @@ export class McpHub {
23932440
}
23942441
this.isProgrammaticUpdate = true
23952442
try {
2396-
await safeWriteJson(normalizedPath, config, { prettyPrint: true })
2443+
await safeWriteJson(normalizedPath, config, {
2444+
prettyPrint: true,
2445+
confineTo: this.confineForMcpWrite(source, writeRoot),
2446+
})
23972447
} finally {
23982448
// Reset flag after watcher debounce period (non-blocking)
23992449
this.flagResetTimer = setTimeout(() => {

‎src/services/mcp/__tests__/McpHub.spec.ts‎

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import * as path from "path"
33

44
import type { Mock } from "vitest"
55
import type { ExtensionContext, Uri } from "vscode"
6-
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
6+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
7+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
78
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
89

910
import type { ClineProvider } from "../../../core/webview/ClineProvider"
@@ -35,6 +36,7 @@ vi.mock("fs/promises", () => {
3536
})
3637

3738
// Import safeWriteJson to use in mocks
39+
import { safeWriteJson } from "../../../utils/safeWriteJson"
3840

3941
// Mock safeWriteJson
4042
vi.mock("../../../utils/safeWriteJson", () => ({
@@ -154,10 +156,35 @@ vi.mock("chokidar", () => ({
154156
},
155157
}))
156158

159+
// Only getWorkspacePath is replaced; the original module is loaded and spread so its side effect
160+
// (installing String.prototype.toPosix) and its other exports keep working for this file. The fallback
161+
// is what the fallback test inside the confinement describe below exercises, so it must be distinguishable
162+
// from any workspace path a test could otherwise compute.
163+
vi.mock("../../../utils/path", async (importOriginal) => {
164+
const actual = await importOriginal<typeof import("../../../utils/path")>()
165+
return { ...actual, getWorkspacePath: vi.fn(() => "/fallback/workspace") }
166+
})
167+
157168
describe("McpHub", () => {
158169
let mcpHub: McpHubType
159170
let mockProvider: Partial<ClineProvider>
160171

172+
// A fully typed connection double: the SDK constructors are mocked at the top of the
173+
// file, so no cast is needed to build a connected connection here. Shared by the
174+
// write-confinement tests for every project-capable entry point.
175+
const connectionFor = (source: "global" | "project"): ConnectedMcpConnection => ({
176+
type: "connected",
177+
server: {
178+
name: "test-server",
179+
config: JSON.stringify({ type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] }),
180+
status: "connected",
181+
source,
182+
errorHistory: [],
183+
},
184+
client: new Client({ name: "test-client", version: "1.0.0" }),
185+
transport: new StdioClientTransport({ command: "node", args: ["test.js"] }),
186+
})
187+
161188
// Store original console methods
162189
const originalConsoleError = console.error
163190
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
@@ -1205,7 +1232,148 @@ describe("McpHub", () => {
12051232
})
12061233
})
12071234

1235+
describe("project-scoped write confinement at every call site", () => {
1236+
// safeWriteJson resolves the publish target with realpath before staging beside it, so
1237+
// a project .roo/mcp.json that links outside the workspace would be written THROUGH the
1238+
// link unless the caller declares the workspace as the confinement root. Every
1239+
// project-capable entry point has to pass it. A lower-level safeWriteJson test cannot
1240+
// see a missing MCP argument (the writer is mocked here), so each call site is pinned.
1241+
const projectConfigJson = JSON.stringify({
1242+
mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 } },
1243+
})
1244+
1245+
it("confines a project-scoped timeout write to the workspace root", async () => {
1246+
Object.defineProperty(mockProvider, "cwd", { value: "/mock/workspace", configurable: true })
1247+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1248+
mcpHub.connections = [connectionFor("project")]
1249+
1250+
await mcpHub.updateServerTimeout("test-server", 120)
1251+
1252+
const write = vi.mocked(safeWriteJson).mock.calls.find((call) => String(call[0]).includes(".roo"))
1253+
expect(write).toBeDefined()
1254+
expect(write![2]).toEqual(expect.objectContaining({ confineTo: "/mock/workspace" }))
1255+
})
1256+
1257+
it("leaves a global-scoped timeout write unconstrained", async () => {
1258+
Object.defineProperty(mockProvider, "cwd", { value: "/mock/workspace", configurable: true })
1259+
// The global path creates the default settings file when it is missing, and that
1260+
// write's merge callback reads the file; updateServerConfig then reads it too.
1261+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1262+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1263+
mcpHub.connections = [connectionFor("global")]
1264+
1265+
await mcpHub.updateServerTimeout("test-server", 120)
1266+
1267+
const writes = vi
1268+
.mocked(safeWriteJson)
1269+
.mock.calls.filter((call) => String(call[0]).includes("mcp_settings"))
1270+
expect(writes.length).toBeGreaterThan(0)
1271+
for (const write of writes) {
1272+
expect(write[2]?.confineTo).toBeUndefined()
1273+
}
1274+
})
1275+
1276+
it("confines a project-scoped server deletion to the workspace root", async () => {
1277+
Object.defineProperty(mockProvider, "cwd", { value: "/mock/workspace", configurable: true })
1278+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1279+
mcpHub.connections = [connectionFor("project")]
1280+
1281+
await mcpHub.deleteServer("test-server", "project")
1282+
1283+
const write = vi.mocked(safeWriteJson).mock.calls.find((call) => String(call[0]).includes(".roo"))
1284+
expect(write).toBeDefined()
1285+
expect(write![2]).toEqual(expect.objectContaining({ confineTo: "/mock/workspace" }))
1286+
})
1287+
1288+
it("leaves a global-scoped server deletion unconstrained", async () => {
1289+
Object.defineProperty(mockProvider, "cwd", { value: "/mock/workspace", configurable: true })
1290+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1291+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1292+
mcpHub.connections = [connectionFor("global")]
1293+
1294+
await mcpHub.deleteServer("test-server", "global")
1295+
1296+
const writes = vi
1297+
.mocked(safeWriteJson)
1298+
.mock.calls.filter((call) => String(call[0]).includes("mcp_settings"))
1299+
expect(writes.length).toBeGreaterThan(0)
1300+
for (const write of writes) {
1301+
expect(write[2]?.confineTo).toBeUndefined()
1302+
}
1303+
})
1304+
it("uses the fallback workspace path for both the settings path and the confinement root when the provider carries no cwd", async () => {
1305+
// The provider is reachable but has no cwd, which is the state the `?? getWorkspacePath()` fallback at
1306+
// src/services/mcp/McpHub.ts:2080 exists for. The row pins that ONE root serves both purposes: the
1307+
// project settings path is built from the fallback and the confinement root handed to safeWriteJson is
1308+
// that same fallback, so a project write taken without a provider cwd is confined to the very directory
1309+
// it writes into rather than going unconstrained or being confined to a different directory. cwd is a
1310+
// getter on ClineProvider, so the test installs the absent value.
1311+
Object.defineProperty(mockProvider, "cwd", { value: undefined, configurable: true })
1312+
vi.mocked(fs.readFile).mockResolvedValueOnce(projectConfigJson)
1313+
mcpHub.connections = [connectionFor("project")]
1314+
1315+
await mcpHub.updateServerTimeout("test-server", 120)
1316+
1317+
const write = vi.mocked(safeWriteJson).mock.calls.find((call) => String(call[0]).includes(".roo"))
1318+
expect(write).toBeDefined()
1319+
expect(String(write![0]).split("\\").join("/")).toContain("/fallback/workspace/.roo/mcp.json")
1320+
expect(write![2]).toEqual(expect.objectContaining({ confineTo: "/fallback/workspace" }))
1321+
})
1322+
})
1323+
12081324
describe("toggleToolAlwaysAllow", () => {
1325+
const projectConnection = (source: "global" | "project" = "project"): ConnectedMcpConnection =>
1326+
connectionFor(source)
1327+
1328+
it("confines a project-scoped allowlist write to the workspace root", async () => {
1329+
// A repository can ship .roo/mcp.json as a symlink to a file outside the workspace.
1330+
// safeWriteJson resolves the publish target before staging, so without confinement an
1331+
// allowlist edit would replace that outside file. The workspace root has to be handed
1332+
// over as confineTo so the write fails closed instead.
1333+
// cwd is a read-only getter on ClineProvider, so the test installs the value.
1334+
Object.defineProperty(mockProvider, "cwd", { value: "/mock/workspace", configurable: true })
1335+
vi.mocked(fs.readFile).mockResolvedValueOnce(
1336+
JSON.stringify({
1337+
mcpServers: {
1338+
"test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] },
1339+
},
1340+
}),
1341+
)
1342+
mcpHub.connections = [projectConnection()]
1343+
1344+
await mcpHub.toggleToolAlwaysAllow("test-server", "project", "new-tool", true)
1345+
1346+
const write = vi.mocked(safeWriteJson).mock.calls.find((call) => String(call[0]).includes("mcp.json"))
1347+
expect(write).toBeDefined()
1348+
expect(write![2]).toEqual(expect.objectContaining({ confineTo: "/mock/workspace" }))
1349+
})
1350+
1351+
it("leaves a global-scoped allowlist write unconstrained", async () => {
1352+
// Global settings live in the user's own settings directory, which is not under the
1353+
// workspace; confining them would break every global edit.
1354+
Object.defineProperty(mockProvider, "cwd", { value: "/mock/workspace", configurable: true })
1355+
mcpHub.connections = [projectConnection("global")]
1356+
1357+
await mcpHub.toggleToolAlwaysAllow("test-server", "global", "another-tool", true)
1358+
1359+
// Name the exact call instead of picking any match: a find on the substring
1360+
// "mcp" returns the FIRST mcp-named write, which need not be the allowlist
1361+
// write - the default-creation write getMcpSettingsFilePath makes when the
1362+
// settings file is missing (McpHub.ts:513) also matches, and it never passes
1363+
// confineTo, so the assertion could judge that write and stay green while a
1364+
// confinement root on the real allowlist write went unnoticed. Check every
1365+
// write to the settings file, as the timeout and delete tests above do.
1366+
const writes = vi
1367+
.mocked(safeWriteJson)
1368+
.mock.calls.filter((call) => String(call[0]).endsWith("mcp_settings.json"))
1369+
expect(writes.length).toBeGreaterThan(0)
1370+
// undefined confineTo is the unconstrained case: safeWriteJson only checks the path
1371+
// when a confinement root is supplied.
1372+
for (const write of writes) {
1373+
expect(write[2]?.confineTo).toBeUndefined()
1374+
}
1375+
})
1376+
12091377
it("should add tool to always allow list when enabling", async () => {
12101378
const mockConfig = {
12111379
mcpServers: {

‎src/utils/__tests__/safeWriteJson.interruption.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,44 @@ const targetPath = path.resolve(process.env.INTERRUPTION_TARGET_PATH)
3232
const fsPromises = require("fs/promises")
3333
const actualRename = fsPromises.rename
3434
35+
// Identity, not spelling. The writer renames to the publish target it resolved through
36+
// symlinks, and node's async fs.realpath expands an NTFS 8.3 alias, so on a runner whose temp
37+
// path is reported as an alias (the normal windows-latest condition: the profile is RUNNER~1
38+
// there) the parent's authorized spelling and the writer's rename destination are different
39+
// strings for one file. Text equality here never fires, every case prints WRITE_COMPLETED, and
40+
// the injection silently stops being an injection. Decide what the destination IS: same name,
41+
// and the same directory by dev+ino. The basenames under test are unique inside the storage
42+
// directory, so the pair cannot land on another file.
43+
const targetBase = path.basename(targetPath)
44+
function sameTargetAsAuthorized(candidate) {
45+
if (path.basename(String(candidate)) !== targetBase) {
46+
return false
47+
}
48+
const resolved = path.resolve(String(candidate))
49+
if (resolved === targetPath) {
50+
return true
51+
}
52+
let candidateDir = null
53+
let authorizedDir = null
54+
try {
55+
candidateDir = fs.statSync(path.dirname(resolved), { bigint: true })
56+
} catch {
57+
return false
58+
}
59+
try {
60+
authorizedDir = fs.statSync(path.dirname(targetPath), { bigint: true })
61+
} catch {
62+
return false
63+
}
64+
return candidateDir.dev === authorizedDir.dev && candidateDir.ino === authorizedDir.ino
65+
}
66+
3567
// Fault injection: hard-kill before the replacement lands. Any rename
36-
// whose destination is the canonical path is the commit step, whatever
68+
// whose destination is the publish target is the commit step, whatever
3769
// replacement protocol the writer uses; exit before performing it.
3870
let commitAttempts = 0
3971
fsPromises.rename = async function interruptionRename(oldPath, newPath) {
40-
if (path.resolve(String(newPath)) === targetPath) {
72+
if (sameTargetAsAuthorized(newPath)) {
4173
commitAttempts++
4274
if (process.env.INTERRUPTION_MODE === "fallback" && commitAttempts === 1) {
4375
return Promise.reject(Object.assign(new Error("Injected EPERM"), { code: "EPERM" }))

0 commit comments

Comments
 (0)