mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 09:40:58 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc06ac3202 | |||
| d6492830b6 | |||
| bcf599b3b2 |
@@ -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<SandboxedCommand> {
|
||||
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<boolean> {
|
||||
return false;
|
||||
}
|
||||
protected override ensureGovernanceFilesExist(_workspace: string): void {}
|
||||
protected override async initialize(): Promise<void> {}
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -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<SandboxedCommand> {
|
||||
// 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<void>;
|
||||
|
||||
/**
|
||||
* 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<SandboxedCommand>;
|
||||
|
||||
/**
|
||||
* 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<boolean>;
|
||||
|
||||
/**
|
||||
* Parses denials from execution output to populate the cache.
|
||||
*/
|
||||
abstract parseDenials(
|
||||
result: ShellExecutionResult,
|
||||
): ParsedSandboxDenial | undefined;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,189 +5,95 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
function getSeccompBpfPath(): string {
|
||||
if (cachedBpfPath) return cachedBpfPath;
|
||||
|
||||
const arch = os.arch();
|
||||
let AUDIT_ARCH: number;
|
||||
let SYS_ptrace: number;
|
||||
|
||||
if (arch === 'x64') {
|
||||
AUDIT_ARCH = 0xc000003e; // AUDIT_ARCH_X86_64
|
||||
SYS_ptrace = 101;
|
||||
} else if (arch === 'arm64') {
|
||||
AUDIT_ARCH = 0xc00000b7; // AUDIT_ARCH_AARCH64
|
||||
SYS_ptrace = 117;
|
||||
} else if (arch === 'arm') {
|
||||
AUDIT_ARCH = 0x40000028; // AUDIT_ARCH_ARM
|
||||
SYS_ptrace = 26;
|
||||
} else if (arch === 'ia32') {
|
||||
AUDIT_ARCH = 0x40000003; // AUDIT_ARCH_I386
|
||||
SYS_ptrace = 26;
|
||||
} else {
|
||||
throw new Error(`Unsupported architecture for seccomp filter: ${arch}`);
|
||||
}
|
||||
|
||||
const EPERM = 1;
|
||||
const SECCOMP_RET_KILL_PROCESS = 0x80000000;
|
||||
const SECCOMP_RET_ERRNO = 0x00050000;
|
||||
const SECCOMP_RET_ALLOW = 0x7fff0000;
|
||||
|
||||
const instructions = [
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 4 }, // Load arch
|
||||
{ code: 0x15, jt: 1, jf: 0, k: AUDIT_ARCH }, // Jump to kill if arch != native arch
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_KILL_PROCESS }, // Kill
|
||||
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 0 }, // Load nr
|
||||
{ code: 0x15, jt: 0, jf: 1, k: SYS_ptrace }, // If ptrace, jump to ERRNO
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ERRNO | EPERM }, // ERRNO
|
||||
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW }, // Allow
|
||||
];
|
||||
|
||||
const buf = Buffer.alloc(8 * instructions.length);
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const inst = instructions[i];
|
||||
const offset = i * 8;
|
||||
buf.writeUInt16LE(inst.code, offset);
|
||||
buf.writeUInt8(inst.jt, offset + 2);
|
||||
buf.writeUInt8(inst.jf, offset + 3);
|
||||
buf.writeUInt32LE(inst.k, offset + 4);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
||||
const bpfPath = join(tempDir, 'seccomp.bpf');
|
||||
fs.writeFileSync(bpfPath, buf);
|
||||
cachedBpfPath = bpfPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
import {
|
||||
AbstractOsSandboxManager,
|
||||
type PreparedExecutionDetails,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
import { isStrictlyApproved } from '../utils/commandUtils.js';
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
*/
|
||||
|
||||
export class LinuxSandboxManager implements SandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
private governanceFilesInitialized = false;
|
||||
export class LinuxSandboxManager extends AbstractOsSandboxManager {
|
||||
private cachedBpfPath: string | undefined;
|
||||
private maskFilePath: string | undefined;
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
protected override async initialize(): Promise<void> {
|
||||
// Default no-op
|
||||
}
|
||||
|
||||
private ensureGovernanceFilesExist(workspace: string): void {
|
||||
if (this.governanceFilesInitialized) return;
|
||||
private getSeccompBpfPath(): string {
|
||||
if (this.cachedBpfPath) return this.cachedBpfPath;
|
||||
|
||||
// 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);
|
||||
const arch = os.arch();
|
||||
let AUDIT_ARCH: number;
|
||||
let SYS_ptrace: number;
|
||||
|
||||
if (arch === 'x64') {
|
||||
AUDIT_ARCH = 0xc000003e; // AUDIT_ARCH_X86_64
|
||||
SYS_ptrace = 101;
|
||||
} else if (arch === 'arm64') {
|
||||
AUDIT_ARCH = 0xc00000b7; // AUDIT_ARCH_AARCH64
|
||||
SYS_ptrace = 117;
|
||||
} else if (arch === 'arm') {
|
||||
AUDIT_ARCH = 0x40000028; // AUDIT_ARCH_ARM
|
||||
SYS_ptrace = 26;
|
||||
} else if (arch === 'ia32') {
|
||||
AUDIT_ARCH = 0x40000003; // AUDIT_ARCH_I386
|
||||
SYS_ptrace = 26;
|
||||
} else {
|
||||
throw new Error(`Unsupported architecture for seccomp filter: ${arch}`);
|
||||
}
|
||||
|
||||
this.governanceFilesInitialized = true;
|
||||
}
|
||||
const EPERM = 1;
|
||||
const SECCOMP_RET_KILL_PROCESS = 0x80000000;
|
||||
const SECCOMP_RET_ERRNO = 0x00050000;
|
||||
const SECCOMP_RET_ALLOW = 0x7fff0000;
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
const instructions = [
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 4 }, // Load arch
|
||||
{ code: 0x15, jt: 1, jf: 0, k: AUDIT_ARCH }, // Jump to kill if arch != native arch
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_KILL_PROCESS }, // Kill
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 0 }, // Load nr
|
||||
{ code: 0x15, jt: 0, jf: 1, k: SYS_ptrace }, // If ptrace, jump to ERRNO
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ERRNO | EPERM }, // ERRNO
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW }, // Allow
|
||||
];
|
||||
|
||||
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 buf = Buffer.alloc(8 * instructions.length);
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const inst = instructions[i];
|
||||
const offset = i * 8;
|
||||
buf.writeUInt16LE(inst.code, offset);
|
||||
buf.writeUInt8(inst.jt, offset + 2);
|
||||
buf.writeUInt8(inst.jf, offset + 3);
|
||||
buf.writeUInt32LE(inst.k, offset + 4);
|
||||
}
|
||||
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;
|
||||
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
||||
const bpfPath = join(tempDir, 'seccomp.bpf');
|
||||
fs.writeFileSync(bpfPath, buf);
|
||||
this.cachedBpfPath = bpfPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
@@ -198,94 +104,81 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
}
|
||||
});
|
||||
|
||||
return maskPath;
|
||||
return bpfPath;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
protected override ensureGovernanceFilesExist(workspace: string): void {
|
||||
if (this.governanceFilesInitialized) return;
|
||||
ensureGovernanceFilesExist(workspace);
|
||||
this.governanceFilesInitialized = 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<SandboxedCommand> {
|
||||
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',
|
||||
});
|
||||
|
||||
const bpfPath = getSeccompBpfPath();
|
||||
const bpfPath = this.getSeccompBpfPath();
|
||||
bwrapArgs.push('--seccomp', '9');
|
||||
|
||||
const argsPath = this.writeArgsToTempFile(bwrapArgs);
|
||||
@@ -316,6 +209,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<boolean> {
|
||||
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 +245,26 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
fs.writeFileSync(tempFile, content, { mode: 0o600 });
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (this.maskFilePath && fs.existsSync(this.maskFilePath)) {
|
||||
return this.maskFilePath;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-'));
|
||||
const maskPath = join(tempDir, 'mask');
|
||||
fs.writeFileSync(maskPath, '');
|
||||
fs.chmodSync(maskPath, 0);
|
||||
this.maskFilePath = maskPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
return maskPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof import('node:fs')>('node:fs');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<SandboxedCommand> {
|
||||
export class MacOsSandboxManager extends AbstractOsSandboxManager {
|
||||
protected override async initialize(): Promise<void> {
|
||||
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<SandboxedCommand> {
|
||||
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<boolean> {
|
||||
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(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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<ResolvedSandboxPaths> {
|
||||
/**
|
||||
* Helper that expands each path to include its realpath (if it's a symlink)
|
||||
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
|
||||
*/
|
||||
const expand = (paths?: string[] | null): string[] => {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
const expanded = paths.flatMap((p) => {
|
||||
try {
|
||||
const resolved = resolveToRealPath(p);
|
||||
return resolved === p ? [p] : [p, resolved];
|
||||
} catch {
|
||||
return [p];
|
||||
}
|
||||
});
|
||||
return deduplicateAbsolutePaths(expanded);
|
||||
};
|
||||
|
||||
const forbidden = expand(await options.forbiddenPaths?.());
|
||||
|
||||
const globalIncludes = expand(options.includeDirectories);
|
||||
const policyAllowed = expand(req.policy?.allowedPaths);
|
||||
|
||||
const policyRead = expand(overridePermissions?.fileSystem?.read);
|
||||
const policyWrite = expand(overridePermissions?.fileSystem?.write);
|
||||
|
||||
const resolvedWorkspace = resolveToRealPath(options.workspace);
|
||||
|
||||
const workspaceIdentities = new Set(
|
||||
[options.workspace, resolvedWorkspace].map(toPathKey),
|
||||
);
|
||||
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
|
||||
|
||||
const { worktreeGitDir, mainGitDir } =
|
||||
await resolveGitWorktreePaths(resolvedWorkspace);
|
||||
const gitWorktree = worktreeGitDir
|
||||
? { gitWorktree: { worktreeGitDir, mainGitDir } }
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
|
||||
*/
|
||||
const filter = (paths: string[]) =>
|
||||
paths.filter((p) => {
|
||||
const identity = toPathKey(p);
|
||||
return (
|
||||
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
original: options.workspace,
|
||||
resolved: resolvedWorkspace,
|
||||
},
|
||||
forbidden,
|
||||
globalIncludes: filter(globalIncludes),
|
||||
policyAllowed: filter(policyAllowed),
|
||||
policyRead: filter(policyRead),
|
||||
policyWrite: filter(policyWrite),
|
||||
...gitWorktree,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import('./commandSafety.js')>();
|
||||
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<typeof import('../utils/commandUtils.js')>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<SandboxedCommand> {
|
||||
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<string>();
|
||||
const inheritanceRoots = new Set<string>();
|
||||
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
@@ -200,285 +394,68 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
|
||||
WindowsSandboxManager.helperCompiled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a command for sandboxed execution on Windows.
|
||||
*/
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
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<string>();
|
||||
const inheritanceRoots = new Set<string>();
|
||||
|
||||
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<string[]> {
|
||||
const secrets: string[] = [];
|
||||
const skipDirs = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
]);
|
||||
|
||||
async function walk(dir: string, depth: number) {
|
||||
if (depth > maxDepth) return;
|
||||
try {
|
||||
const entries = await 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
|
||||
// Escape all regex special chars
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
// Convert the escaped asterisk back to a regex wildcard
|
||||
.replace(/\\\*/g, '.*') +
|
||||
'$',
|
||||
);
|
||||
return regex.test(fileName);
|
||||
}
|
||||
return fileName === s.pattern;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string[]> {
|
||||
const secrets: string[] = [];
|
||||
const skipDirs = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
]);
|
||||
|
||||
async function walk(dir: string, depth: number) {
|
||||
if (depth > maxDepth) return;
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!skipDirs.has(entry.name)) {
|
||||
await walk(fullPath, depth + 1);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
if (isSecretFile(entry.name)) {
|
||||
secrets.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
await walk(baseDir, 1);
|
||||
return secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op implementation of SandboxManager that silently passes commands
|
||||
* through while applying environment sanitization.
|
||||
@@ -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<ResolvedSandboxPaths> {
|
||||
/**
|
||||
* Helper that expands each path to include its realpath (if it's a symlink)
|
||||
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
|
||||
*/
|
||||
const expand = (paths?: string[] | null): string[] => {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
const expanded = paths.flatMap((p) => {
|
||||
try {
|
||||
const resolved = resolveToRealPath(p);
|
||||
return resolved === p ? [p] : [p, resolved];
|
||||
} catch {
|
||||
return [p];
|
||||
}
|
||||
});
|
||||
return deduplicateAbsolutePaths(expanded);
|
||||
};
|
||||
|
||||
const forbidden = expand(await options.forbiddenPaths?.());
|
||||
|
||||
const globalIncludes = expand(options.includeDirectories);
|
||||
const policyAllowed = expand(req.policy?.allowedPaths);
|
||||
|
||||
const policyRead = expand(overridePermissions?.fileSystem?.read);
|
||||
const policyWrite = expand(overridePermissions?.fileSystem?.write);
|
||||
|
||||
const resolvedWorkspace = resolveToRealPath(options.workspace);
|
||||
|
||||
const workspaceIdentities = new Set(
|
||||
[options.workspace, resolvedWorkspace].map(toPathKey),
|
||||
);
|
||||
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
|
||||
|
||||
const { worktreeGitDir, mainGitDir } =
|
||||
await resolveGitWorktreePaths(resolvedWorkspace);
|
||||
const gitWorktree = worktreeGitDir
|
||||
? { gitWorktree: { worktreeGitDir, mainGitDir } }
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
|
||||
*/
|
||||
const filter = (paths: string[]) =>
|
||||
paths.filter((p) => {
|
||||
const identity = toPathKey(p);
|
||||
return (
|
||||
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
original: options.workspace,
|
||||
resolved: resolvedWorkspace,
|
||||
},
|
||||
forbidden,
|
||||
globalIncludes: filter(globalIncludes),
|
||||
policyAllowed: filter(policyAllowed),
|
||||
policyRead: filter(policyRead),
|
||||
policyWrite: filter(policyWrite),
|
||||
...gitWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
export { createSandboxManager } from './sandboxManagerFactory.js';
|
||||
|
||||
Reference in New Issue
Block a user