diff --git a/src/runtime/safe-codec.ts b/src/runtime/safe-codec.ts index 3a43250..9ced2e8 100644 --- a/src/runtime/safe-codec.ts +++ b/src/runtime/safe-codec.ts @@ -11,6 +11,7 @@ import { BridgeProtocolError, BridgeExecutionError } from './errors.js'; import { containsSpecialFloat } from './validators.js'; import { decodeValueAsync as decodeArrowValue } from '../utils/codec.js'; +import { PROTOCOL_ID } from './transport.js'; // ═══════════════════════════════════════════════════════════════════════════ // TYPES @@ -161,6 +162,29 @@ function isProtocolResultResponse(value: unknown): value is ProtocolResultRespon return typeof obj.id === 'number' && 'result' in obj; } +/** + * Validate the protocol version in a response. + * Only validates when the response looks like a protocol envelope (has 'id' field). + * Throws if protocol is present but doesn't match expected version. + * Allows missing protocol for backwards compatibility. + */ +function validateProtocolVersion(value: unknown): void { + if (value === null || typeof value !== 'object') { + return; + } + const obj = value as Record; + // Only validate protocol on protocol envelopes (responses with 'id' field) + // This avoids false positives on user data that happens to contain 'protocol' key + if (!('id' in obj)) { + return; + } + if ('protocol' in obj && obj.protocol !== PROTOCOL_ID) { + throw new BridgeProtocolError( + `Invalid protocol version: expected "${PROTOCOL_ID}", got "${obj.protocol}"` + ); + } +} + /** * Find the path to a special float in a value structure. * Returns undefined if no special float is found. @@ -314,6 +338,9 @@ export class SafeCodec { throw new BridgeProtocolError(`JSON parse failed: ${message}`); } + // Validate protocol version (if present) + validateProtocolVersion(parsed); + // Check for Python error response if (isPythonErrorResponse(parsed)) { const error = new BridgeExecutionError( @@ -364,6 +391,9 @@ export class SafeCodec { throw new BridgeProtocolError(`JSON parse failed: ${message}`); } + // Validate protocol version (if present) + validateProtocolVersion(parsed); + // Check for Python error response if (isPythonErrorResponse(parsed)) { const error = new BridgeExecutionError( diff --git a/test/adversarial_playground.test.ts b/test/adversarial_playground.test.ts index eb7b40b..1734c0f 100644 --- a/test/adversarial_playground.test.ts +++ b/test/adversarial_playground.test.ts @@ -485,11 +485,9 @@ describeAdversarial('Adversarial playground', () => { describe('Protocol contract violations', () => { const fixtureCases: Array<{ script: string; pattern: RegExp; skip?: boolean }> = [ { - // New architecture doesn't validate protocol version field in responses - // This test is skipped as protocol version validation is not implemented + // Protocol version validation implemented in SafeCodec script: 'wrong_protocol_bridge.py', pattern: /Invalid protocol/, - skip: true, }, { script: 'missing_id_bridge.py', diff --git a/test/safe-codec.test.ts b/test/safe-codec.test.ts index fe4e084..f31e04e 100644 --- a/test/safe-codec.test.ts +++ b/test/safe-codec.test.ts @@ -681,3 +681,58 @@ describe('Edge Cases', () => { expect(decoded.text).toBe('line1\nline2\ttab\r\nwindows'); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// DECODE RESPONSE - PROTOCOL VALIDATION +// ═══════════════════════════════════════════════════════════════════════════ + +describe('decodeResponse - Protocol Validation', () => { + let codec: SafeCodec; + + beforeEach(() => { + codec = new SafeCodec(); + }); + + it('accepts response without protocol field (backwards compatibility)', () => { + const payload = JSON.stringify({ id: 1, result: 42 }); + const result = codec.decodeResponse(payload); + expect(result).toBe(42); + }); + + it('accepts response with correct protocol version', () => { + const payload = JSON.stringify({ id: 1, protocol: 'tywrap/1', result: { data: 'test' } }); + const result = codec.decodeResponse<{ data: string }>(payload); + expect(result).toEqual({ data: 'test' }); + }); + + it('rejects response with wrong protocol version', () => { + const payload = JSON.stringify({ id: 1, protocol: 'tywrap/0', result: 42 }); + expect(() => codec.decodeResponse(payload)).toThrow(BridgeProtocolError); + expect(() => codec.decodeResponse(payload)).toThrow(/Invalid protocol version/); + }); + + it('rejects response with unknown protocol', () => { + const payload = JSON.stringify({ id: 1, protocol: 'unknown/1', result: 42 }); + expect(() => codec.decodeResponse(payload)).toThrow(BridgeProtocolError); + expect(() => codec.decodeResponse(payload)).toThrow(/expected "tywrap\/1"/); + }); + + it('validates protocol before extracting error response', () => { + // If protocol is wrong, we should reject before checking for Python errors + const payload = JSON.stringify({ + id: 1, + protocol: 'wrong/1', + error: { type: 'ValueError', message: 'test' }, + }); + expect(() => codec.decodeResponse(payload)).toThrow(BridgeProtocolError); + expect(() => codec.decodeResponse(payload)).toThrow(/Invalid protocol version/); + }); + + it('does not validate protocol on non-envelope responses', () => { + // User data that happens to contain 'protocol' key should not trigger validation + // Only responses with 'id' field are treated as protocol envelopes + const payload = JSON.stringify({ protocol: 'http', url: 'https://example.com' }); + const result = codec.decodeResponse<{ protocol: string; url: string }>(payload); + expect(result).toEqual({ protocol: 'http', url: 'https://example.com' }); + }); +});