452 lines
18 KiB
JavaScript
452 lines
18 KiB
JavaScript
/**
|
|
* 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
|