diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index b4a0df7336..12b6bb7cbd 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -120,7 +120,8 @@ The manifest file defines the extension's behavior and configuration. } }, "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] + "excludeTools": ["run_shell_command"], + "policies": "policies.toml" } ``` @@ -135,6 +136,8 @@ The manifest file defines the extension's behavior and configuration. also be an array of strings to load multiple context files. - `excludeTools`: An array of tools to block from the model. You can restrict specific arguments, such as `run_shell_command(rm -rf)`. +- `policies`: An optional path to a policy TOML file relative to the extension + root. See [Policy Engine](#policy-engine) for more information. - `themes`: An optional list of themes provided by the extension. See [Themes](../cli/themes.md) for more information. @@ -203,6 +206,48 @@ 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](../admin/policy-engine.md). These rules are defined in a TOML +file and take effect when the extension is activated. + +To add policies, specify the file path in your `gemini-extension.json`: + +```json +{ + "name": "my-secure-extension", + "version": "1.0.0", + "policies": "policies.toml" +} +``` + +Rules contributed by extensions run in the **Extension Tier** (Tier 2). This +tier has higher priority than the default rules but lower priority than +workspace-specific or user-defined 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/package-lock.json b/package-lock.json index 0bfce7daa0..ec22d4cd4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2129,6 +2129,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -2271,6 +2272,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2451,6 +2453,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2500,6 +2503,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2874,6 +2878,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2907,6 +2912,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2961,6 +2967,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4124,6 +4131,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4398,6 +4406,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5323,6 +5332,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7863,6 +7873,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8383,6 +8394,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9679,6 +9691,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9979,6 +9992,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13667,6 +13681,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13677,6 +13692,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15730,6 +15746,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15953,7 +15970,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15961,6 +15979,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16121,6 +16140,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16328,6 +16348,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16441,6 +16462,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16453,6 +16475,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17084,6 +17107,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17419,6 +17443,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", + "strip-json-comments": "^3.1.1", "systeminformation": "^5.25.11", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", @@ -17619,6 +17644,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, 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..19d7bc9726 --- /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.toml` file 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 that points to the policy file. +- `policies.toml`: Contains the policy rules and safety checkers. + +## 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..b8f858c362 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json @@ -0,0 +1,6 @@ +{ + "name": "policy-example", + "version": "1.0.0", + "description": "An example extension demonstrating Policy Engine support.", + "policies": "policies.toml" +} diff --git a/packages/cli/src/commands/extensions/examples/policies/policies.toml b/packages/cli/src/commands/extensions/examples/policies/policies.toml new file mode 100644 index 0000000000..d89d5e5737 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/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 179959d83e..f189eacb68 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -51,6 +51,12 @@ import { applyAdminAllowlist, getAdminBlockedMcpServersMessage, CoreToolCallStatus, + EXTENSION_POLICY_TIER, + loadPoliciesFromToml, + PolicyDecision, + ApprovalMode, + type PolicyRule, + type SafetyCheckerRule, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; @@ -752,6 +758,78 @@ Would you like to attempt to install via "git clone" instead?`, recursivelyHydrateStrings(skill, hydrationContext), ); + let rules: PolicyRule[] | undefined; + let checkers: SafetyCheckerRule[] | undefined; + + if (config.policies) { + const policyPath = path.join(effectiveExtensionPath, config.policies); + if (fs.existsSync(policyPath)) { + const result = await loadPoliciesFromToml( + [policyPath], + () => EXTENSION_POLICY_TIER, + ); + rules = result.rules; + checkers = result.checkers; + + // Prefix source with extension name to avoid collisions + if (rules) { + rules = rules.filter((rule) => { + // Security: Extensions are not allowed to automatically approve tool calls. + // We ignore any rule that is ALLOW. + if (rule.decision === PolicyDecision.ALLOW) { + debugLogger.warn( + `[ExtensionManager] Extension "${config.name}" 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( + `[ExtensionManager] Extension "${config.name}" attempted to contribute a rule for YOLO mode. Ignoring this rule for security.`, + ); + return false; + } + + rule.source = `Extension (${config.name}): ${rule.source}`; + return true; + }); + } + + if (checkers) { + checkers = checkers.filter((checker) => { + // Security: Extensions are not allowed to contribute YOLO mode checkers. + if (checker.modes?.includes(ApprovalMode.YOLO)) { + debugLogger.warn( + `[ExtensionManager] Extension "${config.name}" attempted to contribute a safety checker for YOLO mode. Ignoring this checker for security.`, + ); + return false; + } + + checker.source = `Extension (${config.name}): ${checker.source}`; + return true; + }); + } + if (checkers) { + for (const checker of checkers) { + checker.source = `Extension (${config.name}): ${checker.source}`; + } + } + + if (result.errors.length > 0) { + for (const error of result.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}`, + ); + } + } + } else { + debugLogger.warn( + `[ExtensionManager] Policy file not found for ${config.name}: ${policyPath}`, + ); + } + } + const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); @@ -785,6 +863,8 @@ Would you like to attempt to install via "git clone" instead?`, skills, agents: agentLoadResult.agents, themes: config.themes, + rules, + checkers, }; this.loadedExtensions = [...this.loadedExtensions, extension]; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ece..e9b6e06e0c 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -33,6 +33,10 @@ export interface ExtensionConfig { * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; + /** + * Path to a policy TOML file relative to the extension root. + */ + policies?: string; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index dbc7f6a415..1aab0ceaa1 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -412,25 +412,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) @@ -577,16 +577,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/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index 56d02e7053..92c5c9a466 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -27,6 +27,7 @@ export function createExtension({ installMetadata = undefined as ExtensionInstallMetadata | undefined, settings = undefined as ExtensionSetting[] | undefined, themes = undefined as CustomTheme[] | undefined, + policies = undefined as string | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); @@ -39,6 +40,7 @@ export function createExtension({ mcpServers, settings, themes, + policies, }), ); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 42f8508697..84bf194405 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -106,7 +106,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, @@ -280,6 +285,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.ts b/packages/core/src/policy/config.ts index 7de415cb37..50219c154c 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -39,14 +39,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; diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 353cdae9c1..330fa67b25 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -484,6 +484,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). */ @@ -493,6 +500,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 e706b16bf7..2263be9866 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -216,30 +216,30 @@ 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 3 and Tier 4 policies', async () => { await fs.writeFile( - path.join(tempDir, 'tier2.toml'), + path.join(tempDir, 'tier3.toml'), ` [[rule]] -toolName = "tier2-tool" +toolName = "tier3-tool" decision = "allow" priority = 100 modes = ["autoEdit"] `, ); - const getPolicyTier2 = (_dir: string) => 2; // Tier 2 - const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2); - - 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 getPolicyTier3 = (_dir: string) => 3; // Tier 3 (Workspace) const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); - expect(result3.rules[0].source).toBe('User: tier2.toml'); - expect(result3.errors).toHaveLength(0); + + expect(result3.rules).toHaveLength(1); + expect(result3.rules[0].toolName).toBe('tier3-tool'); + expect(result3.rules[0].modes).toEqual(['autoEdit']); + expect(result3.rules[0].source).toBe('Workspace: tier3.toml'); + + const getPolicyTier4 = (_dir: string) => 4; // Tier 4 (User) + const result4 = await loadPoliciesFromToml([tempDir], getPolicyTier4); + expect(result4.rules[0].source).toBe('User: tier3.toml'); + expect(result4.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 7be3fe27dc..05f3b47147 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -106,7 +106,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; @@ -171,11 +171,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/utils/extensionLoader.test.ts b/packages/core/src/utils/extensionLoader.test.ts index 9cbcd51e06..6c981cc53c 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): Extension: policies.toml', + }, + ], + checkers: [ + { + toolName: 'test-tool', + checker: { type: 'external', name: 'test-checker' }, + source: 'Extension (test-extension): 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): Extension: policies.toml', + ); + expect(mockPolicyEngine.removeCheckersBySource).toHaveBeenCalledWith( + 'Extension (test-extension): 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.