refactor(core): use centralized path resolution for Linux sandbox (#24985)

This commit is contained in:
Emily Hedlund
2026-04-09 08:28:58 -07:00
committed by GitHub
parent 615e078341
commit 5724d6be0f
4 changed files with 157 additions and 145 deletions
@@ -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) {