/** * Deterministic Tool Gateway * * Extends EnforcementGates with idempotency, schema validation, * and budget metering. Every tool call passes through a deterministic * pipeline: idempotency check -> schema validation -> budget check -> * enforcement gates -> allow/deny. * * @module @claude-flow/guidance/gateway */ import { createHash } from 'node:crypto'; import { EnforcementGates } from './gates.js'; // ============================================================================ // Default Budget // ============================================================================ const DEFAULT_BUDGET = { tokenBudget: { used: 0, limit: Infinity }, toolCallBudget: { used: 0, limit: Infinity }, storageBudget: { usedBytes: 0, limitBytes: Infinity }, timeBudget: { usedMs: 0, limitMs: Infinity }, costBudget: { usedUsd: 0, limitUsd: Infinity }, }; // ============================================================================ // Deterministic Tool Gateway // ============================================================================ export class DeterministicToolGateway { gates; schemas; budget; idempotencyTtlMs; maxCacheSize; requireEvidence; idempotencyCache = new Map(); lastCleanupTime = 0; static CLEANUP_INTERVAL_MS = 30_000; // batch cleanup every 30s constructor(config = {}) { this.gates = new EnforcementGates(config.gateConfig); // Index schemas by tool name this.schemas = new Map(); if (config.schemas) { for (const schema of config.schemas) { this.schemas.set(schema.toolName, schema); } } // Merge partial budget with defaults this.budget = this.mergeBudget(config.budget); this.idempotencyTtlMs = config.idempotencyTtlMs ?? 300_000; // 5 minutes default this.maxCacheSize = config.maxCacheSize ?? 10_000; this.requireEvidence = config.requireEvidence ?? false; } // ========================================================================= // Public API // ========================================================================= /** * Evaluate whether a tool call should be allowed. * * Pipeline: * 1. Check idempotency cache * 2. Validate params against schema * 3. Check budget * 4. Run EnforcementGates checks * 5. Return decision with remaining budget */ evaluate(toolName, params, context) { const evidence = {}; // Step 1: Idempotency check (batch cleanup on interval, not every call) this.maybeCleanExpiredIdempotency(); const idempotencyKey = this.getIdempotencyKey(toolName, params); const cached = this.idempotencyCache.get(idempotencyKey); if (cached) { return { allowed: true, reason: 'Idempotency cache hit; returning cached result', gate: 'idempotency', evidence: { idempotencyKey, cachedAt: cached.timestamp }, idempotencyHit: true, cachedResult: cached.result, budgetRemaining: this.cloneBudget(), }; } evidence.idempotencyKey = idempotencyKey; evidence.idempotencyHit = false; // Step 2: Schema validation const schemaResult = this.validateSchema(toolName, params); evidence.schemaValidation = schemaResult; if (!schemaResult.valid) { return { allowed: false, reason: `Schema validation failed: ${schemaResult.errors.join('; ')}`, gate: 'schema-validation', evidence, idempotencyHit: false, }; } // Step 3: Budget check const budgetStatus = this.checkBudget(); evidence.budgetStatus = budgetStatus; if (!budgetStatus.withinBudget) { const exceeded = this.findExceededBudgets(); return { allowed: false, reason: `Budget exceeded: ${exceeded.join(', ')}`, gate: 'budget', evidence, idempotencyHit: false, budgetRemaining: this.cloneBudget(), }; } // Step 4: EnforcementGates checks const gateResults = this.gates.evaluateToolUse(toolName, params); evidence.gateResults = gateResults; if (gateResults.length > 0) { const aggregated = this.gates.aggregateDecision(gateResults); if (aggregated === 'block') { const blockResult = gateResults.find(r => r.decision === 'block'); return { allowed: false, reason: blockResult.reason, gate: `enforcement:${blockResult.gateName}`, evidence, idempotencyHit: false, budgetRemaining: this.cloneBudget(), }; } if (aggregated === 'require-confirmation') { const confirmResult = gateResults.find(r => r.decision === 'require-confirmation'); return { allowed: false, reason: confirmResult.reason, gate: `enforcement:${confirmResult.gateName}`, evidence, idempotencyHit: false, budgetRemaining: this.cloneBudget(), }; } // 'warn' still allows, but note it in evidence evidence.warnings = gateResults .filter(r => r.decision === 'warn') .map(r => r.reason); } // Also run command-level checks if context provides a command string if (context?.command && typeof context.command === 'string') { const commandResults = this.gates.evaluateCommand(context.command); evidence.commandGateResults = commandResults; if (commandResults.length > 0) { const aggregated = this.gates.aggregateDecision(commandResults); if (aggregated === 'block' || aggregated === 'require-confirmation') { const worst = commandResults.find(r => r.decision === aggregated); return { allowed: false, reason: worst.reason, gate: `enforcement:${worst.gateName}`, evidence, idempotencyHit: false, budgetRemaining: this.cloneBudget(), }; } } } // Step 5: Allow return { allowed: true, reason: 'All gates passed', gate: 'none', evidence, idempotencyHit: false, budgetRemaining: this.cloneBudget(), }; } /** * Record a completed tool call. * Updates budgets and stores the result in the idempotency cache. */ recordCall(toolName, params, result, durationMs, tokenCount) { // Update budgets this.budget.toolCallBudget.used += 1; this.budget.timeBudget.usedMs += durationMs; if (tokenCount !== undefined) { this.budget.tokenBudget.used += tokenCount; } // Estimate storage from serialized result const serialized = JSON.stringify(result) ?? ''; this.budget.storageBudget.usedBytes += Buffer.byteLength(serialized, 'utf-8'); // Estimate cost: simple heuristic based on tokens if (tokenCount !== undefined) { // Rough estimate: $0.003 per 1K tokens (configurable in production) this.budget.costBudget.usedUsd += (tokenCount / 1000) * 0.003; } // Store in idempotency cache with size-based eviction const key = this.getIdempotencyKey(toolName, params); const paramsHash = this.computeParamsHash(params); this.idempotencyCache.set(key, { key, toolName, paramsHash, result, timestamp: Date.now(), ttlMs: this.idempotencyTtlMs, }); // Evict oldest entries if cache exceeds max size if (this.idempotencyCache.size > this.maxCacheSize) { // Map iterates in insertion order; delete the first (oldest) entries const excess = this.idempotencyCache.size - this.maxCacheSize; let removed = 0; for (const [k] of this.idempotencyCache) { if (removed >= excess) break; this.idempotencyCache.delete(k); removed++; } } } /** * Validate tool parameters against the registered schema. * Returns valid:true if no schema is registered for the tool. */ validateSchema(toolName, params) { const schema = this.schemas.get(toolName); if (!schema) { // No schema registered; pass through return { valid: true, errors: [] }; } const errors = []; // Check required params for (const required of schema.requiredParams) { if (!(required in params) || params[required] === undefined) { errors.push(`Missing required parameter: "${required}"`); } } // Check for unknown params const knownParams = new Set([...schema.requiredParams, ...schema.optionalParams]); for (const key of Object.keys(params)) { if (!knownParams.has(key)) { errors.push(`Unknown parameter: "${key}"`); } } // Check param types for (const [key, expectedType] of Object.entries(schema.paramTypes)) { if (!(key in params) || params[key] === undefined) continue; const value = params[key]; const actualType = this.getParamType(value); if (actualType !== expectedType) { errors.push(`Parameter "${key}" expected type "${expectedType}" but got "${actualType}"`); } } // Check param size const serialized = JSON.stringify(params); const sizeBytes = Buffer.byteLength(serialized, 'utf-8'); if (sizeBytes > schema.maxParamSize) { errors.push(`Parameters size ${sizeBytes} bytes exceeds limit of ${schema.maxParamSize} bytes`); } // Check allowed values if (schema.allowedValues) { for (const [key, allowed] of Object.entries(schema.allowedValues)) { if (!(key in params) || params[key] === undefined) continue; const value = params[key]; const isAllowed = allowed.some(a => JSON.stringify(a) === JSON.stringify(value)); if (!isAllowed) { errors.push(`Parameter "${key}" value ${JSON.stringify(value)} is not in the allowed values list`); } } } return { valid: errors.length === 0, errors }; } /** * Check whether all budget dimensions are within limits. */ checkBudget() { const b = this.budget; const withinBudget = b.tokenBudget.used <= b.tokenBudget.limit && b.toolCallBudget.used <= b.toolCallBudget.limit && b.storageBudget.usedBytes <= b.storageBudget.limitBytes && b.timeBudget.usedMs <= b.timeBudget.limitMs && b.costBudget.usedUsd <= b.costBudget.limitUsd; return { withinBudget, budgetStatus: this.cloneBudget() }; } /** * Compute a deterministic idempotency key from tool name and params. * Uses SHA-256 of `toolName:sortedParamsJSON`. */ getIdempotencyKey(toolName, params) { return this.computeIdempotencyKey(toolName, params); } /** * Reset all budget counters to zero. */ resetBudget() { this.budget.tokenBudget.used = 0; this.budget.toolCallBudget.used = 0; this.budget.storageBudget.usedBytes = 0; this.budget.timeBudget.usedMs = 0; this.budget.costBudget.usedUsd = 0; } /** * Get a snapshot of the current budget. */ getBudget() { return this.cloneBudget(); } /** * Get all idempotency records (including expired ones not yet cleaned). */ getCallHistory() { return Array.from(this.idempotencyCache.values()); } /** * Access the underlying EnforcementGates instance. */ getGates() { return this.gates; } // ========================================================================= // Private helpers // ========================================================================= /** * Remove expired idempotency records (batched on interval to avoid per-call overhead). */ maybeCleanExpiredIdempotency() { const now = Date.now(); if (now - this.lastCleanupTime < DeterministicToolGateway.CLEANUP_INTERVAL_MS) { return; // Skip cleanup until interval has passed } this.lastCleanupTime = now; for (const [key, record] of this.idempotencyCache) { if (now - record.timestamp > record.ttlMs) { this.idempotencyCache.delete(key); } } } /** * Compute a deterministic SHA-256 key from tool name and sorted params. */ computeIdempotencyKey(toolName, params) { const sortedParams = this.sortObject(params); const input = `${toolName}:${JSON.stringify(sortedParams)}`; return createHash('sha256').update(input).digest('hex'); } /** * Compute a SHA-256 hash of params only (for the IdempotencyRecord). */ computeParamsHash(params) { const sortedParams = this.sortObject(params); return createHash('sha256').update(JSON.stringify(sortedParams)).digest('hex'); } /** * Recursively sort object keys for deterministic serialization. */ sortObject(obj) { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) return obj.map(item => this.sortObject(item)); if (typeof obj === 'object') { const sorted = {}; for (const key of Object.keys(obj).sort()) { sorted[key] = this.sortObject(obj[key]); } return sorted; } return obj; } /** * Determine the type string for a parameter value. */ getParamType(value) { if (Array.isArray(value)) return 'array'; if (value === null) return 'object'; return typeof value; } /** * Create a deep clone of the current budget. */ cloneBudget() { return { tokenBudget: { ...this.budget.tokenBudget }, toolCallBudget: { ...this.budget.toolCallBudget }, storageBudget: { ...this.budget.storageBudget }, timeBudget: { ...this.budget.timeBudget }, costBudget: { ...this.budget.costBudget }, }; } /** * Merge a partial budget config with defaults. */ mergeBudget(partial) { if (!partial) return this.cloneDefaultBudget(); return { tokenBudget: partial.tokenBudget ? { ...DEFAULT_BUDGET.tokenBudget, ...partial.tokenBudget } : { ...DEFAULT_BUDGET.tokenBudget }, toolCallBudget: partial.toolCallBudget ? { ...DEFAULT_BUDGET.toolCallBudget, ...partial.toolCallBudget } : { ...DEFAULT_BUDGET.toolCallBudget }, storageBudget: partial.storageBudget ? { ...DEFAULT_BUDGET.storageBudget, ...partial.storageBudget } : { ...DEFAULT_BUDGET.storageBudget }, timeBudget: partial.timeBudget ? { ...DEFAULT_BUDGET.timeBudget, ...partial.timeBudget } : { ...DEFAULT_BUDGET.timeBudget }, costBudget: partial.costBudget ? { ...DEFAULT_BUDGET.costBudget, ...partial.costBudget } : { ...DEFAULT_BUDGET.costBudget }, }; } cloneDefaultBudget() { return { tokenBudget: { ...DEFAULT_BUDGET.tokenBudget }, toolCallBudget: { ...DEFAULT_BUDGET.toolCallBudget }, storageBudget: { ...DEFAULT_BUDGET.storageBudget }, timeBudget: { ...DEFAULT_BUDGET.timeBudget }, costBudget: { ...DEFAULT_BUDGET.costBudget }, }; } /** * Find which budget dimensions have been exceeded. */ findExceededBudgets() { const exceeded = []; const b = this.budget; if (b.tokenBudget.used > b.tokenBudget.limit) { exceeded.push(`tokens (${b.tokenBudget.used}/${b.tokenBudget.limit})`); } if (b.toolCallBudget.used > b.toolCallBudget.limit) { exceeded.push(`tool calls (${b.toolCallBudget.used}/${b.toolCallBudget.limit})`); } if (b.storageBudget.usedBytes > b.storageBudget.limitBytes) { exceeded.push(`storage (${b.storageBudget.usedBytes}/${b.storageBudget.limitBytes} bytes)`); } if (b.timeBudget.usedMs > b.timeBudget.limitMs) { exceeded.push(`time (${b.timeBudget.usedMs}/${b.timeBudget.limitMs} ms)`); } if (b.costBudget.usedUsd > b.costBudget.limitUsd) { exceeded.push(`cost ($${b.costBudget.usedUsd.toFixed(4)}/$${b.costBudget.limitUsd.toFixed(4)})`); } return exceeded; } } // ============================================================================ // Factory // ============================================================================ /** * Create a DeterministicToolGateway instance */ export function createToolGateway(config) { return new DeterministicToolGateway(config); } //# sourceMappingURL=gateway.js.map