Download packages/core/src/policy/policy-engine.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 129 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/policy/policy-engine.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/policy/policy-engine.test.ts
-
curl -L -o policy-engine.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/policy/policy-engine.test.ts
129 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; | |
| import { PolicyEngine } from './policy-engine.js'; | |
| import { | |
| PolicyDecision, | |
| type PolicyRule, | |
| type PolicyEngineConfig, | |
| type SafetyCheckerRule, | |
| InProcessCheckerType, | |
| ApprovalMode, | |
| PRIORITY_SUBAGENT_TOOL, | |
| ALWAYS_ALLOW_PRIORITY_FRACTION, | |
| PRIORITY_YOLO_ALLOW_ALL, | |
| } from './types.js'; | |
| import type { FunctionCall } from '@google/genai'; | |
| import { SafetyCheckDecision } from '../safety/protocol.js'; | |
| import type { CheckerRunner } from '../safety/checker-runner.js'; | |
| import { | |
| initializeShellParsers, | |
| parseCommandDetails, | |
| } from '../utils/shell-utils.js'; | |
| import { buildArgsPatterns } from './utils.js'; | |
| import { | |
| NoopSandboxManager, | |
| LocalSandboxManager, | |
| type SandboxManager, | |
| } from '../services/sandboxManager.js'; | |
| // Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI) | |
| // We want to test PolicyEngine logic, not the shell parser's ability to parse commands | |
| vi.mock('../utils/shell-utils.js', async (importOriginal) => { | |
| const actual = | |
| await importOriginal<typeof import('../utils/shell-utils.js')>(); | |
| return { | |
| ...actual, | |
| initializeShellParsers: vi.fn().mockResolvedValue(undefined), | |
| splitCommands: vi.fn().mockImplementation((command: string) => { | |
| // Simple mock splitting logic for test cases | |
| if (command.includes('&&')) { | |
| return command.split('&&').map((c) => c.trim()); | |
| } | |
| return [command]; | |
| }), | |
| parseCommandDetails: vi.fn().mockImplementation((command: string) => { | |
| // Basic mock implementation for PolicyEngine test needs | |
| const commands = command.includes('&&') | |
| ? command.split('&&').map((c) => c.trim()) | |
| : [command.trim()]; | |
| // Detect $(...) or `...` and add as sub-commands for recursion tests | |
| const subCommands = [...commands]; | |
| for (const cmd of commands) { | |
| const subMatch = cmd.match(/\$\((.*)\)/) || cmd.match(/`(.*)`/); | |
| if (subMatch?.[1]) { | |
| subCommands.push(subMatch[1].trim()); | |
| } | |
| } | |
| return { | |
| details: subCommands.map((c, i) => ({ | |
| name: c.split(' ')[0], | |
| text: c, | |
| startIndex: i === 0 ? 0 : -1, // Simple root indication | |
| })), | |
| hasError: false, | |
| }; | |
| }), | |
| stripShellWrapper: vi.fn().mockImplementation((command: string) => { | |
| // Simple mock for stripping wrappers | |
| const match = command.match(/^(?:bash|sh|zsh)\s+-c\s+["'](.*)["']$/i); | |
| return match ? match[1] : command; | |
| }), | |
| hasRedirection: vi.fn().mockImplementation( | |
| (command: string) => | |
| // Simple mock: true if '>' is present, unless it looks like "-> arrow" | |
| command.includes('>') && !command.includes('-> arrow'), | |
| ), | |
| }; | |
| }); | |
| // Mock tool-names to provide a consistent alias for testing | |
| vi.mock('../tools/tool-names.js', async (importOriginal) => { | |
| const actual = | |
| await importOriginal<typeof import('../tools/tool-names.js')>(); | |
| const mockedAliases: Record<string, string> = { | |
| ...actual.TOOL_LEGACY_ALIASES, | |
| legacy_test_tool: 'current_test_tool', | |
| another_legacy_test_tool: 'current_test_tool', | |
| }; | |
| return { | |
| ...actual, | |
| TOOL_LEGACY_ALIASES: mockedAliases, | |
| getToolAliases: vi.fn().mockImplementation((name: string) => { | |
| const aliases = new Set<string>([name]); | |
| const canonicalName = mockedAliases[name] ?? name; | |
| aliases.add(canonicalName); | |
| for (const [legacyName, currentName] of Object.entries(mockedAliases)) { | |
| if (currentName === canonicalName) { | |
| aliases.add(legacyName); | |
| } | |
| } | |
| return Array.from(aliases); | |
| }), | |
| }; | |
| }); | |
| describe('PolicyEngine', () => { | |
| let engine: PolicyEngine; | |
| let mockCheckerRunner: CheckerRunner; | |
| beforeAll(async () => { | |
| await initializeShellParsers(); | |
| }); | |
| beforeEach(() => { | |
| mockCheckerRunner = { | |
| runChecker: vi.fn(), | |
| } as unknown as CheckerRunner; | |
| engine = new PolicyEngine( | |
| { | |
| approvalMode: ApprovalMode.DEFAULT, | |
| sandboxManager: new NoopSandboxManager(), | |
| }, | |
| mockCheckerRunner, | |
| ); | |
| }); | |
| describe('constructor', () => { | |
| it('should use default config when none provided', async () => { | |
| const { decision } = await engine.check({ name: 'test' }, undefined); | |
| expect(decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should respect custom default decision', async () => { | |
| engine = new PolicyEngine({ defaultDecision: PolicyDecision.DENY }); | |
| const { decision } = await engine.check({ name: 'test' }, undefined); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should sort rules by priority', () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'tool1', decision: PolicyDecision.DENY, priority: 1 }, | |
| { toolName: 'tool2', decision: PolicyDecision.ALLOW, priority: 10 }, | |
| { toolName: 'tool3', decision: PolicyDecision.ASK_USER, priority: 5 }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const sortedRules = engine.getRules(); | |
| expect(sortedRules[0].priority).toBe(10); | |
| expect(sortedRules[1].priority).toBe(5); | |
| expect(sortedRules[2].priority).toBe(1); | |
| }); | |
| }); | |
| describe('check', () => { | |
| it('should match tool by name', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'shell', decision: PolicyDecision.ALLOW }, | |
| { toolName: 'edit', decision: PolicyDecision.DENY }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| expect((await engine.check({ name: 'shell' }, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe( | |
| PolicyDecision.DENY, | |
| ); | |
| expect((await engine.check({ name: 'other' }, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| }); | |
| it('should match unqualified tool names with qualified rules when serverName is provided', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_my-server_tool', | |
| mcpName: 'my-server', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Match with unqualified name + serverName | |
| expect((await engine.check({ name: 'tool' }, 'my-server')).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| // Match with qualified name (standard) | |
| expect( | |
| (await engine.check({ name: 'mcp_my-server_tool' }, 'my-server')) | |
| .decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should match by args pattern', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'shell', | |
| argsPattern: /rm -rf/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| { | |
| toolName: 'shell', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const dangerousCall: FunctionCall = { | |
| name: 'shell', | |
| args: { command: 'rm -rf /' }, | |
| }; | |
| const safeCall: FunctionCall = { | |
| name: 'shell', | |
| args: { command: 'ls -la' }, | |
| }; | |
| expect((await engine.check(dangerousCall, undefined)).decision).toBe( | |
| PolicyDecision.DENY, | |
| ); | |
| expect((await engine.check(safeCall, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| it('should apply rules by priority', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'shell', decision: PolicyDecision.DENY, priority: 1 }, | |
| { toolName: 'shell', decision: PolicyDecision.ALLOW, priority: 10 }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Higher priority rule (ALLOW) should win | |
| expect((await engine.check({ name: 'shell' }, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| it('should match current tool call against legacy tool name rules', async () => { | |
| const legacyName = 'legacy_test_tool'; | |
| const currentName = 'current_test_tool'; | |
| const rules: PolicyRule[] = [ | |
| { toolName: legacyName, decision: PolicyDecision.DENY }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Call using the CURRENT name, should be denied because of legacy rule | |
| const { decision } = await engine.check({ name: currentName }, undefined); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should match legacy tool call against current tool name rules (for skills support)', async () => { | |
| const legacyName = 'legacy_test_tool'; | |
| const currentName = 'current_test_tool'; | |
| const rules: PolicyRule[] = [ | |
| { toolName: currentName, decision: PolicyDecision.ALLOW }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Call using the LEGACY name (from a skill), should be allowed because of current rule | |
| const { decision } = await engine.check({ name: legacyName }, undefined); | |
| expect(decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should match tool call using one legacy name against policy for another legacy name (same canonical tool)', async () => { | |
| const legacyName1 = 'legacy_test_tool'; | |
| const legacyName2 = 'another_legacy_test_tool'; | |
| const rules: PolicyRule[] = [ | |
| { toolName: legacyName2, decision: PolicyDecision.DENY }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Call using legacyName1, should be denied because legacyName2 has a deny rule | |
| // and they both point to the same canonical tool. | |
| const { decision } = await engine.check({ name: legacyName1 }, undefined); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should match subagent name as alias for invoke_agent', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'codebase_investigator', decision: PolicyDecision.DENY }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const toolCall: FunctionCall = { | |
| name: 'invoke_agent', | |
| args: { agent_name: 'codebase_investigator', prompt: 'Hello' }, | |
| }; | |
| const { decision } = await engine.check(toolCall, undefined); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should apply wildcard rules (no toolName)', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: '*', decision: PolicyDecision.DENY }, // Applies to all tools | |
| { toolName: 'safe-tool', decision: PolicyDecision.ALLOW, priority: 10 }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| expect( | |
| (await engine.check({ name: 'safe-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| (await engine.check({ name: 'any-other-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should handle non-interactive mode', async () => { | |
| const config: PolicyEngineConfig = { | |
| nonInteractive: true, | |
| rules: [ | |
| { | |
| toolName: 'interactive-tool', | |
| decision: PolicyDecision.ASK_USER, | |
| interactive: true, | |
| }, | |
| { | |
| toolName: 'interactive-tool', | |
| decision: PolicyDecision.DENY, | |
| interactive: false, | |
| }, | |
| { toolName: 'allowed-tool', decision: PolicyDecision.ALLOW }, | |
| { | |
| toolName: 'ask_user', | |
| decision: PolicyDecision.DENY, | |
| interactive: false, | |
| }, | |
| ], | |
| }; | |
| engine = new PolicyEngine(config); | |
| // ASK_USER should become DENY in non-interactive mode | |
| expect( | |
| (await engine.check({ name: 'interactive-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| // ALLOW should remain ALLOW | |
| expect( | |
| (await engine.check({ name: 'allowed-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| // Default ASK_USER should also become DENY | |
| expect( | |
| (await engine.check({ name: 'unknown-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should dynamically switch between modes and respect rule modes', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'edit', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }, | |
| { | |
| toolName: 'edit', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| modes: [ApprovalMode.AUTO_EDIT], | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Default mode: priority 20 rule doesn't match, falls back to priority 10 | |
| expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| // Switch to autoEdit mode | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.AUTO_EDIT, | |
| sandboxManager: new LocalSandboxManager(), | |
| }); | |
| expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| // Switch back to default | |
| engine.setApprovalMode(ApprovalMode.DEFAULT); | |
| expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| }); | |
| it('should respect tools approved by the SandboxManager', async () => { | |
| const mockSandboxManager = { | |
| enabled: true, | |
| prepareCommand: vi.fn(), | |
| isDangerousCommand: vi.fn().mockReturnValue(false), | |
| isKnownSafeCommand: vi | |
| .fn() | |
| .mockImplementation((args) => args[0] === 'npm'), | |
| parseDenials: vi.fn().mockReturnValue(undefined), | |
| } as unknown as SandboxManager; | |
| engine = new PolicyEngine({ | |
| sandboxManager: mockSandboxManager, | |
| defaultDecision: PolicyDecision.ASK_USER, | |
| }); | |
| const { decision } = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'npm install' } }, | |
| undefined, | |
| ); | |
| expect(decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should return ALLOW by default in YOLO mode when no rules match', async () => { | |
| engine = new PolicyEngine({ approvalMode: ApprovalMode.YOLO }); | |
| // No rules defined, should return ALLOW in YOLO mode | |
| const { decision } = await engine.check({ name: 'any-tool' }, undefined); | |
| expect(decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should NOT override explicit DENY rules in YOLO mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'dangerous-tool', decision: PolicyDecision.DENY }, | |
| ]; | |
| engine = new PolicyEngine({ rules, approvalMode: ApprovalMode.YOLO }); | |
| const { decision } = await engine.check( | |
| { name: 'dangerous-tool' }, | |
| undefined, | |
| ); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| // But other tools still allowed | |
| expect( | |
| (await engine.check({ name: 'safe-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should respect rule priority in YOLO mode when a match exists', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }, | |
| { toolName: 'test-tool', decision: PolicyDecision.DENY, priority: 20 }, | |
| ]; | |
| engine = new PolicyEngine({ rules, approvalMode: ApprovalMode.YOLO }); | |
| // Priority 20 (DENY) should win over priority 10 (ASK_USER) | |
| const { decision } = await engine.check({ name: 'test-tool' }, undefined); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should fail closed in YOLO mode when shell parsing fails for restricted rule', async () => { | |
| const originalMock = vi | |
| .mocked(parseCommandDetails) | |
| .getMockImplementation(); | |
| vi.mocked(parseCommandDetails).mockImplementationOnce( | |
| (command: string) => { | |
| if (command === 'echo bypass') { | |
| return { details: [], hasError: true }; | |
| } | |
| return originalMock!(command); | |
| }, | |
| ); | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| argsPattern: /"command":"echo/, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| const { decision } = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'echo bypass' } }, | |
| undefined, | |
| ); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should fail closed in YOLO mode when shell parsing has errors for restricted rule', async () => { | |
| const originalMock = vi | |
| .mocked(parseCommandDetails) | |
| .getMockImplementation(); | |
| vi.mocked(parseCommandDetails).mockImplementationOnce( | |
| (command: string) => { | |
| if (command === 'echo bypass') { | |
| return { | |
| details: [{ name: 'echo', text: 'echo bypass', startIndex: 0 }], | |
| hasError: true, | |
| }; | |
| } | |
| return originalMock!(command); | |
| }, | |
| ); | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| argsPattern: /"command":"echo/, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| const { decision } = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'echo bypass' } }, | |
| undefined, | |
| ); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| }); | |
| describe('addRule', () => { | |
| it('should add a new rule and maintain priority order', () => { | |
| engine.addRule({ | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 5, | |
| }); | |
| engine.addRule({ | |
| toolName: 'tool2', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }); | |
| engine.addRule({ | |
| toolName: 'tool3', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 1, | |
| }); | |
| const rules = engine.getRules(); | |
| expect(rules).toHaveLength(3); | |
| expect(rules[0].priority).toBe(10); | |
| expect(rules[1].priority).toBe(5); | |
| expect(rules[2].priority).toBe(1); | |
| }); | |
| it('should apply newly added rules', async () => { | |
| expect( | |
| (await engine.check({ name: 'new-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| engine.addRule({ toolName: 'new-tool', decision: PolicyDecision.ALLOW }); | |
| expect( | |
| (await engine.check({ name: 'new-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| }); | |
| describe('removeRulesForTool', () => { | |
| it('should remove rules for specific tool', () => { | |
| engine.addRule({ toolName: 'tool1', decision: PolicyDecision.ALLOW }); | |
| engine.addRule({ toolName: 'tool2', decision: PolicyDecision.DENY }); | |
| engine.addRule({ | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }); | |
| expect(engine.getRules()).toHaveLength(3); | |
| engine.removeRulesForTool('tool1'); | |
| const remainingRules = engine.getRules(); | |
| expect(remainingRules).toHaveLength(1); | |
| expect(remainingRules.some((r) => r.toolName === 'tool1')).toBe(false); | |
| expect(remainingRules.some((r) => r.toolName === 'tool2')).toBe(true); | |
| }); | |
| it('should remove rules for specific tool and source', () => { | |
| engine.addRule({ | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ALLOW, | |
| source: 'source1', | |
| }); | |
| engine.addRule({ | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| source: 'source2', | |
| }); | |
| engine.addRule({ | |
| toolName: 'tool2', | |
| decision: PolicyDecision.ALLOW, | |
| source: 'source1', | |
| }); | |
| expect(engine.getRules()).toHaveLength(3); | |
| engine.removeRulesForTool('tool1', 'source1'); | |
| const rules = engine.getRules(); | |
| expect(rules).toHaveLength(2); | |
| expect( | |
| rules.some((r) => r.toolName === 'tool1' && r.source === 'source2'), | |
| ).toBe(true); | |
| expect( | |
| rules.some((r) => r.toolName === 'tool2' && r.source === 'source1'), | |
| ).toBe(true); | |
| expect( | |
| rules.some((r) => r.toolName === 'tool1' && r.source === 'source1'), | |
| ).toBe(false); | |
| }); | |
| it('should handle removing non-existent tool', () => { | |
| engine.addRule({ toolName: 'existing', decision: PolicyDecision.ALLOW }); | |
| expect(() => engine.removeRulesForTool('non-existent')).not.toThrow(); | |
| expect(engine.getRules()).toHaveLength(1); | |
| }); | |
| }); | |
| describe('getRules', () => { | |
| it('should return readonly array of rules', () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'tool1', decision: PolicyDecision.ALLOW }, | |
| { toolName: 'tool2', decision: PolicyDecision.DENY }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const retrievedRules = engine.getRules(); | |
| expect(retrievedRules).toHaveLength(2); | |
| expect(retrievedRules[0].toolName).toBe('tool1'); | |
| expect(retrievedRules[1].toolName).toBe('tool2'); | |
| }); | |
| }); | |
| describe('MCP server wildcard patterns', () => { | |
| it('should match global wildcard (*)', async () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { toolName: '*', decision: PolicyDecision.ALLOW, priority: 10 }, | |
| ], | |
| }); | |
| expect( | |
| (await engine.check({ name: 'read_file' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| (await engine.check({ name: 'mcp_my-server_tool' }, 'my-server')) | |
| .decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should match any MCP tool when toolName is mcp_*', async () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { toolName: 'mcp_*', decision: PolicyDecision.ALLOW, priority: 10 }, | |
| ], | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| expect( | |
| (await engine.check({ name: 'mcp_mcp_tool' }, 'mcp')).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| (await engine.check({ name: 'mcp_other_tool' }, 'other')).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| (await engine.check({ name: 'read_file' }, undefined)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should match MCP server wildcard patterns', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_my-server_*', | |
| mcpName: 'my-server', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| { | |
| toolName: 'mcp_blocked-server_*', | |
| mcpName: 'blocked-server', | |
| decision: PolicyDecision.DENY, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Should match my-server tools | |
| expect( | |
| (await engine.check({ name: 'mcp_my-server_tool1' }, 'my-server')) | |
| .decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'mcp_my-server_another_tool' }, | |
| 'my-server', | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| // Should match blocked-server tools | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'mcp_blocked-server_tool1' }, | |
| 'blocked-server', | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'mcp_blocked-server_dangerous' }, | |
| 'blocked-server', | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| // Should not match other patterns | |
| expect( | |
| (await engine.check({ name: 'mcp_other-server_tool' }, 'other-server')) | |
| .decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| expect( | |
| (await engine.check({ name: 'my-server-tool' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ASK_USER); // No __ separator | |
| expect( | |
| (await engine.check({ name: 'my-server' }, undefined)).decision, | |
| ).toBe(PolicyDecision.ASK_USER); // No tool name | |
| }); | |
| it('should prioritize specific tool rules over server wildcards', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_my-server_*', | |
| mcpName: 'my-server', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| { | |
| toolName: 'mcp_my-server_dangerous-tool', | |
| mcpName: 'my-server', | |
| decision: PolicyDecision.DENY, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Specific tool deny should override server allow | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'mcp_my-server_dangerous-tool' }, | |
| 'my-server', | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| expect( | |
| (await engine.check({ name: 'mcp_my-server_safe-tool' }, 'my-server')) | |
| .decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should NOT match spoofed server names when using wildcards', async () => { | |
| // Vulnerability: A rule for 'mcp_prefix_*' matches 'mcp_prefix__suffix_tool' | |
| // effectively allowing a server named 'mcp_prefix_suffix' to spoof 'prefix'. | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_safe_server_*', | |
| mcpName: 'safe_server', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // A tool from a different server 'mcp_safe_server_malicious' | |
| const spoofedToolCall = { name: 'mcp_mcp_safe_server_malicious_tool' }; | |
| // CURRENT BEHAVIOR (FIXED): Matches because it starts with 'safe_server__' BUT serverName doesn't match 'safe_server' | |
| // We expect this to FAIL matching the ALLOW rule, thus falling back to default (ASK_USER) | |
| expect( | |
| (await engine.check(spoofedToolCall, 'mcp_safe_server_malicious')) | |
| .decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should verify tool name prefix even if serverName matches', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_safe_server_*', | |
| mcpName: 'safe_server', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // serverName matches, but tool name does not start with prefix | |
| const invalidToolCall = { name: 'mcp_other_server_tool' }; | |
| expect( | |
| (await engine.check(invalidToolCall, 'safe_server')).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should allow when both serverName and tool name prefix match', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_safe_server_*', | |
| mcpName: 'safe_server', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const validToolCall = { name: 'mcp_safe_server_tool' }; | |
| expect((await engine.check(validToolCall, 'safe_server')).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| }); | |
| describe('complex scenarios', () => { | |
| it('should handle multiple matching rules with different priorities', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: '*', decision: PolicyDecision.DENY, priority: 0 }, // Default deny all | |
| { toolName: 'shell', decision: PolicyDecision.ASK_USER, priority: 5 }, | |
| { | |
| toolName: 'shell', | |
| argsPattern: /"command":"ls/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Matches highest priority rule (ls command) | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'shell', args: { command: 'ls -la' } }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| // Matches middle priority rule (shell without ls) | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'shell', args: { command: 'pwd' } }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| // Matches lowest priority rule (not shell) | |
| expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe( | |
| PolicyDecision.DENY, | |
| ); | |
| }); | |
| it('should correctly match commands with quotes in commandPrefix', async () => { | |
| const prefix = 'git commit -m "fix"'; | |
| const patterns = buildArgsPatterns(undefined, prefix); | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: new RegExp(patterns[0]!), | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'git commit -m "fix"' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should handle tools with no args', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'read', | |
| argsPattern: /secret/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Tool call without args should not match pattern | |
| expect((await engine.check({ name: 'read' }, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| // Tool call with args not matching pattern | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'read', args: { file: 'public.txt' } }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| // Tool call with args matching pattern | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'read', args: { file: 'secret.txt' } }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should match args pattern regardless of property order', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'shell', | |
| // Pattern matches the stable stringified format | |
| argsPattern: /"command":"rm[^"]*-rf/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Same args with different property order should both match | |
| const args1 = { command: 'rm -rf /', path: '/home' }; | |
| const args2 = { path: '/home', command: 'rm -rf /' }; | |
| expect( | |
| (await engine.check({ name: 'shell', args: args1 }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.DENY); | |
| expect( | |
| (await engine.check({ name: 'shell', args: args2 }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.DENY); | |
| // Verify safe command doesn't match | |
| const safeArgs = { command: 'ls -la', path: '/home' }; | |
| expect( | |
| (await engine.check({ name: 'shell', args: safeArgs }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should handle nested objects in args with stable stringification', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'api', | |
| argsPattern: /"sensitive":true/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Nested objects with different key orders should match consistently | |
| const args1 = { | |
| data: { sensitive: true, value: 'secret' }, | |
| method: 'POST', | |
| }; | |
| const args2 = { | |
| method: 'POST', | |
| data: { value: 'secret', sensitive: true }, | |
| }; | |
| expect( | |
| (await engine.check({ name: 'api', args: args1 }, undefined)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| expect( | |
| (await engine.check({ name: 'api', args: args2 }, undefined)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should handle circular references without stack overflow', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /\[Circular\]/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Create an object with a circular reference | |
| type CircularArgs = Record<string, unknown> & { | |
| data?: Record<string, unknown>; | |
| }; | |
| const circularArgs: CircularArgs = { | |
| name: 'test', | |
| data: {}, | |
| }; | |
| // Create circular reference - TypeScript allows this since data is Record<string, unknown> | |
| (circularArgs.data as Record<string, unknown>)['self'] = | |
| circularArgs.data; | |
| // Should not throw stack overflow error | |
| await expect( | |
| engine.check({ name: 'test', args: circularArgs }, undefined), | |
| ).resolves.not.toThrow(); | |
| // Should detect the circular reference pattern | |
| expect( | |
| (await engine.check({ name: 'test', args: circularArgs }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.DENY); | |
| // Non-circular object should not match | |
| const normalArgs = { name: 'test', data: { value: 'normal' } }; | |
| expect( | |
| (await engine.check({ name: 'test', args: normalArgs }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should handle deep circular references', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'deep', | |
| argsPattern: /\[Circular\]/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Create a deep circular reference | |
| type DeepCircular = Record<string, unknown> & { | |
| level1?: { | |
| level2?: { | |
| level3?: Record<string, unknown>; | |
| }; | |
| }; | |
| }; | |
| const deepCircular: DeepCircular = { | |
| level1: { | |
| level2: { | |
| level3: {}, | |
| }, | |
| }, | |
| }; | |
| // Create circular reference with proper type assertions | |
| const level3 = deepCircular.level1!.level2!.level3!; | |
| level3['back'] = deepCircular.level1; | |
| // Should handle without stack overflow | |
| await expect( | |
| engine.check({ name: 'deep', args: deepCircular }, undefined), | |
| ).resolves.not.toThrow(); | |
| // Should detect the circular reference | |
| expect( | |
| (await engine.check({ name: 'deep', args: deepCircular }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should handle repeated non-circular objects correctly', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /\[Circular\]/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| { | |
| toolName: 'test', | |
| argsPattern: /"value":"shared"/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Create an object with repeated references but no cycles | |
| const sharedObj = { value: 'shared' }; | |
| const args = { | |
| first: sharedObj, | |
| second: sharedObj, | |
| third: { nested: sharedObj }, | |
| }; | |
| // Should NOT mark repeated objects as circular, and should match the shared value pattern | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should omit undefined and function values from objects', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /"definedValue":"test"/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const args = { | |
| definedValue: 'test', | |
| undefinedValue: undefined, | |
| functionValue: () => 'hello', | |
| nullValue: null, | |
| }; | |
| // Should match pattern with defined value, undefined and functions omitted | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| // Check that the pattern would NOT match if undefined was included | |
| const rulesWithUndefined: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /undefinedValue/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules: rulesWithUndefined }); | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| // Check that the pattern would NOT match if function was included | |
| const rulesWithFunction: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /functionValue/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules: rulesWithFunction }); | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should convert undefined and functions to null in arrays', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /\["value",null,null,null\]/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const args = { | |
| array: ['value', undefined, () => 'hello', null], | |
| }; | |
| // Should match pattern with undefined and functions converted to null | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should produce valid JSON for all inputs', async () => { | |
| const testCases: Array<{ input: Record<string, unknown>; desc: string }> = | |
| [ | |
| { input: { simple: 'string' }, desc: 'simple object' }, | |
| { | |
| input: { nested: { deep: { value: 123 } } }, | |
| desc: 'nested object', | |
| }, | |
| { input: { data: [1, 2, 3] }, desc: 'simple array' }, | |
| { input: { mixed: [1, { a: 'b' }, null] }, desc: 'mixed array' }, | |
| { | |
| input: { undef: undefined, func: () => {}, normal: 'value' }, | |
| desc: 'object with undefined and function', | |
| }, | |
| { | |
| input: { data: ['a', undefined, () => {}, null] }, | |
| desc: 'array with undefined and function', | |
| }, | |
| ]; | |
| for (const { input } of testCases) { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /.*/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Should not throw when checking (which internally uses stableStringify) | |
| await expect( | |
| engine.check({ name: 'test', args: input }, undefined), | |
| ).resolves.not.toThrow(); | |
| // The check should succeed | |
| expect( | |
| (await engine.check({ name: 'test', args: input }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| } | |
| }); | |
| it('should respect toJSON methods on objects', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /"sanitized":"safe"/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| { | |
| toolName: 'test', | |
| argsPattern: /"dangerous":"data"/, | |
| decision: PolicyDecision.DENY, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Object with toJSON that sanitizes output | |
| const args = { | |
| data: { | |
| dangerous: 'data', | |
| toJSON: () => ({ sanitized: 'safe' }), | |
| }, | |
| }; | |
| // Should match the sanitized pattern, not the dangerous one | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should handle toJSON that returns primitives', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /"value":"string-value"/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const args = { | |
| value: { | |
| complex: 'object', | |
| toJSON: () => 'string-value', | |
| }, | |
| }; | |
| // toJSON returns a string, which should be properly stringified | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should handle toJSON that throws an error', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| argsPattern: /"fallback":"value"/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const args = { | |
| data: { | |
| fallback: 'value', | |
| toJSON: () => { | |
| throw new Error('toJSON error'); | |
| }, | |
| }, | |
| }; | |
| // Should fall back to regular object serialization when toJSON throws | |
| expect( | |
| (await engine.check({ name: 'test', args }, undefined)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should downgrade ALLOW to ASK_USER for redirected shell commands', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| // Matches "echo" prefix | |
| argsPattern: /"command":"echo/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Safe command should be allowed | |
| expect( | |
| ( | |
| await engine.check( | |
| { name: 'run_shell_command', args: { command: 'echo "hello"' } }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| // Redirected command should be downgraded to ASK_USER | |
| expect( | |
| ( | |
| await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "hello" > file.txt' }, | |
| }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should allow redirected shell commands when allowRedirection is true', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| // Matches "echo" prefix | |
| argsPattern: /"command":"echo/, | |
| decision: PolicyDecision.ALLOW, | |
| allowRedirection: true, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Redirected command should stay ALLOW | |
| expect( | |
| ( | |
| await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "hello" > file.txt' }, | |
| }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should NOT automatically DENY redirected shell commands in non-interactive mode if rules permit it', async () => { | |
| const toolName = 'run_shell_command'; | |
| const command = 'ls > out.txt'; | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName, | |
| decision: PolicyDecision.ALLOW, | |
| allowRedirection: true, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, nonInteractive: true }); | |
| expect( | |
| (await engine.check({ name: toolName, args: { command } }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should respect DENY rules for redirected shell commands in non-interactive mode', async () => { | |
| const toolName = 'run_shell_command'; | |
| const command = 'ls > out.txt'; | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName, | |
| decision: PolicyDecision.ASK_USER, | |
| interactive: true, | |
| }, | |
| { | |
| toolName, | |
| decision: PolicyDecision.DENY, | |
| interactive: false, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, nonInteractive: true }); | |
| expect( | |
| (await engine.check({ name: toolName, args: { command } }, undefined)) | |
| .decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should NOT downgrade ALLOW to ASK_USER for quoted redirection chars', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"echo/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Should remain ALLOW because it's not a real redirection | |
| expect( | |
| ( | |
| await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "-> arrow"' }, | |
| }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should preserve dir_path during recursive shell command checks', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| // Rule that only allows echo in a specific directory | |
| // Note: stableStringify sorts keys alphabetically and has no spaces: {"command":"echo hello","dir_path":"/safe/path"} | |
| argsPattern: /"command":"echo hello".*"dir_path":"\/safe\/path"/, | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| { | |
| // Catch-all ALLOW for shell but with low priority | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| priority: -100, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| sandboxManager: new NoopSandboxManager({ workspace: '/safe/path' }), | |
| }); | |
| // Compound command. The decomposition will call check() for "echo hello" | |
| // which should match our specific high-priority rule IF dir_path is preserved. | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo hello && pwd', dir_path: '/safe/path' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should force ASK_USER when dir_path escapes workspace boundary even if rule allows it', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| sandboxManager: new NoopSandboxManager({ workspace: '/safe/path' }), | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'pwd', dir_path: '/outside/path' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should preserve ALLOW in YOLO mode even when dir_path escapes workspace boundary', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| sandboxManager: new NoopSandboxManager({ workspace: '/safe/path' }), | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'pwd', dir_path: '/outside/path' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should force ASK_USER in default decision path when dir_path escapes workspace boundary', async () => { | |
| engine = new PolicyEngine({ | |
| rules: [], | |
| defaultDecision: PolicyDecision.ALLOW, | |
| sandboxManager: new NoopSandboxManager({ workspace: '/safe/path' }), | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'pwd', dir_path: '/outside/path' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should upgrade ASK_USER to ALLOW if all sub-commands are allowed', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"git status/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"ls/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| { | |
| // Catch-all ASK_USER for shell | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // "git status && ls" matches the catch-all ASK_USER rule initially. | |
| // But since both parts are explicitly ALLOWed, the result should be upgraded to ALLOW. | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'git status && ls' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should NOT upgrade Git commands to ALLOW in untrusted workspace', async () => { | |
| const isTrustedMock = vi.fn().mockReturnValue(false); | |
| const engine = new PolicyEngine({ | |
| isTrustedFolder: isTrustedMock, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'git status' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(isTrustedMock).toHaveBeenCalled(); | |
| }); | |
| it('should NOT treat commands with git in arguments or URLs as Git commands', async () => { | |
| const isTrustedMock = vi.fn().mockReturnValue(false); | |
| const engine = new PolicyEngine({ | |
| isTrustedFolder: isTrustedMock, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo git' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should detect chained Git commands as Git commands and force ASK_USER in untrusted workspaces', async () => { | |
| const isTrustedMock = vi.fn().mockReturnValue(false); | |
| const engine = new PolicyEngine({ | |
| isTrustedFolder: isTrustedMock, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "hello" && git status' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(isTrustedMock).toHaveBeenCalled(); | |
| }); | |
| it('should detect Git commands prefixed with environment variable assignments', async () => { | |
| const isTrustedMock = vi.fn().mockReturnValue(false); | |
| const engine = new PolicyEngine({ | |
| isTrustedFolder: isTrustedMock, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'VAR=val git status' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(isTrustedMock).toHaveBeenCalled(); | |
| }); | |
| it('should NOT upgrade a DENY decision to ASK_USER for Git commands in untrusted workspaces', async () => { | |
| const isTrustedMock = vi.fn().mockReturnValue(false); | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"git status"/, | |
| decision: PolicyDecision.DENY, | |
| priority: 100, | |
| }, | |
| ]; | |
| const engine = new PolicyEngine({ | |
| rules, | |
| isTrustedFolder: isTrustedMock, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'git status' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should upgrade Git commands to ALLOW in trusted workspace when heuristics apply', async () => { | |
| const isTrustedMock = vi.fn().mockReturnValue(true); | |
| const engine = new PolicyEngine({ | |
| isTrustedFolder: isTrustedMock, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'git status' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| expect(isTrustedMock).toHaveBeenCalled(); | |
| }); | |
| it('should respect explicit DENY for compound commands even if parts are allowed', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| // Explicitly DENY the compound command | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"git status && ls"/, | |
| decision: PolicyDecision.DENY, | |
| priority: 30, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"git status/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"ls/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'git status && ls' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should propagate DENY from any sub-command', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"rm/, | |
| decision: PolicyDecision.DENY, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"echo/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // "echo hello && rm -rf /" -> echo is ALLOW, rm is DENY -> Result DENY | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo hello && rm -rf /' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should respect explicit DENY rules for redirected shell commands in non-interactive mode', async () => { | |
| const config: PolicyEngineConfig = { | |
| nonInteractive: true, | |
| rules: [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| interactive: true, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.DENY, | |
| interactive: false, | |
| }, | |
| ], | |
| }; | |
| engine = new PolicyEngine(config); | |
| expect( | |
| ( | |
| await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "hello" > file.txt' }, | |
| }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should default to ASK_USER for atomic commands when matching a wildcard ASK_USER rule', async () => { | |
| // Regression test: atomic commands were auto-allowing because of optimistic initialization | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ASK_USER, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Atomic command "unknown_command" matches the wildcard rule (ASK_USER). | |
| // It should NOT be upgraded to ALLOW. | |
| expect( | |
| ( | |
| await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'unknown_command' }, | |
| }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should allow redirected shell commands in non-interactive mode if allowRedirection is true', async () => { | |
| const config: PolicyEngineConfig = { | |
| nonInteractive: true, | |
| rules: [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| allowRedirection: true, | |
| }, | |
| ], | |
| }; | |
| engine = new PolicyEngine(config); | |
| // Redirected command should stay ALLOW even in non-interactive mode | |
| expect( | |
| ( | |
| await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "hello" > file.txt' }, | |
| }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should avoid infinite recursion for commands with substitution', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // Command with substitution triggers splitCommands returning the same command as its first element. | |
| // This verifies the fix for the infinite recursion bug. | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo $(ls)' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should require confirmation for a compound command with redirection even if individual commands are allowed', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"mkdir\b/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"echo\b/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // The full command has redirection, even if the individual split commands do not. | |
| // splitCommands will return ['mkdir -p "bar"', 'echo "hello"'] | |
| // The redirection '> bar/test.md' is stripped by splitCommands. | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'mkdir -p "bar" && echo "hello" > bar/test.md' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should report redirection when a sub-command specifically has redirection', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"mkdir\b/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"echo\b/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // In this case, we mock splitCommands to keep the redirection in the sub-command | |
| vi.mocked(initializeShellParsers).mockResolvedValue(undefined); | |
| const { splitCommands } = await import('../utils/shell-utils.js'); | |
| vi.mocked(splitCommands).mockReturnValueOnce([ | |
| 'mkdir bar', | |
| 'echo hello > bar/test.md', | |
| ]); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'mkdir bar && echo hello > bar/test.md' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should allow redirected shell commands in AUTO_EDIT mode if individual commands are allowed', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"echo\b/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| sandboxManager: new LocalSandboxManager(), | |
| }); | |
| engine.setApprovalMode(ApprovalMode.AUTO_EDIT); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo "hello" > test.txt' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should allow compound commands with safe operators (&&, ||) if individual commands are allowed', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| argsPattern: /"command":"echo\b/, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }); | |
| // "echo hello && echo world" should be allowed since both parts are ALLOW and no redirection is present. | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'echo hello && echo world' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| }); | |
| describe('Plan Mode vs Subagent Priority (Regression)', () => { | |
| it('should DENY subagents in Plan Mode despite dynamic allow rules', async () => { | |
| // Plan Mode Deny (1.04) > Subagent Allow (1.03) | |
| const fixedRules: PolicyRule[] = [ | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 1.04, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'unknown_subagent', | |
| decision: PolicyDecision.ALLOW, | |
| priority: PRIORITY_SUBAGENT_TOOL, | |
| }, | |
| ]; | |
| const fixedEngine = new PolicyEngine({ | |
| rules: fixedRules, | |
| approvalMode: ApprovalMode.PLAN, | |
| }); | |
| const fixedResult = await fixedEngine.check( | |
| { name: 'unknown_subagent' }, | |
| undefined, | |
| ); | |
| expect(fixedResult.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| }); | |
| describe('shell command parsing failure', () => { | |
| it('should return ALLOW in YOLO mode for dangerous commands due to heuristics override', async () => { | |
| // Create an engine with YOLO mode and a sandbox manager that flags a command as dangerous | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| ]; | |
| const mockSandboxManager = new NoopSandboxManager(); | |
| mockSandboxManager.isDangerousCommand = vi.fn().mockReturnValue(true); | |
| mockSandboxManager.isKnownSafeCommand = vi.fn().mockReturnValue(false); | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| sandboxManager: mockSandboxManager, | |
| }); | |
| const result = await engine.check( | |
| { | |
| name: 'run_shell_command', | |
| args: { command: 'powershell echo "dangerous"' }, | |
| }, | |
| undefined, | |
| ); | |
| // Even though the command is flagged as dangerous, YOLO mode should preserve the ALLOW decision | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should NOT downgrade to ASK_USER for redirected commands in YOLO mode even without sandbox', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| sandboxManager: new NoopSandboxManager(), | |
| }); | |
| const command = 'npm test 2>&1 | tail -80'; | |
| const { decision } = await engine.check( | |
| { name: 'run_shell_command', args: { command } }, | |
| undefined, | |
| ); | |
| expect(decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => { | |
| const { splitCommands } = await import('../utils/shell-utils.js'); | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| // Simulate parsing failure (splitCommands returning empty array) | |
| vi.mocked(splitCommands).mockReturnValueOnce([]); | |
| const result = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'complex command' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| expect(result.rule).toBeDefined(); | |
| expect(result.rule?.priority).toBe(999); | |
| }); | |
| it('should return DENY in YOLO mode if shell command parsing fails and a higher priority rule says DENY', async () => { | |
| const { splitCommands } = await import('../utils/shell-utils.js'); | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.DENY, | |
| priority: 2000, // Very high priority DENY (e.g. Admin) | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| // Simulate parsing failure | |
| vi.mocked(splitCommands).mockReturnValueOnce([]); | |
| const result = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'complex command' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 20, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.DEFAULT, | |
| }); | |
| // Simulate parsing failure | |
| const { parseCommandDetails } = await import('../utils/shell-utils.js'); | |
| vi.mocked(parseCommandDetails).mockReturnValueOnce({ | |
| details: [], | |
| hasError: true, | |
| }); | |
| const result = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'complex command' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(result.rule).toBeDefined(); | |
| expect(result.rule?.priority).toBe(20); | |
| }); | |
| }); | |
| describe('safety checker integration', () => { | |
| it('should call checker when rule allows and has safety_checker', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| checker: { | |
| type: 'external', | |
| name: 'test-checker', | |
| config: { content: 'test-content' }, | |
| }, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: { foo: 'bar' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( | |
| { name: 'test-tool', args: { foo: 'bar' } }, | |
| { | |
| type: 'external', | |
| name: 'test-checker', | |
| config: { content: 'test-content' }, | |
| }, | |
| ); | |
| }); | |
| it('should handle checker errors as DENY', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: 'test', | |
| checker: { | |
| type: 'in-process', | |
| name: InProcessCheckerType.ALLOWED_PATH, | |
| }, | |
| }, | |
| ]; | |
| mockCheckerRunner.runChecker = vi | |
| .fn() | |
| .mockRejectedValue(new Error('Checker failed')); | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| const { decision } = await engine.check({ name: 'test' }, undefined); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should return DENY when checker denies', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| checker: { | |
| type: 'external', | |
| name: 'test-checker', | |
| config: { content: 'test-content' }, | |
| }, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.DENY, | |
| reason: 'test reason', | |
| }); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: { foo: 'bar' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalled(); | |
| }); | |
| it('should not call checker if decision is not ALLOW', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ASK_USER, | |
| }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| checker: { | |
| type: 'external', | |
| name: 'test-checker', | |
| config: { content: 'test-content' }, | |
| }, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: { foo: 'bar' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalled(); | |
| }); | |
| it('should run checkers when rule allows', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: 'test', | |
| checker: { | |
| type: 'in-process', | |
| name: InProcessCheckerType.ALLOWED_PATH, | |
| }, | |
| }, | |
| ]; | |
| mockCheckerRunner.runChecker = vi.fn().mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| const { decision } = await engine.check({ name: 'test' }, undefined); | |
| expect(decision).toBe(PolicyDecision.ALLOW); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledTimes(1); | |
| }); | |
| it('should not call checker if rule has no safety_checker', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules }, mockCheckerRunner); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: { foo: 'bar' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| expect(mockCheckerRunner.runChecker).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe('serverName requirement', () => { | |
| it('should require serverName for checks', async () => { | |
| // @ts-expect-error - intentionally testing missing serverName | |
| expect((await engine.check({ name: 'test' })).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| // When serverName is provided (even undefined), it should work | |
| expect((await engine.check({ name: 'test' }, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| expect( | |
| (await engine.check({ name: 'test' }, 'some-server')).decision, | |
| ).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should run multiple checkers in priority order and stop at first denial', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'test', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: 'test', | |
| priority: 10, | |
| checker: { type: 'external', name: 'checker1' }, | |
| }, | |
| { | |
| toolName: 'test', | |
| priority: 20, // Should run first | |
| checker: { type: 'external', name: 'checker2' }, | |
| }, | |
| ]; | |
| mockCheckerRunner.runChecker = vi | |
| .fn() | |
| .mockImplementation(async (_toolCall, config) => { | |
| if (config.name === 'checker2') { | |
| return { | |
| decision: SafetyCheckDecision.DENY, | |
| reason: 'checker2 denied', | |
| }; | |
| } | |
| return { decision: SafetyCheckDecision.ALLOW }; | |
| }); | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| const { decision, rule } = await engine.check( | |
| { name: 'test' }, | |
| undefined, | |
| ); | |
| expect(decision).toBe(PolicyDecision.DENY); | |
| expect(rule).toBeDefined(); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledTimes(1); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( | |
| expect.anything(), | |
| expect.objectContaining({ name: 'checker2' }), | |
| ); | |
| }); | |
| }); | |
| describe('addChecker', () => { | |
| it('should add a new checker and maintain priority order', () => { | |
| const checker1: SafetyCheckerRule = { | |
| toolName: '*', | |
| checker: { type: 'external', name: 'checker1' }, | |
| priority: 5, | |
| }; | |
| const checker2: SafetyCheckerRule = { | |
| toolName: '*', | |
| checker: { type: 'external', name: 'checker2' }, | |
| priority: 10, | |
| }; | |
| engine.addChecker(checker1); | |
| engine.addChecker(checker2); | |
| const checkers = engine.getCheckers(); | |
| expect(checkers).toHaveLength(2); | |
| expect(checkers[0].priority).toBe(10); | |
| expect(checkers[0].checker.name).toBe('checker2'); | |
| expect(checkers[1].priority).toBe(5); | |
| expect(checkers[1].checker.name).toBe('checker1'); | |
| }); | |
| }); | |
| describe('checker matching logic', () => { | |
| it('should match checkers using toolName and argsPattern', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'tool', decision: PolicyDecision.ALLOW }, | |
| ]; | |
| const matchingChecker: SafetyCheckerRule = { | |
| checker: { type: 'external', name: 'matching' }, | |
| toolName: 'tool', | |
| argsPattern: /"safe":true/, | |
| }; | |
| const nonMatchingChecker: SafetyCheckerRule = { | |
| checker: { type: 'external', name: 'non-matching' }, | |
| toolName: 'other', | |
| }; | |
| engine = new PolicyEngine( | |
| { rules, checkers: [matchingChecker, nonMatchingChecker] }, | |
| mockCheckerRunner, | |
| ); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| await engine.check({ name: 'tool', args: { safe: true } }, undefined); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( | |
| expect.anything(), | |
| expect.objectContaining({ name: 'matching' }), | |
| ); | |
| expect(mockCheckerRunner.runChecker).not.toHaveBeenCalledWith( | |
| expect.anything(), | |
| expect.objectContaining({ name: 'non-matching' }), | |
| ); | |
| }); | |
| it('should match global wildcard (*) for checkers', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: '*', decision: PolicyDecision.ALLOW }, | |
| ]; | |
| const globalChecker: SafetyCheckerRule = { | |
| checker: { type: 'external', name: 'global' }, | |
| toolName: '*', | |
| }; | |
| engine = new PolicyEngine( | |
| { rules, checkers: [globalChecker] }, | |
| mockCheckerRunner, | |
| ); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| await engine.check({ name: 'any_tool' }, undefined); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( | |
| expect.anything(), | |
| expect.objectContaining({ name: 'global' }), | |
| ); | |
| vi.mocked(mockCheckerRunner.runChecker).mockClear(); | |
| await engine.check({ name: 'mcp_server_tool' }, 'server'); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( | |
| expect.anything(), | |
| expect.objectContaining({ name: 'global' }), | |
| ); | |
| }); | |
| it('should support wildcard patterns for checkers', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'mcp_server_tool', | |
| mcpName: 'server', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const wildcardChecker: SafetyCheckerRule = { | |
| checker: { type: 'external', name: 'wildcard' }, | |
| toolName: 'mcp_server_*', | |
| mcpName: 'server', | |
| }; | |
| engine = new PolicyEngine( | |
| { rules, checkers: [wildcardChecker] }, | |
| mockCheckerRunner, | |
| ); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| await engine.check({ name: 'mcp_server_tool' }, 'server'); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( | |
| expect.anything(), | |
| expect.objectContaining({ name: 'wildcard' }), | |
| ); | |
| }); | |
| it('should run safety checkers when decision is ASK_USER and downgrade to DENY on failure', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'tool', decision: PolicyDecision.ASK_USER }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: '*', | |
| checker: { | |
| type: 'in-process', | |
| name: InProcessCheckerType.ALLOWED_PATH, | |
| }, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.DENY, | |
| reason: 'Safety check failed', | |
| }); | |
| const result = await engine.check({ name: 'tool' }, undefined); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalled(); | |
| }); | |
| it('should run safety checkers when decision is ASK_USER and keep ASK_USER on success', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'tool', decision: PolicyDecision.ASK_USER }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: '*', | |
| checker: { | |
| type: 'in-process', | |
| name: InProcessCheckerType.ALLOWED_PATH, | |
| }, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ALLOW, | |
| }); | |
| const result = await engine.check({ name: 'tool' }, undefined); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(mockCheckerRunner.runChecker).toHaveBeenCalled(); | |
| }); | |
| it('should downgrade ALLOW to ASK_USER if checker returns ASK_USER', async () => { | |
| const rules: PolicyRule[] = [ | |
| { toolName: 'tool', decision: PolicyDecision.ALLOW }, | |
| ]; | |
| const checkers: SafetyCheckerRule[] = [ | |
| { | |
| toolName: '*', | |
| checker: { | |
| type: 'in-process', | |
| name: InProcessCheckerType.ALLOWED_PATH, | |
| }, | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ rules, checkers }, mockCheckerRunner); | |
| vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ | |
| decision: SafetyCheckDecision.ASK_USER, | |
| reason: 'Suspicious path', | |
| }); | |
| const result = await engine.check({ name: 'tool' }, undefined); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| }); | |
| describe('getExcludedTools', () => { | |
| interface TestCase { | |
| name: string; | |
| rules: PolicyRule[]; | |
| approvalMode?: ApprovalMode; | |
| nonInteractive?: boolean; | |
| allToolNames?: string[]; | |
| metadata?: Map<string, Record<string, unknown>>; | |
| expected: string[]; | |
| } | |
| const testCases: TestCase[] = [ | |
| { | |
| name: 'should return empty set when no rules provided', | |
| rules: [], | |
| allToolNames: ['tool1'], | |
| expected: [], | |
| }, | |
| { | |
| name: 'should apply rules without explicit modes to all modes', | |
| rules: [{ toolName: 'tool1', decision: PolicyDecision.DENY }], | |
| allToolNames: ['tool1', 'tool2'], | |
| expected: ['tool1'], | |
| }, | |
| { | |
| name: 'should NOT exclude tool if higher priority argsPattern rule exists', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ALLOW, | |
| argsPattern: /safe/, | |
| priority: 100, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['tool1'], | |
| expected: [], | |
| }, | |
| { | |
| name: 'should include tools with DENY decision', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| { | |
| toolName: 'tool2', | |
| decision: PolicyDecision.ALLOW, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['tool1', 'tool2', 'tool3'], | |
| expected: ['tool1'], | |
| }, | |
| { | |
| name: 'should respect priority and ignore lower priority rules (DENY wins)', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| priority: 100, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['tool1'], | |
| expected: ['tool1'], | |
| }, | |
| { | |
| name: 'should respect priority and ignore lower priority rules (ALLOW wins)', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 100, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['tool1'], | |
| expected: [], | |
| }, | |
| { | |
| name: 'should include tools in exclusion list only if explicitly denied in non-interactive mode', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.ASK_USER, | |
| modes: [ApprovalMode.DEFAULT], | |
| interactive: true, | |
| }, | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| modes: [ApprovalMode.DEFAULT], | |
| interactive: false, | |
| }, | |
| ], | |
| nonInteractive: true, | |
| allToolNames: ['tool1'], | |
| expected: ['tool1'], | |
| }, | |
| { | |
| name: 'should specifically exclude ask_user tool in non-interactive mode', | |
| rules: [ | |
| { | |
| toolName: 'ask_user', | |
| decision: PolicyDecision.DENY, | |
| interactive: false, | |
| }, | |
| { | |
| toolName: 'read_file', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ], | |
| nonInteractive: true, | |
| allToolNames: ['ask_user', 'read_file'], | |
| expected: ['ask_user'], | |
| }, | |
| { | |
| name: 'should ignore rules with argsPattern', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| argsPattern: /something/, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['tool1'], | |
| expected: [], | |
| }, | |
| { | |
| name: 'should respect approval mode (PLAN mode)', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| ], | |
| approvalMode: ApprovalMode.PLAN, | |
| allToolNames: ['tool1'], | |
| expected: ['tool1'], | |
| }, | |
| { | |
| name: 'should respect approval mode (DEFAULT mode)', | |
| rules: [ | |
| { | |
| toolName: 'tool1', | |
| decision: PolicyDecision.DENY, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| ], | |
| approvalMode: ApprovalMode.DEFAULT, | |
| allToolNames: ['tool1'], | |
| expected: [], | |
| }, | |
| { | |
| name: 'should respect wildcard ALLOW rules (e.g. YOLO mode)', | |
| rules: [ | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| { | |
| toolName: 'dangerous-tool', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| ], | |
| approvalMode: ApprovalMode.YOLO, | |
| allToolNames: ['dangerous-tool', 'safe-tool'], | |
| expected: [], | |
| }, | |
| { | |
| name: 'should respect server wildcard DENY', | |
| rules: [ | |
| { | |
| toolName: 'mcp_server_*', | |
| mcpName: 'server', | |
| decision: PolicyDecision.DENY, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: [ | |
| 'mcp_server_tool1', | |
| 'mcp_server_tool2', | |
| 'mcp_other_tool', | |
| ], | |
| metadata: new Map([ | |
| ['mcp_server_tool1', { _serverName: 'server' }], | |
| ['mcp_server_tool2', { _serverName: 'server' }], | |
| ['mcp_other_tool', { _serverName: 'other' }], | |
| ]), | |
| expected: ['mcp_server_tool1', 'mcp_server_tool2'], | |
| }, | |
| { | |
| name: 'should expand server wildcard for specific tools if already processed', | |
| rules: [ | |
| { | |
| toolName: 'mcp_server_*', | |
| mcpName: 'server', | |
| decision: PolicyDecision.DENY, | |
| priority: 100, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| { | |
| toolName: 'mcp_server_tool1', | |
| mcpName: 'server', | |
| decision: PolicyDecision.DENY, // redundant but tests ordering | |
| priority: 10, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['mcp_server_tool1', 'mcp_server_tool2'], | |
| metadata: new Map([ | |
| ['mcp_server_tool1', { _serverName: 'server' }], | |
| ['mcp_server_tool2', { _serverName: 'server' }], | |
| ]), | |
| expected: ['mcp_server_tool1', 'mcp_server_tool2'], | |
| }, | |
| { | |
| name: 'should exclude run_shell_command but NOT write_file in simulated Plan Mode', | |
| approvalMode: ApprovalMode.PLAN, | |
| rules: [ | |
| { | |
| // Simulates the high-priority allow for plans directory | |
| toolName: 'write_file', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| argsPattern: /plans/, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| // Simulates the global deny in Plan Mode | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 60, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| // Simulates a tool from another policy (e.g. write.toml) | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 10, | |
| }, | |
| ], | |
| allToolNames: ['write_file', 'run_shell_command', 'read_file'], | |
| expected: ['run_shell_command', 'read_file'], | |
| }, | |
| { | |
| name: 'should NOT exclude tool if covered by a higher priority wildcard ALLOW', | |
| rules: [ | |
| { | |
| toolName: 'mcp_server_*', | |
| mcpName: 'server', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 100, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| { | |
| toolName: 'mcp_server_tool1', | |
| mcpName: 'server', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| modes: [ApprovalMode.DEFAULT], | |
| }, | |
| ], | |
| allToolNames: ['mcp_server_tool1'], | |
| metadata: new Map([['mcp_server_tool1', { _serverName: 'server' }]]), | |
| expected: [], | |
| }, | |
| { | |
| name: 'should handle global wildcard * in getExcludedTools', | |
| rules: [ | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| allToolNames: ['toolA', 'toolB', 'mcp_server_toolC'], | |
| expected: ['toolA', 'toolB', 'mcp_server_toolC'], // all tools denied by * | |
| }, | |
| { | |
| name: 'should handle MCP category wildcard *__* in getExcludedTools', | |
| rules: [ | |
| { | |
| toolName: 'mcp_*', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| allToolNames: ['localTool', 'mcp_myserver_mytool'], | |
| metadata: new Map([ | |
| ['mcp_myserver_mytool', { _serverName: 'myserver' }], | |
| ]), | |
| expected: ['mcp_myserver_mytool'], | |
| }, | |
| { | |
| name: 'should handle tool wildcard mcp_server_* in getExcludedTools', | |
| rules: [ | |
| { | |
| toolName: 'mcp_server_*', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| allToolNames: [ | |
| 'localTool', | |
| 'mcp_server_search', | |
| 'mcp_otherserver_read', | |
| ], | |
| metadata: new Map([ | |
| ['mcp_server_search', { _serverName: 'server' }], | |
| ['mcp_otherserver_read', { _serverName: 'otherserver' }], | |
| ]), | |
| expected: ['mcp_server_search'], | |
| }, | |
| ]; | |
| it.each(testCases)( | |
| '$name', | |
| ({ | |
| rules, | |
| approvalMode, | |
| nonInteractive, | |
| allToolNames, | |
| metadata, | |
| expected, | |
| }) => { | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: approvalMode ?? ApprovalMode.DEFAULT, | |
| nonInteractive: nonInteractive ?? false, | |
| }); | |
| const toolsSet = allToolNames ? new Set(allToolNames) : undefined; | |
| const excluded = engine.getExcludedTools(metadata, toolsSet); | |
| expect(Array.from(excluded).sort()).toEqual(expected.sort()); | |
| }, | |
| ); | |
| it('should skip annotation-based rules when no metadata is provided', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: '*', | |
| toolAnnotations: { destructiveHint: true }, | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| }); | |
| const excluded = engine.getExcludedTools( | |
| undefined, | |
| new Set(['dangerous_tool']), | |
| ); | |
| expect(Array.from(excluded)).toEqual([]); | |
| }); | |
| it('should exclude tools matching annotation-based DENY rule when metadata is provided', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: '*', | |
| toolAnnotations: { destructiveHint: true }, | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| }); | |
| const metadata = new Map<string, Record<string, unknown>>([ | |
| ['dangerous_tool', { destructiveHint: true }], | |
| ['safe_tool', { readOnlyHint: true }], | |
| ]); | |
| const excluded = engine.getExcludedTools( | |
| metadata, | |
| new Set(['dangerous_tool', 'safe_tool']), | |
| ); | |
| expect(Array.from(excluded)).toEqual(['dangerous_tool']); | |
| }); | |
| it('should NOT exclude tools whose annotations do not match', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: '*', | |
| toolAnnotations: { destructiveHint: true }, | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| }); | |
| const metadata = new Map<string, Record<string, unknown>>([ | |
| ['safe_tool', { readOnlyHint: true }], | |
| ]); | |
| const excluded = engine.getExcludedTools( | |
| metadata, | |
| new Set(['safe_tool']), | |
| ); | |
| expect(Array.from(excluded)).toEqual([]); | |
| }); | |
| it('should exclude tools matching both toolName pattern AND annotations', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'mcp_server_*', | |
| mcpName: 'server', | |
| toolAnnotations: { destructiveHint: true }, | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| }, | |
| ], | |
| }); | |
| const metadata = new Map<string, Record<string, unknown>>([ | |
| [ | |
| 'mcp_server_dangerous_tool', | |
| { destructiveHint: true, _serverName: 'server' }, | |
| ], | |
| [ | |
| 'mcp_other_dangerous_tool', | |
| { destructiveHint: true, _serverName: 'other' }, | |
| ], | |
| ['mcp_server_safe_tool', { readOnlyHint: true, _serverName: 'server' }], | |
| ]); | |
| const excluded = engine.getExcludedTools( | |
| metadata, | |
| new Set([ | |
| 'mcp_server_dangerous_tool', | |
| 'mcp_other_dangerous_tool', | |
| 'mcp_server_safe_tool', | |
| ]), | |
| ); | |
| expect(Array.from(excluded)).toEqual(['mcp_server_dangerous_tool']); | |
| }); | |
| it('should exclude unprocessed tools from allToolNames when global DENY is active', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'glob', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| }, | |
| { | |
| toolName: 'read_file', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| }, | |
| { | |
| // Simulates plan.toml: mcpName="*" → toolName="mcp_*" | |
| toolName: 'mcp_*', | |
| toolAnnotations: { readOnlyHint: true }, | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 70, | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 60, | |
| }, | |
| ], | |
| }); | |
| // MCP tools are registered with qualified names in ToolRegistry | |
| const allToolNames = new Set([ | |
| 'glob', | |
| 'read_file', | |
| 'shell', | |
| 'web_fetch', | |
| 'mcp_my-server_read_mcp_tool', | |
| 'mcp_my-server_write_mcp_tool', | |
| ]); | |
| // buildToolMetadata() includes _serverName for MCP tools | |
| const toolMetadata = new Map<string, Record<string, unknown>>([ | |
| [ | |
| 'mcp_my-server_read_mcp_tool', | |
| { readOnlyHint: true, _serverName: 'my-server' }, | |
| ], | |
| [ | |
| 'mcp_my-server_write_mcp_tool', | |
| { readOnlyHint: false, _serverName: 'my-server' }, | |
| ], | |
| ]); | |
| const excluded = engine.getExcludedTools(toolMetadata, allToolNames); | |
| expect(excluded.has('shell')).toBe(true); | |
| expect(excluded.has('web_fetch')).toBe(true); | |
| // Non-read-only MCP tool excluded by catch-all DENY | |
| expect(excluded.has('mcp_my-server_write_mcp_tool')).toBe(true); | |
| expect(excluded.has('glob')).toBe(false); | |
| expect(excluded.has('read_file')).toBe(false); | |
| // Read-only MCP tool allowed by annotation rule | |
| expect(excluded.has('mcp_my-server_read_mcp_tool')).toBe(false); | |
| }); | |
| it('should match MCP wildcard rules when explicitly mapped with _serverName', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'mcp_*', | |
| toolAnnotations: { readOnlyHint: true }, | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 70, | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 60, | |
| }, | |
| ], | |
| }); | |
| // Tool registered with qualified name (collision case) | |
| const allToolNames = new Set([ | |
| 'mcp_myserver_read_tool', | |
| 'mcp_myserver_write_tool', | |
| ]); | |
| const toolMetadata = new Map<string, Record<string, unknown>>([ | |
| [ | |
| 'mcp_myserver_read_tool', | |
| { readOnlyHint: true, _serverName: 'myserver' }, | |
| ], | |
| [ | |
| 'mcp_myserver_write_tool', | |
| { readOnlyHint: false, _serverName: 'myserver' }, | |
| ], | |
| ]); | |
| const excluded = engine.getExcludedTools(toolMetadata, allToolNames); | |
| // Qualified name matched using explicit _serverName | |
| expect(excluded.has('mcp_myserver_read_tool')).toBe(false); | |
| expect(excluded.has('mcp_myserver_write_tool')).toBe(true); | |
| }); | |
| it('should not exclude unprocessed tools when allToolNames is not provided (backward compat)', () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'glob', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| }, | |
| { | |
| toolName: 'read_file', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 60, | |
| }, | |
| ], | |
| }); | |
| const excluded = engine.getExcludedTools(); | |
| // Without allToolNames, only explicitly named DENY tools are excluded | |
| expect(excluded.has('shell')).toBe(false); | |
| expect(excluded.has('web_fetch')).toBe(false); | |
| expect(excluded.has('glob')).toBe(false); | |
| expect(excluded.has('read_file')).toBe(false); | |
| }); | |
| it('should correctly simulate plan.toml rules with allToolNames including MCP tools', () => { | |
| // Simulate plan.toml: catch-all DENY at priority 60, explicit ALLOWs at 70, | |
| // annotation-based ASK_USER for read-only MCP tools at priority 70. | |
| // mcpName="*" in TOML becomes toolName="*__*" after loading. | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'glob', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'grep_search', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'read_file', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'list_directory', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'google_web_search', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'activate_skill', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'ask_user', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'exit_plan_mode', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'mcp_*', | |
| toolAnnotations: { readOnlyHint: true }, | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: 'web_fetch', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 60, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| ], | |
| approvalMode: ApprovalMode.PLAN, | |
| }); | |
| // MCP tools are registered with unqualified names in ToolRegistry | |
| const allToolNames = new Set([ | |
| 'glob', | |
| 'grep_search', | |
| 'read_file', | |
| 'list_directory', | |
| 'google_web_search', | |
| 'activate_skill', | |
| 'ask_user', | |
| 'exit_plan_mode', | |
| 'shell', | |
| 'write_file', | |
| 'replace', | |
| 'web_fetch', | |
| 'write_todos', | |
| 'memory', | |
| 'mcp_mcp-server_read_tool', | |
| 'mcp_mcp-server_write_tool', | |
| ]); | |
| // buildToolMetadata() includes _serverName for MCP tools | |
| const toolMetadata = new Map<string, Record<string, unknown>>([ | |
| [ | |
| 'mcp_mcp-server_read_tool', | |
| { readOnlyHint: true, _serverName: 'mcp-server' }, | |
| ], | |
| [ | |
| 'mcp_mcp-server_write_tool', | |
| { readOnlyHint: false, _serverName: 'mcp-server' }, | |
| ], | |
| ]); | |
| const excluded = engine.getExcludedTools(toolMetadata, allToolNames); | |
| // These should be excluded (caught by catch-all DENY) | |
| expect(excluded.has('shell')).toBe(true); | |
| expect(excluded.has('write_todos')).toBe(true); | |
| expect(excluded.has('memory')).toBe(true); | |
| // write_file and replace are excluded unless they have argsPattern rules | |
| // (argsPattern rules don't exclude, but don't explicitly allow either) | |
| expect(excluded.has('write_file')).toBe(true); | |
| expect(excluded.has('replace')).toBe(true); | |
| // Non-read-only MCP tool excluded by catch-all DENY | |
| expect(excluded.has('mcp_mcp-server_write_tool')).toBe(true); | |
| // These should NOT be excluded (explicitly allowed) | |
| expect(excluded.has('glob')).toBe(false); | |
| expect(excluded.has('grep_search')).toBe(false); | |
| expect(excluded.has('read_file')).toBe(false); | |
| expect(excluded.has('list_directory')).toBe(false); | |
| expect(excluded.has('google_web_search')).toBe(false); | |
| expect(excluded.has('activate_skill')).toBe(false); | |
| expect(excluded.has('web_fetch')).toBe(false); | |
| expect(excluded.has('ask_user')).toBe(false); | |
| expect(excluded.has('exit_plan_mode')).toBe(false); | |
| // Read-only MCP tool allowed by annotation rule (matched via _serverName) | |
| expect(excluded.has('mcp_mcp-server_read_tool')).toBe(false); | |
| }); | |
| }); | |
| describe('YOLO mode with ask_user tool', () => { | |
| it('should return ASK_USER for ask_user tool even in YOLO mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'ask_user', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: PRIORITY_YOLO_ALLOW_ALL, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| const result = await engine.check( | |
| { name: 'ask_user', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should return ALLOW for other tools in YOLO mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'ask_user', | |
| decision: PolicyDecision.ASK_USER, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: PRIORITY_YOLO_ALLOW_ALL, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| const result = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'ls' } }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| }); | |
| describe('Plan Mode', () => { | |
| it('should allow activate_skill but deny shell commands in Plan Mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: '*', | |
| decision: PolicyDecision.DENY, | |
| priority: 60, | |
| modes: [ApprovalMode.PLAN], | |
| denyMessage: | |
| 'You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked.', | |
| }, | |
| { | |
| toolName: 'activate_skill', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.PLAN, | |
| }); | |
| const skillResult = await engine.check( | |
| { name: 'activate_skill', args: { name: 'test' } }, | |
| undefined, | |
| ); | |
| expect(skillResult.decision).toBe(PolicyDecision.ALLOW); | |
| const shellResult = await engine.check( | |
| { name: 'run_shell_command', args: { command: 'ls' } }, | |
| undefined, | |
| ); | |
| expect(shellResult.decision).toBe(PolicyDecision.DENY); | |
| expect(shellResult.rule?.denyMessage).toContain( | |
| 'Execution of scripts (including those from skills) is blocked', | |
| ); | |
| }); | |
| it('should deny enter_plan_mode when already in PLAN mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'enter_plan_mode', | |
| decision: PolicyDecision.DENY, | |
| priority: 70, | |
| modes: [ApprovalMode.PLAN], | |
| denyMessage: 'You are already in Plan Mode.', | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.PLAN, | |
| }); | |
| const result = await engine.check({ name: 'enter_plan_mode' }, undefined); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| expect(result.rule?.denyMessage).toBe('You are already in Plan Mode.'); | |
| }); | |
| it('should deny exit_plan_mode when in DEFAULT mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'exit_plan_mode', | |
| decision: PolicyDecision.DENY, | |
| priority: 10, | |
| modes: [ApprovalMode.DEFAULT], | |
| denyMessage: 'You are not in Plan Mode.', | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.DEFAULT, | |
| }); | |
| const result = await engine.check({ name: 'exit_plan_mode' }, undefined); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| expect(result.rule?.denyMessage).toBe('You are not in Plan Mode.'); | |
| }); | |
| it('should deny both plan tools in YOLO mode', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'enter_plan_mode', | |
| decision: PolicyDecision.DENY, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| { | |
| toolName: 'exit_plan_mode', | |
| decision: PolicyDecision.DENY, | |
| priority: 999, | |
| modes: [ApprovalMode.YOLO], | |
| }, | |
| ]; | |
| engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| const resultEnter = await engine.check( | |
| { name: 'enter_plan_mode' }, | |
| undefined, | |
| ); | |
| expect(resultEnter.decision).toBe(PolicyDecision.DENY); | |
| const resultExit = await engine.check( | |
| { name: 'exit_plan_mode' }, | |
| undefined, | |
| ); | |
| expect(resultExit.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| }); | |
| describe('removeRulesByTier', () => { | |
| it('should remove rules matching a specific tier', () => { | |
| engine.addRule({ | |
| toolName: 'rule1', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 1.1, | |
| }); | |
| engine.addRule({ | |
| toolName: 'rule2', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 1.5, | |
| }); | |
| engine.addRule({ | |
| toolName: 'rule3', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 2.1, | |
| }); | |
| engine.addRule({ | |
| toolName: 'rule4', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 0.5, | |
| }); | |
| engine.addRule({ toolName: 'rule5', decision: PolicyDecision.ALLOW }); // priority undefined -> 0 | |
| expect(engine.getRules()).toHaveLength(5); | |
| engine.removeRulesByTier(1); | |
| const rules = engine.getRules(); | |
| expect(rules).toHaveLength(3); | |
| expect(rules.some((r) => r.toolName === 'rule1')).toBe(false); | |
| expect(rules.some((r) => r.toolName === 'rule2')).toBe(false); | |
| expect(rules.some((r) => r.toolName === 'rule3')).toBe(true); | |
| expect(rules.some((r) => r.toolName === 'rule4')).toBe(true); | |
| expect(rules.some((r) => r.toolName === 'rule5')).toBe(true); | |
| }); | |
| it('should handle removing tier 0 rules (including undefined priority)', () => { | |
| engine.addRule({ | |
| toolName: 'rule1', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 0.5, | |
| }); | |
| engine.addRule({ toolName: 'rule2', decision: PolicyDecision.ALLOW }); // defaults to 0 | |
| engine.addRule({ | |
| toolName: 'rule3', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 1.5, | |
| }); | |
| expect(engine.getRules()).toHaveLength(3); | |
| engine.removeRulesByTier(0); | |
| const rules = engine.getRules(); | |
| expect(rules).toHaveLength(1); | |
| expect(rules[0].toolName).toBe('rule3'); | |
| }); | |
| }); | |
| describe('removeRulesBySource', () => { | |
| it('should remove rules matching a specific source', () => { | |
| engine.addRule({ | |
| toolName: 'rule1', | |
| decision: PolicyDecision.ALLOW, | |
| source: 'source1', | |
| }); | |
| engine.addRule({ | |
| toolName: 'rule2', | |
| decision: PolicyDecision.ALLOW, | |
| source: 'source2', | |
| }); | |
| engine.addRule({ | |
| toolName: 'rule3', | |
| decision: PolicyDecision.ALLOW, | |
| source: 'source1', | |
| }); | |
| expect(engine.getRules()).toHaveLength(3); | |
| engine.removeRulesBySource('source1'); | |
| const rules = engine.getRules(); | |
| expect(rules).toHaveLength(1); | |
| expect(rules[0].toolName).toBe('rule2'); | |
| }); | |
| }); | |
| describe('removeCheckersByTier', () => { | |
| it('should remove checkers matching a specific tier', () => { | |
| engine.addChecker({ | |
| toolName: '*', | |
| checker: { type: 'external', name: 'c1' }, | |
| priority: 1.1, | |
| }); | |
| engine.addChecker({ | |
| toolName: '*', | |
| checker: { type: 'external', name: 'c2' }, | |
| priority: 1.9, | |
| }); | |
| engine.addChecker({ | |
| toolName: '*', | |
| checker: { type: 'external', name: 'c3' }, | |
| priority: 2.5, | |
| }); | |
| expect(engine.getCheckers()).toHaveLength(3); | |
| engine.removeCheckersByTier(1); | |
| const checkers = engine.getCheckers(); | |
| expect(checkers).toHaveLength(1); | |
| expect(checkers[0].priority).toBe(2.5); | |
| }); | |
| }); | |
| describe('removeCheckersBySource', () => { | |
| it('should remove checkers matching a specific source', () => { | |
| engine.addChecker({ | |
| toolName: '*', | |
| checker: { type: 'external', name: 'c1' }, | |
| source: 'sourceA', | |
| }); | |
| engine.addChecker({ | |
| toolName: '*', | |
| checker: { type: 'external', name: 'c2' }, | |
| source: 'sourceB', | |
| }); | |
| engine.addChecker({ | |
| toolName: '*', | |
| checker: { type: 'external', name: 'c3' }, | |
| source: 'sourceA', | |
| }); | |
| expect(engine.getCheckers()).toHaveLength(3); | |
| engine.removeCheckersBySource('sourceA'); | |
| const checkers = engine.getCheckers(); | |
| expect(checkers).toHaveLength(1); | |
| expect(checkers[0].checker.name).toBe('c2'); | |
| }); | |
| }); | |
| describe('Tool Annotations', () => { | |
| it('should match tools by semantic annotations', async () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: '*', | |
| toolAnnotations: { readOnlyHint: true }, | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| ], | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const readOnlyTool = { name: 'read', args: {} }; | |
| const readOnlyMeta = { readOnlyHint: true, extra: 'info' }; | |
| const writeTool = { name: 'write', args: {} }; | |
| const writeMeta = { readOnlyHint: false }; | |
| expect( | |
| (await engine.check(readOnlyTool, undefined, readOnlyMeta)).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| (await engine.check(writeTool, undefined, writeMeta)).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| expect((await engine.check(writeTool, undefined, {})).decision).toBe( | |
| PolicyDecision.DENY, | |
| ); | |
| }); | |
| it('should support scoped annotation rules', async () => { | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'mcp_*', | |
| toolAnnotations: { experimental: true }, | |
| decision: PolicyDecision.DENY, | |
| priority: 20, | |
| }, | |
| { | |
| toolName: 'mcp_*', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 10, | |
| }, | |
| ], | |
| }); | |
| expect( | |
| ( | |
| await engine.check({ name: 'mcp_mcp_test' }, 'mcp', { | |
| experimental: true, | |
| }) | |
| ).decision, | |
| ).toBe(PolicyDecision.DENY); | |
| expect( | |
| ( | |
| await engine.check({ name: 'mcp_mcp_stable' }, 'mcp', { | |
| experimental: false, | |
| }) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| }); | |
| describe('hook checkers', () => { | |
| it('should add and retrieve hook checkers in priority order', () => { | |
| engine.addHookChecker({ | |
| checker: { type: 'external', name: 'h1' }, | |
| priority: 5, | |
| }); | |
| engine.addHookChecker({ | |
| checker: { type: 'external', name: 'h2' }, | |
| priority: 10, | |
| }); | |
| const hookCheckers = engine.getHookCheckers(); | |
| expect(hookCheckers).toHaveLength(2); | |
| expect(hookCheckers[0].priority).toBe(10); | |
| expect(hookCheckers[1].priority).toBe(5); | |
| }); | |
| }); | |
| describe('disableAlwaysAllow', () => { | |
| it('should ignore "Always Allow" rules when disableAlwaysAllow is true', async () => { | |
| const alwaysAllowRule: PolicyRule = { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000, // 3.95 | |
| source: 'Dynamic (Confirmed)', | |
| }; | |
| const engine = new PolicyEngine({ | |
| rules: [alwaysAllowRule], | |
| disableAlwaysAllow: true, | |
| defaultDecision: PolicyDecision.ASK_USER, | |
| }); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should respect "Always Allow" rules when disableAlwaysAllow is false', async () => { | |
| const alwaysAllowRule: PolicyRule = { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000, // 3.95 | |
| source: 'Dynamic (Confirmed)', | |
| }; | |
| const engine = new PolicyEngine({ | |
| rules: [alwaysAllowRule], | |
| disableAlwaysAllow: false, | |
| defaultDecision: PolicyDecision.ASK_USER, | |
| }); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should NOT ignore other rules when disableAlwaysAllow is true', async () => { | |
| const normalRule: PolicyRule = { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 1.5, // Not a .950 fraction | |
| source: 'Normal Rule', | |
| }; | |
| const engine = new PolicyEngine({ | |
| rules: [normalRule], | |
| disableAlwaysAllow: true, | |
| defaultDecision: PolicyDecision.ASK_USER, | |
| }); | |
| const result = await engine.check( | |
| { name: 'test-tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| }); | |
| describe('getExcludedTools with disableAlwaysAllow', () => { | |
| it('should exclude tool if an Always Allow rule says ALLOW but disableAlwaysAllow is true (falling back to DENY)', async () => { | |
| // To prove the ALWAYS_ALLOW rule is ignored, we set the default decision to DENY. | |
| // If the rule was honored, the decision would be ALLOW (tool not excluded). | |
| // Since it's ignored, it falls back to the default DENY (tool is excluded). | |
| // In the real app, it usually falls back to ASK_USER, but ASK_USER also doesn't | |
| // exclude the tool, so we use DENY here purely to make the test observable. | |
| const alwaysAllowRule: PolicyRule = { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000, | |
| }; | |
| const engine = new PolicyEngine({ | |
| rules: [alwaysAllowRule], | |
| disableAlwaysAllow: true, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const excluded = engine.getExcludedTools( | |
| undefined, | |
| new Set(['test-tool']), | |
| ); | |
| expect(excluded.has('test-tool')).toBe(true); | |
| }); | |
| it('should NOT exclude tool if ALWAYS_ALLOW is enabled and rule says ALLOW', async () => { | |
| const alwaysAllowRule: PolicyRule = { | |
| toolName: 'test-tool', | |
| decision: PolicyDecision.ALLOW, | |
| priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000, | |
| }; | |
| const engine = new PolicyEngine({ | |
| rules: [alwaysAllowRule], | |
| disableAlwaysAllow: false, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const excluded = engine.getExcludedTools( | |
| undefined, | |
| new Set(['test-tool']), | |
| ); | |
| expect(excluded.has('test-tool')).toBe(false); | |
| }); | |
| }); | |
| describe('interactive matching', () => { | |
| it('should ignore interactive rules in non-interactive mode', async () => { | |
| const engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'my_tool', | |
| decision: PolicyDecision.ALLOW, | |
| interactive: true, | |
| }, | |
| ], | |
| nonInteractive: true, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const result = await engine.check( | |
| { name: 'my_tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should allow interactive rules in interactive mode', async () => { | |
| const engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'my_tool', | |
| decision: PolicyDecision.ALLOW, | |
| interactive: true, | |
| }, | |
| ], | |
| nonInteractive: false, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const result = await engine.check( | |
| { name: 'my_tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should ignore non-interactive rules in interactive mode', async () => { | |
| const engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'my_tool', | |
| decision: PolicyDecision.ALLOW, | |
| interactive: false, | |
| }, | |
| ], | |
| nonInteractive: false, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const result = await engine.check( | |
| { name: 'my_tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| it('should allow non-interactive rules in non-interactive mode', async () => { | |
| const engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'my_tool', | |
| decision: PolicyDecision.ALLOW, | |
| interactive: false, | |
| }, | |
| ], | |
| nonInteractive: true, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const result = await engine.check( | |
| { name: 'my_tool', args: {} }, | |
| undefined, | |
| ); | |
| expect(result.decision).toBe(PolicyDecision.ALLOW); | |
| }); | |
| it('should apply rules without interactive flag to both', async () => { | |
| const rule: PolicyRule = { | |
| toolName: 'my_tool', | |
| decision: PolicyDecision.ALLOW, | |
| }; | |
| const engineInteractive = new PolicyEngine({ | |
| rules: [rule], | |
| nonInteractive: false, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| const engineNonInteractive = new PolicyEngine({ | |
| rules: [rule], | |
| nonInteractive: true, | |
| defaultDecision: PolicyDecision.DENY, | |
| }); | |
| expect( | |
| ( | |
| await engineInteractive.check( | |
| { name: 'my_tool', args: {} }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| expect( | |
| ( | |
| await engineNonInteractive.check( | |
| { name: 'my_tool', args: {} }, | |
| undefined, | |
| ) | |
| ).decision, | |
| ).toBe(PolicyDecision.ALLOW); | |
| }); | |
| }); | |
| describe('additional_permissions', () => { | |
| const workspace = '/workspace'; | |
| let mockSandboxManager: SandboxManager; | |
| let engine: PolicyEngine; | |
| beforeEach(() => { | |
| mockSandboxManager = { | |
| prepareCommand: vi.fn(), | |
| isKnownSafeCommand: vi.fn().mockReturnValue(false), | |
| isDangerousCommand: vi.fn().mockReturnValue(false), | |
| parseDenials: vi.fn(), | |
| getWorkspace: vi.fn().mockReturnValue(workspace), | |
| } as never as SandboxManager; | |
| engine = new PolicyEngine({ | |
| rules: [ | |
| { | |
| toolName: 'run_shell_command', | |
| decision: PolicyDecision.ALLOW, | |
| modes: [ApprovalMode.AUTO_EDIT], | |
| }, | |
| ], | |
| approvalMode: ApprovalMode.AUTO_EDIT, | |
| sandboxManager: mockSandboxManager, | |
| }); | |
| }); | |
| it('should allow permissions exactly at the workspace root', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| fileSystem: { | |
| read: [workspace], | |
| }, | |
| }, | |
| }, | |
| }; | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| it('should allow permissions for subpaths of the workspace', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| fileSystem: { | |
| read: [`${workspace}/subdir/file.txt`], | |
| }, | |
| }, | |
| }, | |
| }; | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| it('should downgrade ALLOW to ASK_USER if a read path is outside workspace', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| fileSystem: { | |
| read: ['/outside'], | |
| }, | |
| }, | |
| }, | |
| }; | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| }); | |
| it('should downgrade ALLOW to ASK_USER if a write path is outside workspace', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| fileSystem: { | |
| write: ['/outside/secret.txt'], | |
| }, | |
| }, | |
| }, | |
| }; | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| }); | |
| it('should downgrade ALLOW to ASK_USER if any path in a list is outside workspace', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| fileSystem: { | |
| read: [`${workspace}/safe`, '/outside'], | |
| }, | |
| }, | |
| }, | |
| }; | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ASK_USER, | |
| ); | |
| }); | |
| it('should handle missing or empty fileSystem permissions gracefully (ALLOW)', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| network: true, | |
| }, | |
| }, | |
| }; | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| it('should handle non-array fileSystem paths gracefully', async () => { | |
| const call = { | |
| name: 'run_shell_command', | |
| args: { | |
| command: 'ls', | |
| additional_permissions: { | |
| fileSystem: { | |
| read: '/not/an/array' as never as string[], | |
| }, | |
| }, | |
| }, | |
| }; | |
| // It should just ignore the non-array and keep ALLOW if no other rules trigger | |
| expect((await engine.check(call, undefined)).decision).toBe( | |
| PolicyDecision.ALLOW, | |
| ); | |
| }); | |
| }); | |
| describe('Build File Protection', () => { | |
| it('should allow regular files in AUTO_EDIT mode but require ASK_USER for build files', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'replace', | |
| decision: PolicyDecision.ALLOW, | |
| modes: [ApprovalMode.AUTO_EDIT], | |
| }, | |
| { | |
| toolName: 'write_file', | |
| decision: PolicyDecision.ALLOW, | |
| modes: [ApprovalMode.AUTO_EDIT], | |
| }, | |
| ]; | |
| const engine = new PolicyEngine({ | |
| rules, | |
| approvalMode: ApprovalMode.AUTO_EDIT, | |
| }); | |
| // Regular source file should be ALLOWed | |
| const regularCheck = await engine.check( | |
| { | |
| name: 'replace', | |
| args: { file_path: 'src/index.ts', instruction: 'edit' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(regularCheck.decision).toBe(PolicyDecision.ALLOW); | |
| // Bazel BUILD file should require ASK_USER | |
| const buildCheck = await engine.check( | |
| { | |
| name: 'replace', | |
| args: { file_path: 'pkg/BUILD', instruction: 'edit' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(buildCheck.decision).toBe(PolicyDecision.ASK_USER); | |
| expect(buildCheck.rule?.source).toBe('Build File Protection'); | |
| // WORKSPACE file should require ASK_USER | |
| const workspaceCheck = await engine.check( | |
| { | |
| name: 'replace', | |
| args: { file_path: 'WORKSPACE', instruction: 'edit' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(workspaceCheck.decision).toBe(PolicyDecision.ASK_USER); | |
| // package.json should require ASK_USER | |
| const packageJsonCheck = await engine.check( | |
| { | |
| name: 'write_file', | |
| args: { file_path: 'package.json', content: '{}' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(packageJsonCheck.decision).toBe(PolicyDecision.ASK_USER); | |
| // Makefile should require ASK_USER | |
| const makefileCheck = await engine.check( | |
| { | |
| name: 'write_file', | |
| args: { file_path: 'Makefile', content: 'all:\n\techo hi' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(makefileCheck.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should require ASK_USER for build files even in YOLO mode', async () => { | |
| const engine = new PolicyEngine({ | |
| approvalMode: ApprovalMode.YOLO, | |
| }); | |
| // Regular file should be ALLOWed in YOLO mode | |
| const regularCheck = await engine.check( | |
| { | |
| name: 'replace', | |
| args: { file_path: 'src/main.py', instruction: 'edit' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(regularCheck.decision).toBe(PolicyDecision.ALLOW); | |
| // BUILD.bazel must still require ASK_USER | |
| const buildCheck = await engine.check( | |
| { | |
| name: 'replace', | |
| args: { file_path: 'BUILD.bazel', instruction: 'edit' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(buildCheck.decision).toBe(PolicyDecision.ASK_USER); | |
| }); | |
| it('should return DENY for build files in nonInteractive mode when otherwise ALLOWed', async () => { | |
| const rules: PolicyRule[] = [ | |
| { | |
| toolName: 'replace', | |
| decision: PolicyDecision.ALLOW, | |
| }, | |
| ]; | |
| const engine = new PolicyEngine({ | |
| rules, | |
| nonInteractive: true, | |
| }); | |
| const buildCheck = await engine.check( | |
| { | |
| name: 'replace', | |
| args: { file_path: 'BUILD', instruction: 'edit' }, | |
| }, | |
| undefined, | |
| ); | |
| expect(buildCheck.decision).toBe(PolicyDecision.DENY); | |
| }); | |
| }); | |
| }); | |