import { createHash, randomUUID } from 'crypto'; import { computeCallCents } from './cost-table'; import { canonicalJson } from './decision-log'; import type { CallContext, SpendDecision, SpendPolicy } from './types'; export interface CircuitBreakerConfig { enabled?: boolean; repeatThreshold?: number; repeat_threshold?: number; windowSeconds?: number; window_seconds?: number; planChurnMax?: number; plan_churn_max?: number; stepCap?: number; step_cap?: number; } export interface WorkflowEnvelopeConfig { maxUsdPerRun?: number; max_usd_per_run?: number; maxIterations?: number; max_iterations?: number; maxTokens?: number; max_tokens?: number; maxWallClockSeconds?: number; max_wall_clock_seconds?: number; dailyUsdCap?: number; daily_usd_cap?: number; } export interface ToolGateApproval { approved: boolean; approver?: string; verdict?: string; editedArgs?: Record; } export interface ToolGateConfig { enabled?: boolean; mode?: 'human' | 'model'; destructivePatterns?: string[]; destructive_patterns?: string[]; escalationModel?: string; escalation_model?: string; escalationEffort?: 'low' | 'medium' | 'high' | 'xhigh'; escalation_effort?: 'low' | 'medium' | 'high' | 'xhigh'; humanApprover?: (input: ToolGateInput) => ToolGateApproval | Promise; modelReviewer?: (input: ToolGateInput) => ToolGateApproval | Promise; } export interface ToolGateInput { toolName: string; toolArgs?: Record; workflowId?: string; scope?: CallContext['scope']; } export interface GovernanceBlock { kind: 'circuit_breaker' | 'workflow_envelope'; reason: string; projectedCents: number; receipt: Record; } export interface ToolGateResult { approved: boolean; matchedPattern: string | null; mode: 'human' | 'model'; decision: SpendDecision; editedArgs?: Record; } interface WorkflowState { workflowId: string; startedAtMs: number; spendCents: number; iterations: number; tokens: number; haltedReason?: string; } const DEFAULT_DESTRUCTIVE_PATTERNS = [ 'db.write', 'db.delete', 'db.drop', 'drop table', 'shell.rm', 'rm -rf', 'deploy.*', 'git push --force', 'git push -f', 'payment.*', 'transfer.*', 'refund.*', 'email.send_bulk', ]; const circuitWindows = new Map(); const workflowStates = new Map(); const dailyWorkflowSpend = new Map(); export function evaluateGovernanceBeforeDispatch( policy: SpendPolicy, call: CallContext, config: { circuitBreaker?: CircuitBreakerConfig; workflowEnvelope?: WorkflowEnvelopeConfig }, nowMs = Date.now(), ): GovernanceBlock | null { const circuit = evaluateCircuitBreaker(call, config.circuitBreaker, nowMs); if (circuit) return circuit; return evaluateWorkflowEnvelope(policy, call, config.workflowEnvelope, nowMs); } export async function evaluateToolGate( policy: SpendPolicy, input: ToolGateInput, config: ToolGateConfig | undefined, ): Promise { const toolName = String(input.toolName || '').trim(); if (!toolName) throw new Error('toolName is required'); const cleanArgs = sanitizeMetadata(input.toolArgs ?? {}); const enabled = config?.enabled === true; const patterns = (config?.destructivePatterns ?? config?.destructive_patterns ?? DEFAULT_DESTRUCTIVE_PATTERNS).filter(Boolean); const matchedPattern = enabled ? matchDestructivePattern(toolName, cleanArgs, patterns) : null; const mode = config?.mode === 'model' ? 'model' : 'human'; let approval: ToolGateApproval = { approved: true, approver: 'agentguard:not-required', verdict: 'not_destructive' }; if (matchedPattern) { const callback = mode === 'model' ? config?.modelReviewer : config?.humanApprover; approval = callback ? await callback({ ...input, toolArgs: cleanArgs }) : { approved: false, approver: mode === 'model' ? config?.escalationModel ?? 'model-reviewer' : 'human', verdict: 'approval_required' }; } const approved = matchedPattern ? approval.approved === true : true; const receipt = { type: 'destructive_tool_gate', toolName, toolArgs: cleanArgs, workflowId: input.workflowId ?? null, matchedPattern, mode, approved, approver: approval.approver ?? null, reviewerModel: mode === 'model' ? config?.escalationModel ?? config?.escalation_model ?? null : null, reviewerEffort: mode === 'model' ? config?.escalationEffort ?? config?.escalation_effort ?? 'high' : null, verdict: approval.verdict ?? null, }; const decision = buildGovernanceDecision({ policy, call: { provider: 'unknown', model: 'tool-call', inputTokens: 0, outputTokens: 0, scope: input.scope ?? { tenantId: 'local' }, workflowId: input.workflowId, toolName, toolArgs: cleanArgs, }, action: approved ? 'allow' : 'block', projectedCents: 0, reason: approved ? matchedPattern ? `approved: destructive tool matched ${matchedPattern}` : 'approved: tool call did not match destructive patterns' : `blocked: destructive tool matched ${matchedPattern}`, receipt, }); return { approved, matchedPattern, mode, decision, editedArgs: approval.editedArgs }; } export function buildGovernanceDecision(args: { policy: SpendPolicy; call: CallContext; action: 'allow' | 'block'; projectedCents: number; reason: string; receipt: Record; }): SpendDecision { return { decisionId: randomUUID(), timestamp: new Date().toISOString(), action: args.action, triggeredCap: null, triggeredScopeKey: null, projectedCents: args.projectedCents, windowSpendBefore: 0, windowSpendAfter: args.action === 'allow' ? args.projectedCents : 0, provider: args.call.provider, modelRequested: args.call.model, modelResolved: args.call.model, policyId: args.policy.id, policyVersion: args.policy.version, enforcementMode: args.policy.mode, reasons: [args.reason], entryType: 'governance', governanceReceipt: sanitizeMetadata(args.receipt), }; } export function fingerprintCall(call: CallContext): string { if (call.callFingerprint) return sha256(String(call.callFingerprint)); const payload = { provider: call.provider, model: call.model, label: call.label ?? null, capabilityClaim: call.capabilityClaim ?? null, workflowId: call.workflowId ?? null, toolName: call.toolName ?? null, toolArgs: sanitizeMetadata(call.toolArgs ?? null), requestShape: sanitizeMetadata(call.requestShape ?? null), }; return sha256(canonicalJson(payload)); } export function sanitizeMetadata(value: T, path = 'metadata'): T { if (value == null) return value; if (Array.isArray(value)) return value.map((child, index) => sanitizeMetadata(child, `${path}[${index}]`)) as T; if (typeof value !== 'object') return value; const out: Record = {}; for (const [key, child] of Object.entries(value as Record)) { if (/^(prompt|completion|content|messages|input|output|text|raw)$/i.test(key)) { throw new Error('Governance metadata cannot include data-plane field: ' + path + '.' + key); } out[key] = sanitizeMetadata(child, path + '.' + key); } return out as T; } function evaluateCircuitBreaker(call: CallContext, config: CircuitBreakerConfig | undefined, nowMs: number): GovernanceBlock | null { if (config?.enabled !== true) return null; const threshold = positiveInt(config.repeatThreshold ?? config.repeat_threshold, 3); const windowSeconds = positiveInt(config.windowSeconds ?? config.window_seconds, 300); const planChurnMax = positiveInt(config.planChurnMax ?? config.plan_churn_max, 2); const stepCap = positiveInt(config.stepCap ?? config.step_cap, 50); const projectedCents = computeCallCents(call.model, call.inputTokens, call.outputTokens) ?? 0; if (typeof call.reasoningStep === 'number' && call.reasoningStep >= stepCap) { return { kind: 'circuit_breaker', projectedCents, reason: `blocked: reasoning step cap ${stepCap} reached`, receipt: { type: 'circuit_breaker', reason: 'step_cap', stepCap, reasoningStep: call.reasoningStep }, }; } if (typeof call.planRevision === 'number' && call.planRevision >= planChurnMax && call.stateProgress !== true) { return { kind: 'circuit_breaker', projectedCents, reason: `blocked: plan churn exceeded ${planChurnMax} revisions without state progress`, receipt: { type: 'circuit_breaker', reason: 'plan_churn', planChurnMax, planRevision: call.planRevision }, }; } const fingerprint = fingerprintCall(call); const key = `${call.scope.tenantId}:${call.workflowId ?? call.scope.taskId ?? 'global'}:${fingerprint}`; const windowMs = windowSeconds * 1000; const recent = (circuitWindows.get(key) ?? []).filter((at) => nowMs - at <= windowMs); const nextCount = recent.length + 1; if (nextCount >= threshold) { circuitWindows.set(key, [...recent, nowMs]); return { kind: 'circuit_breaker', projectedCents, reason: `blocked: identical call repeated ${threshold}x in ${Math.round((recent.length ? nowMs - recent[0]! : 0) / 1000)}s (loop circuit breaker)`, receipt: { type: 'circuit_breaker', fingerprint, count: nextCount, repeatThreshold: threshold, windowSeconds }, }; } circuitWindows.set(key, [...recent, nowMs]); return null; } function evaluateWorkflowEnvelope(policy: SpendPolicy, call: CallContext, config: WorkflowEnvelopeConfig | undefined, nowMs: number): GovernanceBlock | null { if (!config) return null; const workflowId = call.workflowId ?? call.scope.taskId; if (!workflowId) return null; const projectedCents = computeCallCents(call.model, call.inputTokens, call.outputTokens) ?? 0; const state = workflowStates.get(workflowId) ?? { workflowId, startedAtMs: nowMs, spendCents: 0, iterations: 0, tokens: 0, }; if (state.haltedReason) { return envelopeBlock(policy, call, projectedCents, workflowId, state, state.haltedReason); } const maxRunCents = usdToCents(config.maxUsdPerRun ?? config.max_usd_per_run); const maxIterations = positiveOptional(config.maxIterations ?? config.max_iterations); const maxTokens = positiveOptional(config.maxTokens ?? config.max_tokens); const maxWallClockSeconds = positiveOptional(config.maxWallClockSeconds ?? config.max_wall_clock_seconds); const dailyCents = usdToCents(config.dailyUsdCap ?? config.daily_usd_cap); const tokensAfter = state.tokens + call.inputTokens + call.outputTokens; const spendAfter = state.spendCents + projectedCents; const iterationAfter = state.iterations + 1; const elapsedSeconds = Math.floor((nowMs - state.startedAtMs) / 1000); const dailyKey = `${workflowId}:${new Date(nowMs).toISOString().slice(0, 10)}`; const dailyAfter = (dailyWorkflowSpend.get(dailyKey) ?? 0) + projectedCents; let reason: string | null = null; if (maxRunCents !== null && spendAfter > maxRunCents) { reason = `blocked: workflow envelope exceeded at ${formatUsd(state.spendCents)}/${formatUsd(maxRunCents)} (run ${workflowId})`; } else if (dailyCents !== null && dailyAfter > dailyCents) { reason = `blocked: workflow daily envelope exceeded at ${formatUsd(dailyAfter)}/${formatUsd(dailyCents)} (run ${workflowId})`; } else if (maxIterations !== null && iterationAfter > maxIterations) { reason = `blocked: workflow iteration cap exceeded at ${state.iterations}/${maxIterations} (run ${workflowId})`; } else if (maxTokens !== null && tokensAfter > maxTokens) { reason = `blocked: workflow token envelope exceeded at ${state.tokens}/${maxTokens} (run ${workflowId})`; } else if (maxWallClockSeconds !== null && elapsedSeconds > maxWallClockSeconds) { reason = `blocked: workflow wall-clock envelope exceeded at ${elapsedSeconds}s/${maxWallClockSeconds}s (run ${workflowId})`; } if (reason) { state.haltedReason = reason; workflowStates.set(workflowId, state); return envelopeBlock(policy, call, projectedCents, workflowId, state, reason); } state.spendCents = spendAfter; state.iterations = iterationAfter; state.tokens = tokensAfter; workflowStates.set(workflowId, state); dailyWorkflowSpend.set(dailyKey, dailyAfter); return null; } function envelopeBlock(policy: SpendPolicy, _call: CallContext, projectedCents: number, workflowId: string, state: WorkflowState, reason: string): GovernanceBlock { return { kind: 'workflow_envelope', projectedCents, reason, receipt: { type: 'workflow_envelope_exceeded', workflowId, spendCents: state.spendCents, iterations: state.iterations, tokens: state.tokens, policyId: policy.id, reason, }, }; } function matchDestructivePattern(toolName: string, toolArgs: Record, patterns: string[]): string | null { const haystack = `${toolName} ${canonicalJson(toolArgs)}`.toLowerCase(); for (const pattern of patterns) { const clean = String(pattern).trim().toLowerCase(); if (!clean) continue; const regex = new RegExp('^' + escapeRegex(clean).replace(/\\\*/g, '.*') + '$'); if (regex.test(toolName.toLowerCase()) || haystack.includes(clean.replace(/\*/g, ''))) return pattern; } return null; } function sha256(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex'); } function escapeRegex(value: string): string { return value.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); } function positiveInt(value: unknown, fallback: number): number { return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : fallback; } function positiveOptional(value: unknown): number | null { return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null; } function usdToCents(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value * 100) : null; } function formatUsd(cents: number): string { return '$' + (cents / 100).toFixed(2); }