mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9be5347b3e | |||
| 2c82d18c9f | |||
| 6f11d7eaf7 | |||
| e599bf8ca3 | |||
| 5ddaccde67 | |||
| 06979f7e22 | |||
| 9851e0c024 | |||
| 29ab667755 | |||
| dd58d49aac | |||
| 1a973b9a3b | |||
| 8f83f1fd99 | |||
| 41185d15e7 |
@@ -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.
|
||||
|
||||
### <a id="policy-engine"></a>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 the **Workspace 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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "policy-example",
|
||||
"version": "1.0.0",
|
||||
"description": "An example extension demonstrating Policy Engine support."
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
Storage,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -3465,3 +3467,117 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig extension plan settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
// Mock getProjectIdentifier to avoid "Storage must be initialized before use" error
|
||||
// when accessing plansDir without a custom directory set.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(Storage.prototype as any, 'getProjectIdentifier').mockReturnValue(
|
||||
'test-project',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should use plan directory from active extension when user has not specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should prefer user-specified plan directory over extension-provided one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { directory: 'user-plans-dir' },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
|
||||
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should use the first active extension plan directory and log a warning if multiple are found', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
const warnSpy = vi.spyOn(debugLogger, 'warn');
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan-1',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir-1' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
{
|
||||
name: 'ext-plan-2',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir-2' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('ext-plans-dir-1');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Multiple active extensions define a plan directory',
|
||||
),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ext-plan-1'));
|
||||
});
|
||||
|
||||
it('should ignore plan directory from inactive extensions', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan-inactive',
|
||||
isActive: false,
|
||||
plan: { directory: 'ext-plans-dir-inactive' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).not.toContain(
|
||||
'ext-plans-dir-inactive',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HierarchicalMemory,
|
||||
type PlanSettings,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
@@ -511,6 +512,21 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// Filter active extensions that define a plan directory
|
||||
const activeExtensionsWithPlan = extensionManager
|
||||
.getExtensions()
|
||||
.filter((e) => e.isActive && e.plan?.directory);
|
||||
|
||||
let extensionPlanSettings: PlanSettings | undefined;
|
||||
if (activeExtensionsWithPlan.length > 0) {
|
||||
if (activeExtensionsWithPlan.length > 1) {
|
||||
debugLogger.warn(
|
||||
`Multiple active extensions define a plan directory. Using plan directory from extension: "${activeExtensionsWithPlan[0].name}"`,
|
||||
);
|
||||
}
|
||||
extensionPlanSettings = activeExtensionsWithPlan[0].plan;
|
||||
}
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
@@ -827,7 +843,9 @@ export async function loadCliConfig(
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -51,6 +51,13 @@ import {
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
CoreToolCallStatus,
|
||||
EXTENSION_POLICY_TIER,
|
||||
loadPoliciesFromToml,
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
isSubpath,
|
||||
type PolicyRule,
|
||||
type SafetyCheckerRule,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -698,9 +705,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 = {
|
||||
@@ -752,6 +768,78 @@ 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 (!isSubpath(effectiveExtensionPath, policyDir)) {
|
||||
throw new Error(
|
||||
`Invalid policy directory path. Policies must be within the extension directory.`,
|
||||
);
|
||||
}
|
||||
if (fs.existsSync(policyDir)) {
|
||||
const result = await loadPoliciesFromToml(
|
||||
[policyDir],
|
||||
() => 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 = rule.source?.startsWith(`Extension (${config.name}):`)
|
||||
? 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 = checker.source?.startsWith(
|
||||
`Extension (${config.name}):`,
|
||||
)
|
||||
? checker.source
|
||||
: `Extension (${config.name}): ${checker.source}`;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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'),
|
||||
);
|
||||
@@ -774,6 +862,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
installMetadata,
|
||||
mcpServers: config.mcpServers,
|
||||
excludeTools: config.excludeTools,
|
||||
plan: config.plan,
|
||||
hooks,
|
||||
isActive: this.extensionEnablementManager.isEnabled(
|
||||
config.name,
|
||||
@@ -785,6 +874,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];
|
||||
|
||||
|
||||
@@ -238,6 +238,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,
|
||||
@@ -360,6 +381,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 = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
@@ -535,7 +661,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
|
||||
|
||||
@@ -543,7 +669,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}`,
|
||||
),
|
||||
@@ -566,7 +692,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' }));
|
||||
|
||||
@@ -574,7 +700,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"`,
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ExtensionConfig {
|
||||
contextFileName?: string | string[];
|
||||
excludeTools?: string[];
|
||||
settings?: ExtensionSetting[];
|
||||
plan?: { directory?: string };
|
||||
/**
|
||||
* Custom themes contributed by this extension.
|
||||
* These themes will be registered when the extension is activated.
|
||||
|
||||
@@ -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,
|
||||
@@ -303,6 +308,7 @@ export interface GeminiCLIExtension {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
contextFiles: string[];
|
||||
excludeTools?: string[];
|
||||
plan?: PlanSettings;
|
||||
id: string;
|
||||
hooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
settings?: ExtensionSetting[];
|
||||
@@ -314,6 +320,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 {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
|
||||
export const DEFAULT_POLICY_TIER = 1;
|
||||
export const WORKSPACE_POLICY_TIER = 2;
|
||||
export const USER_POLICY_TIER = 3;
|
||||
export const EXTENSION_POLICY_TIER = WORKSPACE_POLICY_TIER;
|
||||
export const ADMIN_POLICY_TIER = 4;
|
||||
|
||||
// Specific priority offsets and derived priorities for dynamic/settings rules.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -2828,6 +2862,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({
|
||||
@@ -2853,6 +2915,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({
|
||||
@@ -2916,4 +3003,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -262,30 +262,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 (User)
|
||||
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('User: tier3.toml');
|
||||
|
||||
const getPolicyTier4 = (_dir: string) => 4; // Tier 4 (Admin)
|
||||
const result4 = await loadPoliciesFromToml([tempDir], getPolicyTier4);
|
||||
expect(result4.rules[0].source).toBe('Admin: tier3.toml');
|
||||
expect(result4.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle TOML parse errors', async () => {
|
||||
|
||||
@@ -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