From f030dbb1a778a23b8ff924a5787bbe90ace28b7b Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Mon, 13 Apr 2026 12:58:29 -0400 Subject: [PATCH] refactor(core): abstract OS sandbox managers and fix virtual command permissions --- .../sandbox/abstractOsSandboxManager.test.ts | 315 +++++ .../src/sandbox/abstractOsSandboxManager.ts | 326 +++++ .../sandbox/linux/LinuxSandboxManager.test.ts | 76 +- .../src/sandbox/linux/LinuxSandboxManager.ts | 186 +-- .../sandbox/linux/bwrapArgsBuilder.test.ts | 30 +- .../src/sandbox/linux/bwrapArgsBuilder.ts | 14 +- .../sandbox/macos/MacOsSandboxManager.test.ts | 228 +--- .../src/sandbox/macos/MacOsSandboxManager.ts | 153 +-- .../sandbox/macos/seatbeltArgsBuilder.test.ts | 2 +- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 2 +- .../sandbox/utils/sandboxReadWriteUtils.ts | 43 +- .../windows/WindowsSandboxManager.test.ts | 948 ++++++++------- .../sandbox/windows/WindowsSandboxManager.ts | 258 ++-- .../sandboxManager.integration.test.ts | 1078 ++++++++++------- .../core/src/services/sandboxManager.test.ts | 405 ------- packages/core/src/services/sandboxManager.ts | 204 ---- 16 files changed, 2161 insertions(+), 2107 deletions(-) create mode 100644 packages/core/src/sandbox/abstractOsSandboxManager.test.ts create mode 100644 packages/core/src/sandbox/abstractOsSandboxManager.ts delete mode 100644 packages/core/src/services/sandboxManager.test.ts diff --git a/packages/core/src/sandbox/abstractOsSandboxManager.test.ts b/packages/core/src/sandbox/abstractOsSandboxManager.test.ts new file mode 100644 index 0000000000..0da74ed686 --- /dev/null +++ b/packages/core/src/sandbox/abstractOsSandboxManager.test.ts @@ -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]); + }); + }); +}); diff --git a/packages/core/src/sandbox/abstractOsSandboxManager.ts b/packages/core/src/sandbox/abstractOsSandboxManager.ts new file mode 100644 index 0000000000..353a5770a0 --- /dev/null +++ b/packages/core/src/sandbox/abstractOsSandboxManager.ts @@ -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 { + /** + * 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; + + 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 { + 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, + ); + } +} diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index 2432655da5..1b409a4458 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -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('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/); - }); }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 0dc3c76f74..4ac9d8798c 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -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 { - 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 { const bwrapArgs = await buildBwrapArgs({ resolvedPaths, workspaceWrite, @@ -283,8 +187,8 @@ export class LinuxSandboxManager implements SandboxManager { bpfPath, argsPath, '--', - finalCommand, - ...finalArgs, + finalCmd.command, + ...finalCmd.args, ]; return { diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index b9584062bc..5b448a7cd2 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -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('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'); + } + }); +}); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index d7fec044e3..8a2eed08f8 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -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; +} diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 3e1862998e..4529a3e3d1 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -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']), - }), - }), - ); - }); - }); }); }); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index f87dc0289c..b7c2f23813 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -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 { - 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 { 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: () => { diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts index e8801b055b..e2d9660fa5 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -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'; diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index abbf1a6d92..26379a1bcd 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -15,7 +15,7 @@ import { GOVERNANCE_FILES, SECRET_FILES, type ResolvedSandboxPaths, -} from '../../services/sandboxManager.js'; +} from '../abstractOsSandboxManager.js'; import { resolveToRealPath } from '../../utils/paths.js'; /** diff --git a/packages/core/src/sandbox/utils/sandboxReadWriteUtils.ts b/packages/core/src/sandbox/utils/sandboxReadWriteUtils.ts index c1a611716b..dfdc93d761 100644 --- a/packages/core/src/sandbox/utils/sandboxReadWriteUtils.ts +++ b/packages/core/src/sandbox/utils/sandboxReadWriteUtils.ts @@ -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 }; diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 1ba6290e01..f2feeaa9d2 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -8,75 +8,48 @@ 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 { WindowsSandboxManager } from './WindowsSandboxManager.js'; -import * as sandboxManager from '../../services/sandboxManager.js'; -import * as paths from '../../utils/paths.js'; +import { + WindowsSandboxManager, + isSecretFile, + findSecretFiles, +} from './WindowsSandboxManager.js'; +import { + isDangerousCommand as isWindowsDangerousCommand, + isKnownSafeCommand as isWindowsSafeCommand, +} from './commandSafety.js'; +import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; vi.mock('../../utils/shell-utils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - spawnAsync: vi.fn(), initializeShellParsers: vi.fn(), isStrictlyApproved: vi.fn().mockResolvedValue(true), }; }); +vi.mock('./commandSafety.js', () => ({ + isKnownSafeCommand: vi.fn().mockReturnValue(false), + isDangerousCommand: vi.fn(), +})); + +vi.mock('./windowsSandboxDenialUtils.js', () => ({ + parseWindowsSandboxDenials: vi.fn(), +})); + describe('WindowsSandboxManager', () => { let manager: WindowsSandboxManager; let testCwd: string; - /** - * Creates a temporary directory and returns its canonical real path. - */ - function createTempDir(name: string, parent = os.tmpdir()): string { - const rawPath = fs.mkdtempSync(path.join(parent, `gemini-test-${name}-`)); - return fs.realpathSync(rawPath); - } - - const helperExePath = path.resolve( - __dirname, - WindowsSandboxManager.HELPER_EXE, - ); - - /** - * Helper to read manifests from sandbox args - */ - function getManifestPaths(args: string[]): { - forbidden: string[]; - allowed: string[]; - } { - const forbiddenPath = args[3]; - const allowedPath = args[5]; - const forbidden = fs - .readFileSync(forbiddenPath, 'utf8') - .split('\n') - .filter(Boolean); - const allowed = fs - .readFileSync(allowedPath, 'utf8') - .split('\n') - .filter(Boolean); - return { forbidden, allowed }; - } - beforeEach(() => { vi.spyOn(os, 'platform').mockReturnValue('win32'); - vi.spyOn(paths, 'resolveToRealPath').mockImplementation((p) => p); - - // Mock existsSync to skip the csc.exe auto-compilation of helper during unit tests. - const originalExistsSync = fs.existsSync; - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - if (typeof p === 'string' && path.resolve(p) === helperExePath) { - return true; - } - return originalExistsSync(p); - }); - - testCwd = createTempDir('cwd'); + testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); manager = new WindowsSandboxManager({ workspace: testCwd, modeConfig: { readonly: false, allowOverrides: true }, @@ -86,359 +59,454 @@ describe('WindowsSandboxManager', () => { afterEach(() => { vi.restoreAllMocks(); - if (testCwd && fs.existsSync(testCwd)) { - fs.rmSync(testCwd, { recursive: true, force: true }); - } + fs.rmSync(testCwd, { recursive: true, force: true }); }); - it('should prepare a GeminiSandbox.exe command', async () => { - const req: SandboxRequest = { - command: 'whoami', - args: ['/groups'], - cwd: testCwd, - env: { TEST_VAR: 'test_value' }, - policy: { - networkAccess: false, - }, - }; + describe('isKnownSafeCommand', () => { + it('should return true for approved tools in modeConfig', () => { + manager = new WindowsSandboxManager({ + workspace: testCwd, + modeConfig: { approvedTools: ['my-safe-tool'] }, + forbiddenPaths: async () => [], + }); - const result = await manager.prepareCommand(req); - - expect(result.program).toContain('GeminiSandbox.exe'); - expect(result.args).toEqual([ - '0', - testCwd, - '--forbidden-manifest', - expect.stringMatching(/forbidden\.txt$/), - '--allowed-manifest', - expect.stringMatching(/allowed\.txt$/), - 'whoami', - '/groups', - ]); - }); - - it('should handle networkAccess from config', async () => { - const req: SandboxRequest = { - command: 'whoami', - args: [], - cwd: testCwd, - env: {}, - policy: { - networkAccess: true, - }, - }; - - const result = await manager.prepareCommand(req); - expect(result.args[0]).toBe('1'); - }); - - it('should NOT whitelist drive roots in YOLO mode', async () => { - manager = new WindowsSandboxManager({ - workspace: testCwd, - modeConfig: { readonly: false, allowOverrides: true, yolo: true }, - forbiddenPaths: async () => [], + expect(manager.isKnownSafeCommand(['my-safe-tool', 'arg'])).toBe(true); + expect(manager.isKnownSafeCommand(['MY-SAFE-TOOL', 'arg'])).toBe(true); }); - const req: SandboxRequest = { - command: 'whoami', - args: [], - cwd: testCwd, - env: {}, - }; - - const result = await manager.prepareCommand(req); - const { allowed } = getManifestPaths(result.args); - - // Should NOT have drive roots (C:\, D:\, etc.) in the allowed manifest - const driveRoots = allowed.filter((p) => /^[A-Z]:\\$/.test(p)); - expect(driveRoots).toHaveLength(0); + it('should fall back to default isKnownSafeCommand logic', () => { + vi.mocked(isWindowsSafeCommand).mockReturnValue(true); + // 'git' is typically a known safe command in commandSafety.ts + expect(manager.isKnownSafeCommand(['git', 'status'])).toBe(true); + expect(manager.isKnownSafeCommand(['unknown-tool'])).toBe(true); // mocked to true + }); }); - it('should handle network access from additionalPermissions', async () => { - const req: SandboxRequest = { - command: 'whoami', - args: [], - cwd: testCwd, - env: {}, - policy: { - additionalPermissions: { + describe('isDangerousCommand', () => { + it('should delegate to isWindowsDangerousCommand', () => { + vi.mocked(isWindowsDangerousCommand).mockReturnValue(true); + expect(manager.isDangerousCommand(['del', '/f'])).toBe(true); + expect(isWindowsDangerousCommand).toHaveBeenCalledWith(['del', '/f']); + }); + }); + + describe('parseDenials', () => { + it('should delegate to parseWindowsSandboxDenials', () => { + const mockResult = { + exitCode: 1, + output: 'Access is denied.', + } as unknown as ShellExecutionResult; + const mockParsed = { filePaths: ['C:\\blocked'] }; + vi.mocked(parseWindowsSandboxDenials).mockReturnValue(mockParsed); + + expect(manager.parseDenials(mockResult)).toBe(mockParsed); + expect(parseWindowsSandboxDenials).toHaveBeenCalledWith( + mockResult, + expect.any(Object), + ); + }); + }); + + 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', () => { + expect(isSecretFile('.env-backup')).toBe(false); + }); + }); + + describe('findSecretFiles', () => { + it('should find secret files in the specified directory', async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-secrets-test-'), + ); + fs.writeFileSync(path.join(dir, '.env'), 'secret'); + fs.writeFileSync(path.join(dir, 'package.json'), '{}'); + const srcDir = path.join(dir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, '.env.local'), 'secret2'); + + const secrets = await findSecretFiles(dir, 2); + expect(secrets).toContain(path.join(dir, '.env')); + expect(secrets).toContain(path.join(srcDir, '.env.local')); + expect(secrets).not.toContain(path.join(dir, 'package.json')); + + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('should respect maxDepth and not scan deeply nested folders beyond the limit', async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-secrets-depth-test-'), + ); + const nestedDir = path.join(dir, 'a', 'b', 'c'); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(path.join(nestedDir, '.env'), 'secret'); + + const secretsShallow = await findSecretFiles(dir, 1); + expect(secretsShallow).toEqual([]); + + const secretsDeep = await findSecretFiles(dir, 4); + expect(secretsDeep).toContain(path.join(nestedDir, '.env')); + + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('should skip ignored directories like node_modules', async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-secrets-ignore-test-'), + ); + const nodeModulesDir = path.join(dir, 'node_modules'); + fs.mkdirSync(nodeModulesDir); + fs.writeFileSync(path.join(nodeModulesDir, '.env'), 'secret'); + + const secrets = await findSecretFiles(dir, 2); + expect(secrets).toEqual([]); + + fs.rmSync(dir, { recursive: true, force: true }); + }); + }); + + describe('prepareCommand', () => { + it('should prepare a GeminiSandbox.exe command', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: ['/groups'], + cwd: testCwd, + env: { TEST_VAR: 'test_value' }, + policy: { + networkAccess: false, + }, + }; + + const result = await manager.prepareCommand(req); + + expect(result.program).toContain('GeminiSandbox.exe'); + expect(result.args).toEqual([ + '0', + testCwd, + '--forbidden-manifest', + expect.stringMatching(/gemini-cli-sandbox-[^/\\]+[/\\]forbidden\.txt$/), + '--allowed-manifest', + expect.stringMatching(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + 'whoami', + '/groups', + ]); + }); + + it('should handle networkAccess from config', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: testCwd, + env: {}, + policy: { + networkAccess: true, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); + + it('should handle network access from additionalPermissions', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + network: true, + }, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); + + it('should reject network access in Plan mode', async () => { + manager = new WindowsSandboxManager({ + workspace: testCwd, + modeConfig: { readonly: true, allowOverrides: false }, + forbiddenPaths: async () => [], + }); + const req: SandboxRequest = { + command: 'curl', + args: ['google.com'], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { network: true }, + }, + }; + + await expect(manager.prepareCommand(req)).rejects.toThrow( + 'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.', + ); + }); + + it('should handle persistent permissions from policyManager', async () => { + const persistentPath = path.resolve('/persistent/path'); + const mockPolicyManager = { + getCommandPermissions: vi.fn().mockReturnValue({ + fileSystem: { write: [persistentPath] }, network: true, - }, - }, - }; + }), + } as unknown as SandboxPolicyManager; - const result = await manager.prepareCommand(req); - expect(result.args[0]).toBe('1'); - }); + vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined); - it('should reject network access in Plan mode', async () => { - const planManager = new WindowsSandboxManager({ - workspace: testCwd, - modeConfig: { readonly: true, allowOverrides: false }, - forbiddenPaths: async () => [], - }); - const req: SandboxRequest = { - command: 'curl', - args: ['google.com'], - cwd: testCwd, - env: {}, - policy: { - additionalPermissions: { network: true }, - }, - }; - - await expect(planManager.prepareCommand(req)).rejects.toThrow( - 'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.', - ); - }); - - it('should handle persistent permissions from policyManager', async () => { - const persistentPath = createTempDir('persistent', testCwd); - - const mockPolicyManager = { - getCommandPermissions: vi.fn().mockReturnValue({ - fileSystem: { write: [persistentPath] }, - network: true, - }), - } as unknown as SandboxPolicyManager; - - const managerWithPolicy = new WindowsSandboxManager({ - workspace: testCwd, - modeConfig: { allowOverrides: true, network: false }, - policyManager: mockPolicyManager, - forbiddenPaths: async () => [], - }); - - const req: SandboxRequest = { - command: 'test-cmd', - args: [], - cwd: testCwd, - env: {}, - }; - - const result = await managerWithPolicy.prepareCommand(req); - expect(result.args[0]).toBe('1'); // Network allowed by persistent policy - - const { allowed } = getManifestPaths(result.args); - expect(allowed).toContain(persistentPath); - }); - - it('should sanitize environment variables', async () => { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: { - API_KEY: 'secret', - PATH: '/usr/bin', - }, - policy: { - sanitizationConfig: { - allowedEnvironmentVariables: ['PATH'], - blockedEnvironmentVariables: ['API_KEY'], - enableEnvironmentVariableRedaction: true, - }, - }, - }; - - const result = await manager.prepareCommand(req); - expect(result.env['PATH']).toBe('/usr/bin'); - expect(result.env['API_KEY']).toBeUndefined(); - }); - - it('should ensure governance files exist', async () => { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - }; - - await manager.prepareCommand(req); - - expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true); - expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true); - expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true); - expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true); - }); - - it('should include the workspace and allowed paths in the allowed manifest', async () => { - const allowedPath = createTempDir('allowed'); - try { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - allowedPaths: [allowedPath], - }, - }; - - const result = await manager.prepareCommand(req); - const { allowed } = getManifestPaths(result.args); - - expect(allowed).toContain(testCwd); - expect(allowed).toContain(allowedPath); - } finally { - fs.rmSync(allowedPath, { recursive: true, force: true }); - } - }); - - it('should exclude git worktree paths from the allowed manifest (enforce read-only)', async () => { - const worktreeGitDir = createTempDir('worktree-git'); - const mainGitDir = createTempDir('main-git'); - - try { - vi.spyOn(sandboxManager, 'resolveSandboxPaths').mockResolvedValue({ - workspace: { original: testCwd, resolved: testCwd }, - forbidden: [], - globalIncludes: [], - policyAllowed: [], - policyRead: [], - policyWrite: [], - gitWorktree: { - worktreeGitDir, - mainGitDir, - }, + manager = new WindowsSandboxManager({ + workspace: testCwd, + modeConfig: { allowOverrides: true, network: false }, + policyManager: mockPolicyManager, + forbiddenPaths: async () => [], }); const req: SandboxRequest = { - command: 'test', + command: 'test-cmd', args: [], cwd: testCwd, env: {}, }; const result = await manager.prepareCommand(req); - const { allowed } = getManifestPaths(result.args); + expect(result.args[0]).toBe('1'); // Network allowed by persistent policy - // Verify that the git directories are NOT in the allowed manifest - expect(allowed).not.toContain(worktreeGitDir); - expect(allowed).not.toContain(mainGitDir); - } finally { - fs.rmSync(worktreeGitDir, { recursive: true, force: true }); - fs.rmSync(mainGitDir, { recursive: true, force: true }); - } - }); + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + ); + expect(aclCall).toBeDefined(); + expect(aclCall![1]).toContain(`${persistentPath}`); + }); - it('should include additional write paths in the allowed manifest', async () => { - const extraWritePath = createTempDir('extra-write'); - try { + it('should sanitize environment variables', async () => { const req: SandboxRequest = { command: 'test', args: [], cwd: testCwd, - env: {}, + env: { + API_KEY: 'secret', + PATH: '/usr/bin', + }, policy: { - additionalPermissions: { - fileSystem: { - write: [extraWritePath], - }, + sanitizationConfig: { + allowedEnvironmentVariables: ['PATH'], + blockedEnvironmentVariables: ['API_KEY'], + enableEnvironmentVariableRedaction: true, }, }, }; const result = await manager.prepareCommand(req); - const { allowed } = getManifestPaths(result.args); + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['API_KEY']).toBeUndefined(); + }); - expect(allowed).toContain(extraWritePath); - } finally { - fs.rmSync(extraWritePath, { recursive: true, force: true }); - } - }); - - it.runIf(process.platform === 'win32')( - 'should reject UNC paths for allowed access', - async () => { - const uncPath = '\\\\attacker\\share\\malicious.txt'; + it('should ensure governance files exist', async () => { const req: SandboxRequest = { command: 'test', args: [], cwd: testCwd, env: {}, - policy: { - additionalPermissions: { - fileSystem: { - write: [uncPath], - }, - }, - }, }; - // Rejected because it's an unreachable/invalid UNC path or it doesn't exist - await expect(manager.prepareCommand(req)).rejects.toThrow(); - }, - ); + await manager.prepareCommand(req); - it.runIf(process.platform === 'win32')( - 'should include extended-length and local device paths in the allowed manifest', - async () => { - // Create actual files for inheritance/existence checks - const longPath = path.join(testCwd, 'very_long_path.txt'); - const devicePath = path.join(testCwd, 'device_path.txt'); - fs.writeFileSync(longPath, ''); - fs.writeFileSync(devicePath, ''); + expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true); + expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true); + expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true); + expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true); + }); - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - additionalPermissions: { - fileSystem: { - write: [longPath, devicePath], + it('should grant Low Integrity access to the workspace and allowed paths', async () => { + const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed'); + if (!fs.existsSync(allowedPath)) { + fs.mkdirSync(allowedPath); + } + try { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + allowedPaths: [allowedPath], + }, + }; + + await manager.prepareCommand(req); + + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + ); + expect(aclCall).toBeDefined(); + expect(aclCall![1]).toContain(`${path.resolve(testCwd)}`); + expect(aclCall![1]).toContain(`${path.resolve(allowedPath)}`); + } finally { + fs.rmSync(allowedPath, { recursive: true, force: true }); + } + }); + + it('should grant Low Integrity access to additional write paths', async () => { + const extraWritePath = path.join( + os.tmpdir(), + 'gemini-cli-test-extra-write', + ); + if (!fs.existsSync(extraWritePath)) { + fs.mkdirSync(extraWritePath); + } + try { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { + write: [extraWritePath], + }, }, }, - }, - }; + }; - const result = await manager.prepareCommand(req); - const { allowed } = getManifestPaths(result.args); + await manager.prepareCommand(req); - expect(allowed).toContain(path.resolve(longPath)); - expect(allowed).toContain(path.resolve(devicePath)); - }, - ); + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + ); + expect(aclCall).toBeDefined(); + expect(aclCall![1]).toContain(`${path.resolve(extraWritePath)}`); + } finally { + fs.rmSync(extraWritePath, { recursive: true, force: true }); + } + }); - it('includes non-existent forbidden paths in the forbidden manifest', async () => { - const missingPath = path.join( - os.tmpdir(), - 'gemini-cli-test-missing', - 'does-not-exist.txt', + it.runIf(process.platform === 'win32')( + 'should reject UNC paths in grantLowIntegrityAccess', + async () => { + const uncPath = '\\\\attacker\\share\\malicious.txt'; + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { + write: [uncPath], + }, + }, + }, + }; + + vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + await manager.prepareCommand(req); + + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + ); + if (aclCall) { + expect(aclCall[1]).not.toContain(`${uncPath}`); + } + }, ); - // Ensure it definitely doesn't exist - if (fs.existsSync(missingPath)) { - fs.rmSync(missingPath, { recursive: true, force: true }); - } + it.runIf(process.platform === 'win32')( + 'should allow extended-length and local device paths', + async () => { + const longPath = '\\\\?\\C:\\very\\long\\path'; + const devicePath = '\\\\.\\PhysicalDrive0'; - const managerWithForbidden = new WindowsSandboxManager({ - workspace: testCwd, - forbiddenPaths: async () => [missingPath], + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { + write: [longPath, devicePath], + }, + }, + }, + }; + + vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + await manager.prepareCommand(req); + + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + ); + expect(aclCall).toBeDefined(); + expect(aclCall![1]).toContain(`${longPath}`); + expect(aclCall![1]).toContain(`${devicePath}`); + }, + ); + + it('skips denying access to non-existent forbidden paths to prevent failure', async () => { + const missingPath = path.join( + os.tmpdir(), + 'gemini-cli-test-missing', + 'does-not-exist.txt', + ); + + // Ensure it definitely doesn't exist + if (fs.existsSync(missingPath)) { + fs.rmSync(missingPath, { recursive: true, force: true }); + } + + manager = new WindowsSandboxManager({ + workspace: testCwd, + forbiddenPaths: async () => [missingPath], + }); + + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + }; + + await manager.prepareCommand(req); + + // Should have included the missing path in the forbidden manifest regardless + // C# helper will ignore non-existent files. + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]forbidden\.txt$/), + ); + if (aclCall) { + expect(aclCall[1]).toContain(`${path.resolve(missingPath)}`); + } }); - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - }; - - const result = await managerWithForbidden.prepareCommand(req); - const { forbidden } = getManifestPaths(result.args); - - expect(forbidden).toContain(path.resolve(missingPath)); - }); - - it('should include forbidden paths in the forbidden manifest', async () => { - const forbiddenPath = createTempDir('forbidden'); - try { - const managerWithForbidden = new WindowsSandboxManager({ - workspace: testCwd, - forbiddenPaths: async () => [forbiddenPath], - }); + it('should deny access to discovered secret files (e.g., .env)', async () => { + const envFile = path.join(testCwd, '.env'); + fs.writeFileSync(envFile, 'API_KEY=secret'); const req: SandboxRequest = { command: 'test', @@ -447,113 +515,95 @@ describe('WindowsSandboxManager', () => { env: {}, }; - const result = await managerWithForbidden.prepareCommand(req); - const { forbidden } = getManifestPaths(result.args); + await manager.prepareCommand(req); - expect(forbidden).toContain(forbiddenPath); - } finally { - fs.rmSync(forbiddenPath, { recursive: true, force: true }); - } - }); + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]forbidden\.txt$/), + ); + expect(aclCall).toBeDefined(); + expect(aclCall![1]).toContain(`${path.resolve(envFile)}`); + }); - it('should exclude forbidden paths from the allowed manifest if a conflict exists', async () => { - const conflictPath = createTempDir('conflict'); - try { - const managerWithForbidden = new WindowsSandboxManager({ - workspace: testCwd, - forbiddenPaths: async () => [conflictPath], - }); + it('should deny Low Integrity access to forbidden paths', async () => { + const forbiddenPath = path.join(os.tmpdir(), 'gemini-cli-test-forbidden'); + if (!fs.existsSync(forbiddenPath)) { + fs.mkdirSync(forbiddenPath); + } + try { + manager = new WindowsSandboxManager({ + workspace: testCwd, + forbiddenPaths: async () => [forbiddenPath], + }); - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - allowedPaths: [conflictPath], - }, - }; + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + }; - const result = await managerWithForbidden.prepareCommand(req); - const { forbidden, allowed } = getManifestPaths(result.args); + await manager.prepareCommand(req); - // Conflict should have been filtered out of allow calls - expect(allowed).not.toContain(conflictPath); - expect(forbidden).toContain(conflictPath); - } finally { - fs.rmSync(conflictPath, { recursive: true, force: true }); - } - }); + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const aclCall = writeFileSyncCalls.find((call) => + String(call[0]).match( + /gemini-cli-sandbox-[^/\\]+[/\\]forbidden\.txt$/, + ), + ); + expect(aclCall).toBeDefined(); + expect(aclCall![1]).toContain(`${path.resolve(forbiddenPath)}`); + } finally { + fs.rmSync(forbiddenPath, { recursive: true, force: true }); + } + }); - it('should pass __write directly to native helper', async () => { - const filePath = path.join(testCwd, 'test.txt'); - fs.writeFileSync(filePath, ''); - const req: SandboxRequest = { - command: '__write', - args: [filePath], - cwd: testCwd, - env: {}, - }; + it('should override allowed paths if a path is also in forbidden paths', async () => { + const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict'); + if (!fs.existsSync(conflictPath)) { + fs.mkdirSync(conflictPath); + } + try { + manager = new WindowsSandboxManager({ + workspace: testCwd, + forbiddenPaths: async () => [conflictPath], + }); - const result = await manager.prepareCommand(req); + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + allowedPaths: [conflictPath], + }, + }; - // [network, cwd, --forbidden-manifest, fPath, --allowed-manifest, aPath, command, ...args] - expect(result.args[6]).toBe('__write'); - expect(result.args[7]).toBe(filePath); - }); + await manager.prepareCommand(req); - it('should safely handle special characters in internal command paths', async () => { - const maliciousPath = path.join(testCwd, 'foo & echo bar; ! .txt'); - fs.writeFileSync(maliciousPath, ''); - const req: SandboxRequest = { - command: '__write', - args: [maliciousPath], - cwd: testCwd, - env: {}, - }; + const writeFileSyncCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const allowedCall = writeFileSyncCalls.find((call) => + String(call[0]).match(/gemini-cli-sandbox-[^/\\]+[/\\]allowed\.txt$/), + ); + const forbiddenCall = writeFileSyncCalls.find((call) => + String(call[0]).match( + /gemini-cli-sandbox-[^/\\]+[/\\]forbidden\.txt$/, + ), + ); + expect(allowedCall).toBeDefined(); + expect(forbiddenCall).toBeDefined(); - const result = await manager.prepareCommand(req); - - // Native commands pass arguments directly; the binary handles quoting via QuoteArgument - expect(result.args[6]).toBe('__write'); - expect(result.args[7]).toBe(maliciousPath); - }); - - it('should pass __read directly to native helper', async () => { - const filePath = path.join(testCwd, 'test.txt'); - fs.writeFileSync(filePath, 'hello'); - const req: SandboxRequest = { - command: '__read', - args: [filePath], - cwd: testCwd, - env: {}, - }; - - const result = await manager.prepareCommand(req); - - expect(result.args[6]).toBe('__read'); - expect(result.args[7]).toBe(filePath); - }); - - it('should return a cleanup function that deletes the temporary manifest directory', async () => { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - }; - - const result = await manager.prepareCommand(req); - const forbiddenManifestPath = result.args[3]; - const allowedManifestPath = result.args[5]; - - expect(fs.existsSync(forbiddenManifestPath)).toBe(true); - expect(fs.existsSync(allowedManifestPath)).toBe(true); - expect(result.cleanup).toBeDefined(); - - result.cleanup?.(); - expect(fs.existsSync(forbiddenManifestPath)).toBe(false); - expect(fs.existsSync(allowedManifestPath)).toBe(false); - expect(fs.existsSync(path.dirname(forbiddenManifestPath))).toBe(false); + // Ensure that forbidden paths are not included in the allowed manifest, + // as WindowsSandboxManager filters them out to prevent conflicting ACLs. + expect(String(allowedCall![1])).not.toContain( + `${path.resolve(conflictPath)}`, + ); + expect(String(forbiddenCall![1])).toContain( + `${path.resolve(conflictPath)}`, + ); + } finally { + fs.rmSync(conflictPath, { recursive: true, force: true }); + } + }); }); }); diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 0f60f5906f..bb946a3330 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -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 { @@ -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 { + protected async buildSandboxedExecution( + req: SandboxRequest, + finalCmd: { command: string; args: string[] }, + sanitizedEnv: NodeJS.ProcessEnv, + mergedAdditional: SandboxPermissions, + resolvedPaths: ResolvedSandboxPaths, + workspaceWrite: boolean, + ): Promise { 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(); @@ -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 { + 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; +} diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index 65adeaacbb..5846caa9e5 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -11,6 +11,7 @@ import { type SandboxManager, type SandboxedCommand, } from './sandboxManager.js'; +import { GOVERNANCE_FILES } from '../sandbox/abstractOsSandboxManager.js'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import os from 'node:os'; @@ -174,145 +175,323 @@ describe('SandboxManager Integration', () => { } }); - describe('Basic Execution', () => { - it('executes commands within the workspace', async () => { - const { command, args } = Platform.echo('sandbox test'); - const sandboxed = await manager.prepareCommand({ - command, - args, - cwd: workspace, - env: process.env, + describe('Execution & Environment', () => { + describe('Basic Execution', () => { + it('allows workspace execution', async () => { + const { command, args } = Platform.echo('sandbox test'); + const sandboxed = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('sandbox test'); }); - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'success'); - expect(result.stdout.trim()).toBe('sandbox test'); + // The Windows sandbox wrapper (GeminiSandbox.exe) uses standard pipes + // for I/O interception, which breaks ConPTY pseudo-terminal inheritance. + it.skipIf(Platform.isWindows)( + 'supports interactive terminals', + async () => { + const handle = await ShellExecutionService.execute( + Platform.isPty(), + workspace, + () => {}, + new AbortController().signal, + true, + { + sanitizationConfig: getSecureSanitizationConfig(), + sandboxManager: manager, + }, + ); + + const result = await handle.result; + expect(result.exitCode).toBe(0); + expect(result.output).toContain('True'); + }, + ); }); - // The Windows sandbox wrapper (GeminiSandbox.exe) uses standard pipes - // for I/O interception, which breaks ConPTY pseudo-terminal inheritance. - it.skipIf(Platform.isWindows)( - 'supports interactive pseudo-terminals (node-pty)', - async () => { - const handle = await ShellExecutionService.execute( - Platform.isPty(), - workspace, - () => {}, - new AbortController().signal, - true, + describe('Virtual Commands', () => { + it('handles virtual read commands', async () => { + const testFile = path.join(workspace, 'read-virtual.txt'); + fs.writeFileSync(testFile, 'virtual read success'); + + const sandboxed = await manager.prepareCommand({ + command: '__read', + args: [testFile], + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('virtual read success'); + }); + + it('handles virtual write commands', async () => { + const testFile = path.join(workspace, 'write-virtual.txt'); + + const sandboxed = await manager.prepareCommand({ + command: '__write', + args: [testFile], + cwd: workspace, + env: process.env, + }); + + // Executing __write directly via runCommand hangs because 'cat' waits for stdin. + // Instead, we verify the command was translated correctly. + if (Platform.isWindows) { + // On Windows, the native helper handles '__write' + expect(sandboxed.args.includes('__write')).toBe(true); + } else { + // On macOS/Linux, it is translated to a shell command with 'tee -- "$@" > /dev/null' + expect(sandboxed.args.join(' ')).toContain('tee --'); + } + }); + }); + + describe('Environment Sanitization', () => { + it('scrubs sensitive environment variables', async () => { + const checkEnvCmd = { + command: process.execPath, + args: [ + '-e', + 'console.log(process.env.TEST_SECRET_TOKEN || "MISSING")', + ], + }; + + const sandboxed = await manager.prepareCommand({ + ...checkEnvCmd, + cwd: workspace, + env: { ...process.env, TEST_SECRET_TOKEN: 'super-secret-value' }, + policy: { + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + blockedEnvironmentVariables: ['TEST_SECRET_TOKEN'], + }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + // By default, environment sanitization drops non-allowlisted vars or vars that look like secrets. + // Assuming TEST_SECRET_TOKEN is scrubbed: + expect(result.stdout.trim()).toBe('MISSING'); + }); + }); + }); + + describe('Sandbox Policies & Modes', () => { + describe('Plan Mode Transitions', () => { + it('allows writing plans in plan mode', async () => { + // In Plan Mode, modeConfig sets readonly: true, allowOverrides: true + const planManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: true, allowOverrides: true } }, + ); + + const plansDir = path.join(workspace, '.gemini/tmp/session-123/plans'); + fs.mkdirSync(plansDir, { recursive: true }); + const planFile = path.join(plansDir, 'feature-plan.md'); + + // The WriteFile tool requests explicit write access for the plan file path + const { command, args } = Platform.touch(planFile); + + const sandboxed = await planManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + policy: { allowedPaths: [planFile] }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(planFile)).toBe(true); + }); + + it('allows workspace writes after exiting plan mode', async () => { + // Upon exiting Plan Mode, the sandbox transitions to autoEdit/accepting_edits + // which sets readonly: false, allowOverrides: true + const editManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: false, allowOverrides: true } }, + ); + + const taskFile = path.join(workspace, 'src/tasks/task.ts'); + fs.mkdirSync(path.dirname(taskFile), { recursive: true }); + + // Simulate a generic edit anywhere in the workspace + const { command, args } = Platform.touch(taskFile); + + const sandboxed = await editManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + policy: { allowedPaths: [taskFile] }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(taskFile)).toBe(true); + }); + }); + + describe('Workspace Write Policies', () => { + it('enforces read-only mode', async () => { + const testFile = path.join(workspace, 'readonly-test.txt'); + const { command, args } = Platform.touch(testFile); + + const readonlyManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: true, allowOverrides: true } }, + ); + + const sandboxed = await readonlyManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + }); + + it('allows writes for approved tools', async () => { + const testFile = path.join(workspace, 'approved-test.txt'); + const command = Platform.isWindows ? 'cmd.exe' : 'sh'; + const args = Platform.isWindows + ? ['/c', `echo test > "${testFile}"`] + : ['-c', `echo test > "${testFile}"`]; + + // The shell wrapper is stripped by getCommandRoots, so the root command evaluated is 'echo' + const approvedTool = 'echo'; + + const approvedManager = createSandboxManager( + { enabled: true }, { - sanitizationConfig: getSecureSanitizationConfig(), - sandboxManager: manager, + workspace, + modeConfig: { + readonly: true, + allowOverrides: true, + approvedTools: [approvedTool], + }, }, ); - const result = await handle.result; - expect(result.exitCode).toBe(0); - expect(result.output).toContain('True'); - }, - ); + const sandboxed = await approvedManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); + }); + + it('allows writes in YOLO mode', async () => { + const testFile = path.join(workspace, 'yolo-test.txt'); + const { command, args } = Platform.touch(testFile); + + const yoloManager = createSandboxManager( + { enabled: true }, + { + workspace, + modeConfig: { readonly: true, yolo: true, allowOverrides: true }, + }, + ); + + const sandboxed = await yoloManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); + }); + }); }); - describe('File System Access', () => { - it('blocks access outside the workspace', async () => { - const blockedPath = Platform.getExternalBlockedPath(); - const { command, args } = Platform.touch(blockedPath); + describe('File System Security', () => { + describe('File System Access', () => { + it('prevents out-of-bounds access', async () => { + const blockedPath = Platform.getExternalBlockedPath(); + const { command, args } = Platform.touch(blockedPath); - const sandboxed = await manager.prepareCommand({ - command, - args, - cwd: workspace, - env: process.env, + const sandboxed = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); }); - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'failure'); - }); + it('supports dynamic permission expansion', async () => { + const tempDir = createTempDir('expansion-'); + const testFile = path.join(tempDir, 'test.txt'); + const { command, args } = Platform.touch(testFile); - it('allows dynamic expansion of permissions after a failure', async () => { - const tempDir = createTempDir('expansion-'); - const testFile = path.join(tempDir, 'test.txt'); - const { command, args } = Platform.touch(testFile); + // First attempt: fails due to sandbox restrictions + const sandboxed1 = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + const result1 = await runCommand(sandboxed1); + assertResult(result1, sandboxed1, 'failure'); + expect(fs.existsSync(testFile)).toBe(false); - // First attempt: fails due to sandbox restrictions - const sandboxed1 = await manager.prepareCommand({ - command, - args, - cwd: workspace, - env: process.env, - }); - const result1 = await runCommand(sandboxed1); - assertResult(result1, sandboxed1, 'failure'); - expect(fs.existsSync(testFile)).toBe(false); - - // Second attempt: succeeds with additional permissions - const sandboxed2 = await manager.prepareCommand({ - command, - args, - cwd: workspace, - env: process.env, - policy: { allowedPaths: [tempDir] }, - }); - const result2 = await runCommand(sandboxed2); - assertResult(result2, sandboxed2, 'success'); - expect(fs.existsSync(testFile)).toBe(true); - }); - - it('grants access to explicitly allowed paths', async () => { - const allowedDir = createTempDir('allowed-'); - const testFile = path.join(allowedDir, 'test.txt'); - - const { command, args } = Platform.touch(testFile); - const sandboxed = await manager.prepareCommand({ - command, - args, - cwd: workspace, - env: process.env, - policy: { allowedPaths: [allowedDir] }, + // Second attempt: succeeds with additional permissions + const sandboxed2 = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + policy: { allowedPaths: [tempDir] }, + }); + const result2 = await runCommand(sandboxed2); + assertResult(result2, sandboxed2, 'success'); + expect(fs.existsSync(testFile)).toBe(true); }); - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'success'); - expect(fs.existsSync(testFile)).toBe(true); - }); + it('allows access to authorized paths', async () => { + const allowedDir = createTempDir('allowed-'); + const testFile = path.join(allowedDir, 'test.txt'); - it('blocks write access to forbidden paths within the workspace', async () => { - const tempWorkspace = createTempDir('workspace-'); - const forbiddenDir = path.join(tempWorkspace, 'forbidden'); - const testFile = path.join(forbiddenDir, 'test.txt'); - fs.mkdirSync(forbiddenDir); + const { command, args } = Platform.touch(testFile); + const sandboxed = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + policy: { allowedPaths: [allowedDir] }, + }); - const osManager = createSandboxManager( - { enabled: true }, - { - workspace: tempWorkspace, - forbiddenPaths: async () => [forbiddenDir], - }, - ); - const { command, args } = Platform.touch(testFile); - - const sandboxed = await osManager.prepareCommand({ - command, - args, - cwd: tempWorkspace, - env: process.env, + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); }); - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'failure'); - }); - - // Windows icacls does not reliably block read-up access for Low Integrity - // processes, so we skip read-specific assertions on Windows. The internal - // tool architecture prevents read bypasses via the C# wrapper and __read. - it.skipIf(Platform.isWindows)( - 'blocks read access to forbidden paths within the workspace', - async () => { + it('protects forbidden paths from writes', async () => { const tempWorkspace = createTempDir('workspace-'); const forbiddenDir = path.join(tempWorkspace, 'forbidden'); const testFile = path.join(forbiddenDir, 'test.txt'); fs.mkdirSync(forbiddenDir); - fs.writeFileSync(testFile, 'secret data'); const osManager = createSandboxManager( { enabled: true }, @@ -321,8 +500,7 @@ describe('SandboxManager Integration', () => { forbiddenPaths: async () => [forbiddenDir], }, ); - - const { command, args } = Platform.cat(testFile); + const { command, args } = Platform.touch(testFile); const sandboxed = await osManager.prepareCommand({ command, @@ -333,333 +511,391 @@ describe('SandboxManager Integration', () => { const result = await runCommand(sandboxed); assertResult(result, sandboxed, 'failure'); - }, - ); + }); - it('blocks access to files inside forbidden directories recursively', async () => { - const tempWorkspace = createTempDir('workspace-'); - const forbiddenDir = path.join(tempWorkspace, 'forbidden'); - const nestedDir = path.join(forbiddenDir, 'nested'); - const nestedFile = path.join(nestedDir, 'test.txt'); + // Windows icacls does not reliably block read-up access for Low Integrity + // processes, so we skip read-specific assertions on Windows. The internal + // tool architecture prevents read bypasses via the C# wrapper and __read. + it.skipIf(Platform.isWindows)( + 'protects forbidden paths from reads', + async () => { + const tempWorkspace = createTempDir('workspace-'); + const forbiddenDir = path.join(tempWorkspace, 'forbidden'); + const testFile = path.join(forbiddenDir, 'test.txt'); + fs.mkdirSync(forbiddenDir); + fs.writeFileSync(testFile, 'secret data'); - // Create the base forbidden directory first so the manager can restrict access to it. - fs.mkdirSync(forbiddenDir); + const osManager = createSandboxManager( + { enabled: true }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [forbiddenDir], + }, + ); - const osManager = createSandboxManager( - { enabled: true }, - { - workspace: tempWorkspace, - forbiddenPaths: async () => [forbiddenDir], + const { command, args } = Platform.cat(testFile); + + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); }, ); - // Execute a dummy command so the manager initializes its restrictions. - const dummyCommand = await osManager.prepareCommand({ - ...Platform.echo('init'), - cwd: tempWorkspace, - env: process.env, - }); - await runCommand(dummyCommand); + it('protects forbidden directories recursively', async () => { + const tempWorkspace = createTempDir('workspace-'); + const forbiddenDir = path.join(tempWorkspace, 'forbidden'); + const nestedDir = path.join(forbiddenDir, 'nested'); + const nestedFile = path.join(nestedDir, 'test.txt'); - // Now create the nested items. They will inherit the sandbox restrictions from their parent. - fs.mkdirSync(nestedDir, { recursive: true }); - fs.writeFileSync(nestedFile, 'secret'); + // Create the base forbidden directory first so the manager can restrict access to it. + fs.mkdirSync(forbiddenDir); - const { command, args } = Platform.touch(nestedFile); + const osManager = createSandboxManager( + { enabled: true }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [forbiddenDir], + }, + ); - const sandboxed = await osManager.prepareCommand({ - command, - args, - cwd: tempWorkspace, - env: process.env, - }); - - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'failure'); - }); - - it('prioritizes forbiddenPaths over allowedPaths', async () => { - const tempWorkspace = createTempDir('workspace-'); - const conflictDir = path.join(tempWorkspace, 'conflict'); - const testFile = path.join(conflictDir, 'test.txt'); - fs.mkdirSync(conflictDir); - - const osManager = createSandboxManager( - { enabled: true }, - { - workspace: tempWorkspace, - forbiddenPaths: async () => [conflictDir], - }, - ); - const { command, args } = Platform.touch(testFile); - - const sandboxed = await osManager.prepareCommand({ - command, - args, - cwd: tempWorkspace, - env: process.env, - policy: { - allowedPaths: [conflictDir], - }, - }); - - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'failure'); - }); - - it('gracefully ignores non-existent paths in allowedPaths and forbiddenPaths', async () => { - const tempWorkspace = createTempDir('workspace-'); - const nonExistentPath = path.join(tempWorkspace, 'does-not-exist'); - - const osManager = createSandboxManager( - { enabled: true }, - { - workspace: tempWorkspace, - forbiddenPaths: async () => [nonExistentPath], - }, - ); - const { command, args } = Platform.echo('survived'); - const sandboxed = await osManager.prepareCommand({ - command, - args, - cwd: tempWorkspace, - env: process.env, - policy: { - allowedPaths: [nonExistentPath], - }, - }); - - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'success'); - expect(result.stdout.trim()).toBe('survived'); - }); - - it('prevents creation of non-existent forbidden paths', async () => { - const tempWorkspace = createTempDir('workspace-'); - const nonExistentFile = path.join(tempWorkspace, 'never-created.txt'); - - const osManager = createSandboxManager( - { enabled: true }, - { - workspace: tempWorkspace, - forbiddenPaths: async () => [nonExistentFile], - }, - ); - - // We use touch to attempt creation of the file - const { command: cmdTouch, args: argsTouch } = - Platform.touch(nonExistentFile); - - const sandboxedCmd = await osManager.prepareCommand({ - command: cmdTouch, - args: argsTouch, - cwd: tempWorkspace, - env: process.env, - }); - - // Execute the command, we expect it to fail (permission denied or read-only file system) - const result = await runCommand(sandboxedCmd); - - assertResult(result, sandboxedCmd, 'failure'); - expect(fs.existsSync(nonExistentFile)).toBe(false); - }); - - it('blocks access to both a symlink and its target when the symlink is forbidden', async () => { - const tempWorkspace = createTempDir('workspace-'); - const targetFile = path.join(tempWorkspace, 'target.txt'); - const symlinkFile = path.join(tempWorkspace, 'link.txt'); - - fs.writeFileSync(targetFile, 'secret data'); - fs.symlinkSync(targetFile, symlinkFile); - - const osManager = createSandboxManager( - { enabled: true }, - { - workspace: tempWorkspace, - forbiddenPaths: async () => [symlinkFile], - }, - ); - - // Attempt to write to the target file directly - const { command: cmdTarget, args: argsTarget } = - Platform.touch(targetFile); - const commandTarget = await osManager.prepareCommand({ - command: cmdTarget, - args: argsTarget, - cwd: tempWorkspace, - env: process.env, - }); - - const resultTarget = await runCommand(commandTarget); - assertResult(resultTarget, commandTarget, 'failure'); - - // Attempt to write via the symlink - const { command: cmdLink, args: argsLink } = Platform.touch(symlinkFile); - const commandLink = await osManager.prepareCommand({ - command: cmdLink, - args: argsLink, - cwd: tempWorkspace, - env: process.env, - }); - - const resultLink = await runCommand(commandLink); - assertResult(resultLink, commandLink, 'failure'); - }); - }); - - describe('Git Worktree Support', () => { - it('allows access to git common directory in a worktree', async () => { - const mainRepo = createTempDir('main-repo-'); - const worktreeDir = createTempDir('worktree-'); - - const mainGitDir = path.join(mainRepo, '.git'); - fs.mkdirSync(mainGitDir, { recursive: true }); - fs.writeFileSync( - path.join(mainGitDir, 'config'), - '[core]\n\trepositoryformatversion = 0\n', - ); - - const worktreeGitDir = path.join( - mainGitDir, - 'worktrees', - 'test-worktree', - ); - fs.mkdirSync(worktreeGitDir, { recursive: true }); - - // Create the .git file in the worktree directory pointing to the worktree git dir - fs.writeFileSync( - path.join(worktreeDir, '.git'), - `gitdir: ${worktreeGitDir}\n`, - ); - - // Create the backlink from worktree git dir to the worktree's .git file - const backlinkPath = path.join(worktreeGitDir, 'gitdir'); - fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git')); - - // Create a file in the worktree git dir that we want to access - const secretFile = path.join(worktreeGitDir, 'secret.txt'); - fs.writeFileSync(secretFile, 'git-secret'); - - const osManager = createSandboxManager( - { enabled: true }, - { workspace: worktreeDir }, - ); - - const { command, args } = Platform.cat(secretFile); - const sandboxed = await osManager.prepareCommand({ - command, - args, - cwd: worktreeDir, - env: process.env, - }); - - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'success'); - expect(result.stdout.trim()).toBe('git-secret'); - }); - - it('blocks write access to git common directory in a worktree', async () => { - const mainRepo = createTempDir('main-repo-'); - const worktreeDir = createTempDir('worktree-'); - - const mainGitDir = path.join(mainRepo, '.git'); - fs.mkdirSync(mainGitDir, { recursive: true }); - - const worktreeGitDir = path.join( - mainGitDir, - 'worktrees', - 'test-worktree', - ); - fs.mkdirSync(worktreeGitDir, { recursive: true }); - - fs.writeFileSync( - path.join(worktreeDir, '.git'), - `gitdir: ${worktreeGitDir}\n`, - ); - fs.writeFileSync( - path.join(worktreeGitDir, 'gitdir'), - path.join(worktreeDir, '.git'), - ); - - const targetFile = path.join(worktreeGitDir, 'secret.txt'); - - const osManager = createSandboxManager( - { enabled: true }, - // Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only - { workspace: worktreeDir, modeConfig: { yolo: true } }, - ); - - const { command, args } = Platform.touch(targetFile); - const sandboxed = await osManager.prepareCommand({ - command, - args, - cwd: worktreeDir, - env: process.env, - }); - - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'failure'); - expect(fs.existsSync(targetFile)).toBe(false); - }); - }); - - describe('Network Access', () => { - let server: http.Server; - let url: string; - - beforeAll(async () => { - server = http.createServer((_, res) => { - res.setHeader('Connection', 'close'); - res.writeHead(200); - res.end('ok'); - }); - await new Promise((resolve, reject) => { - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as import('net').AddressInfo; - url = `http://127.0.0.1:${addr.port}`; - resolve(); + // Execute a dummy command so the manager initializes its restrictions. + const dummyCommand = await osManager.prepareCommand({ + ...Platform.echo('init'), + cwd: tempWorkspace, + env: process.env, }); + await runCommand(dummyCommand); + + // Now create the nested items. They will inherit the sandbox restrictions from their parent. + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(nestedFile, 'secret'); + + const { command, args } = Platform.touch(nestedFile); + + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + }); + + it('prioritizes denials over allowances', async () => { + const tempWorkspace = createTempDir('workspace-'); + const conflictDir = path.join(tempWorkspace, 'conflict'); + const testFile = path.join(conflictDir, 'test.txt'); + fs.mkdirSync(conflictDir); + + const osManager = createSandboxManager( + { enabled: true }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [conflictDir], + }, + ); + const { command, args } = Platform.touch(testFile); + + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + policy: { + allowedPaths: [conflictDir], + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + }); + + it('handles missing paths gracefully', async () => { + const tempWorkspace = createTempDir('workspace-'); + const nonExistentPath = path.join(tempWorkspace, 'does-not-exist'); + + const osManager = createSandboxManager( + { enabled: true }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [nonExistentPath], + }, + ); + const { command, args } = Platform.echo('survived'); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + policy: { + allowedPaths: [nonExistentPath], + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('survived'); + }); + + it('prevents creation of forbidden files', async () => { + const tempWorkspace = createTempDir('workspace-'); + const nonExistentFile = path.join(tempWorkspace, 'never-created.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [nonExistentFile], + }, + ); + + // We use touch to attempt creation of the file + const { command: cmdTouch, args: argsTouch } = + Platform.touch(nonExistentFile); + + const sandboxedCmd = await osManager.prepareCommand({ + command: cmdTouch, + args: argsTouch, + cwd: tempWorkspace, + env: process.env, + }); + + // Execute the command, we expect it to fail (permission denied or read-only file system) + const result = await runCommand(sandboxedCmd); + + assertResult(result, sandboxedCmd, 'failure'); + expect(fs.existsSync(nonExistentFile)).toBe(false); + }); + + it('restricts symlinks to forbidden targets', async () => { + const tempWorkspace = createTempDir('workspace-'); + const targetFile = path.join(tempWorkspace, 'target.txt'); + const symlinkFile = path.join(tempWorkspace, 'link.txt'); + + fs.writeFileSync(targetFile, 'secret data'); + fs.symlinkSync(targetFile, symlinkFile); + + const osManager = createSandboxManager( + { enabled: true }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [symlinkFile], + }, + ); + + // Attempt to write to the target file directly + const { command: cmdTarget, args: argsTarget } = + Platform.touch(targetFile); + const commandTarget = await osManager.prepareCommand({ + command: cmdTarget, + args: argsTarget, + cwd: tempWorkspace, + env: process.env, + }); + + const resultTarget = await runCommand(commandTarget); + assertResult(resultTarget, commandTarget, 'failure'); + + // Attempt to write via the symlink + const { command: cmdLink, args: argsLink } = + Platform.touch(symlinkFile); + const commandLink = await osManager.prepareCommand({ + command: cmdLink, + args: argsLink, + cwd: tempWorkspace, + env: process.env, + }); + + const resultLink = await runCommand(commandLink); + assertResult(resultLink, commandLink, 'failure'); }); }); - afterAll(async () => { - if (server) await new Promise((res) => server.close(() => res())); - }); - - // Windows Job Object rate limits exempt loopback (127.0.0.1) traffic, - // so this test cannot verify loopback blocking on Windows. - it.skipIf(Platform.isWindows)( - 'blocks network access by default', - async () => { - const { command, args } = Platform.curl(url); - const sandboxed = await manager.prepareCommand({ + describe('Governance Files', () => { + it('initializes repository governance files', async () => { + const { command, args } = Platform.echo('test'); + await manager.prepareCommand({ command, args, cwd: workspace, env: process.env, }); - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'failure'); - }, - ); + // Verify all expected governance files were initialized correctly + for (const file of GOVERNANCE_FILES) { + const expectedPath = path.join(workspace, file.path); + expect(fs.existsSync(expectedPath)).toBe(true); + expect(fs.statSync(expectedPath).isDirectory()).toBe( + file.isDirectory, + ); + } + }); + }); - it('grants network access when explicitly allowed', async () => { - const { command, args } = Platform.curl(url); - const sandboxed = await manager.prepareCommand({ - command, - args, - cwd: workspace, - env: process.env, - policy: { networkAccess: true }, + describe('Git Worktree Support', () => { + it('supports git worktrees', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + fs.writeFileSync( + path.join(mainGitDir, 'config'), + '[core]\n\trepositoryformatversion = 0\n', + ); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + // Create the .git file in the worktree directory pointing to the worktree git dir + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + + // Create the backlink from worktree git dir to the worktree's .git file + const backlinkPath = path.join(worktreeGitDir, 'gitdir'); + fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git')); + + // Create a file in the worktree git dir that we want to access + const secretFile = path.join(worktreeGitDir, 'secret.txt'); + fs.writeFileSync(secretFile, 'git-secret'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.cat(secretFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('git-secret'); }); - const result = await runCommand(sandboxed); - assertResult(result, sandboxed, 'success'); - if (!Platform.isWindows) { - expect(result.stdout.trim()).toBe('ok'); - } + it('protects git worktree metadata', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + // Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only + { workspace: worktreeDir, modeConfig: { yolo: true } }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(targetFile)).toBe(false); + }); + }); + }); + + describe('Network Security', () => { + describe('Network Access', () => { + let server: http.Server; + let url: string; + + beforeAll(async () => { + server = http.createServer((_, res) => { + res.setHeader('Connection', 'close'); + res.writeHead(200); + res.end('ok'); + }); + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as import('net').AddressInfo; + url = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + }); + + afterAll(async () => { + if (server) await new Promise((res) => server.close(() => res())); + }); + + // Windows Job Object rate limits exempt loopback (127.0.0.1) traffic, + // so this test cannot verify loopback blocking on Windows. + it.skipIf(Platform.isWindows)( + 'prevents unauthorized network access', + async () => { + const { command, args } = Platform.curl(url); + const sandboxed = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + }, + ); + + it('allows authorized network access', async () => { + const { command, args } = Platform.curl(url); + const sandboxed = await manager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + policy: { networkAccess: true }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + if (!Platform.isWindows) { + expect(result.stdout.trim()).toBe('ok'); + } + }); }); }); }); diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts deleted file mode 100644 index 5de25db457..0000000000 --- a/packages/core/src/services/sandboxManager.test.ts +++ /dev/null @@ -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( - '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( - '../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); - }); - }); -}); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index a4dc356984..c2d91fdd50 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -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 { - 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 { - /** - * 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';