fix(#2810): accept the documented effortSurface axis on EoS registry entries (#2813)

* fix(#2810): accept the documented effortSurface axis on EoS registry entries

The EoS registry schema required an exact eight-key `interactions.axes`
object, while `docs/registries/README.md` and `CONTEXT.md` both documented
nine keys including `effortSurface`. An entry that faithfully mirrored its
upstream descriptor's `effortSurface` key was rejected outright.

`effortSurface` reached the runtime-descriptor vocabulary through ADR-1239
amendment #2481 (`HOST_INTEGRATION_AXES`), but the registry's hand-maintained
copy of that vocabulary never picked it up. The runtime-descriptor surface is
guarded by tests/host-integration-validator-parity.test.cjs; the registry copy
had no equivalent guard, which is what let the two drift.

Accept `effortSurface` as an OPTIONAL ninth axis validated against the
canonical ['argv','none'] rather than a required one: registry entries mirror
their upstream registry/eos-entry.json byte-for-byte, so requiring it would
retroactively invalidate every entry published before the amendment.

Adds tests/registry-axes-parity.test.cjs, which asserts that every key shared
between the registry vocabulary and HOST_INTEGRATION_AXES has an identical
enum array, plus limit-1/limit/limit+1 boundary coverage on the axes key set.

Closes #2810

* test(#2810): fail when a canonical axis is added but never mirrored

The enum-equality assertion compares only keys the registry and
HOST_INTEGRATION_AXES already share, so it is blind to the exact drift that
produced #2810: a new canonical axis appears and the registry copy is never
told. Verified by simulation — mutating an enum is caught, adding a new
canonical key is not.

Assert instead that every HOST_INTEGRATION_AXES key is either modeled by the
registry or named in an explicit NOT_MODELLED allowlist (subagentToolkit and
isolation, both dispatch sub-fields the registry collapses into its free-form
dispatch summary). Adding a canonical axis now fails until someone decides
which bucket it belongs in. The allowlist is itself guarded against going
stale.

Refs #2810

* fix(#2810): harden the axis value lookup with the CodeQL barrier pattern

Both orthogonal reviews flagged the same line: `AXES[key] !== undefined`
is not an own-property test, and the bracket reads are shaped like a
prototype-pollution sink even though the unknown-key gate above provably
makes them unreachable.

Switch the presence test to `Object.hasOwn` and add the repo's inline
literal guards (`capability-state.cts:146-155`, "Prototype-pollution guard
(inline literal, CodeQL barrier)"), which CodeQL can follow where it cannot
follow the `.includes()` filter that actually does the work.

Behavior is unchanged — re-verified all five axes key-count shapes plus a
genuine own `__proto__` property built through JSON.parse (the shape a
third-party registry PR would submit): it is rejected as an unknown key and
Object.prototype is untouched.

Refs #2810

* chore(#2810): backfill changeset PR number
This commit is contained in:
Tom Boucher
2026-07-29 07:00:35 -04:00
committed by GitHub
parent 46bae2f9ff
commit 9624167eec
6 changed files with 286 additions and 11 deletions

View File

@@ -36,6 +36,11 @@
* (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
* this registry accepts a free-form human summary string instead, so it
* carries the `AXES_FREE_STRING` sentinel rather than an enum array.
* `OPTIONAL_AXES` adds one further, OPTIONAL key on top of those eight:
* `effortSurface` (ADR-1239 amendment #2481). An entry may omit it
* (every entry published before the amendment stays valid) or declare
* it as `argv` | `none`, mirroring `HOST_INTEGRATION_AXES.effortSurface`
* in `src/host-integration.cts`.
* - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
* fields for each entry type, including `enginesGsd` (ADR-1244 D1
* "Versioned capability manifest" — the `engines.gsd` semver-range gate,
@@ -90,6 +95,18 @@ const AXES = Object.freeze({
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
});
// ─── ADR-1239 amendment #2481 — one additional, OPTIONAL negotiated axis ─────
// `effortSurface` was added to the runtime-descriptor vocabulary AFTER the
// original eight (`HOST_INTEGRATION_AXES.effortSurface` in
// `src/host-integration.cts`). It is kept OPTIONAL here — not folded into
// `AXES` — because registry entries mirror their upstream
// `registry/eos-entry.json` byte-for-byte, and requiring it would
// retroactively invalidate every entry published before the amendment.
// Values must match `HOST_INTEGRATION_AXES.effortSurface` exactly.
const OPTIONAL_AXES = Object.freeze({
effortSurface: Object.freeze(['argv', 'none']),
});
// ─── Required top-level fields ───────────────────────────────────────────────
const CAPABILITY_REQUIRED = Object.freeze([
'id',
@@ -246,15 +263,39 @@ function validateEosInteractions(interactions, addError) {
if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
addError('interactions.axes', 'axes must be an object');
} else {
const expectedKeys = Object.keys(AXES);
const requiredKeys = Object.keys(AXES);
const optionalKeys = Object.keys(OPTIONAL_AXES);
const actualKeys = Object.keys(axes);
const actualKeySet = new Set(actualKeys);
const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k));
if (!keysMatch) {
addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes');
} else {
for (const key of expectedKeys) {
const allowedValues = AXES[key];
// Every AXES key is mandatory; an extra key is tolerated ONLY when it is
// a recognized OPTIONAL_AXES key (currently just `effortSurface`) — any
// other extra key is still rejected as unknown.
const missingRequiredKeys = requiredKeys.filter((k) => !actualKeySet.has(k));
const unknownKeys = actualKeys.filter((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k));
if (missingRequiredKeys.length > 0) {
addError('interactions.axes', `axes is missing required key(s): ${missingRequiredKeys.join(', ')}`);
}
if (unknownKeys.length > 0) {
addError('interactions.axes', `axes has unknown key(s): ${unknownKeys.join(', ')}`);
}
// Only validate individual values once the key set itself is sound —
// mirrors the original gate (values were never checked against a
// malformed key set either).
if (missingRequiredKeys.length === 0 && unknownKeys.length === 0) {
for (const key of actualKeys) {
// Inline literal guards — CodeQL barrier pattern. Reaching here already
// implies `key` is one of the nine literal axis names (the unknown-key
// gate above rejected everything else), so this is unreachable in
// practice; it is written inline anyway because CodeQL cannot follow
// that gate across the `.includes()` filter and would otherwise flag
// the bracket reads below as prototype-pollution sinks.
if (key === '__proto__') continue;
if (key === 'constructor') continue;
if (key === 'prototype') continue;
const allowedValues = Object.hasOwn(AXES, key) ? AXES[key] : OPTIONAL_AXES[key];
const v = axes[key];
if (allowedValues === AXES_FREE_STRING) {
if (typeof v !== 'string' || v.trim() === '') {
@@ -496,7 +537,14 @@ function renderMarkdown(entries, opts) {
lines.push(`- **Author:** ${mdInline(entry.author)}`);
if (isEos) {
const axesSummary = Object.keys(AXES)
// Required AXES keys always render, in their fixed order; an OPTIONAL_AXES
// key (e.g. `effortSurface`) renders ONLY when the entry actually carries
// it — an entry that omits it must render byte-identical to before
// OPTIONAL_AXES existed (no `effortSurface=undefined` noise).
const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter(
(key) => interactions.axes && Object.hasOwn(interactions.axes, key),
);
const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys]
.map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
.join(', ');
const summary =
@@ -556,6 +604,7 @@ module.exports = {
INTERFACE_POINTS,
PROFILES,
AXES,
OPTIONAL_AXES,
AXES_FREE_STRING,
CAPABILITY_REQUIRED,
EOS_REQUIRED,