Download packages/core/src/services/sandboxManager.integration.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 41.3 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/services/sandboxManager.integration.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/services/sandboxManager.integration.test.ts
-
curl -L -o sandboxManager.integration.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/services/sandboxManager.integration.test.ts
41.3 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| describe, | |
| it, | |
| expect, | |
| beforeAll, | |
| beforeEach, | |
| afterEach, | |
| afterAll, | |
| } from 'vitest'; | |
| import { createSandboxManager } from './sandboxManagerFactory.js'; | |
| import { ShellExecutionService } from './shellExecutionService.js'; | |
| import { getSecureSanitizationConfig } from './environmentSanitization.js'; | |
| import { | |
| type SandboxManager, | |
| type SandboxedCommand, | |
| GOVERNANCE_FILES, | |
| } from './sandboxManager.js'; | |
| import { execFile } from 'node:child_process'; | |
| import { promisify } from 'node:util'; | |
| import os from 'node:os'; | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import http from 'node:http'; | |
| /** | |
| * Cross-platform command wrappers using Node.js inline scripts. | |
| * Ensures consistent execution behavior and reliable exit codes across | |
| * different host operating systems and restricted sandbox environments. | |
| */ | |
| const Platform = { | |
| isWindows: os.platform() === 'win32', | |
| isMac: os.platform() === 'darwin', | |
| /** Returns a command to create an empty file. */ | |
| touch(filePath: string) { | |
| return { | |
| command: process.execPath, | |
| args: [ | |
| '-e', | |
| `require("node:fs").writeFileSync(${JSON.stringify(filePath)}, "")`, | |
| ], | |
| }; | |
| }, | |
| /** Returns a command to read a file's content. */ | |
| cat(filePath: string) { | |
| return { | |
| command: process.execPath, | |
| args: [ | |
| '-e', | |
| `console.log(require("node:fs").readFileSync(${JSON.stringify(filePath)}, "utf8"))`, | |
| ], | |
| }; | |
| }, | |
| /** Returns a command to echo a string. */ | |
| echo(text: string) { | |
| return { | |
| command: process.execPath, | |
| args: ['-e', `console.log(${JSON.stringify(text)})`], | |
| }; | |
| }, | |
| /** Returns a command to perform a network request. */ | |
| curl(url: string) { | |
| return { | |
| command: process.execPath, | |
| args: [ | |
| '-e', | |
| `require("node:http").get(${JSON.stringify(url)}, (res) => { res.on("data", (d) => process.stdout.write(d)); res.on("end", () => process.exit(0)); }).on("error", () => process.exit(1));`, | |
| ], | |
| }; | |
| }, | |
| /** Returns a command that checks if the current terminal is interactive. */ | |
| isPty() { | |
| // ShellExecutionService.execute expects a raw shell string | |
| return `"${process.execPath}" -e "console.log(process.stdout.isTTY ? 'True' : 'False')"`; | |
| }, | |
| /** Returns a path that is strictly outside the workspace and likely blocked. */ | |
| getExternalBlockedPath() { | |
| return this.isWindows | |
| ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' | |
| : '/Users/Shared/.gemini_test_blocked'; | |
| }, | |
| }; | |
| async function runCommand(command: SandboxedCommand) { | |
| try { | |
| const { stdout, stderr } = await promisify(execFile)( | |
| command.program, | |
| command.args, | |
| { | |
| cwd: command.cwd, | |
| env: command.env, | |
| encoding: 'utf-8', | |
| }, | |
| ); | |
| return { status: 0, stdout, stderr }; | |
| } catch (error: unknown) { | |
| const err = error as { code?: number; stdout?: string; stderr?: string }; | |
| return { | |
| status: err.code ?? 1, | |
| stdout: err.stdout ?? '', | |
| stderr: err.stderr ?? '', | |
| }; | |
| } | |
| } | |
| /** | |
| * Asserts the result of a sandboxed command execution, and provides detailed | |
| * diagnostics on failure. | |
| */ | |
| function assertResult( | |
| result: { status: number; stdout: string; stderr: string }, | |
| command: SandboxedCommand, | |
| expected: 'success' | 'failure', | |
| ) { | |
| const isSuccess = result.status === 0; | |
| const shouldBeSuccess = expected === 'success'; | |
| if (isSuccess === shouldBeSuccess) { | |
| if (shouldBeSuccess) { | |
| expect(result.status).toBe(0); | |
| } else { | |
| expect(result.status).not.toBe(0); | |
| } | |
| return; | |
| } | |
| const commandLine = `${command.program} ${command.args.join(' ')}`; | |
| const message = `Command ${ | |
| shouldBeSuccess ? 'failed' : 'succeeded' | |
| } unexpectedly. | |
| Command: ${commandLine} | |
| CWD: ${command.cwd || 'N/A'} | |
| Status: ${result.status} (expected ${expected})${ | |
| result.stdout ? `\nStdout: ${result.stdout.trim()}` : '' | |
| }${result.stderr ? `\nStderr: ${result.stderr.trim()}` : ''}`; | |
| throw new Error(message); | |
| } | |
| describe('SandboxManager Integration', () => { | |
| let tempDirectories: string[] = []; | |
| /** | |
| * Creates a temporary directory and tracks it for automatic cleanup after each test. | |
| * - macOS: Created in process.cwd() to avoid the seatbelt profile's global os.tmpdir() whitelist. | |
| * - Win/Linux: Created in os.tmpdir() because enforcing sandbox restrictions inside a large directory can be very slow. | |
| */ | |
| function createTempDir(prefix = 'gemini-sandbox-test-'): string { | |
| const baseDir = Platform.isMac | |
| ? path.join(process.cwd(), `.${prefix}`) | |
| : path.join(os.tmpdir(), prefix); | |
| const dir = fs.mkdtempSync(baseDir); | |
| tempDirectories.push(dir); | |
| return dir; | |
| } | |
| let workspace: string; | |
| let manager: SandboxManager; | |
| beforeEach(() => { | |
| tempDirectories = []; | |
| // Create a fresh, isolated workspace for every test to prevent state | |
| // leakage from causing intermittent or order-dependent test failures. | |
| workspace = createTempDir('workspace-'); | |
| manager = createSandboxManager({ enabled: true }, { workspace }); | |
| }); | |
| afterEach(() => { | |
| for (const dir of tempDirectories) { | |
| try { | |
| fs.rmSync(dir, { recursive: true, force: true }); | |
| } catch { | |
| // Best-effort cleanup | |
| } | |
| } | |
| tempDirectories = []; | |
| }); | |
| 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'); | |
| }); | |
| // 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'); | |
| }, | |
| ); | |
| }); | |
| 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: [plansDir] }, | |
| }); | |
| 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'); | |
| const taskDir = path.dirname(taskFile); | |
| fs.mkdirSync(taskDir, { 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: [taskDir] }, | |
| }); | |
| 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 }, | |
| { | |
| workspace, | |
| modeConfig: { | |
| readonly: true, | |
| allowOverrides: true, | |
| approvedTools: [approvedTool], | |
| }, | |
| }, | |
| ); | |
| 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 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 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); | |
| // 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('allows access to authorized 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] }, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'success'); | |
| expect(fs.existsSync(testFile)).toBe(true); | |
| }); | |
| 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); | |
| 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, '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)( | |
| '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'); | |
| 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'); | |
| }, | |
| ); | |
| 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'); | |
| // 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], | |
| }, | |
| ); | |
| // 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'); | |
| }); | |
| }); | |
| describe('Governance Files', () => { | |
| it('prevents modification of governance files', async () => { | |
| // Ensure workspace is initialized and governance files are created | |
| const { command: echoCmd, args: echoArgs } = Platform.echo('test'); | |
| await manager.prepareCommand({ | |
| command: echoCmd, | |
| args: echoArgs, | |
| cwd: workspace, | |
| env: process.env, | |
| // Even if the entire workspace is explicitly allowed, governance files must be protected | |
| policy: { allowedPaths: [workspace] }, | |
| }); | |
| for (const file of GOVERNANCE_FILES) { | |
| const filePath = path.join(workspace, file.path); | |
| // Try to append to/overwrite the file or create a file inside the directory | |
| const { command, args } = file.isDirectory | |
| ? Platform.touch(path.join(filePath, 'evil.txt')) | |
| : Platform.touch(filePath); | |
| const sandboxed = await manager.prepareCommand({ | |
| command, | |
| args, | |
| cwd: workspace, | |
| env: process.env, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'failure'); | |
| } | |
| }); | |
| }); | |
| 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'); | |
| }); | |
| 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('Governance Files', () => { | |
| it('blocks write access to governance files in the workspace', async () => { | |
| const tempWorkspace = createTempDir('workspace-'); | |
| const gitDir = path.join(tempWorkspace, '.git'); | |
| fs.mkdirSync(gitDir); | |
| const testFile = path.join(gitDir, 'config'); | |
| const osManager = createSandboxManager( | |
| { enabled: true }, | |
| { workspace: tempWorkspace }, | |
| ); | |
| 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, 'failure'); | |
| expect(fs.existsSync(testFile)).toBe(false); | |
| }); | |
| it('allows write access to governance files when explicitly requested via additionalPermissions', async () => { | |
| const tempWorkspace = createTempDir('workspace-'); | |
| const gitDir = path.join(tempWorkspace, '.git'); | |
| fs.mkdirSync(gitDir); | |
| const testFile = path.join(gitDir, 'config'); | |
| const osManager = createSandboxManager( | |
| { enabled: true }, | |
| { workspace: tempWorkspace }, | |
| ); | |
| const { command, args } = Platform.touch(testFile); | |
| const sandboxed = await osManager.prepareCommand({ | |
| command, | |
| args, | |
| cwd: tempWorkspace, | |
| env: process.env, | |
| policy: { | |
| additionalPermissions: { fileSystem: { write: [gitDir] } }, | |
| }, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'success'); | |
| expect(fs.existsSync(testFile)).toBe(true); | |
| }); | |
| }); | |
| 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); | |
| }); | |
| it('blocks write access to git common directory in a worktree when not explicitly requested via additionalPermissions', 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 }, | |
| { workspace: worktreeDir }, | |
| ); | |
| 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); | |
| }); | |
| it('allows write access to git common directory in a worktree when explicitly requested via additionalPermissions', 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 }, | |
| { workspace: worktreeDir }, | |
| ); | |
| const { command, args } = Platform.touch(targetFile); | |
| const sandboxed = await osManager.prepareCommand({ | |
| command, | |
| args, | |
| cwd: worktreeDir, | |
| env: process.env, | |
| policy: { | |
| additionalPermissions: { fileSystem: { write: [worktreeGitDir] } }, | |
| }, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'success'); | |
| expect(fs.existsSync(targetFile)).toBe(true); | |
| }); | |
| it('allows write access to external git directory in a non-worktree environment when explicitly requested via additionalPermissions', async () => { | |
| const externalGitDir = createTempDir('external-git-'); | |
| const workspaceDir = createTempDir('workspace-'); | |
| fs.mkdirSync(externalGitDir, { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(workspaceDir, '.git'), | |
| `gitdir: ${externalGitDir}\n`, | |
| ); | |
| const targetFile = path.join(externalGitDir, 'secret.txt'); | |
| const osManager = createSandboxManager( | |
| { enabled: true }, | |
| { workspace: workspaceDir }, | |
| ); | |
| const { command, args } = Platform.touch(targetFile); | |
| const sandboxed = await osManager.prepareCommand({ | |
| command, | |
| args, | |
| cwd: workspaceDir, | |
| env: process.env, | |
| policy: { | |
| additionalPermissions: { fileSystem: { write: [externalGitDir] } }, | |
| }, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'success'); | |
| expect(fs.existsSync(targetFile)).toBe(true); | |
| }); | |
| }); | |
| describe('Git and Governance Write Access', () => { | |
| it('allows write access to .gitignore when workspace is writable', async () => { | |
| const testFile = path.join(workspace, '.gitignore'); | |
| fs.writeFileSync(testFile, 'initial'); | |
| const editManager = createSandboxManager( | |
| { enabled: true }, | |
| { workspace, modeConfig: { readonly: false, allowOverrides: true } }, | |
| ); | |
| const { command, args } = Platform.touch(testFile); | |
| const sandboxed = await editManager.prepareCommand({ | |
| command, | |
| args, | |
| cwd: workspace, | |
| env: process.env, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'success'); | |
| expect(fs.existsSync(testFile)).toBe(true); | |
| }); | |
| it('automatically allows write access to .git when running git command and workspace is writable', async () => { | |
| const gitDir = path.join(workspace, '.git'); | |
| if (!fs.existsSync(gitDir)) fs.mkdirSync(gitDir); | |
| const lockFile = path.join(gitDir, 'index.lock'); | |
| const editManager = createSandboxManager( | |
| { enabled: true }, | |
| { workspace, modeConfig: { readonly: false, allowOverrides: true } }, | |
| ); | |
| // We use a command that looks like git to trigger the special handling. | |
| // LinuxSandboxManager identifies the command root from the shell wrapper. | |
| const { command: nodePath, args: nodeArgs } = Platform.touch(lockFile); | |
| const commandString = Platform.isWindows | |
| ? `git --version > NUL && "${nodePath.replace(/\\/g, '/')}" ${nodeArgs | |
| .map((a) => `'${a.replace(/\\/g, '/')}'`) | |
| .join(' ')}` | |
| : `git --version > /dev/null; "${nodePath}" ${nodeArgs | |
| .map((a) => (a.includes(' ') || a.includes('(') ? `'${a}'` : a)) | |
| .join(' ')}`; | |
| const sandboxed = await editManager.prepareCommand({ | |
| command: 'sh', | |
| args: ['-c', commandString], | |
| cwd: workspace, | |
| env: process.env, | |
| }); | |
| const result = await runCommand(sandboxed); | |
| assertResult(result, sandboxed, 'success'); | |
| expect(fs.existsSync(lockFile)).toBe(true); | |
| }); | |
| }); | |
| 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<void>((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<void>((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'); | |
| } | |
| }); | |
| }); | |
| }); | |
| }); | |