2026-03-16 21:34:48 +00:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2026 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-24 21:23:51 -04:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
2026-03-16 21:34:48 +00:00
|
|
|
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
|
|
|
|
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
2026-03-24 04:04:17 +00:00
|
|
|
import fs from 'node:fs';
|
2026-03-26 20:35:21 +00:00
|
|
|
import * as shellUtils from '../../utils/shell-utils.js';
|
2026-03-24 04:04:17 +00:00
|
|
|
|
|
|
|
|
vi.mock('node:fs', async () => {
|
|
|
|
|
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
default: {
|
|
|
|
|
// @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")'
|
|
|
|
|
...actual.default,
|
|
|
|
|
existsSync: vi.fn(() => true),
|
2026-03-25 18:58:45 -07:00
|
|
|
realpathSync: vi.fn((p) => p.toString()),
|
|
|
|
|
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
|
2026-03-24 04:04:17 +00:00
|
|
|
mkdirSync: vi.fn(),
|
2026-03-26 20:35:21 +00:00
|
|
|
mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'),
|
2026-03-24 04:04:17 +00:00
|
|
|
openSync: vi.fn(),
|
|
|
|
|
closeSync: vi.fn(),
|
|
|
|
|
writeFileSync: vi.fn(),
|
2026-03-26 20:35:21 +00:00
|
|
|
readdirSync: vi.fn(() => []),
|
|
|
|
|
chmodSync: vi.fn(),
|
|
|
|
|
unlinkSync: vi.fn(),
|
|
|
|
|
rmSync: vi.fn(),
|
2026-03-24 04:04:17 +00:00
|
|
|
},
|
|
|
|
|
existsSync: vi.fn(() => true),
|
2026-03-25 18:58:45 -07:00
|
|
|
realpathSync: vi.fn((p) => p.toString()),
|
|
|
|
|
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
|
2026-03-24 04:04:17 +00:00
|
|
|
mkdirSync: vi.fn(),
|
2026-03-26 20:35:21 +00:00
|
|
|
mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'),
|
2026-03-24 04:04:17 +00:00
|
|
|
openSync: vi.fn(),
|
|
|
|
|
closeSync: vi.fn(),
|
|
|
|
|
writeFileSync: vi.fn(),
|
2026-03-26 20:35:21 +00:00
|
|
|
readdirSync: vi.fn(() => []),
|
|
|
|
|
chmodSync: vi.fn(),
|
|
|
|
|
unlinkSync: vi.fn(),
|
|
|
|
|
rmSync: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
|
|
|
|
const actual =
|
|
|
|
|
await importOriginal<typeof import('../../utils/shell-utils.js')>();
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
spawnAsync: vi.fn(() =>
|
|
|
|
|
Promise.resolve({ status: 0, stdout: Buffer.from('') }),
|
|
|
|
|
),
|
|
|
|
|
initializeShellParsers: vi.fn(),
|
|
|
|
|
isStrictlyApproved: vi.fn().mockResolvedValue(true),
|
2026-03-24 04:04:17 +00:00
|
|
|
};
|
|
|
|
|
});
|
2026-03-16 21:34:48 +00:00
|
|
|
|
|
|
|
|
describe('LinuxSandboxManager', () => {
|
|
|
|
|
const workspace = '/home/user/workspace';
|
2026-03-23 11:43:58 -04:00
|
|
|
let manager: LinuxSandboxManager;
|
2026-03-16 21:34:48 +00:00
|
|
|
|
2026-03-23 11:43:58 -04:00
|
|
|
beforeEach(() => {
|
2026-03-24 04:04:17 +00:00
|
|
|
vi.clearAllMocks();
|
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
|
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
2026-03-23 11:43:58 -04:00
|
|
|
manager = new LinuxSandboxManager({ workspace });
|
|
|
|
|
});
|
2026-03-16 21:34:48 +00:00
|
|
|
|
2026-03-24 21:23:51 -04:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
const getBwrapArgs = async (
|
|
|
|
|
req: SandboxRequest,
|
|
|
|
|
customManager?: LinuxSandboxManager,
|
|
|
|
|
) => {
|
|
|
|
|
const mgr = customManager || manager;
|
|
|
|
|
const result = await mgr.prepareCommand(req);
|
2026-03-17 20:29:13 +00:00
|
|
|
expect(result.program).toBe('sh');
|
|
|
|
|
expect(result.args[0]).toBe('-c');
|
|
|
|
|
expect(result.args[1]).toBe(
|
|
|
|
|
'bpf_path="$1"; shift; exec bwrap "$@" 9< "$bpf_path"',
|
|
|
|
|
);
|
|
|
|
|
expect(result.args[2]).toBe('_');
|
|
|
|
|
expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/);
|
2026-03-23 11:43:58 -04:00
|
|
|
return result.args.slice(4);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
describe('prepareCommand', () => {
|
|
|
|
|
it('should correctly format the base command and args', async () => {
|
|
|
|
|
const bwrapArgs = await getBwrapArgs({
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(bwrapArgs).toEqual([
|
|
|
|
|
'--unshare-all',
|
|
|
|
|
'--new-session',
|
|
|
|
|
'--die-with-parent',
|
|
|
|
|
'--ro-bind',
|
|
|
|
|
'/',
|
|
|
|
|
'/',
|
|
|
|
|
'--dev',
|
|
|
|
|
'/dev',
|
|
|
|
|
'--proc',
|
|
|
|
|
'/proc',
|
|
|
|
|
'--tmpfs',
|
|
|
|
|
'/tmp',
|
2026-03-25 18:58:45 -07:00
|
|
|
'--ro-bind-try',
|
2026-03-24 22:37:32 -04:00
|
|
|
workspace,
|
|
|
|
|
workspace,
|
|
|
|
|
'--ro-bind',
|
|
|
|
|
`${workspace}/.gitignore`,
|
|
|
|
|
`${workspace}/.gitignore`,
|
|
|
|
|
'--ro-bind',
|
|
|
|
|
`${workspace}/.geminiignore`,
|
|
|
|
|
`${workspace}/.geminiignore`,
|
|
|
|
|
'--ro-bind',
|
|
|
|
|
`${workspace}/.git`,
|
|
|
|
|
`${workspace}/.git`,
|
|
|
|
|
'--seccomp',
|
|
|
|
|
'9',
|
|
|
|
|
'--',
|
|
|
|
|
'ls',
|
|
|
|
|
'-la',
|
|
|
|
|
]);
|
2026-03-23 11:43:58 -04:00
|
|
|
});
|
2026-03-17 20:29:13 +00:00
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
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/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
it('should correctly pass through the cwd to the resulting command', async () => {
|
|
|
|
|
const req: SandboxRequest = {
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
|
|
|
|
cwd: '/different/cwd',
|
|
|
|
|
env: {},
|
|
|
|
|
};
|
2026-03-16 21:34:48 +00:00
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
const result = await manager.prepareCommand(req);
|
2026-03-23 11:43:58 -04:00
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
expect(result.cwd).toBe('/different/cwd');
|
2026-03-24 04:04:17 +00:00
|
|
|
});
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
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();
|
2026-03-24 04:04:17 +00:00
|
|
|
});
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
it('should allow network when networkAccess is true', async () => {
|
|
|
|
|
const bwrapArgs = await getBwrapArgs({
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
policy: {
|
|
|
|
|
networkAccess: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
expect(bwrapArgs).toContain('--share-net');
|
2026-03-24 04:04:17 +00:00
|
|
|
});
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
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) => {
|
|
|
|
|
if (p.toString() === `${workspace}/.gitignore`)
|
|
|
|
|
return '/shared/global.gitignore';
|
|
|
|
|
return p.toString();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const bwrapArgs = await getBwrapArgs({
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(bwrapArgs).toContain('--ro-bind');
|
|
|
|
|
expect(bwrapArgs).toContain(`${workspace}/.gitignore`);
|
|
|
|
|
expect(bwrapArgs).toContain('/shared/global.gitignore');
|
|
|
|
|
|
|
|
|
|
// Check that both are bound
|
|
|
|
|
const gitignoreIndex = bwrapArgs.indexOf(`${workspace}/.gitignore`);
|
|
|
|
|
expect(bwrapArgs[gitignoreIndex - 1]).toBe('--ro-bind');
|
|
|
|
|
expect(bwrapArgs[gitignoreIndex + 1]).toBe(`${workspace}/.gitignore`);
|
|
|
|
|
|
|
|
|
|
const realGitignoreIndex = bwrapArgs.indexOf(
|
|
|
|
|
'/shared/global.gitignore',
|
|
|
|
|
);
|
|
|
|
|
expect(bwrapArgs[realGitignoreIndex - 1]).toBe('--ro-bind');
|
|
|
|
|
expect(bwrapArgs[realGitignoreIndex + 1]).toBe(
|
|
|
|
|
'/shared/global.gitignore',
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-03-23 11:43:58 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
describe('allowedPaths', () => {
|
|
|
|
|
it('should parameterize allowed paths and normalize them', async () => {
|
|
|
|
|
const bwrapArgs = await getBwrapArgs({
|
|
|
|
|
command: 'node',
|
|
|
|
|
args: ['script.js'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
policy: {
|
|
|
|
|
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
expect(bwrapArgs).toContain('--bind-try');
|
|
|
|
|
expect(bwrapArgs[bwrapArgs.indexOf('/tmp/cache') - 1]).toBe(
|
2026-03-24 22:37:32 -04:00
|
|
|
'--bind-try',
|
2026-03-25 18:58:45 -07:00
|
|
|
);
|
|
|
|
|
expect(bwrapArgs[bwrapArgs.indexOf('/opt/tools') - 1]).toBe(
|
2026-03-24 22:37:32 -04:00
|
|
|
'--bind-try',
|
2026-03-25 18:58:45 -07:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
|
|
|
|
|
const bwrapArgs = await getBwrapArgs({
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
policy: {
|
|
|
|
|
allowedPaths: [workspace + '/'],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
const binds = bwrapArgs.filter((a) => a === workspace);
|
|
|
|
|
expect(binds.length).toBe(2);
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-24 22:37:32 -04:00
|
|
|
describe('forbiddenPaths', () => {
|
|
|
|
|
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.statSync).mockImplementation((p) => {
|
2026-03-24 22:37:32 -04:00
|
|
|
if (p.toString().includes('cache')) {
|
|
|
|
|
return { isDirectory: () => true } as fs.Stats;
|
|
|
|
|
}
|
|
|
|
|
return { isDirectory: () => false } as fs.Stats;
|
|
|
|
|
});
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
2026-03-24 22:37:32 -04:00
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const customManager = new LinuxSandboxManager({
|
|
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const bwrapArgs = await getBwrapArgs(
|
|
|
|
|
{
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
customManager,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
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');
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('resolves forbidden symlink paths to their real paths', async () => {
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.statSync).mockImplementation(
|
|
|
|
|
() => ({ isDirectory: () => false }) as fs.Stats,
|
2026-03-24 22:37:32 -04:00
|
|
|
);
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
|
|
|
|
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
|
|
|
|
|
return p.toString();
|
|
|
|
|
});
|
2026-03-24 22:37:32 -04:00
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const customManager = new LinuxSandboxManager({
|
|
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: ['/tmp/forbidden-symlink'],
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const bwrapArgs = await getBwrapArgs(
|
|
|
|
|
{
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
customManager,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt');
|
|
|
|
|
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
|
|
|
|
|
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
|
|
|
|
const error = new Error('File not found') as NodeJS.ErrnoException;
|
|
|
|
|
error.code = 'ENOENT';
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.statSync).mockImplementation(() => {
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
2026-03-24 22:37:32 -04:00
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const customManager = new LinuxSandboxManager({
|
|
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: ['/tmp/not-here.txt'],
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const bwrapArgs = await getBwrapArgs(
|
|
|
|
|
{
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
customManager,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
const idx = bwrapArgs.indexOf('/tmp/not-here.txt');
|
|
|
|
|
expect(bwrapArgs[idx - 2]).toBe('--symlink');
|
|
|
|
|
expect(bwrapArgs[idx - 1]).toBe('/dev/null');
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('masks directory symlinks with tmpfs for both paths', async () => {
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.statSync).mockImplementation(
|
|
|
|
|
() => ({ isDirectory: () => true }) as fs.Stats,
|
2026-03-24 22:37:32 -04:00
|
|
|
);
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
|
|
|
|
if (p === '/tmp/dir-link') return '/opt/real-dir';
|
|
|
|
|
return p.toString();
|
|
|
|
|
});
|
2026-03-24 22:37:32 -04:00
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const customManager = new LinuxSandboxManager({
|
|
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: ['/tmp/dir-link'],
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const bwrapArgs = await getBwrapArgs(
|
|
|
|
|
{
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
customManager,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
const idx = bwrapArgs.indexOf('/opt/real-dir');
|
|
|
|
|
expect(bwrapArgs[idx - 1]).toBe('--tmpfs');
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.statSync).mockImplementation(
|
|
|
|
|
() => ({ isDirectory: () => true }) as fs.Stats,
|
2026-03-24 22:37:32 -04:00
|
|
|
);
|
2026-03-25 18:58:45 -07:00
|
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
2026-03-24 22:37:32 -04:00
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const customManager = new LinuxSandboxManager({
|
|
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: ['/tmp/conflict'],
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
const bwrapArgs = await getBwrapArgs(
|
|
|
|
|
{
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
policy: {
|
|
|
|
|
allowedPaths: ['/tmp/conflict'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
customManager,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
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);
|
2026-03-24 22:37:32 -04:00
|
|
|
});
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
2026-03-23 11:43:58 -04:00
|
|
|
});
|
2026-03-26 20:35:21 +00:00
|
|
|
|
|
|
|
|
it('blocks .env and .env.* files in the workspace root', async () => {
|
|
|
|
|
vi.mocked(shellUtils.spawnAsync).mockImplementation((cmd, args) => {
|
|
|
|
|
if (cmd === 'find' && args?.[0] === workspace) {
|
|
|
|
|
// Assert that find is NOT excluding dotfiles
|
|
|
|
|
expect(args).not.toContain('-not');
|
|
|
|
|
expect(args).toContain('-prune');
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
status: 0,
|
|
|
|
|
stdout: Buffer.from(
|
|
|
|
|
`${workspace}/.env\0${workspace}/.env.local\0${workspace}/.env.test\0`,
|
|
|
|
|
),
|
|
|
|
|
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
status: 0,
|
|
|
|
|
stdout: Buffer.from(''),
|
|
|
|
|
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const bwrapArgs = await getBwrapArgs({
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
|
|
|
|
cwd: workspace,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const bindsIndex = bwrapArgs.indexOf('--seccomp');
|
|
|
|
|
const binds = bwrapArgs.slice(0, bindsIndex);
|
|
|
|
|
|
|
|
|
|
expect(binds).toContain(`${workspace}/.env`);
|
|
|
|
|
expect(binds).toContain(`${workspace}/.env.local`);
|
|
|
|
|
expect(binds).toContain(`${workspace}/.env.test`);
|
|
|
|
|
|
|
|
|
|
// Verify they are bound to a mask file
|
|
|
|
|
const envIndex = binds.indexOf(`${workspace}/.env`);
|
|
|
|
|
expect(binds[envIndex - 2]).toBe('--bind');
|
|
|
|
|
expect(binds[envIndex - 1]).toMatch(/gemini-cli-mask-file-.*mocked\/mask/);
|
|
|
|
|
});
|
2026-03-16 21:34:48 +00:00
|
|
|
});
|