mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
fix(core): enhance sandbox usability and fix build error (#24460)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -144,6 +144,10 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
return parsePosixSandboxDenials(result);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (
|
||||
LinuxSandboxManager.maskFilePath &&
|
||||
@@ -193,9 +197,11 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved;
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
|
||||
const networkAccess =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || false;
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
|
||||
@@ -140,6 +140,31 @@ describe('MacOsSandboxManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT whitelist root in YOLO mode', async () => {
|
||||
manager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { readonly: false, allowOverrides: true, yolo: true },
|
||||
});
|
||||
|
||||
await manager.prepareCommand({
|
||||
command: 'ls',
|
||||
args: ['/'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
additionalPermissions: expect.objectContaining({
|
||||
fileSystem: expect.objectContaining({
|
||||
read: expect.not.arrayContaining(['/']),
|
||||
write: expect.not.arrayContaining(['/']),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('virtual commands', () => {
|
||||
it('should translate __read to /bin/cat', async () => {
|
||||
const testFile = path.join(mockWorkspace, 'file.txt');
|
||||
|
||||
@@ -55,6 +55,10 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
return parsePosixSandboxDenials(result);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
await initializeShellParsers();
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
@@ -90,9 +94,11 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
)
|
||||
: false;
|
||||
|
||||
const workspaceWrite = !isReadonlyMode || isApproved;
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || false;
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
|
||||
await resolveSandboxPaths(this.options, req);
|
||||
@@ -103,7 +109,6 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
// Merge all permissions
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
|
||||
@@ -23,6 +23,15 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
|
||||
(allow signal (target same-sandbox))
|
||||
(allow process-info*)
|
||||
|
||||
; Map system frameworks + dylibs for loader.
|
||||
(allow file-map-executable
|
||||
(subpath "/System/Library/Frameworks")
|
||||
(subpath "/System/Library/PrivateFrameworks")
|
||||
(subpath "/usr/lib")
|
||||
(subpath "/bin")
|
||||
(subpath "/usr/bin")
|
||||
)
|
||||
|
||||
(allow file-write-data
|
||||
(require-all
|
||||
(path "/dev/null")
|
||||
@@ -86,16 +95,22 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
|
||||
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.system.logger")
|
||||
(global-name "com.apple.system.notification_center")
|
||||
(global-name "com.apple.logd")
|
||||
(global-name "com.apple.secinitd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.analyticsd")
|
||||
(global-name "com.apple.analyticsd.messagetracer")
|
||||
)
|
||||
\n; IOKit
|
||||
(allow iokit-open
|
||||
(iokit-registry-entry-class "RootDomainUserClient")
|
||||
)
|
||||
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
)
|
||||
|
||||
; Needed for python multiprocessing on MacOS for the SemLock
|
||||
(allow ipc-posix-sem)
|
||||
|
||||
@@ -132,10 +147,19 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
|
||||
(allow file-read* file-write*
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/zero")
|
||||
(literal "/dev/tty")
|
||||
(subpath "/dev/fd")
|
||||
(subpath "/tmp")
|
||||
(subpath "/private/tmp")
|
||||
)
|
||||
|
||||
(allow file-read-metadata
|
||||
(literal "/")
|
||||
(subpath "/var")
|
||||
(subpath "/private/var")
|
||||
(subpath "/dev")
|
||||
)
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getProactiveToolSuggestions,
|
||||
isNetworkReliantCommand,
|
||||
} from './proactivePermissions.js';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs', () => ({
|
||||
default: {
|
||||
promises: {
|
||||
access: vi.fn(),
|
||||
},
|
||||
constants: {
|
||||
F_OK: 0,
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
access: vi.fn(),
|
||||
},
|
||||
constants: {
|
||||
F_OK: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('proactivePermissions', () => {
|
||||
const homeDir = '/Users/testuser';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(homeDir);
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
});
|
||||
|
||||
describe('isNetworkReliantCommand', () => {
|
||||
it('should return true for always-network tools', () => {
|
||||
expect(isNetworkReliantCommand('ssh')).toBe(true);
|
||||
expect(isNetworkReliantCommand('git')).toBe(true);
|
||||
expect(isNetworkReliantCommand('curl')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for network-heavy node subcommands', () => {
|
||||
expect(isNetworkReliantCommand('npm', 'install')).toBe(true);
|
||||
expect(isNetworkReliantCommand('yarn', 'add')).toBe(true);
|
||||
expect(isNetworkReliantCommand('bun', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for local node subcommands', () => {
|
||||
expect(isNetworkReliantCommand('npm', 'test')).toBe(false);
|
||||
expect(isNetworkReliantCommand('yarn', 'run')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown tools', () => {
|
||||
expect(isNetworkReliantCommand('ls')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProactiveToolSuggestions', () => {
|
||||
it('should return undefined for unknown tools', async () => {
|
||||
expect(await getProactiveToolSuggestions('ls')).toBeUndefined();
|
||||
expect(await getProactiveToolSuggestions('node')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return permissions for npm if paths exist', async () => {
|
||||
vi.mocked(fs.promises.access).mockImplementation(
|
||||
(p: fs.PathLike, _mode?: number) => {
|
||||
const pathStr = p.toString();
|
||||
if (
|
||||
pathStr === path.join(homeDir, '.npm') ||
|
||||
pathStr === path.join(homeDir, '.cache') ||
|
||||
pathStr === path.join(homeDir, '.npmrc')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = await getProactiveToolSuggestions('npm');
|
||||
expect(permissions).toBeDefined();
|
||||
expect(permissions?.network).toBe(true);
|
||||
// .npmrc should be read-only
|
||||
expect(permissions?.fileSystem?.read).toContain(
|
||||
path.join(homeDir, '.npmrc'),
|
||||
);
|
||||
expect(permissions?.fileSystem?.write).not.toContain(
|
||||
path.join(homeDir, '.npmrc'),
|
||||
);
|
||||
// .npm should be read-write
|
||||
expect(permissions?.fileSystem?.read).toContain(
|
||||
path.join(homeDir, '.npm'),
|
||||
);
|
||||
expect(permissions?.fileSystem?.write).toContain(
|
||||
path.join(homeDir, '.npm'),
|
||||
);
|
||||
// .cache should be read-write
|
||||
expect(permissions?.fileSystem?.write).toContain(
|
||||
path.join(homeDir, '.cache'),
|
||||
);
|
||||
// should NOT contain .ssh or .gitconfig for npm
|
||||
expect(permissions?.fileSystem?.read).not.toContain(
|
||||
path.join(homeDir, '.ssh'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should grant network access and suggest primary cache paths even if they do not exist', async () => {
|
||||
vi.mocked(fs.promises.access).mockRejectedValue(new Error('ENOENT'));
|
||||
const permissions = await getProactiveToolSuggestions('npm');
|
||||
expect(permissions).toBeDefined();
|
||||
expect(permissions?.network).toBe(true);
|
||||
expect(permissions?.fileSystem?.write).toContain(
|
||||
path.join(homeDir, '.npm'),
|
||||
);
|
||||
// .cache is optional and should NOT be included if it doesn't exist
|
||||
expect(permissions?.fileSystem?.write).not.toContain(
|
||||
path.join(homeDir, '.cache'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest .ssh and .gitconfig only for git', async () => {
|
||||
vi.mocked(fs.promises.access).mockImplementation(
|
||||
(p: fs.PathLike, _mode?: number) => {
|
||||
const pathStr = p.toString();
|
||||
if (
|
||||
pathStr === path.join(homeDir, '.ssh') ||
|
||||
pathStr === path.join(homeDir, '.gitconfig')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = await getProactiveToolSuggestions('git');
|
||||
expect(permissions?.network).toBe(true);
|
||||
expect(permissions?.fileSystem?.read).toContain(
|
||||
path.join(homeDir, '.ssh'),
|
||||
);
|
||||
expect(permissions?.fileSystem?.read).toContain(
|
||||
path.join(homeDir, '.gitconfig'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest .ssh but NOT .gitconfig for ssh', async () => {
|
||||
vi.mocked(fs.promises.access).mockImplementation(
|
||||
(p: fs.PathLike, _mode?: number) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === path.join(homeDir, '.ssh')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = await getProactiveToolSuggestions('ssh');
|
||||
expect(permissions?.network).toBe(true);
|
||||
expect(permissions?.fileSystem?.read).toContain(
|
||||
path.join(homeDir, '.ssh'),
|
||||
);
|
||||
expect(permissions?.fileSystem?.read).not.toContain(
|
||||
path.join(homeDir, '.gitconfig'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Windows specific paths', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const appData = 'C:\\Users\\testuser\\AppData\\Roaming';
|
||||
vi.stubEnv('AppData', appData);
|
||||
|
||||
vi.mocked(fs.promises.access).mockImplementation(
|
||||
(p: fs.PathLike, _mode?: number) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === path.join(appData, 'npm')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = await getProactiveToolSuggestions('npm.exe');
|
||||
expect(permissions).toBeDefined();
|
||||
expect(permissions?.fileSystem?.read).toContain(
|
||||
path.join(appData, 'npm'),
|
||||
);
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should include bun, pnpm, and yarn specific paths', async () => {
|
||||
vi.mocked(fs.promises.access).mockResolvedValue(undefined);
|
||||
|
||||
const bun = await getProactiveToolSuggestions('bun');
|
||||
expect(bun?.fileSystem?.read).toContain(path.join(homeDir, '.bun'));
|
||||
expect(bun?.fileSystem?.read).not.toContain(path.join(homeDir, '.yarn'));
|
||||
|
||||
const yarn = await getProactiveToolSuggestions('yarn');
|
||||
expect(yarn?.fileSystem?.read).toContain(path.join(homeDir, '.yarn'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { type SandboxPermissions } from '../../services/sandboxManager.js';
|
||||
|
||||
const NETWORK_RELIANT_TOOLS = new Set([
|
||||
'npm',
|
||||
'npx',
|
||||
'yarn',
|
||||
'pnpm',
|
||||
'bun',
|
||||
'git',
|
||||
'ssh',
|
||||
'scp',
|
||||
'sftp',
|
||||
'curl',
|
||||
'wget',
|
||||
]);
|
||||
|
||||
const NODE_ECOSYSTEM_TOOLS = new Set(['npm', 'npx', 'yarn', 'pnpm', 'bun']);
|
||||
|
||||
const NETWORK_HEAVY_SUBCOMMANDS = new Set([
|
||||
'install',
|
||||
'i',
|
||||
'ci',
|
||||
'update',
|
||||
'up',
|
||||
'publish',
|
||||
'add',
|
||||
'remove',
|
||||
'outdated',
|
||||
'audit',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns true if the command or subcommand is known to be network-reliant.
|
||||
*/
|
||||
export function isNetworkReliantCommand(
|
||||
commandName: string,
|
||||
subCommand?: string,
|
||||
): boolean {
|
||||
const normalizedCommand = commandName.toLowerCase().replace(/\.exe$/, '');
|
||||
if (!NETWORK_RELIANT_TOOLS.has(normalizedCommand)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Node ecosystem tools only need network for specific subcommands
|
||||
if (NODE_ECOSYSTEM_TOOLS.has(normalizedCommand)) {
|
||||
// Bare yarn/bun/pnpm is an alias for install
|
||||
if (
|
||||
!subCommand &&
|
||||
(normalizedCommand === 'yarn' ||
|
||||
normalizedCommand === 'bun' ||
|
||||
normalizedCommand === 'pnpm')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!!subCommand && NETWORK_HEAVY_SUBCOMMANDS.has(subCommand.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Other tools (ssh, git, curl, etc.) are always network-reliant
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns suggested additional permissions for network-reliant tools
|
||||
* based on common configuration and cache directories.
|
||||
*/
|
||||
/**
|
||||
* Returns suggested additional permissions for network-reliant tools
|
||||
* based on common configuration and cache directories.
|
||||
*/
|
||||
export async function getProactiveToolSuggestions(
|
||||
commandName: string,
|
||||
): Promise<SandboxPermissions | undefined> {
|
||||
const normalizedCommand = commandName.toLowerCase().replace(/\.exe$/, '');
|
||||
if (!NETWORK_RELIANT_TOOLS.has(normalizedCommand)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const readOnlyPaths: string[] = [];
|
||||
const primaryCachePaths: string[] = [];
|
||||
const optionalCachePaths: string[] = [];
|
||||
|
||||
if (normalizedCommand === 'npm' || normalizedCommand === 'npx') {
|
||||
readOnlyPaths.push(path.join(home, '.npmrc'));
|
||||
primaryCachePaths.push(path.join(home, '.npm'));
|
||||
optionalCachePaths.push(path.join(home, '.node-gyp'));
|
||||
optionalCachePaths.push(path.join(home, '.cache'));
|
||||
} else if (normalizedCommand === 'yarn') {
|
||||
readOnlyPaths.push(path.join(home, '.yarnrc'));
|
||||
readOnlyPaths.push(path.join(home, '.yarnrc.yml'));
|
||||
primaryCachePaths.push(path.join(home, '.yarn'));
|
||||
primaryCachePaths.push(path.join(home, '.config', 'yarn'));
|
||||
optionalCachePaths.push(path.join(home, '.cache'));
|
||||
} else if (normalizedCommand === 'pnpm') {
|
||||
readOnlyPaths.push(path.join(home, '.npmrc'));
|
||||
primaryCachePaths.push(path.join(home, '.pnpm-store'));
|
||||
primaryCachePaths.push(path.join(home, '.config', 'pnpm'));
|
||||
optionalCachePaths.push(path.join(home, '.cache'));
|
||||
} else if (normalizedCommand === 'bun') {
|
||||
readOnlyPaths.push(path.join(home, '.bunfig.toml'));
|
||||
primaryCachePaths.push(path.join(home, '.bun'));
|
||||
optionalCachePaths.push(path.join(home, '.cache'));
|
||||
} else if (normalizedCommand === 'git') {
|
||||
readOnlyPaths.push(path.join(home, '.ssh'));
|
||||
readOnlyPaths.push(path.join(home, '.gitconfig'));
|
||||
optionalCachePaths.push(path.join(home, '.cache'));
|
||||
} else if (
|
||||
normalizedCommand === 'ssh' ||
|
||||
normalizedCommand === 'scp' ||
|
||||
normalizedCommand === 'sftp'
|
||||
) {
|
||||
readOnlyPaths.push(path.join(home, '.ssh'));
|
||||
}
|
||||
|
||||
// Windows specific paths
|
||||
if (os.platform() === 'win32') {
|
||||
const appData = process.env['AppData'];
|
||||
const localAppData = process.env['LocalAppData'];
|
||||
if (normalizedCommand === 'npm' || normalizedCommand === 'npx') {
|
||||
if (appData) {
|
||||
primaryCachePaths.push(path.join(appData, 'npm'));
|
||||
optionalCachePaths.push(path.join(appData, 'npm-cache'));
|
||||
}
|
||||
if (localAppData) {
|
||||
optionalCachePaths.push(path.join(localAppData, 'npm-cache'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalReadOnly: string[] = [];
|
||||
const finalReadWrite: string[] = [];
|
||||
|
||||
const checkExists = async (p: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.promises.access(p, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readOnlyChecks = await Promise.all(
|
||||
readOnlyPaths.map(async (p) => ({ path: p, exists: await checkExists(p) })),
|
||||
);
|
||||
for (const { path: p, exists } of readOnlyChecks) {
|
||||
if (exists) {
|
||||
finalReadOnly.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of primaryCachePaths) {
|
||||
finalReadWrite.push(p);
|
||||
}
|
||||
|
||||
const optionalChecks = await Promise.all(
|
||||
optionalCachePaths.map(async (p) => ({
|
||||
path: p,
|
||||
exists: await checkExists(p),
|
||||
})),
|
||||
);
|
||||
for (const { path: p, exists } of optionalChecks) {
|
||||
if (exists) {
|
||||
finalReadWrite.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileSystem:
|
||||
finalReadOnly.length > 0 || finalReadWrite.length > 0
|
||||
? {
|
||||
read: [...finalReadOnly, ...finalReadWrite],
|
||||
write: finalReadWrite,
|
||||
}
|
||||
: undefined,
|
||||
network: true,
|
||||
};
|
||||
}
|
||||
@@ -40,4 +40,80 @@ describe('parsePosixSandboxDenials', () => {
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should detect npm specific file system denials', () => {
|
||||
const output = `
|
||||
npm verbose logfile could not be created: Error: EPERM: operation not permitted, open '/Users/galzahavi/.npm/_logs/2026-04-01T02_47_18_624Z-debug-0.log'
|
||||
`;
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output,
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain(
|
||||
'/Users/galzahavi/.npm/_logs/2026-04-01T02_47_18_624Z-debug-0.log',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect npm specific path errors', () => {
|
||||
const output = `
|
||||
npm error code EPERM
|
||||
npm error syscall open
|
||||
npm error path /Users/galzahavi/.npm/_cacache/tmp/ccf579a2
|
||||
`;
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output,
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain(
|
||||
'/Users/galzahavi/.npm/_cacache/tmp/ccf579a2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect network denials with ENOTFOUND', () => {
|
||||
const output = `
|
||||
npm http fetch GET https://registry.npmjs.org/2 attempt 1 failed with ENOTFOUND
|
||||
`;
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output,
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.network).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect non-verbose npm path errors', () => {
|
||||
const output = `
|
||||
npm ERR! code EPERM
|
||||
npm ERR! syscall open
|
||||
npm ERR! path /Users/galzahavi/.npm/_cacache/tmp/ccf579a2
|
||||
`;
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output,
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain(
|
||||
'/Users/galzahavi/.npm/_cacache/tmp/ccf579a2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect pnpm specific network errors', () => {
|
||||
const output = `
|
||||
ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/nonexistent: Not Found
|
||||
`;
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output,
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.network).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect pnpm specific file system errors', () => {
|
||||
const output = `
|
||||
EACCES: permission denied, mkdir '/Users/galzahavi/.pnpm-store/v3'
|
||||
`;
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output,
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('/Users/galzahavi/.pnpm-store/v3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ export function parsePosixSandboxDenials(
|
||||
|
||||
const isFileDenial = [
|
||||
'operation not permitted',
|
||||
'permission denied',
|
||||
'eperm',
|
||||
'eacces',
|
||||
'vim:e303',
|
||||
'should be read/write',
|
||||
'sandbox_apply',
|
||||
@@ -32,6 +35,17 @@ export function parsePosixSandboxDenials(
|
||||
'could not resolve host',
|
||||
'connection refused',
|
||||
'no address associated with hostname',
|
||||
'econnrefused',
|
||||
'enotfound',
|
||||
'etimedout',
|
||||
'econnreset',
|
||||
'network error',
|
||||
'getaddrinfo',
|
||||
'socket hang up',
|
||||
'connect-timeout',
|
||||
'err_pnpm_fetch',
|
||||
'err_pnpm_no_matching_version',
|
||||
"syscall: 'listen'",
|
||||
].some((keyword) => combined.includes(keyword));
|
||||
|
||||
if (!isFileDenial && !isNetworkDenial) {
|
||||
@@ -40,17 +54,31 @@ export function parsePosixSandboxDenials(
|
||||
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
// Extract denied paths (POSIX absolute paths)
|
||||
const regex =
|
||||
/(?:^|\s)['"]?(\/[\w.-/]+)['"]?:\s*[Oo]peration not permitted/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(output)) !== null) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
if (errorOutput) {
|
||||
while ((match = regex.exec(errorOutput)) !== null) {
|
||||
// Extract denied paths (POSIX absolute paths or home-relative paths starting with ~)
|
||||
const regexes = [
|
||||
// format: /path: operation not permitted
|
||||
/(?:^|\s)['"]?((?:\/|~)[\w.\-/:~]+)['"]?:\s*[Oo]peration not permitted/gi,
|
||||
// format: operation not permitted, open '/path'
|
||||
/[Oo]peration not permitted,\s*open\s*['"]?((?:\/|~)[\w.\-/:~]+)['"]?/gi,
|
||||
// format: permission denied, open '/path'
|
||||
/[Pp]ermission denied,\s*open\s*['"]?((?:\/|~)[\w.\-/:~]+)['"]?/gi,
|
||||
// format: npm error path /path or npm ERR! path /path
|
||||
/npm\s+(?:error|ERR!)\s+path\s+((?:\/|~)[\w.\-/:~]+)/gi,
|
||||
// format: EACCES: permission denied, mkdir '/path'
|
||||
/EACCES:\s*permission denied,\s*\w+\s*['"]?((?:\/|~)[\w.\-/:~]+)['"]?/gi,
|
||||
];
|
||||
|
||||
for (const regex of regexes) {
|
||||
let match;
|
||||
while ((match = regex.exec(output)) !== null) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
if (errorOutput) {
|
||||
regex.lastIndex = 0; // Reset for next use
|
||||
while ((match = regex.exec(errorOutput)) !== null) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback heuristic: look for any absolute path in the output if it was a file denial
|
||||
|
||||
@@ -86,6 +86,35 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(result.args[0]).toBe('1');
|
||||
});
|
||||
|
||||
it('should NOT whitelist drive roots in YOLO mode', async () => {
|
||||
manager = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
modeConfig: { readonly: false, allowOverrides: true, yolo: true },
|
||||
forbiddenPaths: async () => [],
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'whoami',
|
||||
args: [],
|
||||
cwd: testCwd,
|
||||
env: {},
|
||||
};
|
||||
|
||||
await manager.prepareCommand(req);
|
||||
|
||||
// Verify spawnAsync was called for icacls
|
||||
const icaclsCalls = vi
|
||||
.mocked(spawnAsync)
|
||||
.mock.calls.filter((call) => call[0] === 'icacls');
|
||||
|
||||
// Should NOT have called icacls for C:\, D:\, etc.
|
||||
const driveRootCalls = icaclsCalls.filter(
|
||||
(call) =>
|
||||
typeof call[1]?.[0] === 'string' && /^[A-Z]:\\$/.test(call[1][0]),
|
||||
);
|
||||
expect(driveRootCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle network access from additionalPermissions', async () => {
|
||||
const req: SandboxRequest = {
|
||||
command: 'whoami',
|
||||
|
||||
@@ -72,6 +72,10 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return parseWindowsSandboxDenials(result);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a file or directory exists.
|
||||
*/
|
||||
@@ -240,6 +244,8 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
];
|
||||
}
|
||||
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getCommandName(command, args);
|
||||
const persistentPermissions = allowOverrides
|
||||
@@ -259,6 +265,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
],
|
||||
},
|
||||
network:
|
||||
isYolo ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
@@ -301,7 +308,9 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
// Grant "Low Mandatory Level" read/write access to allowedPaths.
|
||||
for (const allowedPath of allowedPaths) {
|
||||
const resolved = await tryRealpath(allowedPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
try {
|
||||
await fs.promises.access(resolved, fs.constants.F_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Allowed path does not exist: ${resolved}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
@@ -316,7 +325,9 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
);
|
||||
for (const writePath of additionalWritePaths) {
|
||||
const resolved = await tryRealpath(writePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
try {
|
||||
await fs.promises.access(resolved, fs.constants.F_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Additional write path does not exist: ${resolved}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
|
||||
Reference in New Issue
Block a user