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:
Mahima Shanware
2026-02-24 00:02:02 +00:00
parent aa9163da60
commit 90cbd0cd5b
2 changed files with 200 additions and 0 deletions

View File

@@ -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);
});
});
});

View File

@@ -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) {