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.