'use strict'; /** * capability-validator.cjs — shared, runtime-callable capability validator. * * Extracted from scripts/gen-capability-registry.cjs per ADR-1244 D2 so that * both the build-time generator and the runtime overlay loader can require the * validator WITHOUT pulling in the generator's build-time-only machinery * (ExitError, config-schema.manifest.json, install-profiles.cjs, clusters.cjs, * gen-loop-host-contract.cjs, etc.). * * This is a COMMITTED plain .cjs (not built from .cts) so it is available on a * fresh worktree before `npm run build:lib` has run. */ const path = require('node:path'); const { LOOP_HOST_CONTRACT } = require('./loop-host-contract.cjs'); // ─── Shared schema-version constant ────────────────────────────────────────── const SCHEMA_VERSION = '1'; // ─── Loop Host Contract ─────────────────────────────────────────────────────── // Canonical point order — explicit constant (do NOT rely on Set insertion order). // Used for point-ordering semantics in consumes-satisfiability validation and topo-sort. const POINT_ORDER = [ 'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post', 'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post', 'verify:pre', 'verify:post', 'ship:pre', 'ship:post', ]; // C1: Artifact availability — host-produced artifacts become available at their step's :post // point. Build a map: artifact → earliest POINT_ORDER index at which it is available. // (discuss produces CONTEXT.md → discuss:post = index 1; // plan produces PLAN.md → plan:post = index 3; // execute produces SUMMARY.md → execute:post = index 7; // verify produces UAT.md → verify:post = index 9) // // NOTE: this map covers ONLY host artifacts. Hook-produced artifacts are handled per-run // during consumes-satisfiability validation (C2 global pass). const HOST_ARTIFACT_EARLIEST_POINT_IDX = (() => { const m = Object.create(null); for (const entry of LOOP_HOST_CONTRACT) { // The :post point is the last point in each step's points array. const postPoint = entry.points[entry.points.length - 1]; const postIdx = POINT_ORDER.indexOf(postPoint); for (const artifact of entry.coreArtifacts.produces) { // Only record the earliest (should be unique, but take min to be safe). if (m[artifact] === undefined || postIdx < m[artifact]) { m[artifact] = postIdx; } } } return m; })(); // Flatten all valid loop points into a Set for O(1) validation const VALID_LOOP_POINTS = new Set(POINT_ORDER); // Map point → step contract (agentRoles + coreArtifacts) const POINT_TO_CONTRACT = new Map(); for (const entry of LOOP_HOST_CONTRACT) { for (const point of entry.points) { POINT_TO_CONTRACT.set(point, entry); } } // ─── Config-slice validation ────────────────────────────────────────────────── const VALID_CONFIG_SLICE_TYPES = new Set(['boolean', 'string', 'number', 'enum']); /** * Validate a single config-slice entry (one key's { type, default, description }). * Returns an array of error strings. Empty = valid. * * @param {string} capId Capability id (for error messages) * @param {string} key Config key (for error messages) * @param {object} slice The slice object from cap.config[key] * @returns {string[]} */ function validateConfigSliceEntry(capId, key, slice) { const errors = []; if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) { errors.push('capability "' + capId + '" config["' + key + '"]: slice must be a non-null object'); return errors; } // type must be one of the allowed set if (!VALID_CONFIG_SLICE_TYPES.has(slice.type)) { errors.push( 'capability "' + capId + '" config["' + key + '"]: type must be one of ' + [...VALID_CONFIG_SLICE_TYPES].join(', ') + ' (got: ' + JSON.stringify(slice.type) + ')', ); } // default must be present if (!Object.prototype.hasOwnProperty.call(slice, 'default')) { errors.push( 'capability "' + capId + '" config["' + key + '"]: default is required', ); } else { // type-consistency check const def = slice.default; if (slice.type === 'boolean') { if (typeof def !== 'boolean') { errors.push( 'capability "' + capId + '" config["' + key + '"]: default must be a boolean for type:"boolean" (got: ' + typeof def + ')', ); } } else if (slice.type === 'string') { if (typeof def !== 'string') { errors.push( 'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"string" (got: ' + typeof def + ')', ); } } else if (slice.type === 'number') { if (typeof def !== 'number') { errors.push( 'capability "' + capId + '" config["' + key + '"]: default must be a number for type:"number" (got: ' + typeof def + ')', ); } else if (!Number.isFinite(def)) { // FIX 6a: Reject NaN and non-finite number defaults errors.push( 'capability "' + capId + '" config["' + key + '"]: default for type:"number" must be a finite number (got: ' + String(def) + ')', ); } } else if (slice.type === 'enum') { // FIX 5a: enum REQUIRES a non-empty values array (all strings), and default must be in it if (!Array.isArray(slice.values) || slice.values.length === 0) { errors.push( 'capability "' + capId + '" config["' + key + '"]: type:"enum" requires a non-empty "values" array of strings', ); } else if (!slice.values.every((v) => typeof v === 'string')) { errors.push( 'capability "' + capId + '" config["' + key + '"]: type:"enum" values array must contain only strings', ); } if (typeof def !== 'string') { errors.push( 'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"enum" (got: ' + typeof def + ')', ); } else if (Array.isArray(slice.values) && slice.values.length > 0 && !slice.values.includes(def)) { errors.push( 'capability "' + capId + '" config["' + key + '"]: default "' + def + '" is not one of the declared enum values [' + slice.values.join(', ') + ']', ); } } } // description must be a non-empty string if (typeof slice.description !== 'string' || slice.description.length === 0) { errors.push( 'capability "' + capId + '" config["' + key + '"]: description must be a non-empty string (got: ' + JSON.stringify(slice.description) + ')', ); } return errors; } // ─── Per-capability validation ──────────────────────────────────────────────── const KEBAB_RE = /^[a-z][a-z0-9-]*$/; // ADR-2782 D3: a third role for reviewer lanes that are NOT install targets // (gemini, coderabbit, ollama, lm_studio, llama_cpp). A `role: "reviewer"` // capability carries a reviewer body, no runtime body, and no install surface. const VALID_ROLES = new Set(['feature', 'runtime', 'reviewer']); const VALID_TIERS = new Set(['core', 'standard', 'full']); const VALID_ON_ERROR = new Set(['skip', 'halt']); const RUNTIME_COMPAT_WILDCARD = '*'; // ── ADR-1244 D1: versioned-manifest envelope ───────────────────────────────── // Official strict SemVer 2.0.0 grammar (https://semver.org). Rejects partials // ("1.0"), prefixes ("v1.0.0"), leading-zero segments ("01.2.3"), numeric // prerelease identifiers with leading zeros ("1.2.3-01"), empty identifiers // ("1.2.3-..") and — critically — prerelease/build identifiers containing // anything outside [0-9A-Za-z-] (so a version can never smuggle shell // metacharacters, spaces or unicode into a downstream `git tag v` or // path). Accepts "1.2.3-dev.0", "1.2.3-rc.1", "1.2.3+build.5". const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; // Permissive *shape* check for a semver range (engines.gsd / compatVersions // values). Range SATISFACTION is enforced by the runtime overlay (ADR-1244 D2); // here we only reject empty/garbage and shell metacharacters. const SEMVER_RANGE_RE = /^[0-9A-Za-z.\-+ |<>=~^*()]+$/; // Subresource-integrity hash: "sha512-" + base64 of a 64-byte digest (86 base64 // chars + "==" padding). Exact length so malformed pins ("sha512-abc") fail. const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/; // #1460 (R) HIGH — shell-safe hook-script allowlist. A hook `script` is resolved to an // ABSOLUTE path and written verbatim as the hook `command` STRING in settings.json, which a // host runtime consumes through a shell (first-party hooks emit `node "${...}/hooks/x.js"`). // A manifest-controlled name like `run.sh; touch /tmp/pwn` (filenames may legally contain // `;`, spaces, `$`, backtick, `|`, newline on POSIX) would inject a second command — even // though the file genuinely exists inside the bundle and so passes path-confinement. We // therefore restrict the relative script path to a CONSERVATIVE allowlist: only // [A-Za-z0-9._/-], with no leading `-` on any segment (option-injection), no `..` segment, // and not absolute. Anything else (whitespace, any shell metacharacter, control/NUL) is a // hard validation error — fail closed so the capability install/load is rejected loudly. const SAFE_HOOK_SCRIPT_RE = /^[A-Za-z0-9._/-]+$/; // #3631 (defense-in-depth, mirrors capability-lifecycle.cts — KEEP BOTH IN SYNC): a declared script // path must not point into the space bundleContentHash (capability-consent.cts) excludes from the // consent-binding digest. A file whose basename ends `.pyc`/`.pyo` can contain perfectly valid // JavaScript and would be executed by `node` regardless of extension, and a `__pycache__`/ // `.pytest_cache` segment marks a directory whose digest marker is suppressed — so a MANIFEST-DECLARED // executable surface must never be able to reach either, or the exclusion becomes reachable from a // path an attacker fully controls at declare-time rather than only via post-consent tamper. // Regex asymmetry is DELIBERATE, KEEP BOTH RULES IN SYNC WITH capability-lifecycle.cts (byte-identical // text, verified by the isSafeHookScriptPath parity test in tests/capability-registry.test.cjs): // (i) PYCACHE_SUFFIX_RE is case-INSENSITIVE (`/i`) on purpose — a validator should be STRICTER than // the digest it defends, so it rejects `x.PYC` too even though bundleContentHash's own suffix // match (hasPycacheFileSuffix, capability-consent.cts) is byte-exact and would still hash it. // (ii) The __pycache__/.pytest_cache SEGMENT match is case-SENSITIVE to match the digest's own // byte-exact, case-sensitive directory-basename comparison (CPython always writes a lowercase // `__pycache__`) — a validator segment match looser than the digest here would reject paths the // digest would still hash, which is over-strict in the wrong direction for a defense-in-depth // check layered on top of an already-correct digest. // (iii) The `[/\\]` backslash alternations in both regexes are defensive/UNREACHABLE in practice: // SAFE_HOOK_SCRIPT_RE (above) already rejects any backslash character outright, so a script // string containing `\` never reaches either PYCACHE_*_RE check. const PYCACHE_SEGMENT_RE = /(?:^|[/\\])(__pycache__|\.pytest_cache)(?:[/\\]|$)/; const PYCACHE_SUFFIX_RE = /\.(pyc|pyo)$/i; /** * #1460 (R): true when a relative hook-script path is shell-safe (see SAFE_HOOK_SCRIPT_RE). * Rejects absolute paths, `..` segments, a leading `-` on any path segment, and any char * outside the allowlist (whitespace / shell metacharacters / control / NUL). #3631: also rejects a * path with a `__pycache__`/`.pytest_cache` segment or a `.pyc`/`.pyo` basename suffix (defense in * depth — keeps a declared executable surface out of the digest-excluded space). */ function isSafeHookScriptPath(script) { if (typeof script !== 'string' || script.length === 0) return false; if (!SAFE_HOOK_SCRIPT_RE.test(script)) return false; if (path.isAbsolute(script)) return false; const segments = script.split(/[/\\]/); if (segments.includes('..')) return false; // A leading '-' on any segment would be parsed as an option by the shell/`node`. for (const seg of segments) { if (seg.startsWith('-')) return false; } if (PYCACHE_SEGMENT_RE.test(script)) return false; if (PYCACHE_SUFFIX_RE.test(path.basename(script))) return false; return true; } // A syntactically plausible semver range (shape-only — see SEMVER_RANGE_RE). // Requires a digit or a bare wildcard so pure-alpha garbage ("abcx", "()x") is // rejected; full range satisfaction is the runtime overlay's job (ADR-1244 D2). function isPlausibleRange(s) { if (typeof s !== 'string') return false; const t = s.trim(); if (t.length === 0 || !SEMVER_RANGE_RE.test(s)) return false; return /\d/.test(t) || t === '*' || t === 'x' || t === 'X'; } /** * ADR-1244 D1: validate the versioned-manifest envelope. * - version REQUIRED semver string (the registry rejects a manifest * without one). * - engines optional object; engines.gsd optional semver-range string. * - compatVersions optional object mapping a capability version (semver) to a * gsd version range. * - integrity optional "sha512-" string. * - provenance optional { sourceRepo, commit } strings. * * Shape only — range satisfaction and integrity verification are enforced by * the source resolver / runtime overlay (ADR-1244 D2/D3). * * @param {object} cap The parsed JSON object. * @returns {string[]} Array of error strings; empty = valid. */ function validateVersionEnvelope(cap) { const errors = []; if (typeof cap.version !== 'string' || !SEMVER_RE.test(cap.version)) { errors.push('version must be a semver string (e.g. "1.2.3"); got: ' + JSON.stringify(cap.version)); } if (cap.engines !== undefined) { if (typeof cap.engines !== 'object' || cap.engines === null || Array.isArray(cap.engines)) { errors.push('engines must be an object (e.g. { "gsd": ">=1.6.0" })'); } else if (cap.engines.gsd !== undefined && !isPlausibleRange(cap.engines.gsd)) { errors.push('engines.gsd must be a semver range string; got: ' + JSON.stringify(cap.engines.gsd)); } } if (cap.compatVersions !== undefined) { if (typeof cap.compatVersions !== 'object' || cap.compatVersions === null || Array.isArray(cap.compatVersions)) { errors.push('compatVersions must be an object mapping capability versions to gsd version ranges'); } else { for (const [k, v] of Object.entries(cap.compatVersions)) { if (!SEMVER_RE.test(k)) errors.push('compatVersions key "' + k + '" must be a semver string'); if (!isPlausibleRange(v)) errors.push('compatVersions["' + k + '"] must be a semver range string'); } } } if (cap.integrity !== undefined && (typeof cap.integrity !== 'string' || !SHA512_INTEGRITY_RE.test(cap.integrity))) { errors.push('integrity must be a "sha512-" string'); } if (cap.provenance !== undefined) { const p = cap.provenance; if (typeof p !== 'object' || p === null || Array.isArray(p)) { errors.push('provenance must be an object { sourceRepo, commit }'); } else { if (typeof p.sourceRepo !== 'string' || p.sourceRepo.length === 0) { errors.push('provenance.sourceRepo must be a non-empty string'); } if (typeof p.commit !== 'string' || p.commit.length === 0) { errors.push('provenance.commit must be a non-empty string'); } } } return errors; } /** * Validate a single capability declaration. * * @param {object} cap The parsed JSON object. * @param {string} folderId The folder name (must equal cap.id). * @returns {string[]} Array of error strings; empty = valid. */ function validateCapability(cap, folderId) { const errors = []; if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) { return ['capability must be a JSON object']; } // ── Common envelope ──────────────────────────────────────────────────────── if (typeof cap.id !== 'string' || !KEBAB_RE.test(cap.id)) { errors.push('id must be a kebab-case string'); } else if (cap.id !== folderId) { errors.push('id "' + cap.id + '" must equal the folder name "' + folderId + '"'); } if (!VALID_ROLES.has(cap.role)) { errors.push('role must be one of: ' + [...VALID_ROLES].join(', ') + ' (got: ' + cap.role + ')'); } if (typeof cap.title !== 'string' || cap.title.length === 0) { errors.push('title must be a non-empty string'); } // C4: description is required if (typeof cap.description !== 'string' || cap.description.length === 0) { errors.push('description must be a non-empty string'); } if (!VALID_TIERS.has(cap.tier)) { errors.push('tier must be one of: core, standard, full (got: ' + cap.tier + ')'); } if (!Array.isArray(cap.requires)) { errors.push('requires must be an array of capability ids'); } else { for (const req of cap.requires) { if (typeof req !== 'string') { errors.push('requires entries must be strings (got: ' + JSON.stringify(req) + ')'); } } } // ── Versioned-manifest envelope (ADR-1244 D1) ────────────────────────────── errors.push(...validateVersionEnvelope(cap)); // ── Role-specific body ──────────────────────────────────────────────────── if (cap.role === 'feature') { errors.push(...validateFeatureBody(cap)); // ADR-2782 D1 admits the reviewer body on `runtime` and `reviewer` ONLY. A // feature capability owns loop artefacts, not an external review CLI. This is // an ERROR rather than an ignored field because declaring a body is an // ASSERTION of lane-ness (D4's Postel boundary), and a manifest built for a // GSD that admits it declares `engines.gsd` and is gated before reaching here. if (cap.reviewer !== undefined) { errors.push('role:feature capability must not have a "reviewer" body (admissible on role runtime or reviewer only)'); } } else if (cap.role === 'runtime') { errors.push(...validateRuntimeBody(cap)); // A host that is ALSO a reviewer keeps exactly one manifest (ADR-2782 D1). errors.push(...validateReviewerBody(cap)); } else if (cap.role === 'reviewer') { // ADR-2782 D3 — a lane that is not an install target. No runtime body, no // install surface, no runtimeCompat (it surfaces through no host runtime). if (cap.reviewer === undefined) { errors.push('role:reviewer capability must have a "reviewer" body — the role asserts a lane'); } if (cap.runtime !== undefined) { errors.push('role:reviewer capability must not have a "runtime" body (it is not an install target)'); } for (const field of FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER) { if (cap[field] !== undefined) { errors.push('role:reviewer capability must not have "' + field + '" (feature-only field)'); } } errors.push(...validateReviewerBody(cap)); } return errors; } /** * ADR-959: Validate a single commands[] entry on a feature-role capability. * { family: string, module: string, router: string, subcommands?: string[] } * * - family: non-empty string, no reserved names * - module: non-empty string, no path traversal, no absolute paths, no "/" * segments other than a bare basename (expected form: "foo.cjs") * - router: non-empty string * - subcommands: optional array of strings (doc/introspection only) * * @param {string} capId Capability id (for error messages) * @param {*} entry The entry to validate * @param {string} prefix Path prefix (e.g. "commands[0]") * @returns {string[]} Array of error strings; empty = valid. */ function validateCommandEntry(capId, entry, prefix) { const errors = []; const ctx = 'capability "' + capId + '" ' + prefix; if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { errors.push(ctx + ' must be an object with family, module, and router'); return errors; } // family: non-empty string, no reserved names if (typeof entry.family !== 'string' || entry.family.length === 0) { errors.push(ctx + '.family must be a non-empty string'); } else if (entry.family === '__proto__' || entry.family === 'constructor' || entry.family === 'prototype') { // S2a: inline literal reserved-name guard (CodeQL barrier) errors.push(ctx + '.family "' + entry.family + '" is a reserved name'); } // module: must be a safe bare basename matching /^[A-Za-z0-9._-]+\.cjs$/ — // no path separators, no "..", no NUL bytes, no absolute paths, ends in .cjs. // This conservative pattern subsumes all earlier traversal/absolute/separator checks. if (typeof entry.module !== 'string' || entry.module.length === 0) { errors.push(ctx + '.module must be a non-empty string'); } else { const mod = entry.module; const SAFE_BASENAME = /^[A-Za-z0-9._-]+\.cjs$/; if (!SAFE_BASENAME.test(mod)) { errors.push( ctx + '.module must be a safe bare basename (pattern: /^[A-Za-z0-9._-]+\\.cjs$/, no path separators, no "..", no NUL bytes, must end in ".cjs"); got: ' + JSON.stringify(mod), ); } } // router: non-empty string if (typeof entry.router !== 'string' || entry.router.length === 0) { errors.push(ctx + '.router must be a non-empty string'); } // subcommands: optional array of non-empty strings (doc/introspection only) if (entry.subcommands !== undefined) { if (!Array.isArray(entry.subcommands)) { errors.push(ctx + '.subcommands must be an array of strings if present'); } else { for (let i = 0; i < entry.subcommands.length; i++) { if (typeof entry.subcommands[i] !== 'string') { errors.push(ctx + '.subcommands[' + i + '] must be a string'); } else if (entry.subcommands[i].length === 0) { errors.push(ctx + '.subcommands[' + i + '] must be a non-empty string'); } } } } return errors; } function validateRuntimeCompat(capId, runtimeCompat) { const errors = []; const ctx = 'capability "' + capId + '" runtimeCompat'; if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) { errors.push(ctx + ' must be an object with supported and unsupported arrays'); return errors; } const validateRuntimeArray = (field, { allowWildcard }) => { const value = runtimeCompat[field]; if (!Array.isArray(value)) { errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : '')); return; } if (field === 'supported' && value.length === 0) { errors.push(ctx + '.supported must be a non-empty array'); } let hasWildcard = false; for (let i = 0; i < value.length; i++) { const entry = value[i]; if (typeof entry !== 'string' || entry.length === 0) { errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string'); continue; } if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') { errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name'); } if (entry === RUNTIME_COMPAT_WILDCARD) { if (!allowWildcard) { errors.push(ctx + '.' + field + ' must not include wildcard "*"'); } hasWildcard = true; } else if (!KEBAB_RE.test(entry)) { errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"'); } } if (hasWildcard && value.length > 1) { errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids'); } }; validateRuntimeArray('supported', { allowWildcard: true }); validateRuntimeArray('unsupported', { allowWildcard: false }); if (runtimeCompat.notes !== undefined) { if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) { errors.push(ctx + '.notes must be an object of runtime id to string if present'); } else { for (const [key, value] of Object.entries(runtimeCompat.notes)) { if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) { errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"'); } if (typeof value !== 'string' || value.length === 0) { errors.push(ctx + '.notes["' + key + '"] must be a non-empty string'); } } } } return errors; } function validateFeatureBody(cap) { const errors = []; errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat)); if (!Array.isArray(cap.skills)) { errors.push('skills must be an array of strings'); } else { for (const s of cap.skills) { if (typeof s !== 'string') { errors.push('skills entries must be strings'); } else if (s === '__proto__' || s === 'constructor' || s === 'prototype') { // S2a: inline literal reserved-name guard (CodeQL barrier) errors.push('skills entry "' + s + '" is a reserved name'); } } } // ADR-959: optional commands array if (cap.commands !== undefined) { if (!Array.isArray(cap.commands)) { errors.push('commands must be an array of {family, module, router} objects'); } else { for (let i = 0; i < cap.commands.length; i++) { errors.push(...validateCommandEntry(cap.id || cap.role, cap.commands[i], 'commands[' + i + ']')); } } } if (!Array.isArray(cap.agents)) { errors.push('agents must be an array of strings'); } else { for (const a of cap.agents) { if (typeof a !== 'string') { errors.push('agents entries must be strings'); } else if (a === '__proto__' || a === 'constructor' || a === 'prototype') { // S2a: inline literal reserved-name guard (CodeQL barrier) errors.push('agents entry "' + a + '" is a reserved name'); } } } if (typeof cap.config !== 'object' || cap.config === null || Array.isArray(cap.config)) { errors.push('config must be an object'); } else { // C5: validate config key names and value shapes for (const key of Object.keys(cap.config)) { if (key === '' ) { errors.push('config keys must be non-empty strings'); } else if (key === '__proto__' || key === 'constructor' || key === 'prototype') { // S2a: inline literal reserved-name guard (CodeQL barrier) errors.push('config key "' + key + '" is a reserved name'); } const val = cap.config[key]; if (val === null || typeof val !== 'object' || Array.isArray(val)) { errors.push('config["' + key + '"] must be an object (got: ' + (val === null ? 'null' : typeof val) + ')'); } else if (typeof val.type !== 'string' || val.type.length === 0) { errors.push('config["' + key + '"] must have a string "type" field (e.g. "boolean", "string", "number", "enum")'); } } } // C4: hooks, when present, must be an array of {event: string, script: string} if (cap.hooks !== undefined) { if (!Array.isArray(cap.hooks)) { errors.push('hooks must be an array of {event, script} objects'); } else { for (let i = 0; i < cap.hooks.length; i++) { const h = cap.hooks[i]; if (typeof h !== 'object' || h === null || Array.isArray(h)) { errors.push('hooks[' + i + '] must be an object with event and script keys'); } else { if (typeof h.event !== 'string' || h.event.length === 0) { errors.push('hooks[' + i + '].event must be a non-empty string'); } if (typeof h.script !== 'string' || h.script.length === 0) { errors.push('hooks[' + i + '].script must be a non-empty string'); } else if (!isSafeHookScriptPath(h.script)) { // #1460 (R) HIGH: the script becomes an absolute hook `command` consumed by a shell; // reject any unsafe character (shell metacharacters/whitespace/control), a leading `-`, // an absolute path, or a `..` segment so a manifest can never inject a second command. errors.push( 'hooks[' + i + '].script must be a relative path containing only [A-Za-z0-9._/-] ' + '(no whitespace, shell metacharacters (e.g. ; | & $ ` ( ) < > * ? newline), leading "-", ' + 'absolute path, or ".." segment) — it contains unsafe characters: ' + JSON.stringify(h.script), ); } // #1634: optional tool-scoping `matcher` (a settings.json concept — exact tool name, // pipe-separated list, wildcard, or regex; e.g. "Write|Edit"). When present it must be a // non-empty string without control characters; absent => match-all (omitted at projection // so existing shipped capabilities are unchanged). if (h.matcher !== undefined) { if (typeof h.matcher !== 'string' || h.matcher.length === 0) { errors.push('hooks[' + i + '].matcher must be a non-empty string when present'); } else { // Reject ASCII control characters (0x00-0x1f and 0x7f DEL) via char codes — a literal // control-char range regex trips ESLint's no-control-regex rule, and char codes are // equally precise. let hasControl = false; for (let c = 0; c < h.matcher.length; c++) { const code = h.matcher.charCodeAt(c); if (code < 0x20 || code === 0x7f) { hasControl = true; break; } } if (hasControl) { errors.push('hooks[' + i + '].matcher must not contain control characters'); } } } } } } } // Build the declared skill/agent sets for ref membership checks (used in validateStep). // Only build these if the arrays are valid (already validated above). const declaredSkills = Array.isArray(cap.skills) ? new Set(cap.skills.filter((s) => typeof s === 'string')) : null; const declaredAgents = Array.isArray(cap.agents) ? new Set(cap.agents.filter((a) => typeof a === 'string')) : null; if (!Array.isArray(cap.steps)) { errors.push('steps must be an array'); } else { for (let i = 0; i < cap.steps.length; i++) { errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']', declaredSkills, declaredAgents)); } } if (!Array.isArray(cap.contributions)) { errors.push('contributions must be an array'); } else { for (let i = 0; i < cap.contributions.length; i++) { errors.push(...validateContribution(cap.contributions[i], 'contributions[' + i + ']')); } } if (!Array.isArray(cap.gates)) { errors.push('gates must be an array'); } else { for (let i = 0; i < cap.gates.length; i++) { errors.push(...validateGate(cap.gates[i], 'gates[' + i + ']')); } } // activationKey: optional string naming the dotted config key that gates this capability. // If present: must be a non-empty string that is declared in this capability's own config slice. if (cap.activationKey !== undefined) { if (typeof cap.activationKey !== 'string' || cap.activationKey.length === 0) { errors.push( 'capability "' + (cap.id || '(unknown)') + '" activationKey must be a non-empty string (got: ' + JSON.stringify(cap.activationKey) + ')', ); } else if (cap.activationKey === '__proto__' || cap.activationKey === 'constructor' || cap.activationKey === 'prototype') { // Prototype-pollution guard (inline literal, CodeQL barrier) errors.push( 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey + '" is a reserved JavaScript property name and cannot be used as an activationKey', ); } else if ( typeof cap.config !== 'object' || cap.config === null || !Object.prototype.hasOwnProperty.call(cap.config, cap.activationKey) ) { errors.push( 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey + '" is not declared in this capability\'s config slice — add it to the "config" object or use a key that is declared there', ); } } return errors; } // ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors, // all exported by bin/install.js (commands/skills) and src/runtime-artifact-conversion.cts (agents). // Any ArtifactKind with a non-null converter must use one of these. const VALID_CONVERTER_NAMES = new Set([ // commands / skills converters (pre-existing) 'convertClaudeCommandToAntigravitySkill', 'convertClaudeCommandToAugmentSkill', 'convertClaudeCommandToClineSkill', 'convertClaudeCommandToClaudeSkill', 'convertClaudeCommandToCodebuddyCommand', 'convertClaudeCommandToCodebuddySkill', 'convertClaudeCommandToCodexSkill', 'convertClaudeCommandToCopilotSkill', 'convertClaudeCommandToCursorSkill', 'convertClaudeCommandToKiloSkill', 'convertClaudeCommandToKimiSkill', 'convertClaudeCommandToKimiCodeSkill', 'convertClaudeCommandToOpencodeSkill', 'convertClaudeCommandToTraeSkill', 'convertClaudeCommandToWindsurfSkill', 'convertClaudeCommandToWindsurfWorkflow', // agent converters (#1173 — descriptor-driven agent conversion wiring) 'convertClaudeAgentToCopilotAgent', 'convertClaudeAgentToAntigravityAgent', 'convertClaudeAgentToCursorAgent', 'convertClaudeAgentToWindsurfAgent', 'convertClaudeAgentToAugmentAgent', 'convertClaudeAgentToTraeAgent', 'convertClaudeAgentToCodebuddyAgent', 'convertClaudeAgentToClineAgent', 'convertClaudeAgentToCodexAgent', // ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection. 'convertClaudeAgentToQwenAgent', // #3384 — ZCode agents are Claude-shaped but its dispatcher treats mcp__* tools // grants as required MCP servers; this converter strips them at install time. 'convertClaudeAgentToZcodeAgent', // #2875 Part 2 (the agents-bypass closure) — data-driven Hermes branding // converter (reads hostBehaviors.brandingRewrites rather than a hardcode), // and the kilo/opencode agent converters (shared with those runtimes' // commands-kind entries, options-bag signature `(content, {isAgent, modelOverride})`). 'convertClaudeAgentToHermesAgent', 'convertClaudeToKiloFrontmatter', 'convertClaudeToOpencodeFrontmatter', ]); // C3: Validate role:runtime body const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']); // 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with // no file-projected config directory at all. const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']); const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']); const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']); const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']); // extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943). // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the // host exposes no extension surface (engine owns the bus, e.g. VS Code). const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'kilo', 'none']); const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); // #2871 Phase 2 — only `commands` and `skills` are trigger-bearing (a `/gsd-` // the USER types). `agents` and `kimi-agents` are a separate dispatch interface // point (subagent invocation via `subagent_type`/named dispatch), never a trigger — // see 40-design.md's "agents are not trigger-bearing" correction. A narrower set // than VALID_ARTIFACT_KIND_NAMES on purpose: an artifact KIND can be agents; a // trigger-precedence MEMBER never can. const VALID_TRIGGER_PRECEDENCE_KINDS = new Set(['commands', 'skills']); // The default runtime.triggerPrecedence (highest priority first) applied when a // descriptor omits the axis (see validateRuntimeBody's required-with-default // handling below). Not invented: `['skills', 'commands']` is the ordering every // in-tree runtime that emits both kinds (from the same trigger stems) wants — // claude's local/global collision and the same-scope collision (codebuddy, kilo, // opencode, zcode) all resolve to skills winning. claude's shipped descriptor // declares this SAME value explicitly, and // tests/runtime-artifact-layout-trigger-surface.test.cjs asserts the two agree // (a parity assertion — two surfaces reading one rule must not silently drift). const DEFAULT_TRIGGER_PRECEDENCE = Object.freeze(['skills', 'commands']); const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey']; // 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that // are never CLI-installed (no allRuntimes membership, no install flag). const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', 'none']); // 'antigravity' added #2096 Phase B Upgrade 1 — settings.json permissions.allow writer. const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo', 'antigravity']); // SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see // capabilities/qwen/capability.json's extendedHookEvents). const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']); // ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts) const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']); const VALID_COMMAND_SURFACES = new Set(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']); const VALID_MODEL_MODES = new Set(['active', 'passive']); const VALID_HOOK_BUSES = new Set(['host', 'engine', 'none']); const VALID_STATE_IO = new Set(['filesystem', 'sandboxed-storage', 'session-log-append']); const VALID_TRANSPORTS = new Set(['mcp', 'native-extension']); const VALID_HOST_RUNTIMES = new Set(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']); const VALID_SUBAGENT_TOOLKITS = new Set(['full', 'read-only', 'built-in-only']); // ADR-1239 amendment (#2481): how reasoning effort reaches the host. const VALID_EFFORT_SURFACES = new Set(['argv', 'none']); // ADR-1239 Codex-binding amendment (#2584): how a host isolates concurrent // same-wave executors — a dispatch sub-field, not a top-level axis. const VALID_DISPATCH_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']); // ─── Reviewer lane body (ADR-2782 D1/D2/D3/D7/D8) ──────────────────────────── // // A reviewer lane is one external CLI or model endpoint that /gsd:review hands a // plan to. The body is admissible on `role: "runtime"` (a host that is ALSO a // reviewer) and on `role: "reviewer"` (a lane that is not an install target). // // The vocabulary below tracks src/review-lane-descriptor.cts (Phase 1, #2794) // field-for-field INCLUDING nesting, so no translation layer exists between the // core descriptor and the manifest. Four members were added by Phase 1's ADR // amendment, each forced by a lane that ships today: `promptChannel: 'none'` // (CodeRabbit is fed no prompt), `outputChannel: 'file-arg'` + `outputArg` // (Codex writes via -o and discards stdout, #1698), and `flags[]` (Antigravity // answers to both --antigravity and --agy). // // EVERY error message below enumerates its valid members. That is deliberate: // the prose reference lands in Phase 6 (#2800), so until then the validator's // own errors ARE the documentation — the same gap that left `hostBehaviors` // discoverable only by grepping a source line is not repeated here. // A slug is NOT a capability id. Ids are kebab (KEBAB_RE, which rejects "_"); // slugs carry the shipped roster's snake forms (`lm_studio`, `llama_cpp`) and // name the config keys (`review.lm_studio_host`) that ADR-2782 D9 leaves // unchanged. Reusing KEBAB_RE here would reject two shipped lanes. // // ⚠ DEFECT.GENERATIVE-FIX — this grammar is DUPLICATED, by necessity, from // `LANE_SLUG_RE` in src/review-lane-descriptor.cts (Phase 1, #2794). It cannot be // imported: that module compiles to gsd-core/bin/lib/review-lane-descriptor.cjs, // which is gitignored build output, and THIS file is a committed plain .cjs that // must load on a fresh worktree before `npm run build:lib` has ever run (see the // header). Two surfaces sharing one parser therefore require a parity assertion // that fails when they diverge — `laneSlugGrammarMatchesPhase1Descriptor` in // tests/reviewer-manifest-body.test.cjs. // // A LEADING DIGIT IS PERMITTED. Phase 1 allows it and a manifest validator that // did not would reject a slug the core descriptor accepts — a model-named lane // such as `4o-mini` — which is exactly the translation layer ADR-2782 exists to // delete. Keep the two grammars byte-identical. const LANE_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/; // Flags are kebab even when the slug is snake: `lm_studio` → `--lm-studio`. // Phase 1 declares flags but does not constrain their grammar, so this is the // first and only definition — no parity partner to track. const LANE_FLAG_RE = /^--[a-z0-9][a-z0-9-]*$/; const VALID_LANE_TRANSPORTS = new Set(['spawn', 'openai-http']); const VALID_LANE_PROBE_KINDS = new Set(['command-exists', 'command-capability', 'http-reachable']); const VALID_PROMPT_CHANNELS = new Set(['stdin', 'argv', 'argv-file-ref', 'none']); const VALID_OUTPUT_CHANNELS = new Set(['stdout', 'file-arg']); const VALID_LANE_EFFORT_CHANNELS = new Set(['none', 'argv', 'env']); const VALID_MODEL_DISCOVERY = new Set(['none', 'first-from-models-endpoint']); const VALID_EMPTY_OUTPUT = new Set(['stub-with-stderr', 'handler-owned']); const VALID_EVIDENCE_CLASSES = new Set(['source-grounded', 'diff-only']); // ADR-2782 D6 — a CLOSED enum of FIRST-PARTY module names, never a path and // never third-party code. `null` is the default and covers 7 of the 11 shipped // lanes; it is checked separately because a Set cannot hold the "declared // absent" case distinctly from an unknown string. // // ADMISSION RULE for a new member — this enum is the pressure valve that keeps // the descriptor from becoming an ad-hoc interpreter, and it only works while // membership stays scarce. A new member requires EITHER >=2 lanes that share the // behaviour, OR a documented upstream defect that data provably cannot express. // Today: `openai-compatible` serves 3 lanes (healthy); `antigravity` serves 1, // justified solely by a documented upstream stdout bug. An enum that grows one // member per lane has stopped being a vocabulary and become a dispatch table for // bespoke code — at which point the descriptor is a plugin system wearing a // manifest, and that is a decision for an ADR, not for a downstream phase. // `opencode` admitted by Phase 5b (#2799) under the SECOND arm of the rule above: // it serves 1 lane, justified by a documented upstream defect data cannot express. // OpenCode's default `build` agent is an agentic coder, not a prompt→completion // API; on a large review prompt it can end its turn with ZERO output tokens, and // `--format default` then drops the assistant text entirely, silently losing the // reviewer (#1936). The review must therefore be RECONSTRUCTED from the assistant // `text` parts of a `--format json` stream — a parse, not a copy. Expressing that // as data would need an `outputChannel: 'json-parts'` plus a selector expression, // i.e. exactly the ad-hoc interpreter the admission rule exists to prevent. const VALID_LANE_HANDLERS = new Set(['antigravity', 'openai-compatible', 'opencode']); // D2 — `transport` selects the invoke sub-shape. A manifest carrying fields from // BOTH sub-shapes, or from NEITHER, has undefined meaning and fails validation. // The discriminator is explicit rather than inferred from field presence, which // is precisely the ambiguity these two sets exist to detect. // `env` added by #2483. It belongs in THIS set and not merely in validateSpawnInvoke: an environment // pair has no meaning for a transport that issues an HTTP POST, so a manifest declaring it alongside // `openai-http` fields is the undefined-meaning case the comment above describes. Registering it here // is what makes that case reportable — the openai-http arm rejects exactly the members of this list, // so a field absent from it is silently accepted on the wrong transport. Note `effortChannel` is // deliberately in NEITHER set: D2 defines it for both transports, so it is shared, not spawn-only. const SPAWN_ONLY_INVOKE_FIELDS = ['binary', 'args', 'promptChannel', 'outputChannel', 'outputArg', 'modelArg', 'env']; // Environment names refused outright on a reviewer lane (#2483): each one turns a declared pair into // arbitrary code execution in the spawned child. DEFENCE IN DEPTH, NOT THE BOUNDARY — say so plainly, // because a future reader who mistakes this for the control will under-invest in the one that is. // The boundary is install-time consent: `capability-trust` discloses every declared env pair, shows // its value, and binds it to the consent signature, so an unlisted name is still SEEN before it runs. // // Deliberately incomplete, and it cannot be otherwise: the spawned binary is arbitrary third-party // code, so the exhaustive set is every interpreter's injection variables. What this list buys is that // the highest-confidence, lowest-legitimacy routes cannot be taken quietly. `PATH` is included — it is // the most complete primitive of the set (repoint it at a directory holding a fake binary) and no // shipped reviewer manifest declares it; a lane that needs a specific executable declares an absolute // `invoke.binary` rather than reshaping the child's `PATH`. // // Matched CASE-INSENSITIVELY (members stored uppercase) — Windows environment lookup is // case-insensitive, so an exact-case set is bypassed by declaring `Path` or `node_options`. const DENIED_LANE_ENV_KEYS = new Set([ 'PATH', 'NODE_OPTIONS', 'NODE_REPL_EXTERNAL_MODULE', 'NODE_PATH', 'LD_PRELOAD', 'LD_AUDIT', 'LD_LIBRARY_PATH', 'DYLD_INSERT_LIBRARIES', 'DYLD_LIBRARY_PATH', 'PYTHONSTARTUP', 'PYTHONPATH', 'BASH_ENV', 'ENV', 'PERL5OPT', 'RUBYOPT', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'CLASSPATH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', ]); // `defaultHost` / `fallbackModel` added by Phase 5b (#2799). Phase 4 federated every // `review.*_host` key with a default of `""`, so the REAL fallback destination and model // (`http://localhost:11434` / `llama3` and friends) existed only inside the bash leg. Once the // lane is invoked from data, an unset host with no declared default would POST to a garbage URL. const HTTP_ONLY_INVOKE_FIELDS = ['hostConfigKey', 'defaultHost', 'path', 'modelDiscovery', 'fallbackModel']; // Feature-only fields are as forbidden on a lane-only capability as on a runtime // one; a `role: "reviewer"` capability owns no artefacts and wires no loop point. const FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey']; // GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant) // Derived from the actual pairings in the 16 real runtime descriptors. const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ ['settings-json', new Set(['settings-json', 'none'])], ['codex-toml', new Set(['codex-hooks-json'])], ['copilot-instructions', new Set(['copilot-inline'])], ['cline-rules', new Set(['cline-rules'])], ['cursor-hooks-json', new Set(['cursor-hooks-json'])], ['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])], // 'none' added #2103 — VS Code has no CLI install surface at all; its only // valid hooksSurface pairing is the other 'none' (engine owns the hook bus). ['none', new Set(['none'])], ]); // GATE B: extended hook event families → required hookEvents value // Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'. const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']); // SubagentStart added #2092 Phase B Upgrade 2 — Claude hook-event dialect // counterpart of SubagentStop (qwen-only today). const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'SubagentStart']); /** * Validate a runtime.configHome object per ADR-1016 Decision 1. * Returns an array of error strings. * * @param {string} capId Capability id (for error messages) * @param {*} ch The configHome value * @returns {string[]} */ function validateConfigHome(capId, ch) { const errors = []; const ctx = 'capability "' + capId + '" runtime.configHome'; if (typeof ch !== 'object' || ch === null || Array.isArray(ch)) { errors.push(ctx + ' must be an object (got: ' + (ch === null ? 'null' : typeof ch) + ')'); return errors; } // kind — must be in closed vocab; inline literal guard (CodeQL barrier) if (ch.kind === '__proto__' || ch.kind === 'constructor' || ch.kind === 'prototype') { errors.push(ctx + '.kind "' + ch.kind + '" is a reserved name'); } else if (!VALID_CONFIG_HOME_KINDS.has(ch.kind)) { errors.push( ctx + '.kind must be one of: ' + [...VALID_CONFIG_HOME_KINDS].join(', ') + ' (got: ' + JSON.stringify(ch.kind) + ')', ); } // name — required string, except when kind === 'none': the runtime has no // file-projected config directory at all, so a descriptive name is // optional (a carve-out mirroring the dot-home-nested⇒parent conditional // below, not a new validation mechanism). If present it must still be a // non-empty string (e.g. vscode's configHome.name stays a descriptive // "vscode" string even though it is never used to build a path). if (ch.kind !== 'none') { if (typeof ch.name !== 'string' || ch.name.length === 0) { errors.push(ctx + '.name must be a non-empty string'); } } else if (ch.name !== undefined && (typeof ch.name !== 'string' || ch.name.length === 0)) { errors.push(ctx + '.name must be a non-empty string if present when kind is "none"'); } // parent — required when kind == dot-home-nested if (ch.kind === 'dot-home-nested') { if (typeof ch.parent !== 'string' || ch.parent.length === 0) { errors.push(ctx + '.parent must be a non-empty string when kind is "dot-home-nested"'); } } // env — required; must be an array of strings (every runtime has ≥0 env overrides) if (!Array.isArray(ch.env)) { errors.push(ctx + '.env is required and must be an array of strings (got: ' + JSON.stringify(ch.env) + ')'); } else { for (let i = 0; i < ch.env.length; i++) { if (typeof ch.env[i] !== 'string') { errors.push(ctx + '.env[' + i + '] must be a string'); } } } // probe — optional; if present must be an array of strings if (ch.probe !== undefined) { if (!Array.isArray(ch.probe)) { errors.push(ctx + '.probe must be an array of strings if present'); } else { for (let i = 0; i < ch.probe.length; i++) { if (typeof ch.probe[i] !== 'string') { errors.push(ctx + '.probe[' + i + '] must be a string'); } } } } // probeExists — optional; if present must be a non-empty string (sub-path existence check for probe) if (ch.probeExists !== undefined) { if (typeof ch.probeExists !== 'string' || ch.probeExists.length === 0) { errors.push(ctx + '.probeExists must be a non-empty string if present (got: ' + JSON.stringify(ch.probeExists) + ')'); } } // skillsHome — optional; if present must be a full valid configHome object (recursive validation) if (ch.skillsHome !== undefined) { // Recursive call: validate skillsHome as a nested configHome. // Use a synthetic capId to surface the sub-path in error messages. const skillsHomeErrors = validateConfigHome(capId + '.skillsHome', ch.skillsHome); // Rewrite the inner ctx prefix so errors read as "...runtime.configHome.skillsHome..." for (const e of skillsHomeErrors) { errors.push(e.replace( 'capability "' + capId + '.skillsHome" runtime.configHome', ctx + '.skillsHome', )); } } return errors; } /** * Validate a single ArtifactKind entry per ADR-1016 Decision 3. * Returns an array of error strings. * * @param {string} capId Capability id (for error messages) * @param {*} entry The ArtifactKind object * @param {string} prefix Path prefix for error messages (e.g. "artifactLayout.global[0]") * @returns {string[]} */ function validateArtifactKindEntry(capId, entry, prefix) { const errors = []; const ctx = 'capability "' + capId + '" runtime.' + prefix; if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { errors.push(ctx + ' must be an object'); return errors; } // kind — must be in closed vocab; inline literal guard (CodeQL barrier) if (entry.kind === '__proto__' || entry.kind === 'constructor' || entry.kind === 'prototype') { errors.push(ctx + '.kind "' + entry.kind + '" is a reserved name'); } else if (!VALID_ARTIFACT_KIND_NAMES.has(entry.kind)) { errors.push( ctx + '.kind must be one of: ' + [...VALID_ARTIFACT_KIND_NAMES].join(', ') + ' (got: ' + JSON.stringify(entry.kind) + ')', ); } // destSubpath — required non-empty string if (typeof entry.destSubpath !== 'string' || entry.destSubpath.length === 0) { errors.push(ctx + '.destSubpath must be a non-empty string'); } // nesting — required; must be in closed vocab (ADR-857 §5d: now drives install) if (entry.nesting === undefined || entry.nesting === null) { errors.push(ctx + '.nesting is required and must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ')); } else if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) { errors.push( ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') + ' (got: ' + JSON.stringify(entry.nesting) + ')', ); } // prefix — required; must be a string (may be empty string '') if (entry.prefix === undefined || entry.prefix === null) { errors.push(ctx + '.prefix is required (must be a string, may be empty)'); } else if (typeof entry.prefix !== 'string') { errors.push(ctx + '.prefix must be a string (got: ' + typeof entry.prefix + ')'); } // recursive — optional; if present must be a boolean if (entry.recursive !== undefined) { if (typeof entry.recursive !== 'boolean') { errors.push(ctx + '.recursive must be a boolean if present (got: ' + typeof entry.recursive + ')'); } } // converter — required; must be a string or null (closed ConverterName enum — now enforced in phase 5e) if (!Object.prototype.hasOwnProperty.call(entry, 'converter')) { errors.push(ctx + '.converter is required (must be a string or null)'); } else if (entry.converter !== null && typeof entry.converter !== 'string') { errors.push(ctx + '.converter must be a string or null (got: ' + typeof entry.converter + ')'); } else if (entry.converter !== null && typeof entry.converter === 'string' && !VALID_CONVERTER_NAMES.has(entry.converter)) { // Closed ConverterName enum (ADR-857 phase 5e): reject unknown converter names errors.push(ctx + '.converter "' + entry.converter + '" is not a known ConverterName'); } return errors; } /** * Validate runtime.artifactLayout per ADR-1016 Decision 3. * Accepts the structured { global, local } shape. * Returns an array of error strings. * * @param {string} capId Capability id (for error messages) * @param {*} layout The artifactLayout value * @returns {string[]} */ function validateArtifactLayout(capId, layout) { const errors = []; const ctx = 'capability "' + capId + '" runtime.artifactLayout'; if (typeof layout !== 'object' || layout === null || Array.isArray(layout)) { errors.push(ctx + ' must be an object with "global" and "local" arrays'); return errors; } for (const scope of ['global', 'local']) { const arr = layout[scope]; if (!Array.isArray(arr)) { errors.push(ctx + '.' + scope + ' must be an array'); } else { for (let i = 0; i < arr.length; i++) { const entry = arr[i]; errors.push(...validateArtifactKindEntry(capId, entry, 'artifactLayout.' + scope + '[' + i + ']')); if ( scope === 'local' && typeof entry === 'object' && entry !== null && !Array.isArray(entry) && Object.prototype.hasOwnProperty.call(entry, 'home') ) { errors.push( ctx + '.local[' + i + '].home is not allowed; ' + 'local artifact layout entries must remain project-scoped', ); } } } } return errors; } function validateRuntimeBody(cap) { const errors = []; // C3: feature-only fields must NOT appear on a runtime cap for (const field of FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME) { if (cap[field] !== undefined) { errors.push('role:runtime capability must not have "' + field + '" (feature-only field)'); } } // C3: require a runtime object if (typeof cap.runtime !== 'object' || cap.runtime === null || Array.isArray(cap.runtime)) { errors.push('role:runtime capability must have a "runtime" object'); return errors; // can't validate further without the object } const r = cap.runtime; // configHome — must be a structured object (ADR-1016 Decision 1) errors.push(...validateConfigHome(cap.id || '(unknown)', r.configHome)); // configFormat — closed 5-enum (unchanged) if (!VALID_CONFIG_FORMATS.has(r.configFormat)) { errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')'); } // artifactLayout — structured { global, local } per ADR-1016 Decision 3 errors.push(...validateArtifactLayout(cap.id || '(unknown)', r.artifactLayout)); // commandStyle — closed 2-enum (ADR-1016 Decision 4); inline literal guard (CodeQL barrier) if (r.commandStyle === '__proto__' || r.commandStyle === 'constructor' || r.commandStyle === 'prototype') { errors.push('runtime.commandStyle "' + r.commandStyle + '" is a reserved name'); } else if (!VALID_COMMAND_STYLES.has(r.commandStyle)) { errors.push( 'runtime.commandStyle must be one of: ' + [...VALID_COMMAND_STYLES].join(', ') + ' (got: ' + JSON.stringify(r.commandStyle) + ')', ); } // hooksSurface — closed 7-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier) if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') { errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name'); } else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) { errors.push( 'runtime.hooksSurface must be one of: ' + [...VALID_HOOKS_SURFACES].join(', ') + ' (got: ' + JSON.stringify(r.hooksSurface) + ')', ); } // hookEvents — optional; if present must be in closed enum (ADR-1016 Decision 5). // Managed-hook dialect only (claude/gemini). The OpenCode extension-system event // subset is NOT a hookEvents value — it is `extensionEvents` (ADR-1239 / #1943). if (r.hookEvents !== undefined) { if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') { errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name'); } else if (!VALID_HOOK_EVENTS.has(r.hookEvents)) { errors.push( 'runtime.hookEvents must be one of: ' + [...VALID_HOOK_EVENTS].join(', ') + ' (got: ' + JSON.stringify(r.hookEvents) + ')', ); } } // extensionEvents — optional; the extension-system event dialect (ADR-1239 amendment / #1943). // Distinct from hookEvents. Only imperative-embedding hosts (with a plugin/extension // API) set it; declarative hosts do not. if (r.extensionEvents !== undefined) { if (r.extensionEvents === '__proto__' || r.extensionEvents === 'constructor' || r.extensionEvents === 'prototype') { errors.push('runtime.extensionEvents "' + r.extensionEvents + '" is a reserved name'); } else if (!VALID_EXTENSION_EVENTS.has(r.extensionEvents)) { errors.push( 'runtime.extensionEvents must be one of: ' + [...VALID_EXTENSION_EVENTS].join(', ') + ' (got: ' + JSON.stringify(r.extensionEvents) + ')', ); } } // sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier) if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') { errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name'); } else if (!VALID_SANDBOX_TIERS.has(r.sandboxTier)) { errors.push( 'runtime.sandboxTier must be one of: ' + [...VALID_SANDBOX_TIERS].join(', ') + ' (got: ' + JSON.stringify(r.sandboxTier) + ')', ); } // supportTier — 1 or 2 (unchanged) if (r.supportTier !== 1 && r.supportTier !== 2) { errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')'); } // installSurface — required string in closed enum if (!VALID_INSTALL_SURFACES.has(r.installSurface)) { errors.push( 'runtime.installSurface must be one of: ' + [...VALID_INSTALL_SURFACES].join(', ') + ' (got: ' + JSON.stringify(r.installSurface) + ')', ); } // writesSharedSettings — required boolean if (typeof r.writesSharedSettings !== 'boolean') { errors.push( 'runtime.writesSharedSettings must be a boolean (got: ' + JSON.stringify(r.writesSharedSettings) + ')', ); } // permissionWriter — required key; value must be null or a string in VALID_PERMISSION_WRITERS if (!Object.prototype.hasOwnProperty.call(r, 'permissionWriter')) { errors.push('runtime.permissionWriter is required (must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ')'); } else if (r.permissionWriter !== null && !VALID_PERMISSION_WRITERS.has(r.permissionWriter)) { errors.push( 'runtime.permissionWriter must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ' (got: ' + JSON.stringify(r.permissionWriter) + ')', ); } // localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679) // Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry // generator catches any descriptor missing the field before regenerating. // // #2103: conditional on configHome.kind !== 'none' — a Marketplace/VSIX // host with no file-projected config directory (e.g. VS Code) has no // local dir to name; localConfigDir may be null/absent for such runtimes. const configHomeKind = (r.configHome && typeof r.configHome === 'object') ? r.configHome.kind : undefined; if (configHomeKind !== 'none') { if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) { errors.push( 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' + 'got: ' + JSON.stringify(r.localConfigDir), ); } else if (!r.localConfigDir.startsWith('.')) { errors.push( 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir), ); } } else if (r.localConfigDir !== null && r.localConfigDir !== undefined) { errors.push( 'runtime.localConfigDir must be null or absent when configHome.kind is "none"; ' + 'got: ' + JSON.stringify(r.localConfigDir), ); } // extendedHookEvents — required array; every element must be in closed enum if (!Array.isArray(r.extendedHookEvents)) { errors.push( 'runtime.extendedHookEvents must be an array (got: ' + JSON.stringify(r.extendedHookEvents) + ')', ); } else { for (let i = 0; i < r.extendedHookEvents.length; i++) { const ev = r.extendedHookEvents[i]; if (typeof ev !== 'string' || !VALID_EXTENDED_HOOK_EVENTS.has(ev)) { errors.push( 'runtime.extendedHookEvents[' + i + '] must be one of: ' + [...VALID_EXTENDED_HOOK_EVENTS].join(', ') + ' (got: ' + JSON.stringify(ev) + ')', ); } } } // hostIntegration — ADR-1239 Phase A: required object with closed-enum axes if (typeof r.hostIntegration !== 'object' || r.hostIntegration === null || Array.isArray(r.hostIntegration)) { errors.push('runtime.hostIntegration is required and must be an object'); } else { const hi = r.hostIntegration; // S2b: reserved-OWN-KEY guard on hostIntegration (CodeQL barrier — inline literal comparisons) if (Object.prototype.hasOwnProperty.call(hi, '__proto__')) { errors.push('runtime.hostIntegration must not contain reserved key "__proto__"'); } if (Object.prototype.hasOwnProperty.call(hi, 'constructor')) { errors.push('runtime.hostIntegration must not contain reserved key "constructor"'); } if (Object.prototype.hasOwnProperty.call(hi, 'prototype')) { errors.push('runtime.hostIntegration must not contain reserved key "prototype"'); } // embeddingMode if (hi.embeddingMode === '__proto__' || hi.embeddingMode === 'constructor' || hi.embeddingMode === 'prototype') { errors.push('runtime.hostIntegration.embeddingMode "' + hi.embeddingMode + '" is a reserved name'); } else if (hi.embeddingMode !== 'undocumented' && !VALID_EMBEDDING_MODES.has(hi.embeddingMode)) { errors.push( 'runtime.hostIntegration.embeddingMode must be one of: ' + [...VALID_EMBEDDING_MODES].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.embeddingMode) + ')', ); } // commandSurface if (hi.commandSurface === '__proto__' || hi.commandSurface === 'constructor' || hi.commandSurface === 'prototype') { errors.push('runtime.hostIntegration.commandSurface "' + hi.commandSurface + '" is a reserved name'); } else if (hi.commandSurface !== 'undocumented' && !VALID_COMMAND_SURFACES.has(hi.commandSurface)) { errors.push( 'runtime.hostIntegration.commandSurface must be one of: ' + [...VALID_COMMAND_SURFACES].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.commandSurface) + ')', ); } // modelMode if (hi.modelMode === '__proto__' || hi.modelMode === 'constructor' || hi.modelMode === 'prototype') { errors.push('runtime.hostIntegration.modelMode "' + hi.modelMode + '" is a reserved name'); } else if (hi.modelMode !== 'undocumented' && !VALID_MODEL_MODES.has(hi.modelMode)) { errors.push( 'runtime.hostIntegration.modelMode must be one of: ' + [...VALID_MODEL_MODES].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.modelMode) + ')', ); } // hookBus if (hi.hookBus === '__proto__' || hi.hookBus === 'constructor' || hi.hookBus === 'prototype') { errors.push('runtime.hostIntegration.hookBus "' + hi.hookBus + '" is a reserved name'); } else if (hi.hookBus !== 'undocumented' && !VALID_HOOK_BUSES.has(hi.hookBus)) { errors.push( 'runtime.hostIntegration.hookBus must be one of: ' + [...VALID_HOOK_BUSES].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.hookBus) + ')', ); } // stateIO if (hi.stateIO === '__proto__' || hi.stateIO === 'constructor' || hi.stateIO === 'prototype') { errors.push('runtime.hostIntegration.stateIO "' + hi.stateIO + '" is a reserved name'); } else if (hi.stateIO !== 'undocumented' && !VALID_STATE_IO.has(hi.stateIO)) { errors.push( 'runtime.hostIntegration.stateIO must be one of: ' + [...VALID_STATE_IO].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.stateIO) + ')', ); } // transport if (hi.transport === '__proto__' || hi.transport === 'constructor' || hi.transport === 'prototype') { errors.push('runtime.hostIntegration.transport "' + hi.transport + '" is a reserved name'); } else if (hi.transport !== 'undocumented' && !VALID_TRANSPORTS.has(hi.transport)) { errors.push( 'runtime.hostIntegration.transport must be one of: ' + [...VALID_TRANSPORTS].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.transport) + ')', ); } // runtime (axis) if (hi.runtime === '__proto__' || hi.runtime === 'constructor' || hi.runtime === 'prototype') { errors.push('runtime.hostIntegration.runtime "' + hi.runtime + '" is a reserved name'); } else if (hi.runtime !== 'undocumented' && !VALID_HOST_RUNTIMES.has(hi.runtime)) { errors.push( 'runtime.hostIntegration.runtime must be one of: ' + [...VALID_HOST_RUNTIMES].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.runtime) + ')', ); } // effortSurface (axis) — ADR-1239 amendment (#2481). // OPTIONAL, unlike the Phase-A axes. It was added after descriptors already // existed, so requiring it would invalidate every descriptor written before it — // including third-party ones, breaking the "purely additive" property ADR-1239 // promises for external descriptors. An omitted axis is legitimate: negotiation // degrades it to the safe floor ('none') and warns, exactly as for any // undeclared axis. Only a PRESENT value is checked against the vocabulary. if (hi.effortSurface === undefined) { // absent — nothing to validate; negotiateHostCapabilities fails it closed. } else if (hi.effortSurface === '__proto__' || hi.effortSurface === 'constructor' || hi.effortSurface === 'prototype') { errors.push('runtime.hostIntegration.effortSurface "' + hi.effortSurface + '" is a reserved name'); } else if (hi.effortSurface !== 'undocumented' && !VALID_EFFORT_SURFACES.has(hi.effortSurface)) { errors.push( 'runtime.hostIntegration.effortSurface must be one of: ' + [...VALID_EFFORT_SURFACES].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(hi.effortSurface) + ')', ); } // dispatch — required object if (typeof hi.dispatch !== 'object' || hi.dispatch === null || Array.isArray(hi.dispatch)) { errors.push('runtime.hostIntegration.dispatch must be an object'); } else { const d = hi.dispatch; // S2b: reserved-OWN-KEY guard on dispatch (CodeQL barrier — inline literal comparisons) if (Object.prototype.hasOwnProperty.call(d, '__proto__')) { errors.push('runtime.hostIntegration.dispatch must not contain reserved key "__proto__"'); } if (Object.prototype.hasOwnProperty.call(d, 'constructor')) { errors.push('runtime.hostIntegration.dispatch must not contain reserved key "constructor"'); } if (Object.prototype.hasOwnProperty.call(d, 'prototype')) { errors.push('runtime.hostIntegration.dispatch must not contain reserved key "prototype"'); } // namedDispatch — boolean or 'undocumented' if (typeof d.namedDispatch !== 'boolean' && d.namedDispatch !== 'undocumented') { errors.push( 'runtime.hostIntegration.dispatch.namedDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.namedDispatch) + ')', ); } // nested — boolean or 'undocumented' if (typeof d.nested !== 'boolean' && d.nested !== 'undocumented') { errors.push( 'runtime.hostIntegration.dispatch.nested must be a boolean or "undocumented" (got: ' + JSON.stringify(d.nested) + ')', ); } // background — boolean or 'undocumented' if (typeof d.background !== 'boolean' && d.background !== 'undocumented') { errors.push( 'runtime.hostIntegration.dispatch.background must be a boolean or "undocumented" (got: ' + JSON.stringify(d.background) + ')', ); } // subagentToolkit — closed enum or 'undocumented' if (d.subagentToolkit === '__proto__' || d.subagentToolkit === 'constructor' || d.subagentToolkit === 'prototype') { errors.push('runtime.hostIntegration.dispatch.subagentToolkit "' + d.subagentToolkit + '" is a reserved name'); } else if (d.subagentToolkit !== 'undocumented' && !VALID_SUBAGENT_TOOLKITS.has(d.subagentToolkit)) { errors.push( 'runtime.hostIntegration.dispatch.subagentToolkit must be one of: ' + [...VALID_SUBAGENT_TOOLKITS].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(d.subagentToolkit) + ')', ); } // maxDepth — integer >= -1 or 'undocumented' if (d.maxDepth !== 'undocumented' && (!Number.isInteger(d.maxDepth) || d.maxDepth < -1)) { errors.push( 'runtime.hostIntegration.dispatch.maxDepth must be an integer >= -1 or "undocumented" (got: ' + JSON.stringify(d.maxDepth) + ')', ); } // backgroundDispatch — REQUIRED (all 16 runtime descriptors carry it, matching the sibling fields // namedDispatch/nested/background/subagentToolkit/maxDepth which are all required). if (!Object.prototype.hasOwnProperty.call(d, 'backgroundDispatch')) { errors.push( 'runtime.hostIntegration.dispatch.backgroundDispatch is required (must be a boolean or "undocumented")', ); } else if (typeof d.backgroundDispatch !== 'boolean' && d.backgroundDispatch !== 'undocumented') { errors.push( 'runtime.hostIntegration.dispatch.backgroundDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.backgroundDispatch) + ')', ); } // isolation — ADR-1239 Codex-binding amendment (#2584). // OPTIONAL, like effortSurface: added after descriptors already existed, // so requiring it would invalidate every descriptor authored before it — // including third-party ones, breaking the "purely additive" property // ADR-1239 promises for external descriptors. An omitted isolation is // legitimate: negotiation degrades it to 'none' (the safe floor) and // warns, exactly as for any other undeclared dispatch sub-field. Only a // PRESENT value is checked against the closed vocabulary. if (d.isolation === undefined) { // absent — nothing to validate; negotiateHostCapabilities fails it closed. } else if (d.isolation === '__proto__' || d.isolation === 'constructor' || d.isolation === 'prototype') { errors.push('runtime.hostIntegration.dispatch.isolation "' + d.isolation + '" is a reserved name'); } else if (d.isolation !== 'undocumented' && !VALID_DISPATCH_ISOLATION.has(d.isolation)) { errors.push( 'runtime.hostIntegration.dispatch.isolation must be one of: ' + [...VALID_DISPATCH_ISOLATION].join(', ') + ' (or "undocumented") (got: ' + JSON.stringify(d.isolation) + ')', ); } } } // orchestratorExec — ADR-1239 Codex-binding amendment (#2584), Phase 2. // OPTIONAL top-level field (sibling of hostBehaviors), like hookEvents: // only hosts whose hostIntegration.dispatch.isolation is // 'orchestrator-worktree' need it, so it is not required on every runtime // descriptor. When present it must be a well-formed exec descriptor for // src/host-integration.cts's resolveOrchestratorExec. if (r.orchestratorExec !== undefined) { if (typeof r.orchestratorExec !== 'object' || r.orchestratorExec === null || Array.isArray(r.orchestratorExec)) { errors.push( 'runtime.orchestratorExec must be an object (got: ' + (r.orchestratorExec === null ? 'null' : (Array.isArray(r.orchestratorExec) ? 'array' : typeof r.orchestratorExec)) + ')', ); } else { const oe = r.orchestratorExec; // S2b: reserved-OWN-KEY guard (CodeQL barrier — inline literal comparisons) if (Object.prototype.hasOwnProperty.call(oe, '__proto__')) { errors.push('runtime.orchestratorExec must not contain reserved key "__proto__"'); } if (Object.prototype.hasOwnProperty.call(oe, 'constructor')) { errors.push('runtime.orchestratorExec must not contain reserved key "constructor"'); } if (Object.prototype.hasOwnProperty.call(oe, 'prototype')) { errors.push('runtime.orchestratorExec must not contain reserved key "prototype"'); } // command — required non-empty string; reserved-name guard (CodeQL barrier) if (oe.command === '__proto__' || oe.command === 'constructor' || oe.command === 'prototype') { errors.push('runtime.orchestratorExec.command "' + oe.command + '" is a reserved name'); } else if (typeof oe.command !== 'string' || oe.command.length === 0) { errors.push( 'runtime.orchestratorExec.command must be a non-empty string (got: ' + JSON.stringify(oe.command) + ')', ); } // args — optional array of strings if (oe.args !== undefined) { if (!Array.isArray(oe.args) || !oe.args.every((a) => typeof a === 'string')) { errors.push( 'runtime.orchestratorExec.args must be an array of strings (got: ' + JSON.stringify(oe.args) + ')', ); } } // cwdFlag — optional; string or null if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') { errors.push( 'runtime.orchestratorExec.cwdFlag must be a string or null (got: ' + JSON.stringify(oe.cwdFlag) + ')', ); } // promptFlag — optional; string or null (#2627, Phase 3). `null`/absent // means the host takes the executor prompt positionally (codex, opencode); // a string names the flag that carries it (kimi/kimi-code: --prompt). if (oe.promptFlag !== undefined && oe.promptFlag !== null && typeof oe.promptFlag !== 'string') { errors.push( 'runtime.orchestratorExec.promptFlag must be a string or null (got: ' + JSON.stringify(oe.promptFlag) + ')', ); } } } // harnessIsolationFlag — ADR-1239 Codex-binding amendment (#2584), Phase 3 // (#2627). OPTIONAL top-level field: the counterpart of orchestratorExec for // `dispatch.isolation: 'harness-worktree'` hosts, naming the host's OWN // isolation flag that GSD passes on dispatch (claude: isolation="worktree"; // cursor: --worktree). Descriptor data so the scheduler passes a declared // token instead of branching on a runtime id (ADR-1239: "the per-host // dispatch invocation ... is descriptor data, not a scheduler branch"). if (r.harnessIsolationFlag !== undefined && (typeof r.harnessIsolationFlag !== 'string' || r.harnessIsolationFlag.length === 0)) { errors.push( 'runtime.harnessIsolationFlag must be a non-empty string (got: ' + JSON.stringify(r.harnessIsolationFlag) + ')', ); } // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX) // Only check if both fields are valid strings (individual field validators above report type errors). if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') { const allowedHooksSurfaces = INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.get(r.installSurface); if (allowedHooksSurfaces !== undefined && !allowedHooksSurfaces.has(r.hooksSurface)) { errors.push( 'runtime.hooksSurface "' + r.hooksSurface + '" is not valid for installSurface "' + r.installSurface + '"' + ' — allowed: ' + [...allowedHooksSurfaces].join(', ') + ' (src: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES in scripts/gen-capability-registry.cjs)', ); } } // GATE B: extendedHookEvents ↔ hookEvents consistency (DEFECT.GENERATIVE-FIX) // If extendedHookEvents contains Gemini agent-events, hookEvents must be 'gemini'. // If it contains Claude-family events, hookEvents must be 'claude'. // Empty extendedHookEvents imposes no constraint. if (Array.isArray(r.extendedHookEvents) && r.extendedHookEvents.length > 0) { const hasGeminiEvents = r.extendedHookEvents.some((ev) => GEMINI_AGENT_EVENTS.has(ev)); const hasClaudeEvents = r.extendedHookEvents.some((ev) => CLAUDE_FAMILY_EVENTS.has(ev)); if (hasGeminiEvents && r.hookEvents !== 'gemini') { errors.push( 'runtime.extendedHookEvents contains Gemini agent-events (' + r.extendedHookEvents.filter((ev) => GEMINI_AGENT_EVENTS.has(ev)).join(', ') + ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "gemini"', ); } if (hasClaudeEvents && r.hookEvents !== 'claude') { errors.push( 'runtime.extendedHookEvents contains Claude-family events (' + r.extendedHookEvents.filter((ev) => CLAUDE_FAMILY_EVENTS.has(ev)).join(', ') + ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "claude"', ); } } // triggerPrecedence — #2871 Phase 2 amendment to ADR-1016's runtime body. // REQUIRED-WITH-DEFAULT: no other axis in this validator uses this shape — // every axis above either hard-requires the field (throws when absent) or // treats absence as fully unconstrained (effortSurface/isolation: "nothing to // validate" when undefined). This axis does neither: an ABSENT value is // substituted with DEFAULT_TRIGGER_PRECEDENCE and then validated exactly as // if it HAD been supplied, so a third-party capability.json authored before // this phase keeps validating (ADR-894 additive-only / Hyrum's Law, ADR-1244) // while a PRESENT-but-malformed value still fails loudly instead of silently // passing through unchecked. const triggerPrecedenceValue = Object.prototype.hasOwnProperty.call(r, 'triggerPrecedence') ? r.triggerPrecedence : DEFAULT_TRIGGER_PRECEDENCE; if (!Array.isArray(triggerPrecedenceValue)) { errors.push( 'runtime.triggerPrecedence must be an array of trigger-bearing kind names (' + [...VALID_TRIGGER_PRECEDENCE_KINDS].join(', ') + ') (got: ' + JSON.stringify(triggerPrecedenceValue) + ')', ); } else if (triggerPrecedenceValue.length === 0) { errors.push('runtime.triggerPrecedence must not be empty'); } else { const seenKinds = new Set(); for (let i = 0; i < triggerPrecedenceValue.length; i++) { const k = triggerPrecedenceValue[i]; if (k === '__proto__' || k === 'constructor' || k === 'prototype') { errors.push('runtime.triggerPrecedence[' + i + '] "' + k + '" is a reserved name'); } else if (typeof k !== 'string' || !VALID_TRIGGER_PRECEDENCE_KINDS.has(k)) { errors.push( 'runtime.triggerPrecedence[' + i + '] must be one of: ' + [...VALID_TRIGGER_PRECEDENCE_KINDS].join(', ') + ' (got: ' + JSON.stringify(k) + ')', ); } else if (seenKinds.has(k)) { errors.push('runtime.triggerPrecedence contains a duplicate kind: ' + JSON.stringify(k)); } else { seenKinds.add(k); } } } return errors; } /** * ADR-2782 D2/D7 — every declared reviewer field is a known key. Anything else * inside the body is IGNORED WITH A WARNING (D4.3), never a validation error: * a capability authored for a newer GSD must degrade to discovered-but-inactive * rather than failing the build of a repo that merely reads it. */ const KNOWN_REVIEWER_FIELDS = new Set([ 'slug', 'flags', 'transport', 'probe', 'invoke', 'timeoutFloorMs', 'emptyOutput', 'reviewsSection', 'evidenceClass', 'requiresBinaries', 'promptBudgetKey', // `modelConfigKey` added by Phase 5b (#2799). The model key was IMPLICIT // (`review.models.`) until a shipped lane broke the convention: antigravity's // slug is `antigravity` but its key is `review.models.agy`, so resolving by slug // missed a configured model and silently disabled the pinned-model escape hatch // #2073 added. A convention one shipped lane already violates is not a contract. 'modelConfigKey', 'handler', ]); /** * The closed `runtime.hostBehaviors` vocabulary (ADR-1016, closed via #2801). * * ADR-1016's core principle is that every per-runtime difference is a value over * a closed primitive vocabulary, and that a host needing a new shape gets a * reviewed first-party primitive rather than "an open escape hatch in the * descriptor" (§Alternatives #2 rejects exactly that). `hostBehaviors` was the * one hole left in that closure: 59 keys across 18 manifests, 39 of them set by * a single capability, validated by nothing. It was described in the reference * docs as a deliberate open seam sanctioned by ADR-1016 — ADR-1016 does not * mention `hostBehaviors` at all, and its stated principle is the opposite. * * Adding a key here is deliberate and reviewed. That friction IS the decision * (ADR-1016 §Consequences: "the closed vocabulary must grow (reviewed) when a * genuinely new host shape appears — intentional friction, the trust boundary"). * * WARNING, never error. Two reasons: * 1. It matches the ADR-2782 D4.3 treatment of an unknown `reviewer` field, so * a manifest built against a newer GSD degrades visibly instead of failing * the build of a repo that merely reads it. * 2. An error would hard-break an out-of-tree descriptor carrying a bespoke * key, with no deprecation window — the exact mistake #2801's own alias * removal spent a full release avoiding. Escalating to an error is a later * step and needs its own window. * * Kept in sorted order, and `tests/reviewer-manifest-body.test.cjs` asserts this * set equals the keys the shipped manifests actually declare, so the list cannot * silently rot away from reality (DEFECT.GENERATIVE-FIX). */ const KNOWN_HOST_BEHAVIORS = new Set([ 'agentFileExtension', 'agentFrontmatterExtensions', 'agentManifestStyle', 'agentTomlFiles', 'attributionConfigResolver', 'attributionSource', 'authorsCanonicalWorkflow', 'brandingRewrites', 'cleanupSkillSidecars', 'clineRulesSurface', 'combinedFamilyInstall', 'commandBodyConverter', 'doneBannerStyle', 'flatCommandDir', 'frontmatterDialect', 'globalDirResolver', 'hookPathStyle', 'hooksJsonSurface', 'hyphenNameAgentBody', 'installsCommandBodiesForWorkflowDelegation', 'legacyCommandsGsdCleanup', 'legacyCommandsGsdInstallMigration', 'legacyCommandsGsdUninstall', 'legacyDevinSkillsCleanup', 'localCommandsViaRules', 'localInstallDeferred', 'localInstallStyle', 'localTargetIsProjectRoot', 'managedHookEvents', 'mcpCompanion', 'namedSubagentsSupported', 'nativeModelAliases', 'nativePlugin', 'noPathRewrite', 'ownsClaudePaths', 'permissionsSchema', 'pluginOnlyInstall', 'projectInstructionFile', 'reapplyCommand', 'reportCommandsDir', 'reportSkillsCount', 'retiredArtifacts', 'settingsFileByScope', 'sharedHooksDirName', 'skillFrontmatterVersion', 'skillPriorityFrontmatter', 'skillsGlobalOnboarding', 'skillsManifestPrefix', 'skipCodexSkillsManifest', 'skipHomePrefixSubstitution', 'skipSettingsUi', 'skipSharedHooksInstall', 'skipUpdateBannerCommand', 'soloStageMetadata', 'sourceMarkerFile', 'tomlConfigInstall', 'trackCategoryDescription', 'verificationStyle', 'writeCategoryDescription', ]); /** * Frozen reason codes for the non-fatal reviewer diagnostics (ADR-2782 D4.3). * * The IR behind `collectReviewerWarnings`' rendered strings. Tests assert on * these codes; the rendered `message` is operator console output and tests must * not depend on it (CONTRIBUTING.md, "Prohibited: Raw Text Matching on Test * Outputs"), which is the same split `bin/verify-reapply-patches.cjs` uses. * * Adding a code is THREE coordinated changes: this enum, the emitting site in * `collectReviewerWarningRecordFields`, and the test that locks * `Object.keys(REVIEWER_WARNING).sort()`. That coupling is the point — it stops * the code surface drifting from the test surface. */ const REVIEWER_WARNING = Object.freeze({ /** A key inside a `reviewer` body that this GSD version does not know. */ UNKNOWN_REVIEWER_FIELD: 'unknown_reviewer_field', /** A `runtime.hostBehaviors` key that was removed from the vocabulary. */ REMOVED_HOST_BEHAVIOR: 'removed_host_behavior', /** A `runtime.hostBehaviors` key outside the closed vocabulary. */ UNKNOWN_HOST_BEHAVIOR: 'unknown_host_behavior', }); /** Dotted path of the field removed by ADR-2782 D9 / #2801. */ const REMOVED_REVIEWER_CLI_FIELD = 'runtime.hostBehaviors.reviewerCli'; const KNOWN_PROBE_FIELDS = new Set(['kind', 'binary', 'needle', 'timeoutMs', 'hostConfigKey', 'path']); /** A bounded probe timeout must be a finite, positive INTEGER of milliseconds. */ function isPositiveIntegerMs(v) { return typeof v === 'number' && Number.isInteger(v) && v > 0; } /** * Render any value for an error message WITHOUT ever throwing. * * `JSON.stringify` throws on a BigInt and on a circular structure, and a value * carrying a throwing `toJSON` propagates that throw. Interpolating a rejected * value into its own rejection message must never itself become the failure — * these validators are contracted to RETURN errors, and #1461 OVL-1 records a * validator that threw and would have crashed every consumer of loadRegistry. * * @param {*} v * @returns {string} */ function describeValue(v) { if (typeof v === 'bigint') return String(v) + 'n'; if (typeof v === 'symbol') return String(v); if (typeof v === 'function') return '[function]'; try { const json = JSON.stringify(v); // stringify returns undefined for undefined and for non-serializable roots. return json === undefined ? String(v) : json; } catch { // Circular structure, a nested BigInt, or a throwing toJSON. try { return Object.prototype.toString.call(v); } catch { return '[unserializable]'; } } } /** * Ceiling on how many undeclared-key diagnostics one capability may produce. * * The loops below iterate MANIFEST-SUPPLIED keys, and an installed third-party * manifest is bounded only by MANIFEST_MAX_BYTES (8MB). Unbounded, one manifest * of 800k keys yields 800k records and ~139MB of message text, retained for the * registry's lifetime in OverlayMeta.diagnostics. Ten is enough to act on; the * rest are summarized. Mirrors the existing truncation idiom in * `capability-loader.cts` (`crossErrs.slice(0, 3)`). */ const MAX_REPORTED_UNKNOWN_KEYS = 10; /** Ceiling on how much of one manifest-supplied key name a diagnostic repeats. */ const MAX_REPORTED_KEY_CHARS = 80; /** * Render a manifest-supplied KEY for a diagnostic: control-safe and bounded. * * Key names carry no grammar anywhere — unlike `cap.id`, which `validateCapability` * gates on KEBAB_RE before these diagnostics run — so a key is fully * attacker-controlled text heading for stderr and OverlayMeta.warnings. C0/C1 * controls (ESC, CR, LF) become U+FFFD so a key cannot emit terminal escapes or * forge a log line, and the result is clipped so one key cannot carry megabytes * into a retained diagnostic. * * @param {*} key * @returns {string} */ function describeKey(key) { const raw = typeof key === 'string' ? key : String(key); // eslint-disable-next-line no-control-regex const safe = raw.replace(/[\x00-\x1f\x7f-\x9f]/g, '�'); return safe.length > MAX_REPORTED_KEY_CHARS ? safe.slice(0, MAX_REPORTED_KEY_CHARS) + '…' : safe; } /** Extract a message from an unknown thrown value without throwing again. */ function safeErrorMessage(err) { try { if (err instanceof Error && typeof err.message === 'string') return err.message; return describeValue(err); } catch { return '[unprintable error]'; } } /** House CodeQL barrier — inline literal guard at every key-derived read/write site. */ function isReservedName(v) { return v === '__proto__' || v === 'constructor' || v === 'prototype'; } /** * One closed-enum membership check, with the members always enumerated in the * error. * * The enumeration is the point, not the deduplication. The prose reference for * the reviewer body lands in Phase 6 (#2800), so until then these errors are the * only documentation of the vocabulary — exactly the gap that left * `hostBehaviors` discoverable solely by grepping a source line. Routing every * enum through one helper makes "the error names the valid members" structural * rather than a convention repeated at nine call sites, where it would drift. * * No reserved-name pre-check: a `VALID_*` set never contains `__proto__`, * `constructor` or `prototype`, so membership alone already rejects them, and * "must be one of: …" tells an author more than "is a reserved name". The * literal guards stay where they do real work — the key-derived write sites. * * @param {string} ctx Error-message prefix. * @param {string} label Dotted field path, e.g. "reviewer.transport". * @param {*} value The declared value. * @param {Set} validSet The closed vocabulary. * @returns {string[]} */ function validateEnumField(ctx, label, value, validSet) { if (validSet.has(value)) return []; return [ ctx + ' ' + label + ' must be one of: ' + [...validSet].join(', ') + ' (got: ' + describeValue(value) + ')', ]; } /** * Collect NON-FATAL reviewer diagnostics for a capability manifest as TYPED * RECORDS (ADR-2782 D4.3, plus the D9 `hostBehaviors.reviewerCli` removal * notice, #2801). * * This is the IR. `collectReviewerWarnings` below renders it to strings for the * two production consumers; tests assert on these records instead of matching * the rendered prose (CONTRIBUTING.md, "Prohibited: Raw Text Matching on Test * Outputs"). * * Record shape — `code` and `capId` are always present; the rest is per-code: * { code: REVIEWER_WARNING.UNKNOWN_REVIEWER_FIELD, * capId, field: 'reviewer.', knownFields: string[], message } * { code: REVIEWER_WARNING.REMOVED_HOST_BEHAVIOR, * capId, field: 'runtime.hostBehaviors.reviewerCli', * replacement: 'reviewer', docs: '', message } * * TOTAL: returns an array for ANY input and never throws. It runs on * loadRegistry's ACCEPT path, so a diagnostic that throws would cost the user a * lane that is otherwise perfectly valid (#1461 OVL-1). * * @param {object} cap A capability manifest. * @returns {Array} Warning records; empty when there is nothing to say. */ function collectReviewerWarningRecords(cap) { try { return collectReviewerWarningRecordFields(cap); } catch { return []; } } /** * Render the IR for operator console output. * * Kept separate from validateReviewerBody so validateCapability's contract * (`=> string[]` of ERRORS) is unchanged for its two existing callers. The * build-time generator writes these to stderr; the overlay loader surfaces them * through OverlayMeta.warnings. A warning written only to a build log nobody * reads is not a warning (ADR-2782 D4, "Where warnings surface"). * * The `string[]` shape is load-bearing for both consumers and does not change. * * @param {object} cap A capability manifest. * @returns {string[]} Warning strings; empty when there is nothing to say. */ function collectReviewerWarnings(cap) { return collectReviewerWarningRecords(cap).map((record) => record.message); } function collectReviewerWarningRecordFields(cap) { const records = []; if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) return records; const capId = typeof cap.id === 'string' ? cap.id : '(unknown)'; // ADR-2782 D9 / #2801 — the REMOVED `runtime.hostBehaviors.reviewerCli` alias. // // Emitted BEFORE the `reviewer`-body early-return below, deliberately. The // manifest this notice exists for is the ALIAS-ONLY one, which by definition // carries no body; after that guard the check would fire only for // capabilities that already declare a lane — exactly the set that does not // need telling. // // Presence-based, not `=== true`: the key is unknown at ANY value now, which // is the same rule the unknown-`reviewer.*`-field loop below applies. Own-key // read, so a polluted prototype cannot manufacture this warning on every // otherwise-innocent manifest. This is ONE keyed removal notice, not general // `hostBehaviors` validation — the closed vocabulary below handles that (#2801). const runtimeBody = cap.runtime; if (typeof runtimeBody === 'object' && runtimeBody !== null && !Array.isArray(runtimeBody)) { const hostBehaviors = runtimeBody.hostBehaviors; if ( typeof hostBehaviors === 'object' && hostBehaviors !== null && !Array.isArray(hostBehaviors) && Object.prototype.hasOwnProperty.call(hostBehaviors, 'reviewerCli') ) { records.push({ code: REVIEWER_WARNING.REMOVED_HOST_BEHAVIOR, capId, field: REMOVED_REVIEWER_CLI_FIELD, replacement: 'reviewer', docs: 'docs/how-to/ship-a-reviewer-lane.md', message: '⚠ capability "' + capId + '" ' + REMOVED_REVIEWER_CLI_FIELD + ' was removed (ADR-2782 D9) ' + '— ignored, and it contributes no reviewer lane. Declare a `reviewer` body instead; see ' + 'docs/how-to/ship-a-reviewer-lane.md', }); } // #2801 — the closed `hostBehaviors` vocabulary (ADR-1016). `reviewerCli` is // excluded here: it already drew its own removal notice above, and a second, // generic "unknown key" record for the same key would be noise, not signal. if (typeof hostBehaviors === 'object' && hostBehaviors !== null && !Array.isArray(hostBehaviors)) { let reported = 0; let omitted = 0; for (const key of Object.keys(hostBehaviors)) { if (isReservedName(key) || key === 'reviewerCli' || KNOWN_HOST_BEHAVIORS.has(key)) continue; if (reported >= MAX_REPORTED_UNKNOWN_KEYS) { omitted += 1; continue; } reported += 1; const safeKey = describeKey(key); records.push({ code: REVIEWER_WARNING.UNKNOWN_HOST_BEHAVIOR, capId, field: 'runtime.hostBehaviors.' + safeKey, message: '⚠ capability "' + capId + '" runtime.hostBehaviors.' + safeKey + ' is not a known host behavior ' + 'in this GSD version — ignored. Adding one is a reviewed first-party change (ADR-1016).', }); } if (omitted > 0) { records.push({ code: REVIEWER_WARNING.UNKNOWN_HOST_BEHAVIOR, capId, field: 'runtime.hostBehaviors', truncated: true, omittedCount: omitted, message: '⚠ capability "' + capId + '" declares ' + omitted + ' further unknown runtime.hostBehaviors ' + 'key(s), not listed. A manifest this far outside the vocabulary is likely built for a ' + 'different GSD version.', }); } } } const r = cap.reviewer; if (typeof r !== 'object' || r === null || Array.isArray(r)) return records; // Same ceiling and the same key sanitization as the hostBehaviors sweep above. // This loop predates #2801 and carried both defects; fixing only the new copy // would leave the identical defect one screen away from its own fix. let reportedFields = 0; let omittedFields = 0; for (const key of Object.keys(r)) { if (isReservedName(key) || KNOWN_REVIEWER_FIELDS.has(key)) continue; if (reportedFields >= MAX_REPORTED_UNKNOWN_KEYS) { omittedFields += 1; continue; } reportedFields += 1; const safeKey = describeKey(key); records.push({ code: REVIEWER_WARNING.UNKNOWN_REVIEWER_FIELD, capId, field: 'reviewer.' + safeKey, knownFields: [...KNOWN_REVIEWER_FIELDS], message: '⚠ capability "' + capId + '" reviewer.' + safeKey + ' is not a known reviewer field ' + 'in this GSD version — ignored. Known fields: ' + [...KNOWN_REVIEWER_FIELDS].join(', '), }); } if (omittedFields > 0) { records.push({ code: REVIEWER_WARNING.UNKNOWN_REVIEWER_FIELD, capId, field: 'reviewer', knownFields: [...KNOWN_REVIEWER_FIELDS], truncated: true, omittedCount: omittedFields, message: '⚠ capability "' + capId + '" declares ' + omittedFields + ' further unknown reviewer field(s), ' + 'not listed.', }); } return records; } /** * Validate a `reviewer` lane body (ADR-2782 D1/D2/D3/D6/D7). * * TOTAL: returns an array of error strings for ANY input and never throws. The * overlay loader contracts every validator to RETURN errors — #1461 OVL-1 * records a validator that THREW and would have crashed every consumer of * loadRegistry. That contract is load-bearing, not stylistic. * * ABSENT-SAFE (D4.1): a capability with no `reviewer` key is simply not a lane. * That is NEVER an error — 39 of 39 shipped capabilities are in this state, and * a validator that errors here breaks the entire registry. `undefined` is the * ONLY permissive case: `null`, `{}`, `[]`, `false` and `0` are all assertions * of a body, and a malformed assertion is an error (Postel's Law with a * boundary — liberal in what a manifest may OMIT, strict in what it ASSERTS). * * Totality is enforced STRUCTURALLY by the wrapper below, not by auditing every * field read. Serialization is made safe via describeValue(), but that alone is * not enough: a value carrying a throwing getter, or a Proxy with a throwing * `get`/`ownKeys` trap, throws on the READ itself, before any message is built. * A caller cannot be asked to re-derive today's reachability analysis — the * contract says "any input", so the guarantee is absolute rather than argued. * * @param {object} cap The parsed capability manifest. * @returns {string[]} Array of error strings; empty = valid. */ function validateReviewerBody(cap) { try { return validateReviewerBodyFields(cap); } catch (err) { // A malformed body must degrade to a validation ERROR, never to a crash of // every consumer of loadRegistry (#1461 OVL-1). return ['capability reviewer body could not be validated: ' + safeErrorMessage(err)]; } } function validateReviewerBodyFields(cap) { const errors = []; if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) return errors; const r = cap.reviewer; if (r === undefined) return errors; // D4.1 — not a lane. Never an error. const ctx = 'capability "' + (typeof cap.id === 'string' ? cap.id : '(unknown)') + '"'; if (typeof r !== 'object' || r === null || Array.isArray(r)) { const got = r === null ? 'null' : Array.isArray(r) ? 'array' : typeof r; errors.push( ctx + ' reviewer must be an object (got: ' + got + '). ' + 'Omit the key entirely to declare no lane — an explicit null is not an omission.', ); return errors; // cannot validate fields of a non-object } // ── slug ─────────────────────────────────────────────────────────────────── // NOT a capability id: ids are kebab, slugs carry the roster's snake forms. if (typeof r.slug !== 'string' || r.slug.length === 0) { errors.push(ctx + ' reviewer.slug must be a non-empty string'); } else if (isReservedName(r.slug)) { errors.push(ctx + ' reviewer.slug "' + r.slug + '" is a reserved name'); } else if (!LANE_SLUG_RE.test(r.slug)) { errors.push( ctx + ' reviewer.slug "' + r.slug + '" must match ' + String(LANE_SLUG_RE) + ' (lower-case; "_" and "-" permitted — a slug is not a capability id and not a flag)', ); } // ── flags ────────────────────────────────────────────────────────────────── if (!Array.isArray(r.flags)) { errors.push(ctx + ' reviewer.flags must be an array of CLI flags'); } else if (r.flags.length === 0) { errors.push( ctx + ' reviewer.flags must declare at least one flag — a lane nobody can name ' + 'cannot be explicitly selected, so ADR-2782 D4\'s explicit-selection rule is unreachable for it', ); } else { const seen = new Set(); for (const flag of r.flags) { if (typeof flag !== 'string' || !LANE_FLAG_RE.test(flag)) { errors.push( ctx + ' reviewer.flags entry ' + describeValue(flag) + ' must match ' + String(LANE_FLAG_RE) + ' (e.g. "--lm-studio")', ); continue; } if (seen.has(flag)) { errors.push(ctx + ' reviewer.flags lists "' + flag + '" more than once'); } seen.add(flag); } } // ── transport (D2) — explicit discriminator, never inferred ──────────────── errors.push(...validateEnumField(ctx, 'reviewer.transport', r.transport, VALID_LANE_TRANSPORTS)); errors.push(...validateLaneProbe(ctx, r.probe)); errors.push(...validateLaneInvoke(ctx, r.transport, r.invoke)); // ── lane scalars ─────────────────────────────────────────────────────────── if (!isPositiveIntegerMs(r.timeoutFloorMs)) { errors.push( ctx + ' reviewer.timeoutFloorMs must be a positive integer of milliseconds ' + '(got: ' + describeValue(r.timeoutFloorMs) + ')', ); } errors.push(...validateEnumField(ctx, 'reviewer.emptyOutput', r.emptyOutput, VALID_EMPTY_OUTPUT)); if (typeof r.reviewsSection !== 'string' || r.reviewsSection.length === 0) { errors.push(ctx + ' reviewer.reviewsSection must be a non-empty string'); } errors.push(...validateEnumField(ctx, 'reviewer.evidenceClass', r.evidenceClass, VALID_EVIDENCE_CLASSES)); if (!Array.isArray(r.requiresBinaries)) { errors.push( ctx + ' reviewer.requiresBinaries must be an array (use [] when the lane needs no ' + 'external tool on PATH). Note the name: the envelope\'s "requires" is capability ids', ); } else { for (const bin of r.requiresBinaries) { if (typeof bin !== 'string' || bin.length === 0) { errors.push(ctx + ' reviewer.requiresBinaries entry ' + describeValue(bin) + ' must be a non-empty string'); } } } // OPTIONAL, and that is required by D4 rather than a convenience: `modelConfigKey` did not exist // before Phase 5b, so demanding it would fail validation on every reviewer manifest authored // against an earlier GSD — exactly the forward/backward-compatibility break D4 rule 2 forbids. // Absent is read as `null` (this lane accepts no model override). `null` is explicit; an empty // string is neither, and is rejected. if (r.modelConfigKey !== undefined && r.modelConfigKey !== null && (typeof r.modelConfigKey !== 'string' || r.modelConfigKey.length === 0)) { errors.push( ctx + ' reviewer.modelConfigKey must be a dotted config key or null ' + '(got: ' + describeValue(r.modelConfigKey) + ')', ); } // `null` is the declared "no per-lane budget"; an empty string is not. if (r.promptBudgetKey !== null && (typeof r.promptBudgetKey !== 'string' || r.promptBudgetKey.length === 0)) { errors.push( ctx + ' reviewer.promptBudgetKey must be a dotted config key or null ' + '(got: ' + describeValue(r.promptBudgetKey) + ')', ); } // ── handler (D6) — closed first-party enum; null is the default ──────────── if (r.handler !== null && !VALID_LANE_HANDLERS.has(r.handler)) { errors.push( ctx + ' reviewer.handler must be null or one of: ' + [...VALID_LANE_HANDLERS].join(', ') + ' (got: ' + describeValue(r.handler) + '). Handlers are first-party module NAMES, ' + 'never paths and never third-party code — a lane needing a shape the vocabulary lacks ' + 'files an issue naming the missing primitive (ADR-2782 D6)', ); } return errors; } /** * ADR-2782 D7 — probe.kind is a closed enum WIDER than existence, and every * probe that starts a process or a connection MUST be bounded. * * `command-exists` alone is structurally insufficient: `kimi` is claimed by both * Kimi Code CLI and the legacy Python kimi-cli (a separate first-party runtime * capability in this repo), so an existence-only probe registers the wrong tool. * The unbounded form of that probe was a live instance of this repo's named * Unbounded Subprocesses defect — it ran on EVERY /gsd:review invocation * regardless of which flags were passed, so a binary waiting on a first-run auth * prompt hung every future review, including reviews that never asked for it. * * @param {string} ctx Error-message prefix. * @param {*} probe The probe value. * @returns {string[]} */ function validateLaneProbe(ctx, probe) { const errors = []; if (typeof probe !== 'object' || probe === null || Array.isArray(probe)) { errors.push(ctx + ' reviewer.probe must be an object with a "kind" from: ' + [...VALID_LANE_PROBE_KINDS].join(', ')); return errors; } const kindErrors = validateEnumField(ctx, 'reviewer.probe.kind', probe.kind, VALID_LANE_PROBE_KINDS); if (kindErrors.length > 0) { errors.push(...kindErrors); return errors; // sub-shape is meaningless without a known kind } for (const key of Object.keys(probe)) { if (!isReservedName(key) && !KNOWN_PROBE_FIELDS.has(key)) { errors.push(ctx + ' reviewer.probe.' + key + ' is not a known probe field'); } } const needsBound = probe.kind === 'command-capability' || probe.kind === 'http-reachable'; if (probe.kind === 'command-exists' || probe.kind === 'command-capability') { if (typeof probe.binary !== 'string' || probe.binary.length === 0) { errors.push(ctx + ' reviewer.probe.binary must be a non-empty string for kind "' + probe.kind + '"'); } } else if (probe.binary !== undefined) { errors.push(ctx + ' reviewer.probe.binary is not permitted for kind "' + probe.kind + '"'); } if (probe.kind === 'command-capability') { if (typeof probe.needle !== 'string' || probe.needle.length === 0) { errors.push(ctx + ' reviewer.probe.needle must be a non-empty string for kind "command-capability"'); } } else if (probe.needle !== undefined) { errors.push(ctx + ' reviewer.probe.needle is not permitted for kind "' + probe.kind + '"'); } if (probe.kind === 'http-reachable') { if (typeof probe.hostConfigKey !== 'string' || probe.hostConfigKey.length === 0) { errors.push(ctx + ' reviewer.probe.hostConfigKey must be a non-empty string for kind "http-reachable"'); } if (typeof probe.path !== 'string' || probe.path.length === 0) { errors.push(ctx + ' reviewer.probe.path must be a non-empty string for kind "http-reachable"'); } } else { if (probe.hostConfigKey !== undefined) { errors.push(ctx + ' reviewer.probe.hostConfigKey is not permitted for kind "' + probe.kind + '"'); } if (probe.path !== undefined) { errors.push(ctx + ' reviewer.probe.path is not permitted for kind "' + probe.kind + '"'); } } if (needsBound) { if (!isPositiveIntegerMs(probe.timeoutMs)) { errors.push( ctx + ' reviewer.probe.timeoutMs must be a positive integer of milliseconds for kind "' + probe.kind + '" — an unbounded probe hangs every /gsd:review invocation ' + '(got: ' + describeValue(probe.timeoutMs) + ')', ); } } else if (probe.timeoutMs !== undefined) { errors.push( ctx + ' reviewer.probe.timeoutMs is not permitted for kind "command-exists" — ' + 'no process is started, so there is nothing to bound', ); } return errors; } /** * ADR-2782 D2 — the invoke sub-shape is selected by `transport`. A manifest * declaring fields from BOTH sub-shapes, or from NEITHER, fails validation: * inference from field presence leaves those two cases carrying undefined * meaning, which is exactly what a closed vocabulary exists to prevent. * * @param {string} ctx Error-message prefix. * @param {*} transport The (already enum-checked) transport value. * @param {*} invoke The invoke value. * @returns {string[]} */ function validateLaneInvoke(ctx, transport, invoke) { const errors = []; if (typeof invoke !== 'object' || invoke === null || Array.isArray(invoke)) { errors.push(ctx + ' reviewer.invoke must be an object'); return errors; } const hasSpawnField = SPAWN_ONLY_INVOKE_FIELDS.some((f) => invoke[f] !== undefined); const hasHttpField = HTTP_ONLY_INVOKE_FIELDS.some((f) => invoke[f] !== undefined); if (hasSpawnField && hasHttpField) { errors.push( ctx + ' reviewer.invoke mixes spawn-only fields (' + SPAWN_ONLY_INVOKE_FIELDS.join(', ') + ') with openai-http-only fields (' + HTTP_ONLY_INVOKE_FIELDS.join(', ') + ') — a lane is one transport or the other', ); } if (transport === 'spawn') { for (const f of HTTP_ONLY_INVOKE_FIELDS) { if (invoke[f] !== undefined) { errors.push(ctx + ' reviewer.invoke.' + f + ' is not permitted for transport "spawn"'); } } errors.push(...validateSpawnInvoke(ctx, invoke)); } else if (transport === 'openai-http') { for (const f of SPAWN_ONLY_INVOKE_FIELDS) { if (invoke[f] !== undefined) { errors.push(ctx + ' reviewer.invoke.' + f + ' is not permitted for transport "openai-http"'); } } errors.push(...validateHttpInvoke(ctx, invoke)); } // transport already reported as invalid upstream — do not double-report here. return errors; } function validateSpawnInvoke(ctx, invoke) { const errors = []; if (typeof invoke.binary !== 'string' || invoke.binary.length === 0) { errors.push(ctx + ' reviewer.invoke.binary must be a non-empty string for transport "spawn"'); } if (!Array.isArray(invoke.args)) { errors.push(ctx + ' reviewer.invoke.args must be an array (use [] when the lane takes no arguments)'); } else { for (const a of invoke.args) { if (typeof a !== 'string') { errors.push(ctx + ' reviewer.invoke.args entry ' + describeValue(a) + ' must be a string'); } } } errors.push(...validateEnumField(ctx, 'reviewer.invoke.promptChannel', invoke.promptChannel, VALID_PROMPT_CHANNELS)); const outputChannelErrors = validateEnumField(ctx, 'reviewer.invoke.outputChannel', invoke.outputChannel, VALID_OUTPUT_CHANNELS); if (outputChannelErrors.length > 0) { errors.push(...outputChannelErrors); } else if (invoke.outputChannel === 'file-arg') { // Knowing the review lands in a file is useless without the argument naming it. if (typeof invoke.outputArg !== 'string' || invoke.outputArg.length === 0) { errors.push( ctx + ' reviewer.invoke.outputArg is required (non-empty string) when outputChannel is "file-arg"', ); } } else if (invoke.outputArg !== undefined) { // Forbidden rather than ignored: a manifest carrying an outputArg it does not // use is data a later reader may honour. errors.push( ctx + ' reviewer.invoke.outputArg is only permitted when outputChannel is "file-arg" ' + '(got outputChannel: ' + describeValue(invoke.outputChannel) + ')', ); } // `null` declares "this lane accepts no model override". An empty string does not. if (invoke.modelArg !== null && (typeof invoke.modelArg !== 'string' || invoke.modelArg.length === 0)) { errors.push( ctx + ' reviewer.invoke.modelArg must be a non-empty string or null ' + '(got: ' + describeValue(invoke.modelArg) + ')', ); } errors.push(...validateEnumField(ctx, 'reviewer.invoke.effortChannel', invoke.effortChannel, VALID_LANE_EFFORT_CHANNELS)); // `env` — per-invocation environment pairs (#2483). OPTIONAL, unlike every field above: only a lane // that needs to shape its child's environment declares it, and absent is the common case. Validated // when present, because `resolveLanePlan` DROPS a non-string value rather than coercing it — so an // unvalidated manifest declares a pair that silently never reaches the spawn, which is the failure // mode a shipped env-guard can least afford. Keys are held to the portable POSIX // environment-name grammar, which is a POLICY — not a claim about what an environment can physically // hold. Measured: of the names refused below only NUL is actually rejected by `spawnSync`; `=`, a // leading digit, a dash and a space are all carried through to the child (an `A=B` key arrives as // the raw entry `A=B=value`, and reads back via `process.env['A=B']`). They are refused because a // name outside the grammar is not portably addressable by the program meant to read it. if (invoke.env !== undefined) { if (typeof invoke.env !== 'object' || invoke.env === null || Array.isArray(invoke.env)) { errors.push( ctx + ' reviewer.invoke.env must be an object of environment name/value pairs ' + '(got: ' + describeValue(invoke.env) + ')', ); } else { for (const key of Object.keys(invoke.env)) { // `__proto__` passes the grammar below and IS a real own key once a manifest is JSON-parsed, // but assigning it onto a plain accumulator goes through the inherited `__proto__` SETTER // instead of creating an own property. For the string values this field permits the setter is // a no-op — the prototype is not even changed — so the pair would validate and then simply // vanish before the spawn: the declared-but-never-delivered failure this block exists to catch. // Refused by name, because the grammar cannot see it. // // Only `__proto__` needs this. Sibling reserved-name guards in this file reject // `constructor`/`prototype` alongside it, but those guard bracket LOOKUPS that resolve // prototype members; here the read is `Object.keys` + an own-value read, and `constructor` // assigns as an ordinary own key. Refusing it too would reject a name the spawn could carry. // CASE-INSENSITIVE, and that is not pedantry: Windows environment lookups are // case-insensitive, so `Path` / `node_options` reach the child as `PATH` / `NODE_OPTIONS` // and an exact-case set is bypassed by changing one letter. The grammar above already // constrains keys to ASCII, so a plain uppercase fold is sufficient here. if (DENIED_LANE_ENV_KEYS.has(key.toUpperCase())) { errors.push( ctx + ' reviewer.invoke.env key "' + key + '" is not permitted ' + '(it makes the spawned reviewer run code of the manifest\'s choosing; ' + 'declare an absolute `invoke.binary` instead of reshaping the child\'s environment)', ); } else if (key === '__proto__') { errors.push( ctx + ' reviewer.invoke.env key "__proto__" is not permitted ' + '(it is silently dropped when the spawn plan is assembled, so it would never reach the child)', ); } else if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { errors.push( ctx + ' reviewer.invoke.env key ' + describeValue(key) + ' is not a valid environment variable name', ); } if (typeof invoke.env[key] !== 'string') { errors.push( ctx + ' reviewer.invoke.env.' + key + ' must be a string ' + '(got: ' + describeValue(invoke.env[key]) + ')', ); } } } } return errors; } function validateHttpInvoke(ctx, invoke) { const errors = []; if (typeof invoke.hostConfigKey !== 'string' || invoke.hostConfigKey.length === 0) { errors.push( ctx + ' reviewer.invoke.hostConfigKey must be a non-empty dotted config key ' + 'for transport "openai-http" (it names the config key holding the base URL)', ); } if (typeof invoke.path !== 'string' || invoke.path.length === 0) { errors.push(ctx + ' reviewer.invoke.path must be a non-empty string for transport "openai-http" (e.g. "/v1/chat/completions")'); } errors.push(...validateEnumField(ctx, 'reviewer.invoke.modelDiscovery', invoke.modelDiscovery, VALID_MODEL_DISCOVERY)); // D2 fixes effortChannel to 'none' for this transport — an HTTP lane has no // argv to carry an effort flag and no env of its own. if (invoke.effortChannel !== 'none') { errors.push( ctx + ' reviewer.invoke.effortChannel must be "none" for transport "openai-http" ' + '(got: ' + describeValue(invoke.effortChannel) + ')', ); } return errors; } // #1459 CONVERGENCE finding 1(b) — GENEROUS DoS backstop on a (possibly project-plantable) hook // fragment file. A real fragment is a few KiB of markdown; 8 MiB is wildly more than any legitimate // fragment. The bounded reader refuses a non-regular (FIFO/device/symlink-to-nonregular) or oversized // fragment WITHOUT a raw blocking read, so a forged in-bundle FIFO/oversized fragment.path becomes an // un-materializable fragment (a validation error / skip) instead of hanging or OOM-ing the loop. const FRAGMENT_MAX_BYTES = 8 * 1024 * 1024; function materializeHookFragments(cap, capDir) { const errors = []; const hookGroups = [ ['steps', Array.isArray(cap.steps) ? cap.steps : []], ['contributions', Array.isArray(cap.contributions) ? cap.contributions : []], ]; // #1459 CONVERGENCE finding 1(b): the fragment body is read via the SHARED bounded fd reader (open → // fstat → require regular file → size cap → read exactly size), NOT a raw fs.readFileSync(abs,'utf8') // which BLOCKS forever on a forged in-bundle FIFO and reads an oversized fragment unbounded into memory. // Required lazily so the committed plain-.cjs validator does not hard-depend on the built ledger artifact // at module-load time (materialize is a runtime path, reached only after build:lib). A bounded-reader // throw (non-regular/oversized/IO) → an un-materializable-fragment validation error, not a hang. let readSmallRegularFile; try { ({ readSmallRegularFile } = require('./capability-ledger.cjs')); } catch { // Defensive: if the bounded reader is unavailable, fall back to a fail-CLOSED stub so we never // silently revert to an unbounded raw read. A null-returning stub turns every path fragment into an // "could not be read" error rather than a hang (declarative-only fragments use `inline` and skip this). readSmallRegularFile = () => null; } for (const [groupName, hooks] of hookGroups) { for (let i = 0; i < hooks.length; i++) { const hook = hooks[i]; if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue; const fragment = hook.fragment; if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue; if (typeof fragment.inline === 'string') continue; if (typeof fragment.path !== 'string') continue; const abs = path.resolve(capDir, fragment.path); const capRoot = path.resolve(capDir); if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) { errors.push( cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' + fragment.path, ); continue; } try { const body = readSmallRegularFile(abs, FRAGMENT_MAX_BYTES); if (body === null) { // null = genuinely missing (ENOENT) OR refused as non-regular/oversized via the stub fallback. errors.push( cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read (missing, non-regular ' + '(FIFO/device), or exceeds the size cap): ' + fragment.path, ); continue; } fragment.inline = body; } catch (err) { // Bounded-reader fail-closed throw (non-regular/oversized/IO) — an un-materializable fragment. errors.push( cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' + fragment.path + ' (' + err.message + ')', ); } } } return errors; } function validateFragment(fragment, prefix) { const errors = []; if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) { errors.push(prefix + ' must be an object with path or inline key'); return errors; } const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path'); const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline'); if (!hasPath && !hasInline) { errors.push(prefix + ' must have a "path" or "inline" key'); } if (hasInline) { const inline = fragment.inline; if (typeof inline !== 'string') { errors.push(prefix + '.inline must be a string'); } else if (inline === '') { errors.push(prefix + '.inline must be a non-empty string'); } } // S1: fragment.path traversal guard — must be a relative path with no ".." segments if (hasPath) { const p = fragment.path; if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) { errors.push(prefix + '.path must be a relative path with no ".." segments'); } } return errors; } /** * Validate a single step entry. * * @param {object} step The step to validate. * @param {string} prefix Path prefix for error messages (e.g. "steps[0]"). * @param {Set|null} declaredSkills Set of skill stems declared in this capability's skills array, * or null if the skills array was not valid (skip membership check). * @param {Set|null} declaredAgents Set of agent names declared in this capability's agents array, * or null if the agents array was not valid (skip membership check). * @returns {string[]} */ function validateStep(step, prefix, declaredSkills, declaredAgents) { const errors = []; if (!VALID_LOOP_POINTS.has(step.point)) { errors.push(prefix + '.point "' + step.point + '" is not a valid loop point'); } if (typeof step.ref !== 'object' || step.ref === null) { errors.push(prefix + '.ref must be an object with skill, agent, or command key'); } else { const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill'); const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent'); const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command'); const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length; if (dispatchCount === 0) { errors.push(prefix + '.ref must have a "skill", "agent", or "command" key'); } else if (dispatchCount > 1) { // ref must be exclusive: skill XOR agent XOR command errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple'); } if (hasSkill && typeof step.ref.skill !== 'string') { errors.push(prefix + '.ref.skill must be a string'); } else if (hasSkill && typeof step.ref.skill === 'string' && step.ref.skill.startsWith('gsd-')) { // Double-prefix guard: ref.skill is an unprefixed stem (e.g. "ui-review"). // Workflow dispatch prepends "gsd-" at runtime → "gsd-ui-review". // A stem that already starts with "gsd-" would produce "gsd-gsd-..." at dispatch. errors.push( prefix + '.ref.skill "' + step.ref.skill + '" must not start with "gsd-" ' + '(it is an unprefixed stem; the workflow prepends "gsd-" at dispatch — ' + 'starting with "gsd-" would produce "gsd-' + step.ref.skill + '")', ); } else if (hasSkill && typeof step.ref.skill === 'string' && declaredSkills !== null && !declaredSkills.has(step.ref.skill)) { // Membership check: ref.skill must be declared in this capability's skills array. // This catches typos and ensures every dispatched skill is owned by this capability. errors.push( prefix + '.ref.skill "' + step.ref.skill + '" is not declared in this capability\'s skills: [' + [...declaredSkills].join(', ') + ']', ); } if (hasAgent && typeof step.ref.agent !== 'string') { errors.push(prefix + '.ref.agent must be a string'); } else if (hasAgent && typeof step.ref.agent === 'string' && declaredAgents !== null && !declaredAgents.has(step.ref.agent)) { // Membership check: ref.agent must be declared in this capability's agents array. errors.push( prefix + '.ref.agent "' + step.ref.agent + '" is not declared in this capability\'s agents: [' + [...declaredAgents].join(', ') + ']', ); } if (hasCommand && typeof step.ref.command !== 'string') { errors.push(prefix + '.ref.command must be a string'); } } if (!Array.isArray(step.produces)) { errors.push(prefix + '.produces must be an array'); } else { for (const p of step.produces) { if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); } } if (!Array.isArray(step.consumes)) { errors.push(prefix + '.consumes must be an array'); } else { for (const c of step.consumes) { if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); } } if (step.when !== undefined && typeof step.when !== 'string') { errors.push(prefix + '.when must be a string if present'); } if (step.fragment !== undefined) { errors.push(...validateFragment(step.fragment, prefix + '.fragment')); } if (!VALID_ON_ERROR.has(step.onError)) { errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')'); } return errors; } function validateContribution(contrib, prefix) { const errors = []; if (!VALID_LOOP_POINTS.has(contrib.point)) { errors.push(prefix + '.point "' + contrib.point + '" is not a valid loop point'); } if (typeof contrib.into !== 'string') { errors.push(prefix + '.into must be a string (agent role name)'); } if (!Array.isArray(contrib.produces)) { errors.push(prefix + '.produces must be an array'); } else { for (const p of contrib.produces) { if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); } } if (!Array.isArray(contrib.consumes)) { errors.push(prefix + '.consumes must be an array'); } else { for (const c of contrib.consumes) { if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); } } errors.push(...validateFragment(contrib.fragment, prefix + '.fragment')); if (contrib.when !== undefined && typeof contrib.when !== 'string') { errors.push(prefix + '.when must be a string if present'); } if (contrib.onError !== undefined && !VALID_ON_ERROR.has(contrib.onError)) { errors.push(prefix + '.onError must be "skip" or "halt" if present'); } return errors; } function validateGate(gate, prefix) { const errors = []; if (!VALID_LOOP_POINTS.has(gate.point)) { errors.push(prefix + '.point "' + gate.point + '" is not a valid loop point'); } if (typeof gate.check !== 'object' || gate.check === null) { errors.push(prefix + '.check must be an object'); } else { const hasQuery = Object.prototype.hasOwnProperty.call(gate.check, 'query'); const hasPredicate = Object.prototype.hasOwnProperty.call(gate.check, 'predicate'); const hasAgentVerdict = Object.prototype.hasOwnProperty.call(gate.check, 'agentVerdict'); const count = [hasQuery, hasPredicate, hasAgentVerdict].filter(Boolean).length; if (count !== 1) { errors.push(prefix + '.check must have exactly one of: query, predicate, agentVerdict'); } // agentVerdict forces blocking: false (advisory only) if (hasAgentVerdict && gate.blocking === true) { errors.push( prefix + '.check.agentVerdict forces blocking: false (non-deterministic checks may not halt the loop)', ); } } if (gate.when !== undefined && typeof gate.when !== 'string') { errors.push(prefix + '.when must be a string if present'); } if (typeof gate.blocking !== 'boolean') { errors.push(prefix + '.blocking must be a boolean'); } if (!VALID_ON_ERROR.has(gate.onError)) { errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + gate.onError + ')'); } return errors; } // ─── Contract validation ────────────────────────────────────────────────────── /** * Validate per-capability contract constraints against the Loop Host Contract. * This covers: * - contribution.into ∈ step's agentRoles * - when references a config key in cap.config * * NOTE: step.consumes satisfiability is NOT checked here — it requires the full * set of validated capabilities (cross-capability produces). It runs in * validateConsumesGlobal() after loadAndValidate builds capMap. * * @param {object} cap Validated capability object * @param {string} capId Capability id (for error messages) */ function validateAgainstContract(cap, capId) { if (cap.role !== 'feature') return []; const errors = []; const prefix = 'capability "' + capId + '"'; // contribution.into must be in the step's agentRoles for (const contrib of cap.contributions) { if (!VALID_LOOP_POINTS.has(contrib.point)) continue; // already reported const contract = POINT_TO_CONTRACT.get(contrib.point); if (contract && !contract.agentRoles.includes(contrib.into)) { errors.push( prefix + ' contribution.into "' + contrib.into + '" at point "' + contrib.point + '" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') + ']', ); } } // when references a plausibly-valid config key (string — we require it's in cap.config) for (const step of cap.steps) { if (step.when !== undefined) { if (typeof step.when !== 'string') continue; // already reported above if ( typeof cap.config === 'object' && cap.config !== null && !Object.prototype.hasOwnProperty.call(cap.config, step.when) ) { errors.push( prefix + ' step.when "' + step.when + '" is not defined in capability config keys', ); } } } for (const contrib of cap.contributions) { if (contrib.when !== undefined) { if (typeof contrib.when !== 'string') continue; if ( typeof cap.config === 'object' && cap.config !== null && !Object.prototype.hasOwnProperty.call(cap.config, contrib.when) ) { errors.push( prefix + ' contribution.when "' + contrib.when + '" is not defined in capability config keys', ); } } } for (const gate of cap.gates) { if (gate.when !== undefined) { if (typeof gate.when !== 'string') continue; if ( typeof cap.config === 'object' && cap.config !== null && !Object.prototype.hasOwnProperty.call(cap.config, gate.when) ) { errors.push( prefix + ' gate.when "' + gate.when + '" is not defined in capability config keys', ); } } } return errors; } /** * C1+C2: Global consumes-satisfiability validation. * * A hook at point P consuming artifact A is satisfiable iff: * - A is a host-produced artifact available from its step's :post point (C1), and * that :post point's POINT_ORDER index ≤ P's index; OR * - A is produced by any capability hook step at a point whose POINT_ORDER index ≤ P's index * (same-point is OK — topoSortSteps enforces intra-point order); OR * - A is never produced anywhere → rejected. * * Runs after capMap is fully built so cross-capability produces are visible. * * @param {Map} capMap Fully-validated capability map. * @returns {string[]} Array of error strings. */ function validateConsumesGlobal(capMap) { const errors = []; // Build producedAtPoint: artifact → earliest POINT_ORDER index at which it is produced. // Seed with host artifacts (C1: available from their step's :post point). // Host-artifact entries are tagged {pointIdx, isHost:true} so they are never excluded by // the self-consume check. const producedAtPoint = Object.create(null); for (const [artifact, postIdx] of Object.entries(HOST_ARTIFACT_EARLIEST_POINT_IDX)) { if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; producedAtPoint[artifact] = postIdx; } // Build a richer per-artifact producer list for the self-consume check. // Each entry: { pointIdx, capId, stepIdx } — identifies which cap+step produced the artifact. // Host artifacts are seeded separately (no capId) and always satisfy the consume check. // capHookProducers[artifact] = [{pointIdx, capId, stepIdx}, ...] const capHookProducers = Object.create(null); // Add hook-produced artifacts from all capabilities. for (const [capId, cap] of capMap) { if (cap.role !== 'feature') continue; for (let si = 0; si < (cap.steps || []).length; si++) { const step = cap.steps[si]; if (!VALID_LOOP_POINTS.has(step.point)) continue; const pointIdx = POINT_ORDER.indexOf(step.point); for (const artifact of (step.produces || [])) { if (typeof artifact !== 'string') continue; if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; if (producedAtPoint[artifact] === undefined || pointIdx < producedAtPoint[artifact]) { producedAtPoint[artifact] = pointIdx; } if (!capHookProducers[artifact]) capHookProducers[artifact] = []; capHookProducers[artifact].push({ pointIdx, capId, stepIdx: si }); } } } // Duplicate-producer invariant: two capability steps may not produce the same artifact // at the same Loop Extension Point. Same-point dual production makes data-flow resolution // ambiguous and is rejected at gen time (Decision #6). for (const artifact of Object.keys(capHookProducers)) { if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; const producers = capHookProducers[artifact]; // Group by pointIdx const byPoint = Object.create(null); for (const entry of producers) { if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = []; byPoint[entry.pointIdx].push(entry); } for (const pointIdxStr of Object.keys(byPoint)) { const group = byPoint[pointIdxStr]; // Count distinct (capId, stepIdx) producer steps — a single step listing the same // artifact twice in its produces array pushes duplicate entries but represents only // ONE producer step and must not false-positive the cross-step gate. const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx)); if (distinctProducers.size >= 2) { const pointIdx = Number(pointIdxStr); const pointName = POINT_ORDER[pointIdx]; const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', '); throw new Error( 'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' + 'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' + '(capabilities: ' + capIds + '). ' + 'Two capability steps producing the same artifact at the same Loop Extension Point ' + 'makes data-flow resolution ambiguous and is rejected at gen time.', ); } } } // Now check every hook step's consumes. // Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A]. // A is satisfiable for H iff: // (a) A is a host artifact with pointIdx <= stepPointIdx, OR // (b) A is produced by a DIFFERENT cap/step at pointIdx <= stepPointIdx. // "Different" means capId != H.capId OR stepIdx != H.stepIdx. for (const [capId, cap] of capMap) { if (cap.role !== 'feature') continue; const prefix = 'capability "' + capId + '"'; for (let si = 0; si < (cap.steps || []).length; si++) { const step = cap.steps[si]; if (!VALID_LOOP_POINTS.has(step.point)) continue; const stepPointIdx = POINT_ORDER.indexOf(step.point); for (const artifact of (step.consumes || [])) { if (typeof artifact !== 'string') continue; // Check host-artifact satisfaction first (never excluded by self-consume). const hostIdx = HOST_ARTIFACT_EARLIEST_POINT_IDX[artifact]; const hostSatisfied = hostIdx !== undefined && hostIdx <= stepPointIdx; if (hostSatisfied) continue; // fast-path: host artifact is available // Check cap-hook producers, excluding this step itself. const producers = capHookProducers[artifact]; if (!producers || producers.length === 0) { // Not a host artifact and never produced by any hook. errors.push( prefix + ' step at point "' + step.point + '" consumes "' + artifact + '" which is never produced by any host artifact or capability hook', ); continue; } // Find any non-self producer at pointIdx <= stepPointIdx. const otherEarliestIdx = producers.reduce((best, p) => { const isSelf = p.capId === capId && p.stepIdx === si; if (isSelf) return best; return (best === undefined || p.pointIdx < best) ? p.pointIdx : best; }, undefined); if (otherEarliestIdx === undefined) { // Only producer is this step itself — self-consume violation. errors.push( prefix + ' step at point "' + step.point + '" consumes "' + artifact + '" which is only produced by this step itself (a step cannot consume its own output)', ); } else if (otherEarliestIdx > stepPointIdx) { errors.push( prefix + ' step at point "' + step.point + '" consumes "' + artifact + '" which is only produced after this point (earliest available at POINT_ORDER index ' + otherEarliestIdx + ' = "' + POINT_ORDER[otherEarliestIdx] + '")', ); } // else: satisfied by another cap/step at an earlier-or-same point — OK. } } } return errors; } // ─── Cross-capability invariants ────────────────────────────────────────────── const TIER_RANK = { core: 0, standard: 1, full: 2 }; /** * Enforce cross-capability invariants. * * @param {Map} capMap id → validated capability object * @param {Set} centralKeys Set of keys in the central config-schema * @returns {string[]} Array of error strings; empty = all pass. */ function validateCrossCapability(capMap, centralKeys, centralPatterns = []) { const errors = []; // Ownership: one owner per skill stem + agent name const skillOwner = new Map(); // skill → capId const agentOwner = new Map(); // agent → capId const familyOwner = new Map(); // command family → capId (ADR-959) for (const [capId, cap] of capMap) { if (cap.role !== 'feature') continue; for (const skill of cap.skills) { if (skillOwner.has(skill)) { errors.push( 'skill "' + skill + '" is owned by both "' + skillOwner.get(skill) + '" and "' + capId + '"', ); } else { skillOwner.set(skill, capId); } } for (const agent of cap.agents) { if (agentOwner.has(agent)) { errors.push( 'agent "' + agent + '" is owned by both "' + agentOwner.get(agent) + '" and "' + capId + '"', ); } else { agentOwner.set(agent, capId); } } // ADR-959: single family ownership across the whole registry if (Array.isArray(cap.commands)) { for (const cmd of cap.commands) { if (typeof cmd.family !== 'string' || cmd.family.length === 0) continue; // already reported if (cmd.family === '__proto__' || cmd.family === 'constructor' || cmd.family === 'prototype') continue; if (familyOwner.has(cmd.family)) { errors.push( 'command family "' + cmd.family + '" is owned by both "' + familyOwner.get(cmd.family) + '" and "' + capId + '"', ); } else { familyOwner.set(cmd.family, capId); } } } } // Config key ownership: exclusive AND absent from central schema. // // ADR-2782 D1/D9: the role filter was `role !== 'feature'`, which silently // exempted every non-feature capability from ownership AND from the // central-schema collision check — the reason reviewer config keys were // stranded centrally. Ownership is a property of DECLARING a config slice, not // of being a feature, so the filter is now purely on the slice's presence. // Verified inert at introduction: no shipped capability declares `config` on a // non-feature role, so this widening changes no existing key — it stops a // latent silent drop and unblocks Phase 4 (#2797). const configKeyOwner = new Map(); // key → capId for (const [capId, cap] of capMap) { if (typeof cap.config !== 'object' || cap.config === null) continue; for (const key of Object.keys(cap.config)) { if (configKeyOwner.has(key)) { errors.push( 'config key "' + key + '" is owned by both "' + configKeyOwner.get(key) + '" and "' + capId + '"', ); } else { configKeyOwner.set(key, capId); } if (centralKeys.has(key)) { errors.push( 'config key "' + key + '" is declared in capability "' + capId + '" AND exists in the central config-schema — migration mid-flight: ' + 'remove from central config-schema before adding to the capability', ); } // #2797: exact-key membership is not the whole central schema. A key may // also be claimed by a central DYNAMIC PATTERN, and until now that // collision was invisible here — `centralKeys` is built from // `manifest.validKeys` alone. // // Why that mattered enough to fix rather than note: `isCentralConfigKey` // DOES consult the patterns, and `mergeFederatedConfig` skips every key // for which it returns true. So a federated slice overlapping a central // pattern is INERT — it carries no traffic — while the build stays green. // Two of the four key families Phase 4 migrates (`review.models.` // and `review.max_prompt_tokens_per_reviewer.`) were pattern-backed, // so the invariant was blind to exactly the migration it exists to police. const collidingPattern = centralPatterns.find((p) => p.test(key)); if (collidingPattern) { errors.push( 'config key "' + key + '" is declared in capability "' + capId + '" AND is matched by central config-schema pattern /' + collidingPattern.source + '/ — the federated slice would be inert (mergeFederatedConfig skips central keys): ' + 'remove the pattern from the central config-schema in the SAME commit', ); } } } // ── Reviewer lane uniqueness (ADR-2782 D8) ───────────────────────────────── // // slug, every flag, and reviewsSection are each unique across the MERGED // first-party ∪ overlay set. reviewsSection uniqueness is not cosmetic: two // lanes sharing a heading silently merge their output in REVIEWS.md, producing // a review that appears to have consensus it does not have. // // This runs in validateCrossCapability rather than in the generator because // BOTH callers reach it: the build-time generator over first-party, and // capability-loader's loadRegistry over `acceptedMap` (first-party ∪ accepted // overlays) per candidate. First-party is already in the map when an overlay // candidate is added, so the OVERLAY is the collider that gets dropped — // which is exactly D8's "first-party wins", with no provenance check here. // // Reviewer INSTANCES (review.reviewer_instances., ADR-1517) resolve // THROUGH a lane and are not lanes; they never enter these sets. const laneSlugClaims = new Map(); // slug → capId[] const laneFlagClaims = new Map(); // flag → capId[] const laneSectionClaims = new Map(); // reviewsSection → capId[] // Claims are ACCUMULATED and reported after the sweep, never reported on the // second claimant. Reporting pairwise-on-collision looks equivalent and is not: // with three lanes on one key it names whichever pair happened to arrive first, // so the message text depends on Map insertion order — which is readdir order // at build time and candidate order at load time. A cross-platform CI lane // would then disagree with a local run about the text of the same failure. // Accumulating makes the output a pure function of the input set for ANY N. const claim = (claims, key, capId) => { if (typeof key !== 'string' || key.length === 0) return; if (isReservedName(key)) return; let claimants = claims.get(key); if (claimants === undefined) { claimants = []; claims.set(key, claimants); } if (!claimants.includes(capId)) claimants.push(capId); }; for (const [capId, cap] of capMap) { const r = cap.reviewer; // A capability with no lane contributes to no uniqueness set. A MALFORMED // body was already reported by validateCapability — do not double-report. if (typeof r !== 'object' || r === null || Array.isArray(r)) continue; claim(laneSlugClaims, r.slug, capId); claim(laneSectionClaims, r.reviewsSection, capId); if (Array.isArray(r.flags)) { // Flattened across arrays: Antigravity answers to --antigravity AND --agy, // so uniqueness is per-flag, not per-lane. for (const flag of r.flags) claim(laneFlagClaims, flag, capId); } } // One error per colliding key naming EVERY claimant, ids sorted; the whole // block is sorted before it is appended, so both the messages and their order // are independent of how the capabilities were enumerated. const laneCollisions = []; for (const [claims, label] of [ [laneSlugClaims, 'slug'], [laneFlagClaims, 'flag'], [laneSectionClaims, 'reviewsSection'], ]) { for (const [key, claimants] of claims) { if (claimants.length < 2) continue; laneCollisions.push( 'reviewer ' + label + ' "' + key + '" is declared by ' + [...claimants].sort().map((i) => '"' + i + '"').join(' and '), ); } } laneCollisions.sort(); errors.push(...laneCollisions); // requires: all ids exist for (const [capId, cap] of capMap) { if (!Array.isArray(cap.requires)) continue; for (const req of cap.requires) { if (!capMap.has(req)) { errors.push( 'capability "' + capId + '" requires "' + req + '" which does not exist', ); } } } // runtimeCompat: explicit runtime ids must reference runtime capabilities. // The wildcard "*" means descriptor-backed runtimes are supported by default. const runtimeIds = new Set(); for (const [id, cap] of capMap) { if (cap.role === 'runtime') runtimeIds.add(id); } for (const [capId, cap] of capMap) { if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue; for (const field of ['supported', 'unsupported']) { const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : []; for (const runtimeId of entries) { if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue; if (!runtimeIds.has(runtimeId)) { errors.push( 'capability "' + capId + '" runtimeCompat.' + field + ' references unknown runtime "' + runtimeId + '"', ); } } } if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') { for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) { if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; if (!runtimeIds.has(runtimeId)) { errors.push( 'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"', ); } } } } // requires: acyclic const cycleErrors = detectRequiresCycles(capMap); errors.push(...cycleErrors); // requires: tier-monotone (core may not require standard/full; standard may not require full) for (const [capId, cap] of capMap) { if (!Array.isArray(cap.requires) || !VALID_TIERS.has(cap.tier)) continue; const myRank = TIER_RANK[cap.tier]; for (const req of cap.requires) { const reqCap = capMap.get(req); if (!reqCap || !VALID_TIERS.has(reqCap.tier)) continue; const reqRank = TIER_RANK[reqCap.tier]; if (reqRank > myRank) { errors.push( 'tier-monotone violation: capability "' + capId + '" (tier: ' + cap.tier + ') requires "' + req + '" (tier: ' + reqCap.tier + ') — a capability may not require a higher-tier capability', ); } } } return errors; } /** * Detect cycles in the requires graph using DFS. */ function detectRequiresCycles(capMap) { const errors = []; const WHITE = 0, GRAY = 1, BLACK = 2; const color = new Map([...capMap.keys()].map((k) => [k, WHITE])); function dfs(id, stack) { if (color.get(id) === GRAY) { const cycleStr = [...stack, id].join(' → '); errors.push('requires cycle detected: ' + cycleStr); return; } if (color.get(id) === BLACK) return; color.set(id, GRAY); stack.push(id); const cap = capMap.get(id); if (cap && Array.isArray(cap.requires)) { for (const req of cap.requires) { if (capMap.has(req)) dfs(req, stack); } } stack.pop(); color.set(id, BLACK); } for (const id of capMap.keys()) { if (color.get(id) === WHITE) dfs(id, []); } return errors; } // ─── requiresClosure ───────────────────────────────────────────────────────── /** * Compute the transitive requires closure for a capability id. * Returns a Set of all transitively required capability ids. * * @param {string} id * @param {Map} capMap */ function computeRequiresClosure(id, capMap) { const visited = new Set(); const queue = [id]; while (queue.length > 0) { const current = queue.shift(); const cap = capMap.get(current); if (!cap || !Array.isArray(cap.requires)) continue; for (const req of cap.requires) { if (!visited.has(req)) { visited.add(req); queue.push(req); } } } return visited; } // ─── Topological ordering ───────────────────────────────────────────────────── function topoSortHookEntries(entries, hookKey, hookKind) { if (entries.length <= 1) return entries; // Build adjacency: entry A must come before entry B if B consumes something A produces const n = entries.length; const inDegree = new Array(n).fill(0); const adj = Array.from({ length: n }, () => []); for (let i = 0; i < n; i++) { const producesI = new Set(entries[i][hookKey].produces || []); for (let j = 0; j < n; j++) { if (i === j) continue; const consumesJ = entries[j][hookKey].consumes || []; for (const artifact of consumesJ) { if (producesI.has(artifact)) { adj[i].push(j); inDegree[j]++; break; } } } } // Kahn's algorithm with stable tiebreak on capId const queue = []; for (let i = 0; i < n; i++) { if (inDegree[i] === 0) queue.push(i); } // Sort queue by capId for determinism queue.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); const result = []; while (queue.length > 0) { // Take the first (sorted) ready node const idx = queue.shift(); result.push(entries[idx]); const newReady = []; for (const neighbor of adj[idx]) { inDegree[neighbor]--; if (inDegree[neighbor] === 0) newReady.push(neighbor); } newReady.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); queue.push(...newReady); } // Fix #2: if result.length < n, Kahn's could not complete — there is a produces/consumes // cycle. Do NOT silently fall back to declaration order; throw a clear error. if (result.length < n) { const sortedIds = entries.map((e) => e.capId).join(', '); throw new Error( 'produces/consumes cycle detected in ' + hookKind + ' at point "' + (entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') + '" among capabilities [' + sortedIds + ']: ' + 'a cycle in hook produces/consumes prevents deterministic ordering', ); } return result; } /** * Topologically sort steps at a given point by produces/consumes. * Capability-id tiebreak for determinism. * * @param {{ capId: string, step: object }[]} entries * @returns {{ capId: string, step: object }[]} */ function topoSortSteps(entries) { return topoSortHookEntries(entries, 'step', 'steps'); } function topoSortContributions(entries) { return topoSortHookEntries(entries, 'contrib', 'contributions'); } // ─── Gen-time wired guard ───────────────────────────────────────────────────── /** * Hook group (capability.json array name) → hook kind (dispatch discriminator). * Single source of truth for both validateHooksWired and the scanner-parity * test in tests/capability-registry.test.cjs (#3606). */ const HOOK_GROUP_KINDS = Object.freeze({ steps: 'step', contributions: 'contribution', gates: 'gate', }); /** * Validate that every hook point declared by a capability has a corresponding * `loop render-hooks ` call site in one of the host-loop workflow files, * AND — #3606 — that the call site's dispatch text covers the hook's KIND. * * A call site proves hooks are rendered, not dispatched: a consumer that * iterates only `kind == "gate"` (or narrows `kind == "step"` to one * `ref.skill`) silently drops every other registered kind — a capability can * be wired, enabled, resolved active, and never run. See * gsd-core/references/loop-hook-dispatch.md ("A point whose workflow * hand-rolls one kind does not implement this contract"). * * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points * are already caught by validateStep/validateContribution/validateGate — do not * double-report. * * KNOWN LIMITATION (#3606): coverage is the UNION across all call sites for a * point in the five STEP_WORKFLOWS host files. Consumers outside that universe * (quick.md, autonomous.md, code-review*.md, audit-milestone.md, * secure-phase.md, validate-phase.md) are not per-file checked — a narrowed * consumer there passes as long as one host file covers the point. Per-file * coverage maps are the tightening path. * * @param {object} cap Validated capability object. * @param {Map>} wiredKinds Per point, the hook kinds the * host workflows' call-site dispatch text covers (getWiredKinds). A point * absent from the map is unwired. * @returns {string[]} Array of error strings; empty means all points * are wired and every registered kind is covered. */ function validateHooksWired(cap, wiredKinds) { const errors = []; const capId = cap.id || '(unknown)'; function checkPoint(point, groupName, kind, idx) { // Only flag valid points that are unwired — invalid points are schema-validator's job. if (!VALID_LOOP_POINTS.has(point)) return; if (!wiredKinds.has(point)) { errors.push( 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + '" is declared but not wired in any host-loop workflow ' + '(no `loop render-hooks ' + point + '` call site). ' + 'Wire the call site in the host workflow ' + '(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.', ); return; } const covered = wiredKinds.get(point); if (covered.size === 0) { errors.push( 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + '" has `loop render-hooks ' + point + '` call site(s), but their dispatch text covers NO ' + 'hook kind — every consumer is narrowed to specific hooks. Dispatch every registered kind ' + 'per gsd-core/references/loop-hook-dispatch.md.', ); return; } if (!covered.has(kind)) { errors.push( 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + '" registers a ' + kind + ' hook, but the host call site\'s dispatch text never ' + 'covers `kind == "' + kind + '"` (it covers: ' + [...covered].sort().join(', ') + '). ' + 'A hand-rolled single-kind consumer silently never dispatches the other kinds — ' + 'dispatch every registered kind per gsd-core/references/loop-hook-dispatch.md.', ); } } for (const [group, kind] of Object.entries(HOOK_GROUP_KINDS)) { for (let i = 0; i < (cap[group] || []).length; i++) { const hook = cap[group][i]; if (hook.point !== undefined) checkPoint(hook.point, group, kind, i); } } return errors; } // ─── classifyCrossErrors ────────────────────────────────────────────────────── /** * Fix #3: Emit pending-migration WARNINGs for config keys that collide with the central * config-schema. Per ADR-894 staged cutover, a collision during the registry-only phase is * NOT a hard error — the capability pipeline is being established before the atomic cutover * PR for each feature. The registry still generates; the warning tells the maintainer which * keys need to be moved out of the central schema at cutover time. * * A NEW unexpected collision (a key that shouldn't be in both) is also surfaced — the * maintainer sees it in build output rather than it being silently swallowed. * * Reference: ADR-894 §4 "config-key ownership exclusive AND complete — presence in both = * collision = a mid-flight migration; finish the move." * * @param {string[]} crossErrors Errors from validateCrossCapability (may include collision msgs) * @returns {{ hardErrors: string[], pendingMigrationWarnings: string[] }} */ function classifyCrossErrors(crossErrors) { const hardErrors = []; const pendingMigrationWarnings = []; const collisionRe = /config key "([^"]+)" is declared in capability "([^"]+)" AND exists in the central config-schema/; for (const e of crossErrors) { const m = collisionRe.exec(e); if (m) { // Collision = pending-migration warning, not a hard error during 3a-impl staged cutover pendingMigrationWarnings.push( '⚠ pending-migration: capability \'' + m[2] + '\' declares config key \'' + m[1] + '\' still present in central config-schema; finish the move at cutover', ); } else { hardErrors.push(e); } } return { hardErrors, pendingMigrationWarnings }; } // ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ───────────── // Map: installSurface → expected configFormat // Derived from the pairing of capability.json descriptors (installSurface) // and capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map // is the single parity contract between the two generated surfaces. // NOTE: both values come from the descriptor bodies in capMap — no dependency on // runtime-config-adapter-registry.cjs, which now requires capability-registry.cjs // (the file this gen-script produces), and thus must not be required here. const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([ ['settings-json', 'settings-json'], ['codex-toml', 'toml'], ['copilot-instructions', 'markdown'], ['cline-rules', 'markdown-dir'], ['cursor-hooks-json', 'none'], ['profile-marker-only', 'none'], // 'none' added #2103 — a runtime with NO CLI install surface at all (e.g. // VS Code) has no config-file format to write either. ['none', 'none'], ]); /** * ADR-857 phase 5e: configFormat ↔ installSurface parity gate. * * For each runtime capability that has an installSurface in its descriptor, * assert that its configFormat matches the expected value derived from its * installSurface. Both values are read directly from the capMap descriptor * bodies — no dependency on runtime-config-adapter-registry.cjs. * * HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is * derived from two parallel generated surfaces and must fail loudly). * * @param {Map} capMap Fully-validated capability map. * @returns {void} Throws on mismatch; returns normally on success. */ function runConfigFormatParityGate(capMap) { // Read installSurface directly from the descriptor bodies already loaded into // capMap — eliminates the require cycle introduced when adapter-registry was // changed to require capability-registry.cjs (ADR-857 phase 5g drive 2). for (const [capId, cap] of capMap) { if (cap.role !== 'runtime') continue; const r = cap.runtime; if (!r || typeof r.configFormat !== 'string') continue; // already validated above // Only check runtimes that have an installSurface (i.e. are config-adapter runtimes) if (typeof r.installSurface !== 'string') continue; // grok etc. excluded — no installSurface const installSurface = r.installSurface; const expectedConfigFormat = INSTALL_SURFACE_TO_CONFIG_FORMAT.get(installSurface); if (expectedConfigFormat === undefined) { // Unknown installSurface — the mapping needs to be updated throw new Error( 'configFormat parity gate: runtime "' + capId + '" has installSurface "' + installSurface + '" which is not in the INSTALL_SURFACE_TO_CONFIG_FORMAT mapping — ' + 'update the mapping in scripts/gen-capability-registry.cjs', ); } if (r.configFormat !== expectedConfigFormat) { throw new Error( 'configFormat parity gate FAILED for runtime "' + capId + '":\n' + ' installSurface: ' + installSurface + '\n' + ' expected configFormat: ' + expectedConfigFormat + '\n' + ' actual configFormat: ' + r.configFormat + '\n' + 'The capability.json configFormat must match the value derived from installSurface ' + '(src: scripts/gen-capability-registry.cjs INSTALL_SURFACE_TO_CONFIG_FORMAT)', ); } } } // ─── Exports ────────────────────────────────────────────────────────────────── module.exports = { // Constants SCHEMA_VERSION, POINT_ORDER, HOST_ARTIFACT_EARLIEST_POINT_IDX, VALID_LOOP_POINTS, POINT_TO_CONTRACT, VALID_CONFIG_SLICE_TYPES, KEBAB_RE, VALID_ROLES, VALID_TIERS, VALID_ON_ERROR, RUNTIME_COMPAT_WILDCARD, SEMVER_RE, SEMVER_RANGE_RE, SHA512_INTEGRITY_RE, VALID_CONVERTER_NAMES, VALID_CONFIG_FORMATS, VALID_CONFIG_HOME_KINDS, VALID_COMMAND_STYLES, VALID_HOOKS_SURFACES, VALID_HOOK_EVENTS, VALID_EXTENSION_EVENTS, VALID_SANDBOX_TIERS, VALID_ARTIFACT_KIND_NAMES, VALID_ARTIFACT_NESTINGS, VALID_TRIGGER_PRECEDENCE_KINDS, DEFAULT_TRIGGER_PRECEDENCE, FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME, // ADR-2782 D1/D2/D3/D6/D7/D8 — reviewer lane body FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER, LANE_SLUG_RE, LANE_FLAG_RE, VALID_LANE_TRANSPORTS, VALID_LANE_PROBE_KINDS, VALID_PROMPT_CHANNELS, VALID_OUTPUT_CHANNELS, VALID_LANE_EFFORT_CHANNELS, VALID_MODEL_DISCOVERY, VALID_EMPTY_OUTPUT, VALID_EVIDENCE_CLASSES, VALID_LANE_HANDLERS, KNOWN_REVIEWER_FIELDS, KNOWN_HOST_BEHAVIORS, MAX_REPORTED_UNKNOWN_KEYS, MAX_REPORTED_KEY_CHARS, validateReviewerBody, collectReviewerWarnings, collectReviewerWarningRecords, REVIEWER_WARNING, REMOVED_REVIEWER_CLI_FIELD, VALID_INSTALL_SURFACES, VALID_PERMISSION_WRITERS, VALID_EXTENDED_HOOK_EVENTS, VALID_EMBEDDING_MODES, VALID_COMMAND_SURFACES, VALID_MODEL_MODES, VALID_HOOK_BUSES, VALID_STATE_IO, VALID_TRANSPORTS, VALID_HOST_RUNTIMES, VALID_SUBAGENT_TOOLKITS, VALID_DISPATCH_ISOLATION, _HOST_INTEGRATION_VOCAB: { embeddingMode: [...VALID_EMBEDDING_MODES], commandSurface: [...VALID_COMMAND_SURFACES], modelMode: [...VALID_MODEL_MODES], hookBus: [...VALID_HOOK_BUSES], stateIO: [...VALID_STATE_IO], transport: [...VALID_TRANSPORTS], runtime: [...VALID_HOST_RUNTIMES], subagentToolkit: [...VALID_SUBAGENT_TOOLKITS], effortSurface: [...VALID_EFFORT_SURFACES], isolation: [...VALID_DISPATCH_ISOLATION], }, INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, GEMINI_AGENT_EVENTS, CLAUDE_FAMILY_EVENTS, TIER_RANK, INSTALL_SURFACE_TO_CONFIG_FORMAT, // Functions isPlausibleRange, validateVersionEnvelope, validateCapability, validateCommandEntry, validateRuntimeCompat, validateFeatureBody, validateConfigHome, validateArtifactKindEntry, validateArtifactLayout, validateRuntimeBody, materializeHookFragments, validateFragment, validateStep, validateContribution, validateGate, validateAgainstContract, validateConsumesGlobal, validateCrossCapability, detectRequiresCycles, computeRequiresClosure, topoSortHookEntries, topoSortSteps, topoSortContributions, validateHooksWired, HOOK_GROUP_KINDS, validateConfigSliceEntry, classifyCrossErrors, runConfigFormatParityGate, };