mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
refactor(core): use centralized path resolution for Linux sandbox (#24985)
This commit is contained in:
@@ -131,6 +131,25 @@ describe('LinuxSandboxManager', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows virtual commands targeting includeDirectories', async () => {
|
||||||
|
const includeDir = '/opt/tools';
|
||||||
|
const testFile = path.join(includeDir, 'tool.sh');
|
||||||
|
const customManager = new LinuxSandboxManager({
|
||||||
|
workspace,
|
||||||
|
includeDirectories: [includeDir],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await customManager.prepareCommand({
|
||||||
|
command: '__read',
|
||||||
|
args: [testFile],
|
||||||
|
cwd: workspace,
|
||||||
|
env: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.args[result.args.length - 2]).toBe('/bin/cat');
|
||||||
|
expect(result.args[result.args.length - 1]).toBe(testFile);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects overrides in plan mode', async () => {
|
it('rejects overrides in plan mode', async () => {
|
||||||
const customManager = new LinuxSandboxManager({
|
const customManager = new LinuxSandboxManager({
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
@@ -240,7 +240,10 @@ export class LinuxSandboxManager implements SandboxManager {
|
|||||||
req,
|
req,
|
||||||
mergedAdditional,
|
mergedAdditional,
|
||||||
this.options.workspace,
|
this.options.workspace,
|
||||||
req.policy?.allowedPaths,
|
[
|
||||||
|
...(req.policy?.allowedPaths || []),
|
||||||
|
...(this.options.includeDirectories || []),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sanitizationConfig = getSecureSanitizationConfig(
|
const sanitizationConfig = getSecureSanitizationConfig(
|
||||||
@@ -261,13 +264,9 @@ export class LinuxSandboxManager implements SandboxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bwrapArgs = await buildBwrapArgs({
|
const bwrapArgs = await buildBwrapArgs({
|
||||||
workspace: this.options.workspace,
|
resolvedPaths,
|
||||||
workspaceWrite,
|
workspaceWrite,
|
||||||
networkAccess,
|
networkAccess: mergedAdditional.network ?? false,
|
||||||
allowedPaths: resolvedPaths.policyAllowed,
|
|
||||||
forbiddenPaths: resolvedPaths.forbidden,
|
|
||||||
additionalPermissions: mergedAdditional,
|
|
||||||
includeDirectories: this.options.includeDirectories || [],
|
|
||||||
maskFilePath: this.getMaskFilePath(),
|
maskFilePath: this.getMaskFilePath(),
|
||||||
isWriteCommand: req.command === '__write',
|
isWriteCommand: req.command === '__write',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { buildBwrapArgs, type BwrapArgsOptions } from './bwrapArgsBuilder.js';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import * as shellUtils from '../../utils/shell-utils.js';
|
import * as shellUtils from '../../utils/shell-utils.js';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import { type ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||||
|
|
||||||
vi.mock('node:fs', async () => {
|
vi.mock('node:fs', async () => {
|
||||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||||
@@ -61,6 +62,21 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
|||||||
describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
||||||
const workspace = '/home/user/workspace';
|
const workspace = '/home/user/workspace';
|
||||||
|
|
||||||
|
const createResolvedPaths = (
|
||||||
|
overrides: Partial<ResolvedSandboxPaths> = {},
|
||||||
|
): ResolvedSandboxPaths => ({
|
||||||
|
workspace: {
|
||||||
|
original: workspace,
|
||||||
|
resolved: workspace,
|
||||||
|
},
|
||||||
|
forbidden: [],
|
||||||
|
globalIncludes: [],
|
||||||
|
policyAllowed: [],
|
||||||
|
policyRead: [],
|
||||||
|
policyWrite: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
@@ -72,13 +88,9 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const defaultOptions: BwrapArgsOptions = {
|
const defaultOptions: BwrapArgsOptions = {
|
||||||
workspace,
|
resolvedPaths: createResolvedPaths(),
|
||||||
workspaceWrite: false,
|
workspaceWrite: false,
|
||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
allowedPaths: [],
|
|
||||||
forbiddenPaths: [],
|
|
||||||
additionalPermissions: {},
|
|
||||||
includeDirectories: [],
|
|
||||||
maskFilePath: '/tmp/mask',
|
maskFilePath: '/tmp/mask',
|
||||||
isWriteCommand: false,
|
isWriteCommand: false,
|
||||||
};
|
};
|
||||||
@@ -137,9 +149,9 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
it('maps explicit write permissions to --bind-try', async () => {
|
it('maps explicit write permissions to --bind-try', async () => {
|
||||||
const args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
additionalPermissions: {
|
resolvedPaths: createResolvedPaths({
|
||||||
fileSystem: { write: ['/home/user/workspace/out/dir'] },
|
policyWrite: ['/home/user/workspace/out/dir'],
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const index = args.indexOf('--bind-try');
|
const index = args.indexOf('--bind-try');
|
||||||
@@ -148,23 +160,27 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should protect both the symlink and the real path of governance files', async () => {
|
it('should protect both the symlink and the real path of governance files', async () => {
|
||||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
const args = await buildBwrapArgs({
|
||||||
if (p.toString() === `${workspace}/.gitignore`)
|
...defaultOptions,
|
||||||
return '/shared/global.gitignore';
|
resolvedPaths: createResolvedPaths({
|
||||||
return p.toString();
|
workspace: {
|
||||||
|
original: workspace,
|
||||||
|
resolved: '/shared/global-workspace',
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const args = await buildBwrapArgs(defaultOptions);
|
|
||||||
|
|
||||||
expect(args).toContain('--ro-bind');
|
expect(args).toContain('--ro-bind');
|
||||||
expect(args).toContain(`${workspace}/.gitignore`);
|
expect(args).toContain(`${workspace}/.gitignore`);
|
||||||
expect(args).toContain('/shared/global.gitignore');
|
expect(args).toContain('/shared/global-workspace/.gitignore');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parameterize allowed paths and normalize them', async () => {
|
it('should parameterize allowed paths', async () => {
|
||||||
const args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
|
resolvedPaths: createResolvedPaths({
|
||||||
|
policyAllowed: ['/tmp/cache', '/opt/tools'],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(args).toContain('--bind-try');
|
expect(args).toContain('--bind-try');
|
||||||
@@ -180,7 +196,9 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
|
|
||||||
const args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
allowedPaths: ['/home/user/workspace/new-file.txt'],
|
resolvedPaths: createResolvedPaths({
|
||||||
|
policyAllowed: ['/home/user/workspace/new-file.txt'],
|
||||||
|
}),
|
||||||
isWriteCommand: true,
|
isWriteCommand: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,7 +218,9 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
|
|
||||||
const args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
|
resolvedPaths: createResolvedPaths({
|
||||||
|
forbidden: ['/tmp/cache', '/opt/secret.txt'],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cacheIndex = args.indexOf('/tmp/cache');
|
const cacheIndex = args.indexOf('/tmp/cache');
|
||||||
@@ -211,18 +231,16 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
expect(args[secretIndex - 1]).toBe('/dev/null');
|
expect(args[secretIndex - 1]).toBe('/dev/null');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves forbidden symlink paths to their real paths', async () => {
|
it('handles resolved forbidden paths', async () => {
|
||||||
vi.mocked(fs.statSync).mockImplementation(
|
vi.mocked(fs.statSync).mockImplementation(
|
||||||
() => ({ isDirectory: () => false }) as fs.Stats,
|
() => ({ 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 args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
forbiddenPaths: ['/tmp/forbidden-symlink'],
|
resolvedPaths: createResolvedPaths({
|
||||||
|
forbidden: ['/opt/real-target.txt'],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const secretIndex = args.indexOf('/opt/real-target.txt');
|
const secretIndex = args.indexOf('/opt/real-target.txt');
|
||||||
@@ -230,33 +248,33 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
expect(args[secretIndex - 1]).toBe('/dev/null');
|
expect(args[secretIndex - 1]).toBe('/dev/null');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('masks directory symlinks with tmpfs for both paths', async () => {
|
it('masks directory paths with tmpfs', async () => {
|
||||||
vi.mocked(fs.statSync).mockImplementation(
|
vi.mocked(fs.statSync).mockImplementation(
|
||||||
() => ({ isDirectory: () => true }) as fs.Stats,
|
() => ({ isDirectory: () => true }) as fs.Stats,
|
||||||
);
|
);
|
||||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
|
||||||
if (p === '/tmp/dir-link') return '/opt/real-dir';
|
|
||||||
return p.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
const args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
forbiddenPaths: ['/tmp/dir-link'],
|
resolvedPaths: createResolvedPaths({
|
||||||
|
forbidden: ['/opt/real-dir'],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const idx = args.indexOf('/opt/real-dir');
|
const idx = args.indexOf('/opt/real-dir');
|
||||||
expect(args[idx - 1]).toBe('--tmpfs');
|
expect(args[idx - 1]).toBe('--tmpfs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
it('should apply forbidden paths after allowed paths', async () => {
|
||||||
vi.mocked(fs.statSync).mockImplementation(
|
vi.mocked(fs.statSync).mockImplementation(
|
||||||
() => ({ isDirectory: () => true }) as fs.Stats,
|
() => ({ isDirectory: () => true }) as fs.Stats,
|
||||||
);
|
);
|
||||||
|
|
||||||
const args = await buildBwrapArgs({
|
const args = await buildBwrapArgs({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
forbiddenPaths: ['/tmp/conflict'],
|
resolvedPaths: createResolvedPaths({
|
||||||
allowedPaths: ['/tmp/conflict'],
|
policyAllowed: ['/tmp/conflict'],
|
||||||
|
forbidden: ['/tmp/conflict'],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const bindIndex = args.findIndex(
|
const bindIndex = args.findIndex(
|
||||||
@@ -294,4 +312,31 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
|||||||
expect(args[envIndex - 2]).toBe('--bind');
|
expect(args[envIndex - 2]).toBe('--bind');
|
||||||
expect(args[envIndex - 1]).toBe('/tmp/mask');
|
expect(args[envIndex - 1]).toBe('/tmp/mask');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scans globalIncludes for secret files', async () => {
|
||||||
|
const includeDir = '/opt/tools';
|
||||||
|
vi.mocked(shellUtils.spawnAsync).mockImplementation((cmd, args) => {
|
||||||
|
if (cmd === 'find' && args?.[0] === includeDir) {
|
||||||
|
return Promise.resolve({
|
||||||
|
status: 0,
|
||||||
|
stdout: Buffer.from(`${includeDir}/.env\0`),
|
||||||
|
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
status: 0,
|
||||||
|
stdout: Buffer.from(''),
|
||||||
|
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = await buildBwrapArgs({
|
||||||
|
...defaultOptions,
|
||||||
|
resolvedPaths: createResolvedPaths({
|
||||||
|
globalIncludes: [includeDir],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).toContain(`${includeDir}/.env`);
|
||||||
|
const envIndex = args.indexOf(`${includeDir}/.env`);
|
||||||
|
expect(args[envIndex - 2]).toBe('--bind');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,18 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { join, dirname, normalize } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import {
|
import {
|
||||||
type SandboxPermissions,
|
|
||||||
GOVERNANCE_FILES,
|
GOVERNANCE_FILES,
|
||||||
getSecretFileFindArgs,
|
getSecretFileFindArgs,
|
||||||
sanitizePaths,
|
type ResolvedSandboxPaths,
|
||||||
} from '../../services/sandboxManager.js';
|
} from '../../services/sandboxManager.js';
|
||||||
import {
|
import { resolveGitWorktreePaths, isErrnoException } from '../utils/fsUtils.js';
|
||||||
tryRealpath,
|
|
||||||
resolveGitWorktreePaths,
|
|
||||||
isErrnoException,
|
|
||||||
} from '../utils/fsUtils.js';
|
|
||||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||||
import { debugLogger } from '../../utils/debugLogger.js';
|
import { debugLogger } from '../../utils/debugLogger.js';
|
||||||
|
|
||||||
@@ -24,13 +19,9 @@ import { debugLogger } from '../../utils/debugLogger.js';
|
|||||||
* Options for building bubblewrap (bwrap) arguments.
|
* Options for building bubblewrap (bwrap) arguments.
|
||||||
*/
|
*/
|
||||||
export interface BwrapArgsOptions {
|
export interface BwrapArgsOptions {
|
||||||
workspace: string;
|
resolvedPaths: ResolvedSandboxPaths;
|
||||||
workspaceWrite: boolean;
|
workspaceWrite: boolean;
|
||||||
networkAccess: boolean;
|
networkAccess: boolean;
|
||||||
allowedPaths: string[];
|
|
||||||
forbiddenPaths: string[];
|
|
||||||
additionalPermissions: SandboxPermissions;
|
|
||||||
includeDirectories: string[];
|
|
||||||
maskFilePath: string;
|
maskFilePath: string;
|
||||||
isWriteCommand: boolean;
|
isWriteCommand: boolean;
|
||||||
}
|
}
|
||||||
@@ -41,13 +32,22 @@ export interface BwrapArgsOptions {
|
|||||||
export async function buildBwrapArgs(
|
export async function buildBwrapArgs(
|
||||||
options: BwrapArgsOptions,
|
options: BwrapArgsOptions,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
|
const {
|
||||||
|
resolvedPaths,
|
||||||
|
workspaceWrite,
|
||||||
|
networkAccess,
|
||||||
|
maskFilePath,
|
||||||
|
isWriteCommand,
|
||||||
|
} = options;
|
||||||
|
const { workspace } = resolvedPaths;
|
||||||
|
|
||||||
const bwrapArgs: string[] = [
|
const bwrapArgs: string[] = [
|
||||||
'--unshare-all',
|
'--unshare-all',
|
||||||
'--new-session', // Isolate session
|
'--new-session', // Isolate session
|
||||||
'--die-with-parent', // Prevent orphaned runaway processes
|
'--die-with-parent', // Prevent orphaned runaway processes
|
||||||
];
|
];
|
||||||
|
|
||||||
if (options.networkAccess || options.additionalPermissions.network) {
|
if (networkAccess) {
|
||||||
bwrapArgs.push('--share-net');
|
bwrapArgs.push('--share-net');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,23 +63,16 @@ export async function buildBwrapArgs(
|
|||||||
'/tmp',
|
'/tmp',
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspacePath = tryRealpath(options.workspace);
|
const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try';
|
||||||
|
|
||||||
const bindFlag = options.workspaceWrite ? '--bind-try' : '--ro-bind-try';
|
bwrapArgs.push(bindFlag, workspace.original, workspace.original);
|
||||||
|
if (workspace.resolved !== workspace.original) {
|
||||||
if (options.workspaceWrite) {
|
bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved);
|
||||||
bwrapArgs.push('--bind-try', options.workspace, options.workspace);
|
|
||||||
if (workspacePath !== options.workspace) {
|
|
||||||
bwrapArgs.push('--bind-try', workspacePath, workspacePath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bwrapArgs.push('--ro-bind-try', options.workspace, options.workspace);
|
|
||||||
if (workspacePath !== options.workspace) {
|
|
||||||
bwrapArgs.push('--ro-bind-try', workspacePath, workspacePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath);
|
const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(
|
||||||
|
workspace.resolved,
|
||||||
|
);
|
||||||
if (worktreeGitDir) {
|
if (worktreeGitDir) {
|
||||||
bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir);
|
bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir);
|
||||||
}
|
}
|
||||||
@@ -87,110 +80,62 @@ export async function buildBwrapArgs(
|
|||||||
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
|
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
const includeDirs = sanitizePaths(options.includeDirectories);
|
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||||
for (const includeDir of includeDirs) {
|
bwrapArgs.push('--ro-bind-try', includeDir, includeDir);
|
||||||
try {
|
|
||||||
const resolved = tryRealpath(includeDir);
|
|
||||||
bwrapArgs.push('--ro-bind-try', resolved, resolved);
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, '');
|
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||||
for (const allowedPath of options.allowedPaths) {
|
if (fs.existsSync(allowedPath)) {
|
||||||
const resolved = tryRealpath(allowedPath);
|
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
|
||||||
if (!fs.existsSync(resolved)) {
|
} else {
|
||||||
// If the path doesn't exist, we still want to allow access to its parent
|
// If the path doesn't exist, we still want to allow access to its parent
|
||||||
// if it's explicitly allowed, to enable creating it.
|
// to enable creating it. Since allowedPath is already resolved by resolveSandboxPaths,
|
||||||
try {
|
// its parent is also correctly resolved.
|
||||||
const resolvedParent = tryRealpath(dirname(resolved));
|
const parent = dirname(allowedPath);
|
||||||
bwrapArgs.push(
|
bwrapArgs.push(isWriteCommand ? '--bind-try' : bindFlag, parent, parent);
|
||||||
options.isWriteCommand ? '--bind-try' : bindFlag,
|
|
||||||
resolvedParent,
|
|
||||||
resolvedParent,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalizedAllowedPath = normalize(resolved).replace(/\/$/, '');
|
|
||||||
if (normalizedAllowedPath !== normalizedWorkspace) {
|
|
||||||
bwrapArgs.push('--bind-try', resolved, resolved);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalReads = sanitizePaths(
|
for (const p of resolvedPaths.policyRead) {
|
||||||
options.additionalPermissions.fileSystem?.read,
|
bwrapArgs.push('--ro-bind-try', p, p);
|
||||||
);
|
|
||||||
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(
|
for (const p of resolvedPaths.policyWrite) {
|
||||||
options.additionalPermissions.fileSystem?.write,
|
bwrapArgs.push('--bind-try', p, p);
|
||||||
);
|
|
||||||
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) {
|
for (const file of GOVERNANCE_FILES) {
|
||||||
const filePath = join(options.workspace, file.path);
|
const filePath = join(workspace.original, file.path);
|
||||||
const realPath = tryRealpath(filePath);
|
const realPath = join(workspace.resolved, file.path);
|
||||||
bwrapArgs.push('--ro-bind', filePath, filePath);
|
bwrapArgs.push('--ro-bind', filePath, filePath);
|
||||||
if (realPath !== filePath) {
|
if (realPath !== filePath) {
|
||||||
bwrapArgs.push('--ro-bind', realPath, realPath);
|
bwrapArgs.push('--ro-bind', realPath, realPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const p of options.forbiddenPaths) {
|
for (const p of resolvedPaths.forbidden) {
|
||||||
let resolved: string;
|
if (!fs.existsSync(p)) continue;
|
||||||
try {
|
try {
|
||||||
resolved = tryRealpath(p); // Forbidden paths should still resolve to block the real path
|
const stat = fs.statSync(p);
|
||||||
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()) {
|
if (stat.isDirectory()) {
|
||||||
bwrapArgs.push('--tmpfs', resolved, '--remount-ro', resolved);
|
bwrapArgs.push('--tmpfs', p, '--remount-ro', p);
|
||||||
} else {
|
} else {
|
||||||
bwrapArgs.push('--ro-bind', '/dev/null', resolved);
|
bwrapArgs.push('--ro-bind', '/dev/null', p);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (isErrnoException(e) && e.code === 'ENOENT') {
|
if (isErrnoException(e) && e.code === 'ENOENT') {
|
||||||
bwrapArgs.push('--symlink', '/dev/null', resolved);
|
bwrapArgs.push('--symlink', '/dev/null', p);
|
||||||
} else {
|
} else {
|
||||||
debugLogger.warn(
|
debugLogger.warn(
|
||||||
`Failed to stat forbidden path ${resolved}: ${e instanceof Error ? e.message : String(e)}`,
|
`Failed to secure forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
);
|
);
|
||||||
bwrapArgs.push('--ro-bind', '/dev/null', resolved);
|
bwrapArgs.push('--ro-bind', '/dev/null', p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mask secret files (.env, .env.*)
|
// Mask secret files (.env, .env.*)
|
||||||
const secretArgs = await getSecretFilesArgs(
|
const secretArgs = await getSecretFilesArgs(resolvedPaths, maskFilePath);
|
||||||
options.workspace,
|
|
||||||
options.allowedPaths,
|
|
||||||
options.maskFilePath,
|
|
||||||
);
|
|
||||||
bwrapArgs.push(...secretArgs);
|
bwrapArgs.push(...secretArgs);
|
||||||
|
|
||||||
return bwrapArgs;
|
return bwrapArgs;
|
||||||
@@ -200,12 +145,16 @@ export async function buildBwrapArgs(
|
|||||||
* Generates bubblewrap arguments to mask secret files.
|
* Generates bubblewrap arguments to mask secret files.
|
||||||
*/
|
*/
|
||||||
async function getSecretFilesArgs(
|
async function getSecretFilesArgs(
|
||||||
workspace: string,
|
resolvedPaths: ResolvedSandboxPaths,
|
||||||
allowedPaths: string[],
|
|
||||||
maskPath: string,
|
maskPath: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
const searchDirs = new Set([workspace, ...allowedPaths]);
|
const searchDirs = new Set([
|
||||||
|
resolvedPaths.workspace.original,
|
||||||
|
resolvedPaths.workspace.resolved,
|
||||||
|
...resolvedPaths.policyAllowed,
|
||||||
|
...resolvedPaths.globalIncludes,
|
||||||
|
]);
|
||||||
const findPatterns = getSecretFileFindArgs();
|
const findPatterns = getSecretFileFindArgs();
|
||||||
|
|
||||||
for (const dir of searchDirs) {
|
for (const dir of searchDirs) {
|
||||||
|
|||||||
Reference in New Issue
Block a user