From bcf599b3b272637bcfb30d3205a8d54badea3182 Mon Sep 17 00:00:00 2001 From: ehedlund Date: Wed, 15 Apr 2026 16:01:24 -0400 Subject: [PATCH] refactor(core): abstract OsSandboxManager and centralize common logic --- .../sandbox/abstractOsSandboxManager.test.ts | 173 +++++ .../src/sandbox/abstractOsSandboxManager.ts | 260 +++++++ packages/core/src/sandbox/constants.ts | 24 + .../sandbox/linux/LinuxSandboxManager.test.ts | 17 - .../src/sandbox/linux/LinuxSandboxManager.ts | 292 ++++---- .../sandbox/linux/bwrapArgsBuilder.test.ts | 2 +- .../src/sandbox/linux/bwrapArgsBuilder.ts | 20 +- .../sandbox/macos/MacOsSandboxManager.test.ts | 31 - .../src/sandbox/macos/MacOsSandboxManager.ts | 212 +++--- .../sandbox/macos/seatbeltArgsBuilder.test.ts | 2 +- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 7 +- .../core/src/sandbox/utils/commandUtils.ts | 2 +- .../core/src/sandbox/utils/governanceUtils.ts | 43 ++ .../src/sandbox/utils/sandboxDenialUtils.ts | 2 +- .../sandbox/utils/sandboxPathUtils.test.ts | 106 +++ .../src/sandbox/utils/sandboxPathUtils.ts | 120 ++++ .../windows/WindowsSandboxManager.test.ts | 200 +++--- .../sandbox/windows/WindowsSandboxManager.ts | 637 +++++++++--------- .../sandboxManager.integration.test.ts | 2 +- .../core/src/services/sandboxManager.test.ts | 196 +----- packages/core/src/services/sandboxManager.ts | 212 +----- 21 files changed, 1388 insertions(+), 1172 deletions(-) create mode 100644 packages/core/src/sandbox/abstractOsSandboxManager.test.ts create mode 100644 packages/core/src/sandbox/abstractOsSandboxManager.ts create mode 100644 packages/core/src/sandbox/constants.ts create mode 100644 packages/core/src/sandbox/utils/governanceUtils.ts create mode 100644 packages/core/src/sandbox/utils/sandboxPathUtils.test.ts create mode 100644 packages/core/src/sandbox/utils/sandboxPathUtils.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..db6b19e4c1 --- /dev/null +++ b/packages/core/src/sandbox/abstractOsSandboxManager.test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + AbstractOsSandboxManager, + type PreparedExecutionDetails, +} from './abstractOsSandboxManager.js'; + +import type { + SandboxRequest, + ParsedSandboxDenial, + SandboxedCommand, + SandboxPermissions, +} from '../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../services/shellExecutionService.js'; +import path from 'node:path'; +import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js'; + +/** + * A minimal concrete subclass to test AbstractOsSandboxManager in isolation. + * Overriding abstract methods avoids real OS dependencies and focuses on the + * Template Method flow and environment preparation. + */ +class TestOsSandboxManager extends AbstractOsSandboxManager { + isDangerousCommand(_args: string[]): boolean { + return false; + } + parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return undefined; + } + protected async buildSandboxedCommand( + details: PreparedExecutionDetails, + ): Promise { + return { + program: 'test', + args: [details.networkAccess ? '1' : '0'], + env: details.sanitizedEnv, + cwd: details.req.cwd, + cleanup: () => {}, + }; + } + protected override isOsSafeCommand(_args: string[]): boolean { + return false; + } + protected override async isStrictlyApproved( + _command: string, + _args: string[], + _req: SandboxRequest, + ): Promise { + return false; + } + protected override ensureGovernanceFilesExist(_workspace: string): void {} + protected override async initialize(): Promise {} + protected override mapVirtualCommandToNative( + command: string, + args: string[], + ) { + return { command, args }; + } + protected override updateReadWritePermissions() {} + protected override rewriteReadWriteCommand( + _req: SandboxRequest, + command: string, + args: string[], + _permissions: SandboxPermissions, + ) { + return { command, args }; + } + protected override isCaseInsensitive() { + return false; + } +} + +describe('AbstractOsSandboxManager', () => { + const workspace = path.resolve('/workspace'); + + it('rejects overrides when allowOverrides is false', async () => { + const customManager = new TestOsSandboxManager({ + workspace, + modeConfig: { allowOverrides: false }, + }); + await expect( + customManager.prepareCommand({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + policy: { networkAccess: true }, + }), + ).rejects.toThrow(/Cannot override/); + }); + + it('should correctly pass through the cwd to the resulting command', async () => { + const manager = new TestOsSandboxManager({ workspace }); + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: '/test/different/cwd', + env: {}, + }); + + expect(result.cwd).toBe('/test/different/cwd'); + }); + + it('should apply environment sanitization via the default mechanisms', async () => { + const manager = new TestOsSandboxManager({ workspace }); + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: workspace, + env: { + SAFE_VAR: '1', + GITHUB_TOKEN: 'sensitive', + }, + policy: { + sanitizationConfig: { enableEnvironmentVariableRedaction: true }, + }, + }); + + expect(result.env['SAFE_VAR']).toBe('1'); + expect(result.env['GITHUB_TOKEN']).toBeUndefined(); + }); + + it('should reject network access in Plan mode', async () => { + const planManager = new TestOsSandboxManager({ + workspace, + modeConfig: { readonly: true, allowOverrides: false }, + }); + const req: SandboxRequest = { + command: 'curl', + args: ['google.com'], + cwd: workspace, + 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 = path.join(workspace, 'persistent'); + const mockPolicyManager = { + getCommandPermissions: vi.fn().mockReturnValue({ + fileSystem: { write: [persistentPath] }, + network: true, + }), + }; + + const managerWithPolicy = new TestOsSandboxManager({ + workspace, + modeConfig: { allowOverrides: true, network: false }, + policyManager: mockPolicyManager as unknown as SandboxPolicyManager, + }); + + const req: SandboxRequest = { + command: 'test-cmd', + args: [], + cwd: workspace, + env: {}, + }; + + const result = await managerWithPolicy.prepareCommand(req); + expect(result.args[0]).toBe('1'); // Network allowed by persistent policy + }); +}); diff --git a/packages/core/src/sandbox/abstractOsSandboxManager.ts b/packages/core/src/sandbox/abstractOsSandboxManager.ts new file mode 100644 index 0000000000..99a286dfd5 --- /dev/null +++ b/packages/core/src/sandbox/abstractOsSandboxManager.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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, + getCommandName, +} from './utils/commandUtils.js'; +import { + createSandboxDenialCache, + type SandboxDenialCache, +} from './utils/sandboxDenialUtils.js'; +import { + type ResolvedSandboxPaths, + resolveSandboxPaths, +} from './utils/sandboxPathUtils.js'; + +export interface PreparedExecutionDetails { + finalCommand: string; + finalArgs: string[]; + sanitizedEnv: NodeJS.ProcessEnv; + resolvedPaths: ResolvedSandboxPaths; + workspaceWrite: boolean; + networkAccess: boolean; + req: SandboxRequest; +} + +/** + * Base class for OS-specific sandbox managers. + * Enforces the Template Method pattern for command preparation. + */ +export abstract class AbstractOsSandboxManager implements SandboxManager { + protected readonly denialCache: SandboxDenialCache = + createSandboxDenialCache(); + protected governanceFilesInitialized = false; + + constructor(protected readonly options: GlobalSandboxOptions) {} + + getWorkspace(): string { + return this.options.workspace; + } + + getOptions(): GlobalSandboxOptions { + return this.options; + } + + /** + * Prepares a command for sandboxed execution by resolving permissions and paths. + */ + async prepareCommand(req: SandboxRequest): Promise { + // Initialize OS-specific sandbox mechanisms if needed + await this.initialize(); + + // Sanitize environment variables based on policy + const sanitizationConfig = getSecureSanitizationConfig( + req.policy?.sanitizationConfig, + ); + const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + + // Verify that request doesn't attempt illegal overrides + const isReadonlyMode = this.options.modeConfig?.readonly ?? true; + const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; + verifySandboxOverrides(allowOverrides, req.policy); + + // Translate virtual commands (like __read) to native commands + const { command, args } = this.mapVirtualCommandToNative( + req.command, + req.args, + ); + + // Extract a stable command name for policy lookup + const commandName = await getCommandName({ ...req, command, args }); + + // Determine if this specific execution is strictly approved + const isApproved = allowOverrides + ? await this.isStrictlyApproved(command, args, req) + : false; + + // Resolve broad write permissions (workspace, readonly, yolo) + const isYolo = this.options.modeConfig?.yolo ?? false; + const workspaceWrite = !isReadonlyMode || isApproved || isYolo; + + const defaultNetwork = + this.options.modeConfig?.network || req.policy?.networkAccess || isYolo; + + // Load persistent permissions for this command from policy manager + const persistentPermissions = allowOverrides + ? this.options.policyManager?.getCommandPermissions(commandName) + : undefined; + + // Merge request-specific and persistent permissions + const mergedPermissions: 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, + }; + + // Allow OS-specific managers to update permissions (rules) based on the request (e.g., Windows tracking file targets for manifests) + this.updateReadWritePermissions(mergedPermissions, req); + + // Allow OS-specific managers to rewrite the command (action) based on permissions (e.g., resolving read/write paths for POSIX systems) + const { command: finalCommand, args: finalArgs } = + this.rewriteReadWriteCommand(req, command, args, mergedPermissions); + + // Resolve all paths to absolute real paths + const resolvedPaths = await resolveSandboxPaths( + this.options, + req, + mergedPermissions, + ); + + // Ensure sandbox policy evidence files exist in workspace + this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved); + + // Delegate the actual construction of the sandboxed invocation + return this.buildSandboxedCommand({ + finalCommand, + finalArgs, + sanitizedEnv, + resolvedPaths, + workspaceWrite, + networkAccess: mergedPermissions.network ?? false, + req, + }); + } + + /** + * Checks if a command is allowed by policy or considered safe by the OS. + */ + isKnownSafeCommand(args: string[]): boolean { + const toolName = args[0]; + if (!toolName) return false; + + if (this.isToolApproved(toolName)) { + return true; + } + + return this.isOsSafeCommand(args); + } + + /** + * Checks if a tool is in the list of approved tools. + */ + protected isToolApproved(toolName: string): boolean { + const tools = this.options.modeConfig?.approvedTools ?? []; + if (tools.length === 0) return false; + + if (this.isCaseInsensitive()) { + const targetTool = toolName.toLowerCase(); + return tools.map((t) => t.toLowerCase()).includes(targetTool); + } + + return tools.includes(toolName); + } + + /** + * Lifecycle hook called at the very beginning of command preparation. + * Used for OS-specific initialization (e.g., compiling helpers). + */ + protected abstract initialize(): Promise; + + /** + * Ensures that governance files exist in the sandbox workspace. + */ + protected abstract ensureGovernanceFilesExist(workspace: string): void; + + /** + * Translates virtual commands (like `__read` or `__write`) into native OS commands. + */ + protected abstract mapVirtualCommandToNative( + command: string, + args: string[], + ): { command: string; args: string[] }; + + /** + * Allows OS-specific managers to update permissions (rules) for read/write commands. + */ + protected abstract updateReadWritePermissions( + permissions: SandboxPermissions, + req: SandboxRequest, + ): void; + + /** + * Allows OS-specific managers to rewrite the command (action) for read/write commands + * after permissions are resolved. + */ + protected abstract rewriteReadWriteCommand( + req: SandboxRequest, + command: string, + args: string[], + permissions: SandboxPermissions, + ): { command: string; args: string[] }; + + /** + * Builds the final sandboxed command execution details. + */ + protected abstract buildSandboxedCommand( + details: PreparedExecutionDetails, + ): Promise; + + /** + * Returns whether the filesystem is case-insensitive. + */ + protected abstract isCaseInsensitive(): boolean; + + /** + * Returns whether the command or arguments are considered dangerous + * regardless of the sandbox configuration. + */ + abstract isDangerousCommand(args: string[]): boolean; + + /** + * OS-specific check for known safe commands. + */ + protected abstract isOsSafeCommand(args: string[]): boolean; + + /** + * Checks if the command is strictly approved for execution, + * potentially overriding read-only restrictions. + */ + protected abstract isStrictlyApproved( + command: string, + args: string[], + req: SandboxRequest, + ): Promise; + + /** + * Parses denials from execution output to populate the cache. + */ + abstract parseDenials( + result: ShellExecutionResult, + ): ParsedSandboxDenial | undefined; +} diff --git a/packages/core/src/sandbox/constants.ts b/packages/core/src/sandbox/constants.ts new file mode 100644 index 0000000000..2347e67bf1 --- /dev/null +++ b/packages/core/src/sandbox/constants.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * 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 }, +]; + +/** + * Files that typically contain sensitive secrets or environment variables + * and should be protected from unauthorized access or exfiltration. + */ +export const SECRET_FILES = [ + { pattern: '.env' }, + { pattern: '.env.*' }, +] as const; diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index 2432655da5..5d3f2b1878 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -53,7 +53,6 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => { Promise.resolve({ status: 0, stdout: Buffer.from('') }), ), initializeShellParsers: vi.fn(), - isStrictlyApproved: vi.fn().mockResolvedValue(true), }; }); @@ -149,21 +148,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 a431085c33..3533f7d504 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -5,41 +5,28 @@ */ 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, } from '../../services/sandboxManager.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, + isKnownSafeCommand as isPosixSafeCommand, + isDangerousCommand as isPosixDangerousCommand, } from '../utils/commandSafety.js'; -import { - parsePosixSandboxDenials, - createSandboxDenialCache, - type SandboxDenialCache, -} from '../utils/sandboxDenialUtils.js'; -import { isErrnoException } from '../utils/fsUtils.js'; +import { ensureGovernanceFilesExist } from '../utils/governanceUtils.js'; +import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js'; import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js'; import { buildBwrapArgs } from './bwrapArgsBuilder.js'; +import { + AbstractOsSandboxManager, + type PreparedExecutionDetails, +} from '../abstractOsSandboxManager.js'; +import { isStrictlyApproved } from '../utils/commandUtils.js'; let cachedBpfPath: string | undefined; @@ -110,177 +97,84 @@ 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 - fs.lstatSync(filePath); - return; - } catch (e: unknown) { - if (isErrnoException(e) && e.code !== 'ENOENT') { - throw e; - } - } - - 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(); - private governanceFilesInitialized = false; - constructor(private readonly options: GlobalSandboxOptions) {} + protected override async initialize(): Promise { + // Default no-op + } - private ensureGovernanceFilesExist(workspace: string): void { + protected override ensureGovernanceFilesExist(workspace: string): void { if (this.governanceFilesInitialized) return; - - // These must exist on the host before running the sandbox to ensure they are protected. - for (const file of GOVERNANCE_FILES) { - const filePath = join(workspace, file.path); - touch(filePath, file.isDirectory); - } - + ensureGovernanceFilesExist(workspace); this.governanceFilesInitialized = true; } - isKnownSafeCommand(args: string[]): boolean { - return isKnownSafeCommand(args); - } - - isDangerousCommand(args: string[]): boolean { - return isDangerousCommand(args); - } - - parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { - return parsePosixSandboxDenials(result, this.denialCache); - } - - getWorkspace(): string { - return this.options.workspace; - } - - getOptions(): GlobalSandboxOptions { - return this.options; - } - - private getMaskFilePath(): string { - if ( - LinuxSandboxManager.maskFilePath && - fs.existsSync(LinuxSandboxManager.maskFilePath) - ) { - return LinuxSandboxManager.maskFilePath; - } - const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-')); - const maskPath = join(tempDir, 'mask'); - fs.writeFileSync(maskPath, ''); - fs.chmodSync(maskPath, 0); - LinuxSandboxManager.maskFilePath = maskPath; - - // Cleanup on exit - process.on('exit', () => { - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore errors - } - }); - - 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 + /** + * Virtual commands like __read and __write must be mapped to native POSIX tools + * so that Bubblewrap can enforce file access policies on real executables. + */ + protected override mapVirtualCommandToNative( + command: string, + args: string[], + ): { command: string; args: string[] } { if (command === '__read') { - command = 'cat'; - } else if (command === '__write') { - command = 'sh'; - args = ['-c', 'cat > "$1"', '_', ...args]; + return { command: 'cat', args }; } + if (command === '__write') { + return { command: 'sh', args: ['-c', 'cat > "$1"', '_', ...args] }; + } + return { command, 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; + /** + * Linux permissions are entirely driven by Bubblewrap bind mounts and seccomp filters + * resolved from paths later in the flow. No intermediate tweaking is needed here. + */ + protected override updateReadWritePermissions( + _permissions: SandboxPermissions, + _req: SandboxRequest, + ): void { + // Default no-op + } - const networkAccess = - this.options.modeConfig?.network || req.policy?.networkAccess || isYolo; + /** + * Ensures read/write commands strictly respect allowed paths and workspace boundaries + * before the final sandbox invocation is constructed. + */ + protected override rewriteReadWriteCommand( + req: SandboxRequest, + command: string, + args: string[], + permissions: SandboxPermissions, + ): { command: string; args: string[] } { + return handleReadWriteCommands(req, permissions, this.options.workspace, [ + ...(req.policy?.allowedPaths || []), + ...(this.options.includeDirectories || []), + ]); + } - 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( + protected async buildSandboxedCommand( + details: PreparedExecutionDetails, + ): Promise { + const { + finalCommand, + finalArgs, + sanitizedEnv, + resolvedPaths, + workspaceWrite, + networkAccess, 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, - ); - - this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved); + } = details; const bwrapArgs = await buildBwrapArgs({ resolvedPaths, workspaceWrite, - networkAccess: mergedAdditional.network ?? false, + networkAccess, maskFilePath: this.getMaskFilePath(), isReadOnlyCommand: req.command === '__read', }); @@ -316,6 +210,33 @@ export class LinuxSandboxManager implements SandboxManager { }; } + protected override isCaseInsensitive(): boolean { + return false; + } + + isDangerousCommand(args: string[]): boolean { + return isPosixDangerousCommand(args); + } + + protected override isOsSafeCommand(args: string[]): boolean { + return isPosixSafeCommand(args); + } + + protected override async isStrictlyApproved( + command: string, + args: string[], + req: SandboxRequest, + ): Promise { + return isStrictlyApproved( + { ...req, command, args }, + this.options.modeConfig?.approvedTools, + ); + } + + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return parsePosixSandboxDenials(result, this.denialCache); + } + private writeArgsToTempFile(args: string[]): string { const tempFile = join( os.tmpdir(), @@ -325,4 +246,29 @@ export class LinuxSandboxManager implements SandboxManager { fs.writeFileSync(tempFile, content, { mode: 0o600 }); return tempFile; } + + private getMaskFilePath(): string { + if ( + LinuxSandboxManager.maskFilePath && + fs.existsSync(LinuxSandboxManager.maskFilePath) + ) { + return LinuxSandboxManager.maskFilePath; + } + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-')); + const maskPath = join(tempDir, 'mask'); + fs.writeFileSync(maskPath, ''); + fs.chmodSync(maskPath, 0); + LinuxSandboxManager.maskFilePath = maskPath; + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + + return maskPath; + } } diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index 6cff168d21..07881e2760 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -9,7 +9,7 @@ import { buildBwrapArgs, type BwrapArgsOptions } 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 '../utils/sandboxPathUtils.js'; vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index 591bba8a0e..2c07d2bf9c 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -6,11 +6,8 @@ import fs from 'node:fs'; import { join, dirname } from 'node:path'; -import { - GOVERNANCE_FILES, - getSecretFileFindArgs, - type ResolvedSandboxPaths, -} from '../../services/sandboxManager.js'; +import type { ResolvedSandboxPaths } from '../utils/sandboxPathUtils.js'; +import { GOVERNANCE_FILES, SECRET_FILES } from '../constants.js'; import { isErrnoException } from '../utils/fsUtils.js'; import { spawnAsync } from '../../utils/shell-utils.js'; import { debugLogger } from '../../utils/debugLogger.js'; @@ -215,3 +212,16 @@ async function getSecretFilesArgs( } return args; } + +/** + * Returns arguments for the Linux 'find' command to locate secret files. + */ +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; +} diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 963743a78d..9e57582d06 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -87,37 +87,6 @@ describe('MacOsSandboxManager', () => { expect(fs.existsSync(tempFile)).toBe(false); }); - it('should correctly pass through the cwd to the resulting command', async () => { - const result = await manager.prepareCommand({ - command: 'echo', - args: ['hello'], - cwd: '/test/different/cwd', - env: {}, - policy: mockPolicy, - }); - - 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', diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index f87dc0289c..2d92ce869c 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -8,150 +8,95 @@ 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, } from '../../services/sandboxManager.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; -import { - sanitizeEnvironment, - getSecureSanitizationConfig, -} from '../../services/environmentSanitization.js'; import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js'; +import { isStrictlyApproved } from '../utils/commandUtils.js'; import { initializeShellParsers } from '../../utils/shell-utils.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'; +import { + AbstractOsSandboxManager, + type PreparedExecutionDetails, +} from '../abstractOsSandboxManager.js'; -export class MacOsSandboxManager implements SandboxManager { - private readonly denialCache: SandboxDenialCache = createSandboxDenialCache(); - - constructor(private readonly options: GlobalSandboxOptions) {} - - isKnownSafeCommand(args: string[]): boolean { - const toolName = args[0]; - const approvedTools = this.options.modeConfig?.approvedTools ?? []; - if (toolName && approvedTools.includes(toolName)) { - return true; - } - return isKnownSafeCommand(args); - } - - isDangerousCommand(args: string[]): boolean { - return isDangerousCommand(args); - } - - parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { - return parsePosixSandboxDenials(result, this.denialCache); - } - - getWorkspace(): string { - return this.options.workspace; - } - - getOptions(): GlobalSandboxOptions { - return this.options; - } - - async prepareCommand(req: SandboxRequest): Promise { +export class MacOsSandboxManager extends AbstractOsSandboxManager { + protected override async initialize(): Promise { await initializeShellParsers(); - const sanitizationConfig = getSecureSanitizationConfig( - req.policy?.sanitizationConfig, - ); + } - const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + protected override ensureGovernanceFilesExist(_workspace: string): void { + // Default no-op - Seatbelt is able to block non-existent files + } - 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 + /** + * Mapping virtual commands to absolute paths of native tools ensures that + * Seatbelt profiles can target the exact binaries accurately. + */ + protected override mapVirtualCommandToNative( + command: string, + args: string[], + ): { command: string; args: string[] } { if (command === '__read') { - command = '/bin/cat'; - } else if (command === '__write') { - command = '/bin/sh'; - args = ['-c', 'cat > "$1"', '_', ...args]; + return { command: '/bin/cat', args }; } + if (command === '__write') { + return { command: '/bin/sh', args: ['-c', 'cat > "$1"', '_', ...args] }; + } + return { command, args }; + } - const currentReq = { ...req, command, args }; + /** + * macOS permissions are enforced via dynamically generated Seatbelt profiles + * based on file paths. No dynamic tweaking of the request object is needed here. + */ + protected override updateReadWritePermissions( + _permissions: SandboxPermissions, + _req: SandboxRequest, + ): void { + // Default no-op + } - // 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; + /** + * Ensures read/write commands strictly respect allowed paths and workspace boundaries + * before the Seatbelt profile is generated. + */ + protected override rewriteReadWriteCommand( + req: SandboxRequest, + command: string, + args: string[], + permissions: SandboxPermissions, + ): { command: string; args: string[] } { + return handleReadWriteCommands(req, permissions, this.options.workspace, [ + ...(req.policy?.allowedPaths || []), + ...(this.options.includeDirectories || []), + ]); + } - 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( + protected async buildSandboxedCommand( + details: PreparedExecutionDetails, + ): Promise { + const { + finalCommand, + finalArgs, + sanitizedEnv, + resolvedPaths, + workspaceWrite, + networkAccess, req, - mergedAdditional, - this.options.workspace, - [ - ...(req.policy?.allowedPaths || []), - ...(this.options.includeDirectories || []), - ], - ); - - const resolvedPaths = await resolveSandboxPaths( - this.options, - req, - mergedAdditional, - ); + } = details; const sandboxArgs = buildSeatbeltProfile({ resolvedPaths, - networkAccess: mergedAdditional.network, + networkAccess, workspaceWrite, }); @@ -172,6 +117,33 @@ export class MacOsSandboxManager implements SandboxManager { }; } + protected override isCaseInsensitive(): boolean { + return false; + } + + isDangerousCommand(args: string[]): boolean { + return isPosixDangerousCommand(args); + } + + protected override isOsSafeCommand(args: string[]): boolean { + return isPosixSafeCommand(args); + } + + protected override async isStrictlyApproved( + command: string, + args: string[], + req: SandboxRequest, + ): Promise { + return isStrictlyApproved( + { ...req, command, args }, + this.options.modeConfig?.approvedTools, + ); + } + + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return parsePosixSandboxDenials(result, this.denialCache); + } + private writeProfileToTempFile(profile: string): string { const tempFile = path.join( os.tmpdir(), diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts index e8801b055b..78c851da9e 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 '../utils/sandboxPathUtils.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..d85f082f1d 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -11,11 +11,8 @@ import { BASE_SEATBELT_PROFILE, NETWORK_SEATBELT_PROFILE, } from './baseProfile.js'; -import { - GOVERNANCE_FILES, - SECRET_FILES, - type ResolvedSandboxPaths, -} from '../../services/sandboxManager.js'; +import type { ResolvedSandboxPaths } from '../utils/sandboxPathUtils.js'; +import { GOVERNANCE_FILES, SECRET_FILES } from '../constants.js'; import { resolveToRealPath } from '../../utils/paths.js'; /** diff --git a/packages/core/src/sandbox/utils/commandUtils.ts b/packages/core/src/sandbox/utils/commandUtils.ts index 772df65afa..307f244d00 100644 --- a/packages/core/src/sandbox/utils/commandUtils.ts +++ b/packages/core/src/sandbox/utils/commandUtils.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type SandboxRequest } from '../../services/sandboxManager.js'; +import type { SandboxRequest } from '../../services/sandboxManager.js'; import { getCommandRoots, initializeShellParsers, diff --git a/packages/core/src/sandbox/utils/governanceUtils.ts b/packages/core/src/sandbox/utils/governanceUtils.ts new file mode 100644 index 0000000000..fb18d885f2 --- /dev/null +++ b/packages/core/src/sandbox/utils/governanceUtils.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import { join, dirname } from 'node:path'; +import { GOVERNANCE_FILES } from '../constants.js'; +import { assertValidPathString } from '../../utils/paths.js'; +import { isErrnoException } from './fsUtils.js'; + +/** + * Ensures that governance files exist in the sandbox workspace. + */ +export function ensureGovernanceFilesExist(workspace: string): void { + for (const file of GOVERNANCE_FILES) { + const filePath = join(workspace, file.path); + touch(filePath, file.isDirectory); + } +} + +/** + * Helper to create a file or directory if it doesn't exist. + */ +export function touch(filePath: string, isDirectory: boolean): void { + assertValidPathString(filePath); + try { + fs.lstatSync(filePath); + return; + } catch (e: unknown) { + if (isErrnoException(e) && e.code !== 'ENOENT') { + throw e; + } + } + + if (isDirectory) { + fs.mkdirSync(filePath, { recursive: true }); + } else { + fs.mkdirSync(dirname(filePath), { recursive: true }); + fs.closeSync(fs.openSync(filePath, 'a')); + } +} diff --git a/packages/core/src/sandbox/utils/sandboxDenialUtils.ts b/packages/core/src/sandbox/utils/sandboxDenialUtils.ts index e288be0ed0..d6959fd5dc 100644 --- a/packages/core/src/sandbox/utils/sandboxDenialUtils.ts +++ b/packages/core/src/sandbox/utils/sandboxDenialUtils.ts @@ -5,7 +5,7 @@ */ import { LRUCache } from 'mnemonist'; -import { type ParsedSandboxDenial } from '../../services/sandboxManager.js'; +import type { ParsedSandboxDenial } from '../../services/sandboxManager.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { isValidPathString } from '../../utils/paths.js'; diff --git a/packages/core/src/sandbox/utils/sandboxPathUtils.test.ts b/packages/core/src/sandbox/utils/sandboxPathUtils.test.ts new file mode 100644 index 0000000000..a3c0de2353 --- /dev/null +++ b/packages/core/src/sandbox/utils/sandboxPathUtils.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { resolveSandboxPaths } from './sandboxPathUtils.js'; +import type { SandboxRequest } from '../../services/sandboxManager.js'; +import path from 'node:path'; + +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(process, 'platform', 'get').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/utils/sandboxPathUtils.ts b/packages/core/src/sandbox/utils/sandboxPathUtils.ts new file mode 100644 index 0000000000..f6c4dd6d60 --- /dev/null +++ b/packages/core/src/sandbox/utils/sandboxPathUtils.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + GlobalSandboxOptions, + SandboxRequest, + SandboxPermissions, +} from '../../services/sandboxManager.js'; +import { resolveGitWorktreePaths } from './fsUtils.js'; +import { + toPathKey, + deduplicateAbsolutePaths, + resolveToRealPath, +} from '../../utils/paths.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; + }; +} + +/** + * 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, + }; +} diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 1ba6290e01..1619623662 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -8,11 +8,18 @@ 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 fsPromises from 'node:fs/promises'; + +import * as sandboxPathUtils from '../utils/sandboxPathUtils.js'; +import { + WindowsSandboxManager, + findSecretFiles, + isSecretFile, +} from './WindowsSandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; -import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import * as paths from '../../utils/paths.js'; + +vi.mock('node:fs/promises'); vi.mock('../../utils/shell-utils.js', async (importOriginal) => { const actual = @@ -21,7 +28,35 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => { ...actual, spawnAsync: vi.fn(), initializeShellParsers: vi.fn(), - isStrictlyApproved: vi.fn().mockResolvedValue(true), + getCommandName: vi + .fn() + .mockImplementation(async (command: string) => path.basename(command)), + }; +}); + +vi.mock('./commandSafety.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isStrictlyApproved: vi + .fn() + .mockImplementation(async (command, _args, approvedTools) => { + const tools = approvedTools ?? []; + return tools.includes(command); + }), + }; +}); + +vi.mock('../utils/commandUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getCommandName: vi + .fn() + .mockImplementation(async (req: { command: string }) => + path.basename(req.command), + ), }; }); @@ -171,81 +206,6 @@ describe('WindowsSandboxManager', () => { expect(result.args[0]).toBe('1'); }); - 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', @@ -290,7 +250,7 @@ describe('WindowsSandboxManager', () => { const mainGitDir = createTempDir('main-git'); try { - vi.spyOn(sandboxManager, 'resolveSandboxPaths').mockResolvedValue({ + vi.spyOn(sandboxPathUtils, 'resolveSandboxPaths').mockResolvedValue({ workspace: { original: testCwd, resolved: testCwd }, forbidden: [], globalIncludes: [], @@ -557,3 +517,81 @@ describe('WindowsSandboxManager', () => { expect(fs.existsSync(path.dirname(forbiddenManifestPath))).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('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); + }); + }); +}); diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 42fa196749..2ca0636ace 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -5,44 +5,33 @@ */ import fs from 'node:fs'; -import path, { join } from 'node:path'; +import { readdir } from 'node:fs/promises'; +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 { + SandboxRequest, + SandboxedCommand, + SandboxPermissions, + ParsedSandboxDenial, + GlobalSandboxOptions, } from '../../services/sandboxManager.js'; +import { SECRET_FILES } from '../constants.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; -import { - sanitizeEnvironment, - getSecureSanitizationConfig, -} from '../../services/environmentSanitization.js'; +import { ensureGovernanceFilesExist } from '../utils/governanceUtils.js'; import { debugLogger } from '../../utils/debugLogger.js'; -import { spawnAsync, getCommandName } from '../../utils/shell-utils.js'; +import { spawnAsync } from '../../utils/shell-utils.js'; import { - isKnownSafeCommand, - isDangerousCommand, - isStrictlyApproved, + isKnownSafeCommand as isWindowsSafeCommand, + isDangerousCommand as isWindowsDangerousCommand, + isStrictlyApproved as isWindowsStrictlyApproved, } from './commandSafety.js'; -import { verifySandboxOverrides } from '../utils/commandUtils.js'; import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; -import { isErrnoException } from '../utils/fsUtils.js'; +import { isSubpath, resolveToRealPath } from '../../utils/paths.js'; import { - isSubpath, - resolveToRealPath, - assertValidPathString, -} from '../../utils/paths.js'; -import { - type SandboxDenialCache, - createSandboxDenialCache, -} from '../utils/sandboxDenialUtils.js'; + AbstractOsSandboxManager, + type PreparedExecutionDetails, +} from '../abstractOsSandboxManager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -52,54 +41,259 @@ 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 static helperCompiled = false; private readonly helperPath: string; - private readonly denialCache: SandboxDenialCache = createSandboxDenialCache(); - private static helperCompiled = false; - private governanceFilesInitialized = false; - - 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)) { - return true; + protected override async initialize(): Promise { + await this.ensureHelperCompiled(); + } + + protected override ensureGovernanceFilesExist(workspace: string): void { + if (this.governanceFilesInitialized) return; + ensureGovernanceFilesExist(workspace); + this.governanceFilesInitialized = true; + } + + /** + * Windows supports virtual commands directly via the native helper execution. + * No translation is required. + */ + protected override mapVirtualCommandToNative( + command: string, + args: string[], + ): { command: string; args: string[] } { + return { command, args }; + } + + /** + * Windows requires explicit tracking of requested read/write file targets + * to add them dynamically to the allowed and inheritance roots manifest. + */ + protected override updateReadWritePermissions( + permissions: SandboxPermissions, + req: SandboxRequest, + ): void { + if (req.command === '__read' && req.args[0]) { + permissions.fileSystem?.read?.push(req.args[0]); + } else if (req.command === '__write' && req.args[0]) { + permissions.fileSystem?.write?.push(req.args[0]); } - return isKnownSafeCommand(args); + } + + /** + * Windows does not require command string rewriting as strict file system + * isolation is enforced independently at the native OS process level. + */ + protected override rewriteReadWriteCommand( + _req: SandboxRequest, + command: string, + args: string[], + _permissions: SandboxPermissions, + ): { command: string; args: string[] } { + return { command, args }; + } + + protected async buildSandboxedCommand( + details: PreparedExecutionDetails, + ): Promise { + const { + finalCommand, + finalArgs, + sanitizedEnv, + resolvedPaths, + workspaceWrite, + networkAccess, + req, + } = details; + + // Collect all forbidden paths. + const forbiddenManifest = new Set( + resolvedPaths.forbidden.map((p) => resolveToRealPath(p)), + ); + + const searchDirs = new Set([ + resolvedPaths.workspace.resolved, + ...resolvedPaths.policyAllowed, + ...resolvedPaths.globalIncludes, + ]); + + const secretFilesPromises = Array.from(searchDirs).map(async (dir) => { + try { + const secretFiles = await findSecretFiles(dir, 3); + for (const secretFile of secretFiles) { + forbiddenManifest.add(resolveToRealPath(secretFile)); + } + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to find secret files in ${dir}`, + e, + ); + } + }); + + await Promise.all(secretFilesPromises); + + // Track paths that will be granted write access. + const allowedManifest = new Set(); + const inheritanceRoots = new Set(); + + const addWritableRoot = (p: string) => { + const resolved = resolveToRealPath(p); + inheritanceRoots.add(p); + inheritanceRoots.add(resolved); + + if (this.isSystemDirectory(resolved)) return; + if (forbiddenManifest.has(resolved)) return; + + if ( + resolved.startsWith('\\\\') && + !resolved.startsWith('\\\\?\\') && + !resolved.startsWith('\\\\.\\') + ) { + debugLogger.log( + 'WindowsSandboxManager: Rejecting UNC path for allowed manifest:', + resolved, + ); + return; + } + allowedManifest.add(resolved); + }; + + // Populate writable roots. + if (workspaceWrite) { + addWritableRoot(resolvedPaths.workspace.resolved); + } + + for (const includeDir of resolvedPaths.globalIncludes) { + addWritableRoot(includeDir); + } + + for (const allowedPath of resolvedPaths.policyAllowed) { + try { + await fs.promises.access(allowedPath, fs.constants.F_OK); + } catch { + throw new Error( + `Sandbox request rejected: Allowed path does not exist: ${allowedPath}. ` + + 'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.', + ); + } + addWritableRoot(allowedPath); + } + + for (const writePath of resolvedPaths.policyWrite) { + try { + await fs.promises.access(writePath, fs.constants.F_OK); + addWritableRoot(writePath); + continue; + } catch { + const isInherited = Array.from(inheritanceRoots).some((root) => + isSubpath(root, writePath), + ); + + if (!isInherited) { + throw new Error( + `Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${writePath}. ` + + 'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.', + ); + } + } + } + + // Generate manifests + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'gemini-cli-sandbox-'), + ); + + const forbiddenManifestPath = path.join(tempDir, 'forbidden.txt'); + await fs.promises.writeFile( + forbiddenManifestPath, + Array.from(forbiddenManifest).join('\n'), + ); + + const allowedManifestPath = path.join(tempDir, 'allowed.txt'); + await fs.promises.writeFile( + allowedManifestPath, + Array.from(allowedManifest).join('\n'), + ); + + // Construct the helper command + const program = this.helperPath; + + const finalHelperArgs = [ + networkAccess ? '1' : '0', + req.cwd, + '--forbidden-manifest', + forbiddenManifestPath, + '--allowed-manifest', + allowedManifestPath, + finalCommand, + ...finalArgs, + ]; + + const finalEnv = { ...sanitizedEnv }; + + return { + program, + args: finalHelperArgs, + env: finalEnv, + cwd: req.cwd, + cleanup: () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }, + }; + } + + protected override isCaseInsensitive(): boolean { + return true; } isDangerousCommand(args: string[]): boolean { - return isDangerousCommand(args); + return isWindowsDangerousCommand(args); + } + + protected override isOsSafeCommand(args: string[]): boolean { + return isWindowsSafeCommand(args); + } + + protected override async isStrictlyApproved( + command: string, + args: string[], + _req: SandboxRequest, + ): Promise { + return isWindowsStrictlyApproved( + command, + args, + this.options.modeConfig?.approvedTools, + ); } parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { return parseWindowsSandboxDenials(result, this.denialCache); } - getWorkspace(): string { - return this.options.workspace; - } + private isSystemDirectory(resolvedPath: string): boolean { + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; + const programFilesX86 = + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - getOptions(): GlobalSandboxOptions { - return this.options; - } - - private ensureGovernanceFilesExist(workspace: string): void { - if (this.governanceFilesInitialized) return; - - // These must exist on the host before running the sandbox to ensure they are protected. - for (const file of GOVERNANCE_FILES) { - const filePath = join(workspace, file.path); - touch(filePath, file.isDirectory); - } - - this.governanceFilesInitialized = true; + return ( + resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) + ); } private async ensureHelperCompiled(): Promise { @@ -200,285 +394,62 @@ export class WindowsSandboxManager implements SandboxManager { WindowsSandboxManager.helperCompiled = true; } - - /** - * Prepares a command for sandboxed execution on Windows. - */ - async prepareCommand(req: SandboxRequest): Promise { - await this.ensureHelperCompiled(); - - 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 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, - ); - - this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved); - - // 1. 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)), - ); - - // On Windows, we explicitly deny access to secret files for Low Integrity processes. - // We scan common search directories (workspace, allowed paths) for secrets. - const searchDirs = new Set([ - resolvedPaths.workspace.resolved, - ...resolvedPaths.policyAllowed, - ...resolvedPaths.globalIncludes, - ]); - - const secretFilesPromises = Array.from(searchDirs).map(async (dir) => { - try { - // We use maxDepth 3 to catch common nested secrets while keeping performance high. - const secretFiles = await findSecretFiles(dir, 3); - for (const secretFile of secretFiles) { - forbiddenManifest.add(resolveToRealPath(secretFile)); - } - } catch (e) { - debugLogger.log( - `WindowsSandboxManager: Failed to find secret files in ${dir}`, - e, - ); - } - }); - - await Promise.all(secretFilesPromises); - - // 2. 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(); - const inheritanceRoots = new Set(); - - const addWritableRoot = (p: string) => { - const resolved = resolveToRealPath(p); - - // Track both versions for inheritance checks to be robust against symlinks. - inheritanceRoots.add(p); - inheritanceRoots.add(resolved); - - // Never grant access to system directories or explicitly forbidden paths. - if (this.isSystemDirectory(resolved)) return; - if (forbiddenManifest.has(resolved)) return; - - // Explicitly reject UNC paths to prevent credential theft/SSRF, - // but allow local extended-length and device paths. - if ( - resolved.startsWith('\\\\') && - !resolved.startsWith('\\\\?\\') && - !resolved.startsWith('\\\\.\\') - ) { - debugLogger.log( - 'WindowsSandboxManager: Rejecting UNC path for allowed manifest:', - resolved, - ); - return; - } - 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; - - if (workspaceWrite) { - addWritableRoot(resolvedPaths.workspace.resolved); - } - - // B. Globally included directories - for (const includeDir of resolvedPaths.globalIncludes) { - addWritableRoot(includeDir); - } - - // C. Explicitly allowed paths from the request policy - for (const allowedPath of resolvedPaths.policyAllowed) { - try { - await fs.promises.access(allowedPath, fs.constants.F_OK); - } catch { - throw new Error( - `Sandbox request rejected: Allowed path does not exist: ${allowedPath}. ` + - 'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.', - ); - } - addWritableRoot(allowedPath); - } - - // D. 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); - addWritableRoot(writePath); - continue; - } catch { - // If the file doesn't exist, it's only allowed if it resides within a granted root. - const isInherited = Array.from(inheritanceRoots).some((root) => - isSubpath(root, writePath), - ); - - if (!isInherited) { - throw new Error( - `Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${writePath}. ` + - 'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.', - ); - } - } - } - - // Support git worktrees/submodules; read-only to prevent malicious hook/config modification (RCE). - // Read access is inherited; skip addWritableRoot to ensure write protection. - if (resolvedPaths.gitWorktree) { - // No-op for read access on Windows. - } - - // 5. Generate Manifests - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'gemini-cli-sandbox-'), - ); - - const forbiddenManifestPath = path.join(tempDir, 'forbidden.txt'); - await fs.promises.writeFile( - forbiddenManifestPath, - Array.from(forbiddenManifest).join('\n'), - ); - - const allowedManifestPath = path.join(tempDir, 'allowed.txt'); - await fs.promises.writeFile( - allowedManifestPath, - Array.from(allowedManifest).join('\n'), - ); - - // 6. Construct the helper command - const program = this.helperPath; - - const finalArgs = [ - networkAccess ? '1' : '0', - req.cwd, - '--forbidden-manifest', - forbiddenManifestPath, - '--allowed-manifest', - allowedManifestPath, - command, - ...args, - ]; - - const finalEnv = { ...sanitizedEnv }; - - return { - program, - args: finalArgs, - env: finalEnv, - cwd: req.cwd, - cleanup: () => { - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore errors - } - }, - }; - } - - private isSystemDirectory(resolvedPath: string): boolean { - const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; - const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; - const programFilesX86 = - process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - - return ( - resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || - resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || - resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) - ); - } } /** - * Ensures a file or directory exists. + * Finds all secret files in a directory up to a certain depth. + * Default is shallow scan (depth 1) for performance. */ -function touch(filePath: string, isDirectory: boolean): void { - assertValidPathString(filePath); - try { - // If it exists (even as a broken symlink), do nothing - fs.lstatSync(filePath); - return; - } catch (e: unknown) { - if (isErrnoException(e) && e.code !== 'ENOENT') { - throw e; +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 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 } } - 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')); - } + await walk(baseDir, 1); + return secrets; +} + +export function isSecretFile(fileName: string): boolean { + return SECRET_FILES.some((s) => { + if (s.pattern.includes('*')) { + const regex = new RegExp( + '^' + s.pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$', + ); + return regex.test(fileName); + } + return fileName === s.pattern; + }); } diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index 3481c53ca7..36263b18e1 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -18,8 +18,8 @@ import { getSecureSanitizationConfig } from './environmentSanitization.js'; import { type SandboxManager, type SandboxedCommand, - GOVERNANCE_FILES, } from './sandboxManager.js'; +import { GOVERNANCE_FILES } from '../sandbox/constants.js'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import os from 'node:os'; diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 81864416f4..ef74178afa 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -6,20 +6,12 @@ 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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { NoopSandboxManager } 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 = @@ -55,184 +47,9 @@ vi.mock('../utils/paths.js', async () => { }; }); -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(process, 'platform', 'get').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(); @@ -392,14 +209,5 @@ describe('SandboxManager', () => { 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..20cb1a8ef1 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -4,12 +4,10 @@ * 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, + isKnownSafeCommand as isPosixSafeCommand, + isDangerousCommand as isPosixDangerousCommand, } from '../sandbox/utils/commandSafety.js'; import { isKnownSafeCommand as isWindowsSafeCommand, @@ -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. @@ -310,13 +179,13 @@ export class NoopSandboxManager implements SandboxManager { isKnownSafeCommand(args: string[]): boolean { return os.platform() === 'win32' ? isWindowsSafeCommand(args) - : isMacSafeCommand(args); + : isPosixSafeCommand(args); } isDangerousCommand(args: string[]): boolean { return os.platform() === 'win32' ? isWindowsDangerousCommand(args) - : isMacDangerousCommand(args); + : isPosixDangerousCommand(args); } parseDenials(): undefined { @@ -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';