mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
Add support for policy engine in extensions (#20049)
Co-authored-by: Jerop Kipruto <jerop@google.com>
This commit is contained in:
@@ -107,7 +107,12 @@ import { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
type PolicyEngineConfig,
|
||||
type PolicyRule,
|
||||
type SafetyCheckerRule,
|
||||
} from '../policy/types.js';
|
||||
import { HookSystem } from '../hooks/index.js';
|
||||
import type {
|
||||
UserTierId,
|
||||
@@ -324,6 +329,14 @@ export interface GeminiCLIExtension {
|
||||
* These themes will be registered when the extension is activated.
|
||||
*/
|
||||
themes?: CustomTheme[];
|
||||
/**
|
||||
* Policy rules contributed by this extension.
|
||||
*/
|
||||
rules?: PolicyRule[];
|
||||
/**
|
||||
* Safety checkers contributed by this extension.
|
||||
*/
|
||||
checkers?: SafetyCheckerRule[];
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow
|
||||
expect(rule?.priority).toBeCloseTo(4.3, 5); // Command line allow
|
||||
});
|
||||
|
||||
it('should deny tools in tools.exclude', async () => {
|
||||
@@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude
|
||||
expect(rule?.priority).toBeCloseTo(4.4, 5); // Command line exclude
|
||||
});
|
||||
|
||||
it('should allow tools from allowed MCP servers', async () => {
|
||||
@@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(3.1); // MCP allowed server
|
||||
expect(rule?.priority).toBe(4.1); // MCP allowed server
|
||||
});
|
||||
|
||||
it('should deny tools from excluded MCP servers', async () => {
|
||||
@@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(3.9); // MCP excluded server
|
||||
expect(rule?.priority).toBe(4.9); // MCP excluded server
|
||||
});
|
||||
|
||||
it('should allow tools from trusted MCP servers', async () => {
|
||||
@@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(trustedRule).toBeDefined();
|
||||
expect(trustedRule?.priority).toBe(3.2); // MCP trusted server
|
||||
expect(trustedRule?.priority).toBe(4.2); // MCP trusted server
|
||||
|
||||
// Untrusted server should not have an allow rule
|
||||
const untrustedRule = config.rules?.find(
|
||||
@@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(allowedRule).toBeDefined();
|
||||
expect(allowedRule?.priority).toBe(3.1); // MCP allowed server
|
||||
expect(allowedRule?.priority).toBe(4.1); // MCP allowed server
|
||||
|
||||
// Check trusted server
|
||||
const trustedRule = config.rules?.find(
|
||||
@@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(trustedRule).toBeDefined();
|
||||
expect(trustedRule?.priority).toBe(3.2); // MCP trusted server
|
||||
expect(trustedRule?.priority).toBe(4.2); // MCP trusted server
|
||||
|
||||
// Check excluded server
|
||||
const excludedRule = config.rules?.find(
|
||||
@@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(excludedRule).toBeDefined();
|
||||
expect(excludedRule?.priority).toBe(3.9); // MCP excluded server
|
||||
expect(excludedRule?.priority).toBe(4.9); // MCP excluded server
|
||||
});
|
||||
|
||||
it('should allow all tools in YOLO mode', async () => {
|
||||
@@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => {
|
||||
);
|
||||
|
||||
expect(serverDenyRule).toBeDefined();
|
||||
expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server
|
||||
expect(serverDenyRule?.priority).toBe(4.9); // MCP excluded server
|
||||
expect(toolAllowRule).toBeDefined();
|
||||
expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow
|
||||
expect(toolAllowRule?.priority).toBeCloseTo(4.3, 5); // Command line allow
|
||||
|
||||
// Server deny (3.9) has higher priority than tool allow (3.3),
|
||||
// Server deny (4.9) has higher priority than tool allow (4.3),
|
||||
// so server deny wins (this is expected behavior - server-level blocks are security critical)
|
||||
});
|
||||
|
||||
@@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
|
||||
expect(serverAllowRule).toBeDefined();
|
||||
expect(toolDenyRule).toBeDefined();
|
||||
// Command line exclude (3.4) has higher priority than MCP server trust (3.2)
|
||||
// Command line exclude (4.4) has higher priority than MCP server trust (4.2)
|
||||
// This is the correct behavior - specific exclusions should beat general server trust
|
||||
expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!);
|
||||
});
|
||||
@@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => {
|
||||
it('should handle complex priority scenarios correctly', async () => {
|
||||
const settings: PolicySettings = {
|
||||
tools: {
|
||||
allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3
|
||||
exclude: ['my-server__tool2', 'glob'], // Priority 3.4
|
||||
allowed: ['my-server__tool1', 'other-tool'], // Priority 4.3
|
||||
exclude: ['my-server__tool2', 'glob'], // Priority 4.4
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['allowed-server'], // Priority 3.1
|
||||
excluded: ['excluded-server'], // Priority 3.9
|
||||
allowed: ['allowed-server'], // Priority 4.1
|
||||
excluded: ['excluded-server'], // Priority 4.9
|
||||
},
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
trust: true, // Priority 90 -> 3.2
|
||||
trust: true, // Priority 4.2
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
expect(globDenyRule).toBeDefined();
|
||||
expect(globAllowRule).toBeDefined();
|
||||
// Deny from settings (user tier)
|
||||
expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude
|
||||
expect(globDenyRule!.priority).toBeCloseTo(4.4, 5); // Command line exclude
|
||||
// Allow from default TOML: 1 + 50/1000 = 1.05
|
||||
expect(globAllowRule!.priority).toBeCloseTo(1.05, 5);
|
||||
|
||||
@@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => {
|
||||
}))
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
|
||||
// Check that the highest priority items are the excludes (user tier: 3.4 and 3.9)
|
||||
// Check that the highest priority items are the excludes (user tier: 4.4 and 4.9)
|
||||
const highestPriorityExcludes = priorities?.filter(
|
||||
(p) =>
|
||||
Math.abs(p.priority! - 3.4) < 0.01 ||
|
||||
Math.abs(p.priority! - 3.9) < 0.01,
|
||||
Math.abs(p.priority! - 4.4) < 0.01 ||
|
||||
Math.abs(p.priority! - 4.9) < 0.01,
|
||||
);
|
||||
expect(
|
||||
highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY),
|
||||
@@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => {
|
||||
r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(excludeRule).toBeDefined();
|
||||
expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude
|
||||
expect(excludeRule?.priority).toBeCloseTo(4.4, 5); // Command line exclude
|
||||
});
|
||||
|
||||
it('should support argsPattern in policy rules', async () => {
|
||||
@@ -733,8 +733,8 @@ priority = 150
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
// Priority 150 in user tier → 3.150
|
||||
expect(rule?.priority).toBeCloseTo(3.15, 5);
|
||||
// Priority 150 in user tier → 4.150
|
||||
expect(rule?.priority).toBeCloseTo(4.15, 5);
|
||||
expect(rule?.argsPattern).toBeInstanceOf(RegExp);
|
||||
expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true);
|
||||
expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true);
|
||||
@@ -1046,7 +1046,7 @@ name = "invalid-name"
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow
|
||||
expect(rule?.priority).toBeCloseTo(4.3, 5); // Command line allow
|
||||
|
||||
vi.doUnmock('node:fs/promises');
|
||||
});
|
||||
@@ -1188,7 +1188,7 @@ modes = ["plan"]
|
||||
r.modes?.includes(ApprovalMode.PLAN),
|
||||
);
|
||||
expect(subagentRule).toBeDefined();
|
||||
expect(subagentRule?.priority).toBeCloseTo(3.1, 5);
|
||||
expect(subagentRule?.priority).toBeCloseTo(4.1, 5);
|
||||
|
||||
vi.doUnmock('node:fs/promises');
|
||||
});
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
type PolicyEngineConfig,
|
||||
PolicyDecision,
|
||||
type PolicyRule,
|
||||
type ApprovalMode,
|
||||
ApprovalMode,
|
||||
type PolicySettings,
|
||||
type SafetyCheckerRule,
|
||||
} from './types.js';
|
||||
import type { PolicyEngine } from './policy-engine.js';
|
||||
import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js';
|
||||
@@ -39,14 +40,15 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
|
||||
|
||||
// Policy tier constants for priority calculation
|
||||
export const DEFAULT_POLICY_TIER = 1;
|
||||
export const WORKSPACE_POLICY_TIER = 2;
|
||||
export const USER_POLICY_TIER = 3;
|
||||
export const ADMIN_POLICY_TIER = 4;
|
||||
export const EXTENSION_POLICY_TIER = 2;
|
||||
export const WORKSPACE_POLICY_TIER = 3;
|
||||
export const USER_POLICY_TIER = 4;
|
||||
export const ADMIN_POLICY_TIER = 5;
|
||||
|
||||
// Specific priority offsets and derived priorities for dynamic/settings rules.
|
||||
// These are added to the tier base (e.g., USER_POLICY_TIER).
|
||||
|
||||
// Workspace tier (2) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY
|
||||
// Workspace tier (3) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY
|
||||
// This ensures user "always allow" selections are high priority
|
||||
// within the workspace tier but still lose to user/admin policies.
|
||||
export const ALWAYS_ALLOW_PRIORITY = WORKSPACE_POLICY_TIER + 0.95;
|
||||
@@ -59,7 +61,9 @@ export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1;
|
||||
|
||||
/**
|
||||
* Gets the list of directories to search for policy files, in order of increasing priority
|
||||
* (Default -> User -> Project -> Admin).
|
||||
* (Default -> Extension -> Workspace -> User -> Admin).
|
||||
*
|
||||
* Note: Extension policies are loaded separately by the extension manager.
|
||||
*
|
||||
* @param defaultPoliciesDir Optional path to a directory containing default policies.
|
||||
* @param policyPaths Optional user-provided policy paths (from --policy flag).
|
||||
@@ -95,7 +99,7 @@ export function getPolicyDirectories(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory.
|
||||
* Determines the policy tier (1=default, 2=extension, 3=workspace, 4=user, 5=admin) for a given directory.
|
||||
* This is used by the TOML loader to assign priority bands.
|
||||
*/
|
||||
export function getPolicyTier(
|
||||
@@ -178,6 +182,69 @@ async function filterSecurePolicyDirectories(
|
||||
return results.filter((dir): dir is string => dir !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and sanitizes policies from an extension's policies directory.
|
||||
* Security: Filters out 'ALLOW' rules and YOLO mode configurations.
|
||||
*/
|
||||
export async function loadExtensionPolicies(
|
||||
extensionName: string,
|
||||
policyDir: string,
|
||||
): Promise<{
|
||||
rules: PolicyRule[];
|
||||
checkers: SafetyCheckerRule[];
|
||||
errors: PolicyFileError[];
|
||||
}> {
|
||||
const result = await loadPoliciesFromToml(
|
||||
[policyDir],
|
||||
() => EXTENSION_POLICY_TIER,
|
||||
);
|
||||
|
||||
const rules = result.rules.filter((rule) => {
|
||||
// Security: Extensions are not allowed to automatically approve tool calls.
|
||||
if (rule.decision === PolicyDecision.ALLOW) {
|
||||
debugLogger.warn(
|
||||
`[PolicyConfig] Extension "${extensionName}" attempted to contribute an ALLOW rule for tool "${rule.toolName}". Ignoring this rule for security.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: Extensions are not allowed to contribute YOLO mode rules.
|
||||
if (rule.modes?.includes(ApprovalMode.YOLO)) {
|
||||
debugLogger.warn(
|
||||
`[PolicyConfig] Extension "${extensionName}" attempted to contribute a rule for YOLO mode. Ignoring this rule for security.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefix source with extension name to avoid collisions and double prefixing.
|
||||
// toml-loader.ts adds "Extension: file.toml", we transform it to "Extension (name): file.toml".
|
||||
rule.source = rule.source?.replace(
|
||||
/^Extension: /,
|
||||
`Extension (${extensionName}): `,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
const checkers = result.checkers.filter((checker) => {
|
||||
// Security: Extensions are not allowed to contribute YOLO mode checkers.
|
||||
if (checker.modes?.includes(ApprovalMode.YOLO)) {
|
||||
debugLogger.warn(
|
||||
`[PolicyConfig] Extension "${extensionName}" attempted to contribute a safety checker for YOLO mode. Ignoring this checker for security.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefix source with extension name.
|
||||
checker.source = checker.source?.replace(
|
||||
/^Extension: /,
|
||||
`Extension (${extensionName}): `,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
return { rules, checkers, errors: result.errors };
|
||||
}
|
||||
|
||||
export async function createPolicyEngineConfig(
|
||||
settings: PolicySettings,
|
||||
approvalMode: ApprovalMode,
|
||||
@@ -234,17 +301,19 @@ export async function createPolicyEngineConfig(
|
||||
const checkers = [...tomlCheckers];
|
||||
|
||||
// Priority system for policy rules:
|
||||
|
||||
// - Higher priority numbers win over lower priority numbers
|
||||
// - When multiple rules match, the highest priority rule is applied
|
||||
// - Rules are evaluated in order of priority (highest first)
|
||||
//
|
||||
// Priority bands (tiers):
|
||||
// - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
|
||||
// - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
|
||||
// - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
|
||||
// - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
|
||||
// - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
|
||||
// - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
|
||||
// - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
|
||||
// - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100)
|
||||
//
|
||||
// This ensures Admin > User > Workspace > Default hierarchy is always preserved,
|
||||
// This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved,
|
||||
// while allowing user-specified priorities to work within each tier.
|
||||
//
|
||||
// Settings-based and dynamic rules (mixed tiers):
|
||||
@@ -254,7 +323,7 @@ export async function createPolicyEngineConfig(
|
||||
// TRUSTED_MCP_SERVER_PRIORITY: MCP servers with trust=true (persistent trusted servers)
|
||||
// ALLOWED_MCP_SERVER_PRIORITY: MCP servers allowed list (persistent general server allows)
|
||||
// ALWAYS_ALLOW_PRIORITY: Tools that the user has selected as "Always Allow" in the interactive UI
|
||||
// (Workspace tier 2.x - scoped to the project)
|
||||
// (Workspace tier 3.x - scoped to the project)
|
||||
//
|
||||
// TOML policy priorities (before transformation):
|
||||
// 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
|
||||
|
||||
@@ -406,6 +406,40 @@ describe('PolicyEngine', () => {
|
||||
expect(remainingRules.some((r) => r.toolName === 'tool2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove rules for specific tool and source', () => {
|
||||
engine.addRule({
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'source1',
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.DENY,
|
||||
source: 'source2',
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'tool2',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'source1',
|
||||
});
|
||||
|
||||
expect(engine.getRules()).toHaveLength(3);
|
||||
|
||||
engine.removeRulesForTool('tool1', 'source1');
|
||||
|
||||
const rules = engine.getRules();
|
||||
expect(rules).toHaveLength(2);
|
||||
expect(
|
||||
rules.some((r) => r.toolName === 'tool1' && r.source === 'source2'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rules.some((r) => r.toolName === 'tool2' && r.source === 'source1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rules.some((r) => r.toolName === 'tool1' && r.source === 'source1'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent tool', () => {
|
||||
engine.addRule({ toolName: 'existing', decision: PolicyDecision.ALLOW });
|
||||
|
||||
@@ -2836,6 +2870,34 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRulesBySource', () => {
|
||||
it('should remove rules matching a specific source', () => {
|
||||
engine.addRule({
|
||||
toolName: 'rule1',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'source1',
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'rule2',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'source2',
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'rule3',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'source1',
|
||||
});
|
||||
|
||||
expect(engine.getRules()).toHaveLength(3);
|
||||
|
||||
engine.removeRulesBySource('source1');
|
||||
|
||||
const rules = engine.getRules();
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].toolName).toBe('rule2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCheckersByTier', () => {
|
||||
it('should remove checkers matching a specific tier', () => {
|
||||
engine.addChecker({
|
||||
@@ -2861,6 +2923,31 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCheckersBySource', () => {
|
||||
it('should remove checkers matching a specific source', () => {
|
||||
engine.addChecker({
|
||||
checker: { type: 'external', name: 'c1' },
|
||||
source: 'sourceA',
|
||||
});
|
||||
engine.addChecker({
|
||||
checker: { type: 'external', name: 'c2' },
|
||||
source: 'sourceB',
|
||||
});
|
||||
engine.addChecker({
|
||||
checker: { type: 'external', name: 'c3' },
|
||||
source: 'sourceA',
|
||||
});
|
||||
|
||||
expect(engine.getCheckers()).toHaveLength(3);
|
||||
|
||||
engine.removeCheckersBySource('sourceA');
|
||||
|
||||
const checkers = engine.getCheckers();
|
||||
expect(checkers).toHaveLength(1);
|
||||
expect(checkers[0].checker.name).toBe('c2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Annotations', () => {
|
||||
it('should match tools by semantic annotations', async () => {
|
||||
engine = new PolicyEngine({
|
||||
@@ -2924,4 +3011,22 @@ describe('PolicyEngine', () => {
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook checkers', () => {
|
||||
it('should add and retrieve hook checkers in priority order', () => {
|
||||
engine.addHookChecker({
|
||||
checker: { type: 'external', name: 'h1' },
|
||||
priority: 5,
|
||||
});
|
||||
engine.addHookChecker({
|
||||
checker: { type: 'external', name: 'h2' },
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const hookCheckers = engine.getHookCheckers();
|
||||
expect(hookCheckers).toHaveLength(2);
|
||||
expect(hookCheckers[0].priority).toBe(10);
|
||||
expect(hookCheckers[1].priority).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,34 @@ deny_message = "Deletion is permanent"
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should support modes property for Tier 2 and Tier 3 policies', async () => {
|
||||
it('should support modes property for Tier 4 and Tier 5 policies', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'tier2.toml'),
|
||||
path.join(tempDir, 'tier4.toml'),
|
||||
`
|
||||
[[rule]]
|
||||
toolName = "tier2-tool"
|
||||
toolName = "tier4-tool"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["autoEdit"]
|
||||
`,
|
||||
);
|
||||
|
||||
const getPolicyTier2 = (_dir: string) => 2; // Tier 2
|
||||
const getPolicyTier4 = (_dir: string) => 4; // Tier 4 (User)
|
||||
const result4 = await loadPoliciesFromToml([tempDir], getPolicyTier4);
|
||||
|
||||
expect(result4.rules).toHaveLength(1);
|
||||
expect(result4.rules[0].toolName).toBe('tier4-tool');
|
||||
expect(result4.rules[0].modes).toEqual(['autoEdit']);
|
||||
expect(result4.rules[0].source).toBe('User: tier4.toml');
|
||||
|
||||
const getPolicyTier2 = (_dir: string) => 2; // Tier 2 (Extension)
|
||||
const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2);
|
||||
expect(result2.rules[0].source).toBe('Extension: tier4.toml');
|
||||
|
||||
expect(result2.rules).toHaveLength(1);
|
||||
expect(result2.rules[0].toolName).toBe('tier2-tool');
|
||||
expect(result2.rules[0].modes).toEqual(['autoEdit']);
|
||||
expect(result2.rules[0].source).toBe('Workspace: tier2.toml');
|
||||
|
||||
const getPolicyTier3 = (_dir: string) => 3; // Tier 3
|
||||
const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3);
|
||||
expect(result3.rules[0].source).toBe('User: tier2.toml');
|
||||
expect(result3.errors).toHaveLength(0);
|
||||
const getPolicyTier5 = (_dir: string) => 5; // Tier 5 (Admin)
|
||||
const result5 = await loadPoliciesFromToml([tempDir], getPolicyTier5);
|
||||
expect(result5.rules[0].source).toBe('Admin: tier4.toml');
|
||||
expect(result5.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle TOML parse errors', async () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ export type PolicyFileErrorType =
|
||||
export interface PolicyFileError {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
tier: 'default' | 'user' | 'workspace' | 'admin';
|
||||
tier: 'default' | 'extension' | 'user' | 'workspace' | 'admin';
|
||||
ruleIndex?: number;
|
||||
errorType: PolicyFileErrorType;
|
||||
message: string;
|
||||
@@ -173,11 +173,14 @@ export async function readPolicyFiles(
|
||||
/**
|
||||
* Converts a tier number to a human-readable tier name.
|
||||
*/
|
||||
function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' {
|
||||
function getTierName(
|
||||
tier: number,
|
||||
): 'default' | 'extension' | 'user' | 'workspace' | 'admin' {
|
||||
if (tier === 1) return 'default';
|
||||
if (tier === 2) return 'workspace';
|
||||
if (tier === 3) return 'user';
|
||||
if (tier === 4) return 'admin';
|
||||
if (tier === 2) return 'extension';
|
||||
if (tier === 3) return 'workspace';
|
||||
if (tier === 4) return 'user';
|
||||
if (tier === 5) return 'admin';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Workspace-Level Policies', () => {
|
||||
vi.doUnmock('node:fs/promises');
|
||||
});
|
||||
|
||||
it('should load workspace policies with correct priority (Tier 2)', async () => {
|
||||
it('should load workspace policies with correct priority (Tier 3)', async () => {
|
||||
const workspacePoliciesDir = '/mock/workspace/policies';
|
||||
const defaultPoliciesDir = '/mock/default/policies';
|
||||
|
||||
@@ -98,21 +98,21 @@ priority = 10
|
||||
toolName = "test_tool"
|
||||
decision = "deny"
|
||||
priority = 10
|
||||
`; // Tier 3 -> 3.010
|
||||
`; // Tier 4 -> 4.010
|
||||
}
|
||||
if (path.includes('workspace.toml')) {
|
||||
return `[[rule]]
|
||||
toolName = "test_tool"
|
||||
decision = "allow"
|
||||
priority = 10
|
||||
`; // Tier 2 -> 2.010
|
||||
`; // Tier 3 -> 3.010
|
||||
}
|
||||
if (path.includes('admin.toml')) {
|
||||
return `[[rule]]
|
||||
toolName = "test_tool"
|
||||
decision = "deny"
|
||||
priority = 10
|
||||
`; // Tier 4 -> 4.010
|
||||
`; // Tier 5 -> 5.010
|
||||
}
|
||||
return '';
|
||||
});
|
||||
@@ -144,9 +144,9 @@ priority = 10
|
||||
|
||||
// Check for all 4 rules
|
||||
const defaultRule = rules?.find((r) => r.priority === 1.01);
|
||||
const workspaceRule = rules?.find((r) => r.priority === 2.01);
|
||||
const userRule = rules?.find((r) => r.priority === 3.01);
|
||||
const adminRule = rules?.find((r) => r.priority === 4.01);
|
||||
const workspaceRule = rules?.find((r) => r.priority === 3.01);
|
||||
const userRule = rules?.find((r) => r.priority === 4.01);
|
||||
const adminRule = rules?.find((r) => r.priority === 5.01);
|
||||
|
||||
expect(defaultRule).toBeDefined();
|
||||
expect(userRule).toBeDefined();
|
||||
@@ -224,7 +224,7 @@ priority=10`,
|
||||
expect(rules![0].priority).toBe(1.01);
|
||||
});
|
||||
|
||||
it('should load workspace policies and correctly transform to Tier 2', async () => {
|
||||
it('should load workspace policies and correctly transform to Tier 3', async () => {
|
||||
const workspacePoliciesDir = '/mock/workspace/policies';
|
||||
|
||||
// Mock FS
|
||||
@@ -284,7 +284,7 @@ priority=500`,
|
||||
|
||||
const rule = config.rules?.find((r) => r.toolName === 'p_tool');
|
||||
expect(rule).toBeDefined();
|
||||
// Workspace Tier (2) + 500/1000 = 2.5
|
||||
expect(rule?.priority).toBe(2.5);
|
||||
// Workspace Tier (3) + 500/1000 = 3.5
|
||||
expect(rule?.priority).toBe(3.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { SimpleExtensionLoader } from './extensionLoader.js';
|
||||
import { PolicyDecision } from '../policy/types.js';
|
||||
import type { Config, GeminiCLIExtension } from '../config/config.js';
|
||||
import { type McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
@@ -38,6 +39,12 @@ describe('SimpleExtensionLoader', () => {
|
||||
let mockHookSystemInit: MockInstance;
|
||||
let mockAgentRegistryReload: MockInstance;
|
||||
let mockSkillsReload: MockInstance;
|
||||
let mockPolicyEngine: {
|
||||
addRule: MockInstance;
|
||||
addChecker: MockInstance;
|
||||
removeRulesBySource: MockInstance;
|
||||
removeCheckersBySource: MockInstance;
|
||||
};
|
||||
|
||||
const activeExtension: GeminiCLIExtension = {
|
||||
name: 'test-extension',
|
||||
@@ -47,7 +54,22 @@ describe('SimpleExtensionLoader', () => {
|
||||
contextFiles: [],
|
||||
excludeTools: ['some-tool'],
|
||||
id: '123',
|
||||
rules: [
|
||||
{
|
||||
toolName: 'test-tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'Extension (test-extension): policies.toml',
|
||||
},
|
||||
],
|
||||
checkers: [
|
||||
{
|
||||
toolName: 'test-tool',
|
||||
checker: { type: 'external', name: 'test-checker' },
|
||||
source: 'Extension (test-extension): policies.toml',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const inactiveExtension: GeminiCLIExtension = {
|
||||
name: 'test-extension',
|
||||
isActive: false,
|
||||
@@ -67,6 +89,12 @@ describe('SimpleExtensionLoader', () => {
|
||||
mockHookSystemInit = vi.fn();
|
||||
mockAgentRegistryReload = vi.fn();
|
||||
mockSkillsReload = vi.fn();
|
||||
mockPolicyEngine = {
|
||||
addRule: vi.fn(),
|
||||
addChecker: vi.fn(),
|
||||
removeRulesBySource: vi.fn(),
|
||||
removeCheckersBySource: vi.fn(),
|
||||
};
|
||||
mockConfig = {
|
||||
getMcpClientManager: () => mockMcpClientManager,
|
||||
getEnableExtensionReloading: () => extensionReloadingEnabled,
|
||||
@@ -81,6 +109,7 @@ describe('SimpleExtensionLoader', () => {
|
||||
reload: mockAgentRegistryReload,
|
||||
}),
|
||||
reloadSkills: mockSkillsReload,
|
||||
getPolicyEngine: () => mockPolicyEngine,
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
@@ -88,6 +117,29 @@ describe('SimpleExtensionLoader', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should register policies when an extension starts', async () => {
|
||||
const loader = new SimpleExtensionLoader([activeExtension]);
|
||||
await loader.start(mockConfig);
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
activeExtension.rules![0],
|
||||
);
|
||||
expect(mockPolicyEngine.addChecker).toHaveBeenCalledWith(
|
||||
activeExtension.checkers![0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should unregister policies when an extension stops', async () => {
|
||||
const loader = new TestingSimpleExtensionLoader([activeExtension]);
|
||||
await loader.start(mockConfig);
|
||||
await loader.stopExtension(activeExtension);
|
||||
expect(mockPolicyEngine.removeRulesBySource).toHaveBeenCalledWith(
|
||||
'Extension (test-extension): policies.toml',
|
||||
);
|
||||
expect(mockPolicyEngine.removeCheckersBySource).toHaveBeenCalledWith(
|
||||
'Extension (test-extension): policies.toml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should start active extensions', async () => {
|
||||
const loader = new SimpleExtensionLoader([activeExtension]);
|
||||
await loader.start(mockConfig);
|
||||
|
||||
@@ -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