mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
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:
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
||||||
import * as sandboxManager from '../../services/sandboxManager.js';
|
|
||||||
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
||||||
import fs from 'node:fs';
|
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")'
|
// @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")'
|
||||||
...actual.default,
|
...actual.default,
|
||||||
existsSync: vi.fn(() => true),
|
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(),
|
mkdirSync: vi.fn(),
|
||||||
openSync: vi.fn(),
|
openSync: vi.fn(),
|
||||||
closeSync: vi.fn(),
|
closeSync: vi.fn(),
|
||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
},
|
},
|
||||||
existsSync: vi.fn(() => true),
|
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(),
|
mkdirSync: vi.fn(),
|
||||||
openSync: vi.fn(),
|
openSync: vi.fn(),
|
||||||
closeSync: vi.fn(),
|
closeSync: vi.fn(),
|
||||||
@@ -48,8 +49,12 @@ describe('LinuxSandboxManager', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const getBwrapArgs = async (req: SandboxRequest) => {
|
const getBwrapArgs = async (
|
||||||
const result = await manager.prepareCommand(req);
|
req: SandboxRequest,
|
||||||
|
customManager?: LinuxSandboxManager,
|
||||||
|
) => {
|
||||||
|
const mgr = customManager || manager;
|
||||||
|
const result = await mgr.prepareCommand(req);
|
||||||
expect(result.program).toBe('sh');
|
expect(result.program).toBe('sh');
|
||||||
expect(result.args[0]).toBe('-c');
|
expect(result.args[0]).toBe('-c');
|
||||||
expect(result.args[1]).toBe(
|
expect(result.args[1]).toBe(
|
||||||
@@ -60,41 +65,6 @@ describe('LinuxSandboxManager', () => {
|
|||||||
return result.args.slice(4);
|
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', () => {
|
describe('prepareCommand', () => {
|
||||||
it('should correctly format the base command and args', async () => {
|
it('should correctly format the base command and args', async () => {
|
||||||
const bwrapArgs = await getBwrapArgs({
|
const bwrapArgs = await getBwrapArgs({
|
||||||
@@ -117,7 +87,7 @@ describe('LinuxSandboxManager', () => {
|
|||||||
'/proc',
|
'/proc',
|
||||||
'--tmpfs',
|
'--tmpfs',
|
||||||
'/tmp',
|
'/tmp',
|
||||||
'--bind',
|
'--ro-bind-try',
|
||||||
workspace,
|
workspace,
|
||||||
workspace,
|
workspace,
|
||||||
'--ro-bind',
|
'--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 () => {
|
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||||
const req: SandboxRequest = {
|
const req: SandboxRequest = {
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
@@ -184,12 +221,7 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(bwrapArgs).toContain('--unshare-user');
|
expect(bwrapArgs).toContain('--share-net');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('governance files', () => {
|
describe('governance files', () => {
|
||||||
@@ -252,15 +284,32 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify the specific bindings were added correctly
|
expect(bwrapArgs).toContain('--bind-try');
|
||||||
expectDynamicBinds(bwrapArgs, [
|
expect(bwrapArgs[bwrapArgs.indexOf('/tmp/cache') - 1]).toBe(
|
||||||
'--bind-try',
|
'--bind-try',
|
||||||
'/tmp/cache',
|
);
|
||||||
'/tmp/cache',
|
expect(bwrapArgs[bwrapArgs.indexOf('/opt/tools') - 1]).toBe(
|
||||||
'--bind-try',
|
'--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 () => {
|
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
|
const binds = bwrapArgs.filter((a) => a === workspace);
|
||||||
expectDynamicBinds(bwrapArgs, []);
|
expect(binds.length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('forbiddenPaths', () => {
|
describe('forbiddenPaths', () => {
|
||||||
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
||||||
vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => {
|
vi.mocked(fs.statSync).mockImplementation((p) => {
|
||||||
// Mock /tmp/cache as a directory, and /opt/secret.txt as a file
|
|
||||||
if (p.toString().includes('cache')) {
|
if (p.toString().includes('cache')) {
|
||||||
return { isDirectory: () => true } as fs.Stats;
|
return { isDirectory: () => true } as fs.Stats;
|
||||||
}
|
}
|
||||||
return { isDirectory: () => false } as fs.Stats;
|
return { isDirectory: () => false } as fs.Stats;
|
||||||
});
|
});
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||||
p.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const bwrapArgs = await getBwrapArgs({
|
const bwrapArgs = await getBwrapArgs({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
@@ -302,27 +348,22 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectDynamicBinds(bwrapArgs, [
|
const cacheIndex = bwrapArgs.indexOf('/tmp/cache');
|
||||||
'--tmpfs',
|
expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs');
|
||||||
'/tmp/cache',
|
|
||||||
'--remount-ro',
|
const secretIndex = bwrapArgs.indexOf('/opt/secret.txt');
|
||||||
'/tmp/cache',
|
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
|
||||||
'--ro-bind-try',
|
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
|
||||||
'/dev/null',
|
|
||||||
'/opt/secret.txt',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves forbidden symlink paths to their real paths', async () => {
|
it('resolves forbidden symlink paths to their real paths', async () => {
|
||||||
vi.spyOn(fs.promises, 'stat').mockImplementation(
|
vi.mocked(fs.statSync).mockImplementation(
|
||||||
async () => ({ isDirectory: () => false }) as fs.Stats,
|
() => ({ isDirectory: () => false }) as fs.Stats,
|
||||||
);
|
);
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||||
async (p) => {
|
|
||||||
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
|
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
|
||||||
return p.toString();
|
return p.toString();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const bwrapArgs = await getBwrapArgs({
|
const bwrapArgs = await getBwrapArgs({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
@@ -334,24 +375,18 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should explicitly mask both the resolved path and the original symlink path
|
const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt');
|
||||||
expectDynamicBinds(bwrapArgs, [
|
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
|
||||||
'--ro-bind-try',
|
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
|
||||||
'/dev/null',
|
|
||||||
'/opt/real-target.txt',
|
|
||||||
'--ro-bind-try',
|
|
||||||
'/dev/null',
|
|
||||||
'/tmp/forbidden-symlink',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
||||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||||
error.code = 'ENOENT';
|
error.code = 'ENOENT';
|
||||||
vi.spyOn(fs.promises, 'stat').mockRejectedValue(error);
|
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
throw error;
|
||||||
p.toString(),
|
});
|
||||||
);
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||||
|
|
||||||
const bwrapArgs = await getBwrapArgs({
|
const bwrapArgs = await getBwrapArgs({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
@@ -363,23 +398,19 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectDynamicBinds(bwrapArgs, [
|
const idx = bwrapArgs.indexOf('/tmp/not-here.txt');
|
||||||
'--symlink',
|
expect(bwrapArgs[idx - 2]).toBe('--symlink');
|
||||||
'/.forbidden',
|
expect(bwrapArgs[idx - 1]).toBe('/dev/null');
|
||||||
'/tmp/not-here.txt',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('masks directory symlinks with tmpfs for both paths', async () => {
|
it('masks directory symlinks with tmpfs for both paths', async () => {
|
||||||
vi.spyOn(fs.promises, 'stat').mockImplementation(
|
vi.mocked(fs.statSync).mockImplementation(
|
||||||
async () => ({ isDirectory: () => true }) as fs.Stats,
|
() => ({ isDirectory: () => true }) as fs.Stats,
|
||||||
);
|
);
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||||
async (p) => {
|
|
||||||
if (p === '/tmp/dir-link') return '/opt/real-dir';
|
if (p === '/tmp/dir-link') return '/opt/real-dir';
|
||||||
return p.toString();
|
return p.toString();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const bwrapArgs = await getBwrapArgs({
|
const bwrapArgs = await getBwrapArgs({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
@@ -391,25 +422,15 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectDynamicBinds(bwrapArgs, [
|
const idx = bwrapArgs.indexOf('/opt/real-dir');
|
||||||
'--tmpfs',
|
expect(bwrapArgs[idx - 1]).toBe('--tmpfs');
|
||||||
'/opt/real-dir',
|
|
||||||
'--remount-ro',
|
|
||||||
'/opt/real-dir',
|
|
||||||
'--tmpfs',
|
|
||||||
'/tmp/dir-link',
|
|
||||||
'--remount-ro',
|
|
||||||
'/tmp/dir-link',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
||||||
vi.spyOn(fs.promises, 'stat').mockImplementation(
|
vi.mocked(fs.statSync).mockImplementation(
|
||||||
async () => ({ isDirectory: () => true }) as fs.Stats,
|
() => ({ isDirectory: () => true }) as fs.Stats,
|
||||||
);
|
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
|
||||||
p.toString(),
|
|
||||||
);
|
);
|
||||||
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||||
|
|
||||||
const bwrapArgs = await getBwrapArgs({
|
const bwrapArgs = await getBwrapArgs({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
@@ -422,15 +443,12 @@ describe('LinuxSandboxManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectDynamicBinds(bwrapArgs, [
|
const bindTryIdx = bwrapArgs.indexOf('--bind-try');
|
||||||
'--bind-try',
|
const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs');
|
||||||
'/tmp/conflict',
|
|
||||||
'/tmp/conflict',
|
expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict');
|
||||||
'--tmpfs',
|
expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict');
|
||||||
'/tmp/conflict',
|
expect(tmpfsIdx).toBeGreaterThan(bindTryIdx);
|
||||||
'--remount-ro',
|
|
||||||
'/tmp/conflict',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { debugLogger } from '../../utils/debugLogger.js';
|
||||||
import { join, dirname, normalize } from 'node:path';
|
import { join, dirname, normalize } from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import {
|
import {
|
||||||
@@ -12,15 +13,25 @@ import {
|
|||||||
type GlobalSandboxOptions,
|
type GlobalSandboxOptions,
|
||||||
type SandboxRequest,
|
type SandboxRequest,
|
||||||
type SandboxedCommand,
|
type SandboxedCommand,
|
||||||
|
type SandboxPermissions,
|
||||||
GOVERNANCE_FILES,
|
GOVERNANCE_FILES,
|
||||||
sanitizePaths,
|
sanitizePaths,
|
||||||
tryRealpath,
|
|
||||||
} from '../../services/sandboxManager.js';
|
} from '../../services/sandboxManager.js';
|
||||||
import {
|
import {
|
||||||
sanitizeEnvironment,
|
sanitizeEnvironment,
|
||||||
getSecureSanitizationConfig,
|
getSecureSanitizationConfig,
|
||||||
} from '../../services/environmentSanitization.js';
|
} 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;
|
let cachedBpfPath: string | undefined;
|
||||||
|
|
||||||
@@ -102,13 +113,24 @@ function touch(filePath: string, isDirectory: boolean) {
|
|||||||
import {
|
import {
|
||||||
isKnownSafeCommand,
|
isKnownSafeCommand,
|
||||||
isDangerousCommand,
|
isDangerousCommand,
|
||||||
} from '../macos/commandSafety.js';
|
} from '../utils/commandSafety.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
* 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 {
|
export class LinuxSandboxManager implements SandboxManager {
|
||||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
constructor(private readonly options: LinuxSandboxOptions) {}
|
||||||
|
|
||||||
isKnownSafeCommand(args: string[]): boolean {
|
isKnownSafeCommand(args: string[]): boolean {
|
||||||
return isKnownSafeCommand(args);
|
return isKnownSafeCommand(args);
|
||||||
@@ -119,6 +141,41 @@ export class LinuxSandboxManager implements SandboxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
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(
|
const sanitizationConfig = getSecureSanitizationConfig(
|
||||||
req.policy?.sanitizationConfig,
|
req.policy?.sanitizationConfig,
|
||||||
);
|
);
|
||||||
@@ -126,13 +183,142 @@ export class LinuxSandboxManager implements SandboxManager {
|
|||||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||||
|
|
||||||
const bwrapArgs: string[] = [
|
const bwrapArgs: string[] = [
|
||||||
...this.getNetworkArgs(req),
|
'--unshare-all',
|
||||||
...this.getBaseArgs(),
|
'--new-session', // Isolate session
|
||||||
...this.getGovernanceArgs(),
|
'--die-with-parent', // Prevent orphaned runaway processes
|
||||||
...this.getAllowedPathsArgs(req.policy?.allowedPaths),
|
|
||||||
...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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();
|
const bpfPath = getSeccompBpfPath();
|
||||||
|
|
||||||
bwrapArgs.push('--seccomp', '9');
|
bwrapArgs.push('--seccomp', '9');
|
||||||
@@ -153,142 +339,4 @@ export class LinuxSandboxManager implements SandboxManager {
|
|||||||
cwd: req.cwd,
|
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(/\/$/, '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('MacOsSandboxManager', () => {
|
|||||||
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
|
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
|
||||||
|
|
||||||
// Mock the seatbelt args builder to isolate manager tests
|
// Mock the seatbelt args builder to isolate manager tests
|
||||||
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockResolvedValue([
|
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([
|
||||||
'-p',
|
'-p',
|
||||||
'(mock profile)',
|
'(mock profile)',
|
||||||
'-D',
|
'-D',
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ import {
|
|||||||
isKnownSafeCommand,
|
isKnownSafeCommand,
|
||||||
isDangerousCommand,
|
isDangerousCommand,
|
||||||
isStrictlyApproved,
|
isStrictlyApproved,
|
||||||
} from './commandSafety.js';
|
} from '../utils/commandSafety.js';
|
||||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||||
|
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||||
|
|
||||||
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
|
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
|
||||||
/** The current sandbox mode behavior from config. */
|
/** The current sandbox mode behavior from config. */
|
||||||
@@ -70,17 +71,7 @@ export class MacOsSandboxManager implements SandboxManager {
|
|||||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||||
|
|
||||||
// Reject override attempts in plan mode
|
// Reject override attempts in plan mode
|
||||||
if (!allowOverrides && req.policy?.additionalPermissions) {
|
verifySandboxOverrides(allowOverrides, req.policy);
|
||||||
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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||||
const isApproved = allowOverrides
|
const isApproved = allowOverrides
|
||||||
@@ -120,7 +111,7 @@ export class MacOsSandboxManager implements SandboxManager {
|
|||||||
false,
|
false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sandboxArgs = await buildSeatbeltArgs({
|
const sandboxArgs = buildSeatbeltArgs({
|
||||||
workspace: this.options.workspace,
|
workspace: this.options.workspace,
|
||||||
allowedPaths: [...(req.policy?.allowedPaths || [])],
|
allowedPaths: [...(req.policy?.allowedPaths || [])],
|
||||||
forbiddenPaths: req.policy?.forbiddenPaths,
|
forbiddenPaths: req.policy?.forbiddenPaths,
|
||||||
|
|||||||
@@ -3,25 +3,31 @@
|
|||||||
* Copyright 2026 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||||
import * as sandboxManager from '../../services/sandboxManager.js';
|
import * as fsUtils from '../utils/fsUtils.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
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', () => {
|
describe('seatbeltArgsBuilder', () => {
|
||||||
beforeEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildSeatbeltArgs', () => {
|
describe('buildSeatbeltArgs', () => {
|
||||||
it('should build a strict allowlist profile allowing the workspace via param', async () => {
|
it('should build a strict allowlist profile allowing the workspace via param', () => {
|
||||||
// Mock tryRealpath to just return the path for testing
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
|
||||||
async (p) => p,
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/Users/test/workspace',
|
workspace: '/Users/test/workspace',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,11 +44,9 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
|
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow network when networkAccess is true', async () => {
|
it('should allow network when networkAccess is true', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||||
async (p) => p,
|
const args = buildSeatbeltArgs({
|
||||||
);
|
|
||||||
const args = await buildSeatbeltArgs({
|
|
||||||
workspace: '/test',
|
workspace: '/test',
|
||||||
networkAccess: true,
|
networkAccess: true,
|
||||||
});
|
});
|
||||||
@@ -51,10 +55,8 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('governance files', () => {
|
describe('governance files', () => {
|
||||||
it('should inject explicit deny rules for governance files', async () => {
|
it('should inject explicit deny rules for governance files', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p.toString());
|
||||||
p.toString(),
|
|
||||||
);
|
|
||||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||||
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -64,35 +66,29 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
}) as unknown as fs.Stats,
|
}) as unknown as fs.Stats,
|
||||||
);
|
);
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/Users/test/workspace',
|
workspace: '/test/workspace',
|
||||||
});
|
});
|
||||||
const profile = args[1];
|
const profile = args[1];
|
||||||
|
|
||||||
// .gitignore should be a literal deny
|
|
||||||
expect(args).toContain('-D');
|
expect(args).toContain('-D');
|
||||||
expect(args).toContain(
|
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
|
||||||
'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore',
|
|
||||||
);
|
|
||||||
expect(profile).toContain(
|
expect(profile).toContain(
|
||||||
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
|
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
|
||||||
);
|
);
|
||||||
|
|
||||||
// .git should be a subpath deny
|
expect(args).toContain('GOVERNANCE_FILE_2=/test/workspace/.git');
|
||||||
expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git');
|
|
||||||
expect(profile).toContain(
|
expect(profile).toContain(
|
||||||
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
|
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should protect both the symlink and the real path if they differ', async () => {
|
it('should protect both the symlink and the real path if they differ', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
|
||||||
async (p) => {
|
|
||||||
if (p === '/test/workspace/.gitignore')
|
if (p === '/test/workspace/.gitignore')
|
||||||
return '/test/real/.gitignore';
|
return '/test/real/.gitignore';
|
||||||
return p.toString();
|
return p.toString();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||||
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@@ -102,7 +98,7 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
}) as unknown as fs.Stats,
|
}) as unknown as fs.Stats,
|
||||||
);
|
);
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({ workspace: '/test/workspace' });
|
const args = buildSeatbeltArgs({ workspace: '/test/workspace' });
|
||||||
const profile = args[1];
|
const profile = args[1];
|
||||||
|
|
||||||
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
|
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
|
||||||
@@ -117,15 +113,13 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('allowedPaths', () => {
|
describe('allowedPaths', () => {
|
||||||
it('should parameterize allowed paths and normalize them', async () => {
|
it('should parameterize allowed paths and normalize them', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
|
||||||
async (p) => {
|
|
||||||
if (p === '/test/symlink') return '/test/real_path';
|
if (p === '/test/symlink') return '/test/real_path';
|
||||||
return p;
|
return p;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/test',
|
workspace: '/test',
|
||||||
allowedPaths: ['/custom/path1', '/test/symlink'],
|
allowedPaths: ['/custom/path1', '/test/symlink'],
|
||||||
});
|
});
|
||||||
@@ -141,12 +135,10 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('forbiddenPaths', () => {
|
describe('forbiddenPaths', () => {
|
||||||
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
it('should parameterize forbidden paths and explicitly deny them', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||||
async (p) => p,
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/test',
|
workspace: '/test',
|
||||||
forbiddenPaths: ['/secret/path'],
|
forbiddenPaths: ['/secret/path'],
|
||||||
});
|
});
|
||||||
@@ -161,22 +153,21 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves forbidden symlink paths to their real paths', async () => {
|
it('resolves forbidden symlink paths to their real paths', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
|
||||||
async (p) => {
|
if (p === '/test/symlink' || p === '/test/missing-dir') {
|
||||||
if (p === '/test/symlink') return '/test/real_path';
|
return '/test/real_path';
|
||||||
|
}
|
||||||
return p;
|
return p;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/test',
|
workspace: '/test',
|
||||||
forbiddenPaths: ['/test/symlink'],
|
forbiddenPaths: ['/test/symlink'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const profile = args[1];
|
const profile = args[1];
|
||||||
|
|
||||||
// The builder should resolve the symlink and explicitly deny the real target path
|
|
||||||
expect(args).toContain('-D');
|
expect(args).toContain('-D');
|
||||||
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
|
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
|
||||||
expect(profile).toContain(
|
expect(profile).toContain(
|
||||||
@@ -184,12 +175,10 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
it('explicitly denies non-existent forbidden paths to prevent creation', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||||
async (p) => p,
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/test',
|
workspace: '/test',
|
||||||
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
|
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 () => {
|
it('should override allowed paths if a path is also in forbidden paths', () => {
|
||||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||||
async (p) => p,
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = await buildSeatbeltArgs({
|
const args = buildSeatbeltArgs({
|
||||||
workspace: '/test',
|
workspace: '/test',
|
||||||
allowedPaths: ['/custom/path1'],
|
allowedPaths: ['/custom/path1'],
|
||||||
forbiddenPaths: ['/custom/path1'],
|
forbiddenPaths: ['/custom/path1'],
|
||||||
@@ -226,8 +213,6 @@ describe('seatbeltArgsBuilder', () => {
|
|||||||
expect(profile).toContain(allowString);
|
expect(profile).toContain(allowString);
|
||||||
expect(profile).toContain(denyString);
|
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 allowIndex = profile.indexOf(allowString);
|
||||||
const denyIndex = profile.indexOf(denyString);
|
const denyIndex = profile.indexOf(denyString);
|
||||||
expect(denyIndex).toBeGreaterThan(allowIndex);
|
expect(denyIndex).toBeGreaterThan(allowIndex);
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
type SandboxPermissions,
|
type SandboxPermissions,
|
||||||
sanitizePaths,
|
sanitizePaths,
|
||||||
GOVERNANCE_FILES,
|
GOVERNANCE_FILES,
|
||||||
tryRealpath,
|
|
||||||
} from '../../services/sandboxManager.js';
|
} from '../../services/sandboxManager.js';
|
||||||
|
import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for building macOS Seatbelt arguments.
|
* 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', ...])
|
* 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.
|
* Does not include the final '--' separator or the command to run.
|
||||||
*/
|
*/
|
||||||
export async function buildSeatbeltArgs(
|
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||||
options: SeatbeltArgsOptions,
|
|
||||||
): Promise<string[]> {
|
|
||||||
let profile = BASE_SEATBELT_PROFILE + '\n';
|
let profile = BASE_SEATBELT_PROFILE + '\n';
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
const workspacePath = await tryRealpath(options.workspace);
|
const workspacePath = tryRealpath(options.workspace);
|
||||||
args.push('-D', `WORKSPACE=${workspacePath}`);
|
args.push('-D', `WORKSPACE=${workspacePath}`);
|
||||||
args.push('-D', `WORKSPACE_RAW=${options.workspace}`);
|
args.push('-D', `WORKSPACE_RAW=${options.workspace}`);
|
||||||
profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`;
|
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).
|
// (Seatbelt evaluates rules in order, later rules win for same path).
|
||||||
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
|
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
|
||||||
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
|
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).
|
// Determine if it should be treated as a directory (subpath) or a file (literal).
|
||||||
// .git is generally a directory, while ignore files are literals.
|
// .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
|
// Auto-detect and support git worktrees by granting read and write access to the underlying git directory
|
||||||
try {
|
const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath);
|
||||||
const gitPath = path.join(workspacePath, '.git');
|
if (worktreeGitDir) {
|
||||||
const gitStat = fs.lstatSync(gitPath);
|
args.push('-D', `WORKTREE_GIT_DIR=${worktreeGitDir}`);
|
||||||
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`;
|
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)
|
if (mainGitDir) {
|
||||||
// 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}`);
|
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
|
||||||
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
|
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 tmpPath = await tryRealpath(os.tmpdir());
|
const tmpPath = tryRealpath(os.tmpdir());
|
||||||
args.push('-D', `TMPDIR=${tmpPath}`);
|
args.push('-D', `TMPDIR=${tmpPath}`);
|
||||||
|
|
||||||
const nodeRootPath = await tryRealpath(
|
const nodeRootPath = tryRealpath(
|
||||||
path.dirname(path.dirname(process.execPath)),
|
path.dirname(path.dirname(process.execPath)),
|
||||||
);
|
);
|
||||||
args.push('-D', `NODE_ROOT=${nodeRootPath}`);
|
args.push('-D', `NODE_ROOT=${nodeRootPath}`);
|
||||||
@@ -142,7 +118,7 @@ export async function buildSeatbeltArgs(
|
|||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
if (!p.trim()) continue;
|
if (!p.trim()) continue;
|
||||||
try {
|
try {
|
||||||
let resolved = await tryRealpath(p);
|
let resolved = tryRealpath(p);
|
||||||
|
|
||||||
// If this is a 'bin' directory (like /usr/local/bin or homebrew/bin),
|
// If this is a 'bin' directory (like /usr/local/bin or homebrew/bin),
|
||||||
// also grant read access to its parent directory so that symlinked
|
// also grant read access to its parent directory so that symlinked
|
||||||
@@ -165,8 +141,10 @@ export async function buildSeatbeltArgs(
|
|||||||
|
|
||||||
// Handle allowedPaths
|
// Handle allowedPaths
|
||||||
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
|
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
|
||||||
|
const resolvedAllowedPaths: string[] = [];
|
||||||
for (let i = 0; i < allowedPaths.length; i++) {
|
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}`);
|
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
|
||||||
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
|
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;
|
const { read, write } = options.additionalPermissions.fileSystem;
|
||||||
if (read) {
|
if (read) {
|
||||||
for (let i = 0; i < read.length; i++) {
|
for (let i = 0; i < read.length; i++) {
|
||||||
const resolved = await tryRealpath(read[i]);
|
const resolved = tryRealpath(read[i]);
|
||||||
const paramName = `ADDITIONAL_READ_${i}`;
|
const paramName = `ADDITIONAL_READ_${i}`;
|
||||||
args.push('-D', `${paramName}=${resolved}`);
|
args.push('-D', `${paramName}=${resolved}`);
|
||||||
let isFile = false;
|
let isFile = false;
|
||||||
@@ -194,7 +172,7 @@ export async function buildSeatbeltArgs(
|
|||||||
}
|
}
|
||||||
if (write) {
|
if (write) {
|
||||||
for (let i = 0; i < write.length; i++) {
|
for (let i = 0; i < write.length; i++) {
|
||||||
const resolved = await tryRealpath(write[i]);
|
const resolved = tryRealpath(write[i]);
|
||||||
const paramName = `ADDITIONAL_WRITE_${i}`;
|
const paramName = `ADDITIONAL_WRITE_${i}`;
|
||||||
args.push('-D', `${paramName}=${resolved}`);
|
args.push('-D', `${paramName}=${resolved}`);
|
||||||
let isFile = false;
|
let isFile = false;
|
||||||
@@ -215,7 +193,7 @@ export async function buildSeatbeltArgs(
|
|||||||
// Handle forbiddenPaths
|
// Handle forbiddenPaths
|
||||||
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
|
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
|
||||||
for (let i = 0; i < forbiddenPaths.length; i++) {
|
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}`);
|
args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`);
|
||||||
profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`;
|
profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ describe('WindowsSandboxManager', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await expect(planManager.prepareCommand(req)).rejects.toThrow(
|
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.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
isStrictlyApproved,
|
isStrictlyApproved,
|
||||||
} from './commandSafety.js';
|
} from './commandSafety.js';
|
||||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||||
|
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -214,17 +215,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
|||||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||||
|
|
||||||
// Reject override attempts in plan mode
|
// Reject override attempts in plan mode
|
||||||
if (!allowOverrides && req.policy?.additionalPermissions) {
|
verifySandboxOverrides(allowOverrides, req.policy);
|
||||||
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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch persistent approvals for this command
|
// Fetch persistent approvals for this command
|
||||||
const commandName = await getCommandName(req.command, req.args);
|
const commandName = await getCommandName(req.command, req.args);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import path from 'node:path';
|
|||||||
import {
|
import {
|
||||||
isKnownSafeCommand as isMacSafeCommand,
|
isKnownSafeCommand as isMacSafeCommand,
|
||||||
isDangerousCommand as isMacDangerousCommand,
|
isDangerousCommand as isMacDangerousCommand,
|
||||||
} from '../sandbox/macos/commandSafety.js';
|
} from '../sandbox/utils/commandSafety.js';
|
||||||
import {
|
import {
|
||||||
isKnownSafeCommand as isWindowsSafeCommand,
|
isKnownSafeCommand as isWindowsSafeCommand,
|
||||||
isDangerousCommand as isWindowsDangerousCommand,
|
isDangerousCommand as isWindowsDangerousCommand,
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ export function createSandboxManager(
|
|||||||
policyManager,
|
policyManager,
|
||||||
});
|
});
|
||||||
} else if (os.platform() === 'linux') {
|
} else if (os.platform() === 'linux') {
|
||||||
return new LinuxSandboxManager({ workspace });
|
return new LinuxSandboxManager({
|
||||||
|
workspace,
|
||||||
|
modeConfig,
|
||||||
|
policyManager,
|
||||||
|
});
|
||||||
} else if (os.platform() === 'darwin') {
|
} else if (os.platform() === 'darwin') {
|
||||||
return new MacOsSandboxManager({
|
return new MacOsSandboxManager({
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
Reference in New Issue
Block a user