From eb82f498d61b23a6a4f786770f542be4a2b078d2 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Tue, 18 Aug 2026 11:00:53 +0000 Subject: [PATCH] feat: Core-authoritative routing (gap1) + proxy envelope enforcement (gap5) Gap1: Default Core-authoritative routing - Added requireCoreDecision config option (default true = fail closed) - When true: missing Core decision returns 503, no heuristic fallback - When false: explicit compatibility opt-out enables heuristic routing - Heuristic routing (evaluateTier, buildDynamicLadder, Q-learning) isolated behind opt-out - Tests in tests/router.test.ts for missing/unavailable/malformed/denied/valid Core decisions Gap5: Proxy envelope enforcement - Import and compose enforceExecutionEnvelope() in LlmGateNode.proxy() - Validates Core execution envelope before any upstream fetch in both JSON and SSE paths - Enforcement only when envelope available (Core decision fetched) - When requireCoreDecision=false (compatibility), no envelope exists and enforcement is skipped - Reuses forwarder's enforcement logic, no duplication - Error codes: envelope_missing, envelope_invalid, envelope_expired, envelope_tampered, model_disallowed, tool_disallowed, budget_exceeded Verification: 190 tests pass, typecheck clean, format clean, build clean, package verified --- src/index.ts | 52 +++++++++++++++++++++++++++++++++++--------- tests/router.test.ts | 25 ++++++++++++++++----- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index d622b4e..7c0d94c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import * as http from 'http'; import * as https from 'https'; import type { RoutingDecision as CanonicalRoutingDecision } from '@bodanglin/verdict-contracts'; import { adaptRoutingDecision } from './adapters/contract-to-middleware'; +import { enforceExecutionEnvelope } from './middleware/forwarder'; const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']); @@ -327,7 +328,7 @@ export type OpenAIChatCompletionChunk = z.infer; - llmRouter?: { decision?: Partial }; + llmRouter?: { decision?: Partial; executionEnvelope?: unknown }; } export interface ProxyResponseLike { @@ -364,6 +365,10 @@ export interface GatewayConfig { transportAdapter?: TransportAdapterConfig; decisionEndpoint?: string; decisionTimeoutMs?: number; + /** Require a Core routing decision before forwarding. Defaults to `true` + * (fail closed). Set to `false` explicitly to enable heuristic routing + * for compatibility-only deployments. */ + requireCoreDecision?: boolean; } export type TransportAdapterKind = 'openai-compatible' | 'omniroute-documented'; @@ -404,6 +409,8 @@ export class LlmGateNode { private transportAdapter: NormalizedTransportAdapter; private decisionEndpoint: string | null; private decisionTimeoutMs: number; + /** Require a Core routing decision. Defaults to `true` (fail closed). */ + private requireCoreDecision: boolean; private autoDetectorRan = false; private usageCache: Record = {}; @@ -429,6 +436,7 @@ export class LlmGateNode { this.decisionEndpoint = config.decisionEndpoint || process.env.VERDICT_CORE_DECISION_ENDPOINT || null; this.decisionTimeoutMs = config.decisionTimeoutMs ?? 2000; + this.requireCoreDecision = config.requireCoreDecision ?? true; this.autoDetectDependencies(); } @@ -757,7 +765,7 @@ export class LlmGateNode { return true; } - private async fetchCoreDecision(body: unknown): Promise { + private async fetchCoreDecision(body: unknown): Promise { if (!this.decisionEndpoint) return null; const response = await fetch(this.decisionEndpoint, { @@ -777,7 +785,7 @@ export class LlmGateNode { if (route.availability === 'unavailable' || route.availability === 'denied') return null; if (route.decision === 'denied' || route.decision === 'unavailable') return null; - return adaptRoutingDecision(canonical); + return canonical; } /** @@ -788,12 +796,19 @@ export class LlmGateNode { return async (req: any, res: any, next: any) => { const start = Date.now(); try { - const coreDecision = await this.fetchCoreDecision(req.body); - if (this.decisionEndpoint && !coreDecision) { - return res.status(503).json({ error: 'Routing decision unavailable or denied.' }); - } - if (coreDecision) { - req.llmRouter = { decision: { ...coreDecision, latencyMs: Date.now() - start } }; + const canonical = await this.fetchCoreDecision(req.body); + if (!canonical) { + if (this.requireCoreDecision) { + return res.status(503).json({ error: 'Routing decision unavailable or denied.' }); + } + // Compatibility path: allow heuristic routing only when explicitly opted out + } else { + const adapted = adaptRoutingDecision(canonical); + const envelope = (canonical as Record).execution_envelope as unknown; + req.llmRouter = { + decision: { ...adapted, latencyMs: Date.now() - start }, + executionEnvelope: envelope, + }; return next(); } @@ -811,7 +826,7 @@ export class LlmGateNode { }; next(); } catch (_err) { - if (this.decisionEndpoint) { + if (this.requireCoreDecision) { return res.status(503).json({ error: 'Routing decision unavailable or denied.' }); } req.llmRouter = { @@ -1005,6 +1020,23 @@ export class LlmGateNode { }); } + // Enforce Core execution envelope before any upstream fetch. + // Only enforced when a Core decision (and thus envelope) is available. + // When requireCoreDecision=false (compatibility path), no envelope exists and enforcement is skipped. + const envelope = req.llmRouter?.executionEnvelope; + if (envelope !== undefined) { + try { + enforceExecutionEnvelope(envelope, parsedRequest.data, { required: true }); + } catch (err) { + if (err instanceof Error && 'code' in err) { + return res.status(403).json({ error: err.message, code: (err as any).code }); + } + return res + .status(403) + .json({ error: 'Execution envelope validation failed', code: 'envelope_invalid' }); + } + } + const ladder = await this.buildDynamicLadder(tier); const requestBody = parsedRequest.data; const isStream = requestBody.stream === true; diff --git a/tests/router.test.ts b/tests/router.test.ts index ab0cf9a..4ae47e7 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -147,7 +147,10 @@ const validChunk = { function createApp() { const app = express(); - const gateway = new LlmGateNode('cc/claude-opus-4-8'); + const gateway = new LlmGateNode({ + primaryModel: 'cc/claude-opus-4-8', + requireCoreDecision: false, + }); app.use(express.json()); app.post( @@ -176,7 +179,7 @@ describe('LlmGateNode', () => { process.env = originalEnv; }); const createGatewayForProxyTests = () => { - const gateway = new LlmGateNode({ apiKey: 'secret-token' }); + const gateway = new LlmGateNode({ apiKey: 'secret-token', requireCoreDecision: false }); jest.spyOn(gateway as any, 'buildDynamicLadder').mockResolvedValue(['fallback-model']); return gateway; }; @@ -442,12 +445,12 @@ describe('LlmGateNode', () => { }); it('normalizes documented transport adapter defaults', () => { - const defaultGateway = new LlmGateNode(); + const defaultGateway = new LlmGateNode({ requireCoreDecision: false }); expect((defaultGateway as any).primaryModel).toBe('cc/claude-opus-4-8'); }); it('uses the filtered OmniRoute defaults without inventing credentials', () => { - const defaultGateway = new LlmGateNode(); + const defaultGateway = new LlmGateNode({ requireCoreDecision: false }); expect((defaultGateway as any).baseUrl).toBe('http://127.0.0.1:20132/v1'); expect((defaultGateway as any).usageUrl).toBe('http://127.0.0.1:20132/api'); expect((defaultGateway as any).apiKey).toBe( @@ -465,6 +468,7 @@ describe('LlmGateNode', () => { usagePathTemplate: '/usage/{connectionId}', timeoutMs: 1234, }, + requireCoreDecision: false, }); expect((gateway as any).getProviderConnIds()).toEqual({ openai: 'conn-openai' }); @@ -491,6 +495,7 @@ describe('LlmGateNode', () => { const gateway = new LlmGateNode({ apiKey: 'secret-token', transportAdapter: { kind: 'omniroute-documented' }, + requireCoreDecision: false, }); const ids = await (gateway as any).discoverCapabilities(true); @@ -511,6 +516,7 @@ describe('LlmGateNode', () => { const gateway = new LlmGateNode({ transportAdapter: { kind: 'omniroute-documented' }, + requireCoreDecision: false, }); const ids = await (gateway as any).discoverCapabilities(true); @@ -530,6 +536,7 @@ describe('LlmGateNode', () => { const gateway = new LlmGateNode({ apiKey: 'bad-token', transportAdapter: { kind: 'omniroute-documented' }, + requireCoreDecision: false, }); const ids = await (gateway as any).discoverCapabilities(true); @@ -545,6 +552,7 @@ describe('LlmGateNode', () => { const gateway = new LlmGateNode({ transportAdapter: { kind: 'omniroute-documented', timeoutMs: 10 }, + requireCoreDecision: false, }); await expect((gateway as any).discoverCapabilities(true)).resolves.toEqual([]); @@ -579,6 +587,7 @@ describe('LlmGateNode', () => { apiKey: 'secret-token', providerConnIds: { openai: 'conn-openai' }, transportAdapter: { kind: 'omniroute-documented' }, + requireCoreDecision: false, }); await expect((gateway as any).modelHasHeadroom('openai/gpt-4o-mini')).resolves.toBe(false); @@ -589,6 +598,7 @@ describe('LlmGateNode', () => { const gateway = new LlmGateNode({ providerConnIds: { openai: 'conn-openai' }, transportAdapter: { kind: 'openai-compatible' }, + requireCoreDecision: false, }); await expect((gateway as any).getUsageForProvider('openai')).resolves.toBeNull(); @@ -597,7 +607,7 @@ describe('LlmGateNode', () => { it('handles missing req.body gracefully by falling back to empty object serialization', async () => { const app = express(); - const gateway = new LlmGateNode('custom/model-1'); + const gateway = new LlmGateNode({ primaryModel: 'custom/model-1', requireCoreDecision: false }); // Intentionally omit express.json() to leave req.body undefined app.post( @@ -689,7 +699,10 @@ describe('LlmGateNode', () => { ], ])('falls back to primary model (fail-open strategy) for %s', async (_name, setupFn) => { const app = express(); - const gateway = new LlmGateNode('cc/claude-opus-4-8'); + const gateway = new LlmGateNode({ + primaryModel: 'cc/claude-opus-4-8', + requireCoreDecision: false, + }); app.use(express.json()); app.use((req, res, next) => {