From 02607ece6640b9dbc423c08f799f5583badffb8d Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 16 Aug 2026 11:43:26 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(adapters):=20enforce=20earned-capabilit?= =?UTF-8?q?y=20invariant=20at=20read=20time,=20not=20only=20at=20grant=20(?= =?UTF-8?q?ADR-0031=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grantedCapabilitiesFor now re-derives the whole invariant on every read: a capability is returned only when it is a TIER_GRANTS value AND its gating tier is recorded 'passed' at the exact current hash — so a hand-forged or merged store that just adds a canBePrimary/aqeProvider key is filtered to empty, and aqeProvider is unreturnable even beside a real grant. recordTierGate also drops a grant-bearing capability when it downgrades the backing tier, keeping the stored evidence and the live grant consistent. This makes the invariant a standing property, not just a write-time gate — the prerequisite for wiring grants into the registry safely. --- src/lib/adapters/grants.mjs | 49 +++++++++++++++-- tests/kit/adapter-grants.test.mjs | 87 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/lib/adapters/grants.mjs b/src/lib/adapters/grants.mjs index 8c13fb0..c2665a2 100644 --- a/src/lib/adapters/grants.mjs +++ b/src/lib/adapters/grants.mjs @@ -40,6 +40,16 @@ export const TIER_GRANTS = Object.freeze({ statusline: 'commandStatusline', }); +/** Reverse of TIER_GRANTS: capability -> its gating tier, or undefined if + * `capability` is not one `ak` can actually grant (e.g. a forged/legacy + * 'aqeProvider' key sitting in a hand-edited or merged store). Shared by + * grantCapability (write-time check) and grantedCapabilitiesFor (read-time + * re-check — F-1: the write-time gate alone does not protect a flat JSON + * file an operator can edit directly). */ +function gatingTierForCapability(capability) { + return Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0]; +} + export const adapterGrantsPath = () => path.join(configDir(), 'adapter-grants.json'); function readStore(file) { @@ -144,6 +154,13 @@ export function recordTierResult(name, tier, { hash, evidence } = {}, { file = a * (ADR-0031 §4) — gated, not failed. `gatedBy` pins the tracking issue, e.g. * 'agentic-qe#563' or 'ruvnet/ruflo#2962'. Same hash-replace semantics as * recordTierResult. + * + * F-2: if `tier` is grant-bearing (a TIER_GRANTS key) and currently backs a + * LIVE capability at this same hash, downgrading it to 'gated' must also + * drop that capability — evidence and grant must never disagree (a status + * display must never show a tier as 'gated' while the capability it used to + * back is still listed as granted). A hash change already wipes capabilities + * via freshRecordAt, so this only matters for the same-hash downgrade case. * @param {string} name * @param {string} tier * @param {{hash?: string, gatedBy?: string}} details @@ -159,6 +176,12 @@ export function recordTierGate(name, tier, { hash, gatedBy } = {}, { file = adap const store = readStore(file); const record = freshRecordAt(store, name, hash); record.tiers[tier] = { status: 'gated', recordedAt: new Date().toISOString(), gatedBy }; + const capability = TIER_GRANTS[tier]; + if (capability && record.capabilities && Object.hasOwn(record.capabilities, capability)) { + const capabilities = { ...record.capabilities }; + delete capabilities[capability]; + record.capabilities = capabilities; + } store[name] = record; writeStore(file, store); } @@ -176,7 +199,7 @@ export function recordTierGate(name, tier, { hash, gatedBy } = {}, { file = adap export function grantCapability(name, capability, { hash } = {}, { file = adapterGrantsPath() } = {}) { requireName(name, 'grantCapability'); requireHash(hash, 'grantCapability'); - const tier = Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0]; + const tier = gatingTierForCapability(capability); if (!tier) { throw new TypeError(`grantCapability: '${capability}' is not a grantable capability (must be one of ${Object.values(TIER_GRANTS).join(', ')})`); } @@ -238,14 +261,32 @@ export function grantsFor(name, { file = adapterGrantsPath(), currentHash } = {} * hash matches `currentHash` — a manifest edit silently voids every grant * until re-earned, exactly consent's pin model. {} on any mismatch, missing * record, or read failure. Never throws. This is the ONLY reader capability- - * wiring code may consume — see the module-header invariant. */ + * wiring code may consume — see the module-header invariant. + * + * F-1: this re-derives the grant invariant at READ time rather than trusting + * record.capabilities verbatim. adapter-grants.json is a flat JSON file an + * operator (or a bad merge) can hand-edit directly — grantCapability's + * write-time gate does not protect against that. A capability is only ever + * returned when it (a) is a real TIER_GRANTS value — never e.g. a forged + * 'aqeProvider' key — AND (b) its gating tier is recorded 'passed' at this + * exact hash. This raises store forgery from "add one key" to "also forge a + * matching passed tier", and makes aqeProvider unreturnable even if present + * in the raw file. */ export function grantedCapabilitiesFor(name, currentHash, { file = adapterGrantsPath() } = {}) { try { const record = grantsFor(name, { file }); if (!record || record.hash !== currentHash) return {}; - return record.capabilities && typeof record.capabilities === 'object' && !Array.isArray(record.capabilities) - ? { ...record.capabilities } + const stored = record.capabilities && typeof record.capabilities === 'object' && !Array.isArray(record.capabilities) + ? record.capabilities : {}; + const out = {}; + for (const [capability, value] of Object.entries(stored)) { + if (value !== true) continue; + const tier = gatingTierForCapability(capability); + if (!tier || record.tiers?.[tier]?.status !== 'passed') continue; + out[capability] = true; + } + return out; } catch { return {}; } diff --git a/tests/kit/adapter-grants.test.mjs b/tests/kit/adapter-grants.test.mjs index 4a71644..eba948b 100644 --- a/tests/kit/adapter-grants.test.mjs +++ b/tests/kit/adapter-grants.test.mjs @@ -252,3 +252,90 @@ test('recordTierResult on an evidence-only tier (admission) still accepts empty/ assert.equal(record.tiers.admission.status, 'passed'); assert.equal(record.tiers.admission.evidence, ''); }); + +// ── F-1 (security review): grantedCapabilitiesFor re-derives the grant +// invariant at READ time, not just at write time. adapter-grants.json is a +// flat JSON file an operator (or a bad merge) can hand-edit directly — +// grantCapability's write-time gate does not protect against that. A +// capability must only ever be returned when it is a real TIER_GRANTS value +// AND its gating tier is recorded 'passed' at the exact same hash. ───────── + +test('F-1: grantedCapabilitiesFor rejects a forged capability that has no matching passed tier, even at the correct hash', () => { + const file = tempFile(); + // Simulate a hand-edited/forged/merged store: 'canBePrimary' sits in + // capabilities with NO 'primary-eligible' tier ever recorded passed. + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const store = JSON.parse(fs.readFileSync(file, 'utf8')); + store.acme.capabilities = { canBePrimary: true }; + fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); + + assert.deepEqual( + grantedCapabilitiesFor('acme', HASH_A, { file }), + {}, + 'a forged capability with no backing passed tier must never be returned', + ); +}); + +test("F-1: grantedCapabilitiesFor rejects a forged 'aqeProvider' key even though it is present in the raw store", () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + const store = JSON.parse(fs.readFileSync(file, 'utf8')); + store.acme.capabilities.aqeProvider = true; // forged — never written by grantCapability + fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); + + const granted = grantedCapabilitiesFor('acme', HASH_A, { file }); + assert.deepEqual(granted, { canBePrimary: true }, 'the real grant still returns; the forged aqeProvider key must not'); + assert.ok(!Object.hasOwn(granted, 'aqeProvider')); +}); + +test('F-1: grantedCapabilitiesFor rejects a capability whose gating tier was recorded but is not status "passed"', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + const store = JSON.parse(fs.readFileSync(file, 'utf8')); + store.acme.capabilities = { canBePrimary: true }; // forged alongside a merely-gated tier + fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +// ── F-2 (security review): running `gate` AFTER a grant must downgrade the +// live capability along with the tier — evidence and grant must never +// disagree, or a status display would show a self-contradiction (a 'gated' +// tier backing a still-live capability) and the audit trail would be lost. + +test('F-2: recordTierGate on a grant-bearing tier that currently backs a live capability drops that capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); + + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.ok(!Object.hasOwn(record.capabilities ?? {}, 'canBePrimary'), 'the stored record must drop the capability, not just the read side'); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('F-2: recordTierGate on a grant-bearing tier leaves an UNRELATED live capability untouched', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); +}); + +test('F-2: recordTierGate on a tier with no live capability (never granted) is a no-op on capabilities', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + // No grantCapability call — the tier passed but was never granted. + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.equal(record.capabilities, undefined); +}); From 800ec67a8965caec70c29265267e0a26268a70f2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 16 Aug 2026 11:43:26 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(cli):=20ak=20host=20adapters=20grant/b?= =?UTF-8?q?less,=20gate,=20status=20=E2=80=94=20maintainer=20capability=20?= =?UTF-8?q?grants=20(ADR-0031=20P5/P7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's explicit grant of a tier-earned capability (grant, aliased bless per ADR-0031 §3's 'blessed external adapter'), the upstream-gate recorder (gate #NNN), and the per-adapter status view. grant refuses any capability whose gating tier is not recorded passed at the current manifest hash — so canBePrimary/commandStatusline correctly refuse today (their tiers can't yet pass), which is the safety invariant working, and aqeProvider is never grantable (upstream-owned). The grant confirmation discloses the earned evidence, the manifest hook commands, and the consent state before the prompt (resolving the manifest once, so the disclosed trust state describes exactly the bytes being granted), and states plainly that a grant is recorded but currently inert — no runtime consumer reads grants yet. No exercise/callback path is ever reachable from the CLI (a caller result must never be both the pass and its own evidence). --- src/commands/x/host-adapters-grants.mjs | 241 ++++++++++++ src/commands/x/host-adapters.mjs | 45 ++- tests/kit/conformance-tiers.test.mjs | 21 ++ tests/kit/host-adapters-cli.test.mjs | 479 +++++++++++++++++++++++- 4 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 src/commands/x/host-adapters-grants.mjs diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs new file mode 100644 index 0000000..f905531 --- /dev/null +++ b/src/commands/x/host-adapters-grants.mjs @@ -0,0 +1,241 @@ +// x host adapters grant/gate/status/revoke-grant — the maintainer's P5 +// promotion CLI (ADR-0031 §1, §2, §3) and the P7 upstream-gate CLI (§4). +// Split out of host-adapters.mjs (which keeps list/trust/revoke/conformance) +// purely to stay under the house 500-line-per-file budget; both files are +// one logical surface dispatched from the same `run()` in host-adapters.mjs. +// +// Nothing here loads third-party code. grant/gate/status call grants.mjs, a +// pure data layer over a hash-pinned JSON store (adapter-grants.json). +// grantCapability there REFUSES unless the gating tier is already recorded +// 'passed' at the exact current manifest hash: a capability is conferred by +// already-recorded evidence plus this explicit maintainer act, never by the +// CLI itself exercising a path (a caller-supplied exercise result would be +// both the pass and its own evidence) — so no subcommand here accepts or +// forwards any 'exercise'/callback option. +import { + CONFORMANCE_TIERS, TIER_GRANTS, recordTierGate, grantCapability, revokeGrants, + grantsFor, grantedCapabilitiesFor, gatedTiersFor, +} from '../../lib/adapters/grants.mjs'; +import { ok, warn, fail, info, bold } from '../../lib/output.mjs'; +import { + findEntry, loadAndHash, stripControl, hookCommandsFor, +} from './host-adapters.mjs'; + +// ── grant (P5 promotion command; alias `bless`) ───────────────────────── +// The maintainer's explicit act that turns already-recorded conformance +// evidence into a live capability (ADR-0031 §1). This blesses an EXTERNAL +// adapter's grant (§3's "Blessed external adapter" — hence the `bless` +// alias), not a built-in promotion — promoting to a built-in is a manual +// registry PR (§3), not this command. + +/** TIER_GRANTS' values are the only capabilities `ak` can ever grant — + * critically this excludes 'aqeProvider', an upstream-owned enumeration + * (ADR-0031 §4) that must never appear grantable here. */ +function grantableCapability(capability) { + return Object.values(TIER_GRANTS).includes(capability); +} + +function gatingTierFor(capability) { + return Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0]; +} + +export async function grant({ + name, capability, cfg, consent, reader, ask, isTTY, yes, grantsFile, +}) { + if (typeof name !== 'string' || !name || typeof capability !== 'string' || !capability) { + fail('usage: ak host adapters grant '); + return 2; + } + const safeCapability = stripControl(capability); + if (!grantableCapability(capability)) { + fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}. 'aqeProvider' in particular is an upstream-owned identity (agentic-qe's own provider enumeration) and is never ak-grantable — see ADR-0031 §4.`); + return 1; + } + const safeName = stripControl(name); + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${safeName}' in kit.json hostAdapters`); return 1; } + + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(`'${safeName}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + const { manifest, hash } = loaded; + const tier = gatingTierFor(capability); + const safeTier = stripControl(tier); + + // F-3 (security review): this is the highest-privilege act in the model — + // a hex hash alone told the operator nothing about what they were actually + // converting to a live capability. Disclose the ACTUAL evidence backing + // the gating tier, the manifest's declared hooks, and the manifest's trust + // state, all BEFORE the confirmation prompt. + const record = grantsFor(name, { file: grantsFile, currentHash: hash }); + const tierEntry = record?.tiers?.[tier]; + const evidence = tierEntry?.status === 'passed' ? tierEntry.evidence : undefined; + + console.log(bold(`grant '${safeCapability}' to '${safeName}'`)); + console.log(` gating tier: ${safeTier}`); + console.log(` manifest hash: ${hash}`); + console.log(` tier evidence: ${evidence ? stripControl(evidence) : '(no passed-tier evidence recorded at this hash — the grant below will be refused)'}`); + const hooks = hookCommandsFor(manifest); + console.log(` manifest hooks:${hooks.length ? '' : ' (none)'}`); + for (const line of hooks) console.log(` ${stripControl(line)}`); + // N-1 (security re-review): derive trust state from the hash this call + // ALREADY resolved above — never re-resolve the manifest source (stateFor + // would call loadAndHash a second time). On a mutable remote source + // (https:/npm:) a second resolve can return different bytes than the + // first, which would (a) disclose a trust state describing content that + // isn't what's actually being granted, and (b) double the fetch cost (an + // npm: source runs `npm pack` twice). Same three-state logic as stateFor, + // just computed from data already in hand. + let trustState; + try { + const recordedHash = consent.recordedHashFor(name); + if (recordedHash === null || recordedHash === undefined) trustState = 'not consented'; + else trustState = recordedHash === hash ? 'trusted' : 'consent-stale'; + } catch (error) { + trustState = `manifest error (consent-error: ${error?.message ?? String(error)})`; + } + console.log(` manifest trust state: ${trustState}`); + info('this grant pins the MANIFEST content (its hash), not the hook script bytes it references — see ADR-0031 §2 for that boundary.'); + info("granting a capability is a trust act, same posture as 'trust': it is recorded now, but nothing in ak reads granted capabilities into runtime behaviour yet — see the note after recording."); + + if (!yes) { + if (!isTTY) { + fail('grant needs confirmation — re-run with --yes after reviewing the summary above (non-interactive session)'); + return 2; + } + const confirmed = await ask(`Grant '${safeCapability}' to '${safeName}' at this manifest content? [y/N] `); + if (!confirmed) { info(`grant for '${safeName}' left unchanged`); return 0; } + } + + try { + grantCapability(name, capability, { hash }, { file: grantsFile }); + } catch (error) { + fail(`grant refused: ${stripControl(error?.message ?? String(error))} — earn it first with \`ak host adapters conformance ${safeName}\` (needs a passed '${safeTier}' tier at this exact manifest hash)`); + return 1; + } + + ok(`granted '${safeCapability}' to '${safeName}' at ${hash}`); + info('an edit to the manifest voids this grant until the tier is re-earned and re-granted'); + // F-7 (honesty, ADR-0023): no runtime code reads granted capabilities + // today — grantedCapabilitiesFor's only call sites are the conformance + // harness's own gate and `ak host adapters status`. The grant is recorded, + // not yet live; it lights up once a later wave wires granted capabilities + // into actual runtime behaviour. + info("this grant is recorded, not yet live: nothing in ak reads granted capabilities into runtime behaviour today — it will take effect once a later wave wires that in"); + return 0; +} + +// ── gate (P7): record an upstream capability request ──────────────────── + +export async function gate({ + name, tier, ref, cfg, reader, grantsFile, +}) { + if (typeof name !== 'string' || !name || typeof tier !== 'string' || !tier || typeof ref !== 'string' || !ref) { + fail('usage: ak host adapters gate '); + return 2; + } + const safeName = stripControl(name); + const safeTier = stripControl(tier); + if (!CONFORMANCE_TIERS.includes(tier)) { + fail(`'${safeTier}' is not a valid conformance tier (one of ${CONFORMANCE_TIERS.join(', ')})`); + return 1; + } + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${safeName}' in kit.json hostAdapters`); return 1; } + + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(`'${safeName}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + + try { + recordTierGate(name, tier, { hash: loaded.hash, gatedBy: ref }, { file: grantsFile }); + } catch (error) { + fail(`gate refused: ${stripControl(error?.message ?? String(error))}`); + return 1; + } + + ok(`recorded '${safeName}' tier '${safeTier}' as gated on ${stripControl(ref)} at ${loaded.hash}`); + info(`\`ak host adapters status ${safeName}\` will show this as waiting-on-upstream until the tracked issue ships and the gate is cleared`); + return 0; +} + +// ── status (P7 display) ─────────────────────────────────────────────── +// A read-only report over grants.mjs's reporting surfaces — never the +// hash-unaware capability reader (see grants.mjs's module-header invariant). +// Staleness (a manifest edit since evidence was recorded) must be obvious, +// not buried: it voids every passed tier and every grant until re-earned. + +async function statusOne(name, entry, { reader, grantsFile }) { + const safeName = stripControl(name); + console.log(bold(`host adapter status — ${safeName}`)); + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(` manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + const { hash } = loaded; + console.log(` manifest hash: ${hash}`); + + const record = grantsFor(name, { file: grantsFile, currentHash: hash }); + const passed = record ? Object.entries(record.tiers).filter(([, t]) => t?.status === 'passed') : []; + if (!passed.length) { + info(' passed tiers: (none)'); + } else { + console.log(' passed tiers:'); + for (const [tier] of passed) { + const line = ` ${stripControl(tier)}${record.stale ? ' — STALE (manifest changed since this evidence was recorded; void until re-earned)' : ''}`; + if (record.stale) warn(line); else ok(line); + } + } + + const gated = gatedTiersFor(name, { file: grantsFile, currentHash: hash }); + if (!gated.length) { + info(' gated tiers: (none)'); + } else { + console.log(' gated tiers (waiting on upstream):'); + for (const { tier, gatedBy } of gated) info(` ${stripControl(tier)} -> ${stripControl(gatedBy)}`); + } + + const granted = Object.keys(grantedCapabilitiesFor(name, hash, { file: grantsFile })); + console.log(` granted capabilities: ${granted.length ? granted.map(stripControl).join(', ') : '(none)'}`); + return 0; +} + +export async function status({ + name, cfg, reader, grantsFile, +}) { + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; + if (typeof name === 'string' && name) { + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${stripControl(name)}' in kit.json hostAdapters`); return 1; } + return statusOne(name, entry, { reader, grantsFile }); + } + if (!entries.length) { info('no host adapters configured in kit.json'); return 0; } + for (const entry of entries) { + await statusOne(entry?.name ?? '(unnamed)', entry, { reader, grantsFile }); + console.log(''); + } + return 0; +} + +// ── revoke-grant: withdraw conformance evidence + grants ──────────────── +// Distinct from `revoke`, which only touches the adapter-consent store +// (whether admission accepts the manifest at all). This touches +// adapter-grants.json (tier evidence, upstream gates, granted capabilities) +// — an operator revoking a manifest's TRUST does not automatically revoke +// capabilities EARNED separately, and vice versa; they are separate stores +// for separate questions. Fail-safe like `revoke`: reachable with the +// experimental flag off, so a disabled surface can still be voided. + +export function revokeGrant({ name, grantsFile }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant '); return 2; } + const safeName = stripControl(name); + const existed = revokeGrants(name, { file: grantsFile }); + if (existed) { ok(`revoked all conformance evidence and grants for '${safeName}'`); return 0; } + info(`no recorded grants for '${safeName}'`); + return 0; +} diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 8f62ae4..ce724b7 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -1,5 +1,10 @@ // x host adapters — the trust CLI for external host-adapter manifests (ADR-0031 -// P1). Nothing in admission.mjs ever writes consent; this closes that gap by +// P1) plus the tiered conformance runner (P4). The maintainer's grant/gate CLI +// (P5) and the upstream-gate display (P7) live in the sibling +// host-adapters-grants.mjs (split out to stay under the house 500-line-per-file +// budget) and are re-exported into this file's `run()` dispatch below — from +// the operator's point of view it is all one `ak host adapters` surface. +// Nothing in admission.mjs ever writes consent; this closes that gap by // recording hash-pinned consent the same way Codex pins an MCP server's // content (ADR-0029 §6). No adapter code ever executes here — this module // only reads, validates, hashes, discloses, and (on confirmation) records. @@ -17,6 +22,9 @@ import * as consentStore from '../../lib/adapters/consent.mjs'; import { runTieredConformance as defaultRunTieredConformance } from '../../lib/adapters/conformance.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; +import { + grant as grantCap, gate as gateTier, status as statusReport, revokeGrant, +} from './host-adapters-grants.mjs'; const FLAG_ENV_VAR = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; @@ -48,7 +56,7 @@ async function defaultAsk(question) { * JSON.stringify does NOT escape, unlike C0) outright; \n/\t/\r collapse to * a single space instead of vanishing, so a multi-line/tabbed string stays * readable as one line rather than silently losing a word boundary. */ -function stripControl(value) { +export function stripControl(value) { const input = String(value ?? ''); let out = ''; for (const ch of input) { @@ -60,7 +68,7 @@ function stripControl(value) { return out; } -function findEntry(cfg, name) { +export function findEntry(cfg, name) { return (Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []).find((e) => e?.name === name) ?? null; } @@ -69,7 +77,7 @@ function findEntry(cfg, name) { * posture admission.mjs's admitOne holds. `reader` may return either the * sources.mjs `{raw, origin}` shape or a bare raw document (tests are free * to stub either). */ -async function loadAndHash(entry, { reader }) { +export async function loadAndHash(entry, { reader }) { let raw; // Fail-closed, not fail-open: a bare-raw reader result (no {raw,origin} // wrapper — including one whose `origin` is missing/malformed) is @@ -128,7 +136,7 @@ async function loadAndHash(entry, { reader }) { /** Trust state for one entry — never throws. One of 'trusted', 'consent-stale', * 'not consented', or 'manifest error ()'. */ -async function stateFor(entry, { consent, reader }) { +export async function stateFor(entry, { consent, reader }) { if (typeof entry?.name !== 'string' || !entry.name) return 'manifest error (invalid-entry: missing name)'; const loaded = await loadAndHash(entry, { reader }); if (!loaded.ok) return `manifest error (${stripControl(loaded.reason)})`; @@ -288,7 +296,7 @@ function tierLine(tier) { return `${String(tier.tier).padEnd(18)} ${String(tier.status).padEnd(8)} ${dim(stripControl(detail))}`; } -function hookCommandsFor(manifest) { +export function hookCommandsFor(manifest) { const hooks = []; for (const [verb, def] of Object.entries(manifest.lifecycle ?? {})) { if (def?.hook?.command) hooks.push(`lifecycle.${verb}: ${JSON.stringify(def.hook.command)}`); @@ -395,11 +403,12 @@ export async function run({ // Revocation is fail-safe and stays reachable regardless of the // experimental flag: an operator who turns the flag OFF must still be - // able to withdraw a standing consent record, or that record silently + // able to withdraw a standing consent or grant record, or it silently // reactivates the next time the flag is turned back on. `list`/`trust`/ - // `conformance` stay gated — they're the surface that reads/records new - // trust or evidence. + // `conformance`/`grant`/`gate`/`status` stay gated — they're the surface + // that reads/records new trust, evidence, or capability. if (sub === 'revoke') return revoke({ name, consent }); + if (sub === 'revoke-grant') return revokeGrant({ name, grantsFile }); if (!flagEnabled(env)) { fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); @@ -420,7 +429,23 @@ export async function run({ name, cfg: resolvedCfg, reader, runTiered: runTieredConformance, consentFile, grantsFile, }); } + // F-8 (security review, ADR-0031-accurate naming): `bless` is the alias — + // §3 calls this exact out-of-tree grant path a "Blessed external adapter"; + // "Promoted built-in" is the separate manual registry PR this command does + // NOT do. `grant` stays the primary spelling; `promote` was dropped. + if (sub === 'grant' || sub === 'bless') { + return grantCap({ + name, capability: positionals[2], cfg: resolvedCfg, consent, reader, ask, isTTY, + yes: !!flags.yes, grantsFile, + }); + } + if (sub === 'gate') { + return gateTier({ + name, tier: positionals[2], ref: positionals[3], cfg: resolvedCfg, reader, grantsFile, + }); + } + if (sub === 'status') return statusReport({ name, cfg: resolvedCfg, reader, grantsFile }); - fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance)`); + fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`); return 2; } diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs index adc69ae..f853769 100644 --- a/tests/kit/conformance-tiers.test.mjs +++ b/tests/kit/conformance-tiers.test.mjs @@ -277,6 +277,27 @@ test('primary-eligible and statusline tiers are gated (never passed) against acm assert.equal(grantsFor('acme', { file: grantsFile }), null); }); +// F-6 (security review): the property that actually holds the "no exercise +// confers a grant" invariant is checkGrantGatedTier evaluating `granted` +// BEFORE ever calling `exercise` (conformance.mjs) — a caller-supplied +// exercise result can never confer the FIRST grant, because exercise is +// simply never reached without one already recorded. Pin that ordering +// directly: inject a real exercisePrimaryEligible with NO existing grant. +test('primary-eligible tier stays gated (never passed) when an exercise callback is injected but no grant exists — granted is checked BEFORE exercise runs', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + exercisePrimaryEligible: async () => ({ ok: true }), + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'gated'); + assert.notEqual(tier.status, 'passed'); + assert.equal(grantsFor('acme', { file: grantsFile }), null, 'a caller-supplied exercise result must never confer the FIRST grant'); +}); + test('primary-eligible tier stays honestly gated even when GRANTED, because no lead/escalation runtime path is built yet', async () => { const grantsFile = tempGrantsFile(); // Seed a real grant the way the (future) promotion command would. diff --git a/tests/kit/host-adapters-cli.test.mjs b/tests/kit/host-adapters-cli.test.mjs index d59eae7..cb4cd7f 100644 --- a/tests/kit/host-adapters-cli.test.mjs +++ b/tests/kit/host-adapters-cli.test.mjs @@ -6,6 +6,15 @@ // re-trust, stale-hash disclosure, revoke true/false, list states, an // unknown adapter name, and `conformance`'s printed table + recording + // exit codes. +// +// Also covers grant/promote, gate, status, and revoke-grant (ADR-0031 P5 + +// P7, implemented in the sibling host-adapters-grants.mjs but dispatched +// through this same `run()`): the grant happy/refusal paths against a +// force-recorded passed tier, capability allow-listing (aqeProvider and any +// other non-TIER_GRANTS value rejected), hash-pinned staleness voiding a +// grant, the non-interactive confirm gate, gate's ref validation, status's +// passed/gated/granted rendering and stale marking, and that no new +// subcommand ever accepts/forwards an exercise/callback option. import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -19,7 +28,9 @@ import { recordedHashFor, recordConsent, revokeConsent, } from '../../src/lib/adapters/consent.mjs'; import { HOST_REGISTRY } from '../../src/lib/adapters/registries.mjs'; -import { grantsFor } from '../../src/lib/adapters/grants.mjs'; +import { + grantsFor, recordTierResult, recordTierGate, grantCapability, grantedCapabilitiesFor, +} from '../../src/lib/adapters/grants.mjs'; import { runTieredConformance } from '../../src/lib/adapters/conformance.mjs'; const ON_ENV = { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }; @@ -921,3 +932,469 @@ test('conformance: the banner strips a raw C1 byte (U+009B, CSI) out of a hook c assert.ok(!text.includes(CSI), 'no raw C1 control character may reach the disclosure output'); assert.match(text, /detectpayload/, 'the C1 byte is removed outright, the surrounding text survives'); }); + +// ── grant / bless (ADR-0031 P5) ───────────────────────────────────────── +// grant's disclosure now also derives the manifest trust state (F-3), so +// every test that reaches loadAndHash passes an isolated `consent` store — +// never the real default consentStore — to stay hermetic. + +test('grant: happy path — capability granted once the gating tier is force-recorded passed at the current hash', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'leads a run, receives escalation' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'canBePrimary'], env: ON_ENV, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, grantsFile, flags: {}, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.match(cap.text(), /granted 'canBePrimary'/); + // F-3: the disclosure prints the actual evidence backing the tier, not + // just a hash, and the F-7 honesty note that the grant is not yet live. + assert.match(cap.text(), /tier evidence:\s+leads a run, receives escalation/); + assert.match(cap.text(), /not yet live/); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { canBePrimary: true }); +}); + +// N-1 (security re-review): grant must resolve the manifest source EXACTLY +// ONCE. Previously the disclosure's trust-state line called stateFor, which +// ran its own independent loadAndHash — on a mutable remote source a second +// resolve could return different bytes than the first, so the disclosed +// trust state could describe content that was never the granted content. +// This reader returns different content on any call after the first; if +// grant ever resolved twice, the trust-state line would be computed against +// `secondRaw` while the granted hash is `firstRaw`'s — a display-integrity +// bug on the highest-privilege command. +test('grant: resolves the manifest exactly once, and the disclosed trust state describes the ACTUAL granted hash even when a second resolve would differ', async () => { + const grantsFile = tmpGrantsFile(); + const firstRaw = validManifest(); + const secondRaw = validManifest({ version: '9.9.9' }); // different content/hash + const firstHash = hashManifest(validateAdapterManifest(firstRaw)); + recordTierResult('hermes', 'primary-eligible', { hash: firstHash, evidence: 'leads a run' }, { file: grantsFile }); + + const consentFile = tmpConsentFile(); + recordConsent('hermes', firstHash, { file: consentFile }); // consent pinned to the FIRST hash + + let calls = 0; + const reader = async () => { calls += 1; return calls === 1 ? firstRaw : secondRaw; }; + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'canBePrimary'], env: ON_ENV, cfg, + reader, ask: async () => true, isTTY: true, grantsFile, flags: {}, + consent: fileConsent(consentFile), + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.equal(calls, 1, 'the manifest source must be resolved exactly once, not once for the grant and again for the trust-state line'); + // If grant had resolved twice, the trust state would be computed against + // secondRaw's hash (which the consent store never recorded) and show + // 'not consented' or 'consent-stale' instead of 'trusted'. + assert.match(cap.text(), /manifest trust state: trusted/); + assert.match(cap.text(), new RegExp(`manifest hash: ${firstHash}`)); + assert.deepEqual(grantedCapabilitiesFor('hermes', firstHash, { file: grantsFile }), { canBePrimary: true }); +}); + +test('grant: `bless` is an accepted alias for `grant`', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'statusline', { hash, evidence: 'footer renders' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['bless', 'hermes', 'commandStatusline'], env: ON_ENV, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, grantsFile, flags: {}, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { commandStatusline: true }); +}); + +test('grant: REFUSED when the gating tier is not recorded passed — exit 1, nothing granted, names the tier', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'canBePrimary'], env: ON_ENV, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 1); + assert.match(cap.text(), /primary-eligible/); + const hash = hashManifest(validateAdapterManifest(raw)); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); +}); + +test("grant: 'aqeProvider' is rejected as never ak-grantable (ADR-0031 §4), before any manifest read", async () => { + const grantsFile = tmpGrantsFile(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'aqeProvider'], env: ON_ENV, cfg, + reader: neverCalled('reader'), ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + }); + } finally { cap.restore(); } + + assert.equal(code, 1); + assert.match(cap.text(), /upstream-owned/); + assert.match(cap.text(), /ADR-0031 §4/); +}); + +test('grant: any other non-TIER_GRANTS capability is rejected, before any manifest read', async () => { + const grantsFile = tmpGrantsFile(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'transcripts'], env: ON_ENV, cfg, + reader: neverCalled('reader'), ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + }); + } finally { cap.restore(); } + + assert.equal(code, 1); + assert.match(cap.text(), /not a grantable capability/); +}); + +test('grant: refuses when the manifest changed since the tier was recorded passed (hash mismatch)', async () => { + const grantsFile = tmpGrantsFile(); + const originalRaw = validManifest(); + const originalHash = hashManifest(validateAdapterManifest(originalRaw)); + recordTierResult('hermes', 'primary-eligible', { hash: originalHash, evidence: 'ev' }, { file: grantsFile }); + + const editedRaw = validManifest({ version: '1.0.1' }); // edited since the tier passed -> new hash + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'canBePrimary'], env: ON_ENV, cfg, + reader: async () => editedRaw, ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 1); + assert.match(cap.text(), /no passed 'primary-eligible' tier recorded at hash/); + const editedHash = hashManifest(validateAdapterManifest(editedRaw)); + assert.deepEqual(grantedCapabilitiesFor('hermes', editedHash, { file: grantsFile }), {}); +}); + +test('grant: non-interactive without --yes fails 2 before writing, even with a passed tier available', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'statusline', { hash, evidence: 'footer renders' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'commandStatusline'], env: ON_ENV, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: false, grantsFile, flags: {}, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 2); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); +}); + +test('grant: a declined interactive confirmation leaves the grant unchanged, exit 0', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'statusline', { hash, evidence: 'footer renders' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'commandStatusline'], env: ON_ENV, cfg, + reader: async () => raw, ask: async () => false, isTTY: true, grantsFile, flags: {}, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 0); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); +}); + +test('grant: ignores an exercise-shaped extra option — a capability is never conferred by a caller-supplied exercise result', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'canBePrimary'], env: ON_ENV, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + consent: fileConsent(tmpConsentFile()), + // No such input exists on run()'s contract; if it were ever wired + // through, this would be exactly the security-review-forbidden shape + // (a caller-supplied exercise result standing in as both the pass and + // its own evidence). It must be silently inert. + exercise: async () => ({ status: 'passed' }), + }); + } finally { cap.restore(); } + + assert.equal(code, 1, 'no passed tier was ever actually recorded, so the grant must still be refused'); +}); + +// ── gate (ADR-0031 P7) ─────────────────────────────────────────────────── + +test('gate: records a valid #NNN ref at the current manifest hash', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['gate', 'hermes', 'primary-eligible', 'ruvnet/ruflo#2962'], env: ON_ENV, cfg, + reader: async () => raw, grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.match(cap.text(), /gated on ruvnet\/ruflo#2962/); + const record = grantsFor('hermes', { file: grantsFile, currentHash: hash }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.equal(record.tiers['primary-eligible'].gatedBy, 'ruvnet/ruflo#2962'); +}); + +test('gate: rejects a malformed ref — recordTierGate throws, surfaced honestly, exit 1, nothing recorded', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['gate', 'hermes', 'primary-eligible', 'not-a-valid-ref'], env: ON_ENV, cfg, + reader: async () => raw, grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 1); + assert.match(cap.text(), /gate refused/); + assert.equal(grantsFor('hermes', { file: grantsFile }), null); +}); + +test('gate: rejects a tier not in CONFORMANCE_TIERS before ever touching the manifest', async () => { + const grantsFile = tmpGrantsFile(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['gate', 'hermes', 'bogus-tier', 'agentic-qe#563'], env: ON_ENV, cfg, + reader: neverCalled('reader'), grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 1); + assert.match(cap.text(), /not a valid conformance tier/); +}); + +// ── status (ADR-0031 P7 display) ──────────────────────────────────────── + +test('status: shows passed tiers, gated tiers, and granted capabilities, then marks it all STALE after a manifest edit', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'admission', { hash, evidence: 'admits' }, { file: grantsFile }); + recordTierResult('hermes', 'statusline', { hash, evidence: 'footer renders' }, { file: grantsFile }); + grantCapability('hermes', 'commandStatusline', { hash }, { file: grantsFile }); + recordTierGate('hermes', 'primary-eligible', { hash, gatedBy: 'ruvnet/ruflo#2962' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + let cap = capture(); + let code; + try { + code = await run({ + positionals: ['status', 'hermes'], env: ON_ENV, cfg, + reader: async () => raw, grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + let text = cap.text(); + assert.match(text, /passed tiers:/); + assert.match(text, /admission/); + assert.match(text, /statusline/); + assert.match(text, /gated tiers \(waiting on upstream\):/); + assert.match(text, /primary-eligible -> ruvnet\/ruflo#2962/); + assert.match(text, /granted capabilities: commandStatusline/); + assert.doesNotMatch(text, /STALE/); + + // Edit the manifest — the hash changes, so every prior tier result and + // granted capability must render as void, not silently vanish. + const editedRaw = validManifest({ version: '9.9.9' }); + cap = capture(); + try { + code = await run({ + positionals: ['status', 'hermes'], env: ON_ENV, cfg, + reader: async () => editedRaw, grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + text = cap.text(); + assert.match(text, /STALE/); + assert.match(text, /gated tiers: \(none\)/, 'a stale record voids gated-tier visibility too'); + assert.match(text, /granted capabilities: \(none\)/, 'a stale record voids granted capabilities'); +}); + +test('status: with no summarizes every configured adapter', async () => { + const grantsFile = tmpGrantsFile(); + const rawHermes = validManifest({ name: 'hermes', host: validHost({ id: 'hermes' }) }); + const rawAtlas = validManifest({ name: 'atlas', host: validHost({ id: 'atlas' }) }); + const cfg = cfgWith([ + { name: 'hermes', source: 'mem://hermes' }, + { name: 'atlas', source: 'mem://atlas' }, + ]); + const reader = async (source) => (source === 'mem://hermes' ? rawHermes : rawAtlas); + + const cap = capture(); + let code; + try { + code = await run({ positionals: ['status'], env: ON_ENV, cfg, reader, grantsFile, flags: {} }); + } finally { cap.restore(); } + + assert.equal(code, 0); + const text = cap.text(); + assert.match(text, /host adapter status — hermes/); + assert.match(text, /host adapter status — atlas/); +}); + +test('status: an unknown adapter name fails 1', async () => { + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['status', 'nope'], env: ON_ENV, cfg: cfgWith([{ name: 'hermes', source: 'mem://hermes' }]), + reader: neverCalled('reader'), grantsFile: tmpGrantsFile(), flags: {}, + }); + } finally { cap.restore(); } + assert.equal(code, 1); +}); + +// ── flag-off for grant/gate/status: exit 2, nothing written ───────────── + +test('grant/bless/gate/status all refuse with exit 2 when the flag is off, and write nothing', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'ev' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + const reader = neverCalled('reader'); + + for (const positionals of [ + ['grant', 'hermes', 'canBePrimary'], + ['bless', 'hermes', 'canBePrimary'], + ['gate', 'hermes', 'primary-eligible', 'agentic-qe#563'], + ['status', 'hermes'], + ['status'], + ]) { + const cap = capture(); + let code; + try { + code = await run({ positionals, env: OFF_ENV, cfg, reader, grantsFile, flags: { yes: true } }); + } finally { cap.restore(); } + assert.equal(code, 2, `positionals=${JSON.stringify(positionals)}`); + } + + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); +}); + +// ── revoke-grant ───────────────────────────────────────────────────────── + +test('revoke-grant works even when the experimental flag is off (fail-safe), and only touches the grants store', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'admission', { hash, evidence: 'ev' }, { file: grantsFile }); + + const file = tmpConsentFile(); + const consent = fileConsent(file); + recordConsent('hermes', hash, { file }); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes'], env: OFF_ENV, cfg: cfgWith([]), consent, grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0); + assert.match(cap.text(), /revoked all conformance evidence and grants/); + assert.equal(grantsFor('hermes', { file: grantsFile }), null); + // revoke-grant must never touch the separate consent (trust) store. + assert.equal(recordedHashFor('hermes', { file }), hash); +}); + +test('revoke-grant reports no recorded grants when none existed', async () => { + const grantsFile = tmpGrantsFile(); + const cap = capture(); + let code; + try { + code = await run({ positionals: ['revoke-grant', 'ghost'], env: ON_ENV, cfg: cfgWith([]), grantsFile, flags: {} }); + } finally { cap.restore(); } + assert.equal(code, 0); + assert.match(cap.text(), /no recorded grants/); +}); + +// ── no exercise/callback surface anywhere in the new commands ─────────── + +test('the grant/gate/status implementation never destructures or forwards an exercise/callback parameter', () => { + const grantsSrcPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../src/commands/x/host-adapters-grants.mjs'); + const hostAdaptersSrcPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../src/commands/x/host-adapters.mjs'); + const grantsSrc = fs.readFileSync(grantsSrcPath, 'utf8'); + const hostAdaptersSrc = fs.readFileSync(hostAdaptersSrcPath, 'utf8'); + // Real code destructuring/forwarding a parameter reads as `exercise,`, + // `exercise:`, or `exercise }` — distinct from this constraint's own prose + // ("exercising a path", "'exercise'/callback option"), which never follows + // the word with one of those three characters. + const PARAM_SHAPE = /\bexercise\s*[,:}]/; + assert.doesNotMatch(grantsSrc, PARAM_SHAPE, 'host-adapters-grants.mjs must never destructure/forward an exercise parameter'); + assert.doesNotMatch(hostAdaptersSrc, PARAM_SHAPE, 'host-adapters.mjs must never destructure/forward an exercise parameter into grant/gate'); +}); From 53441298a1c0f9206033150451bd29e708677d38 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 16 Aug 2026 11:43:26 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs(adr):=20ADR-0031=20=E2=80=94=20grant/g?= =?UTF-8?q?ate=20CLI=20rows=20Working;=20record=20the=20manifest-not-hook-?= =?UTF-8?q?bytes=20evidence=20boundary=20(=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-capability-graduation-and-upstream-requests.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index a036065..3659feb 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -68,6 +68,16 @@ host, assert the real behaviour, against an installed layout — that gates one Passing a tier records evidence; the maintainer's grant turns evidence into capability. A tier the adapter cannot meet because the capability is upstream is marked **gated** (§4), not failed. +The recorded evidence is hash-pinned to the **manifest** — the same content pin consent uses +(ADR-0029 §6) — so any edit to the manifest voids the evidence and the grants that rest on it. It is +**not** pinned to the bytes of the hook *scripts* the manifest references: a file-sourced adapter can +rewrite `run-hook.mjs` under an unchanged manifest hash. This is the identical boundary ADR-0029 +already accepted for consent, carried forward here; the grant confirmation states it explicitly so a +maintainer granting `primary-eligible`/`statusline` knows the pin covers the declaration, not the +script that produced the observation. Tightening this — hashing the referenced hook files into the +tier evidence and re-verifying at grant time — is a tracked follow-up, warranted before the freeze +(§6) if a real adapter's graduation depends on it. + ### 3. Two graduation destinations A conformed adapter lands in one of two places, the maintainer's call: @@ -156,9 +166,9 @@ unbuilt. This table is the source of truth for what is real. | External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` | | External lifecycle execution wired into setup/sync/uninstall | **Working** (2026-08-16, wave C) | The loops iterate `hostsWithLifecycle()` (built-ins + admitted) through a shape-agnostic renderer; an admitted host's lifecycle runs only when explicitly enabled in `kit.json` **and** the flag is set. Admitted lifecycle hooks are cwd-anchored to the adapter's own directory (per-verb `lifecycle-unanchored` refusal for a relative hook on a remote source), the same F-1 protection as execution. *Known limitation:* the `sync` path is wired but not yet reachable through a real `ak sync` — `status.mjs`'s subsystem derivation is still opencode-scoped; setup and uninstall are fully live. Generalizing `status.mjs` is a tracked follow-up | | Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, wave C) | `runTieredConformance` + `ak host adapters conformance`: `admission` and `activity-routing` genuinely pass black-box against a real fixture (real subprocess worker); `session-driving`/`primary-eligible`/`statusline` report `gated`/`skipped` honestly because their runtime paths (external session driving, lead/escalation, statusline render) are not built — the harness never fabricates a pass. Passed/gated tiers are recorded into the grant store against the manifest hash; a failed `admission` tier short-circuits every downstream tier so no evidence is laundered | -| Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave | +| Capability-grant store + promotion command | **Working** (2026-08-16, wave D) | `grants.mjs` (hash-pinned, evidence-gated, edit-invalidated like consent — the earned capability is enforced at **read** time, not only at grant time) plus `ak host adapters grant`/`bless`: the maintainer's explicit grant of a tier-earned capability. It refuses any capability whose gating tier is not recorded `passed` at the current manifest hash — so `canBePrimary`/`commandStatusline` correctly refuse today (their tiers can't yet pass, §2), which is the safety invariant working. A granted capability is recorded but currently **inert** — no runtime consumer reads grants yet (the wave that wires granted capabilities into behaviour is still ahead) | | Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A) | file / https (no redirects, bounded time+bytes) / `npm:` (`npm pack --ignore-scripts` + `tar -xzOf` stdout-only — nothing extracted to disk, package scripts never run); resolver runs before hashing, so a mutated remote surfaces as `consent-stale`. The https fetch is host-unrestricted by design (the source is operator-authored in user-scope `kit.json`; redirects refused, no credentials attached) | -| Upstream request tracking (`gated: #NNN` against a tier) | **Partial** (2026-08-16, wave A) | Per-tier `gated` records exist in the grant store (`recordTierGate`, ref-format-validated); CLI recording/display pending a later wave | +| Upstream request tracking (`gated: #NNN` against a tier) | **Working** (2026-08-16, wave D) | `ak host adapters gate ` records a ref-format-validated upstream gate; `ak host adapters status` surfaces per-tier passed/gated state (stale-marked on a manifest edit) and the granted capabilities | | A real external adapter (Hermes) clearing the kit → contract freeze | **Not started** | Freeze criterion (§6) | ## Alternatives considered