refactor(core): abstract OS sandbox managers and fix virtual command permissions

This commit is contained in:
Emily Hedlund
2026-04-13 12:58:29 -04:00
parent 3c5f4b7515
commit f030dbb1a7
16 changed files with 2161 additions and 2107 deletions
@@ -0,0 +1,315 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
AbstractOsSandboxManager,
resolveSandboxPaths,
} from './abstractOsSandboxManager.js';
import type {
GlobalSandboxOptions,
SandboxedCommand,
SandboxPermissions,
SandboxRequest,
} from '../services/sandboxManager.js';
import type { ResolvedSandboxPaths } from './abstractOsSandboxManager.js';
class TestSandboxManager extends AbstractOsSandboxManager {
override isKnownSafeCommand = vi.fn().mockReturnValue(false);
override isDangerousCommand = vi.fn().mockReturnValue(false);
override parseDenials = vi.fn().mockReturnValue(undefined);
protected get isCaseInsensitive(): boolean {
return true;
}
protected override resolveFinalCommand = vi
.fn()
.mockImplementation((req) => ({
command: req.command,
args: req.args,
}));
protected override buildSandboxedExecution = vi.fn().mockResolvedValue({
program: 'test-program',
args: [],
env: {},
} as SandboxedCommand);
constructor(options: GlobalSandboxOptions) {
super(options);
}
}
describe('AbstractOsSandboxManager', () => {
let mockWorkspace: string;
let manager: TestSandboxManager;
beforeEach(() => {
mockWorkspace = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-base-sandbox-test-')),
);
manager = new TestSandboxManager({ workspace: mockWorkspace });
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(mockWorkspace, { recursive: true, force: true });
});
describe('isToolApproved', () => {
it('should return false if no approved tools are configured', () => {
const emptyManager = new TestSandboxManager({ workspace: mockWorkspace });
expect(emptyManager['isToolApproved']('ls')).toBe(false);
});
it('should return true if the tool matches exactly', () => {
const managerWithTools = new TestSandboxManager({
workspace: mockWorkspace,
modeConfig: { approvedTools: ['git'] },
});
expect(managerWithTools['isToolApproved']('git')).toBe(true);
expect(managerWithTools['isToolApproved']('ls')).toBe(false);
});
it('should respect case-insensitivity when requested', () => {
const managerWithTools = new TestSandboxManager({
workspace: mockWorkspace,
modeConfig: { approvedTools: ['GIT'] },
});
expect(managerWithTools['isToolApproved']('git', true)).toBe(true);
expect(managerWithTools['isToolApproved']('git', false)).toBe(false);
});
});
describe('touch', () => {
it('should create a directory if isDirectory is true', () => {
const targetDir = path.join(mockWorkspace, 'newDir');
manager['touch'](targetDir, true);
expect(fs.existsSync(targetDir)).toBe(true);
expect(fs.lstatSync(targetDir).isDirectory()).toBe(true);
});
it('should create an empty file and its parent directories if isDirectory is false', () => {
const targetFile = path.join(mockWorkspace, 'newDir', 'newFile.txt');
manager['touch'](targetFile, false);
expect(fs.existsSync(targetFile)).toBe(true);
expect(fs.lstatSync(targetFile).isFile()).toBe(true);
});
it('should do nothing if the path already exists', () => {
const targetDir = path.join(mockWorkspace, 'existingDir');
fs.mkdirSync(targetDir);
expect(() => manager['touch'](targetDir, true)).not.toThrow();
});
it('should not throw if the path exists as a broken symlink', () => {
const targetLink = path.join(mockWorkspace, 'brokenLink');
const nonExistentTarget = path.join(mockWorkspace, 'nonExistent');
fs.symlinkSync(nonExistentTarget, targetLink);
expect(() => manager['touch'](targetLink, false)).not.toThrow();
});
});
describe('prepareCommand', () => {
it('applies environment sanitization', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {
SAFE_VAR: '1',
API_KEY: 'sensitive',
},
policy: {
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
blockedEnvironmentVariables: ['API_KEY'],
allowedEnvironmentVariables: ['SAFE_VAR'],
},
},
});
const call = manager['buildSandboxedExecution'].mock.calls[0];
const sanitizedEnv = call[2]; // 3rd arg is sanitizedEnv
expect(sanitizedEnv['SAFE_VAR']).toBe('1');
expect(sanitizedEnv['API_KEY']).toBeUndefined();
});
it('rejects overrides in plan mode', async () => {
const planManager = new TestSandboxManager({
workspace: mockWorkspace,
modeConfig: { readonly: true, allowOverrides: false },
});
await expect(
planManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: { additionalPermissions: { network: true } },
}),
).rejects.toThrow(/Cannot override/);
});
it('ensures governance files exist before execution', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
});
expect(fs.existsSync(path.join(mockWorkspace, '.gitignore'))).toBe(true);
expect(fs.existsSync(path.join(mockWorkspace, '.geminiignore'))).toBe(
true,
);
expect(fs.existsSync(path.join(mockWorkspace, '.git'))).toBe(true);
expect(fs.lstatSync(path.join(mockWorkspace, '.git')).isDirectory()).toBe(
true,
);
});
it('resolves path and permissions, passing them to buildSandboxedExecution', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
allowedPaths: ['/tmp/allowed'],
networkAccess: true,
},
});
expect(manager['buildSandboxedExecution']).toHaveBeenCalled();
const callArgs = manager['buildSandboxedExecution'].mock.calls[0];
const mergedPermissions = callArgs[3] as SandboxPermissions;
const resolvedPaths = callArgs[4] as ResolvedSandboxPaths;
expect(mergedPermissions.network).toBe(true);
// It expands allowedPaths into its original and resolved forms (which defaults to original on missing files unless symlinked)
expect(resolvedPaths.policyAllowed).toContain('/tmp/allowed');
});
it('resolves workspaceWrite to true when in yolo mode', async () => {
const yoloManager = new TestSandboxManager({
workspace: mockWorkspace,
modeConfig: { readonly: true, allowOverrides: true, yolo: true },
});
await yoloManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
});
const callArgs = yoloManager['buildSandboxedExecution'].mock.calls[0];
const workspaceWrite = callArgs[5] as boolean;
expect(workspaceWrite).toBe(true);
});
});
describe('resolveSandboxPaths', () => {
it('should resolve allowed and forbidden paths', async () => {
const workspace = path.resolve('/workspace');
const forbidden = path.join(workspace, 'forbidden');
const allowed = path.join(workspace, 'allowed');
const options = {
workspace,
forbiddenPaths: async () => [forbidden],
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [allowed],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([allowed]);
expect(result.forbidden).toEqual([forbidden]);
});
it('should filter out workspace from allowed paths', async () => {
const workspace = path.resolve('/workspace');
const other = path.resolve('/other/path');
const options = {
workspace,
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace, workspace + path.sep, other],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([other]);
});
it('should prioritize forbidden paths over allowed paths', async () => {
const workspace = path.resolve('/workspace');
const secret = path.join(workspace, 'secret');
const normal = path.join(workspace, 'normal');
const options = {
workspace,
forbiddenPaths: async () => [secret],
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [secret, normal],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([normal]);
expect(result.forbidden).toEqual([secret]);
});
it('should handle case-insensitive conflicts on supported platforms', async () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
const workspace = path.resolve('/workspace');
const secretUpper = path.join(workspace, 'SECRET');
const secretLower = path.join(workspace, 'secret');
const options = {
workspace,
forbiddenPaths: async () => [secretUpper],
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [secretLower],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([]);
expect(result.forbidden).toEqual([secretUpper]);
});
});
});
@@ -0,0 +1,326 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { dirname, join } from 'node:path';
import type {
SandboxManager,
GlobalSandboxOptions,
SandboxRequest,
SandboxedCommand,
SandboxPermissions,
ParsedSandboxDenial,
} from '../services/sandboxManager.js';
import type { ShellExecutionResult } from '../services/shellExecutionService.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../services/environmentSanitization.js';
import { verifySandboxOverrides } from './utils/commandUtils.js';
import {
assertValidPathString,
deduplicateAbsolutePaths,
toPathKey,
resolveToRealPath,
} from '../utils/paths.js';
import {
createSandboxDenialCache,
type SandboxDenialCache,
} from './utils/sandboxDenialUtils.js';
import { getCommandName } from '../utils/shell-utils.js';
import { resolveGitWorktreePaths } from './utils/fsUtils.js';
import { validateVirtualCommandPaths } from './utils/sandboxReadWriteUtils.js';
/**
* A structured result of fully resolved sandbox paths.
* All paths in this object are absolute, deduplicated, and expanded to include
* both the original path and its real target (if it is a symlink).
*/
export interface ResolvedSandboxPaths {
/** The primary workspace directory. */
workspace: {
/** The original path provided in the sandbox options. */
original: string;
/** The real path. */
resolved: string;
};
/** Explicitly denied paths. */
forbidden: string[];
/** Directories included globally across all commands in this sandbox session. */
globalIncludes: string[];
/** Paths explicitly allowed by the policy of the currently executing command. */
policyAllowed: string[];
/** Paths granted temporary read access by the current command's dynamic permissions. */
policyRead: string[];
/** Paths granted temporary write access by the current command's dynamic permissions. */
policyWrite: string[];
/** Auto-detected paths for git worktrees/submodules. */
gitWorktree?: {
/** The actual .git directory for this worktree. */
worktreeGitDir: string;
/** The main repository's .git directory (if applicable). */
mainGitDir?: string;
};
}
/**
* Files that represent the governance or "constitution" of the repository
* and should be write-protected in any sandbox.
*/
export const GOVERNANCE_FILES = [
{ path: '.gitignore', isDirectory: false },
{ path: '.geminiignore', isDirectory: false },
{ path: '.git', isDirectory: true },
] as const;
/**
* Files that contain sensitive secrets or credentials and should be
* completely hidden (deny read/write) in any sandbox.
*/
export const SECRET_FILES = [
{ pattern: '.env' },
{ pattern: '.env.*' },
] as const;
/**
* Resolves and sanitizes all path categories for a sandbox request.
*/
export async function resolveSandboxPaths(
options: GlobalSandboxOptions,
req: SandboxRequest,
overridePermissions?: SandboxPermissions,
): Promise<ResolvedSandboxPaths> {
/**
* Helper that expands each path to include its realpath (if it's a symlink)
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
*/
const expand = (paths?: string[] | null): string[] => {
if (!paths || paths.length === 0) return [];
const expanded = paths.flatMap((p) => {
try {
const resolved = resolveToRealPath(p);
return resolved === p ? [p] : [p, resolved];
} catch {
return [p];
}
});
return deduplicateAbsolutePaths(expanded);
};
const forbidden = expand(await options.forbiddenPaths?.());
const globalIncludes = expand(options.includeDirectories);
const policyAllowed = expand(req.policy?.allowedPaths);
const policyRead = expand(overridePermissions?.fileSystem?.read);
const policyWrite = expand(overridePermissions?.fileSystem?.write);
const resolvedWorkspace = resolveToRealPath(options.workspace);
const workspaceIdentities = new Set(
[options.workspace, resolvedWorkspace].map(toPathKey),
);
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
const { worktreeGitDir, mainGitDir } =
await resolveGitWorktreePaths(resolvedWorkspace);
const gitWorktree = worktreeGitDir
? { gitWorktree: { worktreeGitDir, mainGitDir } }
: undefined;
/**
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
*/
const filter = (paths: string[]) =>
paths.filter((p) => {
const identity = toPathKey(p);
return (
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
);
});
return {
workspace: {
original: options.workspace,
resolved: resolvedWorkspace,
},
forbidden,
globalIncludes: filter(globalIncludes),
policyAllowed: filter(policyAllowed),
policyRead: filter(policyRead),
policyWrite: filter(policyWrite),
...gitWorktree,
};
}
export abstract class AbstractOsSandboxManager implements SandboxManager {
protected readonly denialCache: SandboxDenialCache =
createSandboxDenialCache();
constructor(protected readonly options: GlobalSandboxOptions) {}
abstract isKnownSafeCommand(args: string[]): boolean;
abstract isDangerousCommand(args: string[]): boolean;
abstract parseDenials(
result: ShellExecutionResult,
): ParsedSandboxDenial | undefined;
getWorkspace(): string {
return this.options.workspace;
}
getOptions(): GlobalSandboxOptions {
return this.options;
}
protected touch(filePath: string, isDirectory: boolean): void {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
protected isToolApproved(
toolName: string,
caseInsensitive: boolean = false,
): boolean {
const approvedTools = this.options.modeConfig?.approvedTools;
if (!approvedTools || approvedTools.length === 0) return false;
if (caseInsensitive) {
return approvedTools.some(
(t) => t.toLowerCase() === toolName.toLowerCase(),
);
}
return approvedTools.includes(toolName);
}
protected abstract get isCaseInsensitive(): boolean;
protected resolveFinalCommand(
req: SandboxRequest,
_permissions: SandboxPermissions,
_resolvedPaths: ResolvedSandboxPaths,
): { command: string; args: string[] } {
return { command: req.command, args: req.args };
}
protected abstract buildSandboxedExecution(
req: SandboxRequest,
finalCmd: { command: string; args: string[] },
sanitizedEnv: NodeJS.ProcessEnv,
mergedAdditional: SandboxPermissions,
resolvedPaths: ResolvedSandboxPaths,
workspaceWrite: boolean,
): Promise<SandboxedCommand>;
protected adjustPermissionsForVirtualCommands(
req: SandboxRequest,
permissions: SandboxPermissions,
): void {
if (req.command === '__read' || req.command === '__write') {
validateVirtualCommandPaths(req, this.options.workspace, [
...(req.policy?.allowedPaths || []),
...(this.options.includeDirectories || []),
]);
if (req.command === '__read') {
permissions.fileSystem!.read!.push(...req.args);
} else {
permissions.fileSystem!.write!.push(...req.args);
}
}
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
verifySandboxOverrides(allowOverrides, req.policy);
const isYolo = this.options.modeConfig?.yolo ?? false;
const commandName = await getCommandName(req.command, req.args);
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? this.isToolApproved(commandName, this.isCaseInsensitive)
: false;
const workspaceWrite: boolean = !isReadonlyMode || isApproved || isYolo;
const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
// Fetch persistent approvals for this command
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
defaultNetwork ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
this.adjustPermissionsForVirtualCommands(req, mergedAdditional);
const resolvedPaths = await resolveSandboxPaths(
this.options,
req,
mergedAdditional,
);
const { command: finalCommand, args: finalArgs } = this.resolveFinalCommand(
req,
mergedAdditional,
resolvedPaths,
);
for (const file of GOVERNANCE_FILES) {
const filePath = join(resolvedPaths.workspace.resolved, file.path);
this.touch(filePath, file.isDirectory);
}
return this.buildSandboxedExecution(
req,
{ command: finalCommand, args: finalArgs },
sanitizedEnv,
mergedAdditional,
resolvedPaths,
workspaceWrite,
);
}
}
@@ -5,9 +5,15 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import fs from 'node:fs';
import path from 'node:path';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import {
isKnownSafeCommand as isPosixSafeCommand,
isDangerousCommand as isPosixDangerousCommand,
} from '../utils/commandSafety.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
@@ -57,6 +63,16 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
};
});
vi.mock('../utils/commandSafety.js', () => ({
isKnownSafeCommand: vi.fn(),
isDangerousCommand: vi.fn(),
}));
vi.mock('../utils/sandboxDenialUtils.js', () => ({
parsePosixSandboxDenials: vi.fn(),
createSandboxDenialCache: vi.fn().mockReturnValue({}),
}));
describe('LinuxSandboxManager', () => {
const workspace = '/home/user/workspace';
let manager: LinuxSandboxManager;
@@ -72,6 +88,48 @@ describe('LinuxSandboxManager', () => {
vi.restoreAllMocks();
});
describe('isKnownSafeCommand', () => {
it('should return true if the tool is in the approvedTools list', () => {
const managerWithTools = new LinuxSandboxManager({
workspace,
modeConfig: { approvedTools: ['git'] },
});
expect(managerWithTools.isKnownSafeCommand(['git'])).toBe(true);
expect(isPosixSafeCommand).not.toHaveBeenCalled();
});
it('should fallback to isPosixSafeCommand for non-approved tools', () => {
vi.mocked(isPosixSafeCommand).mockReturnValue(true);
expect(manager.isKnownSafeCommand(['ls'])).toBe(true);
expect(isPosixSafeCommand).toHaveBeenCalledWith(['ls']);
});
});
describe('isDangerousCommand', () => {
it('should delegate to isPosixDangerousCommand', () => {
vi.mocked(isPosixDangerousCommand).mockReturnValue(true);
expect(manager.isDangerousCommand(['rm', '-rf', '/'])).toBe(true);
expect(isPosixDangerousCommand).toHaveBeenCalledWith(['rm', '-rf', '/']);
});
});
describe('parseDenials', () => {
it('should delegate to parsePosixSandboxDenials', () => {
const mockResult = {
exitCode: 1,
output: 'denied',
} as unknown as ShellExecutionResult;
const mockParsed = { filePaths: ['/tmp/blocked'] };
vi.mocked(parsePosixSandboxDenials).mockReturnValue(mockParsed);
expect(manager.parseDenials(mockResult)).toBe(mockParsed);
expect(parsePosixSandboxDenials).toHaveBeenCalledWith(
mockResult,
manager['denialCache'],
);
});
});
describe('prepareCommand', () => {
it('wraps the command and arguments correctly using a temporary file', async () => {
const result = await manager.prepareCommand({
@@ -149,21 +207,5 @@ describe('LinuxSandboxManager', () => {
expect(result.args[result.args.length - 2]).toBe('/bin/cat');
expect(result.args[result.args.length - 1]).toBe(testFile);
});
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: { networkAccess: true },
}),
).rejects.toThrow(/Cannot override/);
});
});
});
@@ -5,40 +5,27 @@
*/
import fs from 'node:fs';
import { join, dirname } from 'node:path';
import { join } from 'node:path';
import os from 'node:os';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
type SandboxPermissions,
GOVERNANCE_FILES,
type ParsedSandboxDenial,
resolveSandboxPaths,
import type {
GlobalSandboxOptions,
SandboxRequest,
SandboxedCommand,
SandboxPermissions,
ParsedSandboxDenial,
} from '../../services/sandboxManager.js';
import {
type ResolvedSandboxPaths,
AbstractOsSandboxManager,
} from '../abstractOsSandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import {
isStrictlyApproved,
verifySandboxOverrides,
getCommandName,
} from '../utils/commandUtils.js';
import { assertValidPathString } from '../../utils/paths.js';
import {
isKnownSafeCommand,
isDangerousCommand,
} from '../utils/commandSafety.js';
import {
parsePosixSandboxDenials,
createSandboxDenialCache,
type SandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
import {
isKnownSafeCommand as isPosixSafeCommand,
isDangerousCommand as isPosixDangerousCommand,
} from '../utils/commandSafety.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
let cachedBpfPath: string | undefined;
@@ -109,54 +96,42 @@ function getSeccompBpfPath(): string {
return bpfPath;
}
/**
* Ensures a file or directory exists.
*/
function touch(filePath: string, isDirectory: boolean) {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
fs.mkdirSync(dirname(filePath), { recursive: true });
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
export class LinuxSandboxManager implements SandboxManager {
export class LinuxSandboxManager extends AbstractOsSandboxManager {
private static maskFilePath: string | undefined;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {}
constructor(options: GlobalSandboxOptions) {
super(options);
}
isKnownSafeCommand(args: string[]): boolean {
return isKnownSafeCommand(args);
const toolName = args[0];
if (toolName && this.isToolApproved(toolName)) {
return true;
}
return isPosixSafeCommand(args);
}
isDangerousCommand(args: string[]): boolean {
return isDangerousCommand(args);
return isPosixDangerousCommand(args);
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parsePosixSandboxDenials(result, this.denialCache);
}
getWorkspace(): string {
return this.options.workspace;
protected get isCaseInsensitive(): boolean {
return false;
}
getOptions(): GlobalSandboxOptions {
return this.options;
protected override resolveFinalCommand(
req: SandboxRequest,
_permissions: SandboxPermissions,
_resolvedPaths: ResolvedSandboxPaths,
): { command: string; args: string[] } {
return handleReadWriteCommands(req);
}
private getMaskFilePath(): string {
@@ -184,85 +159,14 @@ export class LinuxSandboxManager implements SandboxManager {
return maskPath;
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
verifySandboxOverrides(allowOverrides, req.policy);
let command = req.command;
let args = req.args;
// Translate virtual commands for sandboxed file system access
if (command === '__read') {
command = 'cat';
} else if (command === '__write') {
command = 'sh';
args = ['-c', 'cat > "$1"', '_', ...args];
}
const commandName = await getCommandName({ ...req, command, args });
const isApproved = allowOverrides
? await isStrictlyApproved(
{ ...req, command, args },
this.options.modeConfig?.approvedTools,
)
: false;
const isYolo = this.options.modeConfig?.yolo ?? false;
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
const networkAccess =
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
networkAccess ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
req,
mergedAdditional,
this.options.workspace,
[
...(req.policy?.allowedPaths || []),
...(this.options.includeDirectories || []),
],
);
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const resolvedPaths = await resolveSandboxPaths(
this.options,
req,
mergedAdditional,
);
for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
}
protected async buildSandboxedExecution(
req: SandboxRequest,
finalCmd: { command: string; args: string[] },
sanitizedEnv: NodeJS.ProcessEnv,
mergedAdditional: SandboxPermissions,
resolvedPaths: ResolvedSandboxPaths,
workspaceWrite: boolean,
): Promise<SandboxedCommand> {
const bwrapArgs = await buildBwrapArgs({
resolvedPaths,
workspaceWrite,
@@ -283,8 +187,8 @@ export class LinuxSandboxManager implements SandboxManager {
bpfPath,
argsPath,
'--',
finalCommand,
...finalArgs,
finalCmd.command,
...finalCmd.args,
];
return {
@@ -5,11 +5,15 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { buildBwrapArgs, type BwrapArgsOptions } from './bwrapArgsBuilder.js';
import {
buildBwrapArgs,
type BwrapArgsOptions,
getSecretFileFindArgs,
} from './bwrapArgsBuilder.js';
import fs from 'node:fs';
import * as shellUtils from '../../utils/shell-utils.js';
import os from 'node:os';
import { type ResolvedSandboxPaths } from '../../services/sandboxManager.js';
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
@@ -397,3 +401,25 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
expect(worktreeBindIndex).toBeGreaterThan(writeBindIndex);
});
});
describe('getSecretFileFindArgs', () => {
it('should return the correct find arguments array combining all SECRET_FILES patterns', () => {
const args = getSecretFileFindArgs();
expect(args[0]).toBe('(');
expect(args[args.length - 1]).toBe(')');
// Ensure it correctly handles the structure of `[ '(', '-name', '.env', '-o', '-name', '.env.*', ')' ]`
const isName = args.includes('-name');
expect(isName).toBe(true);
const envIndex = args.indexOf('.env');
expect(envIndex).toBeGreaterThan(0);
expect(args[envIndex - 1]).toBe('-name');
if (args.includes('-o')) {
const oIndex = args.indexOf('-o');
expect(args[oIndex + 1]).toBe('-name');
}
});
});
@@ -8,9 +8,9 @@ import fs from 'node:fs';
import { join, dirname } from 'node:path';
import {
GOVERNANCE_FILES,
getSecretFileFindArgs,
SECRET_FILES,
type ResolvedSandboxPaths,
} from '../../services/sandboxManager.js';
} from '../abstractOsSandboxManager.js';
import { isErrnoException } from '../utils/fsUtils.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import { debugLogger } from '../../utils/debugLogger.js';
@@ -212,3 +212,13 @@ async function getSecretFilesArgs(
}
return args;
}
export function getSecretFileFindArgs(): string[] {
const args: string[] = ['('];
SECRET_FILES.forEach((s: { pattern: string }, i: number) => {
if (i > 0) args.push('-o');
args.push('-name', s.pattern);
});
args.push(')');
return args;
}
@@ -4,12 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
import {
isKnownSafeCommand as isPosixSafeCommand,
isDangerousCommand as isPosixDangerousCommand,
} from '../utils/commandSafety.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
vi.mock('../utils/commandSafety.js', () => ({
isKnownSafeCommand: vi.fn(),
isDangerousCommand: vi.fn(),
}));
vi.mock('../utils/sandboxDenialUtils.js', () => ({
parsePosixSandboxDenials: vi.fn(),
createSandboxDenialCache: vi.fn().mockReturnValue({}),
}));
describe('MacOsSandboxManager', () => {
let mockWorkspace: string;
@@ -20,6 +36,7 @@ describe('MacOsSandboxManager', () => {
let manager: MacOsSandboxManager;
beforeEach(() => {
vi.clearAllMocks();
mockWorkspace = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-macos-test-')),
);
@@ -54,6 +71,48 @@ describe('MacOsSandboxManager', () => {
}
});
describe('isKnownSafeCommand', () => {
it('should return true if the tool is in the approvedTools list', () => {
const managerWithTools = new MacOsSandboxManager({
workspace: mockWorkspace,
modeConfig: { approvedTools: ['git'] },
});
expect(managerWithTools.isKnownSafeCommand(['git'])).toBe(true);
expect(isPosixSafeCommand).not.toHaveBeenCalled();
});
it('should fallback to isPosixSafeCommand for non-approved tools', () => {
vi.mocked(isPosixSafeCommand).mockReturnValue(true);
expect(manager.isKnownSafeCommand(['ls'])).toBe(true);
expect(isPosixSafeCommand).toHaveBeenCalledWith(['ls']);
});
});
describe('isDangerousCommand', () => {
it('should delegate to isPosixDangerousCommand', () => {
vi.mocked(isPosixDangerousCommand).mockReturnValue(true);
expect(manager.isDangerousCommand(['rm', '-rf', '/'])).toBe(true);
expect(isPosixDangerousCommand).toHaveBeenCalledWith(['rm', '-rf', '/']);
});
});
describe('parseDenials', () => {
it('should delegate to parsePosixSandboxDenials', () => {
const mockResult = {
exitCode: 1,
output: 'denied',
} as unknown as ShellExecutionResult;
const mockParsed = { filePaths: ['/tmp/blocked'] };
vi.mocked(parsePosixSandboxDenials).mockReturnValue(mockParsed);
expect(manager.parseDenials(mockResult)).toBe(mockParsed);
expect(parsePosixSandboxDenials).toHaveBeenCalledWith(
mockResult,
manager['denialCache'],
);
});
});
describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const result = await manager.prepareCommand({
@@ -99,25 +158,6 @@ describe('MacOsSandboxManager', () => {
expect(result.cwd).toBe('/test/different/cwd');
});
it('should apply environment sanitization via the default mechanisms', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {
SAFE_VAR: '1',
GITHUB_TOKEN: 'sensitive',
},
policy: {
...mockPolicy,
sanitizationConfig: { enableEnvironmentVariableRedaction: true },
},
});
expect(result.env['SAFE_VAR']).toBe('1');
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
});
it('should allow network when networkAccess is true', async () => {
await manager.prepareCommand({
command: 'echo',
@@ -132,30 +172,6 @@ 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({
workspaceWrite: true,
resolvedPaths: expect.objectContaining({
policyRead: expect.not.arrayContaining(['/']),
policyWrite: expect.not.arrayContaining(['/']),
}),
}),
);
});
describe('virtual commands', () => {
it('should translate __read to /bin/cat', async () => {
const testFile = path.join(mockWorkspace, 'file.txt');
@@ -190,125 +206,5 @@ describe('MacOsSandboxManager', () => {
expect(result.args[result.args.length - 1]).toBe(testFile);
});
});
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.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
resolvedPaths: expect.objectContaining({
workspace: { resolved: mockWorkspace, original: 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.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
resolvedPaths: expect.objectContaining({
policyAllowed: expect.arrayContaining([
'/tmp/allowed1',
'/tmp/allowed2',
]),
}),
}),
);
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: async () => ['/tmp/forbidden1'],
});
await customManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
resolvedPaths: expect.objectContaining({
forbidden: expect.arrayContaining(['/tmp/forbidden1']),
}),
}),
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: async () => ['/tmp/does-not-exist'],
});
await customManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
resolvedPaths: expect.objectContaining({
forbidden: expect.arrayContaining(['/tmp/does-not-exist']),
}),
}),
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: async () => ['/tmp/conflict'],
});
await customManager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
allowedPaths: ['/tmp/conflict'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
resolvedPaths: expect.objectContaining({
policyAllowed: [],
forbidden: expect.arrayContaining(['/tmp/conflict']),
}),
}),
);
});
});
});
});
@@ -7,148 +7,65 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
type SandboxPermissions,
type GlobalSandboxOptions,
type ParsedSandboxDenial,
resolveSandboxPaths,
import type {
SandboxRequest,
SandboxedCommand,
SandboxPermissions,
GlobalSandboxOptions,
ParsedSandboxDenial,
} from '../../services/sandboxManager.js';
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
import { initializeShellParsers } from '../../utils/shell-utils.js';
import { AbstractOsSandboxManager } from '../abstractOsSandboxManager.js';
import {
isKnownSafeCommand,
isDangerousCommand,
isKnownSafeCommand as isPosixSafeCommand,
isDangerousCommand as isPosixDangerousCommand,
} from '../utils/commandSafety.js';
import {
verifySandboxOverrides,
getCommandName as getFullCommandName,
isStrictlyApproved,
} from '../utils/commandUtils.js';
import {
parsePosixSandboxDenials,
createSandboxDenialCache,
type SandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
export class MacOsSandboxManager implements SandboxManager {
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {}
export class MacOsSandboxManager extends AbstractOsSandboxManager {
constructor(options: GlobalSandboxOptions) {
super(options);
}
isKnownSafeCommand(args: string[]): boolean {
const toolName = args[0];
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
if (toolName && approvedTools.includes(toolName)) {
if (toolName && this.isToolApproved(toolName)) {
return true;
}
return isKnownSafeCommand(args);
return isPosixSafeCommand(args);
}
isDangerousCommand(args: string[]): boolean {
return isDangerousCommand(args);
return isPosixDangerousCommand(args);
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parsePosixSandboxDenials(result, this.denialCache);
}
getWorkspace(): string {
return this.options.workspace;
protected get isCaseInsensitive(): boolean {
return false;
}
getOptions(): GlobalSandboxOptions {
return this.options;
protected override resolveFinalCommand(
req: SandboxRequest,
_permissions: SandboxPermissions,
_resolvedPaths: ResolvedSandboxPaths,
): { command: string; args: string[] } {
return handleReadWriteCommands(req);
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await initializeShellParsers();
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
verifySandboxOverrides(allowOverrides, req.policy);
let command = req.command;
let args = req.args;
// Translate virtual commands for sandboxed file system access
if (command === '__read') {
command = '/bin/cat';
} else if (command === '__write') {
command = '/bin/sh';
args = ['-c', 'cat > "$1"', '_', ...args];
}
const currentReq = { ...req, command, args };
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? await isStrictlyApproved(
currentReq,
this.options.modeConfig?.approvedTools,
)
: false;
const isYolo = this.options.modeConfig?.yolo ?? false;
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
// Fetch persistent approvals for this command
const commandName = await getFullCommandName(currentReq);
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
defaultNetwork ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
req,
mergedAdditional,
this.options.workspace,
[
...(req.policy?.allowedPaths || []),
...(this.options.includeDirectories || []),
],
);
const resolvedPaths = await resolveSandboxPaths(
this.options,
req,
mergedAdditional,
);
protected async buildSandboxedExecution(
req: SandboxRequest,
finalCmd: { command: string; args: string[] },
sanitizedEnv: NodeJS.ProcessEnv,
mergedAdditional: SandboxPermissions,
resolvedPaths: ResolvedSandboxPaths,
workspaceWrite: boolean,
): Promise<SandboxedCommand> {
const sandboxArgs = buildSeatbeltProfile({
resolvedPaths,
networkAccess: mergedAdditional.network,
@@ -159,7 +76,7 @@ export class MacOsSandboxManager implements SandboxManager {
return {
program: '/usr/bin/sandbox-exec',
args: ['-f', tempFile, '--', finalCommand, ...finalArgs],
args: ['-f', tempFile, '--', finalCmd.command, ...finalCmd.args],
env: sanitizedEnv,
cwd: req.cwd,
cleanup: () => {
@@ -8,7 +8,7 @@ import {
buildSeatbeltProfile,
escapeSchemeString,
} from './seatbeltArgsBuilder.js';
import type { ResolvedSandboxPaths } from '../../services/sandboxManager.js';
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
import fs from 'node:fs';
import os from 'node:os';
@@ -15,7 +15,7 @@ import {
GOVERNANCE_FILES,
SECRET_FILES,
type ResolvedSandboxPaths,
} from '../../services/sandboxManager.js';
} from '../abstractOsSandboxManager.js';
import { resolveToRealPath } from '../../utils/paths.js';
/**
@@ -4,10 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import {
type SandboxPermissions,
type SandboxRequest,
} from '../../services/sandboxManager.js';
import { type SandboxRequest } from '../../services/sandboxManager.js';
import { isValidPathString } from '../../utils/paths.js';
/**
@@ -47,38 +44,34 @@ function validatePaths(
return true;
}
export function handleReadWriteCommands(
export function validateVirtualCommandPaths(
req: SandboxRequest,
mergedAdditional: SandboxPermissions,
workspace: string,
allowedPaths: string[] = [],
): { command: string; args: string[] } {
): void {
if (req.command === '__read' || req.command === '__write') {
if (req.args.length > 0) {
if (!validatePaths(req.args, workspace, allowedPaths)) {
throw new Error(
`Sandbox Error: Path traversal or unauthorized access attempt detected in ${req.command}: ${req.args.join(', ')}`,
);
}
}
}
}
export function handleReadWriteCommands(req: SandboxRequest): {
command: string;
args: string[];
} {
let finalCommand = req.command;
let finalArgs = req.args;
if (req.command === '__read') {
finalCommand = '/bin/cat';
if (req.args.length > 0) {
if (validatePaths(req.args, workspace, allowedPaths)) {
mergedAdditional.fileSystem!.read!.push(...req.args);
} else {
throw new Error(
`Sandbox Error: Path traversal or unauthorized access attempt detected in __read: ${req.args.join(', ')}`,
);
}
}
} else if (req.command === '__write') {
finalCommand = '/bin/sh';
finalArgs = ['-c', 'tee -- "$@" > /dev/null', '_', ...req.args];
if (req.args.length > 0) {
if (validatePaths(req.args, workspace, allowedPaths)) {
mergedAdditional.fileSystem!.write!.push(...req.args);
} else {
throw new Error(
`Sandbox Error: Path traversal or unauthorized access attempt detected in __write: ${req.args.join(', ')}`,
);
}
}
}
return { command: finalCommand, args: finalArgs };
File diff suppressed because it is too large Load Diff
@@ -4,44 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
SECRET_FILES,
AbstractOsSandboxManager,
} from '../abstractOsSandboxManager.js';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
GOVERNANCE_FILES,
findSecretFiles,
type GlobalSandboxOptions,
type SandboxPermissions,
type ParsedSandboxDenial,
resolveSandboxPaths,
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
import type {
SandboxRequest,
SandboxedCommand,
GlobalSandboxOptions,
SandboxPermissions,
ParsedSandboxDenial,
} from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
import {
isKnownSafeCommand,
isDangerousCommand,
isStrictlyApproved,
isKnownSafeCommand as isWindowsSafeCommand,
isDangerousCommand as isWindowsDangerousCommand,
} from './commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
import {
isSubpath,
resolveToRealPath,
assertValidPathString,
} from '../../utils/paths.js';
import {
type SandboxDenialCache,
createSandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -51,62 +39,34 @@ const __dirname = path.dirname(__filename);
* Job Objects, and Low Integrity levels for process isolation.
* Uses a native C# helper to bypass PowerShell restrictions.
*/
export class WindowsSandboxManager implements SandboxManager {
export class WindowsSandboxManager extends AbstractOsSandboxManager {
static readonly HELPER_EXE = 'GeminiSandbox.exe';
private readonly helperPath: string;
private initialized = false;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {
constructor(options: GlobalSandboxOptions) {
super(options);
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
}
isKnownSafeCommand(args: string[]): boolean {
const toolName = args[0]?.toLowerCase();
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
if (toolName && approvedTools.some((t) => t.toLowerCase() === toolName)) {
const toolName = args[0];
if (toolName && this.isToolApproved(toolName, true)) {
return true;
}
return isKnownSafeCommand(args);
return isWindowsSafeCommand(args);
}
isDangerousCommand(args: string[]): boolean {
return isDangerousCommand(args);
return isWindowsDangerousCommand(args);
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parseWindowsSandboxDenials(result, this.denialCache);
}
getWorkspace(): string {
return this.options.workspace;
}
getOptions(): GlobalSandboxOptions {
return this.options;
}
/**
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean): void {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
protected get isCaseInsensitive(): boolean {
return true;
}
private async ensureInitialized(): Promise<void> {
@@ -210,73 +170,21 @@ export class WindowsSandboxManager implements SandboxManager {
this.initialized = true;
}
/**
* Prepares a command for sandboxed execution on Windows.
*/
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
protected async buildSandboxedExecution(
req: SandboxRequest,
finalCmd: { command: string; args: string[] },
sanitizedEnv: NodeJS.ProcessEnv,
mergedAdditional: SandboxPermissions,
resolvedPaths: ResolvedSandboxPaths,
workspaceWrite: boolean,
): Promise<SandboxedCommand> {
await this.ensureInitialized();
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const networkAccess = mergedAdditional.network;
const command = finalCmd.command;
const args = finalCmd.args;
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
verifySandboxOverrides(allowOverrides, req.policy);
const command = req.command;
const args = req.args;
// Native commands __read and __write are passed directly to GeminiSandbox.exe
const isYolo = this.options.modeConfig?.yolo ?? false;
// Fetch persistent approvals for this command
const commandName = await getCommandName(command, args);
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
// Merge all permissions
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
isYolo ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
if (req.command === '__read' && req.args[0]) {
mergedAdditional.fileSystem!.read!.push(req.args[0]);
} else if (req.command === '__write' && req.args[0]) {
mergedAdditional.fileSystem!.write!.push(req.args[0]);
}
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network;
const resolvedPaths = await resolveSandboxPaths(
this.options,
req,
mergedAdditional,
);
// 1. Collect all forbidden paths.
// Collect all forbidden paths.
// We start with explicitly forbidden paths from the options and request.
const forbiddenManifest = new Set(
resolvedPaths.forbidden.map((p) => resolveToRealPath(p)),
@@ -307,7 +215,7 @@ export class WindowsSandboxManager implements SandboxManager {
await Promise.all(secretFilesPromises);
// 2. Track paths that will be granted write access.
// Track paths that will be granted write access.
// 'allowedManifest' contains resolved paths for the C# helper to apply ACLs.
// 'inheritanceRoots' contains both original and resolved paths for Node.js sub-path validation.
const allowedManifest = new Set<string>();
@@ -340,29 +248,18 @@ export class WindowsSandboxManager implements SandboxManager {
allowedManifest.add(resolved);
};
// 3. Populate writable roots from various sources.
// A. Workspace access
const isApproved = allowOverrides
? await isStrictlyApproved(
command,
args,
this.options.modeConfig?.approvedTools,
)
: false;
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
// Populate writable roots from various sources.
if (workspaceWrite) {
addWritableRoot(resolvedPaths.workspace.resolved);
}
// B. Globally included directories
// Globally included directories
for (const includeDir of resolvedPaths.globalIncludes) {
addWritableRoot(includeDir);
}
// C. Explicitly allowed paths from the request policy
// Explicitly allowed paths from the request policy
for (const allowedPath of resolvedPaths.policyAllowed) {
try {
await fs.promises.access(allowedPath, fs.constants.F_OK);
@@ -375,7 +272,7 @@ export class WindowsSandboxManager implements SandboxManager {
addWritableRoot(allowedPath);
}
// D. Additional write paths (e.g. from internal __write command)
// Additional write paths (e.g. from internal __write command)
for (const writePath of resolvedPaths.policyWrite) {
try {
await fs.promises.access(writePath, fs.constants.F_OK);
@@ -402,15 +299,7 @@ export class WindowsSandboxManager implements SandboxManager {
// No-op for read access on Windows.
}
// 4. Protected governance files
// These must exist on the host before running the sandbox to prevent
// the sandboxed process from creating them with Low integrity.
for (const file of GOVERNANCE_FILES) {
const filePath = path.join(resolvedPaths.workspace.resolved, file.path);
this.touch(filePath, file.isDirectory);
}
// 5. Generate Manifests
// Generate Manifests
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
);
@@ -427,7 +316,7 @@ export class WindowsSandboxManager implements SandboxManager {
Array.from(allowedManifest).join('\n'),
);
// 6. Construct the helper command
// Construct the helper command
const program = this.helperPath;
const finalArgs = [
@@ -471,3 +360,62 @@ export class WindowsSandboxManager implements SandboxManager {
);
}
}
/**
* Checks if a given file name matches any of the secret file patterns.
*/
export function isSecretFile(fileName: string): boolean {
return SECRET_FILES.some((s: { pattern: string }) => {
if (s.pattern.endsWith('*')) {
const prefix = s.pattern.slice(0, -1);
return fileName.startsWith(prefix);
}
return fileName === s.pattern;
});
}
/**
* Finds all secret files in a directory up to a certain depth.
* Default is shallow scan (depth 1) for performance.
*/
export async function findSecretFiles(
baseDir: string,
maxDepth = 1,
): Promise<string[]> {
const secrets: string[] = [];
const skipDirs = new Set([
'node_modules',
'.git',
'.venv',
'__pycache__',
'dist',
'build',
'.next',
'.idea',
'.vscode',
]);
async function walk(dir: string, depth: number) {
if (depth > maxDepth) return;
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
await walk(fullPath, depth + 1);
}
} else if (entry.isFile()) {
if (isSecretFile(entry.name)) {
secrets.push(fullPath);
}
}
}
} catch {
// Ignore read errors
}
}
await walk(baseDir, 1);
return secrets;
}
File diff suppressed because it is too large Load Diff
@@ -1,405 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import path from 'node:path';
import fsPromises from 'node:fs/promises';
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
import {
NoopSandboxManager,
findSecretFiles,
isSecretFile,
resolveSandboxPaths,
type SandboxRequest,
} from './sandboxManager.js';
import { createSandboxManager } from './sandboxManagerFactory.js';
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
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(),
lstat: vi.fn(),
readFile: vi.fn(),
},
readdir: vi.fn(),
realpath: vi.fn(),
stat: vi.fn(),
lstat: vi.fn(),
readFile: vi.fn(),
};
});
vi.mock('../utils/paths.js', async () => {
const actual =
await vi.importActual<typeof import('../utils/paths.js')>(
'../utils/paths.js',
);
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
};
});
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 () => {
const workspace = path.resolve('/workspace');
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
if (dir === workspace) {
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);
const secrets = await findSecretFiles(workspace);
expect(secrets).toEqual([path.join(workspace, '.env')]);
});
it('should NOT find secret files recursively (shallow scan only)', async () => {
const workspace = path.resolve('/workspace');
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
if (dir === workspace) {
return Promise.resolve([
{ name: '.env', isDirectory: () => false, isFile: () => true },
{ name: 'packages', isDirectory: () => true, isFile: () => false },
] as unknown as fs.Dirent[]);
}
if (dir === path.join(workspace, 'packages')) {
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);
const secrets = await findSecretFiles(workspace);
expect(secrets).toEqual([path.join(workspace, '.env')]);
// Should NOT have called readdir for subdirectories
expect(fsPromises.readdir).toHaveBeenCalledTimes(1);
expect(fsPromises.readdir).not.toHaveBeenCalledWith(
path.join(workspace, 'packages'),
expect.anything(),
);
});
});
describe('SandboxManager', () => {
afterEach(() => vi.restoreAllMocks());
describe('resolveSandboxPaths', () => {
it('should resolve allowed and forbidden paths', async () => {
const workspace = path.resolve('/workspace');
const forbidden = path.join(workspace, 'forbidden');
const allowed = path.join(workspace, 'allowed');
const options = {
workspace,
forbiddenPaths: async () => [forbidden],
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [allowed],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([allowed]);
expect(result.forbidden).toEqual([forbidden]);
});
it('should filter out workspace from allowed paths', async () => {
const workspace = path.resolve('/workspace');
const other = path.resolve('/other/path');
const options = {
workspace,
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace, workspace + path.sep, other],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([other]);
});
it('should prioritize forbidden paths over allowed paths', async () => {
const workspace = path.resolve('/workspace');
const secret = path.join(workspace, 'secret');
const normal = path.join(workspace, 'normal');
const options = {
workspace,
forbiddenPaths: async () => [secret],
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [secret, normal],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([normal]);
expect(result.forbidden).toEqual([secret]);
});
it('should handle case-insensitive conflicts on supported platforms', async () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
const workspace = path.resolve('/workspace');
const secretUpper = path.join(workspace, 'SECRET');
const secretLower = path.join(workspace, 'secret');
const options = {
workspace,
forbiddenPaths: async () => [secretUpper],
};
const req = {
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
allowedPaths: [secretLower],
},
};
const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.policyAllowed).toEqual([]);
expect(result.forbidden).toEqual([secretUpper]);
});
});
describe('NoopSandboxManager', () => {
const sandboxManager = new NoopSandboxManager();
it('should pass through the command and arguments unchanged', async () => {
const cwd = path.resolve('/tmp');
const req = {
command: 'ls',
args: ['-la'],
cwd,
env: { PATH: '/usr/bin' },
};
const result = await sandboxManager.prepareCommand(req);
expect(result.program).toBe('ls');
expect(result.args).toEqual(['-la']);
});
it('should sanitize the environment variables', async () => {
const cwd = path.resolve('/tmp');
const req = {
command: 'echo',
args: ['hello'],
cwd,
env: {
PATH: '/usr/bin',
GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
MY_SECRET: 'super-secret',
SAFE_VAR: 'is-safe',
},
policy: {
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await sandboxManager.prepareCommand(req);
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();
});
it('should allow disabling environment variable redaction if requested in config', async () => {
const cwd = path.resolve('/tmp');
const req = {
command: 'echo',
args: ['hello'],
cwd,
env: {
API_KEY: 'sensitive-key',
},
policy: {
sanitizationConfig: {
enableEnvironmentVariableRedaction: false,
},
},
};
const result = await sandboxManager.prepareCommand(req);
// API_KEY should be preserved because redaction was explicitly disabled
expect(result.env['API_KEY']).toBe('sensitive-key');
});
it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => {
const cwd = path.resolve('/tmp');
const req = {
command: 'echo',
args: ['hello'],
cwd,
env: {
MY_SAFE_VAR: 'safe-value',
MY_TOKEN: 'secret-token',
},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'],
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await sandboxManager.prepareCommand(req);
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();
});
it('should respect blockedEnvironmentVariables in config', async () => {
const cwd = path.resolve('/tmp');
const req = {
command: 'echo',
args: ['hello'],
cwd,
env: {
SAFE_VAR: 'safe-value',
BLOCKED_VAR: 'blocked-value',
},
policy: {
sanitizationConfig: {
blockedEnvironmentVariables: ['BLOCKED_VAR'],
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await sandboxManager.prepareCommand(req);
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);
vi.spyOn(os, 'platform').mockReturnValue('win32');
expect(sandboxManager.isDangerousCommand(['del'])).toBe(true);
});
});
describe('createSandboxManager', () => {
it('should return NoopSandboxManager if sandboxing is disabled', () => {
const manager = createSandboxManager(
{ enabled: false },
{ workspace: path.resolve('/workspace') },
);
expect(manager).toBeInstanceOf(NoopSandboxManager);
});
it.each([
{ platform: 'linux', expected: LinuxSandboxManager },
{ platform: 'darwin', expected: MacOsSandboxManager },
{ platform: 'win32', expected: WindowsSandboxManager },
] as const)(
'should return $expected.name if sandboxing is enabled and platform is $platform',
({ platform, expected }) => {
vi.spyOn(os, 'platform').mockReturnValue(platform);
const manager = createSandboxManager(
{ enabled: true },
{ workspace: path.resolve('/workspace') },
);
expect(manager).toBeInstanceOf(expected);
},
);
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true },
{ workspace: path.resolve('/workspace') },
);
expect(manager).toBeInstanceOf(WindowsSandboxManager);
});
});
});
@@ -4,9 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
isKnownSafeCommand as isMacSafeCommand,
isDangerousCommand as isMacDangerousCommand,
@@ -22,44 +20,6 @@ import {
} from './environmentSanitization.js';
import type { ShellExecutionResult } from './shellExecutionService.js';
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import {
toPathKey,
deduplicateAbsolutePaths,
resolveToRealPath,
} from '../utils/paths.js';
import { resolveGitWorktreePaths } from '../sandbox/utils/fsUtils.js';
/**
* A structured result of fully resolved sandbox paths.
* All paths in this object are absolute, deduplicated, and expanded to include
* both the original path and its real target (if it is a symlink).
*/
export interface ResolvedSandboxPaths {
/** The primary workspace directory. */
workspace: {
/** The original path provided in the sandbox options. */
original: string;
/** The real path. */
resolved: string;
};
/** Explicitly denied paths. */
forbidden: string[];
/** Directories included globally across all commands in this sandbox session. */
globalIncludes: string[];
/** Paths explicitly allowed by the policy of the currently executing command. */
policyAllowed: string[];
/** Paths granted temporary read access by the current command's dynamic permissions. */
policyRead: string[];
/** Paths granted temporary write access by the current command's dynamic permissions. */
policyWrite: string[];
/** Auto-detected paths for git worktrees/submodules. */
gitWorktree?: {
/** The actual .git directory for this worktree. */
worktreeGitDir: string;
/** The main repository's .git directory (if applicable). */
mainGitDir?: string;
};
}
export interface SandboxPermissions {
/** Filesystem permissions. */
@@ -191,97 +151,6 @@ export interface SandboxManager {
getOptions(): GlobalSandboxOptions | undefined;
}
/**
* Files that represent the governance or "constitution" of the repository
* and should be write-protected in any sandbox.
*/
export const GOVERNANCE_FILES = [
{ path: '.gitignore', isDirectory: false },
{ path: '.geminiignore', isDirectory: false },
{ path: '.git', isDirectory: true },
] as const;
/**
* Files that contain sensitive secrets or credentials and should be
* completely hidden (deny read/write) in any sandbox.
*/
export const SECRET_FILES = [
{ pattern: '.env' },
{ pattern: '.env.*' },
] as const;
/**
* Checks if a given file name matches any of the secret file patterns.
*/
export function isSecretFile(fileName: string): boolean {
return SECRET_FILES.some((s) => {
if (s.pattern.endsWith('*')) {
const prefix = s.pattern.slice(0, -1);
return fileName.startsWith(prefix);
}
return fileName === s.pattern;
});
}
/**
* Returns arguments for the Linux 'find' command to locate secret files.
*/
export function getSecretFileFindArgs(): string[] {
const args: string[] = ['('];
SECRET_FILES.forEach((s, i) => {
if (i > 0) args.push('-o');
args.push('-name', s.pattern);
});
args.push(')');
return args;
}
/**
* Finds all secret files in a directory up to a certain depth.
* Default is shallow scan (depth 1) for performance.
*/
export async function findSecretFiles(
baseDir: string,
maxDepth = 1,
): Promise<string[]> {
const secrets: string[] = [];
const skipDirs = new Set([
'node_modules',
'.git',
'.venv',
'__pycache__',
'dist',
'build',
'.next',
'.idea',
'.vscode',
]);
async function walk(dir: string, depth: number) {
if (depth > maxDepth) return;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
await walk(fullPath, depth + 1);
}
} else if (entry.isFile()) {
if (isSecretFile(entry.name)) {
secrets.push(fullPath);
}
}
}
} catch {
// Ignore read errors
}
}
await walk(baseDir, 1);
return secrets;
}
/**
* A no-op implementation of SandboxManager that silently passes commands
* through while applying environment sanitization.
@@ -362,76 +231,3 @@ export class LocalSandboxManager implements SandboxManager {
return this.options;
}
}
/**
* Resolves and sanitizes all path categories for a sandbox request.
*/
export async function resolveSandboxPaths(
options: GlobalSandboxOptions,
req: SandboxRequest,
overridePermissions?: SandboxPermissions,
): Promise<ResolvedSandboxPaths> {
/**
* Helper that expands each path to include its realpath (if it's a symlink)
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
*/
const expand = (paths?: string[] | null): string[] => {
if (!paths || paths.length === 0) return [];
const expanded = paths.flatMap((p) => {
try {
const resolved = resolveToRealPath(p);
return resolved === p ? [p] : [p, resolved];
} catch {
return [p];
}
});
return deduplicateAbsolutePaths(expanded);
};
const forbidden = expand(await options.forbiddenPaths?.());
const globalIncludes = expand(options.includeDirectories);
const policyAllowed = expand(req.policy?.allowedPaths);
const policyRead = expand(overridePermissions?.fileSystem?.read);
const policyWrite = expand(overridePermissions?.fileSystem?.write);
const resolvedWorkspace = resolveToRealPath(options.workspace);
const workspaceIdentities = new Set(
[options.workspace, resolvedWorkspace].map(toPathKey),
);
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
const { worktreeGitDir, mainGitDir } =
await resolveGitWorktreePaths(resolvedWorkspace);
const gitWorktree = worktreeGitDir
? { gitWorktree: { worktreeGitDir, mainGitDir } }
: undefined;
/**
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
*/
const filter = (paths: string[]) =>
paths.filter((p) => {
const identity = toPathKey(p);
return (
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
);
});
return {
workspace: {
original: options.workspace,
resolved: resolvedWorkspace,
},
forbidden,
globalIncludes: filter(globalIncludes),
policyAllowed: filter(policyAllowed),
policyRead: filter(policyRead),
policyWrite: filter(policyWrite),
...gitWorktree,
};
}
export { createSandboxManager } from './sandboxManagerFactory.js';