/** * ToolApprovalService — Ticket #74073: Tool Approval Flow * * Manages approval gates for dangerous agent tools. When an agent calls a tool * that matches a rule in `tool_approval_rules`, execution is paused until a * human approves or rejects it (or the timeout expires). * * Follows the same approval pattern as TerminalService.js (pending → approve/reject). * * The approval state lives on the `messages` table columns: * - approval_status ('pending' | 'approved' | 'rejected') * - approved_by (user ID who approved/rejected) * - approved_at (timestamp) * * The `tool_approval_rules` table configures which tools need approval, * risk levels, per-agent auto-approve lists, and timeouts. */ import { dbRun, dbGet, dbAll } from '../database/connection.js'; import { logger } from '../utils/logger.js'; // ─── Glob-style Pattern Matching ──────────────────────────────────────────── /** * Match a tool name against a glob pattern (supports * and ?). * @param {string} toolName - e.g. 'delete_row' * @param {string} pattern - e.g. 'delete_*', 'mcp__*' * @returns {boolean} */ function matchGlob(toolName, pattern) { if (!pattern) return false; // Escape regex special chars except * and ? const regexStr = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.'); return new RegExp(`^${regexStr}$`).test(toolName); } // ─── Rule Caching ─────────────────────────────────────────────────────────── /** @type {Array|null} */ let _rulesCache = null; /** @type {number} */ let _rulesCacheTs = 0; const RULES_CACHE_TTL_MS = 30_000; // 30 seconds /** * Load rules from database, using a short-lived cache to avoid * hitting the DB on every single tool call in a loop. * @returns {Promise} */ async function loadRules() { const now = Date.now(); if (_rulesCache && (now - _rulesCacheTs) < RULES_CACHE_TTL_MS) { return _rulesCache; } try { _rulesCache = await dbAll('SELECT * FROM tool_approval_rules ORDER BY id ASC', []); _rulesCacheTs = now; return _rulesCache; } catch (err) { logger.error({ err: err.message }, 'ToolApprovalService: Failed to load approval rules'); return _rulesCache || []; } } /** Invalidate the rules cache (after insert/update or in tests). */ export function invalidateRulesCache() { _rulesCache = null; _rulesCacheTs = 0; } // ─── Core Functions ───────────────────────────────────────────────────────── /** * Check whether a tool requires human approval before execution. * * Matching priority: * 1. Exact `tool_name` match * 2. Glob `tool_pattern` match * 3. Wildcard '*' rule (catch-all) * * If the agent's ID is in `auto_approve_for_agent_ids`, skip approval. * * @param {string} toolName * @param {number|null} agentId - The agent row ID executing the tool * @returns {Promise} */ export async function requiresApproval(toolName, agentId) { try { const rules = await loadRules(); // Find matching rule: exact name → glob pattern → wildcard '*' let matchedRule = null; for (const rule of rules) { // Exact match on tool_name if (rule.tool_name === toolName) { matchedRule = rule; break; } } if (!matchedRule) { for (const rule of rules) { // Glob pattern match if (rule.tool_pattern && matchGlob(toolName, rule.tool_pattern)) { matchedRule = rule; break; } } } if (!matchedRule) { for (const rule of rules) { // Wildcard catch-all if (rule.tool_name === '*') { matchedRule = rule; break; } } } if (!matchedRule || !matchedRule.requires_approval) { return false; } // Check if the agent is in the auto-approve list if (agentId && matchedRule.auto_approve_for_agent_ids) { let agentIds = matchedRule.auto_approve_for_agent_ids; // May be stored as JSON string — parse safely if (typeof agentIds === 'string') { try { agentIds = JSON.parse(agentIds); } catch { agentIds = []; } } if (Array.isArray(agentIds) && agentIds.includes(agentId)) { return false; } } return true; } catch (err) { logger.error({ err: err.message, toolName, agentId }, 'ToolApprovalService: Error checking approval requirement'); // Fail-open: if we can't check rules, don't block execution return false; } } /** * Get the timeout (in seconds) for a tool's approval from the matching rule. * @param {string} toolName * @returns {Promise} Timeout in seconds (default 300) */ export async function getTimeoutForTool(toolName) { try { const rules = await loadRules(); for (const rule of rules) { if (rule.tool_name === toolName || (rule.tool_pattern && matchGlob(toolName, rule.tool_pattern))) { return rule.timeout_seconds || 300; } } return 300; } catch { return 300; } } /** * Create an approval request by updating the tool_call message with pending status. * * @param {number} conversationId * @param {number} messageId - ID of the tool_call message * @param {string} toolName * @param {Object} args - Tool arguments * @param {number|null} agentId * @returns {Promise} Updated message record */ export async function createApprovalRequest(conversationId, messageId, toolName, args, agentId) { try { const timeoutSeconds = await getTimeoutForTool(toolName); const expiresAt = new Date(Date.now() + timeoutSeconds * 1000).toISOString(); // Update the tool_call message with approval_status = 'pending' await dbRun( `UPDATE messages SET approval_status = $1, metadata = COALESCE(metadata::jsonb, '{}'::jsonb) || $2::jsonb, updated_at = NOW() WHERE id = $3`, [ 'pending', JSON.stringify({ approval_status: 'pending', approval_tool: toolName, approval_args: args, approval_agent_id: agentId, approval_expires_at: expiresAt, timeout_seconds: timeoutSeconds, }), messageId, ] ); logger.info( { conversationId, messageId, toolName, agentId, timeoutSeconds }, 'ToolApprovalService: Created approval request' ); const message = await dbGet('SELECT * FROM messages WHERE id = $1', [messageId]); return message; } catch (err) { logger.error( { err: err.message, conversationId, messageId, toolName }, 'ToolApprovalService: Failed to create approval request' ); throw err; } } /** * Poll the database waiting for a user to approve or reject the tool execution. * * Resolves with 'approved', 'rejected', or 'timeout'. * * @param {number} messageId * @param {number} [timeoutMs=300000] - Max wait time in milliseconds (default 5 min) * @returns {Promise<'approved'|'rejected'|'timeout'>} */ export async function waitForDecision(messageId, timeoutMs = 300_000) { const pollIntervalMs = 1000; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const msg = await dbGet('SELECT approval_status FROM messages WHERE id = $1', [messageId]); if (msg?.approval_status === 'approved') return 'approved'; if (msg?.approval_status === 'rejected') return 'rejected'; } catch (err) { logger.error({ err: err.message, messageId }, 'ToolApprovalService: Error polling for decision'); } // Sleep before next poll await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } // Timeout — update the message to reflect this try { await dbRun( `UPDATE messages SET approval_status = $1, updated_at = NOW() WHERE id = $2 AND approval_status = $3`, ['rejected', messageId, 'pending'] ); } catch { // Best-effort timeout cleanup } logger.warn({ messageId, timeoutMs }, 'ToolApprovalService: Approval timed out'); return 'timeout'; } /** * Approve a tool execution. * * @param {number} messageId * @param {number} userId - Who approved * @param {boolean} [alwaysAllow=false] - If true, create a rule that auto-approves this tool * @returns {Promise} Updated message record */ export async function approveToolExecution(messageId, userId, alwaysAllow = false) { try { const msg = await dbGet('SELECT * FROM messages WHERE id = $1', [messageId]); if (!msg) throw new Error(`Message ${messageId} not found`); if (msg.approval_status !== 'pending') { throw new Error(`Message ${messageId} is not pending approval (status: ${msg.approval_status})`); } await dbRun( `UPDATE messages SET approval_status = $1, approved_by = $2, approved_at = NOW(), updated_at = NOW() WHERE id = $3`, ['approved', userId, messageId] ); logger.info({ messageId, userId, alwaysAllow }, 'ToolApprovalService: Tool execution approved'); // If alwaysAllow, create a rule that marks this tool as not requiring approval if (alwaysAllow) { // Extract tool name from metadata let toolName = null; if (msg.metadata) { try { const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata; toolName = meta.approval_tool; } catch { /* ignore parse errors */ } } // Fallback: tool_call messages store the tool name in content if (!toolName && msg.content_type === 'tool_call') { toolName = msg.content; } if (toolName) { // Check if a rule already exists for this tool const existing = await dbGet( 'SELECT id FROM tool_approval_rules WHERE tool_name = $1', [toolName] ); if (existing) { await dbRun( `UPDATE tool_approval_rules SET requires_approval = $1, updated_at = NOW() WHERE id = $2`, [false, existing.id] ); } else { await dbRun( `INSERT INTO tool_approval_rules (tool_name, risk_level, requires_approval, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW())`, [toolName, 'safe', false, userId] ); } invalidateRulesCache(); logger.info({ toolName, userId }, 'ToolApprovalService: Created always-allow rule'); } } return dbGet('SELECT * FROM messages WHERE id = $1', [messageId]); } catch (err) { logger.error({ err: err.message, messageId, userId }, 'ToolApprovalService: Failed to approve tool execution'); throw err; } } /** * Reject a tool execution. * * @param {number} messageId * @param {number} userId - Who rejected * @param {string} [reason] - Optional rejection reason * @returns {Promise} Updated message record */ export async function rejectToolExecution(messageId, userId, reason) { try { const msg = await dbGet('SELECT * FROM messages WHERE id = $1', [messageId]); if (!msg) throw new Error(`Message ${messageId} not found`); if (msg.approval_status !== 'pending') { throw new Error(`Message ${messageId} is not pending approval (status: ${msg.approval_status})`); } // Update approval status await dbRun( `UPDATE messages SET approval_status = $1, approved_by = $2, approved_at = NOW(), updated_at = NOW() WHERE id = $3`, ['rejected', userId, messageId] ); // Store rejection reason in metadata if provided if (reason) { await dbRun( `UPDATE messages SET metadata = COALESCE(metadata::jsonb, '{}'::jsonb) || $1::jsonb WHERE id = $2`, [JSON.stringify({ rejection_reason: reason }), messageId] ); } logger.info({ messageId, userId, reason }, 'ToolApprovalService: Tool execution rejected'); return dbGet('SELECT * FROM messages WHERE id = $1', [messageId]); } catch (err) { logger.error({ err: err.message, messageId, userId }, 'ToolApprovalService: Failed to reject tool execution'); throw err; } } /** * Get all pending tool approval requests for a conversation. * * @param {number} conversationId * @returns {Promise} Pending tool_call messages */ export async function getPendingApprovals(conversationId) { try { return await dbAll( `SELECT * FROM messages WHERE conversation_id = $1 AND approval_status = $2 AND content_type = $3 ORDER BY created_at ASC`, [conversationId, 'pending', 'tool_call'] ); } catch (err) { logger.error({ err: err.message, conversationId }, 'ToolApprovalService: Failed to get pending approvals'); return []; } } /** * Get all tool approval rules. * @returns {Promise} */ export async function getApprovalRules() { invalidateRulesCache(); return loadRules(); } /** * Update a tool approval rule. * * Allowed fields: tool_name, tool_pattern, risk_level, requires_approval, * auto_approve_for_agent_ids, timeout_seconds. * * @param {number} ruleId * @param {Object} updates * @returns {Promise} Updated rule */ export async function updateApprovalRule(ruleId, updates) { try { const rule = await dbGet('SELECT * FROM tool_approval_rules WHERE id = $1', [ruleId]); if (!rule) throw new Error(`Approval rule ${ruleId} not found`); const allowedFields = [ 'tool_name', 'tool_pattern', 'risk_level', 'requires_approval', 'auto_approve_for_agent_ids', 'timeout_seconds', ]; const setClauses = []; const params = []; let paramIdx = 1; for (const field of allowedFields) { if (updates[field] !== undefined) { setClauses.push(`${field} = $${paramIdx++}`); params.push(updates[field]); } } if (setClauses.length === 0) { return rule; } // Add updated_at setClauses.push(`updated_at = NOW()`); params.push(ruleId); await dbRun( `UPDATE tool_approval_rules SET ${setClauses.join(', ')} WHERE id = $${paramIdx}`, params ); invalidateRulesCache(); logger.info({ ruleId, updates }, 'ToolApprovalService: Updated approval rule'); return dbGet('SELECT * FROM tool_approval_rules WHERE id = $1', [ruleId]); } catch (err) { logger.error({ err: err.message, ruleId }, 'ToolApprovalService: Failed to update approval rule'); throw err; } }