mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 17:51:04 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cedc6131e |
@@ -3290,6 +3290,85 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not reset the hint timer when overflowingIdsSize decreases', async () => {
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
|
||||
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id-1');
|
||||
capturedOverflowActions.addOverflowingId('test-id-2');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
act(() => {
|
||||
capturedOverflowActions.removeOverflowingId('test-id-2');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not auto-reset the hint timer for new overflow while expanded', async () => {
|
||||
const { stdin, unmount } = await act(async () => renderAppContainer());
|
||||
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
|
||||
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id-2');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
|
||||
const { stdin, unmount } = await act(async () => renderAppContainer());
|
||||
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
|
||||
|
||||
@@ -187,6 +187,7 @@ import {
|
||||
isToolAwaitingConfirmation,
|
||||
getAllToolCalls,
|
||||
} from './utils/historyUtils.js';
|
||||
import { shouldAutoTriggerExpandHint } from './utils/expandHint.js';
|
||||
|
||||
interface AppContainerProps {
|
||||
config: Config;
|
||||
@@ -329,24 +330,35 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const showIsExpandableHint = Boolean(expandHintTrigger);
|
||||
const overflowState = useOverflowState();
|
||||
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
|
||||
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
|
||||
const previousOverflowingIdsSizeRef = useRef(0);
|
||||
|
||||
/**
|
||||
* Manages the visibility and x-second timer for the expansion hint.
|
||||
*
|
||||
* This effect triggers the timer countdown whenever an overflow is detected
|
||||
* or the user manually toggles the expansion state with Ctrl+O.
|
||||
* By depending on overflowingIdsSize, the timer resets when *new* views
|
||||
* overflow, but avoids infinitely resetting during single-view streaming.
|
||||
* while the response is actually constrained. The Ctrl+O handler still
|
||||
* refreshes the hint manually when the user toggles expansion.
|
||||
*
|
||||
* In alternate buffer mode, we don't trigger the hint automatically on overflow
|
||||
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
|
||||
* We only auto-refresh when the number of overflowing regions grows. That
|
||||
* keeps the "show more" hint responsive for newly truncated content without
|
||||
* retriggering on layout churn, overflow shrinkage, or while the content is
|
||||
* already expanded.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (hasOverflowState) {
|
||||
const previousOverflowingIdsSize = previousOverflowingIdsSizeRef.current;
|
||||
|
||||
if (
|
||||
shouldAutoTriggerExpandHint({
|
||||
constrainHeight,
|
||||
overflowingIdsSize,
|
||||
previousOverflowingIdsSize,
|
||||
})
|
||||
) {
|
||||
triggerExpandHint(true);
|
||||
}
|
||||
}, [hasOverflowState, overflowingIdsSize, triggerExpandHint]);
|
||||
|
||||
previousOverflowingIdsSizeRef.current = overflowingIdsSize;
|
||||
}, [constrainHeight, overflowingIdsSize, triggerExpandHint]);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useTimedMessage } from './useTimedMessage.js';
|
||||
|
||||
describe('useTimedMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resets the timeout when the same message is retriggered', async () => {
|
||||
const { result, unmount } = await renderHook(() => useTimedMessage(1000));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('hint');
|
||||
});
|
||||
expect(result.current[0]).toBe('hint');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current[1]('hint');
|
||||
});
|
||||
expect(result.current[0]).toBe('hint');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(result.current[0]).toBe('hint');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(result.current[0]).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('clears the message immediately when asked to hide it', async () => {
|
||||
const { result, unmount } = await renderHook(() => useTimedMessage(1000));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('hint');
|
||||
});
|
||||
expect(result.current[0]).toBe('hint');
|
||||
|
||||
act(() => {
|
||||
result.current[1](null);
|
||||
});
|
||||
expect(result.current[0]).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current[0]).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -12,19 +12,29 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
*/
|
||||
export function useTimedMessage<T>(durationMs: number) {
|
||||
const [message, setMessage] = useState<T | null>(null);
|
||||
const messageRef = useRef<T | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showMessage = useCallback(
|
||||
(msg: T | null) => {
|
||||
setMessage(msg);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (msg !== null) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMessage(null);
|
||||
timeoutRef.current = null;
|
||||
messageRef.current = null;
|
||||
setMessage((prev) => (prev === null ? prev : null));
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
if (Object.is(messageRef.current, msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageRef.current = msg;
|
||||
setMessage(msg);
|
||||
},
|
||||
[durationMs],
|
||||
);
|
||||
@@ -33,6 +43,7 @@ export function useTimedMessage<T>(durationMs: number) {
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldAutoTriggerExpandHint } from './expandHint.js';
|
||||
|
||||
describe('shouldAutoTriggerExpandHint', () => {
|
||||
it('returns true when constrained content gains a new overflowing region', () => {
|
||||
expect(
|
||||
shouldAutoTriggerExpandHint({
|
||||
constrainHeight: true,
|
||||
overflowingIdsSize: 2,
|
||||
previousOverflowingIdsSize: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when overflowingIdsSize decreases', () => {
|
||||
expect(
|
||||
shouldAutoTriggerExpandHint({
|
||||
constrainHeight: true,
|
||||
overflowingIdsSize: 1,
|
||||
previousOverflowingIdsSize: 2,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when overflowingIdsSize is unchanged', () => {
|
||||
expect(
|
||||
shouldAutoTriggerExpandHint({
|
||||
constrainHeight: true,
|
||||
overflowingIdsSize: 1,
|
||||
previousOverflowingIdsSize: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false while content is already expanded', () => {
|
||||
expect(
|
||||
shouldAutoTriggerExpandHint({
|
||||
constrainHeight: false,
|
||||
overflowingIdsSize: 2,
|
||||
previousOverflowingIdsSize: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
interface ExpandHintAutoTriggerParams {
|
||||
constrainHeight: boolean;
|
||||
overflowingIdsSize: number;
|
||||
previousOverflowingIdsSize: number;
|
||||
}
|
||||
|
||||
export function shouldAutoTriggerExpandHint({
|
||||
constrainHeight,
|
||||
overflowingIdsSize,
|
||||
previousOverflowingIdsSize,
|
||||
}: ExpandHintAutoTriggerParams): boolean {
|
||||
return constrainHeight && overflowingIdsSize > previousOverflowingIdsSize;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { z } from 'zod';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { type SandboxPermissions } from '../services/sandboxManager.js';
|
||||
import { deduplicateAbsolutePaths } from '../utils/paths.js';
|
||||
import { sanitizePaths } from '../services/sandboxManager.js';
|
||||
import { normalizeCommand } from '../utils/shell-utils.js';
|
||||
|
||||
export const SandboxModeConfigSchema = z.object({
|
||||
@@ -199,11 +199,11 @@ export class SandboxPolicyManager {
|
||||
|
||||
this.sessionApprovals[normalized] = {
|
||||
fileSystem: {
|
||||
read: deduplicateAbsolutePaths([
|
||||
read: sanitizePaths([
|
||||
...(existing.fileSystem?.read ?? []),
|
||||
...(permissions.fileSystem?.read ?? []),
|
||||
]),
|
||||
write: deduplicateAbsolutePaths([
|
||||
write: sanitizePaths([
|
||||
...(existing.fileSystem?.write ?? []),
|
||||
...(permissions.fileSystem?.write ?? []),
|
||||
]),
|
||||
@@ -230,7 +230,7 @@ export class SandboxPolicyManager {
|
||||
...(permissions.fileSystem?.read ?? []),
|
||||
...(permissions.fileSystem?.write ?? []),
|
||||
];
|
||||
const newPaths = new Set(deduplicateAbsolutePaths(newPathsArray));
|
||||
const newPaths = new Set(sanitizePaths(newPathsArray));
|
||||
|
||||
this.config.commands[normalized] = {
|
||||
allowed_paths: Array.from(newPaths),
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* @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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* @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,15 +5,9 @@
|
||||
*/
|
||||
|
||||
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');
|
||||
@@ -63,16 +57,6 @@ 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;
|
||||
@@ -88,48 +72,6 @@ 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({
|
||||
@@ -207,5 +149,21 @@ 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,27 +5,40 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type {
|
||||
GlobalSandboxOptions,
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
import {
|
||||
type SandboxManager,
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
GOVERNANCE_FILES,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
type ResolvedSandboxPaths,
|
||||
AbstractOsSandboxManager,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
|
||||
import {
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
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 } from '../utils/sandboxDenialUtils.js';
|
||||
import {
|
||||
parsePosixSandboxDenials,
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
@@ -97,41 +110,53 @@ function getSeccompBpfPath(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
* Ensures a file or directory exists.
|
||||
*/
|
||||
export class LinuxSandboxManager extends AbstractOsSandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
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 {
|
||||
private static maskFilePath: string | undefined;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
if (toolName && this.isToolApproved(toolName)) {
|
||||
return true;
|
||||
}
|
||||
return isPosixSafeCommand(args);
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isPosixDangerousCommand(args);
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return false;
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
protected override resolveFinalCommand(
|
||||
req: SandboxRequest,
|
||||
_permissions: SandboxPermissions,
|
||||
_resolvedPaths: ResolvedSandboxPaths,
|
||||
): { command: string; args: string[] } {
|
||||
return handleReadWriteCommands(req);
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
@@ -159,14 +184,85 @@ export class LinuxSandboxManager extends AbstractOsSandboxManager {
|
||||
return maskPath;
|
||||
}
|
||||
|
||||
protected async buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand> {
|
||||
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);
|
||||
}
|
||||
|
||||
const bwrapArgs = await buildBwrapArgs({
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
@@ -187,8 +283,8 @@ export class LinuxSandboxManager extends AbstractOsSandboxManager {
|
||||
bpfPath,
|
||||
argsPath,
|
||||
'--',
|
||||
finalCmd.command,
|
||||
...finalCmd.args,
|
||||
finalCommand,
|
||||
...finalArgs,
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,15 +5,11 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
buildBwrapArgs,
|
||||
type BwrapArgsOptions,
|
||||
getSecretFileFindArgs,
|
||||
} from './bwrapArgsBuilder.js';
|
||||
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 '../abstractOsSandboxManager.js';
|
||||
import { type ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -401,25 +397,3 @@ 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,
|
||||
SECRET_FILES,
|
||||
getSecretFileFindArgs,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
@@ -212,13 +212,3 @@ 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,28 +4,12 @@
|
||||
* 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;
|
||||
@@ -36,7 +20,6 @@ describe('MacOsSandboxManager', () => {
|
||||
let manager: MacOsSandboxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockWorkspace = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-macos-test-')),
|
||||
);
|
||||
@@ -71,48 +54,6 @@ 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({
|
||||
@@ -158,6 +99,25 @@ 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',
|
||||
@@ -172,6 +132,30 @@ 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');
|
||||
@@ -206,5 +190,125 @@ 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,65 +7,148 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
|
||||
import { AbstractOsSandboxManager } from '../abstractOsSandboxManager.js';
|
||||
import {
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
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 { initializeShellParsers } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import {
|
||||
verifySandboxOverrides,
|
||||
getCommandName as getFullCommandName,
|
||||
isStrictlyApproved,
|
||||
} from '../utils/commandUtils.js';
|
||||
import {
|
||||
parsePosixSandboxDenials,
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
|
||||
export class MacOsSandboxManager extends AbstractOsSandboxManager {
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
}
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
if (toolName && this.isToolApproved(toolName)) {
|
||||
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (toolName && approvedTools.includes(toolName)) {
|
||||
return true;
|
||||
}
|
||||
return isPosixSafeCommand(args);
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isPosixDangerousCommand(args);
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return false;
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
protected override resolveFinalCommand(
|
||||
req: SandboxRequest,
|
||||
_permissions: SandboxPermissions,
|
||||
_resolvedPaths: ResolvedSandboxPaths,
|
||||
): { command: string; args: string[] } {
|
||||
return handleReadWriteCommands(req);
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
protected async buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand> {
|
||||
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,
|
||||
);
|
||||
|
||||
const sandboxArgs = buildSeatbeltProfile({
|
||||
resolvedPaths,
|
||||
networkAccess: mergedAdditional.network,
|
||||
@@ -76,7 +159,7 @@ export class MacOsSandboxManager extends AbstractOsSandboxManager {
|
||||
|
||||
return {
|
||||
program: '/usr/bin/sandbox-exec',
|
||||
args: ['-f', tempFile, '--', finalCmd.command, ...finalCmd.args],
|
||||
args: ['-f', tempFile, '--', finalCommand, ...finalArgs],
|
||||
env: sanitizedEnv,
|
||||
cwd: req.cwd,
|
||||
cleanup: () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
buildSeatbeltProfile,
|
||||
escapeSchemeString,
|
||||
} from './seatbeltArgsBuilder.js';
|
||||
import type { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
GOVERNANCE_FILES,
|
||||
SECRET_FILES,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { resolveToRealPath } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { type SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import {
|
||||
type SandboxPermissions,
|
||||
type SandboxRequest,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { isValidPathString } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
@@ -44,34 +47,38 @@ function validatePaths(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateVirtualCommandPaths(
|
||||
export function handleReadWriteCommands(
|
||||
req: SandboxRequest,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
workspace: string,
|
||||
allowedPaths: 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[];
|
||||
} {
|
||||
): { 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,32 +4,44 @@
|
||||
* 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 { ResolvedSandboxPaths } from '../abstractOsSandboxManager.js';
|
||||
import type {
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
GlobalSandboxOptions,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
GOVERNANCE_FILES,
|
||||
findSecretFiles,
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxPermissions,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
|
||||
import {
|
||||
isKnownSafeCommand as isWindowsSafeCommand,
|
||||
isDangerousCommand as isWindowsDangerousCommand,
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isStrictlyApproved,
|
||||
} 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);
|
||||
@@ -39,34 +51,62 @@ 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 extends AbstractOsSandboxManager {
|
||||
export class WindowsSandboxManager implements SandboxManager {
|
||||
static readonly HELPER_EXE = 'GeminiSandbox.exe';
|
||||
private readonly helperPath: string;
|
||||
private initialized = false;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
constructor(private readonly options: GlobalSandboxOptions) {
|
||||
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
|
||||
}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
if (toolName && this.isToolApproved(toolName, true)) {
|
||||
const toolName = args[0]?.toLowerCase();
|
||||
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (toolName && approvedTools.some((t) => t.toLowerCase() === toolName)) {
|
||||
return true;
|
||||
}
|
||||
return isWindowsSafeCommand(args);
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isWindowsDangerousCommand(args);
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parseWindowsSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
protected get isCaseInsensitive(): boolean {
|
||||
return true;
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
@@ -170,21 +210,73 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
protected async buildSandboxedExecution(
|
||||
req: SandboxRequest,
|
||||
finalCmd: { command: string; args: string[] },
|
||||
sanitizedEnv: NodeJS.ProcessEnv,
|
||||
mergedAdditional: SandboxPermissions,
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
workspaceWrite: boolean,
|
||||
): Promise<SandboxedCommand> {
|
||||
/**
|
||||
* Prepares a command for sandboxed execution on Windows.
|
||||
*/
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const networkAccess = mergedAdditional.network;
|
||||
const command = finalCmd.command;
|
||||
const args = finalCmd.args;
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
|
||||
// Collect all forbidden paths.
|
||||
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.
|
||||
// We start with explicitly forbidden paths from the options and request.
|
||||
const forbiddenManifest = new Set(
|
||||
resolvedPaths.forbidden.map((p) => resolveToRealPath(p)),
|
||||
@@ -215,7 +307,7 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
|
||||
await Promise.all(secretFilesPromises);
|
||||
|
||||
// Track paths that will be granted write access.
|
||||
// 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>();
|
||||
@@ -248,18 +340,29 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
allowedManifest.add(resolved);
|
||||
};
|
||||
|
||||
// Populate writable roots from various sources.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Globally included directories
|
||||
// B. Globally included directories
|
||||
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||
addWritableRoot(includeDir);
|
||||
}
|
||||
|
||||
// Explicitly allowed paths from the request policy
|
||||
// C. Explicitly allowed paths from the request policy
|
||||
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||
try {
|
||||
await fs.promises.access(allowedPath, fs.constants.F_OK);
|
||||
@@ -272,7 +375,7 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
addWritableRoot(allowedPath);
|
||||
}
|
||||
|
||||
// Additional write paths (e.g. from internal __write command)
|
||||
// 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);
|
||||
@@ -299,7 +402,15 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
// No-op for read access on Windows.
|
||||
}
|
||||
|
||||
// Generate Manifests
|
||||
// 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
|
||||
const tempDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
|
||||
);
|
||||
@@ -316,7 +427,7 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
Array.from(allowedManifest).join('\n'),
|
||||
);
|
||||
|
||||
// Construct the helper command
|
||||
// 6. Construct the helper command
|
||||
const program = this.helperPath;
|
||||
|
||||
const finalArgs = [
|
||||
@@ -360,62 +471,3 @@ export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* @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,
|
||||
sanitizePaths,
|
||||
findSecretFiles,
|
||||
isSecretFile,
|
||||
resolveSandboxPaths,
|
||||
getPathIdentity,
|
||||
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('sanitizePaths', () => {
|
||||
it('should return an empty array if no paths are provided', () => {
|
||||
expect(sanitizePaths(undefined)).toEqual([]);
|
||||
expect(sanitizePaths(null)).toEqual([]);
|
||||
expect(sanitizePaths([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should deduplicate paths and return them', () => {
|
||||
const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo'];
|
||||
expect(sanitizePaths(paths)).toEqual([
|
||||
'/workspace/foo',
|
||||
'/workspace/bar',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate case-insensitively on Windows and macOS', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
const paths = ['/workspace/foo', '/WORKSPACE/FOO'];
|
||||
expect(sanitizePaths(paths)).toEqual(['/workspace/foo']);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
const macPaths = ['/tmp/foo', '/tmp/FOO'];
|
||||
expect(sanitizePaths(macPaths)).toEqual(['/tmp/foo']);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
const linuxPaths = ['/tmp/foo', '/tmp/FOO'];
|
||||
expect(sanitizePaths(linuxPaths)).toEqual(['/tmp/foo', '/tmp/FOO']);
|
||||
});
|
||||
|
||||
it('should throw an error if a path is not absolute', () => {
|
||||
const paths = ['/workspace/foo', 'relative/path'];
|
||||
expect(() => sanitizePaths(paths)).toThrow(
|
||||
'Sandbox path must be absolute: relative/path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPathIdentity', () => {
|
||||
it('should normalize slashes and strip trailing slashes', () => {
|
||||
expect(getPathIdentity('/foo/bar//baz/')).toBe(
|
||||
path.normalize('/foo/bar/baz'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity correctly per platform', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
expect(getPathIdentity('/Workspace/Foo')).toBe(
|
||||
path.normalize('/workspace/foo'),
|
||||
);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
expect(getPathIdentity('/Tmp/Foo')).toBe(path.normalize('/tmp/foo'));
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
expect(getPathIdentity('/Tmp/Foo')).toBe(path.normalize('/Tmp/Foo'));
|
||||
});
|
||||
});
|
||||
|
||||
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,7 +4,9 @@
|
||||
* 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,
|
||||
@@ -20,6 +22,40 @@ import {
|
||||
} from './environmentSanitization.js';
|
||||
import type { ShellExecutionResult } from './shellExecutionService.js';
|
||||
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import { 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. */
|
||||
@@ -151,6 +187,97 @@ 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.
|
||||
@@ -231,3 +358,112 @@ 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 sanitizePaths 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 sanitizePaths(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(getPathIdentity),
|
||||
);
|
||||
const forbiddenIdentities = new Set(forbidden.map(getPathIdentity));
|
||||
|
||||
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 = getPathIdentity(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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an array of paths by deduplicating them and ensuring they are absolute.
|
||||
* Always returns an array (empty if input is null/undefined).
|
||||
*/
|
||||
export function sanitizePaths(paths?: string[] | null): string[] {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
|
||||
const uniquePathsMap = new Map<string, string>();
|
||||
for (const p of paths) {
|
||||
if (!path.isAbsolute(p)) {
|
||||
throw new Error(`Sandbox path must be absolute: ${p}`);
|
||||
}
|
||||
|
||||
const key = getPathIdentity(p);
|
||||
if (!uniquePathsMap.has(key)) {
|
||||
uniquePathsMap.set(key, p);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniquePathsMap.values());
|
||||
}
|
||||
|
||||
/** Returns a normalized identity for a path, stripping trailing slashes and handling case sensitivity. */
|
||||
export function getPathIdentity(p: string): string {
|
||||
let norm = path.normalize(p);
|
||||
|
||||
// Strip trailing slashes (except for root paths)
|
||||
if (norm.length > 1 && (norm.endsWith('/') || norm.endsWith('\\'))) {
|
||||
norm = norm.slice(0, -1);
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
const isCaseInsensitive = platform === 'win32' || platform === 'darwin';
|
||||
return isCaseInsensitive ? norm.toLowerCase() : norm;
|
||||
}
|
||||
|
||||
export { createSandboxManager } from './sandboxManagerFactory.js';
|
||||
|
||||
@@ -10,7 +10,10 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { debugLogger } from '../index.js';
|
||||
import { type SandboxPermissions } from '../services/sandboxManager.js';
|
||||
import {
|
||||
type SandboxPermissions,
|
||||
getPathIdentity,
|
||||
} from '../services/sandboxManager.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
@@ -49,7 +52,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { toPathKey, isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import {
|
||||
getProactiveToolSuggestions,
|
||||
isNetworkReliantCommand,
|
||||
@@ -304,13 +307,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
approvedPaths?: string[],
|
||||
): boolean => {
|
||||
if (!approvedPaths || approvedPaths.length === 0) return false;
|
||||
const requestedRealIdentity = toPathKey(
|
||||
const requestedRealIdentity = getPathIdentity(
|
||||
resolveToRealPath(requestedPath),
|
||||
);
|
||||
|
||||
// Identity check is fast, subpath check is slower
|
||||
return approvedPaths.some((p) => {
|
||||
const approvedRealIdentity = toPathKey(resolveToRealPath(p));
|
||||
const approvedRealIdentity = getPathIdentity(
|
||||
resolveToRealPath(p),
|
||||
);
|
||||
return (
|
||||
requestedRealIdentity === approvedRealIdentity ||
|
||||
isSubpath(approvedRealIdentity, requestedRealIdentity)
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
normalizePath,
|
||||
resolveToRealPath,
|
||||
makeRelative,
|
||||
deduplicateAbsolutePaths,
|
||||
toPathKey,
|
||||
} from './paths.js';
|
||||
import os from 'node:os';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
@@ -705,60 +702,4 @@ describe('normalizePath', () => {
|
||||
expect(result).toBe('/usr/local/bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplicateAbsolutePaths', () => {
|
||||
it('should return an empty array if no paths are provided', () => {
|
||||
expect(deduplicateAbsolutePaths(undefined)).toEqual([]);
|
||||
expect(deduplicateAbsolutePaths(null)).toEqual([]);
|
||||
expect(deduplicateAbsolutePaths([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should deduplicate paths using their normalized identity', () => {
|
||||
const paths = ['/workspace/foo', '/workspace/foo/'];
|
||||
expect(deduplicateAbsolutePaths(paths)).toEqual(['/workspace/foo']);
|
||||
});
|
||||
|
||||
it('should handle case-insensitivity on Windows and macOS', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
const paths = ['/workspace/foo', '/Workspace/Foo'];
|
||||
expect(deduplicateAbsolutePaths(paths)).toEqual(['/workspace/foo']);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
const macPaths = ['/tmp/foo', '/Tmp/Foo'];
|
||||
expect(deduplicateAbsolutePaths(macPaths)).toEqual(['/tmp/foo']);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
const linuxPaths = ['/tmp/foo', '/tmp/FOO'];
|
||||
expect(deduplicateAbsolutePaths(linuxPaths)).toEqual([
|
||||
'/tmp/foo',
|
||||
'/tmp/FOO',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error if a path is not absolute', () => {
|
||||
const paths = ['relative/path'];
|
||||
expect(() => deduplicateAbsolutePaths(paths)).toThrow(
|
||||
'Path must be absolute: relative/path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPathKey', () => {
|
||||
it('should normalize paths and strip trailing slashes', () => {
|
||||
expect(toPathKey('/foo/bar//baz/')).toBe(path.normalize('/foo/bar/baz'));
|
||||
});
|
||||
|
||||
it('should convert paths to lowercase on Windows and macOS', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
expect(toPathKey('/Workspace/Foo')).toBe(
|
||||
path.normalize('/workspace/foo'),
|
||||
);
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
expect(toPathKey('/Tmp/Foo')).toBe(path.normalize('/tmp/foo'));
|
||||
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
expect(toPathKey('/Tmp/Foo')).toBe(path.normalize('/Tmp/Foo'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -454,42 +454,3 @@ function robustRealpath(p: string, visited = new Set<string>()): string {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates an array of paths and ensures all paths are absolute.
|
||||
*/
|
||||
export function deduplicateAbsolutePaths(paths?: string[] | null): string[] {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
|
||||
const uniquePathsMap = new Map<string, string>();
|
||||
for (const p of paths) {
|
||||
if (!path.isAbsolute(p)) {
|
||||
throw new Error(`Path must be absolute: ${p}`);
|
||||
}
|
||||
|
||||
const key = toPathKey(p);
|
||||
if (!uniquePathsMap.has(key)) {
|
||||
uniquePathsMap.set(key, p);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniquePathsMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable string key for a path to be used in comparisons or Map lookups.
|
||||
*/
|
||||
export function toPathKey(p: string): string {
|
||||
// Normalize path segments
|
||||
let norm = path.normalize(p);
|
||||
|
||||
// Strip trailing slashes (except for root paths)
|
||||
if (norm.length > 1 && (norm.endsWith('/') || norm.endsWith('\\'))) {
|
||||
norm = norm.slice(0, -1);
|
||||
}
|
||||
|
||||
// Convert to lowercase on case-insensitive platforms
|
||||
const platform = os.platform();
|
||||
const isCaseInsensitive = platform === 'win32' || platform === 'darwin';
|
||||
return isCaseInsensitive ? norm.toLowerCase() : norm;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user