'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-]*$/; const VALID_ROLES = new Set(['feature', 'runtime']); 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._/-]+$/; /** * #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). */ 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; } 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: feature, runtime (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)); } else if (cap.role === 'runtime') { errors.push(...validateRuntimeBody(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', 'convertClaudeCommandToCursorCommand', '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', ]); // 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']); 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']); // 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++) { errors.push(...validateArtifactKindEntry(capId, arr[i], 'artifactLayout.' + scope + '[' + i + ']')); } } } 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) + ')', ); } } } // 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"', ); } } 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) { 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 const configKeyOwner = new Map(); // key → capId for (const [capId, cap] of capMap) { if (cap.role !== 'feature' || 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', ); } } } // 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 ───────────────────────────────────────────────────── /** * 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. * * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points * are already caught by validateStep/validateContribution/validateGate — do not * double-report. * * @param {object} cap Validated capability object. * @param {Set} wiredSet Set of points that have call sites in host workflows. * @returns {string[]} Array of error strings; empty means all points are wired. */ function validateHooksWired(cap, wiredSet) { const errors = []; const capId = cap.id || '(unknown)'; function checkPoint(point, groupName, idx) { // Only flag valid points that are unwired — invalid points are schema-validator's job. if (!VALID_LOOP_POINTS.has(point)) return; if (!wiredSet.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.', ); } } for (let i = 0; i < (cap.steps || []).length; i++) { const hook = cap.steps[i]; if (hook.point !== undefined) checkPoint(hook.point, 'steps', i); } for (let i = 0; i < (cap.contributions || []).length; i++) { const hook = cap.contributions[i]; if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i); } for (let i = 0; i < (cap.gates || []).length; i++) { const hook = cap.gates[i]; if (hook.point !== undefined) checkPoint(hook.point, 'gates', 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, FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME, 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, _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], }, 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, validateConfigSliceEntry, classifyCrossErrors, runConfigFormatParityGate, };