fix(core): resolve windows symlink bypass and stabilize sandbox integration tests (#24834)

This commit is contained in:
Emily Hedlund
2026-04-08 15:00:50 -07:00
committed by GitHub
parent c7b920717f
commit af3638640c
8 changed files with 586 additions and 503 deletions
@@ -249,8 +249,11 @@ export class LinuxSandboxManager implements SandboxManager {
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const { allowed: allowedPaths, forbidden: forbiddenPaths } = const resolvedPaths = await resolveSandboxPaths(
await resolveSandboxPaths(this.options, req); this.options,
req,
mergedAdditional,
);
for (const file of GOVERNANCE_FILES) { for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path); const filePath = join(this.options.workspace, file.path);
@@ -261,8 +264,8 @@ export class LinuxSandboxManager implements SandboxManager {
workspace: this.options.workspace, workspace: this.options.workspace,
workspaceWrite, workspaceWrite,
networkAccess, networkAccess,
allowedPaths, allowedPaths: resolvedPaths.policyAllowed,
forbiddenPaths, forbiddenPaths: resolvedPaths.forbidden,
additionalPermissions: mergedAdditional, additionalPermissions: mergedAdditional,
includeDirectories: this.options.includeDirectories || [], includeDirectories: this.options.includeDirectories || [],
maskFilePath: this.getMaskFilePath(), maskFilePath: this.getMaskFilePath(),
@@ -233,7 +233,10 @@ describe('MacOsSandboxManager', () => {
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith( expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
allowedPaths: ['/tmp/allowed1', '/tmp/allowed2'], allowedPaths: expect.arrayContaining([
'/tmp/allowed1',
'/tmp/allowed2',
]),
}), }),
); );
}); });
@@ -255,7 +258,7 @@ describe('MacOsSandboxManager', () => {
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith( expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
forbiddenPaths: ['/tmp/forbidden1'], forbiddenPaths: expect.arrayContaining(['/tmp/forbidden1']),
}), }),
); );
}); });
@@ -275,7 +278,7 @@ describe('MacOsSandboxManager', () => {
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith( expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
forbiddenPaths: ['/tmp/does-not-exist'], forbiddenPaths: expect.arrayContaining(['/tmp/does-not-exist']),
}), }),
); );
}); });
@@ -299,7 +302,7 @@ describe('MacOsSandboxManager', () => {
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith( expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
allowedPaths: [], allowedPaths: [],
forbiddenPaths: ['/tmp/conflict'], forbiddenPaths: expect.arrayContaining(['/tmp/conflict']),
}), }),
); );
}); });
@@ -106,13 +106,9 @@ export class MacOsSandboxManager implements SandboxManager {
const isYolo = this.options.modeConfig?.yolo ?? false; const isYolo = this.options.modeConfig?.yolo ?? false;
const workspaceWrite = !isReadonlyMode || isApproved || isYolo; const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
const defaultNetwork = const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo; this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
await resolveSandboxPaths(this.options, req);
// Fetch persistent approvals for this command // Fetch persistent approvals for this command
const commandName = await getFullCommandName(currentReq); const commandName = await getFullCommandName(currentReq);
const persistentPermissions = allowOverrides const persistentPermissions = allowOverrides
@@ -137,6 +133,11 @@ export class MacOsSandboxManager implements SandboxManager {
false, false,
}; };
const resolvedPaths = await resolveSandboxPaths(
this.options,
req,
mergedAdditional,
);
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
req, req,
mergedAdditional, mergedAdditional,
@@ -147,10 +148,10 @@ export class MacOsSandboxManager implements SandboxManager {
const sandboxArgs = buildSeatbeltProfile({ const sandboxArgs = buildSeatbeltProfile({
workspace: this.options.workspace, workspace: this.options.workspace,
allowedPaths: [ allowedPaths: [
...allowedPaths, ...resolvedPaths.policyAllowed,
...(this.options.includeDirectories || []), ...(this.options.includeDirectories || []),
], ],
forbiddenPaths, forbiddenPaths: resolvedPaths.forbidden,
networkAccess: mergedAdditional.network, networkAccess: mergedAdditional.network,
workspaceWrite, workspaceWrite,
additionalPermissions: mergedAdditional, additionalPermissions: mergedAdditional,
@@ -398,16 +398,16 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([ expect(icaclsArgs).toContainEqual([
path.resolve(longPath), path.resolve(longPath),
'/grant', '/grant',
'*S-1-16-4096:(OI)(CI)(M)', '*S-1-16-4096:(M)',
'/setintegritylevel', '/setintegritylevel',
'(OI)(CI)Low', 'Low',
]); ]);
expect(icaclsArgs).toContainEqual([ expect(icaclsArgs).toContainEqual([
path.resolve(devicePath), path.resolve(devicePath),
'/grant', '/grant',
'*S-1-16-4096:(OI)(CI)(M)', '*S-1-16-4096:(M)',
'/setintegritylevel', '/setintegritylevel',
'(OI)(CI)Low', 'Low',
]); ]);
}, },
); );
@@ -15,7 +15,6 @@ import {
GOVERNANCE_FILES, GOVERNANCE_FILES,
findSecretFiles, findSecretFiles,
type GlobalSandboxOptions, type GlobalSandboxOptions,
sanitizePaths,
type SandboxPermissions, type SandboxPermissions,
type ParsedSandboxDenial, type ParsedSandboxDenial,
resolveSandboxPaths, resolveSandboxPaths,
@@ -51,6 +50,10 @@ const __dirname = path.dirname(__filename);
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity) // S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
const LOW_INTEGRITY_SID = '*S-1-16-4096'; const LOW_INTEGRITY_SID = '*S-1-16-4096';
// icacls flags: (OI) Object Inherit, (CI) Container Inherits.
// Omit /T (recursive) for performance; (OI)(CI) ensures inheritance for new items.
const DIRECTORY_FLAGS = '(OI)(CI)';
/** /**
* A SandboxManager implementation for Windows that uses Restricted Tokens, * A SandboxManager implementation for Windows that uses Restricted Tokens,
* Job Objects, and Low Integrity levels for process isolation. * Job Objects, and Low Integrity levels for process isolation.
@@ -277,8 +280,11 @@ export class WindowsSandboxManager implements SandboxManager {
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network; const networkAccess = defaultNetwork || mergedAdditional.network;
const { allowed: allowedPaths, forbidden: forbiddenPaths } = const resolvedPaths = await resolveSandboxPaths(
await resolveSandboxPaths(this.options, req); this.options,
req,
mergedAdditional,
);
// Track all roots where Low Integrity write access has been granted. // Track all roots where Low Integrity write access has been granted.
// New files created within these roots will inherit the Low label. // New files created within these roots will inherit the Low label.
@@ -294,51 +300,45 @@ export class WindowsSandboxManager implements SandboxManager {
: false; : false;
if (!isReadonlyMode || isApproved) { if (!isReadonlyMode || isApproved) {
await this.grantLowIntegrityAccess(this.options.workspace); await this.grantLowIntegrityAccess(resolvedPaths.workspace.resolved);
writableRoots.push(this.options.workspace); writableRoots.push(resolvedPaths.workspace.resolved);
} }
// 2. Globally included directories // 2. Globally included directories
const includeDirs = sanitizePaths(this.options.includeDirectories); for (const includeDir of resolvedPaths.globalIncludes) {
for (const includeDir of includeDirs) {
await this.grantLowIntegrityAccess(includeDir); await this.grantLowIntegrityAccess(includeDir);
writableRoots.push(includeDir); writableRoots.push(includeDir);
} }
// 3. Explicitly allowed paths from the request policy // 3. Explicitly allowed paths from the request policy
for (const allowedPath of allowedPaths) { for (const allowedPath of resolvedPaths.policyAllowed) {
const resolved = resolveToRealPath(allowedPath);
try { try {
await fs.promises.access(resolved, fs.constants.F_OK); await fs.promises.access(allowedPath, fs.constants.F_OK);
} catch { } catch {
throw new Error( throw new Error(
`Sandbox request rejected: Allowed path does not exist: ${resolved}. ` + `Sandbox request rejected: Allowed path does not exist: ${allowedPath}. ` +
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.', 'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
); );
} }
await this.grantLowIntegrityAccess(resolved); await this.grantLowIntegrityAccess(allowedPath);
writableRoots.push(resolved); writableRoots.push(allowedPath);
} }
// 4. Additional write paths (e.g. from internal __write command) // 4. Additional write paths (e.g. from internal __write command)
const additionalWritePaths = sanitizePaths( for (const writePath of resolvedPaths.policyWrite) {
mergedAdditional.fileSystem?.write,
);
for (const writePath of additionalWritePaths) {
const resolved = resolveToRealPath(writePath);
try { try {
await fs.promises.access(resolved, fs.constants.F_OK); await fs.promises.access(writePath, fs.constants.F_OK);
await this.grantLowIntegrityAccess(resolved); await this.grantLowIntegrityAccess(writePath);
continue; continue;
} catch { } catch {
// If the file doesn't exist, it's only allowed if it resides within a granted root. // If the file doesn't exist, it's only allowed if it resides within a granted root.
const isInherited = writableRoots.some((root) => const isInherited = writableRoots.some((root) =>
isSubpath(root, resolved), isSubpath(root, writePath),
); );
if (!isInherited) { if (!isInherited) {
throw new Error( throw new Error(
`Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${resolved}. ` + `Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${writePath}. ` +
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.', 'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
); );
} }
@@ -350,9 +350,9 @@ export class WindowsSandboxManager implements SandboxManager {
// processes to ensure they cannot be read or written. // processes to ensure they cannot be read or written.
const secretsToBlock: string[] = []; const secretsToBlock: string[] = [];
const searchDirs = new Set([ const searchDirs = new Set([
this.options.workspace, resolvedPaths.workspace.resolved,
...allowedPaths, ...resolvedPaths.policyAllowed,
...includeDirs, ...resolvedPaths.globalIncludes,
]); ]);
for (const dir of searchDirs) { for (const dir of searchDirs) {
try { try {
@@ -382,7 +382,7 @@ export class WindowsSandboxManager implements SandboxManager {
// is restricted to avoid host corruption. External commands rely on // is restricted to avoid host corruption. External commands rely on
// Low Integrity read/write restrictions, while internal commands // Low Integrity read/write restrictions, while internal commands
// use the manifest for enforcement. // use the manifest for enforcement.
for (const forbiddenPath of forbiddenPaths) { for (const forbiddenPath of resolvedPaths.forbidden) {
try { try {
await this.denyLowIntegrityAccess(forbiddenPath); await this.denyLowIntegrityAccess(forbiddenPath);
} catch (e) { } catch (e) {
@@ -398,14 +398,14 @@ export class WindowsSandboxManager implements SandboxManager {
// the sandboxed process from creating them with Low integrity. // the sandboxed process from creating them with Low integrity.
// By being created as Medium integrity, they are write-protected from Low processes. // By being created as Medium integrity, they are write-protected from Low processes.
for (const file of GOVERNANCE_FILES) { for (const file of GOVERNANCE_FILES) {
const filePath = path.join(this.options.workspace, file.path); const filePath = path.join(resolvedPaths.workspace.resolved, file.path);
this.touch(filePath, file.isDirectory); this.touch(filePath, file.isDirectory);
} }
// 4. Forbidden paths manifest // 4. Forbidden paths manifest
// We use a manifest file to avoid command-line length limits. // We use a manifest file to avoid command-line length limits.
const allForbidden = Array.from( const allForbidden = Array.from(
new Set([...secretsToBlock, ...forbiddenPaths]), new Set([...secretsToBlock, ...resolvedPaths.forbidden]),
); );
const tempDir = fs.mkdtempSync( const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-forbidden-'), path.join(os.tmpdir(), 'gemini-cli-forbidden-'),
@@ -475,14 +475,19 @@ export class WindowsSandboxManager implements SandboxManager {
} }
try { try {
const stats = await fs.promises.stat(resolvedPath);
const isDirectory = stats.isDirectory();
const flags = isDirectory ? DIRECTORY_FLAGS : '';
// 1. Grant explicit Modify access to the Low Integrity SID // 1. Grant explicit Modify access to the Low Integrity SID
// 2. Set the Mandatory Label to Low to allow "Write Up" from Low processes // 2. Set the Mandatory Label to Low to allow "Write Up" from Low processes
await spawnAsync('icacls', [ await spawnAsync('icacls', [
resolvedPath, resolvedPath,
'/grant', '/grant',
`${LOW_INTEGRITY_SID}:(OI)(CI)(M)`, `${LOW_INTEGRITY_SID}:${flags}(M)`,
'/setintegritylevel', '/setintegritylevel',
'(OI)(CI)Low', `${flags}Low`,
]); ]);
this.allowedCache.add(resolvedPath); this.allowedCache.add(resolvedPath);
} catch (e) { } catch (e) {
@@ -512,29 +517,26 @@ export class WindowsSandboxManager implements SandboxManager {
return; return;
} }
// icacls flags: (OI) Object Inherit, (CI) Container Inherit, (F) Full Access Deny.
// Omit /T (recursive) for performance; (OI)(CI) ensures inheritance for new items.
// Windows dynamically evaluates existing items, though deep explicit Allow ACEs
// could potentially bypass this inherited Deny rule.
const DENY_ALL_INHERIT = '(OI)(CI)(F)';
// icacls fails on non-existent paths, so we cannot explicitly deny // icacls fails on non-existent paths, so we cannot explicitly deny
// paths that do not yet exist (unlike macOS/Linux). // paths that do not yet exist (unlike macOS/Linux).
// Skip to prevent sandbox initialization failure. // Skip to prevent sandbox initialization failure.
let isDirectory = false;
try { try {
await fs.promises.stat(resolvedPath); const stats = await fs.promises.stat(resolvedPath);
isDirectory = stats.isDirectory();
} catch (e: unknown) { } catch (e: unknown) {
if (isNodeError(e) && e.code === 'ENOENT') { if (isNodeError(e) && e.code === 'ENOENT') {
return; return;
} }
throw e; throw e;
} }
const flags = isDirectory ? DIRECTORY_FLAGS : '';
try { try {
await spawnAsync('icacls', [ await spawnAsync('icacls', [
resolvedPath, resolvedPath,
'/deny', '/deny',
`${LOW_INTEGRITY_SID}:${DENY_ALL_INHERIT}`, `${LOW_INTEGRITY_SID}:${flags}(F)`,
]); ]);
this.deniedCache.add(resolvedPath); this.deniedCache.add(resolvedPath);
} catch (e) { } catch (e) {
@@ -1,4 +1,4 @@
/** /**
* @license * @license
* Copyright 2026 Google LLC * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
@@ -8,11 +8,10 @@ import { createSandboxManager } from './sandboxManagerFactory.js';
import { ShellExecutionService } from './shellExecutionService.js'; import { ShellExecutionService } from './shellExecutionService.js';
import { getSecureSanitizationConfig } from './environmentSanitization.js'; import { getSecureSanitizationConfig } from './environmentSanitization.js';
import { import {
type SandboxManager,
type SandboxedCommand, type SandboxedCommand,
NoopSandboxManager,
LocalSandboxManager,
} from './sandboxManager.js'; } from './sandboxManager.js';
import { execFile, execSync } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import os from 'node:os'; import os from 'node:os';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -20,49 +19,59 @@ import path from 'node:path';
import http from 'node:http'; import http from 'node:http';
/** /**
* Abstracts platform-specific shell commands for integration testing. * Cross-platform command wrappers using Node.js inline scripts.
* Ensures consistent execution behavior and reliable exit codes across
* different host operating systems and restricted sandbox environments.
*/ */
const Platform = { const Platform = {
isWindows: os.platform() === 'win32', isWindows: os.platform() === 'win32',
isMac: os.platform() === 'darwin',
/** Returns a command to create an empty file. */ /** Returns a command to create an empty file. */
touch(filePath: string) { touch(filePath: string) {
return this.isWindows return {
? { command: process.execPath,
command: 'powershell.exe', args: [
args: [ '-e',
'-NoProfile', `require("node:fs").writeFileSync(${JSON.stringify(filePath)}, "")`,
'-Command', ],
`New-Item -Path "${filePath}" -ItemType File -Force`, };
],
}
: { command: 'touch', args: [filePath] };
}, },
/** Returns a command to read a file's content. */ /** Returns a command to read a file's content. */
cat(filePath: string) { cat(filePath: string) {
return this.isWindows return {
? { command: 'cmd.exe', args: ['/c', `type "${filePath}"`] } command: process.execPath,
: { command: 'cat', args: [filePath] }; args: [
'-e',
`console.log(require("node:fs").readFileSync(${JSON.stringify(filePath)}, "utf8"))`,
],
};
}, },
/** Returns a command to echo a string. */ /** Returns a command to echo a string. */
echo(text: string) { echo(text: string) {
return this.isWindows return {
? { command: 'cmd.exe', args: ['/c', `echo ${text}`] } command: process.execPath,
: { command: 'echo', args: [text] }; args: ['-e', `console.log(${JSON.stringify(text)})`],
};
}, },
/** Returns a command to perform a network request. */ /** Returns a command to perform a network request. */
curl(url: string) { curl(url: string) {
return { command: 'curl', args: ['-s', '--connect-timeout', '1', url] }; return {
command: process.execPath,
args: [
'-e',
`require("node:http").get(${JSON.stringify(url)}, (res) => { res.on("data", (d) => process.stdout.write(d)); res.on("end", () => process.exit(0)); }).on("error", () => process.exit(1));`,
],
};
}, },
/** Returns a command that checks if the current terminal is interactive. */ /** Returns a command that checks if the current terminal is interactive. */
isPty() { isPty() {
return this.isWindows // ShellExecutionService.execute expects a raw shell string
? 'powershell.exe -NoProfile -Command "echo True"' return `"${process.execPath}" -e "console.log(process.stdout.isTTY ? 'True' : 'False')"`;
: 'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"';
}, },
/** Returns a path that is strictly outside the workspace and likely blocked. */ /** Returns a path that is strictly outside the workspace and likely blocked. */
@@ -96,462 +105,465 @@ async function runCommand(command: SandboxedCommand) {
} }
/** /**
* Determines if the system has the necessary binaries to run the sandbox. * Asserts the result of a sandboxed command execution, and provides detailed
* Throws an error if a supported platform is missing its required tools. * diagnostics on failure.
*/ */
function ensureSandboxAvailable(): boolean { function assertResult(
const platform = os.platform(); result: { status: number; stdout: string; stderr: string },
command: SandboxedCommand,
expected: 'success' | 'failure',
) {
const isSuccess = result.status === 0;
const shouldBeSuccess = expected === 'success';
if (platform === 'win32') { if (isSuccess === shouldBeSuccess) {
// Windows sandboxing relies on icacls, which is a core system utility and if (shouldBeSuccess) {
// always available. expect(result.status).toBe(0);
// TODO: reenable once flakiness is addressed } else {
return false; expect(result.status).not.toBe(0);
}
if (platform === 'darwin') {
if (fs.existsSync('/usr/bin/sandbox-exec')) {
try {
execSync('sandbox-exec -p "(version 1)(allow default)" echo test', {
stdio: 'ignore',
});
return true;
} catch {
// eslint-disable-next-line no-console
console.warn(
'sandbox-exec is present but cannot be used (likely running inside a sandbox already). Skipping sandbox tests.',
);
return false;
}
} }
throw new Error( return;
'Sandboxing tests on macOS require /usr/bin/sandbox-exec to be present.',
);
} }
if (platform === 'linux') { const commandLine = `${command.program} ${command.args.join(' ')}`;
try { const message = `Command ${
execSync('which bwrap', { stdio: 'ignore' }); shouldBeSuccess ? 'failed' : 'succeeded'
return true; } unexpectedly.
} catch { Command: ${commandLine}
throw new Error( CWD: ${command.cwd || 'N/A'}
'Sandboxing tests on Linux require bubblewrap (bwrap) to be installed.', Status: ${result.status} (expected ${expected})${
); result.stdout ? `\nStdout: ${result.stdout.trim()}` : ''
} }${result.stderr ? `\nStderr: ${result.stderr.trim()}` : ''}`;
}
return false; throw new Error(message);
} }
describe('SandboxManager Integration', () => { describe('SandboxManager Integration', () => {
const workspace = process.cwd(); const tempDirectories: string[] = [];
const manager = createSandboxManager({ enabled: true }, { workspace });
// Skip if we are on an unsupported platform or if it's a NoopSandboxManager /**
const shouldSkip = * Creates a temporary directory.
manager instanceof NoopSandboxManager || * - macOS: Created in process.cwd() to avoid the seatbelt profile's global os.tmpdir() whitelist.
manager instanceof LocalSandboxManager || * - Win/Linux: Created in os.tmpdir() because enforcing sandbox restrictions inside a large directory can be very slow.
!ensureSandboxAvailable(); */
function createTempDir(prefix = 'gemini-sandbox-test-'): string {
const baseDir = Platform.isMac
? path.join(process.cwd(), `.${prefix}`)
: path.join(os.tmpdir(), prefix);
describe.skipIf(shouldSkip)('Cross-platform Sandbox Behavior', () => { const dir = fs.mkdtempSync(baseDir);
describe('Basic Execution', () => { tempDirectories.push(dir);
it('executes commands within the workspace', async () => { return dir;
const { command, args } = Platform.echo('sandbox test'); }
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed); let workspace: string;
expect(result.status).toBe(0); let manager: SandboxManager;
expect(result.stdout.trim()).toBe('sandbox test');
beforeAll(() => {
workspace = createTempDir('workspace-');
manager = createSandboxManager({ enabled: true }, { workspace });
});
afterAll(() => {
for (const dir of tempDirectories) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
});
describe('Basic Execution', () => {
it('executes commands within the workspace', async () => {
const { command, args } = Platform.echo('sandbox test');
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
}); });
// The Windows sandbox wrapper (GeminiSandbox.exe) uses standard pipes const result = await runCommand(sandboxed);
// for I/O interception, which breaks ConPTY pseudo-terminal inheritance. assertResult(result, sandboxed, 'success');
it.skipIf(Platform.isWindows)( expect(result.stdout.trim()).toBe('sandbox test');
'supports interactive pseudo-terminals (node-pty)',
async () => {
const handle = await ShellExecutionService.execute(
Platform.isPty(),
workspace,
() => {},
new AbortController().signal,
true,
{
sanitizationConfig: getSecureSanitizationConfig(),
sandboxManager: manager,
},
);
const result = await handle.result;
expect(result.exitCode).toBe(0);
expect(result.output).toContain('True');
},
);
}); });
describe('File System Access', () => { // The Windows sandbox wrapper (GeminiSandbox.exe) uses standard pipes
it('blocks access outside the workspace', async () => { // for I/O interception, which breaks ConPTY pseudo-terminal inheritance.
const blockedPath = Platform.getExternalBlockedPath(); it.skipIf(Platform.isWindows)(
const { command, args } = Platform.touch(blockedPath); 'supports interactive pseudo-terminals (node-pty)',
async () => {
const handle = await ShellExecutionService.execute(
Platform.isPty(),
workspace,
() => {},
new AbortController().signal,
true,
{
sanitizationConfig: getSecureSanitizationConfig(),
sandboxManager: manager,
},
);
const sandboxed = await manager.prepareCommand({ const result = await handle.result;
command, expect(result.exitCode).toBe(0);
args, expect(result.output).toContain('True');
cwd: workspace, },
env: process.env, );
}); });
const result = await runCommand(sandboxed); describe('File System Access', () => {
expect(result.status).not.toBe(0); it('blocks access outside the workspace', async () => {
const blockedPath = Platform.getExternalBlockedPath();
const { command, args } = Platform.touch(blockedPath);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
}); });
it('allows dynamic expansion of permissions after a failure', async () => { const result = await runCommand(sandboxed);
const tempDir = fs.mkdtempSync( assertResult(result, sandboxed, 'failure');
path.join(workspace, '..', 'expansion-'), });
);
const testFile = path.join(tempDir, 'test.txt');
try { it('allows dynamic expansion of permissions after a failure', async () => {
const { command, args } = Platform.touch(testFile); const tempDir = createTempDir('expansion-');
const testFile = path.join(tempDir, 'test.txt');
const { command, args } = Platform.touch(testFile);
// First attempt: fails due to sandbox restrictions // First attempt: fails due to sandbox restrictions
const sandboxed1 = await manager.prepareCommand({ const sandboxed1 = await manager.prepareCommand({
command, command,
args, args,
cwd: workspace, cwd: workspace,
env: process.env, env: process.env,
}); });
const result1 = await runCommand(sandboxed1); const result1 = await runCommand(sandboxed1);
expect(result1.status).not.toBe(0); assertResult(result1, sandboxed1, 'failure');
expect(fs.existsSync(testFile)).toBe(false); expect(fs.existsSync(testFile)).toBe(false);
// Second attempt: succeeds with additional permissions // Second attempt: succeeds with additional permissions
const sandboxed2 = await manager.prepareCommand({ const sandboxed2 = await manager.prepareCommand({
command, command,
args, args,
cwd: workspace, cwd: workspace,
env: process.env, env: process.env,
policy: { allowedPaths: [tempDir] }, policy: { allowedPaths: [tempDir] },
}); });
const result2 = await runCommand(sandboxed2); const result2 = await runCommand(sandboxed2);
expect(result2.status).toBe(0); assertResult(result2, sandboxed2, 'success');
expect(fs.existsSync(testFile)).toBe(true); expect(fs.existsSync(testFile)).toBe(true);
} finally { });
if (fs.existsSync(testFile)) fs.unlinkSync(testFile);
fs.rmSync(tempDir, { recursive: true, force: true }); it('grants access to explicitly allowed paths', async () => {
} const allowedDir = createTempDir('allowed-');
const testFile = path.join(allowedDir, 'test.txt');
const { command, args } = Platform.touch(testFile);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { allowedPaths: [allowedDir] },
}); });
it('grants access to explicitly allowed paths', async () => { const result = await runCommand(sandboxed);
const allowedDir = fs.mkdtempSync( assertResult(result, sandboxed, 'success');
path.join(workspace, '..', 'allowed-'), expect(fs.existsSync(testFile)).toBe(true);
); });
const testFile = path.join(allowedDir, 'test.txt');
try { it('blocks write access to forbidden paths within the workspace', async () => {
const { command, args } = Platform.touch(testFile); const tempWorkspace = createTempDir('workspace-');
const sandboxed = await manager.prepareCommand({ const forbiddenDir = path.join(tempWorkspace, 'forbidden');
command, const testFile = path.join(forbiddenDir, 'test.txt');
args, fs.mkdirSync(forbiddenDir);
cwd: workspace,
env: process.env,
policy: { allowedPaths: [allowedDir] },
});
const result = await runCommand(sandboxed); const osManager = createSandboxManager(
expect(result.status).toBe(0); { enabled: true },
expect(fs.existsSync(testFile)).toBe(true); {
} finally { workspace: tempWorkspace,
if (fs.existsSync(testFile)) fs.unlinkSync(testFile); forbiddenPaths: async () => [forbiddenDir],
fs.rmSync(allowedDir, { recursive: true, force: true }); },
} );
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
}); });
it('blocks access to forbidden paths within the workspace', async () => { const result = await runCommand(sandboxed);
const tempWorkspace = fs.mkdtempSync( assertResult(result, sandboxed, 'failure');
path.join(os.tmpdir(), 'workspace-'), });
);
// Windows icacls does not reliably block read-up access for Low Integrity
// processes, so we skip read-specific assertions on Windows. The internal
// tool architecture prevents read bypasses via the C# wrapper and __read.
it.skipIf(Platform.isWindows)(
'blocks read access to forbidden paths within the workspace',
async () => {
const tempWorkspace = createTempDir('workspace-');
const forbiddenDir = path.join(tempWorkspace, 'forbidden'); const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const testFile = path.join(forbiddenDir, 'test.txt'); const testFile = path.join(forbiddenDir, 'test.txt');
fs.mkdirSync(forbiddenDir); fs.mkdirSync(forbiddenDir);
fs.writeFileSync(testFile, 'secret data');
try { const osManager = createSandboxManager(
const osManager = createSandboxManager( { enabled: true },
{ enabled: true }, {
{ workspace: tempWorkspace,
workspace: tempWorkspace, forbiddenPaths: async () => [forbiddenDir],
forbiddenPaths: async () => [forbiddenDir], },
},
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('blocks access to files inside forbidden directories recursively', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
); );
const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const nestedDir = path.join(forbiddenDir, 'nested');
const nestedFile = path.join(nestedDir, 'test.txt');
fs.mkdirSync(nestedDir, { recursive: true }); const { command, args } = Platform.cat(testFile);
fs.writeFileSync(nestedFile, 'secret');
try { const sandboxed = await osManager.prepareCommand({
const osManager = createSandboxManager( command,
{ enabled: true }, args,
{ cwd: tempWorkspace,
workspace: tempWorkspace, env: process.env,
forbiddenPaths: async () => [forbiddenDir],
},
);
const { command, args } = Platform.cat(nestedFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('prioritizes forbiddenPaths over allowedPaths', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const conflictDir = path.join(tempWorkspace, 'conflict');
const testFile = path.join(conflictDir, 'test.txt');
fs.mkdirSync(conflictDir);
try {
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [conflictDir],
},
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [conflictDir],
},
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('gracefully ignores non-existent paths in allowedPaths and forbiddenPaths', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const nonExistentPath = path.join(tempWorkspace, 'does-not-exist');
try {
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [nonExistentPath],
},
);
const { command, args } = Platform.echo('survived');
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [nonExistentPath],
},
});
const result = await runCommand(sandboxed);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('survived');
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('prevents creation of non-existent forbidden paths', async () => {
// Windows icacls cannot explicitly protect paths that have not yet been created.
if (Platform.isWindows) return;
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const nonExistentFile = path.join(tempWorkspace, 'never-created.txt');
try {
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [nonExistentFile],
},
);
// We use touch to attempt creation of the file
const { command: cmdTouch, args: argsTouch } =
Platform.touch(nonExistentFile);
const sandboxedCmd = await osManager.prepareCommand({
command: cmdTouch,
args: argsTouch,
cwd: tempWorkspace,
env: process.env,
});
// Execute the command, we expect it to fail (permission denied or read-only file system)
const result = await runCommand(sandboxedCmd);
expect(result.status).not.toBe(0);
expect(fs.existsSync(nonExistentFile)).toBe(false);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('blocks access to both a symlink and its target when the symlink is forbidden', async () => {
if (Platform.isWindows) return;
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const targetFile = path.join(tempWorkspace, 'target.txt');
const symlinkFile = path.join(tempWorkspace, 'link.txt');
fs.writeFileSync(targetFile, 'secret data');
fs.symlinkSync(targetFile, symlinkFile);
try {
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [symlinkFile],
},
);
// Attempt to read the target file directly
const { command: cmdTarget, args: argsTarget } =
Platform.cat(targetFile);
const commandTarget = await osManager.prepareCommand({
command: cmdTarget,
args: argsTarget,
cwd: tempWorkspace,
env: process.env,
});
const resultTarget = await runCommand(commandTarget);
expect(resultTarget.status).not.toBe(0);
// Attempt to read via the symlink
const { command: cmdLink, args: argsLink } =
Platform.cat(symlinkFile);
const commandLink = await osManager.prepareCommand({
command: cmdLink,
args: argsLink,
cwd: tempWorkspace,
env: process.env,
});
const resultLink = await runCommand(commandLink);
expect(resultLink.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
});
describe('Network Access', () => {
let server: http.Server;
let url: string;
beforeAll(async () => {
server = http.createServer((_, res) => {
res.setHeader('Connection', 'close');
res.writeHead(200);
res.end('ok');
}); });
await new Promise<void>((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as import('net').AddressInfo;
url = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
});
afterAll(async () => { const result = await runCommand(sandboxed);
if (server) await new Promise<void>((res) => server.close(() => res())); assertResult(result, sandboxed, 'failure');
}); },
);
// Windows Job Object rate limits exempt loopback (127.0.0.1) traffic, it('blocks access to files inside forbidden directories recursively', async () => {
// so this test cannot verify loopback blocking on Windows. const tempWorkspace = createTempDir('workspace-');
it.skipIf(Platform.isWindows)( const forbiddenDir = path.join(tempWorkspace, 'forbidden');
'blocks network access by default', const nestedDir = path.join(forbiddenDir, 'nested');
async () => { const nestedFile = path.join(nestedDir, 'test.txt');
const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed); // Create the base forbidden directory first so the manager can restrict access to it.
expect(result.status).not.toBe(0); fs.mkdirSync(forbiddenDir);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [forbiddenDir],
}, },
); );
it('grants network access when explicitly allowed', async () => { // Execute a dummy command so the manager initializes its restrictions.
const dummyCommand = await osManager.prepareCommand({
...Platform.echo('init'),
cwd: tempWorkspace,
env: process.env,
});
await runCommand(dummyCommand);
// Now create the nested items. They will inherit the sandbox restrictions from their parent.
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(nestedFile, 'secret');
const { command, args } = Platform.touch(nestedFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
});
it('prioritizes forbiddenPaths over allowedPaths', async () => {
const tempWorkspace = createTempDir('workspace-');
const conflictDir = path.join(tempWorkspace, 'conflict');
const testFile = path.join(conflictDir, 'test.txt');
fs.mkdirSync(conflictDir);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [conflictDir],
},
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [conflictDir],
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
});
it('gracefully ignores non-existent paths in allowedPaths and forbiddenPaths', async () => {
const tempWorkspace = createTempDir('workspace-');
const nonExistentPath = path.join(tempWorkspace, 'does-not-exist');
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [nonExistentPath],
},
);
const { command, args } = Platform.echo('survived');
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [nonExistentPath],
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(result.stdout.trim()).toBe('survived');
});
it('prevents creation of non-existent forbidden paths', async () => {
const tempWorkspace = createTempDir('workspace-');
const nonExistentFile = path.join(tempWorkspace, 'never-created.txt');
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [nonExistentFile],
},
);
// We use touch to attempt creation of the file
const { command: cmdTouch, args: argsTouch } =
Platform.touch(nonExistentFile);
const sandboxedCmd = await osManager.prepareCommand({
command: cmdTouch,
args: argsTouch,
cwd: tempWorkspace,
env: process.env,
});
// Execute the command, we expect it to fail (permission denied or read-only file system)
const result = await runCommand(sandboxedCmd);
assertResult(result, sandboxedCmd, 'failure');
expect(fs.existsSync(nonExistentFile)).toBe(false);
});
it('blocks access to both a symlink and its target when the symlink is forbidden', async () => {
const tempWorkspace = createTempDir('workspace-');
const targetFile = path.join(tempWorkspace, 'target.txt');
const symlinkFile = path.join(tempWorkspace, 'link.txt');
fs.writeFileSync(targetFile, 'secret data');
fs.symlinkSync(targetFile, symlinkFile);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [symlinkFile],
},
);
// Attempt to write to the target file directly
const { command: cmdTarget, args: argsTarget } =
Platform.touch(targetFile);
const commandTarget = await osManager.prepareCommand({
command: cmdTarget,
args: argsTarget,
cwd: tempWorkspace,
env: process.env,
});
const resultTarget = await runCommand(commandTarget);
assertResult(resultTarget, commandTarget, 'failure');
// Attempt to write via the symlink
const { command: cmdLink, args: argsLink } = Platform.touch(symlinkFile);
const commandLink = await osManager.prepareCommand({
command: cmdLink,
args: argsLink,
cwd: tempWorkspace,
env: process.env,
});
const resultLink = await runCommand(commandLink);
assertResult(resultLink, commandLink, 'failure');
});
});
describe('Network Access', () => {
let server: http.Server;
let url: string;
beforeAll(async () => {
server = http.createServer((_, res) => {
res.setHeader('Connection', 'close');
res.writeHead(200);
res.end('ok');
});
await new Promise<void>((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as import('net').AddressInfo;
url = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
});
afterAll(async () => {
if (server) await new Promise<void>((res) => server.close(() => res()));
});
// Windows Job Object rate limits exempt loopback (127.0.0.1) traffic,
// so this test cannot verify loopback blocking on Windows.
it.skipIf(Platform.isWindows)(
'blocks network access by default',
async () => {
const { command, args } = Platform.curl(url); const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({ const sandboxed = await manager.prepareCommand({
command, command,
args, args,
cwd: workspace, cwd: workspace,
env: process.env, env: process.env,
policy: { networkAccess: true },
}); });
const result = await runCommand(sandboxed); const result = await runCommand(sandboxed);
expect(result.status).toBe(0); assertResult(result, sandboxed, 'failure');
if (!Platform.isWindows) { },
expect(result.stdout.trim()).toBe('ok'); );
}
it('grants network access when explicitly allowed', async () => {
const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { networkAccess: true },
}); });
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
if (!Platform.isWindows) {
expect(result.stdout.trim()).toBe('ok');
}
}); });
}); });
}); });
@@ -204,7 +204,7 @@ describe('SandboxManager', () => {
const result = await resolveSandboxPaths(options, req as SandboxRequest); const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.allowed).toEqual([allowed]); expect(result.policyAllowed).toEqual([allowed]);
expect(result.forbidden).toEqual([forbidden]); expect(result.forbidden).toEqual([forbidden]);
}); });
@@ -226,7 +226,7 @@ describe('SandboxManager', () => {
const result = await resolveSandboxPaths(options, req as SandboxRequest); const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.allowed).toEqual([other]); expect(result.policyAllowed).toEqual([other]);
}); });
it('should prioritize forbidden paths over allowed paths', async () => { it('should prioritize forbidden paths over allowed paths', async () => {
@@ -249,7 +249,7 @@ describe('SandboxManager', () => {
const result = await resolveSandboxPaths(options, req as SandboxRequest); const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.allowed).toEqual([normal]); expect(result.policyAllowed).toEqual([normal]);
expect(result.forbidden).toEqual([secret]); expect(result.forbidden).toEqual([secret]);
}); });
@@ -274,7 +274,7 @@ describe('SandboxManager', () => {
const result = await resolveSandboxPaths(options, req as SandboxRequest); const result = await resolveSandboxPaths(options, req as SandboxRequest);
expect(result.allowed).toEqual([]); expect(result.policyAllowed).toEqual([]);
expect(result.forbidden).toEqual([secretUpper]); expect(result.forbidden).toEqual([secretUpper]);
}); });
}); });
+77 -15
View File
@@ -23,6 +23,33 @@ import {
} from './environmentSanitization.js'; } from './environmentSanitization.js';
import type { ShellExecutionResult } from './shellExecutionService.js'; import type { ShellExecutionResult } from './shellExecutionService.js';
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js'; import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { resolveToRealPath } from '../utils/paths.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[];
}
export interface SandboxPermissions { export interface SandboxPermissions {
/** Filesystem permissions. */ /** Filesystem permissions. */
fileSystem?: { fileSystem?: {
@@ -326,33 +353,68 @@ export class LocalSandboxManager implements SandboxManager {
} }
/** /**
* Resolves sanitized allowed and forbidden paths for a request. * Resolves and sanitizes all path categories for a sandbox request.
* Filters the workspace from allowed paths and ensures forbidden paths take precedence.
*/ */
export async function resolveSandboxPaths( export async function resolveSandboxPaths(
options: GlobalSandboxOptions, options: GlobalSandboxOptions,
req: SandboxRequest, req: SandboxRequest,
): Promise<{ overridePermissions?: SandboxPermissions,
allowed: string[]; ): Promise<ResolvedSandboxPaths> {
forbidden: string[]; /**
}> { * Helper that expands each path to include its realpath (if it's a symlink)
const forbidden = sanitizePaths(await options.forbiddenPaths?.()); * and pipes the result through sanitizePaths for deduplication and absolute path enforcement.
const allowed = sanitizePaths(req.policy?.allowedPaths); */
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 workspaceIdentity = getPathIdentity(options.workspace); 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 forbiddenIdentities = new Set(forbidden.map(getPathIdentity));
const filteredAllowed = allowed.filter((p) => { /**
const identity = getPathIdentity(p); * Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
return identity !== workspaceIdentity && !forbiddenIdentities.has(identity); */
}); const filter = (paths: string[]) =>
paths.filter((p) => {
const identity = getPathIdentity(p);
return (
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
);
});
return { return {
allowed: filteredAllowed, workspace: {
original: options.workspace,
resolved: resolvedWorkspace,
},
forbidden, forbidden,
globalIncludes: filter(globalIncludes),
policyAllowed: filter(policyAllowed),
policyRead: filter(policyRead),
policyWrite: filter(policyWrite),
}; };
} }
/** /**
* Sanitizes an array of paths by deduplicating them and ensuring they are absolute. * Sanitizes an array of paths by deduplicating them and ensuring they are absolute.
* Always returns an array (empty if input is null/undefined). * Always returns an array (empty if input is null/undefined).