From a28b3ed7d075376affff921c5d19ef3f73e43d45 Mon Sep 17 00:00:00 2001 From: cole Date: Sat, 22 Aug 2026 19:45:10 +0000 Subject: [PATCH 1/3] feat(v3): add the strict relative artifact path policy Answer exactly one question deterministically and fail closed: is this string a strict portable run-relative artifact path? Accept only NFC, well-formed, forward-slash, relative paths of 1..16 segments (128 UTF-8 bytes each, 1024 total). Reject backslashes, leading/trailing/doubled slashes, empty and dot-only segments, colons (foreclosing drive, UNC, alternate-data-stream, and scheme spellings under one rule), Windows reserved device stems with the extension ignored, edge dots/spaces that Windows tooling strips, C0/C1 controls, invisible/format/bidi/tag code points, solidus look-alikes, non-NFC text, and lone surrogates while keeping well-formed astral characters legal. The fixed validation pipeline raises identical first typed errors from a closed 17-code vocabulary, never echoes hostile bytes (U+ notation and offset only), captures every dynamic intrinsic at import, scans strings by code points, and returns deep-frozen detached descriptors. Pure path policy only: no filesystem, network, or process I/O, and no claim that P08 artifact storage exists. Focused hostile coverage pins the rejection matrix, ordering, bounds parity, predicate agreement, and detachment. --- .../mcp/v3/artifact-path.mjs | 355 ++++++++++++++++++ .../test/r1-artifact-path.test.mjs | 163 ++++++++ 2 files changed, 518 insertions(+) create mode 100644 plugins/codex-co-engineer/mcp/v3/artifact-path.mjs create mode 100644 plugins/codex-co-engineer/test/r1-artifact-path.test.mjs diff --git a/plugins/codex-co-engineer/mcp/v3/artifact-path.mjs b/plugins/codex-co-engineer/mcp/v3/artifact-path.mjs new file mode 100644 index 0000000..1b1a951 --- /dev/null +++ b/plugins/codex-co-engineer/mcp/v3/artifact-path.mjs @@ -0,0 +1,355 @@ +// ArtifactRelativePathV1 — strict portable relative artifact path policy +// (ADR 0001 identifiers `bounded_evidence`, +// `gate_a_valid_raw_and_sanitized_artifacts`, `exact_identities`). +// +// Additive v3 module for W3-P07. It owns exactly one question, answered +// deterministically and fail closed: is this string a strict portable +// run-relative artifact path? It answers nothing else: it never touches the +// filesystem, network, or process state, and it does NOT claim that artifact +// storage exists. The P08 store, sanitizer/reader, and evidence bundle are +// separate later surfaces; producers declare paths here and only a later +// storage authority may bind them to bytes. +// +// Accepted grammar — POSIX-style, forward-slash separated, case-sensitive, +// NFC-normalized UTF-8, relative and canonical by construction: +// - 1..ARTIFACT_PATH_MAX_SEGMENTS segments of 1..ARTIFACT_PATH_SEGMENT_MAX_BYTES +// each, total UTF-8 length 1..ARTIFACT_PATH_MAX_BYTES; +// - every segment non-empty and not made only of dots, so '.', '..' and +// '...' style traversal aliases cannot be spelled at all; +// - no backslash anywhere; no leading, trailing, or doubled '/'; +// - no ':' anywhere, which forecloses drive forms (`C:`), drive-relative +// spellings, NTFS alternate data streams (`file:ads`), and scheme tricks +// (`file:`) under one deterministic rule; +// - no Windows reserved device name as a segment stem (case-insensitive +// ASCII fold, extension ignored: `con`, `CON.txt`, `com1`, `lpt0`, +// `clock$` are rejected; whole stems like +// `concat`, `nullify`, and `auxiliary` are ordinary names and stay +// accepted); +// - no leading or trailing ASCII space and no trailing dot in a segment, +// which Windows tooling silently strips; +// - well-formed Unicode only (lone surrogates rejected) and exactly NFC: +// canonically equivalent but differently composed names are distinct +// byte strings, so the policy refuses to choose between them; +// - no C0/C1 control characters (NUL included), no invisible/format/ +// bidi-override/line-separator/tag code points, and no solidus +// look-alikes that render as separators a kernel will not see. +// +// Fixed validation pipeline: identical inputs always raise the identical +// first typed error, independent of how many rules they violate. The order +// is: type, empty, lone surrogates, total byte bound, NFC, backslash, +// leading slash, trailing slash, per-segment structure (count, empty, +// dot-only alias, reserved device stem, edge space/dot, segment bytes), +// then the whole-path character scan, which reports the FIRST offending +// code point in strict left-to-right order across the control, +// invisible/format, separator look-alike, and colon classes. Rejections +// carry stable codes from the closed +// ARTIFACT_RELATIVE_PATH_ERROR_CODES vocabulary and never echo the hostile +// character itself, only its U+ notation and offset. +// +// Intrinsic hardening: every dynamic surface used after caller input exists +// is captured once at clean import. String scanning is a manual indexed walk +// (captured codePointAt/charCodeAt/normalize/split/startsWith/endsWith), so +// a post-import patch of String iteration, RegExp, Array methods, or +// Buffer.byteLength can neither alter acceptance nor execute caller code. + +import { Buffer as NodeBuffer } from 'node:buffer'; + +import { + capturedFreeze, + capturedUtf8ByteLength, +} from './grammar.mjs'; +import { RunContractV1Error } from './run-manifest.mjs'; + +export const ARTIFACT_PATH_POLICY_ID = 'codex-co-engineer.artifact-path.v1'; + +export const ARTIFACT_PATH_MAX_BYTES = 1024; +export const ARTIFACT_PATH_MAX_SEGMENTS = 16; +export const ARTIFACT_PATH_SEGMENT_MAX_BYTES = 128; + +export const ARTIFACT_RELATIVE_PATH_ERROR_CODES = capturedFreeze([ + 'absolute_path_denied', + 'alias_segment_denied', + 'colon_denied', + 'control_character_denied', + 'edge_character_denied', + 'empty_path', + 'empty_segment_denied', + 'invalid_encoding', + 'invalid_separator', + 'invalid_type', + 'invisible_character_denied', + 'out_of_range', + 'reserved_device_name_denied', + 'segment_count_exceeded', + 'segment_too_long', + 'separator_lookalike_denied', + 'trailing_slash_denied', +]); + +function fail(code, path, message) { + throw new RunContractV1Error(code, path, message); +} + +// ---- Captured intrinsics, taken exactly once at initialization. ----------- +const BUFFER_BYTE_LENGTH = NodeBuffer.byteLength; +const FUNCTION_CALL = Function.prototype.call; +const callBound = (method) => FUNCTION_CALL.bind(method); +const STRING_CHAR_CODE_AT = callBound(String.prototype.charCodeAt); +const STRING_CODE_POINT_AT = callBound(String.prototype.codePointAt); +const STRING_NORMALIZE = callBound(String.prototype.normalize); +const STRING_SPLIT = callBound(String.prototype.split); +const STRING_STARTS_WITH = callBound(String.prototype.startsWith); +const STRING_ENDS_WITH = callBound(String.prototype.endsWith); +const STRING_FROM_CODE_POINT = String.fromCodePoint; +const STRING = String; + +const SEGMENT_SEPARATOR = '/'; +const EMPTY_STRING = ''; +const FULL_STOP = '.'; +const SPACE = ' '; +const BACKSLASH_CODE_POINT = 0x5c; +const COLON_CODE_POINT = 0x3a; +const FULL_STOP_CODE_POINT = 0x2e; +const SPACE_CODE_POINT = 0x20; + +// Closed invisible/format/bidi/line-separator/tag ranges. Anything here can +// change how a path renders or resolves while surviving casual review, so it +// is refused outright instead of being interpreted. +const INVISIBLE_RANGES = capturedFreeze([ + [0x00ad, 0x00ad], // soft hyphen + [0x061c, 0x061c], // Arabic letter mark + [0x180e, 0x180e], // Mongolian vowel separator + [0x200b, 0x200f], // zero-width series and LRM/RLM + [0x2028, 0x202e], // line/paragraph separators and bidi overrides + [0x2060, 0x2064], // word joiner and invisible operators + [0x2066, 0x2069], // bidi isolates + [0xfeff, 0xfeff], // BOM / zero-width no-break space + [0xfff9, 0xfffb], // interlinear annotation anchors + [0x1d173, 0x1d17a], // musical formatting controls + [0xe0001, 0xe0001], // language tag + [0xe0020, 0xe007f], // tag characters +]); + +// Solidus look-alikes: code points that display as slashes or backslashes on +// common platforms while remaining ordinary letters to a POSIX kernel, so a +// reviewer sees more separators than the resolver does. +const SEPARATOR_LOOKALIKES = capturedFreeze([ + 0x2044, // fraction slash + 0x2215, // division slash + 0x27cb, // mathematical rising diagonal + 0x27cd, // mathematical falling diagonal + 0x29f8, // big solidus + 0xfe68, // small reverse solidus + 0xff0f, // fullwidth solidus + 0xff3c, // fullwidth reverse solidus +]); + +// Windows reserved device names, compared case-insensitively against the +// stem before the first dot so `CON.txt` is caught exactly like `CON`. +const RESERVED_DEVICE_STEMS = capturedFreeze((() => { + const stems = ['AUX', 'CLOCK$', 'CON', 'NUL', 'PRN']; + for (let digit = 0; digit <= 9; digit += 1) { + const suffix = STRING(digit); + stems.push(`COM${suffix}`, `LPT${suffix}`); + } + return stems.sort(); +})()); + +function asciiUpperFold(text) { + let folded = EMPTY_STRING; + for (let index = 0; index < text.length; index += 1) { + const unit = STRING_CHAR_CODE_AT(text, index); + folded += unit >= 0x61 && unit <= 0x7a + ? STRING_FROM_CODE_POINT(unit - 0x20) + : text[index]; + } + return folded; +} + +function deviceStemOf(segment) { + const dot = segment.indexOf(FULL_STOP); + const stem = dot === -1 ? segment : segment.slice(0, dot); + if (stem.length === 0) return null; + const folded = asciiUpperFold(stem); + for (let index = 0; index < RESERVED_DEVICE_STEMS.length; index += 1) { + if (RESERVED_DEVICE_STEMS[index] === folded) return folded; + } + return null; +} + +function isDotOnlySegment(segment) { + for (let index = 0; index < segment.length; index += 1) { + if (segment[index] !== FULL_STOP) return false; + } + return segment.length > 0; +} + +function containsBackslash(value) { + for (let index = 0; index < value.length; index += 1) { + if (STRING_CHAR_CODE_AT(value, index) === BACKSLASH_CODE_POINT) return true; + } + return false; +} + +function assertWellFormed(value, path) { + // Walk code points, not code units: every astral character is two UTF-16 + // code units and both units live in the surrogate block, so a naive + // unit scan would condemn perfectly well-formed emoji or CJK extensions. + // Only an unpaired surrogate (which codePointAt reports as itself) fails. + let index = 0; + while (index < value.length) { + const codePoint = STRING_CODE_POINT_AT(value, index); + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + fail('invalid_encoding', path, + `${path} contains a lone surrogate at code-unit index ${index}; ` + + 'artifact paths must be well-formed Unicode.'); + } + index += codePoint > 0xffff ? 2 : 1; + } +} + +function assertTotalBytes(value, path) { + const bytes = capturedUtf8ByteLength(value); + if (bytes < 1 || bytes > ARTIFACT_PATH_MAX_BYTES) { + fail('out_of_range', path, + `${path} is ${bytes} UTF-8 bytes; allowed range is 1..${ARTIFACT_PATH_MAX_BYTES}.`); + } + return bytes; +} + +function assertNormalized(value, path) { + if (STRING_NORMALIZE(value, 'NFC') !== value) { + fail('invalid_encoding', path, `${path} is not NFC-normalized; artifact paths must use exactly NFC.`); + } +} + +function assertSlashShape(value, path) { + if (containsBackslash(value)) { + fail('invalid_separator', path, + `${path} must use '${SEGMENT_SEPARATOR}' separators; a backslash is not portable.`); + } + if (STRING_STARTS_WITH(value, SEGMENT_SEPARATOR)) { + fail('absolute_path_denied', path, + `${path} must be relative to the run's artifact root; absolute paths are denied.`); + } + if (STRING_ENDS_WITH(value, SEGMENT_SEPARATOR)) { + fail('trailing_slash_denied', path, + `${path} must name an artifact, not end with '${SEGMENT_SEPARATOR}'.`); + } +} + +function assertSegmentStructure(segments, path) { + if (segments.length > ARTIFACT_PATH_MAX_SEGMENTS) { + fail('segment_count_exceeded', path, + `${path} spans ${segments.length} segments; at most ${ARTIFACT_PATH_MAX_SEGMENTS} are allowed.`); + } + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (segment.length === 0) { + fail('empty_segment_denied', path, `${path} contains an empty segment at position ${index}.`); + } + if (isDotOnlySegment(segment)) { + fail('alias_segment_denied', path, + `${path} segment ${index} is made only of dots; '.' and '..' style aliases are denied.`); + } + const reserved = deviceStemOf(segment); + if (reserved !== null) { + fail('reserved_device_name_denied', path, + `${path} segment ${index} uses reserved device name ${reserved}; device names are denied.`); + } + const last = segment.length - 1; + if (STRING_CHAR_CODE_AT(segment, last) === FULL_STOP_CODE_POINT + || STRING_CHAR_CODE_AT(segment, last) === SPACE_CODE_POINT + || STRING_CHAR_CODE_AT(segment, 0) === SPACE_CODE_POINT) { + fail('edge_character_denied', path, + `${path} segment ${index} has a leading space or a trailing dot/space that Windows tooling strips.`); + } + if (BUFFER_BYTE_LENGTH(segment, 'utf8') > ARTIFACT_PATH_SEGMENT_MAX_BYTES) { + fail('segment_too_long', path, + `${path} segment ${index} exceeds ${ARTIFACT_PATH_SEGMENT_MAX_BYTES} UTF-8 bytes.`); + } + } +} + +function codePointInRanges(codePoint, ranges) { + for (let index = 0; index < ranges.length; index += 1) { + const range = ranges[index]; + if (codePoint >= range[0] && codePoint <= range[1]) return true; + } + return false; +} + +function listContainsCodePoint(list, codePoint) { + for (let index = 0; index < list.length; index += 1) { + if (list[index] === codePoint) return true; + } + return false; +} + +function uNotation(codePoint) { + return `U+${codePoint.toString(16).padStart(4, '0')}`; +} + +function assertCharacterLegality(value, path) { + let offset = 0; + while (offset < value.length) { + const codePoint = STRING_CODE_POINT_AT(value, offset); + const width = codePoint > 0xffff ? 2 : 1; + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) { + fail('control_character_denied', path, + `${path} contains control character ${uNotation(codePoint)} at code-point offset ${offset}.`); + } + if (codePointInRanges(codePoint, INVISIBLE_RANGES)) { + fail('invisible_character_denied', path, + `${path} contains invisible formatting character ${uNotation(codePoint)} at code-point offset ${offset}.`); + } + if (listContainsCodePoint(SEPARATOR_LOOKALIKES, codePoint)) { + fail('separator_lookalike_denied', path, + `${path} contains solidus look-alike ${uNotation(codePoint)} at code-point offset ${offset}.`); + } + if (codePoint === COLON_CODE_POINT) { + fail('colon_denied', path, + `${path} contains ':' at code-point offset ${offset}; colons are denied to foreclose drive, ` + + 'alternate-data-stream, and scheme spellings.'); + } + offset += width; + } +} + +// Validate one strict portable relative artifact path and return a detached, +// frozen descriptor over the accepted canonical form. The accepted form is +// exactly the submitted string: no trimming, case folding, separator +// rewriting, or Unicode re-encoding ever occurs, so two distinct accepted +// inputs always stay distinct. +export function validateArtifactRelativePathV1(value, path = 'relative_path') { + if (typeof value !== 'string') { + fail('invalid_type', path, `${path} must be a string.`); + } + if (value.length === 0) { + fail('empty_path', path, `${path} must not be empty.`); + } + assertWellFormed(value, path); + const bytes = assertTotalBytes(value, path); + assertNormalized(value, path); + assertSlashShape(value, path); + const segments = STRING_SPLIT(value, SEGMENT_SEPARATOR); + assertSegmentStructure(segments, path); + assertCharacterLegality(value, path); + return capturedFreeze({ + path: value, + segments: capturedFreeze([...segments]), + byte_length: bytes, + segment_count: segments.length, + }); +} + +// Boolean predicate twin of the validator: never throws, never partially +// accepts, and agrees with validateArtifactRelativePathV1 on every input. +export function isArtifactRelativePathV1(value) { + try { + validateArtifactRelativePathV1(value, 'relative_path'); + return true; + } catch { + return false; + } +} diff --git a/plugins/codex-co-engineer/test/r1-artifact-path.test.mjs b/plugins/codex-co-engineer/test/r1-artifact-path.test.mjs new file mode 100644 index 0000000..8fbcaeb --- /dev/null +++ b/plugins/codex-co-engineer/test/r1-artifact-path.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ARTIFACT_RELATIVE_PATH_ERROR_CODES, + isArtifactRelativePathV1, + validateArtifactRelativePathV1, +} from '../mcp/v3/artifact-path.mjs'; +import { RunContractV1Error } from '../mcp/v3/run-manifest.mjs'; + +function errorOf(action, expectedPath) { + try { + action(); + } catch (error) { + assert.ok(error instanceof RunContractV1Error, `expected RunContractV1Error, got ${error}`); + if (expectedPath !== undefined) assert.equal(error.path, expectedPath); + return error; + } + assert.fail('expected a typed RunContractV1Error'); +} + +// Callers embed accepted paths under a schema field, so the pinned denial +// paths below use the same dotted field spelling an embedding contract uses. +function pathError(value, expectedCode, expectedPath = 'artifact_ref.relative_path') { + const error = errorOf( + () => validateArtifactRelativePathV1(value, expectedPath), + expectedPath, + ); + assert.equal(error.code, expectedCode, `path ${JSON.stringify(value)}`); + return error; +} + +// ---- Relative path policy: full rejection matrix. ------------------------- + +test('absolute, drive, UNC, device, and traversal forms are all foreclosed', () => { + pathError('', 'empty_path'); + pathError('/etc/passwd', 'absolute_path_denied'); + pathError('//double/leading', 'absolute_path_denied'); + pathError('C:/Users/diff.patch', 'colon_denied'); + pathError('C:diff.patch', 'colon_denied'); + pathError('\\\\server\\share\\x', 'invalid_separator'); + pathError('runs\\\\diff.patch', 'invalid_separator'); + pathError('file:text/plain', 'colon_denied'); + pathError('report.txt:hidden', 'colon_denied'); + pathError('runs/x/', 'trailing_slash_denied'); + pathError('runs//x', 'empty_segment_denied'); + pathError('./diff.patch', 'alias_segment_denied'); + pathError('../diff.patch', 'alias_segment_denied'); + pathError('runs/../escape', 'alias_segment_denied'); + pathError('...', 'alias_segment_denied'); + pathError('runs/.../x', 'alias_segment_denied'); + for (const reserved of ['CON', 'con.txt', 'Com1', 'lpt0', 'NUL', 'nul', 'PRN', 'aux', 'clock$', 'CLOCK$/token']) { + pathError(`runs/${reserved}`, 'reserved_device_name_denied', 'artifact_ref.relative_path'); + } + for (const ordinary of ['concat', 'auxiliary', 'nullify', 'control', 'companion10']) { + assert.equal(isArtifactRelativePathV1(`runs/${ordinary}.txt`), true, ordinary); + } + pathError('runs/name.', 'edge_character_denied'); + pathError('runs/name ', 'edge_character_denied'); + pathError('runs/ name', 'edge_character_denied'); + pathError(`runs/${'a'.repeat(129)}`, 'segment_too_long'); + pathError(Array.from({ length: 17 }, (unused, index) => `s${index}`).join('/'), 'segment_count_exceeded'); + assert.doesNotThrow(() => validateArtifactRelativePathV1( + Array.from({ length: 16 }, (unused, index) => `s${index}`).join('/'), + )); +}); + +test('separator tricks, controls, invisible characters, and encodings are rejected', () => { + pathError('runs/a\u2044b', 'separator_lookalike_denied'); + pathError('runs/a\u2215b', 'separator_lookalike_denied'); + pathError('runs/a\uFF0Fb', 'separator_lookalike_denied'); + pathError('runs/a\uFF3Cb', 'separator_lookalike_denied'); + pathError('runs/a\u0000b', 'control_character_denied'); + pathError('runs/a\tb', 'control_character_denied'); + pathError('runs/a\nb', 'control_character_denied'); + pathError('runs/a\u007Fb', 'control_character_denied'); + pathError('runs/a\u0085b', 'control_character_denied'); + pathError('runs/a\u00ADb', 'invisible_character_denied'); + pathError('runs/a\u200Bb', 'invisible_character_denied'); + pathError('runs/a\u200Eb', 'invisible_character_denied'); + pathError('runs/\u202Edir/x', 'invisible_character_denied'); + pathError('runs/dir\u202E/x', 'invisible_character_denied'); + pathError('runs/\u2066dir/x', 'invisible_character_denied'); + pathError('\uFEFFruns/x', 'invisible_character_denied'); + pathError('runs/tag\u{E0041}x', 'invisible_character_denied'); + pathError('e\u0301clair.patch', 'invalid_encoding'); + pathError('cafe\u0301.patch', 'invalid_encoding'); + assert.doesNotThrow(() => validateArtifactRelativePathV1('café.patch')); + // Well-formed astral characters stay legal; only unpaired surrogates fail. + assert.doesNotThrow(() => validateArtifactRelativePathV1('runs/🙂/diff.patch')); + assert.equal(validateArtifactRelativePathV1('runs/🙂/diff.patch').byte_length, + Buffer.byteLength('runs/🙂/diff.patch', 'utf8')); + assert.throws(() => validateArtifactRelativePathV1('\uD800'), RunContractV1Error); + assert.throws(() => validateArtifactRelativePathV1('a\uDFFBc'), RunContractV1Error); + assert.equal(isArtifactRelativePathV1('\uDFFF'), false); + assert.equal(isArtifactRelativePathV1('\uD83D\uDE00runs'), true); +}); + +test('validation order is fixed so identical inputs always raise identical first errors', () => { + // Encoding beats NFC, NFC beats traversal, absolute beats control, + // backslash beats colon, alias beats invisible. + pathError('e\u0301/../x', 'invalid_encoding'); + pathError('../\u202Ex', 'alias_segment_denied'); + pathError('/x\u0000', 'absolute_path_denied'); + pathError('C:\\x', 'invalid_separator'); + pathError('\u202E/../x', 'alias_segment_denied'); + // The character scan is strictly positional: the first offending code + // point decides, whichever class it belongs to. + pathError('C:\u0000', 'colon_denied'); + pathError('\u0000a:b', 'control_character_denied'); + const twice = () => pathError('e\u0301/../..', 'invalid_encoding').message; + assert.equal(twice(), twice()); +}); + +test('non-string inputs and predicate parity behave without ever throwing', () => { + for (const bad of [undefined, null, 42, true, {}, ['a'], Symbol('path')]) { + assert.equal( + errorOf(() => validateArtifactRelativePathV1(bad, 'p')).code, + 'invalid_type', + ); + assert.equal(isArtifactRelativePathV1(bad), false); + } + const samples = [ + '', '/', '//', '.', '..', 'a', 'a/b', '/abs', 'a/', 'a//b', 'C:/x', + 'ok/deep/path.txt', 'bad\u0000', 'e\u0301', 'café', 'a/con', 'CON', + `${'a'.repeat(1024)}`, `${'a'.repeat(1025)}`, + ]; + for (const sample of samples) { + let threw = false; + try { + validateArtifactRelativePathV1(sample, 'p'); + } catch { + threw = true; + } + assert.equal(isArtifactRelativePathV1(sample), !threw, JSON.stringify(sample)); + } +}); + +test('accepted path descriptors are detached and frozen', () => { + const descriptor = validateArtifactRelativePathV1('runs/run-a/lane-b/diff.patch', 'p'); + assert.equal(Object.isFrozen(descriptor), true); + assert.equal(Object.isFrozen(descriptor.segments), true); + assert.deepEqual([...descriptor.segments], ['runs', 'run-a', 'lane-b', 'diff.patch']); + assert.equal(descriptor.byte_length, Buffer.byteLength('runs/run-a/lane-b/diff.patch', 'utf8')); +}); + +test('every raised path code stays inside the closed vocabulary', () => { + const codes = new Set(ARTIFACT_RELATIVE_PATH_ERROR_CODES); + const attempts = [ + '', '/x', 'a\\b', 'a/../b', 'a//b', 'a/', '...', 'a/CON', 'a/b.', + 'a\u0000b', 'a\u200Bb', 'a\u2044b', 'a:b', `${'a'.repeat(2000)}`, + `${Array.from({ length: 20 }, (_v, i) => `s${i}`).join('/')}`, + '\uD800', 'e\u0301', 42, null, + ]; + for (const attempt of attempts) { + try { + validateArtifactRelativePathV1(attempt, 'p'); + } catch (error) { + assert.ok(error instanceof RunContractV1Error); + assert.ok(codes.has(error.code), `unexpected code ${error.code}`); + } + }}); + From 773b44f97c9b265e9607e60ac478105dcfa86b1d Mon Sep 17 00:00:00 2001 From: cole Date: Sat, 22 Aug 2026 19:45:24 +0000 Subject: [PATCH 2/3] feat(v3): bind the closed ArtifactRefV1 contract and digest authority Bind exactly ten keys: exact run/child identifiers reusing the accepted manifest grammars, artifact kind bound to the detached P17 capability vocabulary, raw-vs-sanitized class with class-dependent bounded-evidence caps (sanitized 256 KiB, raw 32 MiB), declared byte length, 64-character lowercase SHA-256, and closed media-type/content-encoding enums. The relative path carries no class marker; consumers trust the ref, never the name. Hostile inputs fail closed before any effect: live/revoked Proxies with zero traps, accessor properties whose getters never run, symbol keys, exotic prototypes, own undefined, sparse/extended arrays, aliases and cycles, depth and size. Parsed results are deep-frozen detached snapshots built property-by-property, so later caller mutation cannot drift a digest or a comparison. Digests follow the accepted P03 framing conventions without touching its closed registry: SHA-256 over validator-owned canonical JSON behind an explicit domain string, big-endian version, ratified private label, and length-prefixed parts, so key order cannot change a digest while any value change must. Ordering is deterministic via one total tuple comparator plus a bounded (64) duplicate-free batch orderer. Pure schema and path policy only: no constructor from bytes, no reader, no sanitizer, no existence check, and no claim that P08 storage exists. Focused hostile coverage pins proxies, accessors, coercion hooks, closure-before-vocabulary ordering, batch hostility, and mid-call mutation inertness. --- .../codex-co-engineer/mcp/v3/artifact-ref.mjs | 377 ++++++++++++++++++ .../test/fixtures/r1-artifact-fixtures.mjs | 70 ++++ .../test/r1-artifact-ref-adversarial.test.mjs | 228 +++++++++++ .../test/r1-artifact-ref.test.mjs | 342 ++++++++++++++++ 4 files changed, 1017 insertions(+) create mode 100644 plugins/codex-co-engineer/mcp/v3/artifact-ref.mjs create mode 100644 plugins/codex-co-engineer/test/fixtures/r1-artifact-fixtures.mjs create mode 100644 plugins/codex-co-engineer/test/r1-artifact-ref-adversarial.test.mjs create mode 100644 plugins/codex-co-engineer/test/r1-artifact-ref.test.mjs diff --git a/plugins/codex-co-engineer/mcp/v3/artifact-ref.mjs b/plugins/codex-co-engineer/mcp/v3/artifact-ref.mjs new file mode 100644 index 0000000..f5a09e8 --- /dev/null +++ b/plugins/codex-co-engineer/mcp/v3/artifact-ref.mjs @@ -0,0 +1,377 @@ +// ArtifactRefV1 — closed bounded artifact-reference contract plus its +// canonical serialization and digest authority (ADR 0001 identifiers +// `bounded_evidence`, `exact_identities`, +// `gate_a_valid_raw_and_sanitized_artifacts`). +// +// Additive v3 module for W3-P07. An ArtifactRefV1 is a bounded declaration +// that one run child produced one artifact of one kind in one class at one +// strict portable relative path with one declared byte length, SHA-256 +// content digest, and media/content metadata. It binds exactly ten keys: +// +// schema exact "codex-co-engineer.artifact-ref.v1" +// run_id exact RunManifestV1 run identifier +// assignment_id exact AssignmentManifestV1 child identifier +// artifact_kind closed enum bound to the P17 capability vocabulary +// (CAPABILITY_ARTIFACT_KINDS), detached here +// artifact_class "raw" (owner-only local evidence) or "sanitized" +// (model-facing bounded projection) +// relative_path strict portable relative path per artifact-path.mjs; +// the path carries NO class marker — consumers trust the +// ref, never the name +// byte_length declared artifact size in bytes, 1..cap(class); +// sanitized projections face the model and carry the +// tighter cap, raw evidence stays host-local under a +// larger but finite cap (ADR `bounded_evidence`) +// sha256 exact 64-character lowercase hex of the artifact bytes +// media_type closed enum +// content_encoding closed enum ("identity" or "base64") +// +// This module is PURE SCHEMA AND PATH POLICY ONLY. It performs no +// filesystem, network, or process I/O and it makes no claim that P08 +// artifact storage exists: byte_length and sha256 are producer declarations +// that only a later storage authority may bind to real bytes. There is no +// constructor from bytes, no reader, no sanitizer, and no existence check. +// +// Identity conventions follow the accepted P03 authority shape without +// touching it: digests are computed only over validator-owned canonical +// forms, never caller views, behind explicit domain separation and +// versioning — a domain string, a 32-bit big-endian version, a ratified +// private label, and every part behind a 32-bit big-endian length prefix, +// all framed with SHA-256. Canonical serialization reuses the shared +// canonical JSON writer (sorted keys, minimal escaping, well-formed Unicode, +// safe integers), so key order and whitespace cannot change a digest while +// any meaningful value change must. +// +// Hostile inputs fail closed before any effect: live and revoked Proxies, +// accessor properties (getters are never invoked), symbol keys, exotic +// prototypes, own undefined values, sparse or extended arrays, aliased or +// cyclic graphs, and oversized/deep payloads are rejected with stable typed +// errors before any value is read into the contract. Parsed results are +// deep-frozen detached snapshots built property-by-property, so later +// mutation of the caller object cannot drift a digest or a comparison. +// +// Ordering is deterministic: compareArtifactRefsV1 fixes one total tuple +// order and orderArtifactRefsV1 returns a bounded duplicate-free frozen +// list, so equal sets of references always serialize and hash identically +// regardless of submission order. + +import { Buffer } from 'node:buffer'; +import { createHash, timingSafeEqual } from 'node:crypto'; + +import { + capturedDescriptor, + capturedFreeze, + capturedIncludes, +} from './grammar.mjs'; +import { canonicalJsonStringify } from './identity.mjs'; +import { validateArtifactRelativePathV1 } from './artifact-path.mjs'; +import { CAPABILITY_ARTIFACT_KINDS } from './capability-bridge.mjs'; +import { + RunContractV1Error, + assertAllowedKeys, + assertDenseJsonArray, + assertRunId, + isAssignmentId, +} from './run-manifest.mjs'; +import { + assertDirectJsonClosure, + assertNotProxy, + assertPlainObject, + fail, + hasOwn, + optOwn, +} from './selection-json.mjs'; + +export const ARTIFACT_REF_SCHEMA_ID = 'codex-co-engineer.artifact-ref.v1'; + +export const ARTIFACT_DIGEST_DOMAIN = 'codex-co-engineer.artifact.v1'; +export const ARTIFACT_DIGEST_VERSION = 1; +export const ARTIFACT_REF_DIGEST_LABEL = 'artifact-ref.v1'; +export const DIGEST_ALGORITHM = 'sha256'; +export const ARTIFACT_DIGEST_HEX_LENGTH = 64; + +// Closed vocabularies. artifact_kind stays bound to the P17 capability +// record vocabulary so a lane cannot reference an artifact kind its provider +// capability could never declare; the copy is detached, so later mutation of +// either exported array cannot change what this module accepts. +export const ARTIFACT_KINDS = capturedFreeze([...CAPABILITY_ARTIFACT_KINDS]); +export const ARTIFACT_CLASSES = capturedFreeze(['raw', 'sanitized']); +export const MEDIA_TYPES = capturedFreeze([ + 'application/json', + 'application/octet-stream', + 'application/x-ndjson', + 'text/markdown', + 'text/plain', +]); +export const CONTENT_ENCODINGS = capturedFreeze(['base64', 'identity']); + +export const ARTIFACT_REF_ALLOWED_KEYS = capturedFreeze([ + 'schema', + 'run_id', + 'assignment_id', + 'artifact_kind', + 'artifact_class', + 'relative_path', + 'byte_length', + 'sha256', + 'media_type', + 'content_encoding', +]); + +export const MIN_ARTIFACT_BYTE_LENGTH = 1; +export const MAX_RAW_ARTIFACT_BYTE_LENGTH = 33_554_432; +export const MAX_SANITIZED_ARTIFACT_BYTE_LENGTH = 262_144; +export const MAX_ARTIFACT_REFS = 64; + +const PRIVATE_SHA256_PATTERN = /^[0-9a-f]{64}$/u; +export const ARTIFACT_SHA256_PATTERN = new RegExp( + PRIVATE_SHA256_PATTERN.source, PRIVATE_SHA256_PATTERN.flags, +); + +// Stable artifact-specific denial codes. Shared vocabulary codes +// (proxy_denied, accessor_property_denied, symbol_key_denied, +// exotic_prototype_denied, aliased_reference_denied, own_undefined_denied, +// unknown_key, forbidden-class denials, and every artifact-path code) pass +// through unchanged from their owning modules. +export const ARTIFACT_REF_ERROR_CODES = capturedFreeze([ + 'duplicate_artifact_ref', + 'invalid_format', + 'invalid_type', + 'missing_key', + 'out_of_range', + 'refs_exceeded', + 'unknown_artifact_class', + 'unknown_artifact_kind', + 'unknown_content_encoding', + 'unknown_media_type', +]); + +// ---- Captured intrinsics, taken exactly once at initialization. ----------- +const CRYPTO_CREATE_HASH = createHash; +const TIMING_SAFE_EQUAL = timingSafeEqual; +const HASH_PROTOTYPE = Object.getPrototypeOf(CRYPTO_CREATE_HASH(DIGEST_ALGORITHM)); +const HASH_UPDATE = HASH_PROTOTYPE.update; +const HASH_DIGEST = HASH_PROTOTYPE.digest; +const BUFFER_ALLOC = Buffer.alloc.bind(Buffer); +const BUFFER_FROM = Buffer.from.bind(Buffer); +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; +const OBJECT_PROTOTYPE = Object.prototype; +const REFLECT_APPLY = Reflect.apply; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const REGEXP_TEST = RegExp.prototype.test; +const STRING = String; + +function sha256HexPatternMatch(value) { + return REFLECT_APPLY(REGEXP_TEST, PRIVATE_SHA256_PATTERN, [value]) === true; +} + +function maxByteLengthForClass(artifactClass) { + return artifactClass === 'raw' + ? MAX_RAW_ARTIFACT_BYTE_LENGTH + : MAX_SANITIZED_ARTIFACT_BYTE_LENGTH; +} + +function enumError(code, path, label, allowed) { + let joined = ''; + for (let index = 0; index < allowed.length; index += 1) { + joined += index === 0 ? `"${allowed[index]}"` : `, "${allowed[index]}"`; + } + fail(code, path, `${path} must be exactly one of ${joined}; received an outside ${label}.`); +} + +// Validate one ArtifactRefV1 direct-JS value and return a deep-frozen, +// detached snapshot built property-by-property from validated scalars. The +// fixed pipeline order means identical inputs always raise the identical +// first typed error. +export function parseArtifactRefV1(input, path = 'artifact_ref') { + assertPlainObject(input, 'invalid_type', path, `${path}`); + // Closure gate first: proxies, accessors, symbols, exotic prototypes, + // sparse arrays, aliases/cycles, undefined values, depth, and hostile + // shapes are rejected here before any field is interpreted. + assertDirectJsonClosure(input, path); + assertAllowedKeys(input, ARTIFACT_REF_ALLOWED_KEYS, path); + for (let index = 0; index < ARTIFACT_REF_ALLOWED_KEYS.length; index += 1) { + const key = ARTIFACT_REF_ALLOWED_KEYS[index]; + if (!hasOwn(input, key)) { + fail('missing_key', `${path}.${key}`, + `${path}.${key} is required (${ARTIFACT_REF_SCHEMA_ID}); artifact references have no hidden defaults.`); + } + } + + const schema = optOwn(input, 'schema'); + if (schema !== ARTIFACT_REF_SCHEMA_ID) { + fail('invalid_format', `${path}.schema`, + `${path}.schema must be exactly "${ARTIFACT_REF_SCHEMA_ID}".`); + } + const runId = optOwn(input, 'run_id'); + assertRunId(runId, `${path}.run_id`); + const assignmentId = optOwn(input, 'assignment_id'); + if (!isAssignmentId(assignmentId)) { + fail('invalid_format', `${path}.assignment_id`, + `${path}.assignment_id violates the assignment-id grammar; child artifacts bind one exact child.`); + } + const artifactKind = optOwn(input, 'artifact_kind'); + if (!capturedIncludes(ARTIFACT_KINDS, artifactKind)) { + enumError('unknown_artifact_kind', `${path}.artifact_kind`, 'artifact kind', ARTIFACT_KINDS); + } + const artifactClass = optOwn(input, 'artifact_class'); + if (!capturedIncludes(ARTIFACT_CLASSES, artifactClass)) { + enumError('unknown_artifact_class', `${path}.artifact_class`, 'artifact class', ARTIFACT_CLASSES); + } + const relativePath = optOwn(input, 'relative_path'); + validateArtifactRelativePathV1(relativePath, `${path}.relative_path`); + + const byteLength = optOwn(input, 'byte_length'); + if (typeof byteLength !== 'number' || !NUMBER_IS_SAFE_INTEGER(byteLength)) { + fail('invalid_type', `${path}.byte_length`, `${path}.byte_length must be an integer number of bytes.`); + } + const cap = maxByteLengthForClass(artifactClass); + if (byteLength < MIN_ARTIFACT_BYTE_LENGTH || byteLength > cap) { + fail('out_of_range', `${path}.byte_length`, + `${path}.byte_length is ${byteLength} bytes; a ${artifactClass} artifact declares ` + + `${MIN_ARTIFACT_BYTE_LENGTH}..${cap} bytes (ADR bounded_evidence).`); + } + const sha256 = optOwn(input, 'sha256'); + if (typeof sha256 !== 'string' || !sha256HexPatternMatch(sha256)) { + fail('invalid_format', `${path}.sha256`, + `${path}.sha256 must be an exact ${ARTIFACT_DIGEST_HEX_LENGTH}-character lowercase hex SHA-256.`); + } + const mediaType = optOwn(input, 'media_type'); + if (!capturedIncludes(MEDIA_TYPES, mediaType)) { + enumError('unknown_media_type', `${path}.media_type`, 'media type', MEDIA_TYPES); + } + const contentEncoding = optOwn(input, 'content_encoding'); + if (!capturedIncludes(CONTENT_ENCODINGS, contentEncoding)) { + enumError('unknown_content_encoding', `${path}.content_encoding`, 'content encoding', CONTENT_ENCODINGS); + } + + // Detached snapshot: fresh ordinary object, every field installed as a + // frozen enumerable data property copied from the validated value, then + // frozen as a whole. No alias to the caller object survives. + const snapshot = {}; + for (let index = 0; index < ARTIFACT_REF_ALLOWED_KEYS.length; index += 1) { + const key = ARTIFACT_REF_ALLOWED_KEYS[index]; + const value = key === 'byte_length' ? byteLength : capturedDescriptor(input, key).value; + OBJECT_DEFINE_PROPERTY(snapshot, key, { + value, enumerable: true, writable: false, configurable: false, + }); + } + return capturedFreeze(snapshot); +} + +export function validateArtifactRefV1(input, path = 'artifact_ref') { + return parseArtifactRefV1(input, path); +} + +// Canonical serialization of the validator-owned form. The caller view is +// never serialized: parsing happens first, so accessor-shaped lookalikes and +// late-mutating views cannot launder bytes into the canonical text. +export function canonicalArtifactRefJsonV1(input, path = 'artifact_ref') { + return canonicalJsonStringify(parseArtifactRefV1(input, path)); +} + +function framedUpdate(hash, bytes) { + const prefix = BUFFER_ALLOC(4); + prefix.writeUInt32BE(bytes.length, 0); + HASH_UPDATE.call(hash, prefix); + HASH_UPDATE.call(hash, bytes); +} + +// Domain-separated digest over the canonical validated form, following the +// accepted P03 framing conventions (domain, big-endian version, label, and +// each part behind a big-endian length prefix) without extending the P03 +// closed label registry: the artifact label is ratified here and nowhere +// else, so cross-surface digest collision is structurally impossible. +export function artifactRefDigestV1(input, path = 'artifact_ref') { + const snapshot = parseArtifactRefV1(input, path); + const canonical = canonicalJsonStringify(snapshot); + const canonicalBytes = BUFFER_FROM(canonical, 'utf8'); + const hash = CRYPTO_CREATE_HASH(DIGEST_ALGORITHM); + framedUpdate(hash, BUFFER_FROM(ARTIFACT_DIGEST_DOMAIN, 'utf8')); + const version = BUFFER_ALLOC(4); + version.writeUInt32BE(ARTIFACT_DIGEST_VERSION, 0); + HASH_UPDATE.call(hash, version); + framedUpdate(hash, BUFFER_FROM(ARTIFACT_REF_DIGEST_LABEL, 'utf8')); + framedUpdate(hash, canonicalBytes); + return capturedFreeze({ + algorithm: DIGEST_ALGORITHM, + domain: ARTIFACT_DIGEST_DOMAIN, + version: ARTIFACT_DIGEST_VERSION, + label: ARTIFACT_REF_DIGEST_LABEL, + input_bytes: canonicalBytes.length, + digest: HASH_DIGEST.call(hash, 'hex'), + }); +} + +// Timing-safe verification. Malformed expected values return false instead +// of throwing; invalid references still fail closed with their typed errors. +export function verifyArtifactRefDigestV1(input, expectedDigestHex, path = 'artifact_ref') { + if (typeof expectedDigestHex !== 'string' + || expectedDigestHex.length !== ARTIFACT_DIGEST_HEX_LENGTH + || !sha256HexPatternMatch(expectedDigestHex)) { + return false; + } + const actual = artifactRefDigestV1(input, path).digest; + return TIMING_SAFE_EQUAL(BUFFER_FROM(actual, 'hex'), BUFFER_FROM(expectedDigestHex, 'hex')) === true; +} + +function compareStrings(left, right) { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +// Deterministic total order over parsed references. The tuple order is: +// run_id, assignment_id, artifact_class, artifact_kind, relative_path, +// media_type, content_encoding, sha256, byte_length. Two references compare +// equal only when every field matches, i.e. when they are the same reference. +export function compareArtifactRefsV1(leftInput, rightInput) { + const left = parseArtifactRefV1(leftInput, 'left'); + const right = parseArtifactRefV1(rightInput, 'right'); + let verdict = compareStrings(left.run_id, right.run_id); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.assignment_id, right.assignment_id); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.artifact_class, right.artifact_class); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.artifact_kind, right.artifact_kind); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.relative_path, right.relative_path); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.media_type, right.media_type); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.content_encoding, right.content_encoding); + if (verdict !== 0) return verdict; + verdict = compareStrings(left.sha256, right.sha256); + if (verdict !== 0) return verdict; + return left.byte_length === right.byte_length ? 0 : left.byte_length < right.byte_length ? -1 : 1; +} + +// Deterministic, duplicate-free ordering of a bounded batch of references. +// Every element is fully revalidated and detached BEFORE sorting, so caller +// mutation during or after the call can neither disturb the order nor leak +// into the result. Duplicate references (identical validated content, which +// includes two aliases of one object) reject rather than silently collapse. +export function orderArtifactRefsV1(inputs, path = 'artifact_refs') { + assertNotProxy(inputs, path); + assertDenseJsonArray(inputs, path); + if (inputs.length > MAX_ARTIFACT_REFS) { + fail('refs_exceeded', path, + `${path} carries ${inputs.length} references; at most ${MAX_ARTIFACT_REFS} are allowed.`); + } + const snapshots = []; + const seenCanonical = new Set(); + for (let index = 0; index < inputs.length; index += 1) { + const entryPath = `${path}[${index}]`; + assertNotProxy(inputs[index], entryPath); + const snapshot = parseArtifactRefV1(inputs[index], entryPath); + const key = canonicalJsonStringify(snapshot); + if (seenCanonical.has(key)) { + fail('duplicate_artifact_ref', entryPath, + `${entryPath} repeats an identical artifact reference; duplicates are denied instead of collapsed.`); + } + seenCanonical.add(key); + snapshots.push(snapshot); + } + snapshots.sort(compareArtifactRefsV1); + return capturedFreeze(snapshots); +} diff --git a/plugins/codex-co-engineer/test/fixtures/r1-artifact-fixtures.mjs b/plugins/codex-co-engineer/test/fixtures/r1-artifact-fixtures.mjs new file mode 100644 index 0000000..5e8ba5b --- /dev/null +++ b/plugins/codex-co-engineer/test/fixtures/r1-artifact-fixtures.mjs @@ -0,0 +1,70 @@ +// Shared fixtures for the W3-P07 artifact-reference and path-policy tests. +// Pure data and tiny local helpers only; no I/O and no product imports +// beyond the two modules under test. + +import { + ARTIFACT_REF_SCHEMA_ID, +} from '../../mcp/v3/artifact-ref.mjs'; + +export const RUN_ID = 'run-artifact-01'; +export const CHILD_A = 'lane-alpha'; +export const CHILD_B = 'lane-beta'; + +const SHA_A = 'aa'.repeat(32); +const SHA_B = 'bb'.repeat(32); + +export function validRef(overrides = {}) { + return { + schema: ARTIFACT_REF_SCHEMA_ID, + run_id: RUN_ID, + assignment_id: CHILD_A, + artifact_kind: 'git_diff', + artifact_class: 'sanitized', + relative_path: `runs/${RUN_ID}/${CHILD_A}/diff.patch`, + byte_length: 2048, + sha256: SHA_A, + media_type: 'text/plain', + content_encoding: 'identity', + ...overrides, + }; +} + +export function secondRef(overrides = {}) { + return validRef({ + assignment_id: CHILD_B, + relative_path: `runs/${RUN_ID}/${CHILD_B}/diff.patch`, + sha256: SHA_B, + ...overrides, + }); +} + +export function countingProxy(target) { + const counts = { get: 0, ownKeys: 0, getOwnPropertyDescriptor: 0, has: 0, apply: 0 }; + const proxy = new Proxy(target, { + get(inner, property, receiver) { + counts.get += 1; + return Reflect.get(inner, property, receiver); + }, + ownKeys(inner) { + counts.ownKeys += 1; + return Reflect.ownKeys(inner); + }, + getOwnPropertyDescriptor(inner, property, receiver) { + counts.getOwnPropertyDescriptor += 1; + return Reflect.getOwnPropertyDescriptor(inner, property, receiver); + }, + has(inner, property) { + counts.has += 1; + return Reflect.has(inner, property); + }, + apply() { + counts.apply += 1; + throw new Error('proxy apply must never run'); + }, + }); + return { proxy, counts }; +} + +export function trapTotal(counts) { + return counts.get + counts.ownKeys + counts.getOwnPropertyDescriptor + counts.has + counts.apply; +} diff --git a/plugins/codex-co-engineer/test/r1-artifact-ref-adversarial.test.mjs b/plugins/codex-co-engineer/test/r1-artifact-ref-adversarial.test.mjs new file mode 100644 index 0000000..3c150bd --- /dev/null +++ b/plugins/codex-co-engineer/test/r1-artifact-ref-adversarial.test.mjs @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { types as utilTypes } from 'node:util'; +import test from 'node:test'; + +import { + ARTIFACT_REF_ERROR_CODES, + MAX_ARTIFACT_REFS, + artifactRefDigestV1, + canonicalArtifactRefJsonV1, + compareArtifactRefsV1, + orderArtifactRefsV1, + parseArtifactRefV1, + verifyArtifactRefDigestV1, +} from '../mcp/v3/artifact-ref.mjs'; +import { RunContractV1Error } from '../mcp/v3/run-manifest.mjs'; +import { countingProxy, secondRef, trapTotal, validRef } from './fixtures/r1-artifact-fixtures.mjs'; + +function errorOf(action, expectedPath) { + try { + action(); + } catch (error) { + assert.ok(error instanceof RunContractV1Error, `expected RunContractV1Error, got ${error}`); + if (expectedPath !== undefined) assert.equal(error.path, expectedPath); + return error; + } + assert.fail('expected a typed RunContractV1Error'); +} + +function pathError(value, expectedCode, expectedPath = 'artifact_ref.relative_path') { + const error = errorOf( + () => parseArtifactRefV1(validRef({ relative_path: value })), + expectedPath, + ); + assert.equal(error.code, expectedCode, `path ${JSON.stringify(value)}`); + return error; +} + +test('live proxies are denied with zero traps on every artifact surface', () => { + const { proxy, counts } = countingProxy(validRef()); + assert.equal(errorOf(() => parseArtifactRefV1(proxy)).code, 'proxy_denied'); + assert.equal(trapTotal(counts), 0); + + const digestCounts = countingProxy(validRef()); + assert.equal(errorOf(() => artifactRefDigestV1(digestCounts.proxy)).code, 'proxy_denied'); + assert.equal(trapTotal(digestCounts.counts), 0); + + const listCounts = countingProxy([validRef()]); + assert.equal(errorOf(() => orderArtifactRefsV1(listCounts.proxy)).code, 'proxy_denied'); + assert.equal(trapTotal(listCounts.counts), 0); + + const elementCounts = countingProxy(validRef()); + assert.equal(errorOf(() => orderArtifactRefsV1([elementCounts.proxy])).code, 'proxy_denied'); + assert.equal(trapTotal(elementCounts.counts), 0); + + const leftCounts = countingProxy(validRef()); + assert.equal(errorOf(() => compareArtifactRefsV1(leftCounts.proxy, validRef())).code, 'proxy_denied'); + assert.equal(trapTotal(leftCounts.counts), 0); +}); + +test('revoked proxies fail closed before Array.isArray or Reflect can throw', () => { + const { proxy, revoke } = Proxy.revocable(validRef(), { + get() { throw new Error('revoked get'); }, + ownKeys() { throw new Error('revoked ownKeys'); }, + getOwnPropertyDescriptor() { throw new Error('revoked descriptor'); }, + }); + revoke(); + assert.equal(utilTypes.isProxy(proxy), true); + const error = errorOf(() => parseArtifactRefV1(proxy)); + assert.equal(error.code, 'proxy_denied'); + assert.throws(() => Array.isArray(proxy), TypeError); +}); + +test('accessor properties are rejected and their getters never run', () => { + let reads = 0; + const getterRef = validRef(); + Object.defineProperty(getterRef, 'relative_path', { + enumerable: true, + get() { + reads += 1; + return 'runs/run-artifact-01/lane-alpha/diff.patch'; + }, + }); + const error = errorOf(() => parseArtifactRefV1(getterRef), 'artifact_ref.relative_path'); + assert.equal(error.code, 'accessor_property_denied'); + assert.equal(reads, 0); + + let throwingReads = 0; + const throwingRef = validRef(); + Object.defineProperty(throwingRef, 'sha256', { + enumerable: true, + get() { + throwingReads += 1; + throw new Error('getter bomb'); + }, + }); + assert.equal( + errorOf(() => parseArtifactRefV1(throwingRef)).code, + 'accessor_property_denied', + ); + assert.equal(throwingReads, 0); +}); + +test('non-enumerable fields, symbol keys, and exotic prototypes are denied', () => { + const hidden = validRef(); + Object.defineProperty(hidden, 'byte_length', { enumerable: false, value: 2048 }); + assert.equal( + errorOf(() => parseArtifactRefV1(hidden)).code, + 'non_enumerable_property_denied', + ); + + const symbolled = validRef(); + symbolled[Symbol('injected')] = 'payload'; + assert.equal(errorOf(() => parseArtifactRefV1(symbolled)).code, 'symbol_key_denied'); + + class SpoofedRef {} + const instance = new SpoofedRef(); + Object.assign(instance, validRef()); + assert.equal(errorOf(() => parseArtifactRefV1(instance)).code, 'invalid_type'); + assert.equal(errorOf(() => parseArtifactRefV1(new Map())).code, 'invalid_type'); + assert.equal(errorOf(() => parseArtifactRefV1(new Date())).code, 'invalid_type'); + + // Null-prototype data objects remain acceptable direct JSON. + const nullProto = Object.create(null); + Object.assign(nullProto, validRef()); + assert.doesNotThrow(() => parseArtifactRefV1(nullProto)); +}); + +test('own undefined values, boxed values, and coercion hooks never contribute', () => { + assert.equal( + errorOf(() => parseArtifactRefV1(validRef({ media_type: undefined }))).code, + 'own_undefined_denied', + ); + let coerced = 0; + const sneaky = { valueOf() { coerced += 1; return 2048; } }; + assert.equal( + errorOf( + () => parseArtifactRefV1(validRef({ byte_length: sneaky })), + 'artifact_ref.byte_length.valueOf', + ).code, + 'invalid_json_type', + ); + assert.equal(coerced, 0); + const boxed = validRef({ sha256: new String('a'.repeat(64)) }); + assert.equal(errorOf(() => parseArtifactRefV1(boxed)).code, 'exotic_prototype_denied'); +}); + +test('cyclic, aliased, deep, and oversized payloads are rejected before effects', () => { + const cyclic = validRef(); + cyclic.self = cyclic; + assert.equal(errorOf(() => parseArtifactRefV1(cyclic)).code, 'aliased_reference_denied'); + + const shared = { marker: true }; + const aliased = validRef(); + aliased.first = shared; + aliased.second = shared; + assert.equal(errorOf(() => parseArtifactRefV1(aliased)).code, 'aliased_reference_denied'); + + let deep = { leaf: 1 }; + for (let index = 0; index < 64; index += 1) deep = { wrapped: deep }; + const deepRef = validRef(); + deepRef.deep = deep; + assert.equal(errorOf(() => parseArtifactRefV1(deepRef)).code, 'value_depth_exceeded'); + + pathError(`${'a'.repeat(2000)}.patch`, 'out_of_range'); +}); + +test('the closure gate precedes the closed vocabulary check', () => { + const unknownButHostile = validRef(); + unknownButHostile.unknown_key = { nested: unknownButHostile }; + assert.equal(errorOf(() => parseArtifactRefV1(unknownButHostile)).code, 'aliased_reference_denied'); +}); + +test('every artifact-specific denial code stays inside the closed vocabulary', () => { + const refCodes = new Set(ARTIFACT_REF_ERROR_CODES); + for (const code of [ + 'duplicate_artifact_ref', + 'refs_exceeded', + 'unknown_artifact_kind', + 'unknown_artifact_class', + 'unknown_media_type', + 'unknown_content_encoding', + 'missing_key', + 'invalid_format', + 'invalid_type', + 'out_of_range', + ]) { + assert.ok(refCodes.has(code), code); + } +}); + +test('orderArtifactRefsV1 rejects every hostile batch shape before sorting', () => { + assert.equal(errorOf(() => orderArtifactRefsV1([validRef(), validRef()])).code, 'duplicate_artifact_ref'); + const sparse = new Array(2); + sparse[0] = validRef(); + assert.equal(errorOf(() => orderArtifactRefsV1(sparse)).code, 'invalid_array'); + const extended = [validRef()]; + extended.extraProperty = true; + assert.equal(errorOf(() => orderArtifactRefsV1(extended)).code, 'invalid_array'); + const accessorElement = validRef(); + Object.defineProperty(accessorElement, 'run_id', { enumerable: true, get() { return 'run-x'; } }); + assert.equal(errorOf(() => orderArtifactRefsV1([accessorElement])).code, 'accessor_property_denied'); + const tooMany = []; + for (let index = 0; index <= MAX_ARTIFACT_REFS; index += 1) { + tooMany.push(secondRef({ + assignment_id: `lane-${String(index).padStart(3, '0')}`, + relative_path: `runs/run-artifact-01/lane-${String(index).padStart(3, '0')}/d.patch`, + sha256: `${index.toString(16).padStart(2, '0')}`.repeat(32), + })); + } + assert.equal(tooMany.length, MAX_ARTIFACT_REFS + 1); + assert.equal(errorOf(() => orderArtifactRefsV1(tooMany)).code, 'refs_exceeded'); +}); + +test('mid-call caller mutation cannot disturb an in-flight ordering or digest', () => { + const refs = [secondRef(), validRef()]; + const orderedOnce = orderArtifactRefsV1(refs); + refs[1].byte_length = 4096; + const orderedTwice = orderArtifactRefsV1([secondRef(), validRef()]); + assert.deepEqual(orderedOnce.map((ref) => ref.assignment_id), orderedTwice.map((ref) => ref.assignment_id)); + + const input = validRef(); + const canonical = canonicalArtifactRefJsonV1(input); + const digest = artifactRefDigestV1(input).digest; + input.media_type = 'application/json'; + assert.equal(canonicalArtifactRefJsonV1(validRef()), canonical); + assert.equal(artifactRefDigestV1(validRef()).digest, digest); + assert.equal(verifyArtifactRefDigestV1(validRef(), digest), true); +}); diff --git a/plugins/codex-co-engineer/test/r1-artifact-ref.test.mjs b/plugins/codex-co-engineer/test/r1-artifact-ref.test.mjs new file mode 100644 index 0000000..7b43363 --- /dev/null +++ b/plugins/codex-co-engineer/test/r1-artifact-ref.test.mjs @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + ARTIFACT_CLASSES, + ARTIFACT_DIGEST_DOMAIN, + ARTIFACT_DIGEST_HEX_LENGTH, + ARTIFACT_DIGEST_VERSION, + ARTIFACT_KINDS, + ARTIFACT_REF_DIGEST_LABEL, + ARTIFACT_REF_SCHEMA_ID, + ARTIFACT_SHA256_PATTERN, + CONTENT_ENCODINGS, + MAX_ARTIFACT_REFS, + MAX_RAW_ARTIFACT_BYTE_LENGTH, + MAX_SANITIZED_ARTIFACT_BYTE_LENGTH, + MEDIA_TYPES, + MIN_ARTIFACT_BYTE_LENGTH, + artifactRefDigestV1, + canonicalArtifactRefJsonV1, + compareArtifactRefsV1, + orderArtifactRefsV1, + parseArtifactRefV1, + validateArtifactRefV1, + verifyArtifactRefDigestV1, +} from '../mcp/v3/artifact-ref.mjs'; +import { + ARTIFACT_PATH_MAX_BYTES, + isArtifactRelativePathV1, + validateArtifactRelativePathV1, +} from '../mcp/v3/artifact-path.mjs'; +import { + CAPABILITY_ARTIFACT_KINDS, +} from '../mcp/v3/capability-bridge.mjs'; +import { RunContractV1Error } from '../mcp/v3/run-manifest.mjs'; +import { secondRef, validRef } from './fixtures/r1-artifact-fixtures.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +function errorOf(action, expectedPath) { + try { + action(); + } catch (error) { + assert.ok(error instanceof RunContractV1Error, `expected RunContractV1Error, got ${error}`); + if (expectedPath !== undefined) assert.equal(error.path, expectedPath); + return error; + } + assert.fail('expected a typed RunContractV1Error'); +} + +test('a valid artifact reference parses into a frozen detached snapshot', () => { + const input = validRef(); + const snapshot = parseArtifactRefV1(input); + assert.equal(Object.isFrozen(snapshot), true); + assert.deepEqual(Object.keys(snapshot), [ + 'schema', 'run_id', 'assignment_id', 'artifact_kind', 'artifact_class', + 'relative_path', 'byte_length', 'sha256', 'media_type', 'content_encoding', + ]); + for (const key of Object.keys(snapshot)) { + assert.equal(snapshot[key], input[key], key); + assert.ok(Object.hasOwn(snapshot, key), key); + } + const reparsed = validateArtifactRefV1(input); + assert.notEqual(reparsed, snapshot); + assert.equal(canonicalArtifactRefJsonV1(reparsed), canonicalArtifactRefJsonV1(snapshot)); +}); + +test('parsed snapshots are detached: later caller mutation cannot drift anything', () => { + const input = validRef(); + const digest = artifactRefDigestV1(input).digest; + const snapshot = parseArtifactRefV1(input); + input.byte_length = 4096; + input.sha256 = 'f'.repeat(64); + input.relative_path = 'runs/run-artifact-01/lane-alpha/other.patch'; + assert.equal(snapshot.byte_length, 2048); + assert.equal(verifyArtifactRefDigestV1(validRef(), digest), true); + assert.notEqual(artifactRefDigestV1(input).digest, digest); + assert.throws(() => { 'use strict'; snapshot.byte_length = 1; }, TypeError); + assert.throws(() => { 'use strict'; snapshot.new_field = 1; }, TypeError); + assert.equal(Reflect.deleteProperty(snapshot, 'sha256'), false); +}); + +test('canonical JSON and digests are invariant under caller key order', () => { + const straight = validRef(); + const shuffled = validRef(); + const reordered = {}; + for (const key of Object.keys(straight).reverse()) reordered[key] = straight[key]; + Object.assign(shuffled, {}); + assert.equal(canonicalArtifactRefJsonV1(straight), canonicalArtifactRefJsonV1(reordered)); + assert.equal(artifactRefDigestV1(straight).digest, artifactRefDigestV1(reordered).digest); +}); + +test('digest descriptors carry the framed domain, version, label, and byte count', () => { + const descriptor = artifactRefDigestV1(validRef()); + assert.deepEqual(Object.keys(descriptor), [ + 'algorithm', 'domain', 'version', 'label', 'input_bytes', 'digest', + ]); + assert.equal(descriptor.algorithm, 'sha256'); + assert.equal(descriptor.domain, ARTIFACT_DIGEST_DOMAIN); + assert.equal(descriptor.version, ARTIFACT_DIGEST_VERSION); + assert.equal(descriptor.label, ARTIFACT_REF_DIGEST_LABEL); + assert.match(descriptor.digest, /^[0-9a-f]{64}$/u); + assert.equal(descriptor.input_bytes, Buffer.byteLength(canonicalArtifactRefJsonV1(validRef()), 'utf8')); + assert.equal(Object.isFrozen(descriptor), true); +}); + +test('every meaningful value change must change the digest', () => { + const baseline = artifactRefDigestV1(validRef()).digest; + const variations = [ + { run_id: 'run-artifact-02' }, + { assignment_id: 'lane-beta' }, + { artifact_kind: 'provider_report' }, + { artifact_class: 'raw' }, + { relative_path: `runs/run-artifact-01/lane-alpha/other.patch` }, + { byte_length: 2049 }, + { sha256: 'ab'.repeat(32) }, + { media_type: 'application/json' }, + { content_encoding: 'base64' }, + ]; + for (const variation of variations) { + assert.notEqual(artifactRefDigestV1(validRef(variation)).digest, baseline, JSON.stringify(variation)); + } +}); + +test('verification is exact about expected digests and never throws on malformed ones', () => { + const digest = artifactRefDigestV1(validRef()).digest; + assert.equal(verifyArtifactRefDigestV1(validRef(), digest), true); + for (const bad of [undefined, null, 12345, {}, `SHA${digest.slice(3)}`, `${digest}0`, digest.slice(1)]) { + assert.equal(verifyArtifactRefDigestV1(validRef(), bad), false); + } + assert.equal(verifyArtifactRefDigestV1(validRef({ byte_length: 9999 }), digest), false); +}); + +test('byte length bounds are class-dependent and bounded evidence stays finite', () => { + assert.equal(MIN_ARTIFACT_BYTE_LENGTH, 1); + parseArtifactRefV1(validRef({ artifact_class: 'sanitized', byte_length: MAX_SANITIZED_ARTIFACT_BYTE_LENGTH })); + errorOf( + () => parseArtifactRefV1(validRef({ + artifact_class: 'sanitized', byte_length: MAX_SANITIZED_ARTIFACT_BYTE_LENGTH + 1, + })), + 'artifact_ref.byte_length', + ).code; + parseArtifactRefV1(validRef({ artifact_class: 'raw', byte_length: MAX_RAW_ARTIFACT_BYTE_LENGTH })); + assert.equal( + errorOf(() => parseArtifactRefV1(validRef({ + artifact_class: 'raw', byte_length: MAX_RAW_ARTIFACT_BYTE_LENGTH + 1, + })), 'artifact_ref.byte_length').code, + 'out_of_range', + ); + for (const bad of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '2048', null]) { + const code = errorOf(() => parseArtifactRefV1(validRef({ byte_length: bad })), 'artifact_ref.byte_length').code; + assert.ok(['invalid_type', 'out_of_range'].includes(code), `unexpected code ${code}`); + } +}); + +test('closed vocabularies are enforced exactly and stay detached', () => { + assert.deepEqual([...ARTIFACT_KINDS], [...CAPABILITY_ARTIFACT_KINDS]); + const exportsToProbe = [ + ['ARTIFACT_KINDS', ARTIFACT_KINDS], + ['ARTIFACT_CLASSES', ARTIFACT_CLASSES], + ['MEDIA_TYPES', MEDIA_TYPES], + ['CONTENT_ENCODINGS', CONTENT_ENCODINGS], + ]; + for (const [name, vocabulary] of exportsToProbe) { + assert.equal(Object.isFrozen(vocabulary), true, name); + const mutated = [...vocabulary]; + try { + vocabulary.push('bogus'); + } catch { + // Frozen arrays throw in strict mode; acceptance must not change either way. + } + assert.deepEqual([...vocabulary], mutated, name); + } + parseArtifactRefV1(validRef()); + for (const [key, values, code] of [ + ['artifact_kind', ARTIFACT_KINDS, 'unknown_artifact_kind'], + ['artifact_class', ARTIFACT_CLASSES, 'unknown_artifact_class'], + ['media_type', MEDIA_TYPES, 'unknown_media_type'], + ['content_encoding', CONTENT_ENCODINGS, 'unknown_content_encoding'], + ]) { + for (const value of values) { + assert.doesNotThrow(() => parseArtifactRefV1(validRef({ [key]: value }))); + } + for (const hostile of ['Git_Diff', 'RAW', 'text/plain ', '', 'bogus']) { + if (values.includes(hostile)) continue; + const error = errorOf( + () => parseArtifactRefV1(validRef({ [key]: hostile })), + `artifact_ref.${key}`, + ); + assert.equal(error.code, code, `${key}: ${hostile}`); + } + } +}); + +test('each of the ten keys is required with a precise missing_key denial', () => { + for (const key of [ + 'schema', 'run_id', 'assignment_id', 'artifact_kind', 'artifact_class', + 'relative_path', 'byte_length', 'sha256', 'media_type', 'content_encoding', + ]) { + const partial = validRef(); + delete partial[key]; + const error = errorOf(() => parseArtifactRefV1(partial), `artifact_ref.${key}`); + assert.equal(error.code, 'missing_key'); + } +}); + +test('unknown keys are rejected and forbidden classes keep their precise denials', () => { + assert.equal(errorOf(() => parseArtifactRefV1(validRef({ extra: 1 })), 'artifact_ref.extra').code, 'unknown_key'); + const forbidden = [ + ['command', 'executable_content_denied'], + ['env', 'executable_content_denied'], + ['secret', 'credential_content_denied'], + ['token', 'credential_content_denied'], + ['fallback', 'replay_or_fallback_denied'], + ['allow_merge', 'merge_authority_denied'], + ['depends_on', 'dependency_not_allowed'], + ]; + for (const [key, code] of forbidden) { + assert.equal(errorOf(() => parseArtifactRefV1(validRef({ [key]: 1 }))).code, code, key); + } +}); + +test('identity fields reuse the accepted manifest grammars exactly', () => { + for (const bad of ['', 'Run-Artifact', 'run_artifact', 'aa', `-run`, `${'a'.repeat(65)}`]) { + assert.equal( + errorOf(() => parseArtifactRefV1(validRef({ run_id: bad })), 'artifact_ref.run_id').code, + 'invalid_format', + bad, + ); + } + for (const bad of ['', 'Lane-Alpha', 'lane_alpha', `${'a'.repeat(65)}`]) { + assert.equal( + errorOf(() => parseArtifactRefV1(validRef({ assignment_id: bad })), 'artifact_ref.assignment_id').code, + 'invalid_format', + bad, + ); + } + for (const bad of ['A'.repeat(64), 'zz', `${'a'.repeat(63)}g`, null]) { + assert.equal( + errorOf(() => parseArtifactRefV1(validRef({ sha256: bad })), 'artifact_ref.sha256').code, + 'invalid_format', + ); + } + assert.equal(ARTIFACT_SHA256_PATTERN.test('a'.repeat(64)), true); + assert.equal(ARTIFACT_SHA256_PATTERN.test('A'.repeat(64)), false); +}); + +test('relative paths inside references flow through the strict path policy', () => { + const nested = validRef({ + relative_path: `runs/run-artifact-01/lane-alpha/evidence/provider-report.json`, + }); + parseArtifactRefV1(nested); + const descriptor = validateArtifactRelativePathV1(nested.relative_path, 'artifact_ref.relative_path'); + assert.equal(descriptor.segment_count, 5); + assert.equal(descriptor.byte_length, Buffer.byteLength(nested.relative_path, 'utf8')); + const escape = validRef({ relative_path: '../../outside/diff.patch' }); + const error = errorOf(() => parseArtifactRefV1(escape), 'artifact_ref.relative_path'); + assert.equal(error.code, 'alias_segment_denied'); + assert.equal(isArtifactRelativePathV1('../../outside/diff.patch'), false); + assert.equal(isArtifactRelativePathV1(nested.relative_path), true); + assert.equal(ARTIFACT_PATH_MAX_BYTES > 0, true); +}); + +test('compareArtifactRefsV1 fixes one deterministic total tuple order', () => { + const base = validRef(); + const other = secondRef(); + const forward = compareArtifactRefsV1(base, other); + assert.equal(forward, -compareArtifactRefsV1(other, base)); + assert.equal(compareArtifactRefsV1(base, validRef()), 0); + // assignment_id dominates everything below it; at equal identity fields + // the code-unit order of media_type decides ('application/json' sorts first). + assert.equal(compareArtifactRefsV1(base, validRef({ media_type: 'application/json' })), 1); + // artifact_class outranks artifact_kind at equal run/child. + const classOrder = compareArtifactRefsV1( + validRef({ artifact_class: 'raw' }), + validRef({ artifact_kind: 'provider_report' }), + ); + assert.equal(classOrder, -1); +}); + +test('orderArtifactRefsV1 is deterministic, duplicate-free, and bounded', () => { + const refs = []; + for (let index = 0; index < MAX_ARTIFACT_REFS; index += 1) { + refs.push(secondRef({ + assignment_id: `lane-${String(index).padStart(2, '0')}`, + relative_path: `runs/run-artifact-01/lane-${String(index).padStart(2, '0')}/diff.patch`, + })); + } + const ordered = orderArtifactRefsV1(refs); + const reshuffled = orderArtifactRefsV1([...refs].reverse()); + assert.deepEqual(JSON.parse(canonicalArtifactRefJsonV1(ordered[0])), JSON.parse(canonicalArtifactRefJsonV1(reshuffled[0]))); + assert.deepEqual(ordered.map((ref) => ref.assignment_id), reshuffled.map((ref) => ref.assignment_id)); + assert.equal(Object.isFrozen(ordered), true); + assert.ok(ordered.every((ref) => Object.isFrozen(ref))); + for (let index = 1; index < ordered.length; index += 1) { + assert.equal(compareArtifactRefsV1(ordered[index - 1], ordered[index]), -1); + } + + assert.deepEqual(orderArtifactRefsV1([]), []); + const single = orderArtifactRefsV1([secondRef()]); + assert.equal(single.length, 1); + + assert.equal( + errorOf(() => orderArtifactRefsV1([validRef(), validRef()])).code, + 'duplicate_artifact_ref', + ); + const aliasSource = validRef(); + assert.equal( + errorOf(() => orderArtifactRefsV1([aliasSource, aliasSource])).code, + 'duplicate_artifact_ref', + ); + assert.equal( + errorOf(() => orderArtifactRefsV1([...refs, validRef()])).code, + 'refs_exceeded', + ); +}); + +test('the modules are pure schema/path policy: no I/O surface and no process access', async () => { + const allowedSpecifiers = new Set([ + 'node:buffer', 'node:crypto', 'node:util', + './grammar.mjs', './run-manifest.mjs', './identity.mjs', + './artifact-path.mjs', './capability-bridge.mjs', './selection-json.mjs', + './contract.mjs', './assignment-manifest.mjs', './repo-path-matcher.mjs', + ]); + for (const relative of ['artifact-ref.mjs', 'artifact-path.mjs']) { + const source = await readFile(path.join(HERE, '..', 'mcp', 'v3', relative), 'utf8'); + const specifiers = [...source.matchAll(/from '([^']+)'/gu)].map((match) => match[1]); + assert.ok(specifiers.length >= 3, `${relative} should import its dependencies`); + for (const specifier of specifiers) { + assert.ok(allowedSpecifiers.has(specifier), `${relative} imports ${specifier}`); + } + assert.doesNotMatch(source, /node:(fs|fs\/promises|net|http|https|child_process|os|dns|tls|stream|worker_threads|process)/u); + assert.doesNotMatch(source, /\bprocess\./u); + assert.doesNotMatch(source, /\brequire\(/u); + assert.doesNotMatch(source, /\beval\(/u); + // The modules address artifacts; they must not claim that P08 storage exists. + assert.match(source, /P08/u); + } +}); From bb452c6f349aa43542495d2d89872bbaee8197b2 Mon Sep 17 00:00:00 2001 From: cole Date: Sat, 22 Aug 2026 19:45:32 +0000 Subject: [PATCH 3/3] docs(changelog): record the P07 artifact reference and path policy Document the closed ten-key ArtifactRefV1 schema, the strict portable relative artifact path policy with its fixed denial pipeline, the domain-framed canonical digest authority, deterministic ordering, and the pure schema/path-policy boundary that claims no P08 storage. --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f2801..ef2e910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ ### Added +- **ArtifactRefV1 and the strict relative artifact path policy.** Adds two + additive, pure v3 modules for W3-P07. `artifact-path.mjs` owns one + deterministic fail-closed question - is this string a strict portable + run-relative artifact path? - accepting only NFC, well-formed, + forward-slash, relative paths of 1..16 segments (128 UTF-8 bytes each, + 1024 total) with no backslashes, leading/trailing/doubled slashes, empty + or dot-only segments, colons (foreclosing drive, UNC, alternate-data- + stream, and scheme spellings in one rule), Windows reserved device stems + (`con`, `CON.txt`, `com1`, `lpt0`, `clock$`), edge dots/spaces Windows + strips, C0/C1 controls, invisible/format/bidi/tag code points, or solidus + look-alikes; the fixed validation pipeline raises identical first typed + errors from a closed 17-code vocabulary and never echoes hostile bytes. + `artifact-ref.mjs` binds the closed ten-key ArtifactRefV1 schema - exact + run/child identifiers reusing the accepted manifest grammars, artifact + kind bound to the P17 capability vocabulary, raw-vs-sanitized class with + class-dependent bounded-evidence caps (sanitized 256 KiB, raw 32 MiB), + declared byte length, lowercase-hex SHA-256, and closed media-type and + content-encoding enums - behind hostile-container hardening that rejects + live/revoked Proxies with zero traps, accessors without invoking them, + symbol keys, exotic prototypes, own undefined, sparse/extended arrays, + aliases/cycles, depth, and size before any effect, returns deep-frozen + detached snapshots, and frames SHA-256 digests over validator-owned + canonical JSON with explicit domain/version/label separation so key order + cannot change a digest while any value change must; ordering is a fixed + tuple comparator plus a bounded (64), duplicate-free batch orderer. Both + modules are pure schema/path policy only - no filesystem, network, or + process I/O - and neither claims that P08 artifact storage exists: + producers declare references, and only a later storage authority may bind + them to bytes. Coverage lives in `test/r1-artifact-ref.test.mjs` and + `test/r1-artifact-ref-adversarial.test.mjs`. - **Deterministic P05 resolver and P17 capability bridge.** Additive `resolveRunSelectionV1` / `resolveSelectionAnswersV1` bind every assignment's provider/model from authored explicit execution, the