mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(core): Tool Confirmation Message Bus foundation (PR 1 of 3) (#7835)
This commit is contained in:
@@ -71,6 +71,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
|
|||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
import { FileExclusions } from '../utils/ignorePatterns.js';
|
import { FileExclusions } from '../utils/ignorePatterns.js';
|
||||||
import type { EventEmitter } from 'node:events';
|
import type { EventEmitter } from 'node:events';
|
||||||
|
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
import { PolicyEngine } from '../policy/policy-engine.js';
|
||||||
|
import type { PolicyEngineConfig } from '../policy/types.js';
|
||||||
import type { UserTierId } from '../code_assist/types.js';
|
import type { UserTierId } from '../code_assist/types.js';
|
||||||
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
@@ -233,6 +236,7 @@ export interface ConfigParameters {
|
|||||||
enableToolOutputTruncation?: boolean;
|
enableToolOutputTruncation?: boolean;
|
||||||
eventEmitter?: EventEmitter;
|
eventEmitter?: EventEmitter;
|
||||||
useSmartEdit?: boolean;
|
useSmartEdit?: boolean;
|
||||||
|
policyEngineConfig?: PolicyEngineConfig;
|
||||||
output?: OutputSettings;
|
output?: OutputSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +320,8 @@ export class Config {
|
|||||||
private readonly fileExclusions: FileExclusions;
|
private readonly fileExclusions: FileExclusions;
|
||||||
private readonly eventEmitter?: EventEmitter;
|
private readonly eventEmitter?: EventEmitter;
|
||||||
private readonly useSmartEdit: boolean;
|
private readonly useSmartEdit: boolean;
|
||||||
|
private readonly messageBus: MessageBus;
|
||||||
|
private readonly policyEngine: PolicyEngine;
|
||||||
private readonly outputSettings: OutputSettings;
|
private readonly outputSettings: OutputSettings;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
@@ -400,6 +406,8 @@ export class Config {
|
|||||||
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
||||||
this.fileExclusions = new FileExclusions(this);
|
this.fileExclusions = new FileExclusions(this);
|
||||||
this.eventEmitter = params.eventEmitter;
|
this.eventEmitter = params.eventEmitter;
|
||||||
|
this.policyEngine = new PolicyEngine(params.policyEngineConfig);
|
||||||
|
this.messageBus = new MessageBus(this.policyEngine);
|
||||||
this.outputSettings = {
|
this.outputSettings = {
|
||||||
format: params.output?.format ?? OutputFormat.TEXT,
|
format: params.output?.format ?? OutputFormat.TEXT,
|
||||||
};
|
};
|
||||||
@@ -908,6 +916,14 @@ export class Config {
|
|||||||
return this.fileExclusions;
|
return this.fileExclusions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMessageBus(): MessageBus {
|
||||||
|
return this.messageBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPolicyEngine(): PolicyEngine {
|
||||||
|
return this.policyEngine;
|
||||||
|
}
|
||||||
|
|
||||||
async createToolRegistry(): Promise<ToolRegistry> {
|
async createToolRegistry(): Promise<ToolRegistry> {
|
||||||
const registry = new ToolRegistry(this, this.eventEmitter);
|
const registry = new ToolRegistry(this, this.eventEmitter);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './message-bus.js';
|
||||||
|
export * from './types.js';
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { MessageBus } from './message-bus.js';
|
||||||
|
import { PolicyEngine } from '../policy/policy-engine.js';
|
||||||
|
import { PolicyDecision } from '../policy/types.js';
|
||||||
|
import {
|
||||||
|
MessageBusType,
|
||||||
|
type ToolConfirmationRequest,
|
||||||
|
type ToolConfirmationResponse,
|
||||||
|
type ToolPolicyRejection,
|
||||||
|
type ToolExecutionSuccess,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
describe('MessageBus', () => {
|
||||||
|
let messageBus: MessageBus;
|
||||||
|
let policyEngine: PolicyEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
policyEngine = new PolicyEngine();
|
||||||
|
messageBus = new MessageBus(policyEngine);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publish', () => {
|
||||||
|
it('should emit error for invalid message', () => {
|
||||||
|
const errorHandler = vi.fn();
|
||||||
|
messageBus.on('error', errorHandler);
|
||||||
|
|
||||||
|
// @ts-expect-error - Testing invalid message
|
||||||
|
messageBus.publish({ invalid: 'message' });
|
||||||
|
|
||||||
|
expect(errorHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining('Invalid message structure'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate tool confirmation requests have correlationId', () => {
|
||||||
|
const errorHandler = vi.fn();
|
||||||
|
messageBus.on('error', errorHandler);
|
||||||
|
|
||||||
|
// @ts-expect-error - Testing missing correlationId
|
||||||
|
messageBus.publish({
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||||
|
toolCall: { name: 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errorHandler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit confirmation response when policy allows', () => {
|
||||||
|
vi.spyOn(policyEngine, 'check').mockReturnValue(PolicyDecision.ALLOW);
|
||||||
|
|
||||||
|
const responseHandler = vi.fn();
|
||||||
|
messageBus.subscribe(
|
||||||
|
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
responseHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request: ToolConfirmationRequest = {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||||
|
toolCall: { name: 'test-tool', args: {} },
|
||||||
|
correlationId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(request);
|
||||||
|
|
||||||
|
const expectedResponse: ToolConfirmationResponse = {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
correlationId: '123',
|
||||||
|
confirmed: true,
|
||||||
|
};
|
||||||
|
expect(responseHandler).toHaveBeenCalledWith(expectedResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit rejection and response when policy denies', () => {
|
||||||
|
vi.spyOn(policyEngine, 'check').mockReturnValue(PolicyDecision.DENY);
|
||||||
|
|
||||||
|
const responseHandler = vi.fn();
|
||||||
|
const rejectionHandler = vi.fn();
|
||||||
|
messageBus.subscribe(
|
||||||
|
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
responseHandler,
|
||||||
|
);
|
||||||
|
messageBus.subscribe(
|
||||||
|
MessageBusType.TOOL_POLICY_REJECTION,
|
||||||
|
rejectionHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request: ToolConfirmationRequest = {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||||
|
toolCall: { name: 'test-tool', args: {} },
|
||||||
|
correlationId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(request);
|
||||||
|
|
||||||
|
const expectedRejection: ToolPolicyRejection = {
|
||||||
|
type: MessageBusType.TOOL_POLICY_REJECTION,
|
||||||
|
toolCall: { name: 'test-tool', args: {} },
|
||||||
|
};
|
||||||
|
expect(rejectionHandler).toHaveBeenCalledWith(expectedRejection);
|
||||||
|
|
||||||
|
const expectedResponse: ToolConfirmationResponse = {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
correlationId: '123',
|
||||||
|
confirmed: false,
|
||||||
|
};
|
||||||
|
expect(responseHandler).toHaveBeenCalledWith(expectedResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through to UI when policy says ASK_USER', () => {
|
||||||
|
vi.spyOn(policyEngine, 'check').mockReturnValue(PolicyDecision.ASK_USER);
|
||||||
|
|
||||||
|
const requestHandler = vi.fn();
|
||||||
|
messageBus.subscribe(
|
||||||
|
MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||||
|
requestHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request: ToolConfirmationRequest = {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||||
|
toolCall: { name: 'test-tool', args: {} },
|
||||||
|
correlationId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(request);
|
||||||
|
|
||||||
|
expect(requestHandler).toHaveBeenCalledWith(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit other message types directly', () => {
|
||||||
|
const successHandler = vi.fn();
|
||||||
|
messageBus.subscribe(
|
||||||
|
MessageBusType.TOOL_EXECUTION_SUCCESS,
|
||||||
|
successHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
const message: ToolExecutionSuccess<string> = {
|
||||||
|
type: MessageBusType.TOOL_EXECUTION_SUCCESS as const,
|
||||||
|
toolCall: { name: 'test-tool' },
|
||||||
|
result: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(message);
|
||||||
|
|
||||||
|
expect(successHandler).toHaveBeenCalledWith(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribe/unsubscribe', () => {
|
||||||
|
it('should allow subscribing to specific message types', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);
|
||||||
|
|
||||||
|
const message: ToolExecutionSuccess<string> = {
|
||||||
|
type: MessageBusType.TOOL_EXECUTION_SUCCESS as const,
|
||||||
|
toolCall: { name: 'test' },
|
||||||
|
result: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(message);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow unsubscribing from message types', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);
|
||||||
|
messageBus.unsubscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);
|
||||||
|
|
||||||
|
const message: ToolExecutionSuccess<string> = {
|
||||||
|
type: MessageBusType.TOOL_EXECUTION_SUCCESS as const,
|
||||||
|
toolCall: { name: 'test' },
|
||||||
|
result: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(message);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support multiple subscribers for the same message type', () => {
|
||||||
|
const handler1 = vi.fn();
|
||||||
|
const handler2 = vi.fn();
|
||||||
|
|
||||||
|
messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler1);
|
||||||
|
messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler2);
|
||||||
|
|
||||||
|
const message: ToolExecutionSuccess<string> = {
|
||||||
|
type: MessageBusType.TOOL_EXECUTION_SUCCESS as const,
|
||||||
|
toolCall: { name: 'test' },
|
||||||
|
result: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
messageBus.publish(message);
|
||||||
|
|
||||||
|
expect(handler1).toHaveBeenCalledWith(message);
|
||||||
|
expect(handler2).toHaveBeenCalledWith(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should not crash on errors during message processing', () => {
|
||||||
|
const errorHandler = vi.fn();
|
||||||
|
messageBus.on('error', errorHandler);
|
||||||
|
|
||||||
|
// Mock policyEngine to throw an error
|
||||||
|
vi.spyOn(policyEngine, 'check').mockImplementation(() => {
|
||||||
|
throw new Error('Policy check failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request: ToolConfirmationRequest = {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||||
|
toolCall: { name: 'test-tool' },
|
||||||
|
correlationId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => messageBus.publish(request)).not.toThrow();
|
||||||
|
|
||||||
|
// Should emit error
|
||||||
|
expect(errorHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Policy check failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { PolicyEngine } from '../policy/policy-engine.js';
|
||||||
|
import { PolicyDecision } from '../policy/types.js';
|
||||||
|
import { MessageBusType, type Message } from './types.js';
|
||||||
|
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||||
|
|
||||||
|
export class MessageBus extends EventEmitter {
|
||||||
|
constructor(private readonly policyEngine: PolicyEngine) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidMessage(message: Message): boolean {
|
||||||
|
if (!message || !message.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST &&
|
||||||
|
!('correlationId' in message)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMessage(message: Message): void {
|
||||||
|
this.emit(message.type, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(message: Message): void {
|
||||||
|
try {
|
||||||
|
if (!this.isValidMessage(message)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid message structure: ${safeJsonStringify(message)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) {
|
||||||
|
const decision = this.policyEngine.check(message.toolCall);
|
||||||
|
|
||||||
|
switch (decision) {
|
||||||
|
case PolicyDecision.ALLOW:
|
||||||
|
// Directly emit the response instead of recursive publish
|
||||||
|
this.emitMessage({
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
correlationId: message.correlationId,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case PolicyDecision.DENY:
|
||||||
|
// Emit both rejection and response messages
|
||||||
|
this.emitMessage({
|
||||||
|
type: MessageBusType.TOOL_POLICY_REJECTION,
|
||||||
|
toolCall: message.toolCall,
|
||||||
|
});
|
||||||
|
this.emitMessage({
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
correlationId: message.correlationId,
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case PolicyDecision.ASK_USER:
|
||||||
|
// Pass through to UI for user confirmation
|
||||||
|
this.emitMessage(message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown policy decision: ${decision}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For all other message types, just emit them
|
||||||
|
this.emitMessage(message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<T extends Message>(
|
||||||
|
type: T['type'],
|
||||||
|
listener: (message: T) => void,
|
||||||
|
): void {
|
||||||
|
this.on(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe<T extends Message>(
|
||||||
|
type: T['type'],
|
||||||
|
listener: (message: T) => void,
|
||||||
|
): void {
|
||||||
|
this.off(type, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type FunctionCall } from '@google/genai';
|
||||||
|
|
||||||
|
export enum MessageBusType {
|
||||||
|
TOOL_CONFIRMATION_REQUEST = 'tool-confirmation-request',
|
||||||
|
TOOL_CONFIRMATION_RESPONSE = 'tool-confirmation-response',
|
||||||
|
TOOL_POLICY_REJECTION = 'tool-policy-rejection',
|
||||||
|
TOOL_EXECUTION_SUCCESS = 'tool-execution-success',
|
||||||
|
TOOL_EXECUTION_FAILURE = 'tool-execution-failure',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolConfirmationRequest {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_REQUEST;
|
||||||
|
toolCall: FunctionCall;
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolConfirmationResponse {
|
||||||
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE;
|
||||||
|
correlationId: string;
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolPolicyRejection {
|
||||||
|
type: MessageBusType.TOOL_POLICY_REJECTION;
|
||||||
|
toolCall: FunctionCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionSuccess<T = unknown> {
|
||||||
|
type: MessageBusType.TOOL_EXECUTION_SUCCESS;
|
||||||
|
toolCall: FunctionCall;
|
||||||
|
result: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionFailure<E = Error> {
|
||||||
|
type: MessageBusType.TOOL_EXECUTION_FAILURE;
|
||||||
|
toolCall: FunctionCall;
|
||||||
|
error: E;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Message =
|
||||||
|
| ToolConfirmationRequest
|
||||||
|
| ToolConfirmationResponse
|
||||||
|
| ToolPolicyRejection
|
||||||
|
| ToolExecutionSuccess
|
||||||
|
| ToolExecutionFailure;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './policy-engine.js';
|
||||||
|
export * from './types.js';
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { PolicyEngine } from './policy-engine.js';
|
||||||
|
import {
|
||||||
|
PolicyDecision,
|
||||||
|
type PolicyRule,
|
||||||
|
type PolicyEngineConfig,
|
||||||
|
} from './types.js';
|
||||||
|
import type { FunctionCall } from '@google/genai';
|
||||||
|
|
||||||
|
describe('PolicyEngine', () => {
|
||||||
|
let engine: PolicyEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
engine = new PolicyEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should use default config when none provided', () => {
|
||||||
|
const decision = engine.check({ name: 'test' });
|
||||||
|
expect(decision).toBe(PolicyDecision.ASK_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect custom default decision', () => {
|
||||||
|
engine = new PolicyEngine({ defaultDecision: PolicyDecision.DENY });
|
||||||
|
const decision = engine.check({ name: 'test' });
|
||||||
|
expect(decision).toBe(PolicyDecision.DENY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort rules by priority', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 1 },
|
||||||
|
{ toolName: 'tool2', decision: PolicyDecision.ALLOW, priority: 10 },
|
||||||
|
{ toolName: 'tool3', decision: PolicyDecision.ASK_USER, priority: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
const sortedRules = engine.getRules();
|
||||||
|
|
||||||
|
expect(sortedRules[0].priority).toBe(10);
|
||||||
|
expect(sortedRules[1].priority).toBe(5);
|
||||||
|
expect(sortedRules[2].priority).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('check', () => {
|
||||||
|
it('should match tool by name', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{ toolName: 'shell', decision: PolicyDecision.ALLOW },
|
||||||
|
{ toolName: 'edit', decision: PolicyDecision.DENY },
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
expect(engine.check({ name: 'shell' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
expect(engine.check({ name: 'edit' })).toBe(PolicyDecision.DENY);
|
||||||
|
expect(engine.check({ name: 'other' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match by args pattern', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'shell',
|
||||||
|
argsPattern: /rm -rf/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'shell',
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
const dangerousCall: FunctionCall = {
|
||||||
|
name: 'shell',
|
||||||
|
args: { command: 'rm -rf /' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeCall: FunctionCall = {
|
||||||
|
name: 'shell',
|
||||||
|
args: { command: 'ls -la' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(engine.check(dangerousCall)).toBe(PolicyDecision.DENY);
|
||||||
|
expect(engine.check(safeCall)).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply rules by priority', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{ toolName: 'shell', decision: PolicyDecision.DENY, priority: 1 },
|
||||||
|
{ toolName: 'shell', decision: PolicyDecision.ALLOW, priority: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Higher priority rule (ALLOW) should win
|
||||||
|
expect(engine.check({ name: 'shell' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply wildcard rules (no toolName)', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{ decision: PolicyDecision.DENY }, // Applies to all tools
|
||||||
|
{ toolName: 'safe-tool', decision: PolicyDecision.ALLOW, priority: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
expect(engine.check({ name: 'safe-tool' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
expect(engine.check({ name: 'any-other-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-interactive mode', () => {
|
||||||
|
const config: PolicyEngineConfig = {
|
||||||
|
nonInteractive: true,
|
||||||
|
rules: [
|
||||||
|
{ toolName: 'interactive-tool', decision: PolicyDecision.ASK_USER },
|
||||||
|
{ toolName: 'allowed-tool', decision: PolicyDecision.ALLOW },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// ASK_USER should become DENY in non-interactive mode
|
||||||
|
expect(engine.check({ name: 'interactive-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
// ALLOW should remain ALLOW
|
||||||
|
expect(engine.check({ name: 'allowed-tool' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
// Default ASK_USER should also become DENY
|
||||||
|
expect(engine.check({ name: 'unknown-tool' })).toBe(PolicyDecision.DENY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addRule', () => {
|
||||||
|
it('should add a new rule and maintain priority order', () => {
|
||||||
|
engine.addRule({
|
||||||
|
toolName: 'tool1',
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
engine.addRule({
|
||||||
|
toolName: 'tool2',
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
priority: 10,
|
||||||
|
});
|
||||||
|
engine.addRule({
|
||||||
|
toolName: 'tool3',
|
||||||
|
decision: PolicyDecision.ASK_USER,
|
||||||
|
priority: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = engine.getRules();
|
||||||
|
expect(rules).toHaveLength(3);
|
||||||
|
expect(rules[0].priority).toBe(10);
|
||||||
|
expect(rules[1].priority).toBe(5);
|
||||||
|
expect(rules[2].priority).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply newly added rules', () => {
|
||||||
|
expect(engine.check({ name: 'new-tool' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
|
||||||
|
engine.addRule({ toolName: 'new-tool', decision: PolicyDecision.ALLOW });
|
||||||
|
|
||||||
|
expect(engine.check({ name: 'new-tool' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeRulesForTool', () => {
|
||||||
|
it('should remove rules for specific tool', () => {
|
||||||
|
engine.addRule({ toolName: 'tool1', decision: PolicyDecision.ALLOW });
|
||||||
|
engine.addRule({ toolName: 'tool2', decision: PolicyDecision.DENY });
|
||||||
|
engine.addRule({
|
||||||
|
toolName: 'tool1',
|
||||||
|
decision: PolicyDecision.ASK_USER,
|
||||||
|
priority: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(engine.getRules()).toHaveLength(3);
|
||||||
|
|
||||||
|
engine.removeRulesForTool('tool1');
|
||||||
|
|
||||||
|
const remainingRules = engine.getRules();
|
||||||
|
expect(remainingRules).toHaveLength(1);
|
||||||
|
expect(remainingRules.some((r) => r.toolName === 'tool1')).toBe(false);
|
||||||
|
expect(remainingRules.some((r) => r.toolName === 'tool2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle removing non-existent tool', () => {
|
||||||
|
engine.addRule({ toolName: 'existing', decision: PolicyDecision.ALLOW });
|
||||||
|
|
||||||
|
expect(() => engine.removeRulesForTool('non-existent')).not.toThrow();
|
||||||
|
expect(engine.getRules()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRules', () => {
|
||||||
|
it('should return readonly array of rules', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{ toolName: 'tool1', decision: PolicyDecision.ALLOW },
|
||||||
|
{ toolName: 'tool2', decision: PolicyDecision.DENY },
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
const retrievedRules = engine.getRules();
|
||||||
|
expect(retrievedRules).toHaveLength(2);
|
||||||
|
expect(retrievedRules[0].toolName).toBe('tool1');
|
||||||
|
expect(retrievedRules[1].toolName).toBe('tool2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complex scenarios', () => {
|
||||||
|
it('should handle multiple matching rules with different priorities', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{ decision: PolicyDecision.DENY, priority: 0 }, // Default deny all
|
||||||
|
{ toolName: 'shell', decision: PolicyDecision.ASK_USER, priority: 5 },
|
||||||
|
{
|
||||||
|
toolName: 'shell',
|
||||||
|
argsPattern: /"command":"ls/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Matches highest priority rule (ls command)
|
||||||
|
expect(engine.check({ name: 'shell', args: { command: 'ls -la' } })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Matches middle priority rule (shell without ls)
|
||||||
|
expect(engine.check({ name: 'shell', args: { command: 'pwd' } })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Matches lowest priority rule (not shell)
|
||||||
|
expect(engine.check({ name: 'edit' })).toBe(PolicyDecision.DENY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tools with no args', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'read',
|
||||||
|
argsPattern: /secret/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Tool call without args should not match pattern
|
||||||
|
expect(engine.check({ name: 'read' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
|
||||||
|
// Tool call with args not matching pattern
|
||||||
|
expect(engine.check({ name: 'read', args: { file: 'public.txt' } })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool call with args matching pattern
|
||||||
|
expect(engine.check({ name: 'read', args: { file: 'secret.txt' } })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match args pattern regardless of property order', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'shell',
|
||||||
|
// Pattern matches the stable stringified format
|
||||||
|
argsPattern: /"command":"rm[^"]*-rf/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Same args with different property order should both match
|
||||||
|
const args1 = { command: 'rm -rf /', path: '/home' };
|
||||||
|
const args2 = { path: '/home', command: 'rm -rf /' };
|
||||||
|
|
||||||
|
expect(engine.check({ name: 'shell', args: args1 })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'shell', args: args2 })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify safe command doesn't match
|
||||||
|
const safeArgs = { command: 'ls -la', path: '/home' };
|
||||||
|
expect(engine.check({ name: 'shell', args: safeArgs })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested objects in args with stable stringification', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'api',
|
||||||
|
argsPattern: /"sensitive":true/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Nested objects with different key orders should match consistently
|
||||||
|
const args1 = {
|
||||||
|
data: { sensitive: true, value: 'secret' },
|
||||||
|
method: 'POST',
|
||||||
|
};
|
||||||
|
const args2 = {
|
||||||
|
method: 'POST',
|
||||||
|
data: { value: 'secret', sensitive: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(engine.check({ name: 'api', args: args1 })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'api', args: args2 })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle circular references without stack overflow', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /\[Circular\]/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Create an object with a circular reference
|
||||||
|
type CircularArgs = Record<string, unknown> & {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const circularArgs: CircularArgs = {
|
||||||
|
name: 'test',
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
// Create circular reference - TypeScript allows this since data is Record<string, unknown>
|
||||||
|
(circularArgs.data as Record<string, unknown>)['self'] =
|
||||||
|
circularArgs.data;
|
||||||
|
|
||||||
|
// Should not throw stack overflow error
|
||||||
|
expect(() =>
|
||||||
|
engine.check({ name: 'test', args: circularArgs }),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
// Should detect the circular reference pattern
|
||||||
|
expect(engine.check({ name: 'test', args: circularArgs })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-circular object should not match
|
||||||
|
const normalArgs = { name: 'test', data: { value: 'normal' } };
|
||||||
|
expect(engine.check({ name: 'test', args: normalArgs })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deep circular references', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'deep',
|
||||||
|
argsPattern: /\[Circular\]/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Create a deep circular reference
|
||||||
|
type DeepCircular = Record<string, unknown> & {
|
||||||
|
level1?: {
|
||||||
|
level2?: {
|
||||||
|
level3?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const deepCircular: DeepCircular = {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Create circular reference with proper type assertions
|
||||||
|
const level3 = deepCircular.level1!.level2!.level3!;
|
||||||
|
level3['back'] = deepCircular.level1;
|
||||||
|
|
||||||
|
// Should handle without stack overflow
|
||||||
|
expect(() =>
|
||||||
|
engine.check({ name: 'deep', args: deepCircular }),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
// Should detect the circular reference
|
||||||
|
expect(engine.check({ name: 'deep', args: deepCircular })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle repeated non-circular objects correctly', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /\[Circular\]/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /"value":"shared"/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Create an object with repeated references but no cycles
|
||||||
|
const sharedObj = { value: 'shared' };
|
||||||
|
const args = {
|
||||||
|
first: sharedObj,
|
||||||
|
second: sharedObj,
|
||||||
|
third: { nested: sharedObj },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should NOT mark repeated objects as circular, and should match the shared value pattern
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit undefined and function values from objects', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /"definedValue":"test"/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
definedValue: 'test',
|
||||||
|
undefinedValue: undefined,
|
||||||
|
functionValue: () => 'hello',
|
||||||
|
nullValue: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should match pattern with defined value, undefined and functions omitted
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(PolicyDecision.ALLOW);
|
||||||
|
|
||||||
|
// Check that the pattern would NOT match if undefined was included
|
||||||
|
const rulesWithUndefined: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /undefinedValue/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
engine = new PolicyEngine({ rules: rulesWithUndefined });
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the pattern would NOT match if function was included
|
||||||
|
const rulesWithFunction: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /functionValue/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
engine = new PolicyEngine({ rules: rulesWithFunction });
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert undefined and functions to null in arrays', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /\["value",null,null,null\]/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
array: ['value', undefined, () => 'hello', null],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should match pattern with undefined and functions converted to null
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce valid JSON for all inputs', () => {
|
||||||
|
const testCases: Array<{ input: Record<string, unknown>; desc: string }> =
|
||||||
|
[
|
||||||
|
{ input: { simple: 'string' }, desc: 'simple object' },
|
||||||
|
{
|
||||||
|
input: { nested: { deep: { value: 123 } } },
|
||||||
|
desc: 'nested object',
|
||||||
|
},
|
||||||
|
{ input: { data: [1, 2, 3] }, desc: 'simple array' },
|
||||||
|
{ input: { mixed: [1, { a: 'b' }, null] }, desc: 'mixed array' },
|
||||||
|
{
|
||||||
|
input: { undef: undefined, func: () => {}, normal: 'value' },
|
||||||
|
desc: 'object with undefined and function',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: { data: ['a', undefined, () => {}, null] },
|
||||||
|
desc: 'array with undefined and function',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { input } of testCases) {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /.*/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Should not throw when checking (which internally uses stableStringify)
|
||||||
|
expect(() => engine.check({ name: 'test', args: input })).not.toThrow();
|
||||||
|
|
||||||
|
// The check should succeed
|
||||||
|
expect(engine.check({ name: 'test', args: input })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect toJSON methods on objects', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /"sanitized":"safe"/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /"dangerous":"data"/,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Object with toJSON that sanitizes output
|
||||||
|
const args = {
|
||||||
|
data: {
|
||||||
|
dangerous: 'data',
|
||||||
|
toJSON: () => ({ sanitized: 'safe' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should match the sanitized pattern, not the dangerous one
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle toJSON that returns primitives', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /"value":"string-value"/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
value: {
|
||||||
|
complex: 'object',
|
||||||
|
toJSON: () => 'string-value',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// toJSON returns a string, which should be properly stringified
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle toJSON that throws an error', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'test',
|
||||||
|
argsPattern: /"fallback":"value"/,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
data: {
|
||||||
|
fallback: 'value',
|
||||||
|
toJSON: () => {
|
||||||
|
throw new Error('toJSON error');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should fall back to regular object serialization when toJSON throws
|
||||||
|
expect(engine.check({ name: 'test', args })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type FunctionCall } from '@google/genai';
|
||||||
|
import {
|
||||||
|
PolicyDecision,
|
||||||
|
type PolicyEngineConfig,
|
||||||
|
type PolicyRule,
|
||||||
|
} from './types.js';
|
||||||
|
import { stableStringify } from './stable-stringify.js';
|
||||||
|
|
||||||
|
function ruleMatches(
|
||||||
|
rule: PolicyRule,
|
||||||
|
toolCall: FunctionCall,
|
||||||
|
stringifiedArgs: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
// Check tool name if specified
|
||||||
|
if (rule.toolName && toolCall.name !== rule.toolName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check args pattern if specified
|
||||||
|
if (rule.argsPattern) {
|
||||||
|
// If rule has an args pattern but tool has no args, no match
|
||||||
|
if (!toolCall.args) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Use stable JSON stringification with sorted keys to ensure consistent matching
|
||||||
|
if (
|
||||||
|
stringifiedArgs === undefined ||
|
||||||
|
!rule.argsPattern.test(stringifiedArgs)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PolicyEngine {
|
||||||
|
private rules: PolicyRule[];
|
||||||
|
private readonly defaultDecision: PolicyDecision;
|
||||||
|
private readonly nonInteractive: boolean;
|
||||||
|
|
||||||
|
constructor(config: PolicyEngineConfig = {}) {
|
||||||
|
this.rules = (config.rules ?? []).sort(
|
||||||
|
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||||
|
);
|
||||||
|
this.defaultDecision = config.defaultDecision ?? PolicyDecision.ASK_USER;
|
||||||
|
this.nonInteractive = config.nonInteractive ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool call is allowed based on the configured policies.
|
||||||
|
*/
|
||||||
|
check(toolCall: FunctionCall): PolicyDecision {
|
||||||
|
let stringifiedArgs: string | undefined;
|
||||||
|
// Compute stringified args once before the loop
|
||||||
|
if (toolCall.args && this.rules.some((rule) => rule.argsPattern)) {
|
||||||
|
stringifiedArgs = stableStringify(toolCall.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first matching rule (already sorted by priority)
|
||||||
|
for (const rule of this.rules) {
|
||||||
|
if (ruleMatches(rule, toolCall, stringifiedArgs)) {
|
||||||
|
return this.applyNonInteractiveMode(rule.decision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching rule found, use default decision
|
||||||
|
return this.applyNonInteractiveMode(this.defaultDecision);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new rule to the policy engine.
|
||||||
|
*/
|
||||||
|
addRule(rule: PolicyRule): void {
|
||||||
|
this.rules.push(rule);
|
||||||
|
// Re-sort rules by priority
|
||||||
|
this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove rules for a specific tool.
|
||||||
|
*/
|
||||||
|
removeRulesForTool(toolName: string): void {
|
||||||
|
this.rules = this.rules.filter((rule) => rule.toolName !== toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all current rules.
|
||||||
|
*/
|
||||||
|
getRules(): readonly PolicyRule[] {
|
||||||
|
return this.rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyNonInteractiveMode(decision: PolicyDecision): PolicyDecision {
|
||||||
|
// In non-interactive mode, ASK_USER becomes DENY
|
||||||
|
if (this.nonInteractive && decision === PolicyDecision.ASK_USER) {
|
||||||
|
return PolicyDecision.DENY;
|
||||||
|
}
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a stable, deterministic JSON string representation with sorted keys.
|
||||||
|
*
|
||||||
|
* This method is critical for security policy matching. It ensures that the same
|
||||||
|
* object always produces the same string representation, regardless of property
|
||||||
|
* insertion order, which could vary across different JavaScript engines or
|
||||||
|
* runtime conditions.
|
||||||
|
*
|
||||||
|
* Key behaviors:
|
||||||
|
* 1. **Sorted Keys**: Object properties are always serialized in alphabetical order,
|
||||||
|
* ensuring deterministic output for pattern matching.
|
||||||
|
*
|
||||||
|
* 2. **Circular Reference Protection**: Uses ancestor chain tracking (not just
|
||||||
|
* object identity) to detect true circular references while correctly handling
|
||||||
|
* repeated non-circular object references. Circular references are replaced
|
||||||
|
* with "[Circular]" to prevent stack overflow attacks.
|
||||||
|
*
|
||||||
|
* 3. **JSON Spec Compliance**:
|
||||||
|
* - undefined values: Omitted from objects, converted to null in arrays
|
||||||
|
* - Functions: Omitted from objects, converted to null in arrays
|
||||||
|
* - toJSON methods: Respected and called when present (per JSON.stringify spec)
|
||||||
|
*
|
||||||
|
* 4. **Security Considerations**:
|
||||||
|
* - Prevents DoS via circular references that would cause infinite recursion
|
||||||
|
* - Ensures consistent policy rule matching by normalizing property order
|
||||||
|
* - Respects toJSON for objects that sanitize their output
|
||||||
|
* - Handles toJSON methods that throw errors gracefully
|
||||||
|
*
|
||||||
|
* @param obj - The object to stringify (typically toolCall.args)
|
||||||
|
* @returns A deterministic JSON string representation
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Different property orders produce the same output:
|
||||||
|
* stableStringify({b: 2, a: 1}) === stableStringify({a: 1, b: 2})
|
||||||
|
* // Returns: '{"a":1,"b":2}'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Circular references are handled safely:
|
||||||
|
* const obj = {a: 1};
|
||||||
|
* obj.self = obj;
|
||||||
|
* stableStringify(obj)
|
||||||
|
* // Returns: '{"a":1,"self":"[Circular]"}'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // toJSON methods are respected:
|
||||||
|
* const obj = {
|
||||||
|
* sensitive: 'secret',
|
||||||
|
* toJSON: () => ({ safe: 'data' })
|
||||||
|
* };
|
||||||
|
* stableStringify(obj)
|
||||||
|
* // Returns: '{"safe":"data"}'
|
||||||
|
*/
|
||||||
|
export function stableStringify(obj: unknown): string {
|
||||||
|
const stringify = (currentObj: unknown, ancestors: Set<unknown>): string => {
|
||||||
|
// Handle primitives and null
|
||||||
|
if (currentObj === undefined) {
|
||||||
|
return 'null'; // undefined in arrays becomes null in JSON
|
||||||
|
}
|
||||||
|
if (currentObj === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
if (typeof currentObj === 'function') {
|
||||||
|
return 'null'; // functions in arrays become null in JSON
|
||||||
|
}
|
||||||
|
if (typeof currentObj !== 'object') {
|
||||||
|
return JSON.stringify(currentObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular reference (object is in ancestor chain)
|
||||||
|
if (ancestors.has(currentObj)) {
|
||||||
|
return '"[Circular]"';
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestors.add(currentObj);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for toJSON method and use it if present
|
||||||
|
const objWithToJSON = currentObj as { toJSON?: () => unknown };
|
||||||
|
if (typeof objWithToJSON.toJSON === 'function') {
|
||||||
|
try {
|
||||||
|
const jsonValue = objWithToJSON.toJSON();
|
||||||
|
// The result of toJSON needs to be stringified recursively
|
||||||
|
if (jsonValue === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return stringify(jsonValue, ancestors);
|
||||||
|
} catch {
|
||||||
|
// If toJSON throws, treat as a regular object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(currentObj)) {
|
||||||
|
const items = currentObj.map((item) => {
|
||||||
|
// undefined and functions in arrays become null
|
||||||
|
if (item === undefined || typeof item === 'function') {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return stringify(item, ancestors);
|
||||||
|
});
|
||||||
|
return '[' + items.join(',') + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle objects - sort keys and filter out undefined/function values
|
||||||
|
const sortedKeys = Object.keys(currentObj).sort();
|
||||||
|
const pairs: string[] = [];
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const value = (currentObj as Record<string, unknown>)[key];
|
||||||
|
// Skip undefined and function values in objects (per JSON spec)
|
||||||
|
if (value !== undefined && typeof value !== 'function') {
|
||||||
|
pairs.push(JSON.stringify(key) + ':' + stringify(value, ancestors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
} finally {
|
||||||
|
ancestors.delete(currentObj);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return stringify(obj, new Set());
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum PolicyDecision {
|
||||||
|
ALLOW = 'allow',
|
||||||
|
DENY = 'deny',
|
||||||
|
ASK_USER = 'ask_user',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyRule {
|
||||||
|
/**
|
||||||
|
* The name of the tool this rule applies to.
|
||||||
|
* If undefined, the rule applies to all tools.
|
||||||
|
*/
|
||||||
|
toolName?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern to match against tool arguments.
|
||||||
|
* Can be used for more fine-grained control.
|
||||||
|
*/
|
||||||
|
argsPattern?: RegExp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision to make when this rule matches.
|
||||||
|
*/
|
||||||
|
decision: PolicyDecision;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority of this rule. Higher numbers take precedence.
|
||||||
|
* Default is 0.
|
||||||
|
*/
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyEngineConfig {
|
||||||
|
/**
|
||||||
|
* List of policy rules to apply.
|
||||||
|
*/
|
||||||
|
rules?: PolicyRule[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default decision when no rules match.
|
||||||
|
* Defaults to ASK_USER.
|
||||||
|
*/
|
||||||
|
defaultDecision?: PolicyDecision;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to allow tools in non-interactive mode.
|
||||||
|
* When true, ASK_USER decisions become DENY.
|
||||||
|
*/
|
||||||
|
nonInteractive?: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user