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
@@ -17,24 +17,29 @@ describe('MacOsSandboxManager', () => {
const mockNetworkAccess = true;
let mockPolicy: ExecutionPolicy;
let manager: MacOsSandboxManager | undefined;
let manager: MacOsSandboxManager;
beforeEach(() => {
mockWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-macos-test-'),
mockWorkspace = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-macos-test-')),
);
mockAllowedPaths = [
path.join(os.tmpdir(), 'gemini-cli-macos-test-allowed'),
];
if (!fs.existsSync(mockAllowedPaths[0])) {
fs.mkdirSync(mockAllowedPaths[0]);
const allowedPathTemp = path.join(
os.tmpdir(),
'gemini-cli-macos-test-allowed-' + Math.random().toString(36).slice(2),
);
if (!fs.existsSync(allowedPathTemp)) {
fs.mkdirSync(allowedPathTemp);
}
mockAllowedPaths = [fs.realpathSync(allowedPathTemp)];
mockPolicy = {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
};
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
// Mock the seatbelt args builder to isolate manager tests
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltProfile').mockReturnValue(
'(mock profile)',
@@ -51,7 +56,6 @@ describe('MacOsSandboxManager', () => {
describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
@@ -64,8 +68,8 @@ describe('MacOsSandboxManager', () => {
workspace: mockWorkspace,
allowedPaths: mockAllowedPaths,
forbiddenPaths: undefined,
networkAccess: true,
workspaceWrite: true,
networkAccess: mockNetworkAccess,
workspaceWrite: false,
additionalPermissions: {
fileSystem: {
read: [],
@@ -92,7 +96,6 @@ describe('MacOsSandboxManager', () => {
});
it('should correctly pass through the cwd to the resulting command', async () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
@@ -105,7 +108,6 @@ describe('MacOsSandboxManager', () => {
});
it('should apply environment sanitization via the default mechanisms', async () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
@@ -125,7 +127,6 @@ describe('MacOsSandboxManager', () => {
});
it('should allow network when networkAccess is true', async () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
await manager.prepareCommand({
command: 'echo',
args: ['hello'],
@@ -139,9 +140,43 @@ describe('MacOsSandboxManager', () => {
);
});
describe('virtual commands', () => {
it('should translate __read to /bin/cat', async () => {
const testFile = path.join(mockWorkspace, 'file.txt');
const result = await manager.prepareCommand({
command: '__read',
args: [testFile],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
expect(result.args[result.args.length - 2]).toBe('/bin/cat');
expect(result.args[result.args.length - 1]).toBe(testFile);
});
it('should translate __write to /bin/sh -c tee ...', async () => {
const testFile = path.join(mockWorkspace, 'file.txt');
const result = await manager.prepareCommand({
command: '__write',
args: [testFile],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
expect(result.args[result.args.length - 5]).toBe('/bin/sh');
expect(result.args[result.args.length - 4]).toBe('-c');
expect(result.args[result.args.length - 3]).toBe(
'tee -- "$@" > /dev/null',
);
expect(result.args[result.args.length - 2]).toBe('_');
expect(result.args[result.args.length - 1]).toBe(testFile);
});
});
describe('governance files', () => {
it('should ensure governance files exist', async () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
await manager.prepareCommand({
command: 'echo',
args: [],
@@ -160,7 +195,6 @@ describe('MacOsSandboxManager', () => {
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
await manager.prepareCommand({
command: 'echo',
args: [],
@@ -182,11 +216,11 @@ describe('MacOsSandboxManager', () => {
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
manager = new MacOsSandboxManager({
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/forbidden1'],
});
await manager.prepareCommand({
await customManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
@@ -202,11 +236,11 @@ describe('MacOsSandboxManager', () => {
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
manager = new MacOsSandboxManager({
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/does-not-exist'],
});
await manager.prepareCommand({
await customManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
@@ -222,11 +256,11 @@ describe('MacOsSandboxManager', () => {
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
manager = new MacOsSandboxManager({
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/conflict'],
});
await manager.prepareCommand({
await customManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
@@ -21,16 +21,16 @@ import {
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
import {
initializeShellParsers,
getCommandName,
} from '../../utils/shell-utils.js';
import { initializeShellParsers } from '../../utils/shell-utils.js';
import {
isKnownSafeCommand,
isDangerousCommand,
isStrictlyApproved,
} from '../utils/commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import {
verifySandboxOverrides,
getCommandName as getFullCommandName,
isStrictlyApproved,
} from '../utils/commandUtils.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
@@ -68,11 +68,23 @@ export class MacOsSandboxManager implements SandboxManager {
// Reject override attempts in plan mode
verifySandboxOverrides(allowOverrides, req.policy);
let command = req.command;
let args = req.args;
// Translate virtual commands for sandboxed file system access
if (command === '__read') {
command = '/bin/cat';
} else if (command === '__write') {
command = '/bin/sh';
args = ['-c', 'cat > "$1"', '_', ...args];
}
const currentReq = { ...req, command, args };
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? await isStrictlyApproved(
req.command,
req.args,
currentReq,
this.options.modeConfig?.approvedTools,
)
: false;
@@ -82,7 +94,7 @@ export class MacOsSandboxManager implements SandboxManager {
this.options.modeConfig?.network || req.policy?.networkAccess || false;
// Fetch persistent approvals for this command
const commandName = await getCommandName(req.command, req.args);
const commandName = await getFullCommandName(currentReq);
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
@@ -115,7 +127,10 @@ export class MacOsSandboxManager implements SandboxManager {
const sandboxArgs = buildSeatbeltProfile({
workspace: this.options.workspace,
allowedPaths: [...(req.policy?.allowedPaths || [])],
allowedPaths: [
...(req.policy?.allowedPaths || []),
...(this.options.includeDirectories || []),
],
forbiddenPaths: this.options.forbiddenPaths,
networkAccess: mergedAdditional.network,
workspaceWrite,