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

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-03-25 18:58:45 -07:00
committed by GitHub
parent 2e03e3aed5
commit 30e0ab102a
13 changed files with 604 additions and 415 deletions
@@ -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(/\/$/, '');
}
}