refactor(core): standardize OS-specific sandbox tests and extract linux helper methods (#23715)

This commit is contained in:
Emily Hedlund
2026-03-24 22:37:32 -04:00
committed by GitHub
parent a6c7affedb
commit 5b7f7b30a7
6 changed files with 967 additions and 687 deletions
@@ -95,7 +95,8 @@ describe('LinuxSandboxManager', () => {
expect(dynamicBinds).toEqual(expectedDynamicBinds); expect(dynamicBinds).toEqual(expectedDynamicBinds);
}; };
it('correctly outputs bwrap as the program with appropriate isolation flags', async () => { describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const bwrapArgs = await getBwrapArgs({ const bwrapArgs = await getBwrapArgs({
command: 'ls', command: 'ls',
args: ['-la'], args: ['-la'],
@@ -136,29 +137,77 @@ describe('LinuxSandboxManager', () => {
]); ]);
}); });
it('maps allowedPaths to bwrap binds', async () => { it('should correctly pass through the cwd to the resulting command', async () => {
const req: SandboxRequest = {
command: 'ls',
args: [],
cwd: '/different/cwd',
env: {},
};
const result = await manager.prepareCommand(req);
expect(result.cwd).toBe('/different/cwd');
});
it('should apply environment sanitization via the default mechanisms', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: workspace,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
it('should allow network when networkAccess is true', async () => {
const bwrapArgs = await getBwrapArgs({ const bwrapArgs = await getBwrapArgs({
command: 'node', command: 'ls',
args: ['script.js'], args: ['-la'],
cwd: workspace, cwd: workspace,
env: {}, env: {},
policy: { policy: {
allowedPaths: ['/tmp/cache', '/opt/tools', workspace], networkAccess: true,
}, },
}); });
// Verify the specific bindings were added correctly expect(bwrapArgs).toContain('--unshare-user');
expectDynamicBinds(bwrapArgs, [ expect(bwrapArgs).toContain('--unshare-ipc');
'--bind-try', expect(bwrapArgs).toContain('--unshare-pid');
'/tmp/cache', expect(bwrapArgs).toContain('--unshare-uts');
'/tmp/cache', expect(bwrapArgs).toContain('--unshare-cgroup');
'--bind-try', expect(bwrapArgs).not.toContain('--unshare-all');
'/opt/tools',
'/opt/tools',
]);
}); });
it('protects real paths of governance files if they are symlinks', async () => { describe('governance files', () => {
it('should ensure governance files exist', async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
});
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.openSync).toHaveBeenCalled();
});
it('should protect both the symlink and the real path if they differ', async () => {
vi.mocked(fs.realpathSync).mockImplementation((p) => { vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p.toString() === `${workspace}/.gitignore`) if (p.toString() === `${workspace}/.gitignore`)
return '/shared/global.gitignore'; return '/shared/global.gitignore';
@@ -181,23 +230,37 @@ describe('LinuxSandboxManager', () => {
expect(bwrapArgs[gitignoreIndex - 1]).toBe('--ro-bind'); expect(bwrapArgs[gitignoreIndex - 1]).toBe('--ro-bind');
expect(bwrapArgs[gitignoreIndex + 1]).toBe(`${workspace}/.gitignore`); expect(bwrapArgs[gitignoreIndex + 1]).toBe(`${workspace}/.gitignore`);
const realGitignoreIndex = bwrapArgs.indexOf('/shared/global.gitignore'); const realGitignoreIndex = bwrapArgs.indexOf(
'/shared/global.gitignore',
);
expect(bwrapArgs[realGitignoreIndex - 1]).toBe('--ro-bind'); expect(bwrapArgs[realGitignoreIndex - 1]).toBe('--ro-bind');
expect(bwrapArgs[realGitignoreIndex + 1]).toBe('/shared/global.gitignore'); expect(bwrapArgs[realGitignoreIndex + 1]).toBe(
'/shared/global.gitignore',
);
});
}); });
it('touches governance files if they do not exist', async () => { describe('allowedPaths', () => {
vi.mocked(fs.existsSync).mockReturnValue(false); it('should parameterize allowed paths and normalize them', async () => {
const bwrapArgs = await getBwrapArgs({
await getBwrapArgs({ command: 'node',
command: 'ls', args: ['script.js'],
args: [],
cwd: workspace, cwd: workspace,
env: {}, env: {},
policy: {
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
},
}); });
expect(fs.mkdirSync).toHaveBeenCalled(); // Verify the specific bindings were added correctly
expect(fs.openSync).toHaveBeenCalled(); expectDynamicBinds(bwrapArgs, [
'--bind-try',
'/tmp/cache',
'/tmp/cache',
'--bind-try',
'/opt/tools',
'/opt/tools',
]);
}); });
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => { it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
@@ -214,8 +277,10 @@ describe('LinuxSandboxManager', () => {
// Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash // Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash
expectDynamicBinds(bwrapArgs, []); expectDynamicBinds(bwrapArgs, []);
}); });
});
it('maps forbiddenPaths to empty mounts', async () => { describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => { vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => {
// Mock /tmp/cache as a directory, and /opt/secret.txt as a file // Mock /tmp/cache as a directory, and /opt/secret.txt as a file
if (p.toString().includes('cache')) { if (p.toString().includes('cache')) {
@@ -248,44 +313,16 @@ describe('LinuxSandboxManager', () => {
]); ]);
}); });
it('overrides allowedPaths if a path is also in forbiddenPaths', async () => { it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => true }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
});
expectDynamicBinds(bwrapArgs, [
'--bind-try',
'/tmp/conflict',
'/tmp/conflict',
'--tmpfs',
'/tmp/conflict',
'--remount-ro',
'/tmp/conflict',
]);
});
it('protects both the resolved path and the original path for forbidden symlinks', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation( vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => false }) as fs.Stats, async () => ({ isDirectory: () => false }) as fs.Stats,
); );
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => { vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt'; if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
return p.toString(); return p.toString();
}); },
);
const bwrapArgs = await getBwrapArgs({ const bwrapArgs = await getBwrapArgs({
command: 'ls', command: 'ls',
@@ -308,7 +345,7 @@ describe('LinuxSandboxManager', () => {
]); ]);
}); });
it('masks non-existent forbidden paths with a broken symlink', async () => { it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException; const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT'; error.code = 'ENOENT';
vi.spyOn(fs.promises, 'stat').mockRejectedValue(error); vi.spyOn(fs.promises, 'stat').mockRejectedValue(error);
@@ -337,10 +374,12 @@ describe('LinuxSandboxManager', () => {
vi.spyOn(fs.promises, 'stat').mockImplementation( vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => true }) as fs.Stats, async () => ({ isDirectory: () => true }) as fs.Stats,
); );
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => { vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/tmp/dir-link') return '/opt/real-dir'; if (p === '/tmp/dir-link') return '/opt/real-dir';
return p.toString(); return p.toString();
}); },
);
const bwrapArgs = await getBwrapArgs({ const bwrapArgs = await getBwrapArgs({
command: 'ls', command: 'ls',
@@ -363,4 +402,36 @@ describe('LinuxSandboxManager', () => {
'/tmp/dir-link', '/tmp/dir-link',
]); ]);
}); });
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(),
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
});
expectDynamicBinds(bwrapArgs, [
'--bind-try',
'/tmp/conflict',
'/tmp/conflict',
'--tmpfs',
'/tmp/conflict',
'--remount-ro',
'/tmp/conflict',
]);
});
});
});
}); });
@@ -113,78 +113,13 @@ export class LinuxSandboxManager implements SandboxManager {
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const bwrapArgs: string[] = [ const bwrapArgs: string[] = [
...(req.policy?.networkAccess ...this.getNetworkArgs(req),
? [ ...this.getBaseArgs(),
'--unshare-user', ...this.getGovernanceArgs(),
'--unshare-ipc', ...this.getAllowedPathsArgs(req.policy?.allowedPaths),
'--unshare-pid', ...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)),
'--unshare-uts',
'--unshare-cgroup',
]
: ['--unshare-all']),
'--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,
]; ];
// 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);
bwrapArgs.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
bwrapArgs.push('--ro-bind', realPath, realPath);
}
}
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const normalizedWorkspace = this.normalizePath(this.options.workspace);
for (const p of allowedPaths) {
if (this.normalizePath(p) !== normalizedWorkspace) {
bwrapArgs.push('--bind-try', p, p);
}
}
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
for (const p of forbiddenPaths) {
try {
const originalPath = this.normalizePath(p);
const resolvedPath = await tryRealpath(originalPath);
// Mask the resolved path to prevent access to the underlying file.
await this.applyMasking(bwrapArgs, resolvedPath);
// If the original path was a symlink, mask it as well to prevent access
// through the link itself.
if (resolvedPath !== originalPath) {
await this.applyMasking(bwrapArgs, originalPath);
}
} catch (e) {
throw new Error(
`Failed to deny access to forbidden path: ${p}. ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
const bpfPath = getSeccompBpfPath(); const bpfPath = getSeccompBpfPath();
bwrapArgs.push('--seccomp', '9'); bwrapArgs.push('--seccomp', '9');
@@ -202,29 +137,139 @@ export class LinuxSandboxManager implements SandboxManager {
program: 'sh', program: 'sh',
args: shArgs, args: shArgs,
env: sanitizedEnv, env: sanitizedEnv,
cwd: req.cwd,
}; };
} }
/** /**
* Applies bubblewrap arguments to mask a forbidden path. * Generates arguments for network isolation.
*/ */
private async applyMasking(args: string[], path: string) { 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 { try {
const stats = await fs.promises.stat(path); const stats = await fs.promises.stat(path);
if (stats.isDirectory()) { if (stats.isDirectory()) {
// Directories are masked by mounting an empty, read-only tmpfs. // Directories are masked by mounting an empty, read-only tmpfs.
args.push('--tmpfs', path, '--remount-ro', path); return ['--tmpfs', path, '--remount-ro', path];
} else {
// Existing files are masked by binding them to /dev/null.
args.push('--ro-bind-try', '/dev/null', path);
} }
// Existing files are masked by binding them to /dev/null.
return ['--ro-bind-try', '/dev/null', path];
} catch (e) { } catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') { if (isNodeError(e) && e.code === 'ENOENT') {
// Non-existent paths are masked by a broken symlink. This prevents // Non-existent paths are masked by a broken symlink. This prevents
// creation within the sandbox while avoiding host remnants. // creation within the sandbox while avoiding host remnants.
args.push('--symlink', '/.forbidden', path); return ['--symlink', '/.forbidden', path];
return;
} }
throw e; throw e;
} }
@@ -55,7 +55,7 @@ describe('MacOsSandboxManager', () => {
}); });
describe('prepareCommand', () => { describe('prepareCommand', () => {
it('should correctly orchestrate Seatbelt args and format the final command', async () => { it('should correctly format the base command and args', async () => {
const result = await manager.prepareCommand({ const result = await manager.prepareCommand({
command: 'echo', command: 'echo',
args: ['hello'], args: ['hello'],
@@ -118,5 +118,119 @@ describe('MacOsSandboxManager', () => {
expect(result.env['SAFE_VAR']).toBe('1'); expect(result.env['SAFE_VAR']).toBe('1');
expect(result.env['GITHUB_TOKEN']).toBeUndefined(); expect(result.env['GITHUB_TOKEN']).toBeUndefined();
}); });
it('should allow network when networkAccess is true', async () => {
await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
policy: { ...mockPolicy, networkAccess: true },
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({ networkAccess: true }),
);
});
describe('governance files', () => {
it('should ensure governance files exist', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
// The seatbelt builder internally handles governance files, so we simply verify
// it is invoked correctly with the right workspace.
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({ workspace: mockWorkspace }),
);
});
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
allowedPaths: ['/tmp/allowed1', '/tmp/allowed2'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
allowedPaths: ['/tmp/allowed1', '/tmp/allowed2'],
}),
);
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
forbiddenPaths: ['/tmp/forbidden1'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
forbiddenPaths: ['/tmp/forbidden1'],
}),
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
forbiddenPaths: ['/tmp/does-not-exist'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
forbiddenPaths: ['/tmp/does-not-exist'],
}),
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
}),
);
});
});
}); });
}); });
@@ -14,9 +14,12 @@ describe('seatbeltArgsBuilder', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('buildSeatbeltArgs', () => {
it('should build a strict allowlist profile allowing the workspace via param', async () => { it('should build a strict allowlist profile allowing the workspace via param', async () => {
// Mock tryRealpath to just return the path for testing // Mock tryRealpath to just return the path for testing
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => p); vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({ const args = await buildSeatbeltArgs({
workspace: '/Users/test/workspace', workspace: '/Users/test/workspace',
@@ -36,7 +39,9 @@ describe('seatbeltArgsBuilder', () => {
}); });
it('should allow network when networkAccess is true', async () => { it('should allow network when networkAccess is true', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => p); vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({ const args = await buildSeatbeltArgs({
workspace: '/test', workspace: '/test',
networkAccess: true, networkAccess: true,
@@ -45,110 +50,6 @@ describe('seatbeltArgsBuilder', () => {
expect(profile).toContain('(allow network-outbound)'); expect(profile).toContain('(allow network-outbound)');
}); });
it('should parameterize allowed paths and normalize them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
});
const args = await buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
});
const profile = args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(args).toContain('-D');
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
});
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => p);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/secret/path'],
});
const profile = args[1];
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/secret/path');
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => p);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
});
const profile = args[1];
expect(args).toContain('-D');
expect(args).toContain(
'FORBIDDEN_PATH_0=/test/missing-dir/missing-file.txt',
);
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
});
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/symlink'],
});
const profile = args[1];
// The builder should resolve the symlink and explicitly deny the real target path
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => p);
const args = await buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1'],
forbiddenPaths: ['/custom/path1'],
});
const profile = args[1];
const allowString =
'(allow file-read* file-write* (subpath (param "ALLOWED_PATH_0")))';
const denyString =
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))';
expect(profile).toContain(allowString);
expect(profile).toContain(denyString);
// Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string
// Seatbelt rules are evaluated in order where the latest rule matching a path wins
const allowIndex = profile.indexOf(allowString);
const denyIndex = profile.indexOf(denyString);
expect(denyIndex).toBeGreaterThan(allowIndex);
});
describe('governance files', () => { describe('governance files', () => {
it('should inject explicit deny rules for governance files', async () => { it('should inject explicit deny rules for governance files', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
@@ -185,10 +86,13 @@ describe('seatbeltArgsBuilder', () => {
}); });
it('should protect both the symlink and the real path if they differ', async () => { it('should protect both the symlink and the real path if they differ', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => { vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
if (p === '/test/workspace/.gitignore') return '/test/real/.gitignore'; async (p) => {
if (p === '/test/workspace/.gitignore')
return '/test/real/.gitignore';
return p.toString(); return p.toString();
}); },
);
vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation( vi.spyOn(fs, 'lstatSync').mockImplementation(
() => () =>
@@ -211,4 +115,123 @@ describe('seatbeltArgsBuilder', () => {
); );
}); });
}); });
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
},
);
const args = await buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
});
const profile = args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(args).toContain('-D');
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/secret/path'],
});
const profile = args[1];
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/secret/path');
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
},
);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/symlink'],
});
const profile = args[1];
// The builder should resolve the symlink and explicitly deny the real target path
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
});
const profile = args[1];
expect(args).toContain('-D');
expect(args).toContain(
'FORBIDDEN_PATH_0=/test/missing-dir/missing-file.txt',
);
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1'],
forbiddenPaths: ['/custom/path1'],
});
const profile = args[1];
const allowString =
'(allow file-read* file-write* (subpath (param "ALLOWED_PATH_0")))';
const denyString =
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))';
expect(profile).toContain(allowString);
expect(profile).toContain(denyString);
// Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string
// Seatbelt rules are evaluated in order where the latest rule matching a path wins
const allowIndex = profile.indexOf(allowString);
const denyIndex = profile.indexOf(denyString);
expect(denyIndex).toBeGreaterThan(allowIndex);
});
});
});
}); });
@@ -35,7 +35,8 @@ describe('WindowsSandboxManager', () => {
fs.rmSync(testCwd, { recursive: true, force: true }); fs.rmSync(testCwd, { recursive: true, force: true });
}); });
it('should prepare a GeminiSandbox.exe command', async () => { describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const req: SandboxRequest = { const req: SandboxRequest = {
command: 'whoami', command: 'whoami',
args: ['/groups'], args: ['/groups'],
@@ -52,22 +53,20 @@ describe('WindowsSandboxManager', () => {
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']); expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
}); });
it('should handle networkAccess from config', async () => { it('should correctly pass through the cwd to the resulting command', async () => {
const req: SandboxRequest = { const req: SandboxRequest = {
command: 'whoami', command: 'whoami',
args: [], args: [],
cwd: testCwd, cwd: '/different/cwd',
env: {}, env: {},
policy: {
networkAccess: true,
},
}; };
const result = await manager.prepareCommand(req); const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
expect(result.cwd).toBe('/different/cwd');
}); });
it('should sanitize environment variables', async () => { it('should apply environment sanitization via the default mechanisms', async () => {
const req: SandboxRequest = { const req: SandboxRequest = {
command: 'test', command: 'test',
args: [], args: [],
@@ -90,6 +89,22 @@ describe('WindowsSandboxManager', () => {
expect(result.env['API_KEY']).toBeUndefined(); expect(result.env['API_KEY']).toBeUndefined();
}); });
it('should allow network when networkAccess is true', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: testCwd,
env: {},
policy: {
networkAccess: true,
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
describe('governance files', () => {
it('should ensure governance files exist', async () => { it('should ensure governance files exist', async () => {
const req: SandboxRequest = { const req: SandboxRequest = {
command: 'test', command: 'test',
@@ -103,10 +118,14 @@ describe('WindowsSandboxManager', () => {
expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true); expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true); expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true); expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true);
expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true); expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(
true,
);
});
}); });
it('should grant Low Integrity access to the workspace and allowed paths', async () => { describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed'); const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed');
if (!fs.existsSync(allowedPath)) { if (!fs.existsSync(allowedPath)) {
fs.mkdirSync(allowedPath); fs.mkdirSync(allowedPath);
@@ -139,8 +158,41 @@ describe('WindowsSandboxManager', () => {
fs.rmSync(allowedPath, { recursive: true, force: true }); fs.rmSync(allowedPath, { recursive: true, force: true });
} }
}); });
});
it('skips denying access to non-existent forbidden paths to prevent icacls failure', async () => { describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
const forbiddenPath = path.join(
os.tmpdir(),
'gemini-cli-test-forbidden',
);
if (!fs.existsSync(forbiddenPath)) {
fs.mkdirSync(forbiddenPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [forbiddenPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(forbiddenPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const missingPath = path.join( const missingPath = path.join(
os.tmpdir(), os.tmpdir(),
'gemini-cli-test-missing', 'gemini-cli-test-missing',
@@ -172,34 +224,6 @@ describe('WindowsSandboxManager', () => {
]); ]);
}); });
it('should deny Low Integrity access to forbidden paths', async () => {
const forbiddenPath = path.join(os.tmpdir(), 'gemini-cli-test-forbidden');
if (!fs.existsSync(forbiddenPath)) {
fs.mkdirSync(forbiddenPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [forbiddenPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(forbiddenPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
});
it('should override allowed paths if a path is also in forbidden paths', async () => { it('should override allowed paths if a path is also in forbidden paths', async () => {
const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict'); const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict');
if (!fs.existsSync(conflictPath)) { if (!fs.existsSync(conflictPath)) {
@@ -245,4 +269,6 @@ describe('WindowsSandboxManager', () => {
fs.rmSync(conflictPath, { recursive: true, force: true }); fs.rmSync(conflictPath, { recursive: true, force: true });
} }
}); });
});
});
}); });
@@ -231,6 +231,7 @@ export class WindowsSandboxManager implements SandboxManager {
program, program,
args, args,
env: sanitizedEnv, env: sanitizedEnv,
cwd: req.cwd,
}; };
} }