diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7317f38..bcd2a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: Clean Install CI on: push: - branches: [main, master, develop] + branches: [main, master, develop, feat/**] pull_request: branches: [main, master, develop] 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 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. diff --git a/jest.config.js b/jest.config.js index 0d139d4..2e0cd5f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,8 +6,12 @@ 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' }], }, + 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', verbose: true, 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 e1dadcf..2aa70fd 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,77 @@ 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 +421,19 @@ const HOP_BY_HOP_HEADERS = new Set([ // Default Configuration // ============================================================================ -const DEFAULT_CONFIG: Required = { +const DEFAULT_CONFIG: Omit< + Required, + 'executionEnvelope' | 'requireExecutionEnvelope' | 'expectedPolicyDigest' +> & { + executionEnvelope?: unknown; + requireExecutionEnvelope?: boolean; + expectedPolicyDigest?: string; +} = { baseUrl: '', apiKey: '', + executionEnvelope: undefined, + requireExecutionEnvelope: false, + expectedPolicyDigest: undefined, timeoutMs: 60000, maxRetries: 3, retryDelayMs: 1000, @@ -359,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: Required -): Record { +function buildUpstreamHeaders(req: Request, config: ForwarderConfig): Record { const headers: Record = { 'Content-Type': 'application/json', }; @@ -375,7 +492,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 +504,7 @@ function buildUpstreamHeaders( function filterResponseHeaders( upstreamHeaders: Headers, - config: Required + config: ForwarderConfig ): Record { const filtered: Record = {}; @@ -396,7 +513,10 @@ 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 +537,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 +570,18 @@ 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..023a2a3 --- /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'); + }); + }); }); 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']);