fix(core): ensure sandbox approvals are correctly persisted and matched for proactive expansions (#24577)

This commit is contained in:
Gal Zahavi
2026-04-03 14:48:18 -07:00
committed by GitHub
parent 370c45de67
commit 893ae4d29a
10 changed files with 572 additions and 104 deletions
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SandboxPolicyManager } from './sandboxPolicyManager.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
describe('SandboxPolicyManager', () => {
const tempDir = path.join(os.tmpdir(), 'gemini-test-sandbox-policy');
const configPath = path.join(tempDir, 'sandbox.toml');
beforeEach(() => {
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
});
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should add and retrieve session approvals', () => {
const manager = new SandboxPolicyManager(configPath);
manager.addSessionApproval('ls', {
fileSystem: { read: ['/tmp'], write: [] },
network: false,
});
const perms = manager.getCommandPermissions('ls');
expect(perms.fileSystem?.read).toContain('/tmp');
});
it('should protect against prototype pollution (session)', () => {
const manager = new SandboxPolicyManager(configPath);
manager.addSessionApproval('__proto__', {
fileSystem: { read: ['/POLLUTED'], write: [] },
network: true,
});
const perms = manager.getCommandPermissions('any-command');
expect(perms.fileSystem?.read).not.toContain('/POLLUTED');
});
it('should protect against prototype pollution (persistent)', () => {
const manager = new SandboxPolicyManager(configPath);
manager.addPersistentApproval('constructor', {
fileSystem: { read: ['/POLLUTED_PERSISTENT'], write: [] },
network: true,
});
const perms = manager.getCommandPermissions('constructor');
expect(perms.fileSystem?.read).not.toContain('/POLLUTED_PERSISTENT');
});
it('should lowercase command names for normalization', () => {
const manager = new SandboxPolicyManager(configPath);
manager.addSessionApproval('NPM', {
fileSystem: { read: ['/node_modules'], write: [] },
network: true,
});
const perms = manager.getCommandPermissions('npm');
expect(perms.fileSystem?.read).toContain('/node_modules');
});
});
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
import { debugLogger } from '../utils/debugLogger.js';
import { type SandboxPermissions } from '../services/sandboxManager.js';
import { sanitizePaths } from '../services/sandboxManager.js';
import { normalizeCommand } from '../utils/shell-utils.js';
export const SandboxModeConfigSchema = z.object({
network: z.boolean(),
@@ -104,6 +105,10 @@ export class SandboxPolicyManager {
this.config = this.loadConfig();
}
private isProtectedKey(key: string): boolean {
return key === '__proto__' || key === 'constructor' || key === 'prototype';
}
private loadConfig(): SandboxTomlSchemaType {
if (!fs.existsSync(this.configPath)) {
return SandboxPolicyManager.DEFAULT_CONFIG;
@@ -154,8 +159,15 @@ export class SandboxPolicyManager {
}
getCommandPermissions(commandName: string): SandboxPermissions {
const persistent = this.config.commands[commandName];
const session = this.sessionApprovals[commandName];
const normalized = normalizeCommand(commandName);
if (this.isProtectedKey(normalized)) {
return {
fileSystem: { read: [], write: [] },
network: false,
};
}
const persistent = this.config.commands[normalized];
const session = this.sessionApprovals[normalized];
return {
fileSystem: {
@@ -176,25 +188,25 @@ export class SandboxPolicyManager {
commandName: string,
permissions: SandboxPermissions,
): void {
const existing = this.sessionApprovals[commandName] || {
const normalized = normalizeCommand(commandName);
if (this.isProtectedKey(normalized)) {
return;
}
const existing = this.sessionApprovals[normalized] || {
fileSystem: { read: [], write: [] },
network: false,
};
this.sessionApprovals[commandName] = {
this.sessionApprovals[normalized] = {
fileSystem: {
read: Array.from(
new Set([
...(existing.fileSystem?.read ?? []),
...(permissions.fileSystem?.read ?? []),
]),
),
write: Array.from(
new Set([
...(existing.fileSystem?.write ?? []),
...(permissions.fileSystem?.write ?? []),
]),
),
read: sanitizePaths([
...(existing.fileSystem?.read ?? []),
...(permissions.fileSystem?.read ?? []),
]),
write: sanitizePaths([
...(existing.fileSystem?.write ?? []),
...(permissions.fileSystem?.write ?? []),
]),
},
network: existing.network || permissions.network || false,
};
@@ -204,7 +216,11 @@ export class SandboxPolicyManager {
commandName: string,
permissions: SandboxPermissions,
): void {
const existing = this.config.commands[commandName] || {
const normalized = normalizeCommand(commandName);
if (this.isProtectedKey(normalized)) {
return;
}
const existing = this.config.commands[normalized] || {
allowed_paths: [],
allow_network: false,
};
@@ -216,7 +232,7 @@ export class SandboxPolicyManager {
];
const newPaths = new Set(sanitizePaths(newPathsArray));
this.config.commands[commandName] = {
this.config.commands[normalized] = {
allowed_paths: Array.from(newPaths),
allow_network: existing.allow_network || permissions.network || false,
};