feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 (#23691)

This commit is contained in:
Tommaso Sciortino
2026-03-25 17:54:45 +00:00
committed by GitHub
parent f11bd3d079
commit 1b052df52f
18 changed files with 1168 additions and 528 deletions
@@ -99,12 +99,25 @@ function touch(filePath: string, isDirectory: boolean) {
}
}
import {
isKnownSafeCommand,
isDangerousCommand,
} from '../macos/commandSafety.js';
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
export class LinuxSandboxManager implements SandboxManager {
constructor(private readonly options: GlobalSandboxOptions) {}
isKnownSafeCommand(args: string[]): boolean {
return isKnownSafeCommand(args);
}
isDangerousCommand(args: string[]): boolean {
return isDangerousCommand(args);
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
@@ -69,7 +69,7 @@ describe('MacOsSandboxManager', () => {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
forbiddenPaths: undefined,
workspaceWrite: false,
workspaceWrite: true,
additionalPermissions: {
fileSystem: {
read: [],
@@ -14,23 +14,20 @@ import {
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import {
getCommandRoots,
initializeShellParsers,
splitCommands,
stripShellWrapper,
getCommandName,
} from '../../utils/shell-utils.js';
import { isKnownSafeCommand } from './commandSafety.js';
import { parse as shellParse } from 'shell-quote';
import {
isKnownSafeCommand,
isDangerousCommand,
isStrictlyApproved,
} from './commandSafety.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import path from 'node:path';
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
/** Optional base sanitization config. */
sanitizationConfig?: EnvironmentSanitizationConfig;
/** The current sandbox mode behavior from config. */
modeConfig?: {
readonly?: boolean;
@@ -48,52 +45,17 @@ export interface MacOsSandboxOptions extends GlobalSandboxOptions {
export class MacOsSandboxManager implements SandboxManager {
constructor(private readonly options: MacOsSandboxOptions) {}
private async isStrictlyApproved(req: SandboxRequest): Promise<boolean> {
const approvedTools = this.options.modeConfig?.approvedTools;
if (!approvedTools || approvedTools.length === 0) {
return false;
}
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped);
if (roots.length === 0) return false;
const allRootsApproved = roots.every((root) =>
approvedTools.includes(root),
);
if (allRootsApproved) {
isKnownSafeCommand(args: string[]): boolean {
const toolName = args[0];
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
if (toolName && approvedTools.includes(toolName)) {
return true;
}
const pipelineCommands = splitCommands(stripped);
if (pipelineCommands.length === 0) return false;
// For safety, every command in the pipeline must be considered safe.
for (const cmdString of pipelineCommands) {
const parsedArgs = shellParse(cmdString).map(String);
if (!isKnownSafeCommand(parsedArgs)) {
return false;
}
}
return true;
return isKnownSafeCommand(args);
}
private async getCommandName(req: SandboxRequest): Promise<string> {
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
if (roots.length > 0) {
return roots[0];
}
return path.basename(req.command);
isDangerousCommand(args: string[]): boolean {
return isDangerousCommand(args);
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
@@ -122,15 +84,19 @@ export class MacOsSandboxManager implements SandboxManager {
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? await this.isStrictlyApproved(req)
? await isStrictlyApproved(
req.command,
req.args,
this.options.modeConfig?.approvedTools,
)
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
// Fetch persistent approvals for this command
const commandName = await this.getCommandName(req);
const commandName = await getCommandName(req.command, req.args);
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
@@ -148,7 +114,7 @@ export class MacOsSandboxManager implements SandboxManager {
],
},
network:
networkAccess ||
defaultNetwork ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
@@ -4,6 +4,57 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { parse as shellParse } from 'shell-quote';
import {
extractStringFromParseEntry,
initializeShellParsers,
splitCommands,
stripShellWrapper,
} from '../../utils/shell-utils.js';
/**
* Determines if a command is strictly approved for execution on macOS.
* A command is approved if it's composed entirely of tools explicitly listed in `approvedTools`
* OR if it's composed of known safe, read-only POSIX commands.
*
* @param command - The full command string to execute.
* @param args - The arguments for the command.
* @param approvedTools - A list of explicitly approved tool names (e.g., ['npm', 'git']).
* @returns true if the command is strictly approved, false otherwise.
*/
export async function isStrictlyApproved(
command: string,
args: string[],
approvedTools?: string[],
): Promise<boolean> {
const tools = approvedTools ?? [];
await initializeShellParsers();
const fullCmd = [command, ...args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const pipelineCommands = splitCommands(stripped);
// Fallback for simple commands or parsing failures
if (pipelineCommands.length === 0) {
// For simple commands, we check the root command.
// If it's explicitly approved OR it's a known safe POSIX command, we allow it.
return tools.includes(command) || isKnownSafeCommand([command, ...args]);
}
// Check every segment of the pipeline
return pipelineCommands.every((cmdString) => {
const trimmed = cmdString.trim();
if (!trimmed) return true;
const parsedArgs = shellParse(trimmed).map(extractStringFromParseEntry);
if (parsedArgs.length === 0) return true;
const root = parsedArgs[0];
// The segment is approved if the root tool is in the allowlist OR if the whole segment is safe.
return tools.includes(root) || isKnownSafeCommand(parsedArgs);
});
}
/**
* Checks if a command with its arguments is known to be safe to execute
@@ -45,25 +96,18 @@ export function isKnownSafeCommand(args: string[]): boolean {
return false;
}
const commands = script.split(/&&|\|\||\||;/);
const commands = splitCommands(script);
if (commands.length === 0) return false;
let allSafe = true;
for (const cmd of commands) {
return commands.every((cmd) => {
const trimmed = cmd.trim();
if (!trimmed) continue;
if (!trimmed) return true;
const parsed = shellParse(trimmed).map(String);
if (parsed.length === 0) continue;
const parsed = shellParse(trimmed).map(extractStringFromParseEntry);
if (parsed.length === 0) return true;
if (!isSafeToCallWithExec(parsed)) {
allSafe = false;
break;
}
}
if (allSafe && commands.length > 0) {
return true;
}
return isSafeToCallWithExec(parsed);
});
} catch {
return false;
}
@@ -12,10 +12,18 @@ import { WindowsSandboxManager } from './WindowsSandboxManager.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
vi.mock('../../utils/shell-utils.js', () => ({
spawnAsync: vi.fn(),
}));
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/shell-utils.js')>();
return {
...actual,
spawnAsync: vi.fn(),
initializeShellParsers: vi.fn(),
isStrictlyApproved: vi.fn().mockResolvedValue(true),
};
});
describe('WindowsSandboxManager', () => {
let manager: WindowsSandboxManager;
@@ -27,7 +35,10 @@ describe('WindowsSandboxManager', () => {
p.toString(),
);
testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
manager = new WindowsSandboxManager({ workspace: testCwd });
manager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: false, allowOverrides: true },
});
});
afterEach(() => {
@@ -35,240 +46,406 @@ describe('WindowsSandboxManager', () => {
fs.rmSync(testCwd, { recursive: true, force: true });
});
describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: testCwd,
env: { TEST_VAR: 'test_value' },
policy: {
networkAccess: false,
it('should prepare a GeminiSandbox.exe command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: testCwd,
env: { TEST_VAR: 'test_value' },
policy: {
networkAccess: false,
},
};
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
});
it('should handle networkAccess from config', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: testCwd,
env: {},
policy: {
networkAccess: true,
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
it('should handle network access from additionalPermissions', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: testCwd,
env: {},
policy: {
additionalPermissions: {
network: true,
},
};
},
};
const result = await manager.prepareCommand(req);
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
it('should reject network access in Plan mode', async () => {
const planManager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: true, allowOverrides: false },
});
const req: SandboxRequest = {
command: 'curl',
args: ['google.com'],
cwd: testCwd,
env: {},
policy: {
additionalPermissions: { network: true },
},
};
await expect(planManager.prepareCommand(req)).rejects.toThrow(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
);
});
it('should handle persistent permissions from policyManager', async () => {
const persistentPath = path.resolve('/persistent/path');
const mockPolicyManager = {
getCommandPermissions: vi.fn().mockReturnValue({
fileSystem: { write: [persistentPath] },
network: true,
}),
} as unknown as SandboxPolicyManager;
const managerWithPolicy = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { allowOverrides: true, network: false },
policyManager: mockPolicyManager,
});
it('should correctly pass through the cwd to the resulting command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: '/different/cwd',
env: {},
};
const req: SandboxRequest = {
command: 'test-cmd',
args: [],
cwd: testCwd,
env: {},
};
const result = await manager.prepareCommand(req);
const result = await managerWithPolicy.prepareCommand(req);
expect(result.args[0]).toBe('1'); // Network allowed by persistent policy
expect(result.cwd).toBe('/different/cwd');
});
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
it('should apply environment sanitization via the default mechanisms', async () => {
expect(icaclsArgs).toContainEqual([
persistentPath,
'/setintegritylevel',
'Low',
]);
});
it('should sanitize environment variables', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
it('should ensure governance files exist', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
};
await manager.prepareCommand(req);
expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true);
expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true);
});
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed');
if (!fs.existsSync(allowedPath)) {
fs.mkdirSync(allowedPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
env: {},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
allowedPaths: [allowedPath],
},
};
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
await manager.prepareCommand(req);
it('should allow network when networkAccess is true', async () => {
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
path.resolve(testCwd),
'/setintegritylevel',
'Low',
]);
expect(icaclsArgs).toContainEqual([
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
}
});
it('should grant Low Integrity access to additional write paths', async () => {
const extraWritePath = path.join(
os.tmpdir(),
'gemini-cli-test-extra-write',
);
if (!fs.existsSync(extraWritePath)) {
fs.mkdirSync(extraWritePath);
}
try {
const req: SandboxRequest = {
command: 'whoami',
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
networkAccess: true,
additionalPermissions: {
fileSystem: {
write: [extraWritePath],
},
},
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
await manager.prepareCommand(req);
describe('governance files', () => {
it('should ensure governance files exist', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
};
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
await manager.prepareCommand(req);
expect(icaclsArgs).toContainEqual([
path.resolve(extraWritePath),
'/setintegritylevel',
'Low',
]);
} finally {
fs.rmSync(extraWritePath, { recursive: true, force: true });
}
});
expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true);
expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(
true,
);
});
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed');
if (!fs.existsSync(allowedPath)) {
fs.mkdirSync(allowedPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
allowedPaths: [allowedPath],
it.runIf(process.platform === 'win32')(
'should reject UNC paths in grantLowIntegrityAccess',
async () => {
const uncPath = '\\\\attacker\\share\\malicious.txt';
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
additionalPermissions: {
fileSystem: {
write: [uncPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(testCwd),
'/setintegritylevel',
'Low',
]);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
}
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
const forbiddenPath = path.join(
os.tmpdir(),
'gemini-cli-test-forbidden',
);
if (!fs.existsSync(forbiddenPath)) {
fs.mkdirSync(forbiddenPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [forbiddenPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(forbiddenPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const missingPath = path.join(
os.tmpdir(),
'gemini-cli-test-missing',
'does-not-exist.txt',
);
// Ensure it definitely doesn't exist
if (fs.existsSync(missingPath)) {
fs.rmSync(missingPath, { recursive: true, force: true });
}
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [missingPath],
},
};
},
};
await manager.prepareCommand(req);
await manager.prepareCommand(req);
// Should NOT have called icacls to deny the missing path
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
path.resolve(missingPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
});
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
it('should override allowed paths if a path is also in forbidden paths', async () => {
const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict');
if (!fs.existsSync(conflictPath)) {
fs.mkdirSync(conflictPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
allowedPaths: [conflictPath],
forbiddenPaths: [conflictPath],
expect(icaclsArgs).not.toContainEqual([
uncPath,
'/setintegritylevel',
'Low',
]);
},
);
it.runIf(process.platform === 'win32')(
'should allow extended-length and local device paths',
async () => {
const longPath = '\\\\?\\C:\\very\\long\\path';
const devicePath = '\\\\.\\PhysicalDrive0';
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
additionalPermissions: {
fileSystem: {
write: [longPath, devicePath],
},
};
},
},
};
await manager.prepareCommand(req);
await manager.prepareCommand(req);
const spawnMock = vi.mocked(spawnAsync);
const allowCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/setintegritylevel') &&
call[0] === 'icacls' &&
call[1][0] === path.resolve(conflictPath),
);
const denyCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/deny') &&
call[0] === 'icacls' &&
call[1][0] === path.resolve(conflictPath),
);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
// Both should have been called
expect(allowCallIndex).toBeGreaterThan(-1);
expect(denyCallIndex).toBeGreaterThan(-1);
expect(icaclsArgs).toContainEqual([
longPath,
'/setintegritylevel',
'Low',
]);
expect(icaclsArgs).toContainEqual([
devicePath,
'/setintegritylevel',
'Low',
]);
},
);
// Verify order: explicitly denying must happen after the explicit allow
expect(allowCallIndex).toBeLessThan(denyCallIndex);
} finally {
fs.rmSync(conflictPath, { recursive: true, force: true });
}
});
});
it('skips denying access to non-existent forbidden paths to prevent icacls failure', async () => {
const missingPath = path.join(
os.tmpdir(),
'gemini-cli-test-missing',
'does-not-exist.txt',
);
// Ensure it definitely doesn't exist
if (fs.existsSync(missingPath)) {
fs.rmSync(missingPath, { recursive: true, force: true });
}
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [missingPath],
},
};
await manager.prepareCommand(req);
// Should NOT have called icacls to deny the missing path
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
path.resolve(missingPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
});
it('should deny Low Integrity access to forbidden paths', async () => {
const forbiddenPath = path.join(os.tmpdir(), 'gemini-cli-test-forbidden');
if (!fs.existsSync(forbiddenPath)) {
fs.mkdirSync(forbiddenPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [forbiddenPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(forbiddenPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict');
if (!fs.existsSync(conflictPath)) {
fs.mkdirSync(conflictPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
allowedPaths: [conflictPath],
forbiddenPaths: [conflictPath],
},
};
await manager.prepareCommand(req);
const spawnMock = vi.mocked(spawnAsync);
const allowCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/setintegritylevel') &&
call[0] === 'icacls' &&
call[1][0] === path.resolve(conflictPath),
);
const denyCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/deny') &&
call[0] === 'icacls' &&
call[1][0] === path.resolve(conflictPath),
);
// Both should have been called
expect(allowCallIndex).toBeGreaterThan(-1);
expect(denyCallIndex).toBeGreaterThan(-1);
// Verify order: explicitly denying must happen after the explicit allow
expect(allowCallIndex).toBeLessThan(denyCallIndex);
} finally {
fs.rmSync(conflictPath, { recursive: true, force: true });
}
});
});
@@ -16,18 +16,37 @@ import {
type GlobalSandboxOptions,
sanitizePaths,
tryRealpath,
type SandboxPermissions,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
import { isNodeError } from '../../utils/errors.js';
import {
isKnownSafeCommand,
isDangerousCommand,
isStrictlyApproved,
} from './commandSafety.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export interface WindowsSandboxOptions extends GlobalSandboxOptions {
/** The current sandbox mode behavior from config. */
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
/** The policy manager for persistent approvals. */
policyManager?: SandboxPolicyManager;
}
/**
* A SandboxManager implementation for Windows that uses Restricted Tokens,
* Job Objects, and Low Integrity levels for process isolation.
@@ -39,10 +58,23 @@ export class WindowsSandboxManager implements SandboxManager {
private readonly allowedCache = new Set<string>();
private readonly deniedCache = new Set<string>();
constructor(private readonly options: GlobalSandboxOptions) {
constructor(private readonly options: WindowsSandboxOptions) {
this.helperPath = path.resolve(__dirname, 'GeminiSandbox.exe');
}
isKnownSafeCommand(args: string[]): boolean {
const toolName = args[0]?.toLowerCase();
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
if (toolName && approvedTools.some((t) => t.toLowerCase() === toolName)) {
return true;
}
return isKnownSafeCommand(args);
}
isDangerousCommand(args: string[]): boolean {
return isDangerousCommand(args);
}
/**
* Ensures a file or directory exists.
*/
@@ -178,9 +210,60 @@ export class WindowsSandboxManager implements SandboxManager {
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
if (!allowOverrides && req.policy?.additionalPermissions) {
const perms = req.policy.additionalPermissions;
if (
perms.network ||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
) {
throw new Error(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
);
}
}
// Fetch persistent approvals for this command
const commandName = await getCommandName(req.command, req.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:
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
// 1. Handle filesystem permissions for Low Integrity
// Grant "Low Mandatory Level" write access to the workspace.
await this.grantLowIntegrityAccess(this.options.workspace);
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? await isStrictlyApproved(
req.command,
req.args,
this.options.modeConfig?.approvedTools,
)
: false;
if (!isReadonlyMode || isApproved) {
await this.grantLowIntegrityAccess(this.options.workspace);
}
// Grant "Low Mandatory Level" read access to allowedPaths.
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
@@ -188,6 +271,13 @@ export class WindowsSandboxManager implements SandboxManager {
await this.grantLowIntegrityAccess(allowedPath);
}
// Grant "Low Mandatory Level" write access to additional permissions write paths.
const additionalWritePaths =
sanitizePaths(mergedAdditional.fileSystem?.write) || [];
for (const writePath of additionalWritePaths) {
await this.grantLowIntegrityAccess(writePath);
}
// Denies access to forbiddenPaths for Low Integrity processes.
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
for (const forbiddenPath of forbiddenPaths) {
@@ -219,13 +309,12 @@ export class WindowsSandboxManager implements SandboxManager {
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
const program = this.helperPath;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network;
// If the command starts with __, it's an internal command for the sandbox helper itself.
const args = [
req.policy?.networkAccess ? '1' : '0',
req.cwd,
req.command,
...req.args,
];
const args = [networkAccess ? '1' : '0', req.cwd, req.command, ...req.args];
return {
program,
@@ -248,6 +337,20 @@ export class WindowsSandboxManager implements SandboxManager {
return;
}
// Explicitly reject UNC paths to prevent credential theft/SSRF,
// but allow local extended-length and device paths.
if (
resolvedPath.startsWith('\\\\') &&
!resolvedPath.startsWith('\\\\?\\') &&
!resolvedPath.startsWith('\\\\.\\')
) {
debugLogger.log(
'WindowsSandboxManager: Rejecting UNC path for Low Integrity grant:',
resolvedPath,
);
return;
}
// Never modify integrity levels for system directories
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files';
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { isKnownSafeCommand, isDangerousCommand } from './commandSafety.js';
describe('Windows commandSafety', () => {
describe('isKnownSafeCommand', () => {
it('should identify known safe commands', () => {
expect(isKnownSafeCommand(['dir'])).toBe(true);
expect(isKnownSafeCommand(['echo', 'hello'])).toBe(true);
expect(isKnownSafeCommand(['whoami'])).toBe(true);
});
it('should strip .exe extension for safe commands', () => {
expect(isKnownSafeCommand(['dir.exe'])).toBe(true);
expect(isKnownSafeCommand(['ECHO.EXE', 'hello'])).toBe(true);
expect(isKnownSafeCommand(['WHOAMI.exe'])).toBe(true);
});
it('should reject unknown commands', () => {
expect(isKnownSafeCommand(['unknown'])).toBe(false);
expect(isKnownSafeCommand(['npm', 'install'])).toBe(false);
});
});
describe('isDangerousCommand', () => {
it('should identify dangerous commands', () => {
expect(isDangerousCommand(['del', 'file.txt'])).toBe(true);
expect(isDangerousCommand(['powershell', '-Command', 'echo'])).toBe(true);
expect(isDangerousCommand(['cmd', '/c', 'dir'])).toBe(true);
});
it('should strip .exe extension for dangerous commands', () => {
expect(isDangerousCommand(['del.exe', 'file.txt'])).toBe(true);
expect(isDangerousCommand(['POWERSHELL.EXE', '-Command', 'echo'])).toBe(
true,
);
expect(isDangerousCommand(['cmd.exe', '/c', 'dir'])).toBe(true);
});
it('should not flag safe commands as dangerous', () => {
expect(isDangerousCommand(['dir'])).toBe(false);
expect(isDangerousCommand(['echo', 'hello'])).toBe(false);
});
});
});
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { parse as shellParse } from 'shell-quote';
import {
extractStringFromParseEntry,
initializeShellParsers,
splitCommands,
stripShellWrapper,
} from '../../utils/shell-utils.js';
/**
* Determines if a command is strictly approved for execution on Windows.
* A command is approved if it's composed entirely of tools explicitly listed in `approvedTools`
* OR if it's composed of known safe, read-only Windows commands.
*
* @param command - The full command string to execute.
* @param args - The arguments for the command.
* @param approvedTools - A list of explicitly approved tool names (e.g., ['npm', 'git']).
* @returns true if the command is strictly approved, false otherwise.
*/
export async function isStrictlyApproved(
command: string,
args: string[],
approvedTools?: string[],
): Promise<boolean> {
const tools = approvedTools ?? [];
await initializeShellParsers();
const fullCmd = [command, ...args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const pipelineCommands = splitCommands(stripped);
// Fallback for simple commands or parsing failures
if (pipelineCommands.length === 0) {
return tools.includes(command) || isKnownSafeCommand([command, ...args]);
}
// Check every segment of the pipeline
return pipelineCommands.every((cmdString) => {
const trimmed = cmdString.trim();
if (!trimmed) return true;
const parsedArgs = shellParse(trimmed).map(extractStringFromParseEntry);
if (parsedArgs.length === 0) return true;
let root = parsedArgs[0].toLowerCase();
if (root.endsWith('.exe')) {
root = root.slice(0, -4);
}
// The segment is approved if the root tool is in the allowlist OR if the whole segment is safe.
return (
tools.some((t) => t.toLowerCase() === root) ||
isKnownSafeCommand(parsedArgs)
);
});
}
/**
* Checks if a Windows command is known to be safe (read-only).
*/
export function isKnownSafeCommand(args: string[]): boolean {
if (!args || args.length === 0) return false;
let cmd = args[0].toLowerCase();
if (cmd.endsWith('.exe')) {
cmd = cmd.slice(0, -4);
}
// Native Windows/PowerShell safe commands
const safeCommands = new Set([
'dir',
'type',
'echo',
'cd',
'pwd',
'whoami',
'hostname',
'ver',
'vol',
'systeminfo',
'attrib',
'findstr',
'where',
'sort',
'more',
'get-childitem',
'get-content',
'get-location',
'get-help',
'get-process',
'get-service',
'get-eventlog',
'select-string',
]);
if (safeCommands.has(cmd)) {
return true;
}
// We allow git on Windows if it's read-only, using the same logic as POSIX
if (cmd === 'git') {
// For simplicity in this branch, we'll allow standard git read operations
// In a full implementation, we'd port the sub-command validation too.
const sub = args[1]?.toLowerCase();
return ['status', 'log', 'diff', 'show', 'branch'].includes(sub);
}
return false;
}
/**
* Checks if a Windows command is explicitly dangerous.
*/
export function isDangerousCommand(args: string[]): boolean {
if (!args || args.length === 0) return false;
let cmd = args[0].toLowerCase();
if (cmd.endsWith('.exe')) {
cmd = cmd.slice(0, -4);
}
const dangerous = new Set([
'del',
'erase',
'rd',
'rmdir',
'net',
'reg',
'sc',
'format',
'mklink',
'takeown',
'icacls',
'powershell', // prevent shell escapes
'pwsh',
'cmd',
'remove-item',
'stop-process',
'stop-service',
'set-item',
'new-item',
]);
return dangerous.has(cmd);
}