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 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/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); +}); 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'); +});