Compare commits

...

3 Commits

Author SHA1 Message Date
ehedlund cc06ac3202 address Gemini feedback 2026-04-15 17:23:13 -04:00
ehedlund d6492830b6 address GitHub security feedback 2026-04-15 17:18:17 -04:00
ehedlund bcf599b3b2 refactor(core): abstract OsSandboxManager and centralize common logic 2026-04-15 16:01:24 -04:00
21 changed files with 1444 additions and 1226 deletions
@@ -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;
}
+24
View File
@@ -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 -208
View File
@@ -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';