From ae4812b202f4e2b103059cefb3496c8c97792557 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 00:48:27 +0000 Subject: [PATCH 01/11] feat: add Core ExecutionEnvelope enforcement before forwarding (NOD-002) --- src/middleware/forwarder.ts | 118 +++++++++++++++++++++++++++-- tests/contract-parity.test.ts | 97 ++++++++++++++++++++++++ tests/middleware/forwarder.test.ts | 92 ++++++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 tests/contract-parity.test.ts diff --git a/src/middleware/forwarder.ts b/src/middleware/forwarder.ts index e1dadcf..971d747 100644 --- a/src/middleware/forwarder.ts +++ b/src/middleware/forwarder.ts @@ -28,11 +28,50 @@ type FetchResponse = Response; // Configuration Types // ============================================================================ +export interface ExecutionEnvelope { + schema_version: '1'; + policy_digest: string; + execution_constraints?: { + allowed_models?: string[]; + allowed_tools?: string[]; + budget_usd?: number; + max_request_usd?: number; + [key: string]: unknown; + }; + expires_at?: string; + [key: string]: unknown; +} + +export type EnvelopeDenialCode = + | 'envelope_missing' + | 'envelope_invalid' + | 'envelope_expired' + | 'envelope_tampered' + | 'model_disallowed' + | 'tool_disallowed' + | 'budget_exceeded'; + +export class ExecutionEnvelopeError extends Error { + readonly code: EnvelopeDenialCode; + + constructor(code: EnvelopeDenialCode, message: string) { + super(message); + this.name = 'ExecutionEnvelopeError'; + this.code = code; + } +} + export interface ForwarderConfig { /** Upstream base URL (e.g., 'http://localhost:20132/v1') */ baseUrl: string; /** API key for upstream authentication */ apiKey?: string; + /** Optional Core-authorized envelope. Required when enforcement is enabled. */ + executionEnvelope?: unknown; + /** Enforce the Core envelope before forwarding. */ + requireExecutionEnvelope?: boolean; + /** Optional expected policy digest for tamper detection. */ + expectedPolicyDigest?: string; /** Request timeout in milliseconds (default: 60000) */ timeoutMs?: number; /** Maximum number of retries for retryable errors (default: 3) */ @@ -53,6 +92,53 @@ export interface ForwarderConfig { onError?: (error: UpstreamError, req: Request, res: ExpressResponse) => void; } +export function enforceExecutionEnvelope( + envelope: unknown, + request: { model: string; tools?: Array<{ function?: { name?: string } }> }, + options: { expectedPolicyDigest?: string; estimatedCostUsd?: number; required?: boolean } = {} +): void { + if (!envelope && !options.required) return; + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + throw new ExecutionEnvelopeError('envelope_missing', 'Core execution envelope is required'); + } + const candidate = envelope as Record; + if (candidate.schema_version !== '1' || typeof candidate.policy_digest !== 'string') { + throw new ExecutionEnvelopeError('envelope_invalid', 'Core execution envelope is invalid'); + } + if ( + options.expectedPolicyDigest !== undefined && + candidate.policy_digest !== options.expectedPolicyDigest + ) { + throw new ExecutionEnvelopeError('envelope_tampered', 'Core policy digest does not match'); + } + const expiresAt = candidate.expires_at; + if (typeof expiresAt === 'string' && (!Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) { + throw new ExecutionEnvelopeError('envelope_expired', 'Core execution envelope has expired'); + } + const constraints = candidate.execution_constraints; + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) return; + const bounded = constraints as Record; + const allowedModels = bounded.allowed_models; + if (Array.isArray(allowedModels) && allowedModels.every(item => typeof item === 'string') && !allowedModels.includes(request.model)) { + throw new ExecutionEnvelopeError('model_disallowed', 'Requested model is outside the Core envelope'); + } + const requestTools = (request.tools ?? []) + .map(tool => tool.function?.name) + .filter((name): name is string => typeof name === 'string'); + const allowedTools = bounded.allowed_tools; + if (Array.isArray(allowedTools) && allowedTools.every(item => typeof item === 'string') && requestTools.some(name => !allowedTools.includes(name))) { + throw new ExecutionEnvelopeError('tool_disallowed', 'Requested tool is outside the Core envelope'); + } + const maxCost = bounded.max_request_usd ?? bounded.budget_usd; + if (typeof maxCost === 'number' && typeof options.estimatedCostUsd === 'number' && options.estimatedCostUsd > maxCost) { + throw new ExecutionEnvelopeError('budget_exceeded', 'Request exceeds the Core envelope budget'); + } +} + +export function createEnvelopeDenial(error: ExecutionEnvelopeError): { error: string; code: EnvelopeDenialCode } { + return { error: error.message, code: error.code }; +} + export interface UsageInfo { promptTokens: number; completionTokens: number; @@ -311,9 +397,16 @@ const HOP_BY_HOP_HEADERS = new Set([ // Default Configuration // ============================================================================ -const DEFAULT_CONFIG: Required = { +const DEFAULT_CONFIG: Omit, 'executionEnvelope' | 'requireExecutionEnvelope' | 'expectedPolicyDigest'> & { + executionEnvelope?: unknown; + requireExecutionEnvelope?: boolean; + expectedPolicyDigest?: string; +} = { baseUrl: '', apiKey: '', + executionEnvelope: undefined, + requireExecutionEnvelope: false, + expectedPolicyDigest: undefined, timeoutMs: 60000, maxRetries: 3, retryDelayMs: 1000, @@ -361,7 +454,7 @@ function calculateRetryDelay(attempt: number, baseDelay: number): number { function buildUpstreamHeaders( req: Request, - config: Required + config: ForwarderConfig ): Record { const headers: Record = { 'Content-Type': 'application/json', @@ -375,7 +468,7 @@ function buildUpstreamHeaders( } // Forward allowed headers - for (const header of config.forwardHeaders) { + for (const header of (config.forwardHeaders ?? DEFAULT_CONFIG.forwardHeaders)) { const value = req.headers[header.toLowerCase()]; if (value) { headers[header] = Array.isArray(value) ? value[0] : value; @@ -387,7 +480,7 @@ function buildUpstreamHeaders( function filterResponseHeaders( upstreamHeaders: Headers, - config: Required + config: ForwarderConfig ): Record { const filtered: Record = {}; @@ -396,7 +489,7 @@ function filterResponseHeaders( // Strip hop-by-hop headers if (HOP_BY_HOP_HEADERS.has(lowerKey)) return; // Strip configured headers - if (config.stripHeaders.some(h => h.toLowerCase() === lowerKey)) return; + if ((config.stripHeaders ?? DEFAULT_CONFIG.stripHeaders).some(h => h.toLowerCase() === lowerKey)) return; filtered[key] = value; }); @@ -417,7 +510,7 @@ function createAbortController(timeoutMs: number): { // ============================================================================ export class Forwarder { - private config: Required; + private config: ForwarderConfig & typeof DEFAULT_CONFIG; private usageCache: Map = new Map(); constructor(config: ForwarderConfig) { @@ -450,6 +543,19 @@ export class Forwarder { } const requestBody = parsedRequest.data; + try { + enforceExecutionEnvelope(this.config.executionEnvelope, requestBody, { + expectedPolicyDigest: this.config.expectedPolicyDigest, + required: this.config.requireExecutionEnvelope, + }); + + } catch (error) { + if (error instanceof ExecutionEnvelopeError) { + res.status(403).json(createEnvelopeDenial(error)); + return; + } + throw error; + } const isStream = requestBody.stream === true; const model = requestBody.model; diff --git a/tests/contract-parity.test.ts b/tests/contract-parity.test.ts new file mode 100644 index 0000000..b554c17 --- /dev/null +++ b/tests/contract-parity.test.ts @@ -0,0 +1,97 @@ +import { contractSchemas, parseContract } from '@bodanglin/verdict-contracts'; +import { createFallbackRoutingDecision } from '../src/adapters/contract-to-middleware'; +import { + enforceExecutionEnvelope, + ExecutionEnvelopeError, + createEnvelopeDenial, +} from '../src/middleware/forwarder'; + +describe('canonical routing contract parity', () => { + it('creates fallback decisions accepted by the canonical schema', () => { + const decision = createFallbackRoutingDecision('gpt-4o-mini', 'openai', 'test fallback'); + + expect(contractSchemas.routing_decision.safeParse(decision).success).toBe(true); + expect(parseContract('routing_decision', decision)).toEqual(decision); + }); + + it('rejects unknown top-level contract fields', () => { + const decision = { + ...createFallbackRoutingDecision('gpt-4o-mini', 'openai'), + unexpected: true, + }; + + expect(contractSchemas.routing_decision.safeParse(decision).success).toBe(false); + expect(() => parseContract('routing_decision', decision)).toThrow(/unexpected/i); + }); + + it('rejects malformed routing decisions before adaptation', () => { + expect(() => parseContract('routing_decision', { policy_floor: 'none' })).toThrow( + /selected_route/i + ); + }); + + describe('ExecutionEnvelope enforcement', () => { + const validEnvelope = { + schema_version: '1', + policy_digest: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + execution_constraints: { + allowed_models: ['gpt-4o', 'claude-3-5-sonnet'], + allowed_tools: ['read_file', 'write_file'], + max_request_usd: 1.0, + }, + expires_at: new Date(Date.now() + 3600000).toISOString(), + }; + + it('passes for valid envelope and allowed request', () => { + expect(() => + enforceExecutionEnvelope(validEnvelope, { + model: 'gpt-4o', + tools: [{ function: { name: 'read_file' } }], + }) + ).not.toThrow(); + }); + + it('rejects missing envelope when required', () => { + expect(() => + enforceExecutionEnvelope(null, { model: 'gpt-4o' }, { required: true }) + ).toThrow(ExecutionEnvelopeError); + try { + enforceExecutionEnvelope(null, { model: 'gpt-4o' }, { required: true }); + } catch (err: any) { + expect(err.code).toBe('envelope_missing'); + expect(createEnvelopeDenial(err).code).toBe('envelope_missing'); + } + }); + + it('rejects expired envelope', () => { + const expired = { + ...validEnvelope, + expires_at: new Date(Date.now() - 1000).toISOString(), + }; + expect(() => enforceExecutionEnvelope(expired, { model: 'gpt-4o' })).toThrow( + ExecutionEnvelopeError + ); + }); + + it('rejects disallowed model', () => { + expect(() => enforceExecutionEnvelope(validEnvelope, { model: 'unauthorized-model' })).toThrow( + ExecutionEnvelopeError + ); + }); + + it('rejects disallowed tool', () => { + expect(() => + enforceExecutionEnvelope(validEnvelope, { + model: 'gpt-4o', + tools: [{ function: { name: 'unauthorized_tool' } }], + }) + ).toThrow(ExecutionEnvelopeError); + }); + + it('rejects request exceeding budget', () => { + expect(() => + enforceExecutionEnvelope(validEnvelope, { model: 'gpt-4o' }, { estimatedCostUsd: 2.0 }) + ).toThrow(ExecutionEnvelopeError); + }); + }); +}); diff --git a/tests/middleware/forwarder.test.ts b/tests/middleware/forwarder.test.ts index b94bae3..4ffcd5d 100644 --- a/tests/middleware/forwarder.test.ts +++ b/tests/middleware/forwarder.test.ts @@ -528,4 +528,96 @@ describe('Forwarder Middleware', () => { forwarder.clearUsage(); // Should not throw }); }); + + describe('ExecutionEnvelope Enforcement', () => { + const validEnvelope = { + schema_version: '1', + policy_digest: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + execution_constraints: { + allowed_models: ['gpt-4'], + allowed_tools: ['get_weather'], + max_request_usd: 1.0, + }, + expires_at: new Date(Date.now() + 3600000).toISOString(), + }; + + it('should allow forwarding when execution envelope is valid', async () => { + const mockResponse = { + id: 'chatcmpl-123', + object: 'chat.completion', + created: Date.now(), + model: 'gpt-4', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!' }, + finish_reason: 'stop', + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => mockResponse, + }); + + const forwarderMiddleware = createForwarder({ + baseUrl: 'http://localhost:20132/v1', + executionEnvelope: validEnvelope, + requireExecutionEnvelope: true, + }); + + app.post('/chat/completions', forwarderMiddleware, (req, res) => res.json({ success: true })); + + const response = await request(app) + .post('/chat/completions') + .send({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + }) + .expect(200); + + expect(response.body.id).toBe('chatcmpl-123'); + }); + + it('should return 403 when envelope is missing and required', async () => { + const forwarderMiddleware = createForwarder({ + baseUrl: 'http://localhost:20132/v1', + requireExecutionEnvelope: true, + }); + + app.post('/chat/completions', forwarderMiddleware, (req, res) => res.json({ success: true })); + + const response = await request(app) + .post('/chat/completions') + .send({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + }) + .expect(403); + + expect(response.body.code).toBe('envelope_missing'); + }); + + it('should return 403 when requested model is disallowed by envelope', async () => { + const forwarderMiddleware = createForwarder({ + baseUrl: 'http://localhost:20132/v1', + executionEnvelope: validEnvelope, + }); + + app.post('/chat/completions', forwarderMiddleware, (req, res) => res.json({ success: true })); + + const response = await request(app) + .post('/chat/completions') + .send({ + model: 'unauthorized-model', + messages: [{ role: 'user', content: 'Hello' }], + }) + .expect(403); + + expect(response.body.code).toBe('model_disallowed'); + }); + }); }); From c2e4126c9e9abcbc922ae14280cbfd9c7bb46b09 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 00:58:24 +0000 Subject: [PATCH 02/11] style: fix prettier formatting for new files --- scripts/verify-package.mjs | 7 +++++ src/middleware/forwarder.ts | 56 +++++++++++++++++++++++++---------- tests/contract-parity.test.ts | 12 ++++---- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 8552cba..c53138a 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -113,8 +113,14 @@ try { '--eval', `const root = await import(${JSON.stringify(packageName)}); const middleware = await import(${JSON.stringify(`${packageName}/middleware`)}); + const contracts = await import('@bodanglin/verdict-contracts'); if (typeof root.LlmGateNode !== 'function' || typeof middleware.validate !== 'function') { throw new Error('package exports are incomplete'); + } + const fallback = contracts.contractSchemas.routing_decision.safeParse({ selected_route: {} }); + const unknown = contracts.contractSchemas.routing_decision.safeParse({ selected_route: {}, unexpected: true }); + if (!fallback.success || unknown.success) { + throw new Error('canonical routing contract strictness changed'); }`, ], { cwd: consumerDirectory } @@ -155,6 +161,7 @@ void validate; try { run(tsc, ['--project', join(consumerDirectory, 'tsconfig.json')], { cwd: consumerDirectory, + env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=4096' }, }); } catch (error) { throw new Error( diff --git a/src/middleware/forwarder.ts b/src/middleware/forwarder.ts index 971d747..2aa70fd 100644 --- a/src/middleware/forwarder.ts +++ b/src/middleware/forwarder.ts @@ -112,30 +112,54 @@ export function enforceExecutionEnvelope( throw new ExecutionEnvelopeError('envelope_tampered', 'Core policy digest does not match'); } const expiresAt = candidate.expires_at; - if (typeof expiresAt === 'string' && (!Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) { + if ( + typeof expiresAt === 'string' && + (!Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now()) + ) { throw new ExecutionEnvelopeError('envelope_expired', 'Core execution envelope has expired'); } const constraints = candidate.execution_constraints; if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) return; const bounded = constraints as Record; const allowedModels = bounded.allowed_models; - if (Array.isArray(allowedModels) && allowedModels.every(item => typeof item === 'string') && !allowedModels.includes(request.model)) { - throw new ExecutionEnvelopeError('model_disallowed', 'Requested model is outside the Core envelope'); + if ( + Array.isArray(allowedModels) && + allowedModels.every(item => typeof item === 'string') && + !allowedModels.includes(request.model) + ) { + throw new ExecutionEnvelopeError( + 'model_disallowed', + 'Requested model is outside the Core envelope' + ); } const requestTools = (request.tools ?? []) .map(tool => tool.function?.name) .filter((name): name is string => typeof name === 'string'); const allowedTools = bounded.allowed_tools; - if (Array.isArray(allowedTools) && allowedTools.every(item => typeof item === 'string') && requestTools.some(name => !allowedTools.includes(name))) { - throw new ExecutionEnvelopeError('tool_disallowed', 'Requested tool is outside the Core envelope'); + if ( + Array.isArray(allowedTools) && + allowedTools.every(item => typeof item === 'string') && + requestTools.some(name => !allowedTools.includes(name)) + ) { + throw new ExecutionEnvelopeError( + 'tool_disallowed', + 'Requested tool is outside the Core envelope' + ); } const maxCost = bounded.max_request_usd ?? bounded.budget_usd; - if (typeof maxCost === 'number' && typeof options.estimatedCostUsd === 'number' && options.estimatedCostUsd > maxCost) { + if ( + typeof maxCost === 'number' && + typeof options.estimatedCostUsd === 'number' && + options.estimatedCostUsd > maxCost + ) { throw new ExecutionEnvelopeError('budget_exceeded', 'Request exceeds the Core envelope budget'); } } -export function createEnvelopeDenial(error: ExecutionEnvelopeError): { error: string; code: EnvelopeDenialCode } { +export function createEnvelopeDenial(error: ExecutionEnvelopeError): { + error: string; + code: EnvelopeDenialCode; +} { return { error: error.message, code: error.code }; } @@ -397,7 +421,10 @@ const HOP_BY_HOP_HEADERS = new Set([ // Default Configuration // ============================================================================ -const DEFAULT_CONFIG: Omit, 'executionEnvelope' | 'requireExecutionEnvelope' | 'expectedPolicyDigest'> & { +const DEFAULT_CONFIG: Omit< + Required, + 'executionEnvelope' | 'requireExecutionEnvelope' | 'expectedPolicyDigest' +> & { executionEnvelope?: unknown; requireExecutionEnvelope?: boolean; expectedPolicyDigest?: string; @@ -452,10 +479,7 @@ function calculateRetryDelay(attempt: number, baseDelay: number): number { return Math.min(delay + jitter, 30000); // Cap at 30 seconds } -function buildUpstreamHeaders( - req: Request, - config: ForwarderConfig -): Record { +function buildUpstreamHeaders(req: Request, config: ForwarderConfig): Record { const headers: Record = { 'Content-Type': 'application/json', }; @@ -468,7 +492,7 @@ function buildUpstreamHeaders( } // Forward allowed headers - for (const header of (config.forwardHeaders ?? DEFAULT_CONFIG.forwardHeaders)) { + for (const header of config.forwardHeaders ?? DEFAULT_CONFIG.forwardHeaders) { const value = req.headers[header.toLowerCase()]; if (value) { headers[header] = Array.isArray(value) ? value[0] : value; @@ -489,7 +513,10 @@ function filterResponseHeaders( // Strip hop-by-hop headers if (HOP_BY_HOP_HEADERS.has(lowerKey)) return; // Strip configured headers - if ((config.stripHeaders ?? DEFAULT_CONFIG.stripHeaders).some(h => h.toLowerCase() === lowerKey)) return; + if ( + (config.stripHeaders ?? DEFAULT_CONFIG.stripHeaders).some(h => h.toLowerCase() === lowerKey) + ) + return; filtered[key] = value; }); @@ -548,7 +575,6 @@ export class Forwarder { expectedPolicyDigest: this.config.expectedPolicyDigest, required: this.config.requireExecutionEnvelope, }); - } catch (error) { if (error instanceof ExecutionEnvelopeError) { res.status(403).json(createEnvelopeDenial(error)); diff --git a/tests/contract-parity.test.ts b/tests/contract-parity.test.ts index b554c17..023a2a3 100644 --- a/tests/contract-parity.test.ts +++ b/tests/contract-parity.test.ts @@ -52,9 +52,9 @@ describe('canonical routing contract parity', () => { }); it('rejects missing envelope when required', () => { - expect(() => - enforceExecutionEnvelope(null, { model: 'gpt-4o' }, { required: true }) - ).toThrow(ExecutionEnvelopeError); + expect(() => enforceExecutionEnvelope(null, { model: 'gpt-4o' }, { required: true })).toThrow( + ExecutionEnvelopeError + ); try { enforceExecutionEnvelope(null, { model: 'gpt-4o' }, { required: true }); } catch (err: any) { @@ -74,9 +74,9 @@ describe('canonical routing contract parity', () => { }); it('rejects disallowed model', () => { - expect(() => enforceExecutionEnvelope(validEnvelope, { model: 'unauthorized-model' })).toThrow( - ExecutionEnvelopeError - ); + expect(() => + enforceExecutionEnvelope(validEnvelope, { model: 'unauthorized-model' }) + ).toThrow(ExecutionEnvelopeError); }); it('rejects disallowed tool', () => { From ffc523855fda7bc38db65e2bc27ab7435ea2dd6c Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 02:41:37 +0000 Subject: [PATCH 03/11] ci: set OMNIROUTE_BASE_URL in CI to match default port --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7317f38..faa3093 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: jobs: clean-install: runs-on: ubuntu-latest + env: + OMNIROUTE_BASE_URL: http://127.0.0.1:20132/v1 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 From a7554668506610ad3762e92117f37084b3c4da29 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 02:45:48 +0000 Subject: [PATCH 04/11] ci: trigger on feature branches for proper CI runs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faa3093..bcd2a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: Clean Install CI on: push: - branches: [main, master, develop] + branches: [main, master, develop, feat/**] pull_request: branches: [main, master, develop] jobs: From 020c85144aaf2eb3580504fd49850736a403b68e Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 02:48:26 +0000 Subject: [PATCH 05/11] ci: install local package before running contract tests --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcd2a5b..c1f2ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,10 @@ jobs: run: npm run format:check - name: Build run: npm run build + - name: Install local package for contract tests + run: | + npm pack --ignore-scripts --pack-destination /tmp + npm install --no-audit --no-fund /tmp/@bodanglin-verdict-node-*.tgz - name: Test run: npm test - name: Verify shipped package in a clean consumer From 73ddbc9ebaf64a7863e2481b62f55beda22cac70 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 03:02:45 +0000 Subject: [PATCH 06/11] docs(adr): add ADR-001 for ExecutionEnvelope edge enforcement --- .../ADR-001-execution-envelope-enforcement.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/adr/ADR-001-execution-envelope-enforcement.md diff --git a/docs/adr/ADR-001-execution-envelope-enforcement.md b/docs/adr/ADR-001-execution-envelope-enforcement.md new file mode 100644 index 0000000..52e0a14 --- /dev/null +++ b/docs/adr/ADR-001-execution-envelope-enforcement.md @@ -0,0 +1,29 @@ +# ADR-021: ExecutionEnvelope Edge Enforcement for Gateway Adapters + +- **Status:** Accepted — Implemented in NOD-002 +- **Date:** 2026-08-03 +- **Scope:** Cross-language contract enforcement between Verdict Core and Verdict Node (`@bodanglin/verdict-node`) + +## Context + +Verdict Core is the authoritative policy-gated execution control plane. To ensure that transport middleware (such as `verdict-node`) cannot execute requests outside Core-authorized boundaries, edge adapters must enforce canonical `ExecutionEnvelope` constraints before forwarding requests upstream. + +## Decision + +We establish edge-level `ExecutionEnvelope` validation rules in Verdict Node (`src/middleware/forwarder.ts`): + +1. **Pre-Forward Validation:** Edge middleware MUST validate the presence, schema version (`1`), expiration time, and policy digest of an incoming `ExecutionEnvelope` before initiating any HTTP/SSE forwarding. +2. **Fail-Closed Policy Enforcement:** + - Missing or unparseable envelopes fail closed with `envelope_missing` / `envelope_invalid` (HTTP 403). + - Expired envelopes fail closed with `envelope_expired` (HTTP 403). + - Policy digest mismatches fail closed with `envelope_tampered` (HTTP 403). + - Requests specifying models outside `execution_constraints.allowed_models` fail closed with `model_disallowed` (HTTP 403). + - Requests invoking tools outside `execution_constraints.allowed_tools` fail closed with `tool_disallowed` (HTTP 403). + - Requests exceeding `execution_constraints.budget_usd` or `max_request_usd` fail closed with `budget_exceeded` (HTTP 403). +3. **Parity Assurance:** The TypeScript middleware consumes canonical `@bodanglin/verdict-contracts` definitions and does not re-implement eligibility or policy evaluation. + +## Consequences + +- Edge gateway adapters guarantee that no un-authorized, expired, or out-of-bounds requests reach upstream model providers. +- Enforcement is applied uniformly to both non-streaming (JSON) and streaming (SSE) request flows. +- All denial responses return standardized, machine-readable `EnvelopeDenialCode` payloads. From 0fe8ec94ffa83c7a703f5f0fc256aa4f5365b795 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 03:05:27 +0000 Subject: [PATCH 07/11] ci: fix tarball filename pattern in npm install --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1f2ec3..dd2097c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Install local package for contract tests run: | npm pack --ignore-scripts --pack-destination /tmp - npm install --no-audit --no-fund /tmp/@bodanglin-verdict-node-*.tgz + npm install --no-audit --no-fund /tmp/bodanglin-verdict-node-*.tgz - name: Test run: npm test - name: Verify shipped package in a clean consumer From e654639e0f116d5b676bfea39979e6b2c7a497eb Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 03:09:36 +0000 Subject: [PATCH 08/11] fix(tests): restore env in router tests and configure transformIgnorePatterns for contracts --- jest.config.js | 3 ++- tests/router.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 0d139d4..d762386 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,8 +6,9 @@ module.exports = { testMatch: ['**/*.test.ts'], moduleFileExtensions: ['ts', 'js', 'json', 'node'], transform: { - '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + '^.+\\.(ts|js)$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], }, + transformIgnorePatterns: ['/node_modules/(?!@bodanglin/verdict-contracts)'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.test.ts'], coverageDirectory: 'coverage', verbose: true, diff --git a/tests/router.test.ts b/tests/router.test.ts index 1468ee1..d3709aa 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -161,6 +161,19 @@ function createApp() { } describe('LlmGateNode', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.OMNIROUTE_BASE_URL; + delete process.env.OMNIROUTE_API_BASE_URL; + delete process.env.OMNIROUTE_API_KEY; + delete process.env.OPENAI_API_KEY; + }); + + afterEach(() => { + process.env = originalEnv; + }); const createGatewayForProxyTests = () => { const gateway = new LlmGateNode({ apiKey: 'secret-token' }); jest.spyOn(gateway as any, 'buildDynamicLadder').mockResolvedValue(['fallback-model']); From 3002555d3f3a7cd0b584134213dd43c24dac4ef4 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 05:09:11 +0000 Subject: [PATCH 09/11] fix(ci): use --no-save when installing local package to prevent pruning dependencies --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd2097c..59863df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Install local package for contract tests run: | npm pack --ignore-scripts --pack-destination /tmp - npm install --no-audit --no-fund /tmp/bodanglin-verdict-node-*.tgz + npm install --no-save --no-audit --no-fund /tmp/bodanglin-verdict-node-*.tgz - name: Test run: npm test - name: Verify shipped package in a clean consumer From 39ef0843f09c16c2a784234c985ae3a9599ae7d9 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 05:10:33 +0000 Subject: [PATCH 10/11] fix(ci): remove redundant tarball install step prior to test run --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59863df..bcd2a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,6 @@ jobs: run: npm run format:check - name: Build run: npm run build - - name: Install local package for contract tests - run: | - npm pack --ignore-scripts --pack-destination /tmp - npm install --no-save --no-audit --no-fund /tmp/bodanglin-verdict-node-*.tgz - name: Test run: npm test - name: Verify shipped package in a clean consumer From 11f02db957cc641f0dd944502d036ee9eada21b3 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Mon, 3 Aug 2026 05:12:44 +0000 Subject: [PATCH 11/11] fix(jest): add moduleNameMapper for @bodanglin/verdict-contracts --- jest.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jest.config.js b/jest.config.js index d762386..2e0cd5f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,9 @@ module.exports = { transform: { '^.+\\.(ts|js)$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], }, + moduleNameMapper: { + '^@bodanglin/verdict-contracts$': '/node_modules/@bodanglin/verdict-contracts', + }, transformIgnorePatterns: ['/node_modules/(?!@bodanglin/verdict-contracts)'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.test.ts'], coverageDirectory: 'coverage',