From 90cbd0cd5bc5e8a224c9fccbdc76e5e3d62ead66 Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Tue, 24 Feb 2026 00:02:02 +0000 Subject: [PATCH] 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. --- .../core/src/policy/policy-engine.test.ts | 133 ++++++++++++++++++ packages/core/src/policy/policy-engine.ts | 67 +++++++++ 2 files changed, 200 insertions(+) diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 11e8333f47..b740ac45b8 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -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); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 353cdae9c1..652f515ced 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -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) {