mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 18:44:30 -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 { 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/\/$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user