Skip to content

Commit 94f9480

Browse files
authored
fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox restrictions (#24047)
1 parent 9364dd8 commit 94f9480

13 files changed

Lines changed: 543 additions & 85 deletions

evals/plan_mode.eval.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,47 @@ describe('plan_mode', () => {
283283
assertModelHasOutput(result);
284284
},
285285
});
286+
287+
evalTest('ALWAYS_PASSES', {
288+
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
289+
params: {
290+
settings,
291+
},
292+
prompt:
293+
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
294+
assert: async (rig, result) => {
295+
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
296+
expect(
297+
enterPlanCalled,
298+
'Expected enter_plan_mode tool to be called',
299+
).toBe(true);
300+
301+
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
302+
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
303+
true,
304+
);
305+
306+
await rig.waitForTelemetryReady();
307+
const toolLogs = rig.readToolLogs();
308+
309+
// Check if the plan file was written successfully
310+
const planWrite = toolLogs.find(
311+
(log) =>
312+
log.toolRequest.name === 'write_file' &&
313+
log.toolRequest.args.includes('foo-plan.md'),
314+
);
315+
316+
expect(
317+
planWrite,
318+
'Expected write_file to be called for foo-plan.md',
319+
).toBeDefined();
320+
321+
expect(
322+
planWrite?.toolRequest.success,
323+
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
324+
).toBe(true);
325+
326+
assertModelHasOutput(result);
327+
},
328+
});
286329
});

packages/core/src/config/config.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -979,39 +979,33 @@ export class Config implements McpContext, AgentLoopContext {
979979
networkAccess: false,
980980
};
981981

982-
this._sandboxManager = createSandboxManager(this.sandbox, {
983-
workspace: params.targetDir,
984-
});
985-
986-
if (
987-
!(this._sandboxManager instanceof NoopSandboxManager) &&
988-
this.sandbox.enabled
989-
) {
990-
this.fileSystemService = new SandboxedFileSystemService(
991-
this._sandboxManager,
992-
params.targetDir,
993-
);
994-
} else {
995-
this.fileSystemService = new StandardFileSystemService();
996-
}
982+
this.targetDir = path.resolve(params.targetDir);
983+
this.folderTrust = params.folderTrust ?? false;
984+
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
985+
this.pendingIncludeDirectories = params.includeDirectories ?? [];
986+
this.debugMode = params.debugMode;
987+
this.question = params.question;
988+
this.worktreeSettings = params.worktreeSettings;
997989

998990
this._sandboxPolicyManager = new SandboxPolicyManager();
999991
const initialApprovalMode =
1000992
params.approvalMode ??
1001993
params.policyEngineConfig?.approvalMode ??
1002994
'default';
995+
1003996
this._sandboxManager = createSandboxManager(
1004997
this.sandbox,
1005998
{
1006-
workspace: params.targetDir,
999+
workspace: this.targetDir,
1000+
includeDirectories: this.pendingIncludeDirectories,
10071001
policyManager: this._sandboxPolicyManager,
10081002
},
10091003
initialApprovalMode,
10101004
);
10111005

10121006
if (
10131007
!(this._sandboxManager instanceof NoopSandboxManager) &&
1014-
this.sandbox?.enabled
1008+
this.sandbox.enabled
10151009
) {
10161010
this.fileSystemService = new SandboxedFileSystemService(
10171011
this._sandboxManager,
@@ -1021,10 +1015,6 @@ export class Config implements McpContext, AgentLoopContext {
10211015
this.fileSystemService = new StandardFileSystemService();
10221016
}
10231017

1024-
this.targetDir = path.resolve(params.targetDir);
1025-
this.folderTrust = params.folderTrust ?? false;
1026-
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
1027-
this.pendingIncludeDirectories = params.includeDirectories ?? [];
10281018
this.debugMode = params.debugMode;
10291019
this.question = params.question;
10301020
this.worktreeSettings = params.worktreeSettings;

packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
99
import type { SandboxRequest } from '../../services/sandboxManager.js';
1010
import fs from 'node:fs';
11+
import path from 'node:path';
1112
import * as shellUtils from '../../utils/shell-utils.js';
1213

1314
vi.mock('node:fs', async () => {
@@ -350,6 +351,61 @@ describe('LinuxSandboxManager', () => {
350351
const binds = bwrapArgs.filter((a) => a === workspace);
351352
expect(binds.length).toBe(2);
352353
});
354+
355+
it('should bind the parent directory of a non-existent path', async () => {
356+
vi.mocked(fs.existsSync).mockImplementation((p) => {
357+
if (p === '/home/user/workspace/new-file.txt') return false;
358+
return true;
359+
});
360+
361+
const bwrapArgs = await getBwrapArgs({
362+
command: '__write',
363+
args: ['/home/user/workspace/new-file.txt'],
364+
cwd: workspace,
365+
env: {},
366+
policy: {
367+
allowedPaths: ['/home/user/workspace/new-file.txt'],
368+
},
369+
});
370+
371+
const parentDir = '/home/user/workspace';
372+
const bindIndex = bwrapArgs.lastIndexOf(parentDir);
373+
expect(bindIndex).not.toBe(-1);
374+
expect(bwrapArgs[bindIndex - 2]).toBe('--bind-try');
375+
});
376+
});
377+
378+
describe('virtual commands', () => {
379+
it('should translate __read to cat', async () => {
380+
const testFile = path.join(workspace, 'file.txt');
381+
const bwrapArgs = await getBwrapArgs({
382+
command: '__read',
383+
args: [testFile],
384+
cwd: workspace,
385+
env: {},
386+
});
387+
388+
// args are: [...bwrapBaseArgs, '--', '/bin/cat', '.../file.txt']
389+
expect(bwrapArgs[bwrapArgs.length - 2]).toBe('/bin/cat');
390+
expect(bwrapArgs[bwrapArgs.length - 1]).toBe(testFile);
391+
});
392+
393+
it('should translate __write to sh -c cat', async () => {
394+
const testFile = path.join(workspace, 'file.txt');
395+
const bwrapArgs = await getBwrapArgs({
396+
command: '__write',
397+
args: [testFile],
398+
cwd: workspace,
399+
env: {},
400+
});
401+
402+
// args are: [...bwrapBaseArgs, '--', '/bin/sh', '-c', 'tee -- "$@" > /dev/null', '_', '.../file.txt']
403+
expect(bwrapArgs[bwrapArgs.length - 5]).toBe('/bin/sh');
404+
expect(bwrapArgs[bwrapArgs.length - 4]).toBe('-c');
405+
expect(bwrapArgs[bwrapArgs.length - 3]).toBe('tee -- "$@" > /dev/null');
406+
expect(bwrapArgs[bwrapArgs.length - 2]).toBe('_');
407+
expect(bwrapArgs[bwrapArgs.length - 1]).toBe(testFile);
408+
});
353409
});
354410

355411
describe('forbiddenPaths', () => {

packages/core/src/sandbox/linux/LinuxSandboxManager.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,23 @@ export class LinuxSandboxManager implements SandboxManager {
182182

183183
verifySandboxOverrides(allowOverrides, req.policy);
184184

185-
const commandName = await getCommandName(req);
185+
let command = req.command;
186+
let args = req.args;
187+
188+
// Translate virtual commands for sandboxed file system access
189+
if (command === '__read') {
190+
command = 'cat';
191+
} else if (command === '__write') {
192+
command = 'sh';
193+
args = ['-c', 'cat > "$1"', '_', ...args];
194+
}
195+
196+
const commandName = await getCommandName({ ...req, command, args });
186197
const isApproved = allowOverrides
187-
? await isStrictlyApproved(req, this.options.modeConfig?.approvedTools)
198+
? await isStrictlyApproved(
199+
{ ...req, command, args },
200+
this.options.modeConfig?.approvedTools,
201+
)
188202
: false;
189203
const workspaceWrite = !isReadonlyMode || isApproved;
190204
const networkAccess =
@@ -280,11 +294,36 @@ export class LinuxSandboxManager implements SandboxManager {
280294
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
281295
}
282296

297+
const includeDirs = sanitizePaths(this.options.includeDirectories) || [];
298+
for (const includeDir of includeDirs) {
299+
try {
300+
const resolved = tryRealpath(includeDir);
301+
bwrapArgs.push('--ro-bind-try', resolved, resolved);
302+
} catch {
303+
// Ignore
304+
}
305+
}
306+
283307
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
308+
284309
const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, '');
285310
for (const allowedPath of allowedPaths) {
286311
const resolved = tryRealpath(allowedPath);
287-
if (!fs.existsSync(resolved)) continue;
312+
if (!fs.existsSync(resolved)) {
313+
// If the path doesn't exist, we still want to allow access to its parent
314+
// if it's explicitly allowed, to enable creating it.
315+
try {
316+
const resolvedParent = tryRealpath(dirname(resolved));
317+
bwrapArgs.push(
318+
req.command === '__write' ? '--bind-try' : bindFlag,
319+
resolvedParent,
320+
resolvedParent,
321+
);
322+
} catch {
323+
// Ignore
324+
}
325+
continue;
326+
}
288327
const normalizedAllowedPath = normalize(resolved).replace(/\/$/, '');
289328
if (normalizedAllowedPath !== normalizedWorkspace) {
290329
bwrapArgs.push('--bind-try', resolved, resolved);

0 commit comments

Comments
 (0)