mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
feat(policy): add isToolPotentiallyAllowed for pre-execution evaluation
- Introduces `isToolPotentiallyAllowed` to the `PolicyEngine` to evaluate if a tool could be permitted under current modes and rules, without executing it. - Correctly handles global rules, specific tool rules, wildcards, and conditional (argsPattern) rules. - Adds comprehensive unit tests for the new method.
This commit is contained in:
@@ -2458,4 +2458,137 @@ describe('PolicyEngine', () => {
|
||||
expect(checkers[0].priority).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolPotentiallyAllowed', () => {
|
||||
it('should return true if tool is explicitly allowed', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 },
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if tool is explicitly denied', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 100 },
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if tool is allowed by wildcard', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'server__*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1', 'server')).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect priority: higher priority deny wins', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 200 },
|
||||
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 },
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect priority: higher priority allow wins', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 200 },
|
||||
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 100 },
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for conditional DENY (argsPattern) if lower priority is ALLOW', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 200,
|
||||
argsPattern: /something/,
|
||||
},
|
||||
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 },
|
||||
],
|
||||
});
|
||||
// Since it's only conditionally denied, it is "potentially" allowed
|
||||
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect global rules', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ decision: PolicyDecision.DENY, priority: 50 }, // Global deny
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('any_tool')).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore global rules when ignoreGlobalRules is true', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ decision: PolicyDecision.DENY, priority: 100 }, // Global deny
|
||||
],
|
||||
});
|
||||
// Should fallback to defaultDecision (ASK_USER by default)
|
||||
expect(engine.isToolPotentiallyAllowed('any_tool', undefined, true)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should still respect specific tool DENY when ignoreGlobalRules is true', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{ decision: PolicyDecision.DENY, priority: 100 }, // Global deny
|
||||
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 150 }, // Specific deny
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1', undefined, true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect modes', () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 100,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true); // Default mode is usually DEFAULT which might be allowed if defaultDecision is ASK_USER
|
||||
|
||||
const engineDeny = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 100,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
{ decision: PolicyDecision.DENY, priority: 50 },
|
||||
],
|
||||
});
|
||||
engineDeny.setApprovalMode(ApprovalMode.DEFAULT);
|
||||
expect(engineDeny.isToolPotentiallyAllowed('tool1')).toBe(false);
|
||||
|
||||
engineDeny.setApprovalMode(ApprovalMode.PLAN);
|
||||
expect(engineDeny.isToolPotentiallyAllowed('tool1')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -629,6 +629,73 @@ export class PolicyEngine {
|
||||
return excludedTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool could potentially be allowed (or ASK_USER) under the current approval mode.
|
||||
* This is used to filter out tools that are unconditionally denied before generating the prompt schema.
|
||||
*/
|
||||
isToolPotentiallyAllowed(
|
||||
toolName: string,
|
||||
serverName?: string,
|
||||
ignoreGlobalRules = false,
|
||||
): boolean {
|
||||
const aliases = getToolAliases(toolName);
|
||||
const namesToTry = [...aliases];
|
||||
if (serverName) {
|
||||
for (const alias of aliases) {
|
||||
namesToTry.push(`${serverName}__${alias}`);
|
||||
}
|
||||
}
|
||||
|
||||
let globalVerdict: PolicyDecision | undefined;
|
||||
|
||||
for (const rule of this.rules) {
|
||||
// Check if rule applies to current approval mode
|
||||
if (rule.modes && rule.modes.length > 0) {
|
||||
if (!rule.modes.includes(this.approvalMode)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const ruleToolName = rule.toolName;
|
||||
|
||||
let isToolMatch = false;
|
||||
if (!ruleToolName) {
|
||||
if (ignoreGlobalRules) {
|
||||
continue;
|
||||
}
|
||||
isToolMatch = true; // Global rule matches all tools
|
||||
if (globalVerdict === undefined) {
|
||||
globalVerdict = rule.decision;
|
||||
}
|
||||
} else if (isWildcardPattern(ruleToolName)) {
|
||||
isToolMatch = namesToTry.some((name) =>
|
||||
matchesWildcard(ruleToolName, name),
|
||||
);
|
||||
} else {
|
||||
isToolMatch = namesToTry.includes(ruleToolName);
|
||||
}
|
||||
|
||||
if (isToolMatch) {
|
||||
if (rule.decision !== PolicyDecision.DENY) {
|
||||
// Found a rule that might allow it (could be conditional on args)
|
||||
return true;
|
||||
} else if (!rule.argsPattern) {
|
||||
// Unconditional DENY that applies to this tool (either specific or global)
|
||||
return false;
|
||||
}
|
||||
// Conditional DENY (depends on args). Keep checking lower priority rules
|
||||
// because if the args DON'T match this DENY, a lower priority rule might ALLOW it.
|
||||
}
|
||||
}
|
||||
|
||||
// No specific rule allowed it or unconditionally denied it.
|
||||
if (globalVerdict !== undefined && !ignoreGlobalRules) {
|
||||
return globalVerdict !== PolicyDecision.DENY;
|
||||
}
|
||||
|
||||
return this.defaultDecision !== PolicyDecision.DENY;
|
||||
}
|
||||
|
||||
private applyNonInteractiveMode(decision: PolicyDecision): PolicyDecision {
|
||||
// In non-interactive mode, ASK_USER becomes DENY
|
||||
if (this.nonInteractive && decision === PolicyDecision.ASK_USER) {
|
||||
|
||||
Reference in New Issue
Block a user