feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
957
dexto/packages/core/src/approval/manager.test.ts
Normal file
957
dexto/packages/core/src/approval/manager.test.ts
Normal file
@@ -0,0 +1,957 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ApprovalManager } from './manager.js';
|
||||
import { ApprovalStatus, DenialReason } from './types.js';
|
||||
import { AgentEventBus } from '../events/index.js';
|
||||
import { DextoRuntimeError } from '../errors/index.js';
|
||||
import { ApprovalErrorCode } from './error-codes.js';
|
||||
import { createMockLogger } from '../logger/v2/test-utils.js';
|
||||
|
||||
describe('ApprovalManager', () => {
|
||||
let agentEventBus: AgentEventBus;
|
||||
const mockLogger = createMockLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
agentEventBus = new AgentEventBus();
|
||||
});
|
||||
|
||||
describe('Configuration - Separate tool and elicitation control', () => {
|
||||
it('should allow auto-approve for tools while elicitation is enabled', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Tool confirmation should be auto-approved
|
||||
const toolResponse = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: { foo: 'bar' },
|
||||
});
|
||||
|
||||
expect(toolResponse.status).toBe(ApprovalStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should reject elicitation when disabled, even if tools are auto-approved', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Elicitation should throw error when disabled
|
||||
await expect(
|
||||
manager.requestElicitation({
|
||||
schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
prompt: 'Enter your name',
|
||||
serverName: 'Test Server',
|
||||
})
|
||||
).rejects.toThrow(DextoRuntimeError);
|
||||
|
||||
await expect(
|
||||
manager.requestElicitation({
|
||||
schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
prompt: 'Enter your name',
|
||||
serverName: 'Test Server',
|
||||
})
|
||||
).rejects.toThrow(/Elicitation is disabled/);
|
||||
});
|
||||
|
||||
it('should auto-deny tools while elicitation is enabled', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-deny',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Tool confirmation should be auto-denied
|
||||
const toolResponse = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: { foo: 'bar' },
|
||||
});
|
||||
|
||||
expect(toolResponse.status).toBe(ApprovalStatus.DENIED);
|
||||
});
|
||||
|
||||
it('should use separate timeouts for tools and elicitation', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 60000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 180000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.toolConfirmation.timeout).toBe(60000);
|
||||
expect(config.elicitation.timeout).toBe(180000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Approval routing by type', () => {
|
||||
it('should route tool confirmations to tool provider', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const response = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(ApprovalStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should route command confirmations to tool provider', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const response = await manager.requestCommandConfirmation({
|
||||
toolName: 'bash_exec',
|
||||
command: 'rm -rf /',
|
||||
originalCommand: 'rm -rf /',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(ApprovalStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should route elicitation to elicitation provider when enabled', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-deny', // Different mode for tools
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Elicitation should not be auto-denied (uses manual handler)
|
||||
// We'll timeout immediately to avoid hanging tests
|
||||
await expect(
|
||||
manager.requestElicitation({
|
||||
schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
prompt: 'Enter your name',
|
||||
serverName: 'Test Server',
|
||||
timeout: 1, // 1ms timeout to fail fast
|
||||
})
|
||||
).rejects.toThrow(); // Should timeout, not be auto-denied
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pending approvals tracking', () => {
|
||||
it('should track pending approvals across both providers', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Initially no pending approvals
|
||||
expect(manager.getPendingApprovals()).toEqual([]);
|
||||
|
||||
// Auto-approve mode would not create pending approvals
|
||||
// Event-based mode would, but we don't want hanging requests in tests
|
||||
});
|
||||
|
||||
it('should cancel approvals in both providers', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Should not throw when cancelling (even if approval doesn't exist)
|
||||
expect(() => manager.cancelApproval('test-id')).not.toThrow();
|
||||
expect(() => manager.cancelAllApprovals()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw clear error when elicitation is disabled', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.getElicitationData({
|
||||
schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
prompt: 'Enter your name',
|
||||
serverName: 'Test Server',
|
||||
})
|
||||
).rejects.toThrow(/Elicitation is disabled/);
|
||||
});
|
||||
|
||||
it('should provide helpful error message about enabling elicitation', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
try {
|
||||
await manager.requestElicitation({
|
||||
schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
prompt: 'Enter your name',
|
||||
serverName: 'Test Server',
|
||||
});
|
||||
expect.fail('Should have thrown error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DextoRuntimeError);
|
||||
expect((error as Error).message).toContain('Enable elicitation');
|
||||
expect((error as Error).message).toContain('agent configuration');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeout Configuration', () => {
|
||||
it('should allow undefined timeout (infinite wait) for tool confirmation', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
// No timeout specified - should wait indefinitely
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.toolConfirmation.timeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow undefined timeout (infinite wait) for elicitation', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 60000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
// No timeout specified - should wait indefinitely
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.elicitation.timeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow both timeouts to be undefined (infinite wait for all approvals)', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
// No timeout
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
// No timeout
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.toolConfirmation.timeout).toBeUndefined();
|
||||
expect(config.elicitation.timeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use per-request timeout override when provided', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve', // Auto-approve so we can test immediately
|
||||
timeout: 60000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// The per-request timeout should override the config timeout
|
||||
// This is tested implicitly through the factory flow
|
||||
const response = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: { foo: 'bar' },
|
||||
timeout: 30000, // Per-request override
|
||||
});
|
||||
|
||||
expect(response.status).toBe(ApprovalStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should not timeout when timeout is undefined in auto-approve mode', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
// No timeout - should not cause any issues with auto-approve
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const response = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(ApprovalStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should not timeout when timeout is undefined in auto-deny mode', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-deny',
|
||||
// No timeout - should not cause any issues with auto-deny
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const response = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(ApprovalStatus.DENIED);
|
||||
expect(response.reason).toBe(DenialReason.SYSTEM_DENIED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward compatibility', () => {
|
||||
it('should work with manual mode for both tools and elicitation', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
expect(manager.getConfig()).toEqual({
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect explicitly set elicitation enabled value', () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
expect(manager.getConfig().elicitation.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Denial Reasons', () => {
|
||||
it('should include system_denied reason in auto-deny mode', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-deny',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const response = await manager.requestToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(ApprovalStatus.DENIED);
|
||||
expect(response.reason).toBe(DenialReason.SYSTEM_DENIED);
|
||||
expect(response.message).toContain('system policy');
|
||||
});
|
||||
|
||||
it('should throw error with specific reason when tool is denied', async () => {
|
||||
const manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'auto-deny',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
try {
|
||||
await manager.checkToolConfirmation({
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-call-id',
|
||||
args: {},
|
||||
});
|
||||
expect.fail('Should have thrown error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DextoRuntimeError);
|
||||
expect((error as DextoRuntimeError).code).toBe(
|
||||
ApprovalErrorCode.APPROVAL_TOOL_CONFIRMATION_DENIED
|
||||
);
|
||||
expect((error as DextoRuntimeError).message).toContain('system policy');
|
||||
expect((error as any).context.reason).toBe(DenialReason.SYSTEM_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle user_denied reason in error message', async () => {
|
||||
const _manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 1, // Quick timeout for test
|
||||
},
|
||||
elicitation: {
|
||||
enabled: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Simulate user denying via event
|
||||
setTimeout(() => {
|
||||
agentEventBus.emit('approval:response', {
|
||||
approvalId: expect.any(String),
|
||||
status: ApprovalStatus.DENIED,
|
||||
reason: DenialReason.USER_DENIED,
|
||||
message: 'User clicked deny',
|
||||
} as any);
|
||||
}, 50);
|
||||
|
||||
// This will be challenging to test properly without mocking more,
|
||||
// so let's just ensure the type system accepts it
|
||||
expect(DenialReason.USER_DENIED).toBe('user_denied');
|
||||
expect(DenialReason.TIMEOUT).toBe('timeout');
|
||||
});
|
||||
|
||||
it('should include reason in response schema', () => {
|
||||
// Verify the type system allows reason and message
|
||||
const response: { reason?: DenialReason; message?: string } = {
|
||||
reason: DenialReason.USER_DENIED,
|
||||
message: 'You denied this request',
|
||||
};
|
||||
|
||||
expect(response.reason).toBe(DenialReason.USER_DENIED);
|
||||
expect(response.message).toBe('You denied this request');
|
||||
});
|
||||
|
||||
it('should support all denial reason types', () => {
|
||||
const reasons: DenialReason[] = [
|
||||
DenialReason.USER_DENIED,
|
||||
DenialReason.SYSTEM_DENIED,
|
||||
DenialReason.TIMEOUT,
|
||||
DenialReason.USER_CANCELLED,
|
||||
DenialReason.SYSTEM_CANCELLED,
|
||||
DenialReason.VALIDATION_FAILED,
|
||||
DenialReason.ELICITATION_DISABLED,
|
||||
];
|
||||
|
||||
expect(reasons.length).toBe(7);
|
||||
reasons.forEach((reason) => {
|
||||
expect(typeof reason).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bash Pattern Approval', () => {
|
||||
let manager: ApprovalManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
});
|
||||
|
||||
describe('addBashPattern', () => {
|
||||
it('should add a pattern to the approved list', () => {
|
||||
manager.addBashPattern('git *');
|
||||
expect(manager.getBashPatterns().has('git *')).toBe(true);
|
||||
});
|
||||
|
||||
it('should add multiple patterns', () => {
|
||||
manager.addBashPattern('git *');
|
||||
manager.addBashPattern('npm *');
|
||||
manager.addBashPattern('ls *');
|
||||
|
||||
const patterns = manager.getBashPatterns();
|
||||
expect(patterns.size).toBe(3);
|
||||
expect(patterns.has('git *')).toBe(true);
|
||||
expect(patterns.has('npm *')).toBe(true);
|
||||
expect(patterns.has('ls *')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not duplicate patterns', () => {
|
||||
manager.addBashPattern('git *');
|
||||
manager.addBashPattern('git *');
|
||||
|
||||
expect(manager.getBashPatterns().size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesBashPattern (pattern-to-pattern covering)', () => {
|
||||
// Note: matchesBashPattern expects pattern keys (e.g., "git push *"),
|
||||
// not raw commands. ToolManager generates pattern keys from commands.
|
||||
|
||||
it('should match exact pattern against exact stored pattern', () => {
|
||||
manager.addBashPattern('git status *');
|
||||
expect(manager.matchesBashPattern('git status *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('git push *')).toBe(false);
|
||||
});
|
||||
|
||||
it('should cover narrower pattern with broader pattern', () => {
|
||||
// "git *" is broader and should cover "git push *", "git status *", etc.
|
||||
manager.addBashPattern('git *');
|
||||
expect(manager.matchesBashPattern('git *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('git push *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('git status *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('npm *')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not let narrower pattern cover broader pattern', () => {
|
||||
// "git push *" should NOT cover "git *"
|
||||
manager.addBashPattern('git push *');
|
||||
expect(manager.matchesBashPattern('git push *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('git *')).toBe(false);
|
||||
expect(manager.matchesBashPattern('git status *')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match against multiple patterns', () => {
|
||||
manager.addBashPattern('git *');
|
||||
manager.addBashPattern('npm install *');
|
||||
|
||||
expect(manager.matchesBashPattern('git status *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('npm install *')).toBe(true);
|
||||
// npm * is not covered, only npm install * specifically
|
||||
expect(manager.matchesBashPattern('npm run *')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no patterns are set', () => {
|
||||
expect(manager.matchesBashPattern('git status *')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not cross-match unrelated commands', () => {
|
||||
manager.addBashPattern('npm *');
|
||||
// "npx" starts with "np" but is not "npm " + something
|
||||
expect(manager.matchesBashPattern('npx *')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multi-level subcommands', () => {
|
||||
manager.addBashPattern('docker compose *');
|
||||
expect(manager.matchesBashPattern('docker compose *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('docker compose up *')).toBe(true);
|
||||
expect(manager.matchesBashPattern('docker *')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearBashPatterns', () => {
|
||||
it('should clear all patterns', () => {
|
||||
manager.addBashPattern('git *');
|
||||
manager.addBashPattern('npm *');
|
||||
expect(manager.getBashPatterns().size).toBe(2);
|
||||
|
||||
manager.clearBashPatterns();
|
||||
expect(manager.getBashPatterns().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow adding patterns after clearing', () => {
|
||||
manager.addBashPattern('git *');
|
||||
manager.clearBashPatterns();
|
||||
manager.addBashPattern('npm *');
|
||||
|
||||
expect(manager.getBashPatterns().size).toBe(1);
|
||||
expect(manager.getBashPatterns().has('npm *')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBashPatterns', () => {
|
||||
it('should return empty set initially', () => {
|
||||
expect(manager.getBashPatterns().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return a copy that reflects current patterns', () => {
|
||||
manager.addBashPattern('git *');
|
||||
const patterns = manager.getBashPatterns();
|
||||
expect(patterns.has('git *')).toBe(true);
|
||||
|
||||
// Note: ReadonlySet is a TypeScript type constraint, not runtime protection
|
||||
// The returned set IS the internal set, so modifying it would affect the manager
|
||||
// This is acceptable for our use case (debugging/display)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Directory Access Approval', () => {
|
||||
let manager: ApprovalManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ApprovalManager(
|
||||
{
|
||||
toolConfirmation: {
|
||||
mode: 'manual',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
mockLogger
|
||||
);
|
||||
});
|
||||
|
||||
describe('initializeWorkingDirectory', () => {
|
||||
it('should add working directory as session-approved', () => {
|
||||
manager.initializeWorkingDirectory('/home/user/project');
|
||||
expect(manager.isDirectorySessionApproved('/home/user/project/src/file.ts')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize the path before adding', () => {
|
||||
manager.initializeWorkingDirectory('/home/user/../user/project');
|
||||
expect(manager.isDirectorySessionApproved('/home/user/project/file.ts')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addApprovedDirectory', () => {
|
||||
it('should add directory with session type by default', () => {
|
||||
manager.addApprovedDirectory('/external/project');
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should add directory with explicit session type', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should add directory with once type', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'once');
|
||||
// 'once' type should NOT be session-approved (requires prompt each time)
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(false);
|
||||
// But should be generally approved for execution
|
||||
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not downgrade from session to once', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
manager.addApprovedDirectory('/external/project', 'once');
|
||||
// Should still be session-approved
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should upgrade from once to session', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'once');
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(false);
|
||||
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should normalize paths before adding', () => {
|
||||
manager.addApprovedDirectory('/external/../external/project');
|
||||
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirectorySessionApproved', () => {
|
||||
it('should return true for files within session-approved directory', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
|
||||
expect(
|
||||
manager.isDirectorySessionApproved('/external/project/src/deep/file.ts')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for files within once-approved directory', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'once');
|
||||
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for files outside approved directories', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
expect(manager.isDirectorySessionApproved('/other/file.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle path containment correctly', () => {
|
||||
manager.addApprovedDirectory('/external', 'session');
|
||||
// Approving /external should cover /external/sub/file.ts
|
||||
expect(manager.isDirectorySessionApproved('/external/sub/file.ts')).toBe(true);
|
||||
// But not /external-other/file.ts (different directory)
|
||||
expect(manager.isDirectorySessionApproved('/external-other/file.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when working directory is initialized', () => {
|
||||
manager.initializeWorkingDirectory('/home/user/project');
|
||||
expect(manager.isDirectorySessionApproved('/home/user/project/any/file.ts')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirectoryApproved', () => {
|
||||
it('should return true for files within session-approved directory', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for files within once-approved directory', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'once');
|
||||
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for files outside approved directories', () => {
|
||||
manager.addApprovedDirectory('/external/project', 'session');
|
||||
expect(manager.isDirectoryApproved('/other/file.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple approved directories', () => {
|
||||
manager.addApprovedDirectory('/external/project1', 'session');
|
||||
manager.addApprovedDirectory('/external/project2', 'once');
|
||||
|
||||
expect(manager.isDirectoryApproved('/external/project1/file.ts')).toBe(true);
|
||||
expect(manager.isDirectoryApproved('/external/project2/file.ts')).toBe(true);
|
||||
expect(manager.isDirectoryApproved('/external/project3/file.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested directory approvals', () => {
|
||||
manager.addApprovedDirectory('/external', 'session');
|
||||
// Approving /external should cover all subdirectories
|
||||
expect(manager.isDirectoryApproved('/external/sub/deep/file.ts')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApprovedDirectories', () => {
|
||||
it('should return empty map initially', () => {
|
||||
expect(manager.getApprovedDirectories().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return map with type information', () => {
|
||||
manager.addApprovedDirectory('/external/project1', 'session');
|
||||
manager.addApprovedDirectory('/external/project2', 'once');
|
||||
|
||||
const dirs = manager.getApprovedDirectories();
|
||||
expect(dirs.size).toBe(2);
|
||||
// Check that paths are normalized (absolute)
|
||||
const keys = Array.from(dirs.keys());
|
||||
expect(keys.some((k) => k.includes('project1'))).toBe(true);
|
||||
expect(keys.some((k) => k.includes('project2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include working directory after initialization', () => {
|
||||
manager.initializeWorkingDirectory('/home/user/project');
|
||||
const dirs = manager.getApprovedDirectories();
|
||||
expect(dirs.size).toBe(1);
|
||||
// Check that working directory is session type
|
||||
const entries = Array.from(dirs.entries());
|
||||
expect(entries[0]![1]).toBe('session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session vs Once Prompting Behavior', () => {
|
||||
// These tests verify the expected prompting flow
|
||||
|
||||
it('working directory should not require prompt (session-approved)', () => {
|
||||
manager.initializeWorkingDirectory('/home/user/project');
|
||||
// isDirectorySessionApproved returns true → no directory prompt needed
|
||||
expect(manager.isDirectorySessionApproved('/home/user/project/src/file.ts')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('external dir after session approval should not require prompt', () => {
|
||||
manager.addApprovedDirectory('/external', 'session');
|
||||
// isDirectorySessionApproved returns true → no directory prompt needed
|
||||
expect(manager.isDirectorySessionApproved('/external/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('external dir after once approval should require prompt each time', () => {
|
||||
manager.addApprovedDirectory('/external', 'once');
|
||||
// isDirectorySessionApproved returns false → directory prompt needed
|
||||
expect(manager.isDirectorySessionApproved('/external/file.ts')).toBe(false);
|
||||
// But isDirectoryApproved returns true → execution allowed
|
||||
expect(manager.isDirectoryApproved('/external/file.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('unapproved external dir should require prompt', () => {
|
||||
// No directories approved
|
||||
expect(manager.isDirectorySessionApproved('/external/file.ts')).toBe(false);
|
||||
expect(manager.isDirectoryApproved('/external/file.ts')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user