mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-30 13:43:00 -07:00
Add support for policy engine in extensions
This commit is contained in:
@@ -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.
|
||||
|
||||
### <a id="policy-engine"></a>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
|
||||
|
||||
Generated
+27
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "policy-example",
|
||||
"version": "1.0.0",
|
||||
"description": "An example extension demonstrating Policy Engine support.",
|
||||
"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"]
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>();
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user