diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index d36df94d78..2c2b730126 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -227,6 +227,42 @@ skill definitions in a `skills/` directory. For example, Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. +### Policy Engine + +Extensions can contribute policy rules and safety checkers to the Gemini CLI +[Policy Engine](../reference/policy-engine.md). These rules are defined in +`.toml` files and take effect when the extension is activated. + +To add policies, create a `policies/` directory in your extension's root and +place your `.toml` policy files inside it. Gemini CLI automatically loads all +`.toml` files from this directory. + +Rules contributed by extensions run in their own tier (tier 2), alongside +workspace-defined policies. This tier has higher priority than the default rules +but lower priority than user or admin policies. + +> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo` +> mode configurations in extension policies. This ensures that an extension +> cannot automatically approve tool calls or bypass security measures without +> your confirmation. + +**Example `policies.toml`** + +```toml +[[rule]] +toolName = "my_server__dangerous_tool" +decision = "ask_user" +priority = 100 + +[[safety_checker]] +toolName = "my_server__write_data" +priority = 200 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] +``` + ### Themes Extensions can provide custom themes to personalize the CLI UI. Themes are diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index a123634581..810c591c24 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -97,9 +97,10 @@ has a designated number that forms the base of the final priority calculation. | Tier | Base | Description | | :-------- | :--- | :------------------------------------------------------------------------- | | Default | 1 | Built-in policies that ship with the Gemini CLI. | -| Workspace | 2 | Policies defined in the current workspace's configuration directory. | -| User | 3 | Custom policies defined by the user. | -| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Extension | 2 | Policies defined in extensions. | +| Workspace | 3 | Policies defined in the current workspace's configuration directory. | +| User | 4 | Custom policies defined by the user. | +| Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: diff --git a/packages/cli/src/commands/extensions/examples/policies/README.md b/packages/cli/src/commands/extensions/examples/policies/README.md new file mode 100644 index 0000000000..d1c06de6e3 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/README.md @@ -0,0 +1,41 @@ +# Policy engine example extension + +This extension demonstrates how to contribute security rules and safety checkers +to the Gemini CLI Policy Engine. + +## Description + +The extension uses a `policies/` directory containing `.toml` files to define: + +- A rule that requires user confirmation for `rm -rf` commands. +- A rule that denies searching for sensitive files (like `.env`) using `grep`. +- A safety checker that validates file paths for all write operations. + +## Structure + +- `gemini-extension.json`: The manifest file. +- `policies/`: Contains the `.toml` policy files. + +## How to use + +1. Link this extension to your local Gemini CLI installation: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/policies + ``` + +2. Restart your Gemini CLI session. + +3. **Observe the policies:** + - Try asking the model to delete a directory: The policy engine will prompt + you for confirmation due to the `rm -rf` rule. + - Try asking the model to search for secrets: The `grep` rule will deny the + request and display the custom deny message. + - Any file write operation will now be processed through the `allowed-path` + safety checker. + +## Security note + +For security, Gemini CLI ignores any `allow` decisions or `yolo` mode +configurations contributed by extensions. This ensures that extensions can +strengthen security but cannot bypass user confirmation. diff --git a/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json new file mode 100644 index 0000000000..2a2b992532 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "policy-example", + "version": "1.0.0", + "description": "An example extension demonstrating Policy Engine support." +} diff --git a/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml new file mode 100644 index 0000000000..d89d5e5737 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml @@ -0,0 +1,28 @@ +# Example Policy Rules for Gemini CLI Extension +# +# Extensions run in Tier 2 (Extension Tier). +# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored. + +# Rule: Always ask the user before running a specific dangerous shell command. +[[rule]] +toolName = "run_shell_command" +commandPrefix = "rm -rf" +decision = "ask_user" +priority = 100 + +# Rule: Deny access to sensitive files using the grep tool. +[[rule]] +toolName = "grep_search" +argsPattern = "(\.env|id_rsa|passwd)" +decision = "deny" +priority = 200 +deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension." + +# Safety Checker: Apply path validation to all write operations. +[[safety_checker]] +toolName = ["write_file", "replace"] +priority = 300 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 93ad3f3536..56152cd6e1 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -52,6 +52,10 @@ import { applyAdminAllowlist, getAdminBlockedMcpServersMessage, CoreToolCallStatus, + loadExtensionPolicies, + isSubpath, + type PolicyRule, + type SafetyCheckerRule, HookType, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; @@ -764,9 +768,18 @@ Would you like to attempt to install via "git clone" instead?`, } const contextFiles = getContextFileNames(config) - .map((contextFileName) => - path.join(effectiveExtensionPath, contextFileName), - ) + .map((contextFileName) => { + const contextFilePath = path.join( + effectiveExtensionPath, + contextFileName, + ); + if (!isSubpath(effectiveExtensionPath, contextFilePath)) { + throw new Error( + `Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`, + ); + } + return contextFilePath; + }) .filter((contextFilePath) => fs.existsSync(contextFilePath)); const hydrationContext: VariableContext = { @@ -820,6 +833,24 @@ Would you like to attempt to install via "git clone" instead?`, recursivelyHydrateStrings(skill, hydrationContext), ); + let rules: PolicyRule[] | undefined; + let checkers: SafetyCheckerRule[] | undefined; + + const policyDir = path.join(effectiveExtensionPath, 'policies'); + if (fs.existsSync(policyDir)) { + const result = await loadExtensionPolicies(config.name, policyDir); + rules = result.rules; + checkers = result.checkers; + + if (result.errors.length > 0) { + for (const error of result.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, + ); + } + } + } + const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); @@ -853,6 +884,8 @@ Would you like to attempt to install via "git clone" instead?`, skills, agents: agentLoadResult.agents, themes: config.themes, + rules, + checkers, }; } catch (e) { debugLogger.error( diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index affcd0cef0..f8e66bf8e2 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -239,6 +239,27 @@ describe('extension tests', () => { expect(extensions[0].name).toBe('test-extension'); }); + it('should throw an error if a context file path is outside the extension directory', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'traversal-extension', + version: '1.0.0', + contextFileName: '../secret.txt', + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(0); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'traversal-extension: Invalid context file path: "../secret.txt"', + ), + ); + consoleSpy.mockRestore(); + }); + it('should load context file path when GEMINI.md is present', async () => { createExtension({ extensionsDir: userExtensionsDir, @@ -363,6 +384,111 @@ describe('extension tests', () => { ]); }); + it('should load extension policies from the policies directory', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'policy-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "deny_tool" +decision = "deny" +priority = 500 + +[[rule]] +toolName = "ask_tool" +decision = "ask_user" +priority = 100 +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(2); + expect( + extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision, + ).toBe('deny'); + expect( + extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision, + ).toBe('ask_user'); + // Verify source is prefixed + expect(extension.rules![0].source).toContain( + 'Extension (policy-extension):', + ); + }); + + it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'security-test-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "allow_tool" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "yolo_tool" +decision = "ask_user" +priority = 100 +modes = ["yolo"] + +[[safety_checker]] +toolName = "yolo_check" +priority = 100 +modes = ["yolo"] +[safety_checker.checker] +type = "external" +name = "yolo-checker" +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + // ALLOW rules and YOLO rules/checkers should be filtered out + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(0); + expect(extension.checkers).toBeDefined(); + expect(extension.checkers).toHaveLength(0); + + // Should have logged warnings + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute an ALLOW rule'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute a rule for YOLO mode'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'attempted to contribute a safety checker for YOLO mode', + ), + ); + consoleSpy.mockRestore(); + }); + it('should hydrate ${extensionPath} correctly for linked extensions', async () => { const sourceExtDir = getRealPath( createExtension({ @@ -540,7 +666,7 @@ describe('extension tests', () => { // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext'); - fs.mkdirSync(badExtDir); + fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed @@ -548,7 +674,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, ), @@ -571,7 +697,7 @@ describe('extension tests', () => { // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); - fs.mkdirSync(badExtDir); + fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); @@ -579,7 +705,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 1d7573337e..02515815d0 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -177,13 +177,13 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 3.1) provides general allow for server - // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server expect( (await engine.check({ name: 'my-server__safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 3.4) wins over server allow + // But specific tool exclude (priority 4.4) wins over server allow expect( (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) .decision, @@ -476,25 +476,25 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(3.3); // Command line allow + expect(specificToolRule?.priority).toBe(4.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server + expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) @@ -641,16 +641,16 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) + expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) + expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2f5d452446..32d74479e7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -107,7 +107,12 @@ import { FileExclusions } from '../utils/ignorePatterns.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import type { EventEmitter } from 'node:events'; import { PolicyEngine } from '../policy/policy-engine.js'; -import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js'; +import { + ApprovalMode, + type PolicyEngineConfig, + type PolicyRule, + type SafetyCheckerRule, +} from '../policy/types.js'; import { HookSystem } from '../hooks/index.js'; import type { UserTierId, @@ -324,6 +329,14 @@ export interface GeminiCLIExtension { * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; + /** + * Policy rules contributed by this extension. + */ + rules?: PolicyRule[]; + /** + * Safety checkers contributed by this extension. + */ + checkers?: SafetyCheckerRule[]; } export interface ExtensionInstallMetadata { diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index a9fae7a1fa..3ded361084 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(4.3, 5); // Command line allow }); it('should deny tools in tools.exclude', async () => { @@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude + expect(rule?.priority).toBeCloseTo(4.4, 5); // Command line exclude }); it('should allow tools from allowed MCP servers', async () => { @@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(3.1); // MCP allowed server + expect(rule?.priority).toBe(4.1); // MCP allowed server }); it('should deny tools from excluded MCP servers', async () => { @@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(3.9); // MCP excluded server + expect(rule?.priority).toBe(4.9); // MCP excluded server }); it('should allow tools from trusted MCP servers', async () => { @@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(3.2); // MCP trusted server + expect(trustedRule?.priority).toBe(4.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(3.1); // MCP allowed server + expect(allowedRule?.priority).toBe(4.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(3.2); // MCP trusted server + expect(trustedRule?.priority).toBe(4.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(3.9); // MCP excluded server + expect(excludedRule?.priority).toBe(4.9); // MCP excluded server }); it('should allow all tools in YOLO mode', async () => { @@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server + expect(serverDenyRule?.priority).toBe(4.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow + expect(toolAllowRule?.priority).toBeCloseTo(4.3, 5); // Command line allow - // Server deny (3.9) has higher priority than tool allow (3.3), + // Server deny (4.9) has higher priority than tool allow (4.3), // so server deny wins (this is expected behavior - server-level blocks are security critical) }); @@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); - // Command line exclude (3.4) has higher priority than MCP server trust (3.2) + // Command line exclude (4.4) has higher priority than MCP server trust (4.2) // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); @@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => { it('should handle complex priority scenarios correctly', async () => { const settings: PolicySettings = { tools: { - allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3 - exclude: ['my-server__tool2', 'glob'], // Priority 3.4 + allowed: ['my-server__tool1', 'other-tool'], // Priority 4.3 + exclude: ['my-server__tool2', 'glob'], // Priority 4.4 }, mcp: { - allowed: ['allowed-server'], // Priority 3.1 - excluded: ['excluded-server'], // Priority 3.9 + allowed: ['allowed-server'], // Priority 4.1 + excluded: ['excluded-server'], // Priority 4.9 }, mcpServers: { 'trusted-server': { - trust: true, // Priority 90 -> 3.2 + trust: true, // Priority 4.2 }, }, }; @@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => { expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); // Deny from settings (user tier) - expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude + expect(globDenyRule!.priority).toBeCloseTo(4.4, 5); // Command line exclude // Allow from default TOML: 1 + 50/1000 = 1.05 expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); @@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes (user tier: 3.4 and 3.9) + // Check that the highest priority items are the excludes (user tier: 4.4 and 4.9) const highestPriorityExcludes = priorities?.filter( (p) => - Math.abs(p.priority! - 3.4) < 0.01 || - Math.abs(p.priority! - 3.9) < 0.01, + Math.abs(p.priority! - 4.4) < 0.01 || + Math.abs(p.priority! - 4.9) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), @@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude + expect(excludeRule?.priority).toBeCloseTo(4.4, 5); // Command line exclude }); it('should support argsPattern in policy rules', async () => { @@ -733,8 +733,8 @@ priority = 150 r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - // Priority 150 in user tier → 3.150 - expect(rule?.priority).toBeCloseTo(3.15, 5); + // Priority 150 in user tier → 4.150 + expect(rule?.priority).toBeCloseTo(4.15, 5); expect(rule?.argsPattern).toBeInstanceOf(RegExp); expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); @@ -1046,7 +1046,7 @@ name = "invalid-name" r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(4.3, 5); // Command line allow vi.doUnmock('node:fs/promises'); }); @@ -1188,7 +1188,7 @@ modes = ["plan"] r.modes?.includes(ApprovalMode.PLAN), ); expect(subagentRule).toBeDefined(); - expect(subagentRule?.priority).toBeCloseTo(3.1, 5); + expect(subagentRule?.priority).toBeCloseTo(4.1, 5); vi.doUnmock('node:fs/promises'); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 7de415cb37..800006e27e 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -13,8 +13,9 @@ import { type PolicyEngineConfig, PolicyDecision, type PolicyRule, - type ApprovalMode, + ApprovalMode, type PolicySettings, + type SafetyCheckerRule, } from './types.js'; import type { PolicyEngine } from './policy-engine.js'; import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js'; @@ -39,14 +40,15 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies'); // Policy tier constants for priority calculation export const DEFAULT_POLICY_TIER = 1; -export const WORKSPACE_POLICY_TIER = 2; -export const USER_POLICY_TIER = 3; -export const ADMIN_POLICY_TIER = 4; +export const EXTENSION_POLICY_TIER = 2; +export const WORKSPACE_POLICY_TIER = 3; +export const USER_POLICY_TIER = 4; +export const ADMIN_POLICY_TIER = 5; // Specific priority offsets and derived priorities for dynamic/settings rules. // These are added to the tier base (e.g., USER_POLICY_TIER). -// Workspace tier (2) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY +// Workspace tier (3) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY // This ensures user "always allow" selections are high priority // within the workspace tier but still lose to user/admin policies. export const ALWAYS_ALLOW_PRIORITY = WORKSPACE_POLICY_TIER + 0.95; @@ -59,7 +61,9 @@ export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1; /** * Gets the list of directories to search for policy files, in order of increasing priority - * (Default -> User -> Project -> Admin). + * (Default -> Extension -> Workspace -> User -> Admin). + * + * Note: Extension policies are loaded separately by the extension manager. * * @param defaultPoliciesDir Optional path to a directory containing default policies. * @param policyPaths Optional user-provided policy paths (from --policy flag). @@ -95,7 +99,7 @@ export function getPolicyDirectories( } /** - * Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory. + * Determines the policy tier (1=default, 2=extension, 3=workspace, 4=user, 5=admin) for a given directory. * This is used by the TOML loader to assign priority bands. */ export function getPolicyTier( @@ -178,6 +182,69 @@ async function filterSecurePolicyDirectories( return results.filter((dir): dir is string => dir !== null); } +/** + * Loads and sanitizes policies from an extension's policies directory. + * Security: Filters out 'ALLOW' rules and YOLO mode configurations. + */ +export async function loadExtensionPolicies( + extensionName: string, + policyDir: string, +): Promise<{ + rules: PolicyRule[]; + checkers: SafetyCheckerRule[]; + errors: PolicyFileError[]; +}> { + const result = await loadPoliciesFromToml( + [policyDir], + () => EXTENSION_POLICY_TIER, + ); + + const rules = result.rules.filter((rule) => { + // Security: Extensions are not allowed to automatically approve tool calls. + if (rule.decision === PolicyDecision.ALLOW) { + debugLogger.warn( + `[PolicyConfig] Extension "${extensionName}" attempted to contribute an ALLOW rule for tool "${rule.toolName}". Ignoring this rule for security.`, + ); + return false; + } + + // Security: Extensions are not allowed to contribute YOLO mode rules. + if (rule.modes?.includes(ApprovalMode.YOLO)) { + debugLogger.warn( + `[PolicyConfig] Extension "${extensionName}" attempted to contribute a rule for YOLO mode. Ignoring this rule for security.`, + ); + return false; + } + + // Prefix source with extension name to avoid collisions and double prefixing. + // toml-loader.ts adds "Extension: file.toml", we transform it to "Extension (name): file.toml". + rule.source = rule.source?.replace( + /^Extension: /, + `Extension (${extensionName}): `, + ); + return true; + }); + + const checkers = result.checkers.filter((checker) => { + // Security: Extensions are not allowed to contribute YOLO mode checkers. + if (checker.modes?.includes(ApprovalMode.YOLO)) { + debugLogger.warn( + `[PolicyConfig] Extension "${extensionName}" attempted to contribute a safety checker for YOLO mode. Ignoring this checker for security.`, + ); + return false; + } + + // Prefix source with extension name. + checker.source = checker.source?.replace( + /^Extension: /, + `Extension (${extensionName}): `, + ); + return true; + }); + + return { rules, checkers, errors: result.errors }; +} + export async function createPolicyEngineConfig( settings: PolicySettings, approvalMode: ApprovalMode, @@ -234,17 +301,19 @@ export async function createPolicyEngineConfig( const checkers = [...tomlCheckers]; // Priority system for policy rules: + // - Higher priority numbers win over lower priority numbers // - When multiple rules match, the highest priority rule is applied // - Rules are evaluated in order of priority (highest first) // // Priority bands (tiers): // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) - // - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) - // - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) - // - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) + // - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) + // - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100) // - // This ensures Admin > User > Workspace > Default hierarchy is always preserved, + // This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved, // while allowing user-specified priorities to work within each tier. // // Settings-based and dynamic rules (mixed tiers): @@ -254,7 +323,7 @@ export async function createPolicyEngineConfig( // TRUSTED_MCP_SERVER_PRIORITY: MCP servers with trust=true (persistent trusted servers) // ALLOWED_MCP_SERVER_PRIORITY: MCP servers allowed list (persistent general server allows) // ALWAYS_ALLOW_PRIORITY: Tools that the user has selected as "Always Allow" in the interactive UI - // (Workspace tier 2.x - scoped to the project) + // (Workspace tier 3.x - scoped to the project) // // TOML policy priorities (before transformation): // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 7accf5c7e5..f93c9ad3b8 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -406,6 +406,40 @@ describe('PolicyEngine', () => { 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 }); @@ -2836,6 +2870,34 @@ describe('PolicyEngine', () => { }); }); + 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({ @@ -2861,6 +2923,31 @@ describe('PolicyEngine', () => { }); }); + describe('removeCheckersBySource', () => { + it('should remove checkers matching a specific source', () => { + engine.addChecker({ + checker: { type: 'external', name: 'c1' }, + source: 'sourceA', + }); + engine.addChecker({ + checker: { type: 'external', name: 'c2' }, + source: 'sourceB', + }); + engine.addChecker({ + 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({ @@ -2924,4 +3011,22 @@ describe('PolicyEngine', () => { ).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); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 8f61d622c2..03087716ff 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -562,6 +562,13 @@ export class PolicyEngine { ); } + /** + * Remove rules matching a specific source. + */ + removeRulesBySource(source: string): void { + this.rules = this.rules.filter((rule) => rule.source !== source); + } + /** * Remove checkers matching a specific tier (priority band). */ @@ -571,6 +578,15 @@ export class PolicyEngine { ); } + /** + * Remove checkers matching a specific source. + */ + removeCheckersBySource(source: string): void { + this.checkers = this.checkers.filter( + (checker) => checker.source !== source, + ); + } + /** * Remove rules for a specific tool. * If source is provided, only rules matching that source are removed. diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 1e4c008c5d..54a81771b8 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -262,30 +262,34 @@ deny_message = "Deletion is permanent" expect(result.errors).toHaveLength(0); }); - it('should support modes property for Tier 2 and Tier 3 policies', async () => { + it('should support modes property for Tier 4 and Tier 5 policies', async () => { await fs.writeFile( - path.join(tempDir, 'tier2.toml'), + path.join(tempDir, 'tier4.toml'), ` [[rule]] -toolName = "tier2-tool" +toolName = "tier4-tool" decision = "allow" priority = 100 modes = ["autoEdit"] `, ); - const getPolicyTier2 = (_dir: string) => 2; // Tier 2 + const getPolicyTier4 = (_dir: string) => 4; // Tier 4 (User) + const result4 = await loadPoliciesFromToml([tempDir], getPolicyTier4); + + expect(result4.rules).toHaveLength(1); + expect(result4.rules[0].toolName).toBe('tier4-tool'); + expect(result4.rules[0].modes).toEqual(['autoEdit']); + expect(result4.rules[0].source).toBe('User: tier4.toml'); + + const getPolicyTier2 = (_dir: string) => 2; // Tier 2 (Extension) const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2); + expect(result2.rules[0].source).toBe('Extension: tier4.toml'); - expect(result2.rules).toHaveLength(1); - expect(result2.rules[0].toolName).toBe('tier2-tool'); - expect(result2.rules[0].modes).toEqual(['autoEdit']); - expect(result2.rules[0].source).toBe('Workspace: tier2.toml'); - - const getPolicyTier3 = (_dir: string) => 3; // Tier 3 - const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); - expect(result3.rules[0].source).toBe('User: tier2.toml'); - expect(result3.errors).toHaveLength(0); + const getPolicyTier5 = (_dir: string) => 5; // Tier 5 (Admin) + const result5 = await loadPoliciesFromToml([tempDir], getPolicyTier5); + expect(result5.rules[0].source).toBe('Admin: tier4.toml'); + expect(result5.errors).toHaveLength(0); }); it('should handle TOML parse errors', async () => { diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index 6b164d59b8..df4bd3ca9e 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -108,7 +108,7 @@ export type PolicyFileErrorType = export interface PolicyFileError { filePath: string; fileName: string; - tier: 'default' | 'user' | 'workspace' | 'admin'; + tier: 'default' | 'extension' | 'user' | 'workspace' | 'admin'; ruleIndex?: number; errorType: PolicyFileErrorType; message: string; @@ -173,11 +173,14 @@ export async function readPolicyFiles( /** * Converts a tier number to a human-readable tier name. */ -function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' { +function getTierName( + tier: number, +): 'default' | 'extension' | 'user' | 'workspace' | 'admin' { if (tier === 1) return 'default'; - if (tier === 2) return 'workspace'; - if (tier === 3) return 'user'; - if (tier === 4) return 'admin'; + if (tier === 2) return 'extension'; + if (tier === 3) return 'workspace'; + if (tier === 4) return 'user'; + if (tier === 5) return 'admin'; return 'default'; } diff --git a/packages/core/src/policy/workspace-policy.test.ts b/packages/core/src/policy/workspace-policy.test.ts index 999dae6f0d..0a277bc072 100644 --- a/packages/core/src/policy/workspace-policy.test.ts +++ b/packages/core/src/policy/workspace-policy.test.ts @@ -34,7 +34,7 @@ describe('Workspace-Level Policies', () => { vi.doUnmock('node:fs/promises'); }); - it('should load workspace policies with correct priority (Tier 2)', async () => { + it('should load workspace policies with correct priority (Tier 3)', async () => { const workspacePoliciesDir = '/mock/workspace/policies'; const defaultPoliciesDir = '/mock/default/policies'; @@ -98,21 +98,21 @@ priority = 10 toolName = "test_tool" decision = "deny" priority = 10 -`; // Tier 3 -> 3.010 +`; // Tier 4 -> 4.010 } if (path.includes('workspace.toml')) { return `[[rule]] toolName = "test_tool" decision = "allow" priority = 10 -`; // Tier 2 -> 2.010 +`; // Tier 3 -> 3.010 } if (path.includes('admin.toml')) { return `[[rule]] toolName = "test_tool" decision = "deny" priority = 10 -`; // Tier 4 -> 4.010 +`; // Tier 5 -> 5.010 } return ''; }); @@ -144,9 +144,9 @@ priority = 10 // Check for all 4 rules const defaultRule = rules?.find((r) => r.priority === 1.01); - const workspaceRule = rules?.find((r) => r.priority === 2.01); - const userRule = rules?.find((r) => r.priority === 3.01); - const adminRule = rules?.find((r) => r.priority === 4.01); + const workspaceRule = rules?.find((r) => r.priority === 3.01); + const userRule = rules?.find((r) => r.priority === 4.01); + const adminRule = rules?.find((r) => r.priority === 5.01); expect(defaultRule).toBeDefined(); expect(userRule).toBeDefined(); @@ -224,7 +224,7 @@ priority=10`, expect(rules![0].priority).toBe(1.01); }); - it('should load workspace policies and correctly transform to Tier 2', async () => { + it('should load workspace policies and correctly transform to Tier 3', async () => { const workspacePoliciesDir = '/mock/workspace/policies'; // Mock FS @@ -284,7 +284,7 @@ priority=500`, const rule = config.rules?.find((r) => r.toolName === 'p_tool'); expect(rule).toBeDefined(); - // Workspace Tier (2) + 500/1000 = 2.5 - expect(rule?.priority).toBe(2.5); + // Workspace Tier (3) + 500/1000 = 3.5 + expect(rule?.priority).toBe(3.5); }); }); diff --git a/packages/core/src/utils/extensionLoader.test.ts b/packages/core/src/utils/extensionLoader.test.ts index 9cbcd51e06..17526b99a8 100644 --- a/packages/core/src/utils/extensionLoader.test.ts +++ b/packages/core/src/utils/extensionLoader.test.ts @@ -14,6 +14,7 @@ import { type MockInstance, } from 'vitest'; import { SimpleExtensionLoader } from './extensionLoader.js'; +import { PolicyDecision } from '../policy/types.js'; import type { Config, GeminiCLIExtension } from '../config/config.js'; import { type McpClientManager } from '../tools/mcp-client-manager.js'; import type { GeminiClient } from '../core/client.js'; @@ -38,6 +39,12 @@ describe('SimpleExtensionLoader', () => { let mockHookSystemInit: MockInstance; let mockAgentRegistryReload: MockInstance; let mockSkillsReload: MockInstance; + let mockPolicyEngine: { + addRule: MockInstance; + addChecker: MockInstance; + removeRulesBySource: MockInstance; + removeCheckersBySource: MockInstance; + }; const activeExtension: GeminiCLIExtension = { name: 'test-extension', @@ -47,7 +54,22 @@ describe('SimpleExtensionLoader', () => { contextFiles: [], excludeTools: ['some-tool'], id: '123', + rules: [ + { + toolName: 'test-tool', + decision: PolicyDecision.ALLOW, + source: 'Extension (test-extension): policies.toml', + }, + ], + checkers: [ + { + toolName: 'test-tool', + checker: { type: 'external', name: 'test-checker' }, + source: 'Extension (test-extension): policies.toml', + }, + ], }; + const inactiveExtension: GeminiCLIExtension = { name: 'test-extension', isActive: false, @@ -67,6 +89,12 @@ describe('SimpleExtensionLoader', () => { mockHookSystemInit = vi.fn(); mockAgentRegistryReload = vi.fn(); mockSkillsReload = vi.fn(); + mockPolicyEngine = { + addRule: vi.fn(), + addChecker: vi.fn(), + removeRulesBySource: vi.fn(), + removeCheckersBySource: vi.fn(), + }; mockConfig = { getMcpClientManager: () => mockMcpClientManager, getEnableExtensionReloading: () => extensionReloadingEnabled, @@ -81,6 +109,7 @@ describe('SimpleExtensionLoader', () => { reload: mockAgentRegistryReload, }), reloadSkills: mockSkillsReload, + getPolicyEngine: () => mockPolicyEngine, } as unknown as Config; }); @@ -88,6 +117,29 @@ describe('SimpleExtensionLoader', () => { vi.restoreAllMocks(); }); + it('should register policies when an extension starts', async () => { + const loader = new SimpleExtensionLoader([activeExtension]); + await loader.start(mockConfig); + expect(mockPolicyEngine.addRule).toHaveBeenCalledWith( + activeExtension.rules![0], + ); + expect(mockPolicyEngine.addChecker).toHaveBeenCalledWith( + activeExtension.checkers![0], + ); + }); + + it('should unregister policies when an extension stops', async () => { + const loader = new TestingSimpleExtensionLoader([activeExtension]); + await loader.start(mockConfig); + await loader.stopExtension(activeExtension); + expect(mockPolicyEngine.removeRulesBySource).toHaveBeenCalledWith( + 'Extension (test-extension): policies.toml', + ); + expect(mockPolicyEngine.removeCheckersBySource).toHaveBeenCalledWith( + 'Extension (test-extension): policies.toml', + ); + }); + it('should start active extensions', async () => { const loader = new SimpleExtensionLoader([activeExtension]); await loader.start(mockConfig); diff --git a/packages/core/src/utils/extensionLoader.ts b/packages/core/src/utils/extensionLoader.ts index 7110ba8615..8fdee33c2a 100644 --- a/packages/core/src/utils/extensionLoader.ts +++ b/packages/core/src/utils/extensionLoader.ts @@ -75,6 +75,21 @@ export abstract class ExtensionLoader { await this.config.getMcpClientManager()!.startExtension(extension); await this.maybeRefreshGeminiTools(extension); + // Register policy rules and checkers + if (extension.rules || extension.checkers) { + const policyEngine = this.config.getPolicyEngine(); + if (extension.rules) { + for (const rule of extension.rules) { + policyEngine.addRule(rule); + } + } + if (extension.checkers) { + for (const checker of extension.checkers) { + policyEngine.addChecker(checker); + } + } + } + // Note: Context files are loaded only once all extensions are done // loading/unloading to reduce churn, see the `maybeRefreshMemories` call // below. @@ -168,6 +183,27 @@ export abstract class ExtensionLoader { await this.config.getMcpClientManager()!.stopExtension(extension); await this.maybeRefreshGeminiTools(extension); + // Unregister policy rules and checkers + if (extension.rules || extension.checkers) { + const policyEngine = this.config.getPolicyEngine(); + const sources = new Set(); + if (extension.rules) { + for (const rule of extension.rules) { + if (rule.source) sources.add(rule.source); + } + } + if (extension.checkers) { + for (const checker of extension.checkers) { + if (checker.source) sources.add(checker.source); + } + } + + for (const source of sources) { + policyEngine.removeRulesBySource(source); + policyEngine.removeCheckersBySource(source); + } + } + // Note: Context files are loaded only once all extensions are done // loading/unloading to reduce churn, see the `maybeRefreshMemories` call // below.