mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-02 07:54:48 -07:00
feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 (#23691)
This commit is contained in:
committed by
GitHub
parent
f11bd3d079
commit
1b052df52f
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user