feat(sandbox): dynamic Linux sandbox expansion and worktree support (#23692)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-03-25 18:58:45 -07:00
committed by GitHub
parent 2e03e3aed5
commit 30e0ab102a
13 changed files with 604 additions and 415 deletions

View File

@@ -6,7 +6,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import fs from 'node:fs';
@@ -18,14 +17,16 @@ vi.mock('node:fs', async () => {
// @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")'
...actual.default,
existsSync: vi.fn(() => true),
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
realpathSync: vi.fn((p) => p.toString()),
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
mkdirSync: vi.fn(),
openSync: vi.fn(),
closeSync: vi.fn(),
writeFileSync: vi.fn(),
},
existsSync: vi.fn(() => true),
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
realpathSync: vi.fn((p) => p.toString()),
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
mkdirSync: vi.fn(),
openSync: vi.fn(),
closeSync: vi.fn(),
@@ -48,8 +49,12 @@ describe('LinuxSandboxManager', () => {
vi.restoreAllMocks();
});
const getBwrapArgs = async (req: SandboxRequest) => {
const result = await manager.prepareCommand(req);
const getBwrapArgs = async (
req: SandboxRequest,
customManager?: LinuxSandboxManager,
) => {
const mgr = customManager || manager;
const result = await mgr.prepareCommand(req);
expect(result.program).toBe('sh');
expect(result.args[0]).toBe('-c');
expect(result.args[1]).toBe(
@@ -60,41 +65,6 @@ describe('LinuxSandboxManager', () => {
return result.args.slice(4);
};
/**
* Helper to verify only the dynamic, policy-based binds (e.g. allowedPaths, forbiddenPaths).
* It asserts that the base workspace and governance files are present exactly once,
* then strips them away, leaving only the dynamic binds for a focused, non-brittle assertion.
*/
const expectDynamicBinds = (
bwrapArgs: string[],
expectedDynamicBinds: string[],
) => {
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const allBinds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
const baseBinds = [
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
];
// Verify the base binds are present exactly at the beginning
expect(allBinds.slice(0, baseBinds.length)).toEqual(baseBinds);
// Extract the remaining dynamic binds
const dynamicBinds = allBinds.slice(baseBinds.length);
expect(dynamicBinds).toEqual(expectedDynamicBinds);
};
describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const bwrapArgs = await getBwrapArgs({
@@ -117,7 +87,7 @@ describe('LinuxSandboxManager', () => {
'/proc',
'--tmpfs',
'/tmp',
'--bind',
'--ro-bind-try',
workspace,
workspace,
'--ro-bind',
@@ -137,6 +107,73 @@ describe('LinuxSandboxManager', () => {
]);
});
it('binds workspace read-write when readonly is false', async () => {
const customManager = new LinuxSandboxManager({
workspace,
modeConfig: { readonly: false },
});
const bwrapArgs = await getBwrapArgs(
{
command: 'ls',
args: [],
cwd: workspace,
env: {},
},
customManager,
);
expect(bwrapArgs).toContain('--bind-try');
expect(bwrapArgs).toContain(workspace);
});
it('maps network permissions to --share-net', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'curl',
args: [],
cwd: workspace,
env: {},
policy: { additionalPermissions: { network: true } },
});
expect(bwrapArgs).toContain('--share-net');
});
it('maps explicit write permissions to --bind-try', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'touch',
args: [],
cwd: workspace,
env: {},
policy: {
additionalPermissions: {
fileSystem: { write: ['/home/user/workspace/out/dir'] },
},
},
});
const index = bwrapArgs.indexOf('--bind-try');
expect(index).not.toBe(-1);
expect(bwrapArgs[index + 1]).toBe('/home/user/workspace/out/dir');
});
it('rejects overrides in plan mode', async () => {
const customManager = new LinuxSandboxManager({
workspace,
modeConfig: { allowOverrides: false },
});
await expect(
customManager.prepareCommand({
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: { additionalPermissions: { network: true } },
}),
).rejects.toThrow(
/Cannot override readonly\/network\/filesystem restrictions in Plan mode/,
);
});
it('should correctly pass through the cwd to the resulting command', async () => {
const req: SandboxRequest = {
command: 'ls',
@@ -184,12 +221,7 @@ describe('LinuxSandboxManager', () => {
},
});
expect(bwrapArgs).toContain('--unshare-user');
expect(bwrapArgs).toContain('--unshare-ipc');
expect(bwrapArgs).toContain('--unshare-pid');
expect(bwrapArgs).toContain('--unshare-uts');
expect(bwrapArgs).toContain('--unshare-cgroup');
expect(bwrapArgs).not.toContain('--unshare-all');
expect(bwrapArgs).toContain('--share-net');
});
describe('governance files', () => {
@@ -252,15 +284,32 @@ describe('LinuxSandboxManager', () => {
},
});
// Verify the specific bindings were added correctly
expectDynamicBinds(bwrapArgs, [
expect(bwrapArgs).toContain('--bind-try');
expect(bwrapArgs[bwrapArgs.indexOf('/tmp/cache') - 1]).toBe(
'--bind-try',
'/tmp/cache',
'/tmp/cache',
);
expect(bwrapArgs[bwrapArgs.indexOf('/opt/tools') - 1]).toBe(
'--bind-try',
'/opt/tools',
'/opt/tools',
]);
);
});
it('should not grant read-write access to allowedPaths inside the workspace when readonly mode is active', async () => {
const manager = new LinuxSandboxManager({
workspace,
modeConfig: { readonly: true },
});
const result = await manager.prepareCommand({
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace + '/subdirectory'],
},
});
const bwrapArgs = result.args;
const bindIndex = bwrapArgs.indexOf(workspace + '/subdirectory');
expect(bwrapArgs[bindIndex - 1]).toBe('--ro-bind-try');
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
@@ -274,23 +323,20 @@ describe('LinuxSandboxManager', () => {
},
});
// Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash
expectDynamicBinds(bwrapArgs, []);
const binds = bwrapArgs.filter((a) => a === workspace);
expect(binds.length).toBe(2);
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => {
// Mock /tmp/cache as a directory, and /opt/secret.txt as a file
vi.mocked(fs.statSync).mockImplementation((p) => {
if (p.toString().includes('cache')) {
return { isDirectory: () => true } as fs.Stats;
}
return { isDirectory: () => false } as fs.Stats;
});
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -302,27 +348,22 @@ describe('LinuxSandboxManager', () => {
},
});
expectDynamicBinds(bwrapArgs, [
'--tmpfs',
'/tmp/cache',
'--remount-ro',
'/tmp/cache',
'--ro-bind-try',
'/dev/null',
'/opt/secret.txt',
]);
const cacheIndex = bwrapArgs.indexOf('/tmp/cache');
expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs');
const secretIndex = bwrapArgs.indexOf('/opt/secret.txt');
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
});
it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => false }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
return p.toString();
},
vi.mocked(fs.statSync).mockImplementation(
() => ({ isDirectory: () => false }) as fs.Stats,
);
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
return p.toString();
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -334,24 +375,18 @@ describe('LinuxSandboxManager', () => {
},
});
// Should explicitly mask both the resolved path and the original symlink path
expectDynamicBinds(bwrapArgs, [
'--ro-bind-try',
'/dev/null',
'/opt/real-target.txt',
'--ro-bind-try',
'/dev/null',
'/tmp/forbidden-symlink',
]);
const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt');
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.spyOn(fs.promises, 'stat').mockRejectedValue(error);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
vi.mocked(fs.statSync).mockImplementation(() => {
throw error;
});
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -363,23 +398,19 @@ describe('LinuxSandboxManager', () => {
},
});
expectDynamicBinds(bwrapArgs, [
'--symlink',
'/.forbidden',
'/tmp/not-here.txt',
]);
const idx = bwrapArgs.indexOf('/tmp/not-here.txt');
expect(bwrapArgs[idx - 2]).toBe('--symlink');
expect(bwrapArgs[idx - 1]).toBe('/dev/null');
});
it('masks directory symlinks with tmpfs for both paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => true }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/tmp/dir-link') return '/opt/real-dir';
return p.toString();
},
vi.mocked(fs.statSync).mockImplementation(
() => ({ isDirectory: () => true }) as fs.Stats,
);
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p === '/tmp/dir-link') return '/opt/real-dir';
return p.toString();
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -391,25 +422,15 @@ describe('LinuxSandboxManager', () => {
},
});
expectDynamicBinds(bwrapArgs, [
'--tmpfs',
'/opt/real-dir',
'--remount-ro',
'/opt/real-dir',
'--tmpfs',
'/tmp/dir-link',
'--remount-ro',
'/tmp/dir-link',
]);
const idx = bwrapArgs.indexOf('/opt/real-dir');
expect(bwrapArgs[idx - 1]).toBe('--tmpfs');
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => true }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
vi.mocked(fs.statSync).mockImplementation(
() => ({ isDirectory: () => true }) as fs.Stats,
);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -422,15 +443,12 @@ describe('LinuxSandboxManager', () => {
},
});
expectDynamicBinds(bwrapArgs, [
'--bind-try',
'/tmp/conflict',
'/tmp/conflict',
'--tmpfs',
'/tmp/conflict',
'--remount-ro',
'/tmp/conflict',
]);
const bindTryIdx = bwrapArgs.indexOf('--bind-try');
const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs');
expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict');
expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict');
expect(tmpfsIdx).toBeGreaterThan(bindTryIdx);
});
});
});

View File

@@ -5,6 +5,7 @@
*/
import fs from 'node:fs';
import { debugLogger } from '../../utils/debugLogger.js';
import { join, dirname, normalize } from 'node:path';
import os from 'node:os';
import {
@@ -12,15 +13,25 @@ import {
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
type SandboxPermissions,
GOVERNANCE_FILES,
sanitizePaths,
tryRealpath,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { isNodeError } from '../../utils/errors.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import {
isStrictlyApproved,
verifySandboxOverrides,
getCommandName,
} from '../utils/commandUtils.js';
import {
tryRealpath,
resolveGitWorktreePaths,
isErrnoException,
} from '../utils/fsUtils.js';
let cachedBpfPath: string | undefined;
@@ -102,13 +113,24 @@ function touch(filePath: string, isDirectory: boolean) {
import {
isKnownSafeCommand,
isDangerousCommand,
} from '../macos/commandSafety.js';
} from '../utils/commandSafety.js';
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
export interface LinuxSandboxOptions extends GlobalSandboxOptions {
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
policyManager?: SandboxPolicyManager;
}
export class LinuxSandboxManager implements SandboxManager {
constructor(private readonly options: GlobalSandboxOptions) {}
constructor(private readonly options: LinuxSandboxOptions) {}
isKnownSafeCommand(args: string[]): boolean {
return isKnownSafeCommand(args);
@@ -119,6 +141,41 @@ export class LinuxSandboxManager implements SandboxManager {
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
verifySandboxOverrides(allowOverrides, req.policy);
const commandName = await getCommandName(req);
const isApproved = allowOverrides
? await isStrictlyApproved(req, this.options.modeConfig?.approvedTools)
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
networkAccess ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
@@ -126,13 +183,142 @@ export class LinuxSandboxManager implements SandboxManager {
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const bwrapArgs: string[] = [
...this.getNetworkArgs(req),
...this.getBaseArgs(),
...this.getGovernanceArgs(),
...this.getAllowedPathsArgs(req.policy?.allowedPaths),
...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)),
'--unshare-all',
'--new-session', // Isolate session
'--die-with-parent', // Prevent orphaned runaway processes
];
if (mergedAdditional.network) {
bwrapArgs.push('--share-net');
}
bwrapArgs.push(
'--ro-bind',
'/',
'/',
'--dev', // Creates a safe, minimal /dev (replaces --dev-bind)
'/dev',
'--proc', // Creates a fresh procfs for the unshared PID namespace
'/proc',
'--tmpfs', // Provides an isolated, writable /tmp directory
'/tmp',
);
const workspacePath = tryRealpath(this.options.workspace);
const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try';
if (workspaceWrite) {
bwrapArgs.push(
'--bind-try',
this.options.workspace,
this.options.workspace,
);
if (workspacePath !== this.options.workspace) {
bwrapArgs.push('--bind-try', workspacePath, workspacePath);
}
} else {
bwrapArgs.push(
'--ro-bind-try',
this.options.workspace,
this.options.workspace,
);
if (workspacePath !== this.options.workspace) {
bwrapArgs.push('--ro-bind-try', workspacePath, workspacePath);
}
}
const { worktreeGitDir, mainGitDir } =
resolveGitWorktreePaths(workspacePath);
if (worktreeGitDir) {
bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir);
}
if (mainGitDir) {
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
}
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;
const normalizedAllowedPath = normalize(resolved).replace(/\/$/, '');
if (normalizedAllowedPath !== normalizedWorkspace) {
if (
!workspaceWrite &&
normalizedAllowedPath.startsWith(normalizedWorkspace + '/')
) {
bwrapArgs.push('--ro-bind-try', resolved, resolved);
} else {
bwrapArgs.push('--bind-try', resolved, resolved);
}
}
}
const additionalReads =
sanitizePaths(mergedAdditional.fileSystem?.read) || [];
for (const p of additionalReads) {
try {
const safeResolvedPath = tryRealpath(p);
bwrapArgs.push('--ro-bind-try', safeResolvedPath, safeResolvedPath);
} catch (e: unknown) {
debugLogger.warn(e instanceof Error ? e.message : String(e));
}
}
const additionalWrites =
sanitizePaths(mergedAdditional.fileSystem?.write) || [];
for (const p of additionalWrites) {
try {
const safeResolvedPath = tryRealpath(p);
bwrapArgs.push('--bind-try', safeResolvedPath, safeResolvedPath);
} catch (e: unknown) {
debugLogger.warn(e instanceof Error ? e.message : String(e));
}
}
for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
const realPath = tryRealpath(filePath);
bwrapArgs.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
bwrapArgs.push('--ro-bind', realPath, realPath);
}
}
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
for (const p of forbiddenPaths) {
let resolved: string;
try {
resolved = tryRealpath(p); // Forbidden paths should still resolve to block the real path
if (!fs.existsSync(resolved)) continue;
} catch (e: unknown) {
debugLogger.warn(
`Failed to resolve forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`,
);
bwrapArgs.push('--ro-bind', '/dev/null', p);
continue;
}
try {
const stat = fs.statSync(resolved);
if (stat.isDirectory()) {
bwrapArgs.push('--tmpfs', resolved, '--remount-ro', resolved);
} else {
bwrapArgs.push('--ro-bind', '/dev/null', resolved);
}
} catch (e: unknown) {
if (isErrnoException(e) && e.code === 'ENOENT') {
bwrapArgs.push('--symlink', '/dev/null', resolved);
} else {
debugLogger.warn(
`Failed to stat forbidden path ${resolved}: ${e instanceof Error ? e.message : String(e)}`,
);
bwrapArgs.push('--ro-bind', '/dev/null', resolved);
}
}
}
const bpfPath = getSeccompBpfPath();
bwrapArgs.push('--seccomp', '9');
@@ -153,142 +339,4 @@ export class LinuxSandboxManager implements SandboxManager {
cwd: req.cwd,
};
}
/**
* Generates arguments for network isolation.
*/
private getNetworkArgs(req: SandboxRequest): string[] {
return req.policy?.networkAccess
? [
'--unshare-user',
'--unshare-ipc',
'--unshare-pid',
'--unshare-uts',
'--unshare-cgroup',
]
: ['--unshare-all'];
}
/**
* Generates the base bubblewrap arguments for isolation.
*/
private getBaseArgs(): string[] {
return [
'--new-session', // Isolate session
'--die-with-parent', // Prevent orphaned runaway processes
'--ro-bind',
'/',
'/',
'--dev', // Creates a safe, minimal /dev (replaces --dev-bind)
'/dev',
'--proc', // Creates a fresh procfs for the unshared PID namespace
'/proc',
'--tmpfs', // Provides an isolated, writable /tmp directory
'/tmp',
// Note: --dev /dev sets up /dev/pts automatically
'--bind',
this.options.workspace,
this.options.workspace,
];
}
/**
* Generates arguments for protected governance files.
*/
private getGovernanceArgs(): string[] {
const args: string[] = [];
// Protected governance files are bind-mounted as read-only, even if the workspace is RW.
// We ensure they exist on the host and resolve real paths to prevent symlink bypasses.
// In bwrap, later binds override earlier ones for the same path.
for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
const realPath = fs.realpathSync(filePath);
args.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
args.push('--ro-bind', realPath, realPath);
}
}
return args;
}
/**
* Generates arguments for allowed paths.
*/
private getAllowedPathsArgs(allowedPaths?: string[]): string[] {
const args: string[] = [];
const paths = sanitizePaths(allowedPaths) || [];
const normalizedWorkspace = this.normalizePath(this.options.workspace);
for (const p of paths) {
if (this.normalizePath(p) !== normalizedWorkspace) {
args.push('--bind-try', p, p);
}
}
return args;
}
/**
* Generates arguments for forbidden paths.
*/
private async getForbiddenPathsArgs(
forbiddenPaths?: string[],
): Promise<string[]> {
const args: string[] = [];
const paths = sanitizePaths(forbiddenPaths) || [];
for (const p of paths) {
try {
const originalPath = this.normalizePath(p);
const resolvedPath = await tryRealpath(originalPath);
// Mask the resolved path to prevent access to the underlying file.
const resolvedMask = await this.getMaskArgs(resolvedPath);
args.push(...resolvedMask);
// If the original path was a symlink, mask it as well to prevent access
// through the link itself.
if (resolvedPath !== originalPath) {
const originalMask = await this.getMaskArgs(originalPath);
args.push(...originalMask);
}
} catch (e) {
throw new Error(
`Failed to deny access to forbidden path: ${p}. ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
return args;
}
/**
* Generates bubblewrap arguments to mask a forbidden path.
*/
private async getMaskArgs(path: string): Promise<string[]> {
try {
const stats = await fs.promises.stat(path);
if (stats.isDirectory()) {
// Directories are masked by mounting an empty, read-only tmpfs.
return ['--tmpfs', path, '--remount-ro', path];
}
// Existing files are masked by binding them to /dev/null.
return ['--ro-bind-try', '/dev/null', path];
} catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') {
// Non-existent paths are masked by a broken symlink. This prevents
// creation within the sandbox while avoiding host remnants.
return ['--symlink', '/.forbidden', path];
}
throw e;
}
}
private normalizePath(p: string): string {
return normalize(p).replace(/\/$/, '');
}
}

View File

@@ -38,7 +38,7 @@ describe('MacOsSandboxManager', () => {
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
// Mock the seatbelt args builder to isolate manager tests
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockResolvedValue([
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([
'-p',
'(mock profile)',
'-D',

View File

@@ -24,8 +24,9 @@ import {
isKnownSafeCommand,
isDangerousCommand,
isStrictlyApproved,
} from './commandSafety.js';
} from '../utils/commandSafety.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
/** The current sandbox mode behavior from config. */
@@ -70,17 +71,7 @@ export class MacOsSandboxManager implements SandboxManager {
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
if (!allowOverrides && req.policy?.additionalPermissions) {
const perms = req.policy.additionalPermissions;
if (
perms.network ||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
) {
throw new Error(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
);
}
}
verifySandboxOverrides(allowOverrides, req.policy);
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
@@ -120,7 +111,7 @@ export class MacOsSandboxManager implements SandboxManager {
false,
};
const sandboxArgs = await buildSeatbeltArgs({
const sandboxArgs = buildSeatbeltArgs({
workspace: this.options.workspace,
allowedPaths: [...(req.policy?.allowedPaths || [])],
forbiddenPaths: req.policy?.forbiddenPaths,

View File

@@ -3,25 +3,31 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import * as fsUtils from '../utils/fsUtils.js';
import fs from 'node:fs';
import os from 'node:os';
vi.mock('../utils/fsUtils.js', async () => {
const actual = await vi.importActual('../utils/fsUtils.js');
return {
...actual,
tryRealpath: vi.fn((p) => p),
resolveGitWorktreePaths: vi.fn(() => ({})),
};
});
describe('seatbeltArgsBuilder', () => {
beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('buildSeatbeltArgs', () => {
it('should build a strict allowlist profile allowing the workspace via param', async () => {
// Mock tryRealpath to just return the path for testing
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
it('should build a strict allowlist profile allowing the workspace via param', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
const args = await buildSeatbeltArgs({
const args = buildSeatbeltArgs({
workspace: '/Users/test/workspace',
});
@@ -38,11 +44,9 @@ describe('seatbeltArgsBuilder', () => {
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
});
it('should allow network when networkAccess is true', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
it('should allow network when networkAccess is true', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
const args = buildSeatbeltArgs({
workspace: '/test',
networkAccess: true,
});
@@ -51,10 +55,8 @@ describe('seatbeltArgsBuilder', () => {
});
describe('governance files', () => {
it('should inject explicit deny rules for governance files', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
it('should inject explicit deny rules for governance files', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p.toString());
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation(
(p) =>
@@ -64,35 +66,29 @@ describe('seatbeltArgsBuilder', () => {
}) as unknown as fs.Stats,
);
const args = await buildSeatbeltArgs({
workspace: '/Users/test/workspace',
const args = buildSeatbeltArgs({
workspace: '/test/workspace',
});
const profile = args[1];
// .gitignore should be a literal deny
expect(args).toContain('-D');
expect(args).toContain(
'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore',
);
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
);
// .git should be a subpath deny
expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git');
expect(args).toContain('GOVERNANCE_FILE_2=/test/workspace/.git');
expect(profile).toContain(
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
);
});
it('should protect both the symlink and the real path if they differ', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/workspace/.gitignore')
return '/test/real/.gitignore';
return p.toString();
},
);
it('should protect both the symlink and the real path if they differ', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
if (p === '/test/workspace/.gitignore')
return '/test/real/.gitignore';
return p.toString();
});
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation(
() =>
@@ -102,7 +98,7 @@ describe('seatbeltArgsBuilder', () => {
}) as unknown as fs.Stats,
);
const args = await buildSeatbeltArgs({ workspace: '/test/workspace' });
const args = buildSeatbeltArgs({ workspace: '/test/workspace' });
const profile = args[1];
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
@@ -117,15 +113,13 @@ describe('seatbeltArgsBuilder', () => {
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
},
);
it('should parameterize allowed paths and normalize them', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
});
const args = await buildSeatbeltArgs({
const args = buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
});
@@ -141,12 +135,10 @@ describe('seatbeltArgsBuilder', () => {
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
it('should parameterize forbidden paths and explicitly deny them', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
const args = await buildSeatbeltArgs({
const args = buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/secret/path'],
});
@@ -161,22 +153,21 @@ describe('seatbeltArgsBuilder', () => {
);
});
it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
},
);
it('resolves forbidden symlink paths to their real paths', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
if (p === '/test/symlink' || p === '/test/missing-dir') {
return '/test/real_path';
}
return p;
});
const args = await buildSeatbeltArgs({
const args = buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/symlink'],
});
const profile = args[1];
// The builder should resolve the symlink and explicitly deny the real target path
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
expect(profile).toContain(
@@ -184,12 +175,10 @@ describe('seatbeltArgsBuilder', () => {
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
it('explicitly denies non-existent forbidden paths to prevent creation', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
const args = await buildSeatbeltArgs({
const args = buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
});
@@ -205,12 +194,10 @@ describe('seatbeltArgsBuilder', () => {
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
it('should override allowed paths if a path is also in forbidden paths', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
const args = await buildSeatbeltArgs({
const args = buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1'],
forbiddenPaths: ['/custom/path1'],
@@ -226,8 +213,6 @@ describe('seatbeltArgsBuilder', () => {
expect(profile).toContain(allowString);
expect(profile).toContain(denyString);
// Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string
// Seatbelt rules are evaluated in order where the latest rule matching a path wins
const allowIndex = profile.indexOf(allowString);
const denyIndex = profile.indexOf(denyString);
expect(denyIndex).toBeGreaterThan(allowIndex);

View File

@@ -15,8 +15,8 @@ import {
type SandboxPermissions,
sanitizePaths,
GOVERNANCE_FILES,
tryRealpath,
} from '../../services/sandboxManager.js';
import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js';
/**
* Options for building macOS Seatbelt arguments.
@@ -44,13 +44,11 @@ export interface SeatbeltArgsOptions {
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
export async function buildSeatbeltArgs(
options: SeatbeltArgsOptions,
): Promise<string[]> {
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
let profile = BASE_SEATBELT_PROFILE + '\n';
const args: string[] = [];
const workspacePath = await tryRealpath(options.workspace);
const workspacePath = tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
args.push('-D', `WORKSPACE_RAW=${options.workspace}`);
profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`;
@@ -67,7 +65,7 @@ export async function buildSeatbeltArgs(
// (Seatbelt evaluates rules in order, later rules win for same path).
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
const realGovernanceFile = await tryRealpath(governanceFile);
const realGovernanceFile = tryRealpath(governanceFile);
// Determine if it should be treated as a directory (subpath) or a file (literal).
// .git is generally a directory, while ignore files are literals.
@@ -92,42 +90,20 @@ export async function buildSeatbeltArgs(
}
// Auto-detect and support git worktrees by granting read and write access to the underlying git directory
try {
const gitPath = path.join(workspacePath, '.git');
const gitStat = fs.lstatSync(gitPath);
if (gitStat.isFile()) {
const gitContent = fs.readFileSync(gitPath, 'utf8');
const match = gitContent.match(/^gitdir:\s*(.+)$/m);
if (match && match[1]) {
let worktreeGitDir = match[1].trim();
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
}
const resolvedWorktreeGitDir = await tryRealpath(worktreeGitDir);
// Grant write access to the worktree's specific .git directory
args.push('-D', `WORKTREE_GIT_DIR=${resolvedWorktreeGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`;
// Grant write access to the main repository's .git directory (objects, refs, etc. are shared)
// resolvedWorktreeGitDir is usually like: /path/to/main-repo/.git/worktrees/worktree-name
const mainGitDir = await tryRealpath(
path.dirname(path.dirname(resolvedWorktreeGitDir)),
);
if (mainGitDir && mainGitDir.endsWith('.git')) {
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
}
}
}
} catch (_e) {
// Ignore if .git doesn't exist, isn't readable, etc.
const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath);
if (worktreeGitDir) {
args.push('-D', `WORKTREE_GIT_DIR=${worktreeGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`;
}
if (mainGitDir) {
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
}
const tmpPath = await tryRealpath(os.tmpdir());
const tmpPath = tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const nodeRootPath = await tryRealpath(
const nodeRootPath = tryRealpath(
path.dirname(path.dirname(process.execPath)),
);
args.push('-D', `NODE_ROOT=${nodeRootPath}`);
@@ -142,7 +118,7 @@ export async function buildSeatbeltArgs(
for (const p of paths) {
if (!p.trim()) continue;
try {
let resolved = await tryRealpath(p);
let resolved = tryRealpath(p);
// If this is a 'bin' directory (like /usr/local/bin or homebrew/bin),
// also grant read access to its parent directory so that symlinked
@@ -165,8 +141,10 @@ export async function buildSeatbeltArgs(
// Handle allowedPaths
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
const resolvedAllowedPaths: string[] = [];
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = await tryRealpath(allowedPaths[i]);
const allowedPath = tryRealpath(allowedPaths[i]);
resolvedAllowedPaths.push(allowedPath);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
}
@@ -176,7 +154,7 @@ export async function buildSeatbeltArgs(
const { read, write } = options.additionalPermissions.fileSystem;
if (read) {
for (let i = 0; i < read.length; i++) {
const resolved = await tryRealpath(read[i]);
const resolved = tryRealpath(read[i]);
const paramName = `ADDITIONAL_READ_${i}`;
args.push('-D', `${paramName}=${resolved}`);
let isFile = false;
@@ -194,7 +172,7 @@ export async function buildSeatbeltArgs(
}
if (write) {
for (let i = 0; i < write.length; i++) {
const resolved = await tryRealpath(write[i]);
const resolved = tryRealpath(write[i]);
const paramName = `ADDITIONAL_WRITE_${i}`;
args.push('-D', `${paramName}=${resolved}`);
let isFile = false;
@@ -215,7 +193,7 @@ export async function buildSeatbeltArgs(
// Handle forbiddenPaths
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
for (let i = 0; i < forbiddenPaths.length; i++) {
const forbiddenPath = await tryRealpath(forbiddenPaths[i]);
const forbiddenPath = tryRealpath(forbiddenPaths[i]);
args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`);
profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`;
}

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type SandboxRequest } from '../../services/sandboxManager.js';
import {
getCommandRoots,
initializeShellParsers,
splitCommands,
stripShellWrapper,
} from '../../utils/shell-utils.js';
import { isKnownSafeCommand } from './commandSafety.js';
import { parse as shellParse } from 'shell-quote';
import path from 'node:path';
export async function isStrictlyApproved(
req: SandboxRequest,
approvedTools?: string[],
): Promise<boolean> {
if (!approvedTools || approvedTools.length === 0) {
return false;
}
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped);
if (roots.length === 0) return false;
const allRootsApproved = roots.every((root) => approvedTools.includes(root));
if (allRootsApproved) {
return true;
}
const pipelineCommands = splitCommands(stripped);
if (pipelineCommands.length === 0) return false;
for (const cmdString of pipelineCommands) {
const parsedArgs = shellParse(cmdString).map(String);
if (!isKnownSafeCommand(parsedArgs)) {
return false;
}
}
return true;
}
export async function getCommandName(req: SandboxRequest): Promise<string> {
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
if (roots.length > 0) {
return roots[0];
}
return path.basename(req.command);
}
export function verifySandboxOverrides(
allowOverrides: boolean,
policy: SandboxRequest['policy'],
) {
if (!allowOverrides) {
if (
policy?.networkAccess ||
policy?.allowedPaths?.length ||
policy?.additionalPermissions?.network ||
policy?.additionalPermissions?.fileSystem?.read?.length ||
policy?.additionalPermissions?.fileSystem?.write?.length
) {
throw new Error(
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
);
}
}
}

View File

@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
export function isErrnoException(e: unknown): e is NodeJS.ErrnoException {
return e instanceof Error && 'code' in e;
}
export function tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (_e) {
if (isErrnoException(_e) && _e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(tryRealpath(parentDir), path.basename(p));
}
throw _e;
}
}
export function resolveGitWorktreePaths(workspacePath: string): {
worktreeGitDir?: string;
mainGitDir?: string;
} {
try {
const gitPath = path.join(workspacePath, '.git');
const gitStat = fs.lstatSync(gitPath);
if (gitStat.isFile()) {
const gitContent = fs.readFileSync(gitPath, 'utf8');
const match = gitContent.match(/^gitdir:\s+(.+)$/m);
if (match && match[1]) {
let worktreeGitDir = match[1].trim();
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
}
const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir);
// Security check: Verify the bidirectional link to prevent sandbox escape
let isValid = false;
try {
const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir');
const backlink = fs.readFileSync(backlinkPath, 'utf8').trim();
// The backlink must resolve to the workspace's .git file
if (tryRealpath(backlink) === tryRealpath(gitPath)) {
isValid = true;
}
} catch (_e) {
// Fallback for submodules: check core.worktree in config
try {
const configPath = path.join(resolvedWorktreeGitDir, 'config');
const config = fs.readFileSync(configPath, 'utf8');
const match = config.match(/^\s*worktree\s*=\s*(.+)$/m);
if (match && match[1]) {
const worktreePath = path.resolve(
resolvedWorktreeGitDir,
match[1].trim(),
);
if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) {
isValid = true;
}
}
} catch (_e2) {
// Ignore
}
}
if (!isValid) {
return {}; // Reject: valid worktrees/submodules must have a readable backlink
}
const mainGitDir = tryRealpath(
path.dirname(path.dirname(resolvedWorktreeGitDir)),
);
return {
worktreeGitDir: resolvedWorktreeGitDir,
mainGitDir: mainGitDir.endsWith('.git') ? mainGitDir : undefined,
};
}
}
} catch (_e) {
// Ignore if .git doesn't exist, isn't readable, etc.
}
return {};
}

View File

@@ -111,7 +111,7 @@ describe('WindowsSandboxManager', () => {
};
await expect(planManager.prepareCommand(req)).rejects.toThrow(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
);
});

View File

@@ -31,6 +31,7 @@ import {
isStrictlyApproved,
} from './commandSafety.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -214,17 +215,7 @@ export class WindowsSandboxManager implements SandboxManager {
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
if (!allowOverrides && req.policy?.additionalPermissions) {
const perms = req.policy.additionalPermissions;
if (
perms.network ||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
) {
throw new Error(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
);
}
}
verifySandboxOverrides(allowOverrides, req.policy);
// Fetch persistent approvals for this command
const commandName = await getCommandName(req.command, req.args);

View File

@@ -10,7 +10,7 @@ import path from 'node:path';
import {
isKnownSafeCommand as isMacSafeCommand,
isDangerousCommand as isMacDangerousCommand,
} from '../sandbox/macos/commandSafety.js';
} from '../sandbox/utils/commandSafety.js';
import {
isKnownSafeCommand as isWindowsSafeCommand,
isDangerousCommand as isWindowsDangerousCommand,

View File

@@ -42,7 +42,11 @@ export function createSandboxManager(
policyManager,
});
} else if (os.platform() === 'linux') {
return new LinuxSandboxManager({ workspace });
return new LinuxSandboxManager({
workspace,
modeConfig,
policyManager,
});
} else if (os.platform() === 'darwin') {
return new MacOsSandboxManager({
workspace,