Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
/**
|
|
* ToolApprovalService Tests — Ticket #74073: Tool Approval Flow
|
|
*
|
|
* Tests for the tool approval gate system:
|
|
* - requiresApproval() checks tool_approval_rules and respects auto-approve
|
|
* - createApprovalRequest() updates message with pending status
|
|
* - waitForDecision() polls DB and handles timeout
|
|
* - approveToolExecution() sets approved status, optionally creates always-allow rule
|
|
* - rejectToolExecution() sets rejected status and stores reason
|
|
* - getPendingApprovals() returns pending tool_call messages
|
|
* - getApprovalRules() returns all rules
|
|
* - updateApprovalRule() updates allowed fields
|
|
* - matchGlob() pattern matching utility
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
|
|
const { mockDbRun, mockDbGet, mockDbAll, mockIsPostgres } = vi.hoisted(() => {
|
|
const mockDbRun = vi.fn();
|
|
const mockDbGet = vi.fn();
|
|
const mockDbAll = vi.fn();
|
|
const mockIsPostgres = vi.fn(() => false);
|
|
return { mockDbRun, mockDbGet, mockDbAll, mockIsPostgres };
|
|
});
|
|
|
|
vi.mock('../../database/connection.js', () => ({
|
|
dbRun: (...args) => mockDbRun(...args),
|
|
dbGet: (...args) => mockDbGet(...args),
|
|
dbAll: (...args) => mockDbAll(...args),
|
|
isPostgres: () => mockIsPostgres(),
|
|
}));
|
|
|
|
vi.mock('../../utils/logger.js', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// ─── Import SUT after mocks ────────────────────────────────────────────────
|
|
|
|
import {
|
|
requiresApproval,
|
|
createApprovalRequest,
|
|
waitForDecision,
|
|
approveToolExecution,
|
|
rejectToolExecution,
|
|
getPendingApprovals,
|
|
getApprovalRules,
|
|
updateApprovalRule,
|
|
getTimeoutForTool,
|
|
invalidateRulesCache,
|
|
} from '../ToolApprovalService.js';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
const MOCK_RULES = [
|
|
{ id: 1, tool_name: 'write_file', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
|
|
{ id: 2, tool_name: 'delete_row', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
|
|
{ id: 3, tool_name: 'execute_sql', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: [18, 20], timeout_seconds: 600 },
|
|
{ id: 4, tool_name: '*', tool_pattern: 'mcp__*', risk_level: 'medium', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 120 },
|
|
{ id: 5, tool_name: 'get_workspace_info', tool_pattern: null, risk_level: 'safe', requires_approval: false, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
|
|
];
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('ToolApprovalService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Invalidate the rules cache so each test starts fresh
|
|
invalidateRulesCache();
|
|
// Default: dbAll returns the mock rules
|
|
mockDbAll.mockResolvedValue(MOCK_RULES);
|
|
});
|
|
|
|
// ─── requiresApproval ──────────────────────────────────────────
|
|
|
|
describe('requiresApproval()', () => {
|
|
it('should return true for a tool with requires_approval=true', async () => {
|
|
const result = await requiresApproval('write_file', null);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false for a tool with requires_approval=false', async () => {
|
|
const result = await requiresApproval('get_workspace_info', null);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false for a tool not in any rule (no wildcard)', async () => {
|
|
// Override with rules that don't have a wildcard '*' catch-all
|
|
invalidateRulesCache();
|
|
mockDbAll.mockResolvedValue([
|
|
{ id: 1, tool_name: 'write_file', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
|
|
{ id: 2, tool_name: 'delete_row', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
|
|
]);
|
|
const result = await requiresApproval('list_tables', null);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should match wildcard * rule for unmatched tools', async () => {
|
|
// With the default rules, the '*' catch-all matches any tool
|
|
const result = await requiresApproval('list_tables', null);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return true for a glob pattern match (mcp__*)', async () => {
|
|
const result = await requiresApproval('mcp__some_tool', null);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when agent is in auto_approve_for_agent_ids', async () => {
|
|
// Agent 18 (ORCHESTRATOR) is in auto_approve list for execute_sql
|
|
const result = await requiresApproval('execute_sql', 18);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return true when agent is NOT in auto_approve_for_agent_ids', async () => {
|
|
// Agent 21 (FRONTEND) is NOT in auto_approve list for execute_sql
|
|
const result = await requiresApproval('execute_sql', 21);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should handle auto_approve_for_agent_ids as JSON string (SQLite)', async () => {
|
|
invalidateRulesCache();
|
|
mockDbAll.mockResolvedValue([
|
|
{ id: 1, tool_name: 'write_file', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: '[19, 20]', timeout_seconds: 300 },
|
|
]);
|
|
const result = await requiresApproval('write_file', 19);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false (fail-open) when DB query fails', async () => {
|
|
invalidateRulesCache();
|
|
mockDbAll.mockRejectedValue(new Error('DB connection error'));
|
|
const result = await requiresApproval('write_file', null);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── getTimeoutForTool ──────────────────────────────────────────
|
|
|
|
describe('getTimeoutForTool()', () => {
|
|
it('should return the timeout for a matching tool', async () => {
|
|
const timeout = await getTimeoutForTool('execute_sql');
|
|
expect(timeout).toBe(600);
|
|
});
|
|
|
|
it('should return default 300 for non-matching tool', async () => {
|
|
const timeout = await getTimeoutForTool('unknown_tool');
|
|
expect(timeout).toBe(300);
|
|
});
|
|
|
|
it('should match glob patterns', async () => {
|
|
const timeout = await getTimeoutForTool('mcp__some_tool');
|
|
expect(timeout).toBe(120);
|
|
});
|
|
});
|
|
|
|
// ─── createApprovalRequest ──────────────────────────────────────
|
|
|
|
describe('createApprovalRequest()', () => {
|
|
it('should update message with pending approval status', async () => {
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
mockDbGet.mockResolvedValue({
|
|
id: 42, approval_status: 'pending', content_type: 'tool_call', content: 'write_file',
|
|
});
|
|
|
|
const result = await createApprovalRequest(1, 42, 'write_file', { path: '/test' }, 19);
|
|
|
|
expect(mockDbRun).toHaveBeenCalled();
|
|
expect(result).toEqual(expect.objectContaining({ id: 42, approval_status: 'pending' }));
|
|
});
|
|
|
|
it('should throw on DB error', async () => {
|
|
mockDbRun.mockRejectedValue(new Error('DB error'));
|
|
|
|
await expect(createApprovalRequest(1, 42, 'write_file', {}, 19))
|
|
.rejects.toThrow('DB error');
|
|
});
|
|
});
|
|
|
|
// ─── waitForDecision ──────────────────────────────────────────
|
|
|
|
describe('waitForDecision()', () => {
|
|
it('should return "approved" when message gets approved', async () => {
|
|
// First poll: pending, second poll: approved
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ approval_status: 'pending' })
|
|
.mockResolvedValueOnce({ approval_status: 'approved' });
|
|
|
|
const result = await waitForDecision(42, 5000);
|
|
expect(result).toBe('approved');
|
|
});
|
|
|
|
it('should return "rejected" when message gets rejected', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ approval_status: 'pending' })
|
|
.mockResolvedValueOnce({ approval_status: 'rejected' });
|
|
|
|
const result = await waitForDecision(42, 5000);
|
|
expect(result).toBe('rejected');
|
|
});
|
|
|
|
it('should return "timeout" after timeout period', async () => {
|
|
mockDbGet.mockResolvedValue({ approval_status: 'pending' });
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
|
|
// Use a very short timeout for the test
|
|
const result = await waitForDecision(42, 100);
|
|
expect(result).toBe('timeout');
|
|
});
|
|
});
|
|
|
|
// ─── approveToolExecution ──────────────────────────────────────
|
|
|
|
describe('approveToolExecution()', () => {
|
|
it('should set approval_status to approved', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'pending', content_type: 'tool_call', content: 'write_file', metadata: null })
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'approved', approved_by: 1 });
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
|
|
const result = await approveToolExecution(42, 1);
|
|
expect(result).toEqual(expect.objectContaining({ approval_status: 'approved' }));
|
|
});
|
|
|
|
it('should throw if message is not pending', async () => {
|
|
mockDbGet.mockResolvedValue({ id: 42, approval_status: 'approved' });
|
|
|
|
await expect(approveToolExecution(42, 1))
|
|
.rejects.toThrow('not pending');
|
|
});
|
|
|
|
it('should throw if message not found', async () => {
|
|
mockDbGet.mockResolvedValue(null);
|
|
|
|
await expect(approveToolExecution(42, 1))
|
|
.rejects.toThrow('not found');
|
|
});
|
|
|
|
it('should create always-allow rule when alwaysAllow=true', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({
|
|
id: 42, approval_status: 'pending', content_type: 'tool_call',
|
|
content: 'write_file', metadata: JSON.stringify({ approval_tool: 'write_file' }),
|
|
})
|
|
.mockResolvedValueOnce({ id: 1, tool_name: 'write_file' }) // existing rule check
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'approved' }); // final fetch
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
|
|
const result = await approveToolExecution(42, 1, true);
|
|
// Should have called dbRun to update the existing rule
|
|
expect(mockDbRun).toHaveBeenCalledTimes(2); // 1 for approve, 1 for rule update
|
|
});
|
|
});
|
|
|
|
// ─── rejectToolExecution ──────────────────────────────────────
|
|
|
|
describe('rejectToolExecution()', () => {
|
|
it('should set approval_status to rejected', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'pending' })
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'rejected', approved_by: 1 });
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
|
|
const result = await rejectToolExecution(42, 1);
|
|
expect(result).toEqual(expect.objectContaining({ approval_status: 'rejected' }));
|
|
});
|
|
|
|
it('should store rejection reason in metadata', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'pending' })
|
|
.mockResolvedValueOnce({ id: 42, approval_status: 'rejected' });
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
|
|
await rejectToolExecution(42, 1, 'Too risky');
|
|
// Verify dbRun was called with reason metadata
|
|
const calls = mockDbRun.mock.calls;
|
|
const hasReasonUpdate = calls.some(call =>
|
|
typeof call[1]?.[0] === 'string' && call[1][0].includes('rejection_reason')
|
|
);
|
|
expect(hasReasonUpdate).toBe(true);
|
|
});
|
|
|
|
it('should throw if message not found', async () => {
|
|
mockDbGet.mockResolvedValue(null);
|
|
await expect(rejectToolExecution(42, 1))
|
|
.rejects.toThrow('not found');
|
|
});
|
|
|
|
it('should throw if message is not pending', async () => {
|
|
mockDbGet.mockResolvedValue({ id: 42, approval_status: 'approved' });
|
|
await expect(rejectToolExecution(42, 1))
|
|
.rejects.toThrow('not pending');
|
|
});
|
|
});
|
|
|
|
// ─── getPendingApprovals ──────────────────────────────────────
|
|
|
|
describe('getPendingApprovals()', () => {
|
|
it('should return pending tool_call messages for a conversation', async () => {
|
|
const pending = [
|
|
{ id: 42, conversation_id: 1, approval_status: 'pending', content_type: 'tool_call' },
|
|
{ id: 43, conversation_id: 1, approval_status: 'pending', content_type: 'tool_call' },
|
|
];
|
|
mockDbAll.mockResolvedValue(pending);
|
|
|
|
const result = await getPendingApprovals(1);
|
|
expect(result).toEqual(pending);
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it('should return empty array on DB error', async () => {
|
|
mockDbAll.mockRejectedValue(new Error('DB error'));
|
|
const result = await getPendingApprovals(1);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── getApprovalRules ──────────────────────────────────────
|
|
|
|
describe('getApprovalRules()', () => {
|
|
it('should return all rules', async () => {
|
|
const result = await getApprovalRules();
|
|
expect(result).toEqual(MOCK_RULES);
|
|
expect(mockDbAll).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── updateApprovalRule ──────────────────────────────────────
|
|
|
|
describe('updateApprovalRule()', () => {
|
|
it('should update allowed fields', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 1, tool_name: 'write_file', risk_level: 'dangerous', requires_approval: true })
|
|
.mockResolvedValueOnce({ id: 1, tool_name: 'write_file', risk_level: 'medium', requires_approval: false });
|
|
mockDbRun.mockResolvedValue({ changes: 1 });
|
|
|
|
const result = await updateApprovalRule(1, { risk_level: 'medium', requires_approval: false });
|
|
expect(result).toEqual(expect.objectContaining({ risk_level: 'medium' }));
|
|
});
|
|
|
|
it('should throw if rule not found', async () => {
|
|
mockDbGet.mockResolvedValue(null);
|
|
await expect(updateApprovalRule(999, { risk_level: 'safe' }))
|
|
.rejects.toThrow('not found');
|
|
});
|
|
|
|
it('should return unchanged rule if no valid fields provided', async () => {
|
|
const existingRule = { id: 1, tool_name: 'write_file', risk_level: 'dangerous' };
|
|
mockDbGet.mockResolvedValue(existingRule);
|
|
|
|
const result = await updateApprovalRule(1, { invalid_field: 'value' });
|
|
expect(result).toEqual(existingRule);
|
|
// dbRun should NOT be called for update
|
|
expect(mockDbRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|