fix(core): enhance sandbox usability and fix build error (#24460)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-04-01 16:51:06 -07:00
committed by GitHub
parent ca78a0f177
commit 13ccc16457
22 changed files with 1285 additions and 53 deletions
@@ -6,7 +6,7 @@ allowOverrides = false
[modes.default]
network = false
readonly = true
readonly = false
approvedTools = ['cat', 'ls', 'grep', 'head', 'tail', 'less', 'Get-Content', 'dir', 'type', 'findstr', 'Get-ChildItem', 'echo']
allowOverrides = true
@@ -3630,4 +3630,150 @@ describe('PolicyEngine', () => {
).toBe(PolicyDecision.ALLOW);
});
});
describe('additional_permissions', () => {
const workspace = '/workspace';
let mockSandboxManager: SandboxManager;
let engine: PolicyEngine;
beforeEach(() => {
mockSandboxManager = {
prepareCommand: vi.fn(),
isKnownSafeCommand: vi.fn().mockReturnValue(false),
isDangerousCommand: vi.fn().mockReturnValue(false),
parseDenials: vi.fn(),
getWorkspace: vi.fn().mockReturnValue(workspace),
} as never as SandboxManager;
engine = new PolicyEngine({
rules: [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
modes: [ApprovalMode.AUTO_EDIT],
},
],
approvalMode: ApprovalMode.AUTO_EDIT,
sandboxManager: mockSandboxManager,
});
});
it('should allow permissions exactly at the workspace root', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
fileSystem: {
read: [workspace],
},
},
},
};
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
});
it('should allow permissions for subpaths of the workspace', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
fileSystem: {
read: [`${workspace}/subdir/file.txt`],
},
},
},
};
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
});
it('should downgrade ALLOW to ASK_USER if a read path is outside workspace', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
fileSystem: {
read: ['/outside'],
},
},
},
};
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
});
it('should downgrade ALLOW to ASK_USER if a write path is outside workspace', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
fileSystem: {
write: ['/outside/secret.txt'],
},
},
},
};
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
});
it('should downgrade ALLOW to ASK_USER if any path in a list is outside workspace', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
fileSystem: {
read: [`${workspace}/safe`, '/outside'],
},
},
},
};
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
});
it('should handle missing or empty fileSystem permissions gracefully (ALLOW)', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
network: true,
},
},
};
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
});
it('should handle non-array fileSystem paths gracefully', async () => {
const call = {
name: 'run_shell_command',
args: {
command: 'ls',
additional_permissions: {
fileSystem: {
read: '/not/an/array' as never as string[],
},
},
},
};
// It should just ignore the non-array and keep ALLOW if no other rules trigger
expect((await engine.check(call, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
});
});
});
+33
View File
@@ -13,6 +13,7 @@ import {
extractStringFromParseEntry,
} from '../utils/shell-utils.js';
import { parse as shellParse } from 'shell-quote';
import { isSubpath } from '../utils/paths.js';
import {
PolicyDecision,
type PolicyEngineConfig,
@@ -28,6 +29,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import type { CheckerRunner } from '../safety/checker-runner.js';
import { SafetyCheckDecision } from '../safety/protocol.js';
import { getToolAliases } from '../tools/tool-names.js';
import { PARAM_ADDITIONAL_PERMISSIONS } from '../tools/definitions/base-declarations.js';
import {
MCP_TOOL_PREFIX,
isMcpToolAnnotation,
@@ -38,6 +40,7 @@ import {
import {
type SandboxManager,
NoopSandboxManager,
type SandboxPermissions,
} from '../services/sandboxManager.js';
function isWildcardPattern(name: string): boolean {
@@ -647,6 +650,36 @@ export class PolicyEngine {
}
}
if (decision === PolicyDecision.ALLOW) {
const args = toolCall.args;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const additionalPermissions = args?.[PARAM_ADDITIONAL_PERMISSIONS] as
| SandboxPermissions
| undefined;
const fsPerms = additionalPermissions?.fileSystem;
if (fsPerms) {
const workspace = this.sandboxManager.getWorkspace();
const readPaths = Array.isArray(fsPerms.read) ? fsPerms.read : [];
const writePaths = Array.isArray(fsPerms.write) ? fsPerms.write : [];
const allPaths = [...readPaths, ...writePaths];
for (const p of allPaths) {
if (
typeof p === 'string' &&
!isSubpath(workspace, p) &&
workspace !== p
) {
debugLogger.debug(
`[PolicyEngine.check] Additional permission path '${p}' is outside workspace '${workspace}'. Downgrading to ASK_USER.`,
);
decision = PolicyDecision.ASK_USER;
break;
}
}
}
}
// Safety checks
if (decision !== PolicyDecision.DENY && this.checkerRunner) {
for (const checkerRule of this.checkers) {
@@ -19,6 +19,7 @@ export const SandboxModeConfigSchema = z.object({
readonly: z.boolean(),
approvedTools: z.array(z.string()),
allowOverrides: z.boolean().optional(),
yolo: z.boolean().optional(),
});
export const PersistentCommandConfigSchema = z.object({
@@ -66,7 +67,7 @@ export class SandboxPolicyManager {
},
default: {
network: false,
readonly: true,
readonly: false,
approvedTools: [],
allowOverrides: true,
},
@@ -132,8 +133,17 @@ export class SandboxPolicyManager {
}
getModeConfig(
mode: 'plan' | 'accepting_edits' | 'default' | string,
mode: 'plan' | 'accepting_edits' | 'default' | 'yolo' | string,
): SandboxModeConfig {
if (mode === 'yolo') {
return {
network: true,
readonly: false,
approvedTools: [],
allowOverrides: true,
yolo: true,
};
}
if (mode === 'plan') return this.config.modes.plan;
if (mode === 'accepting_edits' || mode === 'autoEdit')
return this.config.modes.accepting_edits;