mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-14 05:17:18 -07:00
refactor(core): abstract OS sandbox managers and fix virtual command permissions
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AbstractOsSandboxManager,
|
||||
resolveSandboxPaths,
|
||||
} from './abstractOsSandboxManager.js';
|
||||
import type {
|
||||
GlobalSandboxOptions,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
SandboxRequest,
|
||||
} from '../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from './abstractOsSandboxManager.js';
|
||||
|
||||
class TestSandboxManager extends AbstractOsSandboxManager {
|
||||
override isKnownSafeCommand = vi.fn().mockReturnValue(false);
|
||||
override isDangerousCommand = vi.fn().mockReturnValue(false);
|
||||
override parseDenials = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override resolveFinalCommand = vi
|
||||
.fn()
|
||||
.mockImplementation((req) => ({
|
||||
command: req.command,
|
||||
args: req.args,
|
||||
}));
|
||||
protected override buildSandboxedExecution = vi.fn().mockResolvedValue({
|
||||
program: 'test-program',
|
||||
args: [],
|
||||
env: {},
|
||||
} as SandboxedCommand);
|
||||
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
describe('AbstractOsSandboxManager', () => {
|
||||
let mockWorkspace: string;
|
||||
let manager: TestSandboxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWorkspace = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-base-sandbox-test-')),
|
||||
);
|
||||
manager = new TestSandboxManager({ workspace: mockWorkspace });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(mockWorkspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('isToolApproved', () => {
|
||||
it('should return false if no approved tools are configured', () => {
|
||||
const emptyManager = new TestSandboxManager({ workspace: mockWorkspace });
|
||||
expect(emptyManager['isToolApproved']('ls')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the tool matches exactly', () => {
|
||||
const managerWithTools = new TestSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { approvedTools: ['git'] },
|
||||
});
|
||||
expect(managerWithTools['isToolApproved']('git')).toBe(true);
|
||||
expect(managerWithTools['isToolApproved']('ls')).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect case-insensitivity when requested', () => {
|
||||
const managerWithTools = new TestSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { approvedTools: ['GIT'] },
|
||||
});
|
||||
expect(managerWithTools['isToolApproved']('git', true)).toBe(true);
|
||||
expect(managerWithTools['isToolApproved']('git', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touch', () => {
|
||||
it('should create a directory if isDirectory is true', () => {
|
||||
const targetDir = path.join(mockWorkspace, 'newDir');
|
||||
manager['touch'](targetDir, true);
|
||||
expect(fs.existsSync(targetDir)).toBe(true);
|
||||
expect(fs.lstatSync(targetDir).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('should create an empty file and its parent directories if isDirectory is false', () => {
|
||||
const targetFile = path.join(mockWorkspace, 'newDir', 'newFile.txt');
|
||||
manager['touch'](targetFile, false);
|
||||
expect(fs.existsSync(targetFile)).toBe(true);
|
||||
expect(fs.lstatSync(targetFile).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('should do nothing if the path already exists', () => {
|
||||
const targetDir = path.join(mockWorkspace, 'existingDir');
|
||||
fs.mkdirSync(targetDir);
|
||||
expect(() => manager['touch'](targetDir, true)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw if the path exists as a broken symlink', () => {
|
||||
const targetLink = path.join(mockWorkspace, 'brokenLink');
|
||||
const nonExistentTarget = path.join(mockWorkspace, 'nonExistent');
|
||||
fs.symlinkSync(nonExistentTarget, targetLink);
|
||||
expect(() => manager['touch'](targetLink, false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareCommand', () => {
|
||||
it('applies environment sanitization', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
API_KEY: 'sensitive',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
blockedEnvironmentVariables: ['API_KEY'],
|
||||
allowedEnvironmentVariables: ['SAFE_VAR'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const call = manager['buildSandboxedExecution'].mock.calls[0];
|
||||
const sanitizedEnv = call[2]; // 3rd arg is sanitizedEnv
|
||||
expect(sanitizedEnv['SAFE_VAR']).toBe('1');
|
||||
expect(sanitizedEnv['API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects overrides in plan mode', async () => {
|
||||
const planManager = new TestSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { readonly: true, allowOverrides: false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
planManager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: { additionalPermissions: { network: true } },
|
||||
}),
|
||||
).rejects.toThrow(/Cannot override/);
|
||||
});
|
||||
|
||||
it('ensures governance files exist before execution', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(mockWorkspace, '.gitignore'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(mockWorkspace, '.geminiignore'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(mockWorkspace, '.git'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(mockWorkspace, '.git')).isDirectory()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves path and permissions, passing them to buildSandboxedExecution', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: ['/tmp/allowed'],
|
||||
networkAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager['buildSandboxedExecution']).toHaveBeenCalled();
|
||||
const callArgs = manager['buildSandboxedExecution'].mock.calls[0];
|
||||
const mergedPermissions = callArgs[3] as SandboxPermissions;
|
||||
const resolvedPaths = callArgs[4] as ResolvedSandboxPaths;
|
||||
|
||||
expect(mergedPermissions.network).toBe(true);
|
||||
// It expands allowedPaths into its original and resolved forms (which defaults to original on missing files unless symlinked)
|
||||
expect(resolvedPaths.policyAllowed).toContain('/tmp/allowed');
|
||||
});
|
||||
|
||||
it('resolves workspaceWrite to true when in yolo mode', async () => {
|
||||
const yoloManager = new TestSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { readonly: true, allowOverrides: true, yolo: true },
|
||||
});
|
||||
|
||||
await yoloManager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const callArgs = yoloManager['buildSandboxedExecution'].mock.calls[0];
|
||||
const workspaceWrite = callArgs[5] as boolean;
|
||||
expect(workspaceWrite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSandboxPaths', () => {
|
||||
it('should resolve allowed and forbidden paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const forbidden = path.join(workspace, 'forbidden');
|
||||
const allowed = path.join(workspace, 'allowed');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [forbidden],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [allowed],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([allowed]);
|
||||
expect(result.forbidden).toEqual([forbidden]);
|
||||
});
|
||||
|
||||
it('should filter out workspace from allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const other = path.resolve('/other/path');
|
||||
const options = {
|
||||
workspace,
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [workspace, workspace + path.sep, other],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([other]);
|
||||
});
|
||||
|
||||
it('should prioritize forbidden paths over allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secret = path.join(workspace, 'secret');
|
||||
const normal = path.join(workspace, 'normal');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secret],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secret, normal],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([normal]);
|
||||
expect(result.forbidden).toEqual([secret]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive conflicts on supported platforms', async () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secretUpper = path.join(workspace, 'SECRET');
|
||||
const secretLower = path.join(workspace, 'secret');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secretUpper],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secretLower],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([]);
|
||||
expect(result.forbidden).toEqual([secretUpper]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type {
|
||||
SandboxManager,
|
||||
GlobalSandboxOptions,
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
} from '../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../services/environmentSanitization.js';
|
||||
import { verifySandboxOverrides } from './utils/commandUtils.js';
|
||||
import {
|
||||
assertValidPathString,
|
||||
deduplicateAbsolutePaths,
|
||||
toPathKey,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import {
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from './utils/sandboxDenialUtils.js';
|
||||
import { getCommandName } from '../utils/shell-utils.js';
|
||||
import { resolveGitWorktreePaths } from './utils/fsUtils.js';
|
||||
import { validateVirtualCommandPaths } from './utils/sandboxReadWriteUtils.js';
|
||||
|
||||
/**
|
||||
* A structured result of fully resolved sandbox paths.
|
||||
* All paths in this object are absolute, deduplicated, and expanded to include
|
||||
* both the original path and its real target (if it is a symlink).
|
||||
*/
|
||||
export interface ResolvedSandboxPaths {
|
||||
/** The primary workspace directory. */
|
||||
workspace: {
|
||||
/** The original path provided in the sandbox options. */
|
||||
original: string;
|
||||
/** The real path. */
|
||||
resolved: string;
|
||||
};
|
||||
/** Explicitly denied paths. */
|
||||
forbidden: string[];
|
||||
/** Directories included globally across all commands in this sandbox session. */
|
||||
globalIncludes: string[];
|
||||
/** Paths explicitly allowed by the policy of the currently executing command. */
|
||||
policyAllowed: string[];
|
||||
/** Paths granted temporary read access by the current command's dynamic permissions. */
|
||||
policyRead: string[];
|
||||
/** Paths granted temporary write access by the current command's dynamic permissions. */
|
||||
policyWrite: string[];
|
||||
/** Auto-detected paths for git worktrees/submodules. */
|
||||
gitWorktree?: {
|
||||
/** The actual .git directory for this worktree. */
|
||||
worktreeGitDir: string;
|
||||
/** The main repository's .git directory (if applicable). */
|
||||
mainGitDir?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Files that represent the governance or "constitution" of the repository
|
||||
* and should be write-protected in any sandbox.
|
||||
*/
|
||||
export const GOVERNANCE_FILES = [
|
||||
{ path: '.gitignore', isDirectory: false },
|
||||
{ path: '.geminiignore', isDirectory: false },
|
||||
{ path: '.git', isDirectory: true },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Files that contain sensitive secrets or credentials and should be
|
||||
* completely hidden (deny read/write) in any sandbox.
|
||||
*/
|
||||
export const SECRET_FILES = [
|
||||
{ pattern: '.env' },
|
||||
{ pattern: '.env.*' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Resolves and sanitizes all path categories for a sandbox request.
|
||||
*/
|
||||
export async function resolveSandboxPaths(
|
||||
options: GlobalSandboxOptions,
|
||||
req: SandboxRequest,
|
||||
overridePermissions?: SandboxPermissions,
|
||||
): Promise<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 abstract class AbstractOsSandboxManager implements SandboxManager {
|
||||
protected readonly denialCache: SandboxDenialCache =
|
||||
createSandboxDenialCache();
|
||||
|
||||
constructor(protected readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
abstract isKnownSafeCommand(args: string[]): boolean;
|
||||
abstract isDangerousCommand(args: string[]): boolean;
|
||||
abstract parseDenials(
|
||||
result: ShellExecutionResult,
|
||||
): ParsedSandboxDenial | undefined;
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
protected touch(filePath: string, isDirectory: boolean): void {
|
||||
assertValidPathString(filePath);
|
||||
try {
|
||||
// If it exists (even as a broken symlink), do nothing
|
||||
if (fs.lstatSync(filePath)) return;
|
||||
} catch {
|
||||
// Ignore ENOENT
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
} else {
|
||||
const dir = dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.closeSync(fs.openSync(filePath, 'a'));
|
||||
}
|
||||
}
|
||||
|
||||
protected isToolApproved(
|
||||
toolName: string,
|
||||
caseInsensitive: boolean = false,
|
||||
): boolean {
|
||||
const approvedTools = this.options.modeConfig?.approvedTools;
|
||||
if (!approvedTools || approvedTools.length === 0) return false;
|
||||
|
||||
if (caseInsensitive) {
|
||||
return approvedTools.some(
|
||||
(t) => t.toLowerCase() === toolName.toLowerCase(),
|
||||
);
|
||||
}
|
||||
return approvedTools.includes(toolName);
|
||||
}
|
||||
|
||||
protected abstract get isCaseInsensitive(): boolean;
|
||||
|
||||
protected resolveFinalCommand(
|
||||
req: SandboxRequest,
|
||||
_permissions: SandboxPermissions,
|
||||
_resolvedPaths: ResolvedSandboxPaths,
|
||||
): { command: string; args: string[] } {
|
||||
return { command: req.command, args: req.args };
|
||||
}
|
||||
|
||||
protected abstract buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand>;
|
||||
|
||||
protected adjustPermissionsForVirtualCommands(
|
||||
req: SandboxRequest,
|
||||
permissions: SandboxPermissions,
|
||||
): void {
|
||||
if (req.command === '__read' || req.command === '__write') {
|
||||
validateVirtualCommandPaths(req, this.options.workspace, [
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
]);
|
||||
|
||||
if (req.command === '__read') {
|
||||
permissions.fileSystem!.read!.push(...req.args);
|
||||
} else {
|
||||
permissions.fileSystem!.write!.push(...req.args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
|
||||
const commandName = await getCommandName(req.command, req.args);
|
||||
|
||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||
const isApproved = allowOverrides
|
||||
? this.isToolApproved(commandName, this.isCaseInsensitive)
|
||||
: false;
|
||||
|
||||
const workspaceWrite: boolean = !isReadonlyMode || isApproved || isYolo;
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
defaultNetwork ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
this.adjustPermissionsForVirtualCommands(req, mergedAdditional);
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
|
||||
const { command: finalCommand, args: finalArgs } = this.resolveFinalCommand(
|
||||
req,
|
||||
mergedAdditional,
|
||||
resolvedPaths,
|
||||
);
|
||||
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(resolvedPaths.workspace.resolved, file.path);
|
||||
this.touch(filePath, file.isDirectory);
|
||||
}
|
||||
|
||||
return this.buildSandboxedExecution(
|
||||
req,
|
||||
{ command: finalCommand, args: finalArgs },
|
||||
sanitizedEnv,
|
||||
mergedAdditional,
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
||||
import {
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -57,6 +63,16 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/commandSafety.js', () => ({
|
||||
isKnownSafeCommand: vi.fn(),
|
||||
isDangerousCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/sandboxDenialUtils.js', () => ({
|
||||
parsePosixSandboxDenials: vi.fn(),
|
||||
createSandboxDenialCache: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe('LinuxSandboxManager', () => {
|
||||
const workspace = '/home/user/workspace';
|
||||
let manager: LinuxSandboxManager;
|
||||
@@ -72,6 +88,48 @@ describe('LinuxSandboxManager', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isKnownSafeCommand', () => {
|
||||
it('should return true if the tool is in the approvedTools list', () => {
|
||||
const managerWithTools = new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { approvedTools: ['git'] },
|
||||
});
|
||||
expect(managerWithTools.isKnownSafeCommand(['git'])).toBe(true);
|
||||
expect(isPosixSafeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to isPosixSafeCommand for non-approved tools', () => {
|
||||
vi.mocked(isPosixSafeCommand).mockReturnValue(true);
|
||||
expect(manager.isKnownSafeCommand(['ls'])).toBe(true);
|
||||
expect(isPosixSafeCommand).toHaveBeenCalledWith(['ls']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDangerousCommand', () => {
|
||||
it('should delegate to isPosixDangerousCommand', () => {
|
||||
vi.mocked(isPosixDangerousCommand).mockReturnValue(true);
|
||||
expect(manager.isDangerousCommand(['rm', '-rf', '/'])).toBe(true);
|
||||
expect(isPosixDangerousCommand).toHaveBeenCalledWith(['rm', '-rf', '/']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDenials', () => {
|
||||
it('should delegate to parsePosixSandboxDenials', () => {
|
||||
const mockResult = {
|
||||
exitCode: 1,
|
||||
output: 'denied',
|
||||
} as unknown as ShellExecutionResult;
|
||||
const mockParsed = { filePaths: ['/tmp/blocked'] };
|
||||
vi.mocked(parsePosixSandboxDenials).mockReturnValue(mockParsed);
|
||||
|
||||
expect(manager.parseDenials(mockResult)).toBe(mockParsed);
|
||||
expect(parsePosixSandboxDenials).toHaveBeenCalledWith(
|
||||
mockResult,
|
||||
manager['denialCache'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareCommand', () => {
|
||||
it('wraps the command and arguments correctly using a temporary file', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
@@ -149,21 +207,5 @@ describe('LinuxSandboxManager', () => {
|
||||
expect(result.args[result.args.length - 2]).toBe('/bin/cat');
|
||||
expect(result.args[result.args.length - 1]).toBe(testFile);
|
||||
});
|
||||
|
||||
it('rejects overrides in plan mode', async () => {
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { allowOverrides: false },
|
||||
});
|
||||
await expect(
|
||||
customManager.prepareCommand({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: { networkAccess: true },
|
||||
}),
|
||||
).rejects.toThrow(/Cannot override/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,40 +5,27 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
GOVERNANCE_FILES,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
import type {
|
||||
GlobalSandboxOptions,
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
type ResolvedSandboxPaths,
|
||||
AbstractOsSandboxManager,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
verifySandboxOverrides,
|
||||
getCommandName,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { assertValidPathString } from '../../utils/paths.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import {
|
||||
parsePosixSandboxDenials,
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
|
||||
import {
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
@@ -109,54 +96,42 @@ function getSeccompBpfPath(): string {
|
||||
return bpfPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a file or directory exists.
|
||||
*/
|
||||
function touch(filePath: string, isDirectory: boolean) {
|
||||
assertValidPathString(filePath);
|
||||
try {
|
||||
// If it exists (even as a broken symlink), do nothing
|
||||
if (fs.lstatSync(filePath)) return;
|
||||
} catch {
|
||||
// Ignore ENOENT
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||
fs.closeSync(fs.openSync(filePath, 'a'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
*/
|
||||
|
||||
export class LinuxSandboxManager implements SandboxManager {
|
||||
export class LinuxSandboxManager extends AbstractOsSandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
return isKnownSafeCommand(args);
|
||||
const toolName = args[0];
|
||||
if (toolName && this.isToolApproved(toolName)) {
|
||||
return true;
|
||||
}
|
||||
return isPosixSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
return isPosixDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
protected override resolveFinalCommand(
|
||||
req: SandboxRequest,
|
||||
_permissions: SandboxPermissions,
|
||||
_resolvedPaths: ResolvedSandboxPaths,
|
||||
): { command: string; args: string[] } {
|
||||
return handleReadWriteCommands(req);
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
@@ -184,85 +159,14 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
return maskPath;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
let command = req.command;
|
||||
let args = req.args;
|
||||
|
||||
// Translate virtual commands for sandboxed file system access
|
||||
if (command === '__read') {
|
||||
command = 'cat';
|
||||
} else if (command === '__write') {
|
||||
command = 'sh';
|
||||
args = ['-c', 'cat > "$1"', '_', ...args];
|
||||
}
|
||||
|
||||
const commandName = await getCommandName({ ...req, command, args });
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
{ ...req, command, args },
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
|
||||
const networkAccess =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
networkAccess ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
||||
req,
|
||||
mergedAdditional,
|
||||
this.options.workspace,
|
||||
[
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
],
|
||||
);
|
||||
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(this.options.workspace, file.path);
|
||||
touch(filePath, file.isDirectory);
|
||||
}
|
||||
|
||||
protected async buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand> {
|
||||
const bwrapArgs = await buildBwrapArgs({
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
@@ -283,8 +187,8 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
bpfPath,
|
||||
argsPath,
|
||||
'--',
|
||||
finalCommand,
|
||||
...finalArgs,
|
||||
finalCmd.command,
|
||||
...finalCmd.args,
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { buildBwrapArgs, type BwrapArgsOptions } from './bwrapArgsBuilder.js';
|
||||
import {
|
||||
buildBwrapArgs,
|
||||
type BwrapArgsOptions,
|
||||
getSecretFileFindArgs,
|
||||
} from './bwrapArgsBuilder.js';
|
||||
import fs from 'node:fs';
|
||||
import * as shellUtils from '../../utils/shell-utils.js';
|
||||
import os from 'node:os';
|
||||
import { type ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -397,3 +401,25 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
||||
expect(worktreeBindIndex).toBeGreaterThan(writeBindIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecretFileFindArgs', () => {
|
||||
it('should return the correct find arguments array combining all SECRET_FILES patterns', () => {
|
||||
const args = getSecretFileFindArgs();
|
||||
|
||||
expect(args[0]).toBe('(');
|
||||
expect(args[args.length - 1]).toBe(')');
|
||||
|
||||
// Ensure it correctly handles the structure of `[ '(', '-name', '.env', '-o', '-name', '.env.*', ')' ]`
|
||||
const isName = args.includes('-name');
|
||||
expect(isName).toBe(true);
|
||||
|
||||
const envIndex = args.indexOf('.env');
|
||||
expect(envIndex).toBeGreaterThan(0);
|
||||
expect(args[envIndex - 1]).toBe('-name');
|
||||
|
||||
if (args.includes('-o')) {
|
||||
const oIndex = args.indexOf('-o');
|
||||
expect(args[oIndex + 1]).toBe('-name');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,9 @@ import fs from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import {
|
||||
GOVERNANCE_FILES,
|
||||
getSecretFileFindArgs,
|
||||
SECRET_FILES,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
@@ -212,3 +212,13 @@ async function getSecretFilesArgs(
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function getSecretFileFindArgs(): string[] {
|
||||
const args: string[] = ['('];
|
||||
SECRET_FILES.forEach((s: { pattern: string }, i: number) => {
|
||||
if (i > 0) args.push('-o');
|
||||
args.push('-name', s.pattern);
|
||||
});
|
||||
args.push(')');
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
|
||||
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
|
||||
import {
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
|
||||
vi.mock('../utils/commandSafety.js', () => ({
|
||||
isKnownSafeCommand: vi.fn(),
|
||||
isDangerousCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/sandboxDenialUtils.js', () => ({
|
||||
parsePosixSandboxDenials: vi.fn(),
|
||||
createSandboxDenialCache: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe('MacOsSandboxManager', () => {
|
||||
let mockWorkspace: string;
|
||||
@@ -20,6 +36,7 @@ describe('MacOsSandboxManager', () => {
|
||||
let manager: MacOsSandboxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockWorkspace = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-macos-test-')),
|
||||
);
|
||||
@@ -54,6 +71,48 @@ describe('MacOsSandboxManager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('isKnownSafeCommand', () => {
|
||||
it('should return true if the tool is in the approvedTools list', () => {
|
||||
const managerWithTools = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { approvedTools: ['git'] },
|
||||
});
|
||||
expect(managerWithTools.isKnownSafeCommand(['git'])).toBe(true);
|
||||
expect(isPosixSafeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to isPosixSafeCommand for non-approved tools', () => {
|
||||
vi.mocked(isPosixSafeCommand).mockReturnValue(true);
|
||||
expect(manager.isKnownSafeCommand(['ls'])).toBe(true);
|
||||
expect(isPosixSafeCommand).toHaveBeenCalledWith(['ls']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDangerousCommand', () => {
|
||||
it('should delegate to isPosixDangerousCommand', () => {
|
||||
vi.mocked(isPosixDangerousCommand).mockReturnValue(true);
|
||||
expect(manager.isDangerousCommand(['rm', '-rf', '/'])).toBe(true);
|
||||
expect(isPosixDangerousCommand).toHaveBeenCalledWith(['rm', '-rf', '/']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDenials', () => {
|
||||
it('should delegate to parsePosixSandboxDenials', () => {
|
||||
const mockResult = {
|
||||
exitCode: 1,
|
||||
output: 'denied',
|
||||
} as unknown as ShellExecutionResult;
|
||||
const mockParsed = { filePaths: ['/tmp/blocked'] };
|
||||
vi.mocked(parsePosixSandboxDenials).mockReturnValue(mockParsed);
|
||||
|
||||
expect(manager.parseDenials(mockResult)).toBe(mockParsed);
|
||||
expect(parsePosixSandboxDenials).toHaveBeenCalledWith(
|
||||
mockResult,
|
||||
manager['denialCache'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareCommand', () => {
|
||||
it('should correctly format the base command and args', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
@@ -99,25 +158,6 @@ describe('MacOsSandboxManager', () => {
|
||||
expect(result.cwd).toBe('/test/different/cwd');
|
||||
});
|
||||
|
||||
it('should apply environment sanitization via the default mechanisms', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
sanitizationConfig: { enableEnvironmentVariableRedaction: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow network when networkAccess is true', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
@@ -132,30 +172,6 @@ describe('MacOsSandboxManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT whitelist root in YOLO mode', async () => {
|
||||
manager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
modeConfig: { readonly: false, allowOverrides: true, yolo: true },
|
||||
});
|
||||
|
||||
await manager.prepareCommand({
|
||||
command: 'ls',
|
||||
args: ['/'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceWrite: true,
|
||||
resolvedPaths: expect.objectContaining({
|
||||
policyRead: expect.not.arrayContaining(['/']),
|
||||
policyWrite: expect.not.arrayContaining(['/']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('virtual commands', () => {
|
||||
it('should translate __read to /bin/cat', async () => {
|
||||
const testFile = path.join(mockWorkspace, 'file.txt');
|
||||
@@ -190,125 +206,5 @@ describe('MacOsSandboxManager', () => {
|
||||
expect(result.args[result.args.length - 1]).toBe(testFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('governance files', () => {
|
||||
it('should ensure governance files exist', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
// The seatbelt builder internally handles governance files, so we simply verify
|
||||
// it is invoked correctly with the right workspace.
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resolvedPaths: expect.objectContaining({
|
||||
workspace: { resolved: mockWorkspace, original: mockWorkspace },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowedPaths', () => {
|
||||
it('should parameterize allowed paths and normalize them', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
allowedPaths: ['/tmp/allowed1', '/tmp/allowed2'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resolvedPaths: expect.objectContaining({
|
||||
policyAllowed: expect.arrayContaining([
|
||||
'/tmp/allowed1',
|
||||
'/tmp/allowed2',
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forbiddenPaths', () => {
|
||||
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
||||
const customManager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: async () => ['/tmp/forbidden1'],
|
||||
});
|
||||
await customManager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resolvedPaths: expect.objectContaining({
|
||||
forbidden: expect.arrayContaining(['/tmp/forbidden1']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
||||
const customManager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: async () => ['/tmp/does-not-exist'],
|
||||
});
|
||||
await customManager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resolvedPaths: expect.objectContaining({
|
||||
forbidden: expect.arrayContaining(['/tmp/does-not-exist']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
||||
const customManager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: async () => ['/tmp/conflict'],
|
||||
});
|
||||
await customManager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
allowedPaths: ['/tmp/conflict'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resolvedPaths: expect.objectContaining({
|
||||
policyAllowed: [],
|
||||
forbidden: expect.arrayContaining(['/tmp/conflict']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,148 +7,65 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
type GlobalSandboxOptions,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
import type {
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
GlobalSandboxOptions,
|
||||
ParsedSandboxDenial,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
|
||||
import { initializeShellParsers } from '../../utils/shell-utils.js';
|
||||
import { AbstractOsSandboxManager } from '../abstractOsSandboxManager.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import {
|
||||
verifySandboxOverrides,
|
||||
getCommandName as getFullCommandName,
|
||||
isStrictlyApproved,
|
||||
} from '../utils/commandUtils.js';
|
||||
import {
|
||||
parsePosixSandboxDenials,
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
export class MacOsSandboxManager extends AbstractOsSandboxManager {
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (toolName && approvedTools.includes(toolName)) {
|
||||
if (toolName && this.isToolApproved(toolName)) {
|
||||
return true;
|
||||
}
|
||||
return isKnownSafeCommand(args);
|
||||
return isPosixSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
return isPosixDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
protected override resolveFinalCommand(
|
||||
req: SandboxRequest,
|
||||
_permissions: SandboxPermissions,
|
||||
_resolvedPaths: ResolvedSandboxPaths,
|
||||
): { command: string; args: string[] } {
|
||||
return handleReadWriteCommands(req);
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
await initializeShellParsers();
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
let command = req.command;
|
||||
let args = req.args;
|
||||
|
||||
// Translate virtual commands for sandboxed file system access
|
||||
if (command === '__read') {
|
||||
command = '/bin/cat';
|
||||
} else if (command === '__write') {
|
||||
command = '/bin/sh';
|
||||
args = ['-c', 'cat > "$1"', '_', ...args];
|
||||
}
|
||||
|
||||
const currentReq = { ...req, command, args };
|
||||
|
||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
currentReq,
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getFullCommandName(currentReq);
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
defaultNetwork ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
||||
req,
|
||||
mergedAdditional,
|
||||
this.options.workspace,
|
||||
[
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
],
|
||||
);
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
|
||||
protected async buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand> {
|
||||
const sandboxArgs = buildSeatbeltProfile({
|
||||
resolvedPaths,
|
||||
networkAccess: mergedAdditional.network,
|
||||
@@ -159,7 +76,7 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
|
||||
return {
|
||||
program: '/usr/bin/sandbox-exec',
|
||||
args: ['-f', tempFile, '--', finalCommand, ...finalArgs],
|
||||
args: ['-f', tempFile, '--', finalCmd.command, ...finalCmd.args],
|
||||
env: sanitizedEnv,
|
||||
cwd: req.cwd,
|
||||
cleanup: () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
buildSeatbeltProfile,
|
||||
escapeSchemeString,
|
||||
} from './seatbeltArgsBuilder.js';
|
||||
import type { ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
GOVERNANCE_FILES,
|
||||
SECRET_FILES,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
import { resolveToRealPath } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
type SandboxPermissions,
|
||||
type SandboxRequest,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { type SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import { isValidPathString } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
@@ -47,38 +44,34 @@ function validatePaths(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleReadWriteCommands(
|
||||
export function validateVirtualCommandPaths(
|
||||
req: SandboxRequest,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
workspace: string,
|
||||
allowedPaths: string[] = [],
|
||||
): { command: string; args: string[] } {
|
||||
): void {
|
||||
if (req.command === '__read' || req.command === '__write') {
|
||||
if (req.args.length > 0) {
|
||||
if (!validatePaths(req.args, workspace, allowedPaths)) {
|
||||
throw new Error(
|
||||
`Sandbox Error: Path traversal or unauthorized access attempt detected in ${req.command}: ${req.args.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleReadWriteCommands(req: SandboxRequest): {
|
||||
command: string;
|
||||
args: string[];
|
||||
} {
|
||||
let finalCommand = req.command;
|
||||
let finalArgs = req.args;
|
||||
|
||||
if (req.command === '__read') {
|
||||
finalCommand = '/bin/cat';
|
||||
if (req.args.length > 0) {
|
||||
if (validatePaths(req.args, workspace, allowedPaths)) {
|
||||
mergedAdditional.fileSystem!.read!.push(...req.args);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Sandbox Error: Path traversal or unauthorized access attempt detected in __read: ${req.args.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (req.command === '__write') {
|
||||
finalCommand = '/bin/sh';
|
||||
finalArgs = ['-c', 'tee -- "$@" > /dev/null', '_', ...req.args];
|
||||
if (req.args.length > 0) {
|
||||
if (validatePaths(req.args, workspace, allowedPaths)) {
|
||||
mergedAdditional.fileSystem!.write!.push(...req.args);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Sandbox Error: Path traversal or unauthorized access attempt detected in __write: ${req.args.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { command: finalCommand, args: finalArgs };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,44 +4,32 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SECRET_FILES,
|
||||
AbstractOsSandboxManager,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
GOVERNANCE_FILES,
|
||||
findSecretFiles,
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxPermissions,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
|
||||
import type {
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
GlobalSandboxOptions,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isStrictlyApproved,
|
||||
isKnownSafeCommand as isWindowsSafeCommand,
|
||||
isDangerousCommand as isWindowsDangerousCommand,
|
||||
} from './commandSafety.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
|
||||
import {
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
assertValidPathString,
|
||||
} from '../../utils/paths.js';
|
||||
import {
|
||||
type SandboxDenialCache,
|
||||
createSandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -51,62 +39,34 @@ const __dirname = path.dirname(__filename);
|
||||
* Job Objects, and Low Integrity levels for process isolation.
|
||||
* Uses a native C# helper to bypass PowerShell restrictions.
|
||||
*/
|
||||
export class WindowsSandboxManager implements SandboxManager {
|
||||
export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
static readonly HELPER_EXE = 'GeminiSandbox.exe';
|
||||
private readonly helperPath: string;
|
||||
private initialized = false;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
|
||||
}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0]?.toLowerCase();
|
||||
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (toolName && approvedTools.some((t) => t.toLowerCase() === toolName)) {
|
||||
const toolName = args[0];
|
||||
if (toolName && this.isToolApproved(toolName, true)) {
|
||||
return true;
|
||||
}
|
||||
return isKnownSafeCommand(args);
|
||||
return isWindowsSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
return isWindowsDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parseWindowsSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a file or directory exists.
|
||||
*/
|
||||
private touch(filePath: string, isDirectory: boolean): void {
|
||||
assertValidPathString(filePath);
|
||||
try {
|
||||
// If it exists (even as a broken symlink), do nothing
|
||||
if (fs.lstatSync(filePath)) return;
|
||||
} catch {
|
||||
// Ignore ENOENT
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
} else {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.closeSync(fs.openSync(filePath, 'a'));
|
||||
}
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
@@ -210,73 +170,21 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a command for sandboxed execution on Windows.
|
||||
*/
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
protected async buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
const networkAccess = mergedAdditional.network;
|
||||
const command = finalCmd.command;
|
||||
const args = finalCmd.args;
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
const command = req.command;
|
||||
const args = req.args;
|
||||
|
||||
// Native commands __read and __write are passed directly to GeminiSandbox.exe
|
||||
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getCommandName(command, args);
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
// Merge all permissions
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
isYolo ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
if (req.command === '__read' && req.args[0]) {
|
||||
mergedAdditional.fileSystem!.read!.push(req.args[0]);
|
||||
} else if (req.command === '__write' && req.args[0]) {
|
||||
mergedAdditional.fileSystem!.write!.push(req.args[0]);
|
||||
}
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
|
||||
// 1. Collect all forbidden paths.
|
||||
// Collect all forbidden paths.
|
||||
// We start with explicitly forbidden paths from the options and request.
|
||||
const forbiddenManifest = new Set(
|
||||
resolvedPaths.forbidden.map((p) => resolveToRealPath(p)),
|
||||
@@ -307,7 +215,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
|
||||
await Promise.all(secretFilesPromises);
|
||||
|
||||
// 2. Track paths that will be granted write access.
|
||||
// Track paths that will be granted write access.
|
||||
// 'allowedManifest' contains resolved paths for the C# helper to apply ACLs.
|
||||
// 'inheritanceRoots' contains both original and resolved paths for Node.js sub-path validation.
|
||||
const allowedManifest = new Set<string>();
|
||||
@@ -340,29 +248,18 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
allowedManifest.add(resolved);
|
||||
};
|
||||
|
||||
// 3. Populate writable roots from various sources.
|
||||
|
||||
// A. Workspace access
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
command,
|
||||
args,
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
// Populate writable roots from various sources.
|
||||
|
||||
if (workspaceWrite) {
|
||||
addWritableRoot(resolvedPaths.workspace.resolved);
|
||||
}
|
||||
|
||||
// B. Globally included directories
|
||||
// Globally included directories
|
||||
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||
addWritableRoot(includeDir);
|
||||
}
|
||||
|
||||
// C. Explicitly allowed paths from the request policy
|
||||
// Explicitly allowed paths from the request policy
|
||||
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||
try {
|
||||
await fs.promises.access(allowedPath, fs.constants.F_OK);
|
||||
@@ -375,7 +272,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
addWritableRoot(allowedPath);
|
||||
}
|
||||
|
||||
// D. Additional write paths (e.g. from internal __write command)
|
||||
// Additional write paths (e.g. from internal __write command)
|
||||
for (const writePath of resolvedPaths.policyWrite) {
|
||||
try {
|
||||
await fs.promises.access(writePath, fs.constants.F_OK);
|
||||
@@ -402,15 +299,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
// No-op for read access on Windows.
|
||||
}
|
||||
|
||||
// 4. Protected governance files
|
||||
// These must exist on the host before running the sandbox to prevent
|
||||
// the sandboxed process from creating them with Low integrity.
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = path.join(resolvedPaths.workspace.resolved, file.path);
|
||||
this.touch(filePath, file.isDirectory);
|
||||
}
|
||||
|
||||
// 5. Generate Manifests
|
||||
// Generate Manifests
|
||||
const tempDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
|
||||
);
|
||||
@@ -427,7 +316,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
Array.from(allowedManifest).join('\n'),
|
||||
);
|
||||
|
||||
// 6. Construct the helper command
|
||||
// Construct the helper command
|
||||
const program = this.helperPath;
|
||||
|
||||
const finalArgs = [
|
||||
@@ -471,3 +360,62 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given file name matches any of the secret file patterns.
|
||||
*/
|
||||
export function isSecretFile(fileName: string): boolean {
|
||||
return SECRET_FILES.some((s: { pattern: string }) => {
|
||||
if (s.pattern.endsWith('*')) {
|
||||
const prefix = s.pattern.slice(0, -1);
|
||||
return fileName.startsWith(prefix);
|
||||
}
|
||||
return fileName === s.pattern;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all secret files in a directory up to a certain depth.
|
||||
* Default is shallow scan (depth 1) for performance.
|
||||
*/
|
||||
export async function findSecretFiles(
|
||||
baseDir: string,
|
||||
maxDepth = 1,
|
||||
): Promise<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.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!skipDirs.has(entry.name)) {
|
||||
await walk(fullPath, depth + 1);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
if (isSecretFile(entry.name)) {
|
||||
secrets.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
await walk(baseDir, 1);
|
||||
return secrets;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,405 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
NoopSandboxManager,
|
||||
findSecretFiles,
|
||||
isSecretFile,
|
||||
resolveSandboxPaths,
|
||||
type SandboxRequest,
|
||||
} from './sandboxManager.js';
|
||||
import { createSandboxManager } from './sandboxManagerFactory.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
|
||||
import type fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('node:fs/promises')>(
|
||||
'node:fs/promises',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
readdir: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
lstat: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
readdir: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
lstat: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/paths.js', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../utils/paths.js')>(
|
||||
'../utils/paths.js',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveToRealPath: vi.fn((p) => p),
|
||||
};
|
||||
});
|
||||
|
||||
describe('isSecretFile', () => {
|
||||
it('should return true for .env', () => {
|
||||
expect(isSecretFile('.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for .env.local', () => {
|
||||
expect(isSecretFile('.env.local')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for .env.production', () => {
|
||||
expect(isSecretFile('.env.production')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for regular files', () => {
|
||||
expect(isSecretFile('package.json')).toBe(false);
|
||||
expect(isSecretFile('index.ts')).toBe(false);
|
||||
expect(isSecretFile('.gitignore')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for files starting with .env but not matching pattern', () => {
|
||||
// This depends on the pattern ".env.*". ".env-backup" would match ".env*" but not ".env.*"
|
||||
expect(isSecretFile('.env-backup')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSecretFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should find secret files in the root directory', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
|
||||
if (dir === workspace) {
|
||||
return Promise.resolve([
|
||||
{ name: '.env', isDirectory: () => false, isFile: () => true },
|
||||
{
|
||||
name: 'package.json',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
},
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as fs.Dirent[]);
|
||||
}
|
||||
return Promise.resolve([] as unknown as fs.Dirent[]);
|
||||
}) as unknown as typeof fsPromises.readdir);
|
||||
|
||||
const secrets = await findSecretFiles(workspace);
|
||||
expect(secrets).toEqual([path.join(workspace, '.env')]);
|
||||
});
|
||||
|
||||
it('should NOT find secret files recursively (shallow scan only)', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
|
||||
if (dir === workspace) {
|
||||
return Promise.resolve([
|
||||
{ name: '.env', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'packages', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as fs.Dirent[]);
|
||||
}
|
||||
if (dir === path.join(workspace, 'packages')) {
|
||||
return Promise.resolve([
|
||||
{ name: '.env.local', isDirectory: () => false, isFile: () => true },
|
||||
] as unknown as fs.Dirent[]);
|
||||
}
|
||||
return Promise.resolve([] as unknown as fs.Dirent[]);
|
||||
}) as unknown as typeof fsPromises.readdir);
|
||||
|
||||
const secrets = await findSecretFiles(workspace);
|
||||
expect(secrets).toEqual([path.join(workspace, '.env')]);
|
||||
// Should NOT have called readdir for subdirectories
|
||||
expect(fsPromises.readdir).toHaveBeenCalledTimes(1);
|
||||
expect(fsPromises.readdir).not.toHaveBeenCalledWith(
|
||||
path.join(workspace, 'packages'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SandboxManager', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('resolveSandboxPaths', () => {
|
||||
it('should resolve allowed and forbidden paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const forbidden = path.join(workspace, 'forbidden');
|
||||
const allowed = path.join(workspace, 'allowed');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [forbidden],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [allowed],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([allowed]);
|
||||
expect(result.forbidden).toEqual([forbidden]);
|
||||
});
|
||||
|
||||
it('should filter out workspace from allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const other = path.resolve('/other/path');
|
||||
const options = {
|
||||
workspace,
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [workspace, workspace + path.sep, other],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([other]);
|
||||
});
|
||||
|
||||
it('should prioritize forbidden paths over allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secret = path.join(workspace, 'secret');
|
||||
const normal = path.join(workspace, 'normal');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secret],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secret, normal],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([normal]);
|
||||
expect(result.forbidden).toEqual([secret]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive conflicts on supported platforms', async () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secretUpper = path.join(workspace, 'SECRET');
|
||||
const secretLower = path.join(workspace, 'secret');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secretUpper],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secretLower],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([]);
|
||||
expect(result.forbidden).toEqual([secretUpper]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoopSandboxManager', () => {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
|
||||
it('should pass through the command and arguments unchanged', async () => {
|
||||
const cwd = path.resolve('/tmp');
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd,
|
||||
env: { PATH: '/usr/bin' },
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.program).toBe('ls');
|
||||
expect(result.args).toEqual(['-la']);
|
||||
});
|
||||
|
||||
it('should sanitize the environment variables', async () => {
|
||||
const cwd = path.resolve('/tmp');
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd,
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
MY_SECRET: 'super-secret',
|
||||
SAFE_VAR: 'is-safe',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['PATH']).toBe('/usr/bin');
|
||||
expect(result.env['SAFE_VAR']).toBe('is-safe');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
expect(result.env['MY_SECRET']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow disabling environment variable redaction if requested in config', async () => {
|
||||
const cwd = path.resolve('/tmp');
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd,
|
||||
env: {
|
||||
API_KEY: 'sensitive-key',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
// API_KEY should be preserved because redaction was explicitly disabled
|
||||
expect(result.env['API_KEY']).toBe('sensitive-key');
|
||||
});
|
||||
|
||||
it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => {
|
||||
const cwd = path.resolve('/tmp');
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd,
|
||||
env: {
|
||||
MY_SAFE_VAR: 'safe-value',
|
||||
MY_TOKEN: 'secret-token',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['MY_SAFE_VAR']).toBe('safe-value');
|
||||
// MY_TOKEN matches /TOKEN/i so it should be redacted despite being allowed in config
|
||||
expect(result.env['MY_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect blockedEnvironmentVariables in config', async () => {
|
||||
const cwd = path.resolve('/tmp');
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd,
|
||||
env: {
|
||||
SAFE_VAR: 'safe-value',
|
||||
BLOCKED_VAR: 'blocked-value',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
blockedEnvironmentVariables: ['BLOCKED_VAR'],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('safe-value');
|
||||
expect(result.env['BLOCKED_VAR']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delegate isKnownSafeCommand to platform specific checkers', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
expect(sandboxManager.isKnownSafeCommand(['ls'])).toBe(true);
|
||||
expect(sandboxManager.isKnownSafeCommand(['dir'])).toBe(false);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
expect(sandboxManager.isKnownSafeCommand(['dir'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should delegate isDangerousCommand to platform specific checkers', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
expect(sandboxManager.isDangerousCommand(['rm', '-rf', '.'])).toBe(true);
|
||||
expect(sandboxManager.isDangerousCommand(['del'])).toBe(false);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
expect(sandboxManager.isDangerousCommand(['del'])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSandboxManager', () => {
|
||||
it('should return NoopSandboxManager if sandboxing is disabled', () => {
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: false },
|
||||
{ workspace: path.resolve('/workspace') },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ platform: 'linux', expected: LinuxSandboxManager },
|
||||
{ platform: 'darwin', expected: MacOsSandboxManager },
|
||||
{ platform: 'win32', expected: WindowsSandboxManager },
|
||||
] as const)(
|
||||
'should return $expected.name if sandboxing is enabled and platform is $platform',
|
||||
({ platform, expected }) => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue(platform);
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: path.resolve('/workspace') },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: path.resolve('/workspace') },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(WindowsSandboxManager);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
isKnownSafeCommand as isMacSafeCommand,
|
||||
isDangerousCommand as isMacDangerousCommand,
|
||||
@@ -22,44 +20,6 @@ import {
|
||||
} from './environmentSanitization.js';
|
||||
import type { ShellExecutionResult } from './shellExecutionService.js';
|
||||
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import {
|
||||
toPathKey,
|
||||
deduplicateAbsolutePaths,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { resolveGitWorktreePaths } from '../sandbox/utils/fsUtils.js';
|
||||
|
||||
/**
|
||||
* A structured result of fully resolved sandbox paths.
|
||||
* All paths in this object are absolute, deduplicated, and expanded to include
|
||||
* both the original path and its real target (if it is a symlink).
|
||||
*/
|
||||
export interface ResolvedSandboxPaths {
|
||||
/** The primary workspace directory. */
|
||||
workspace: {
|
||||
/** The original path provided in the sandbox options. */
|
||||
original: string;
|
||||
/** The real path. */
|
||||
resolved: string;
|
||||
};
|
||||
/** Explicitly denied paths. */
|
||||
forbidden: string[];
|
||||
/** Directories included globally across all commands in this sandbox session. */
|
||||
globalIncludes: string[];
|
||||
/** Paths explicitly allowed by the policy of the currently executing command. */
|
||||
policyAllowed: string[];
|
||||
/** Paths granted temporary read access by the current command's dynamic permissions. */
|
||||
policyRead: string[];
|
||||
/** Paths granted temporary write access by the current command's dynamic permissions. */
|
||||
policyWrite: string[];
|
||||
/** Auto-detected paths for git worktrees/submodules. */
|
||||
gitWorktree?: {
|
||||
/** The actual .git directory for this worktree. */
|
||||
worktreeGitDir: string;
|
||||
/** The main repository's .git directory (if applicable). */
|
||||
mainGitDir?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SandboxPermissions {
|
||||
/** Filesystem permissions. */
|
||||
@@ -191,97 +151,6 @@ export interface SandboxManager {
|
||||
getOptions(): GlobalSandboxOptions | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files that represent the governance or "constitution" of the repository
|
||||
* and should be write-protected in any sandbox.
|
||||
*/
|
||||
export const GOVERNANCE_FILES = [
|
||||
{ path: '.gitignore', isDirectory: false },
|
||||
{ path: '.geminiignore', isDirectory: false },
|
||||
{ path: '.git', isDirectory: true },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Files that contain sensitive secrets or credentials and should be
|
||||
* completely hidden (deny read/write) in any sandbox.
|
||||
*/
|
||||
export const SECRET_FILES = [
|
||||
{ pattern: '.env' },
|
||||
{ pattern: '.env.*' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if a given file name matches any of the secret file patterns.
|
||||
*/
|
||||
export function isSecretFile(fileName: string): boolean {
|
||||
return SECRET_FILES.some((s) => {
|
||||
if (s.pattern.endsWith('*')) {
|
||||
const prefix = s.pattern.slice(0, -1);
|
||||
return fileName.startsWith(prefix);
|
||||
}
|
||||
return fileName === s.pattern;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns arguments for the Linux 'find' command to locate secret files.
|
||||
*/
|
||||
export function getSecretFileFindArgs(): string[] {
|
||||
const args: string[] = ['('];
|
||||
SECRET_FILES.forEach((s, i) => {
|
||||
if (i > 0) args.push('-o');
|
||||
args.push('-name', s.pattern);
|
||||
});
|
||||
args.push(')');
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all secret files in a directory up to a certain depth.
|
||||
* Default is shallow scan (depth 1) for performance.
|
||||
*/
|
||||
export async function findSecretFiles(
|
||||
baseDir: string,
|
||||
maxDepth = 1,
|
||||
): Promise<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.
|
||||
@@ -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