fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox restrictions (#24047)

This commit is contained in:
David Pierce
2026-03-31 22:06:50 +00:00
committed by GitHub
parent 9364dd8a49
commit 94f9480a3a
13 changed files with 555 additions and 97 deletions
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import fs from 'node:fs';
import path from 'node:path';
import * as shellUtils from '../../utils/shell-utils.js';
vi.mock('node:fs', async () => {
@@ -350,6 +351,61 @@ describe('LinuxSandboxManager', () => {
const binds = bwrapArgs.filter((a) => a === workspace);
expect(binds.length).toBe(2);
});
it('should bind the parent directory of a non-existent path', async () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/workspace/new-file.txt') return false;
return true;
});
const bwrapArgs = await getBwrapArgs({
command: '__write',
args: ['/home/user/workspace/new-file.txt'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/home/user/workspace/new-file.txt'],
},
});
const parentDir = '/home/user/workspace';
const bindIndex = bwrapArgs.lastIndexOf(parentDir);
expect(bindIndex).not.toBe(-1);
expect(bwrapArgs[bindIndex - 2]).toBe('--bind-try');
});
});
describe('virtual commands', () => {
it('should translate __read to cat', async () => {
const testFile = path.join(workspace, 'file.txt');
const bwrapArgs = await getBwrapArgs({
command: '__read',
args: [testFile],
cwd: workspace,
env: {},
});
// args are: [...bwrapBaseArgs, '--', '/bin/cat', '.../file.txt']
expect(bwrapArgs[bwrapArgs.length - 2]).toBe('/bin/cat');
expect(bwrapArgs[bwrapArgs.length - 1]).toBe(testFile);
});
it('should translate __write to sh -c cat', async () => {
const testFile = path.join(workspace, 'file.txt');
const bwrapArgs = await getBwrapArgs({
command: '__write',
args: [testFile],
cwd: workspace,
env: {},
});
// args are: [...bwrapBaseArgs, '--', '/bin/sh', '-c', 'tee -- "$@" > /dev/null', '_', '.../file.txt']
expect(bwrapArgs[bwrapArgs.length - 5]).toBe('/bin/sh');
expect(bwrapArgs[bwrapArgs.length - 4]).toBe('-c');
expect(bwrapArgs[bwrapArgs.length - 3]).toBe('tee -- "$@" > /dev/null');
expect(bwrapArgs[bwrapArgs.length - 2]).toBe('_');
expect(bwrapArgs[bwrapArgs.length - 1]).toBe(testFile);
});
});
describe('forbiddenPaths', () => {
@@ -182,9 +182,23 @@ export class LinuxSandboxManager implements SandboxManager {
verifySandboxOverrides(allowOverrides, req.policy);
const commandName = await getCommandName(req);
let command = req.command;
let args = req.args;
// Translate virtual commands for sandboxed file system access
if (command === '__read') {
command = 'cat';
} else if (command === '__write') {
command = 'sh';
args = ['-c', 'cat > "$1"', '_', ...args];
}
const commandName = await getCommandName({ ...req, command, args });
const isApproved = allowOverrides
? await isStrictlyApproved(req, this.options.modeConfig?.approvedTools)
? await isStrictlyApproved(
{ ...req, command, args },
this.options.modeConfig?.approvedTools,
)
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
@@ -280,11 +294,36 @@ export class LinuxSandboxManager implements SandboxManager {
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
}
const includeDirs = sanitizePaths(this.options.includeDirectories) || [];
for (const includeDir of includeDirs) {
try {
const resolved = tryRealpath(includeDir);
bwrapArgs.push('--ro-bind-try', resolved, resolved);
} catch {
// Ignore
}
}
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, '');
for (const allowedPath of allowedPaths) {
const resolved = tryRealpath(allowedPath);
if (!fs.existsSync(resolved)) continue;
if (!fs.existsSync(resolved)) {
// If the path doesn't exist, we still want to allow access to its parent
// if it's explicitly allowed, to enable creating it.
try {
const resolvedParent = tryRealpath(dirname(resolved));
bwrapArgs.push(
req.command === '__write' ? '--bind-try' : bindFlag,
resolvedParent,
resolvedParent,
);
} catch {
// Ignore
}
continue;
}
const normalizedAllowedPath = normalize(resolved).replace(/\/$/, '');
if (normalizedAllowedPath !== normalizedWorkspace) {
bwrapArgs.push('--bind-try', resolved, resolved);