Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions packages/core/src/sandbox/linux/LinuxSandboxManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
createSandboxDenialCache,
type SandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
import { isErrnoException } from '../utils/fsUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
import { buildBwrapArgs } from './bwrapArgsBuilder.js';

Expand Down Expand Up @@ -116,9 +117,12 @@ 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
fs.lstatSync(filePath);
return;
} catch (e: unknown) {
if (isErrnoException(e) && e.code !== 'ENOENT') {
throw e;
}
}

if (isDirectory) {
Expand All @@ -136,9 +140,22 @@ function touch(filePath: string, isDirectory: boolean) {
export class LinuxSandboxManager implements SandboxManager {
private static maskFilePath: string | undefined;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
private governanceFilesInitialized = false;

constructor(private readonly options: GlobalSandboxOptions) {}

private ensureGovernanceFilesExist(workspace: string): void {
if (this.governanceFilesInitialized) return;

// These must exist on the host before running the sandbox to ensure they are protected.
for (const file of GOVERNANCE_FILES) {
const filePath = join(workspace, file.path);
touch(filePath, file.isDirectory);
}

this.governanceFilesInitialized = true;
}

isKnownSafeCommand(args: string[]): boolean {
return isKnownSafeCommand(args);
}
Expand Down Expand Up @@ -258,17 +275,14 @@ export class LinuxSandboxManager implements SandboxManager {
mergedAdditional,
);

for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
}
this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);

const bwrapArgs = await buildBwrapArgs({
resolvedPaths,
workspaceWrite,
networkAccess: mergedAdditional.network ?? false,
maskFilePath: this.getMaskFilePath(),
isWriteCommand: req.command === '__write',
isReadOnlyCommand: req.command === '__read',
});

const bpfPath = getSeccompBpfPath();
Expand Down
26 changes: 23 additions & 3 deletions packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
workspaceWrite: false,
networkAccess: false,
maskFilePath: '/tmp/mask',
isWriteCommand: false,
isReadOnlyCommand: false,
};

it('should correctly format the base arguments', async () => {
Expand Down Expand Up @@ -188,18 +188,18 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
expect(args[args.indexOf('/opt/tools') - 1]).toBe('--bind-try');
});

it('should bind the parent directory of a non-existent path', async () => {
it('should bind the parent directory of a non-existent path with --bind-try when isReadOnlyCommand is false', async () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/workspace/new-file.txt') return false;
return true;
});

const args = await buildBwrapArgs({
...defaultOptions,
isReadOnlyCommand: false,
resolvedPaths: createResolvedPaths({
policyAllowed: ['/home/user/workspace/new-file.txt'],
}),
isWriteCommand: true,
});

const parentDir = '/home/user/workspace';
Expand All @@ -208,6 +208,26 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
expect(args[bindIndex - 2]).toBe('--bind-try');
});

it('should bind the parent directory of a non-existent path with --ro-bind-try when isReadOnlyCommand is true', async () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/workspace/new-file.txt') return false;
return true;
});

const args = await buildBwrapArgs({
...defaultOptions,
isReadOnlyCommand: true,
resolvedPaths: createResolvedPaths({
policyAllowed: ['/home/user/workspace/new-file.txt'],
}),
});

const parentDir = '/home/user/workspace';
const bindIndex = args.lastIndexOf(parentDir);
expect(bindIndex).not.toBe(-1);
expect(args[bindIndex - 2]).toBe('--ro-bind-try');
});

it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.mocked(fs.statSync).mockImplementation((p) => {
if (p.toString().includes('cache')) {
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/sandbox/linux/bwrapArgsBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface BwrapArgsOptions {
workspaceWrite: boolean;
networkAccess: boolean;
maskFilePath: string;
isWriteCommand: boolean;
isReadOnlyCommand: boolean;
}

/**
Expand All @@ -37,7 +37,7 @@ export async function buildBwrapArgs(
workspaceWrite,
networkAccess,
maskFilePath,
isWriteCommand,
isReadOnlyCommand,
} = options;
const { workspace } = resolvedPaths;

Expand Down Expand Up @@ -79,10 +79,13 @@ export async function buildBwrapArgs(
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
} else {
// If the path doesn't exist, we still want to allow access to its parent
// to enable creating it. Since allowedPath is already resolved by resolveSandboxPaths,
// its parent is also correctly resolved.
// to enable creating it.
const parent = dirname(allowedPath);
bwrapArgs.push(isWriteCommand ? '--bind-try' : bindFlag, parent, parent);
bwrapArgs.push(
isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
parent,
parent,
);
}
}

Expand Down
24 changes: 1 addition & 23 deletions packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import { type ExecutionPolicy } from '../../services/sandboxManager.js';
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
import fs from 'node:fs';
import os from 'node:os';
Expand Down Expand Up @@ -191,28 +191,6 @@ describe('MacOsSandboxManager', () => {
});
});

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({
Expand Down
81 changes: 46 additions & 35 deletions packages/core/src/sandbox/windows/WindowsSandboxManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import fs from 'node:fs';
import path from 'node:path';
import path, { join } from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import {
Expand Down Expand Up @@ -33,6 +33,7 @@ import {
} from './commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
import { isErrnoException } from '../utils/fsUtils.js';
import {
isSubpath,
resolveToRealPath,
Expand All @@ -53,10 +54,13 @@ const __dirname = path.dirname(__filename);
*/
export class WindowsSandboxManager implements SandboxManager {
static readonly HELPER_EXE = 'GeminiSandbox.exe';

private readonly helperPath: string;
private initialized = false;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();

private static helperCompiled = false;
private governanceFilesInitialized = false;

constructor(private readonly options: GlobalSandboxOptions) {
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
}
Expand Down Expand Up @@ -86,33 +90,20 @@ export class WindowsSandboxManager implements SandboxManager {
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
}
private ensureGovernanceFilesExist(workspace: string): void {
if (this.governanceFilesInitialized) return;

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'));
// These must exist on the host before running the sandbox to ensure they are protected.
for (const file of GOVERNANCE_FILES) {
const filePath = join(workspace, file.path);
touch(filePath, file.isDirectory);
}

this.governanceFilesInitialized = true;
}

private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (os.platform() !== 'win32') {
this.initialized = true;
private async ensureHelperCompiled(): Promise<void> {
if (WindowsSandboxManager.helperCompiled || os.platform() !== 'win32') {
return;
}

Expand Down Expand Up @@ -207,14 +198,14 @@ export class WindowsSandboxManager implements SandboxManager {
);
}

this.initialized = true;
WindowsSandboxManager.helperCompiled = true;
}

/**
* Prepares a command for sandboxed execution on Windows.
*/
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await this.ensureInitialized();
await this.ensureHelperCompiled();

const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
Expand Down Expand Up @@ -276,6 +267,8 @@ export class WindowsSandboxManager implements SandboxManager {
mergedAdditional,
);

this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);

// 1. Collect all forbidden paths.
// We start with explicitly forbidden paths from the options and request.
const forbiddenManifest = new Set(
Expand Down Expand Up @@ -402,14 +395,6 @@ export class WindowsSandboxManager implements SandboxManager {
// No-op for read access on Windows.
}

// 4. Protected governance files
// These must exist on the host before running the sandbox to prevent
// the sandboxed process from creating them with Low integrity.
for (const file of GOVERNANCE_FILES) {
const filePath = path.join(resolvedPaths.workspace.resolved, file.path);
this.touch(filePath, file.isDirectory);
}

// 5. Generate Manifests
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
Expand Down Expand Up @@ -471,3 +456,29 @@ export class WindowsSandboxManager implements SandboxManager {
);
}
}

/**
* Ensures a file or directory exists.
*/
function touch(filePath: string, isDirectory: boolean): void {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
fs.lstatSync(filePath);
return;
} catch (e: unknown) {
if (isErrnoException(e) && e.code !== 'ENOENT') {
throw e;
}
}

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'));
}
}
Loading
Loading