2026-03-11 14:42:50 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
2026-03-26 20:35:21 +00:00
|
|
|
|
2026-03-16 21:34:48 +00:00
|
|
|
import os from 'node:os';
|
2026-03-24 21:23:51 -04:00
|
|
|
import path from 'node:path';
|
2026-03-26 20:35:21 +00:00
|
|
|
import fsPromises from 'node:fs/promises';
|
2026-03-25 17:54:45 +00:00
|
|
|
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
|
2026-03-24 21:23:51 -04:00
|
|
|
import {
|
|
|
|
|
NoopSandboxManager,
|
|
|
|
|
sanitizePaths,
|
2026-03-26 20:35:21 +00:00
|
|
|
findSecretFiles,
|
|
|
|
|
isSecretFile,
|
2026-03-24 21:23:51 -04:00
|
|
|
tryRealpath,
|
2026-04-01 12:27:55 -04:00
|
|
|
resolveSandboxPaths,
|
|
|
|
|
getPathIdentity,
|
|
|
|
|
type SandboxRequest,
|
2026-03-24 21:23:51 -04:00
|
|
|
} from './sandboxManager.js';
|
2026-03-19 15:25:22 -07:00
|
|
|
import { createSandboxManager } from './sandboxManagerFactory.js';
|
2026-03-16 21:34:48 +00:00
|
|
|
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
2026-03-18 16:07:54 -04:00
|
|
|
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
2026-03-24 07:32:20 -07:00
|
|
|
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
|
2026-03-26 20:35:21 +00:00
|
|
|
import type fs from 'node:fs';
|
|
|
|
|
|
|
|
|
|
vi.mock('node:fs/promises', async () => {
|
|
|
|
|
const actual =
|
|
|
|
|
await vi.importActual<typeof import('node:fs/promises')>(
|
|
|
|
|
'node:fs/promises',
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
default: {
|
|
|
|
|
...actual,
|
|
|
|
|
readdir: vi.fn(),
|
|
|
|
|
realpath: vi.fn(),
|
|
|
|
|
stat: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
readdir: vi.fn(),
|
|
|
|
|
realpath: vi.fn(),
|
|
|
|
|
stat: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('isSecretFile', () => {
|
|
|
|
|
it('should return true for .env', () => {
|
|
|
|
|
expect(isSecretFile('.env')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return true for .env.local', () => {
|
|
|
|
|
expect(isSecretFile('.env.local')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return true for .env.production', () => {
|
|
|
|
|
expect(isSecretFile('.env.production')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return false for regular files', () => {
|
|
|
|
|
expect(isSecretFile('package.json')).toBe(false);
|
|
|
|
|
expect(isSecretFile('index.ts')).toBe(false);
|
|
|
|
|
expect(isSecretFile('.gitignore')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return false for files starting with .env but not matching pattern', () => {
|
|
|
|
|
// This depends on the pattern ".env.*". ".env-backup" would match ".env*" but not ".env.*"
|
|
|
|
|
expect(isSecretFile('.env-backup')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findSecretFiles', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should find secret files in the root directory', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const workspace = path.resolve('/workspace');
|
2026-03-26 20:35:21 +00:00
|
|
|
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
|
2026-04-03 08:50:29 -07:00
|
|
|
if (dir === workspace) {
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.resolve([
|
|
|
|
|
{ name: '.env', isDirectory: () => false, isFile: () => true },
|
|
|
|
|
{
|
|
|
|
|
name: 'package.json',
|
|
|
|
|
isDirectory: () => false,
|
|
|
|
|
isFile: () => true,
|
|
|
|
|
},
|
|
|
|
|
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
|
|
|
|
] as unknown as fs.Dirent[]);
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve([] as unknown as fs.Dirent[]);
|
|
|
|
|
}) as unknown as typeof fsPromises.readdir);
|
|
|
|
|
|
2026-04-03 08:50:29 -07:00
|
|
|
const secrets = await findSecretFiles(workspace);
|
|
|
|
|
expect(secrets).toEqual([path.join(workspace, '.env')]);
|
2026-03-26 20:35:21 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should NOT find secret files recursively (shallow scan only)', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const workspace = path.resolve('/workspace');
|
2026-03-26 20:35:21 +00:00
|
|
|
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
|
2026-04-03 08:50:29 -07:00
|
|
|
if (dir === workspace) {
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.resolve([
|
|
|
|
|
{ name: '.env', isDirectory: () => false, isFile: () => true },
|
|
|
|
|
{ name: 'packages', isDirectory: () => true, isFile: () => false },
|
|
|
|
|
] as unknown as fs.Dirent[]);
|
|
|
|
|
}
|
2026-04-03 08:50:29 -07:00
|
|
|
if (dir === path.join(workspace, 'packages')) {
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.resolve([
|
|
|
|
|
{ name: '.env.local', isDirectory: () => false, isFile: () => true },
|
|
|
|
|
] as unknown as fs.Dirent[]);
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve([] as unknown as fs.Dirent[]);
|
|
|
|
|
}) as unknown as typeof fsPromises.readdir);
|
|
|
|
|
|
2026-04-03 08:50:29 -07:00
|
|
|
const secrets = await findSecretFiles(workspace);
|
|
|
|
|
expect(secrets).toEqual([path.join(workspace, '.env')]);
|
2026-03-26 20:35:21 +00:00
|
|
|
// Should NOT have called readdir for subdirectories
|
|
|
|
|
expect(fsPromises.readdir).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(fsPromises.readdir).not.toHaveBeenCalledWith(
|
2026-04-03 08:50:29 -07:00
|
|
|
path.join(workspace, 'packages'),
|
2026-03-26 20:35:21 +00:00
|
|
|
expect.anything(),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
describe('SandboxManager', () => {
|
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
2026-03-23 11:43:58 -04:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
describe('sanitizePaths', () => {
|
2026-04-01 12:27:55 -04:00
|
|
|
it('should return an empty array if no paths are provided', () => {
|
|
|
|
|
expect(sanitizePaths(undefined)).toEqual([]);
|
|
|
|
|
expect(sanitizePaths(null)).toEqual([]);
|
|
|
|
|
expect(sanitizePaths([])).toEqual([]);
|
2026-03-25 17:54:45 +00:00
|
|
|
});
|
2026-03-23 11:43:58 -04:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should deduplicate paths and return them', () => {
|
|
|
|
|
const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo'];
|
|
|
|
|
expect(sanitizePaths(paths)).toEqual([
|
|
|
|
|
'/workspace/foo',
|
|
|
|
|
'/workspace/bar',
|
|
|
|
|
]);
|
|
|
|
|
});
|
2026-03-23 11:43:58 -04:00
|
|
|
|
2026-04-01 12:27:55 -04:00
|
|
|
it('should deduplicate case-insensitively on Windows and macOS', () => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
|
|
|
|
const paths = ['/workspace/foo', '/WORKSPACE/FOO'];
|
|
|
|
|
expect(sanitizePaths(paths)).toEqual(['/workspace/foo']);
|
|
|
|
|
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
|
|
|
|
const macPaths = ['/tmp/foo', '/tmp/FOO'];
|
|
|
|
|
expect(sanitizePaths(macPaths)).toEqual(['/tmp/foo']);
|
|
|
|
|
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
|
|
|
|
const linuxPaths = ['/tmp/foo', '/tmp/FOO'];
|
|
|
|
|
expect(sanitizePaths(linuxPaths)).toEqual(['/tmp/foo', '/tmp/FOO']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should throw an error if a path is not absolute', () => {
|
|
|
|
|
const paths = ['/workspace/foo', 'relative/path'];
|
|
|
|
|
expect(() => sanitizePaths(paths)).toThrow(
|
|
|
|
|
'Sandbox path must be absolute: relative/path',
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:27:55 -04:00
|
|
|
describe('getPathIdentity', () => {
|
|
|
|
|
it('should normalize slashes and strip trailing slashes', () => {
|
|
|
|
|
expect(getPathIdentity('/foo/bar//baz/')).toBe(
|
|
|
|
|
path.normalize('/foo/bar/baz'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle case sensitivity correctly per platform', () => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(getPathIdentity('/Workspace/Foo')).toBe(
|
|
|
|
|
path.normalize('/workspace/foo'),
|
|
|
|
|
);
|
2026-04-01 12:27:55 -04:00
|
|
|
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(getPathIdentity('/Tmp/Foo')).toBe(path.normalize('/tmp/foo'));
|
2026-04-01 12:27:55 -04:00
|
|
|
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(getPathIdentity('/Tmp/Foo')).toBe(path.normalize('/Tmp/Foo'));
|
2026-04-01 12:27:55 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('resolveSandboxPaths', () => {
|
|
|
|
|
it('should resolve allowed and forbidden paths', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const workspace = path.resolve('/workspace');
|
|
|
|
|
const forbidden = path.join(workspace, 'forbidden');
|
|
|
|
|
const allowed = path.join(workspace, 'allowed');
|
2026-04-01 12:27:55 -04:00
|
|
|
const options = {
|
2026-04-03 08:50:29 -07:00
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: async () => [forbidden],
|
2026-04-01 12:27:55 -04:00
|
|
|
};
|
|
|
|
|
const req = {
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd: workspace,
|
2026-04-01 12:27:55 -04:00
|
|
|
env: {},
|
|
|
|
|
policy: {
|
2026-04-03 08:50:29 -07:00
|
|
|
allowedPaths: [allowed],
|
2026-04-01 12:27:55 -04:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
|
|
|
|
|
2026-04-08 15:00:50 -07:00
|
|
|
expect(result.policyAllowed).toEqual([allowed]);
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(result.forbidden).toEqual([forbidden]);
|
2026-04-01 12:27:55 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should filter out workspace from allowed paths', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const workspace = path.resolve('/workspace');
|
|
|
|
|
const other = path.resolve('/other/path');
|
2026-04-01 12:27:55 -04:00
|
|
|
const options = {
|
2026-04-03 08:50:29 -07:00
|
|
|
workspace,
|
2026-04-01 12:27:55 -04:00
|
|
|
};
|
|
|
|
|
const req = {
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd: workspace,
|
2026-04-01 12:27:55 -04:00
|
|
|
env: {},
|
|
|
|
|
policy: {
|
2026-04-03 08:50:29 -07:00
|
|
|
allowedPaths: [workspace, workspace + path.sep, other],
|
2026-04-01 12:27:55 -04:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
|
|
|
|
|
2026-04-08 15:00:50 -07:00
|
|
|
expect(result.policyAllowed).toEqual([other]);
|
2026-04-01 12:27:55 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should prioritize forbidden paths over allowed paths', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const workspace = path.resolve('/workspace');
|
|
|
|
|
const secret = path.join(workspace, 'secret');
|
|
|
|
|
const normal = path.join(workspace, 'normal');
|
2026-04-01 12:27:55 -04:00
|
|
|
const options = {
|
2026-04-03 08:50:29 -07:00
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: async () => [secret],
|
2026-04-01 12:27:55 -04:00
|
|
|
};
|
|
|
|
|
const req = {
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd: workspace,
|
2026-04-01 12:27:55 -04:00
|
|
|
env: {},
|
|
|
|
|
policy: {
|
2026-04-03 08:50:29 -07:00
|
|
|
allowedPaths: [secret, normal],
|
2026-04-01 12:27:55 -04:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
|
|
|
|
|
2026-04-08 15:00:50 -07:00
|
|
|
expect(result.policyAllowed).toEqual([normal]);
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(result.forbidden).toEqual([secret]);
|
2026-04-01 12:27:55 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle case-insensitive conflicts on supported platforms', async () => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
2026-04-03 08:50:29 -07:00
|
|
|
const workspace = path.resolve('/workspace');
|
|
|
|
|
const secretUpper = path.join(workspace, 'SECRET');
|
|
|
|
|
const secretLower = path.join(workspace, 'secret');
|
2026-04-01 12:27:55 -04:00
|
|
|
const options = {
|
2026-04-03 08:50:29 -07:00
|
|
|
workspace,
|
|
|
|
|
forbiddenPaths: async () => [secretUpper],
|
2026-04-01 12:27:55 -04:00
|
|
|
};
|
|
|
|
|
const req = {
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: [],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd: workspace,
|
2026-04-01 12:27:55 -04:00
|
|
|
env: {},
|
|
|
|
|
policy: {
|
2026-04-03 08:50:29 -07:00
|
|
|
allowedPaths: [secretLower],
|
2026-04-01 12:27:55 -04:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
|
|
|
|
|
2026-04-08 15:00:50 -07:00
|
|
|
expect(result.policyAllowed).toEqual([]);
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(result.forbidden).toEqual([secretUpper]);
|
2026-04-01 12:27:55 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
describe('tryRealpath', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
2026-03-24 21:23:51 -04:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should return the realpath if the file exists', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const realPath = path.resolve('/real/path/to/file.txt');
|
|
|
|
|
const symlinkPath = path.resolve('/some/symlink/to/file.txt');
|
|
|
|
|
vi.mocked(fsPromises.realpath).mockResolvedValue(realPath as never);
|
|
|
|
|
const result = await tryRealpath(symlinkPath);
|
|
|
|
|
expect(result).toBe(realPath);
|
|
|
|
|
expect(fsPromises.realpath).toHaveBeenCalledWith(symlinkPath);
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should fallback to parent directory if file does not exist (ENOENT)', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const nonexistent = path.resolve('/workspace/nonexistent.txt');
|
|
|
|
|
const workspace = path.resolve('/workspace');
|
|
|
|
|
const realWorkspace = path.resolve('/real/workspace');
|
|
|
|
|
|
2026-03-26 20:35:21 +00:00
|
|
|
vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => {
|
2026-04-03 08:50:29 -07:00
|
|
|
if (p === nonexistent) {
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.reject(
|
|
|
|
|
Object.assign(new Error('ENOENT: no such file or directory'), {
|
|
|
|
|
code: 'ENOENT',
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-03-25 17:54:45 +00:00
|
|
|
}
|
2026-04-03 08:50:29 -07:00
|
|
|
if (p === workspace) {
|
|
|
|
|
return Promise.resolve(realWorkspace);
|
2026-03-25 17:54:45 +00:00
|
|
|
}
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.reject(new Error(`Unexpected path: ${p}`));
|
|
|
|
|
}) as never);
|
2026-03-24 21:23:51 -04:00
|
|
|
|
2026-04-03 08:50:29 -07:00
|
|
|
const result = await tryRealpath(nonexistent);
|
2026-03-24 21:23:51 -04:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
// It should combine the real path of the parent with the original basename
|
2026-04-03 08:50:29 -07:00
|
|
|
expect(result).toBe(path.join(realWorkspace, 'nonexistent.txt'));
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const missingFile = path.resolve(
|
|
|
|
|
'/workspace/missing_dir/missing_file.txt',
|
|
|
|
|
);
|
|
|
|
|
const missingDir = path.resolve('/workspace/missing_dir');
|
|
|
|
|
const workspace = path.resolve('/workspace');
|
|
|
|
|
const realWorkspace = path.resolve('/real/workspace');
|
|
|
|
|
|
2026-03-26 20:35:21 +00:00
|
|
|
vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => {
|
2026-04-03 08:50:29 -07:00
|
|
|
if (p === missingFile) {
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.reject(
|
|
|
|
|
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
|
|
|
|
);
|
2026-03-25 17:54:45 +00:00
|
|
|
}
|
2026-04-03 08:50:29 -07:00
|
|
|
if (p === missingDir) {
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.reject(
|
|
|
|
|
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
|
|
|
|
);
|
2026-03-25 17:54:45 +00:00
|
|
|
}
|
2026-04-03 08:50:29 -07:00
|
|
|
if (p === workspace) {
|
|
|
|
|
return Promise.resolve(realWorkspace);
|
2026-03-25 17:54:45 +00:00
|
|
|
}
|
2026-03-26 20:35:21 +00:00
|
|
|
return Promise.reject(new Error(`Unexpected path: ${p}`));
|
|
|
|
|
}) as never);
|
2026-03-24 21:23:51 -04:00
|
|
|
|
2026-04-03 08:50:29 -07:00
|
|
|
const result = await tryRealpath(missingFile);
|
2026-03-24 21:23:51 -04:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
// It should resolve '/workspace' to '/real/workspace' and append the missing parts
|
|
|
|
|
expect(result).toBe(
|
2026-04-03 08:50:29 -07:00
|
|
|
path.join(realWorkspace, 'missing_dir', 'missing_file.txt'),
|
2026-03-25 17:54:45 +00:00
|
|
|
);
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => {
|
|
|
|
|
const rootPath = path.resolve('/');
|
2026-03-26 20:35:21 +00:00
|
|
|
vi.mocked(fsPromises.realpath).mockImplementation(() =>
|
|
|
|
|
Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })),
|
|
|
|
|
);
|
2026-03-25 17:54:45 +00:00
|
|
|
|
|
|
|
|
const result = await tryRealpath(rootPath);
|
|
|
|
|
expect(result).toBe(rootPath);
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const secretFile = path.resolve('/secret/file.txt');
|
2026-03-26 20:35:21 +00:00
|
|
|
vi.mocked(fsPromises.realpath).mockImplementation(() =>
|
|
|
|
|
Promise.reject(
|
|
|
|
|
Object.assign(new Error('EACCES: permission denied'), {
|
|
|
|
|
code: 'EACCES',
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-03-25 17:54:45 +00:00
|
|
|
|
2026-04-03 08:50:29 -07:00
|
|
|
await expect(tryRealpath(secretFile)).rejects.toThrow(
|
2026-03-25 17:54:45 +00:00
|
|
|
'EACCES: permission denied',
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-03-24 21:23:51 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
describe('NoopSandboxManager', () => {
|
|
|
|
|
const sandboxManager = new NoopSandboxManager();
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should pass through the command and arguments unchanged', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const cwd = path.resolve('/tmp');
|
2026-03-25 17:54:45 +00:00
|
|
|
const req = {
|
|
|
|
|
command: 'ls',
|
|
|
|
|
args: ['-la'],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd,
|
2026-03-25 17:54:45 +00:00
|
|
|
env: { PATH: '/usr/bin' },
|
|
|
|
|
};
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
const result = await sandboxManager.prepareCommand(req);
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
expect(result.program).toBe('ls');
|
|
|
|
|
expect(result.args).toEqual(['-la']);
|
|
|
|
|
});
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should sanitize the environment variables', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const cwd = path.resolve('/tmp');
|
2026-03-25 17:54:45 +00:00
|
|
|
const req = {
|
|
|
|
|
command: 'echo',
|
|
|
|
|
args: ['hello'],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd,
|
2026-03-25 17:54:45 +00:00
|
|
|
env: {
|
|
|
|
|
PATH: '/usr/bin',
|
|
|
|
|
GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
|
|
|
MY_SECRET: 'super-secret',
|
|
|
|
|
SAFE_VAR: 'is-safe',
|
|
|
|
|
},
|
2026-03-26 13:04:44 -07:00
|
|
|
policy: {
|
|
|
|
|
sanitizationConfig: {
|
|
|
|
|
enableEnvironmentVariableRedaction: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-25 17:54:45 +00:00
|
|
|
};
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
const result = await sandboxManager.prepareCommand(req);
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
expect(result.env['PATH']).toBe('/usr/bin');
|
|
|
|
|
expect(result.env['SAFE_VAR']).toBe('is-safe');
|
|
|
|
|
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
|
|
|
|
expect(result.env['MY_SECRET']).toBeUndefined();
|
|
|
|
|
});
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-26 13:04:44 -07:00
|
|
|
it('should allow disabling environment variable redaction if requested in config', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const cwd = path.resolve('/tmp');
|
2026-03-25 17:54:45 +00:00
|
|
|
const req = {
|
|
|
|
|
command: 'echo',
|
|
|
|
|
args: ['hello'],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd,
|
2026-03-25 17:54:45 +00:00
|
|
|
env: {
|
|
|
|
|
API_KEY: 'sensitive-key',
|
2026-03-11 14:42:50 -07:00
|
|
|
},
|
2026-03-25 17:54:45 +00:00
|
|
|
policy: {
|
|
|
|
|
sanitizationConfig: {
|
|
|
|
|
enableEnvironmentVariableRedaction: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
const result = await sandboxManager.prepareCommand(req);
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-26 13:04:44 -07:00
|
|
|
// API_KEY should be preserved because redaction was explicitly disabled
|
|
|
|
|
expect(result.env['API_KEY']).toBe('sensitive-key');
|
2026-03-25 17:54:45 +00:00
|
|
|
});
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const cwd = path.resolve('/tmp');
|
2026-03-25 17:54:45 +00:00
|
|
|
const req = {
|
|
|
|
|
command: 'echo',
|
|
|
|
|
args: ['hello'],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd,
|
2026-03-25 17:54:45 +00:00
|
|
|
env: {
|
|
|
|
|
MY_SAFE_VAR: 'safe-value',
|
|
|
|
|
MY_TOKEN: 'secret-token',
|
2026-03-11 14:42:50 -07:00
|
|
|
},
|
2026-03-25 17:54:45 +00:00
|
|
|
policy: {
|
|
|
|
|
sanitizationConfig: {
|
|
|
|
|
allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'],
|
2026-03-26 13:04:44 -07:00
|
|
|
enableEnvironmentVariableRedaction: true,
|
2026-03-25 17:54:45 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
const result = await sandboxManager.prepareCommand(req);
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
expect(result.env['MY_SAFE_VAR']).toBe('safe-value');
|
|
|
|
|
// MY_TOKEN matches /TOKEN/i so it should be redacted despite being allowed in config
|
|
|
|
|
expect(result.env['MY_TOKEN']).toBeUndefined();
|
|
|
|
|
});
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
it('should respect blockedEnvironmentVariables in config', async () => {
|
2026-04-03 08:50:29 -07:00
|
|
|
const cwd = path.resolve('/tmp');
|
2026-03-25 17:54:45 +00:00
|
|
|
const req = {
|
|
|
|
|
command: 'echo',
|
|
|
|
|
args: ['hello'],
|
2026-04-03 08:50:29 -07:00
|
|
|
cwd,
|
2026-03-25 17:54:45 +00:00
|
|
|
env: {
|
|
|
|
|
SAFE_VAR: 'safe-value',
|
|
|
|
|
BLOCKED_VAR: 'blocked-value',
|
2026-03-11 14:42:50 -07:00
|
|
|
},
|
2026-03-25 17:54:45 +00:00
|
|
|
policy: {
|
|
|
|
|
sanitizationConfig: {
|
|
|
|
|
blockedEnvironmentVariables: ['BLOCKED_VAR'],
|
2026-03-26 13:04:44 -07:00
|
|
|
enableEnvironmentVariableRedaction: true,
|
2026-03-25 17:54:45 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
const result = await sandboxManager.prepareCommand(req);
|
2026-03-11 14:42:50 -07:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
expect(result.env['SAFE_VAR']).toBe('safe-value');
|
|
|
|
|
expect(result.env['BLOCKED_VAR']).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should delegate isKnownSafeCommand to platform specific checkers', () => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
|
|
|
|
expect(sandboxManager.isKnownSafeCommand(['ls'])).toBe(true);
|
|
|
|
|
expect(sandboxManager.isKnownSafeCommand(['dir'])).toBe(false);
|
|
|
|
|
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
|
|
|
|
expect(sandboxManager.isKnownSafeCommand(['dir'])).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should delegate isDangerousCommand to platform specific checkers', () => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
|
|
|
|
expect(sandboxManager.isDangerousCommand(['rm', '-rf', '.'])).toBe(true);
|
|
|
|
|
expect(sandboxManager.isDangerousCommand(['del'])).toBe(false);
|
2026-03-16 21:34:48 +00:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
|
|
|
|
expect(sandboxManager.isDangerousCommand(['del'])).toBe(true);
|
|
|
|
|
});
|
2026-03-16 21:34:48 +00:00
|
|
|
});
|
|
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
describe('createSandboxManager', () => {
|
|
|
|
|
it('should return NoopSandboxManager if sandboxing is disabled', () => {
|
2026-03-27 12:57:26 -04:00
|
|
|
const manager = createSandboxManager(
|
|
|
|
|
{ enabled: false },
|
2026-04-03 08:50:29 -07:00
|
|
|
{ workspace: path.resolve('/workspace') },
|
2026-03-27 12:57:26 -04:00
|
|
|
);
|
2026-03-25 17:54:45 +00:00
|
|
|
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
{ platform: 'linux', expected: LinuxSandboxManager },
|
|
|
|
|
{ platform: 'darwin', expected: MacOsSandboxManager },
|
2026-03-27 15:35:01 -07:00
|
|
|
{ platform: 'win32', expected: WindowsSandboxManager },
|
2026-03-25 17:54:45 +00:00
|
|
|
] as const)(
|
|
|
|
|
'should return $expected.name if sandboxing is enabled and platform is $platform',
|
|
|
|
|
({ platform, expected }) => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue(platform);
|
2026-03-27 12:57:26 -04:00
|
|
|
const manager = createSandboxManager(
|
|
|
|
|
{ enabled: true },
|
2026-04-03 08:50:29 -07:00
|
|
|
{ workspace: path.resolve('/workspace') },
|
2026-03-27 12:57:26 -04:00
|
|
|
);
|
2026-03-18 16:07:54 -04:00
|
|
|
expect(manager).toBeInstanceOf(expected);
|
2026-03-25 17:54:45 +00:00
|
|
|
},
|
|
|
|
|
);
|
2026-03-27 17:14:35 -07:00
|
|
|
|
|
|
|
|
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
|
|
|
|
|
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
|
|
|
|
const manager = createSandboxManager(
|
|
|
|
|
{ enabled: true },
|
2026-04-03 08:50:29 -07:00
|
|
|
{ workspace: path.resolve('/workspace') },
|
2026-03-27 17:14:35 -07:00
|
|
|
);
|
|
|
|
|
expect(manager).toBeInstanceOf(WindowsSandboxManager);
|
|
|
|
|
});
|
2026-03-25 17:54:45 +00:00
|
|
|
});
|
2026-03-16 21:34:48 +00:00
|
|
|
});
|