diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index d741c7c..9d0b908 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -164,8 +164,8 @@ unbuilt. This table is the source of truth for what is real. | Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) | | `ak host adapters trust` CLI (records consent/grants) | **Working** (2026-08-16, wave A) | `list`/`trust`/`revoke` + `--expect-hash` pinning; disclosure prints the full validated manifest (control-char-safe); mirrors every pre-hash admission refusal; `revoke` works with the flag off (fail-safe) | | 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, waves C+D2) | `runTieredConformance` + `ak host adapters conformance`: `admission`, `activity-routing`, and now `primary-eligible` genuinely pass black-box against a real fixture — `primary-eligible` drives a real `executeRunPlan` where the host anchors a run and receives a genuine ADR-0019 escalation onto itself (a real second subprocess), recorded with no pre-existing grant. `session-driving`/`statusline` stay honestly `gated`/`skipped` (external session driving and the statusline render path are not built) — the harness never fabricates a pass, and there is no injection seam through which a caller could substitute one. A failed `admission` tier short-circuits every downstream tier so no evidence is laundered. *Known gap (N-1, tracked for §6):* a grant-bearing tier that later re-runs `failed` under an unchanged manifest hash does not yet auto-void the granted capability — operator-visible in `status`, remediable with `revoke-grant`; the auto-downgrade lands with the hook-bytes-pinning work | +| 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. `setup`, `uninstall`, **and now `sync`** are fully live: `status.mjs`'s collector emits a subsystem-tagged row for an enabled admitted lifecycle host, so `sync`'s convergence plan reaches its admitted-host branch (wave D4 closed the earlier `sync`-only reachability gap) | +| Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, waves C+D2) | `runTieredConformance` + `ak host adapters conformance`: `admission`, `activity-routing`, and now `primary-eligible` genuinely pass black-box against a real fixture — `primary-eligible` drives a real `executeRunPlan` where the host anchors a run and receives a genuine ADR-0019 escalation onto itself (a real second subprocess), recorded with no pre-existing grant. `session-driving`/`statusline` stay honestly `gated`/`skipped` (external session driving and the statusline render path are not built) — the harness never fabricates a pass, and there is no injection seam through which a caller could substitute one. A failed `admission` tier short-circuits every downstream tier so no evidence is laundered. A grant-bearing tier that re-runs `failed` under the same manifest hash now auto-voids the stored tier **and** the live granted capability (wave D4, N-1) — the un-earn path mirrors the gated-downgrade; a `skipped` result never voids (prerequisite not evaluated ≠ disproof). Capabilities can also be withdrawn per-capability with `ak host adapters revoke-grant [capability]`. *Bounded (tracked with the hook-bytes-pinning work before §6 freeze):* the auto-void covers a same-hash failure of the *grant-bearing* tier itself, not of its *prerequisite* (`activity-routing`) — an adapter could retain `canBePrimary` by regressing the prerequisite instead; closing this needs the `cli_unavailable`-vs-real-failure distinction (so a machine merely lacking the host CLI never false-voids a legitimate grant) and is the same manifest-vs-hook-bytes boundary the hashing work addresses. `statusline` un-earn lands with its render path | | Capability-grant store + promotion command | **Working** (2026-08-16, waves D+D2) | `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, refused unless the gating tier is recorded `passed` at the current manifest hash. **Wave D2 makes a grant live:** at bootstrap the admitted-host overlay reads `grantedCapabilitiesFor` at the fresh current hash and raises `canBePrimary`/`commandStatusline` on the effective-registry entry (through a local allow-list that can raise only those two, never `aqeProvider` or any other key), so `hostTierLabel` and `effectivePrimaryHostIds()` reflect it. Two consumption gaps remain, honestly disclosed at grant time: no path yet *selects* an external host as primary (`ak host pick` stays built-in-scoped), and `commandStatusline` has no runtime reader yet (its render path is a later wave) | | 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) | **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 | diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 3594881..e5dc63a 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -24,6 +24,7 @@ import { coherence as adbCoherence } from '../lib/agentdb.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, credentialGaps, collectIntegrationFacts } from '../lib/providers.mjs'; +import { hostsWithLifecycle, isBuiltinHost, lifecycleExecutionEnabled } from '../lib/adapters/lifecycle-registry.mjs'; import { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs'; import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs'; import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs'; @@ -185,6 +186,51 @@ export async function renderHostDetailRows({ cfg, pkgRoot, facts, renderers = HO return rows; } +/** Sync reachability gap (ADR-0031 P3 known limitation): setup.mjs and + * uninstall.mjs's admitted-host lifecycle loops (ADR-0031 P3) already iterate + * hostsWithLifecycle() and run for real; sync.mjs's twin loop is gated on + * BOTH lifecycleExecutionEnabled(hostId, cfg) AND `subsystems.has(hostId)`, + * where subsystems is `new Set(plan.map(p => p.subsystem))` derived straight + * from THIS collector's rows (sync.mjs). An admitted host with no + * HOST_DETAIL_RENDERERS entry (only opencode has one) produced no row at + * all, so its subsystem could never appear in the plan and sync's branch was + * unreachable for a real admitted host — even fully enabled, flag on, CLI + * present. This closes that gap: any admitted (never built-in) lifecycle + * host that lifecycleExecutionEnabled() actually gates IN for this run gets + * exactly one subsystem-tagged row, `subsystem === hostId` — deliberately + * the same identity opencode's own renderer uses (subsystem 'opencode' === + * HOST_DETAIL_RENDERERS key 'opencode'), so sync's `subsystems.has(hostId)` + * finds it. + * + * Deliberately lean, not a per-surface renderer like opencodeDetailRows: an + * arbitrary admitted host's only introspection surface is its own declared + * detect/verify hooks (a subprocess spawn), which this read-only, cheap + * collector does not invoke — so the row cannot report real drift and always + * carries a `fix` while the gate holds. Convergence is left to the adapter's + * own apply, which lifecycle.mjs's contract requires to be idempotent. + * Excludes built-in hosts (opencode already has a bespoke renderer above; + * a future built-in lifecycle host with no renderer is a gap for its own + * renderer to close, not this fallback) and any host already present in + * `renderers` (never double-reports one host under two mechanisms). Isolated + * per host, mirroring the per-renderer try/catch contract above — one + * admitted host's failure must not take down collect() or any other host's + * row. */ +function admittedLifecycleFallbackRows(cfg, renderers = HOST_DETAIL_RENDERERS) { + const rows = []; + for (const hostId of hostsWithLifecycle()) { + if (isBuiltinHost(hostId) || hostId in renderers) continue; + try { + if (!lifecycleExecutionEnabled(hostId, cfg)) continue; + rows.push(row(hostId, 'warn', + `${hostId}: external lifecycle host, enabled — sync will converge its hooks`, + `sync applies the ${hostId} lifecycle adapter`)); + } catch (e) { + rows.push(row(hostId, 'warn', `${hostId} lifecycle status unavailable: ${e.message}`)); + } + } + return rows; +} + export async function collect({ pkgRoot, cwd = process.cwd() }) { const rows = []; const cfg = loadKitConfig(); @@ -534,6 +580,7 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { // absent is the CLI-presence branch inside its own renderer, sourced from // the shared facts snapshot — no extra probing here or in the loop). rows.push(...(await renderHostDetailRows({ cfg, pkgRoot, facts: integrationFacts }))); + rows.push(...admittedLifecycleFallbackRows(cfg)); // hosts (install-if-missing) — cheap: file read + `which`, no network. // An enabled host that is entirely absent is installable by sync; an external diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs index da9ced9..b2b5be4 100644 --- a/src/commands/x/host-adapters-grants.mjs +++ b/src/commands/x/host-adapters-grants.mjs @@ -13,7 +13,7 @@ // 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, + CONFORMANCE_TIERS, TIER_GRANTS, recordTierGate, grantCapability, revokeGrants, revokeCapability, grantsFor, grantedCapabilitiesFor, gatedTiersFor, } from '../../lib/adapters/grants.mjs'; import { ok, warn, fail, info, bold } from '../../lib/output.mjs'; @@ -256,10 +256,39 @@ export async function status({ // 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. +// +// F-9 (deferred nit, now taken): `revoke-grant ` alone stays +// all-or-nothing (revokeGrants — the whole record, forcing a full +// conformance re-run to recover every OTHER capability too). An optional +// `[capability]` third positional narrows this to just that one capability +// (revokeCapability), leaving every other tier result and granted capability +// untouched. +// +// F-6 (security-review follow-up): the two forms differ in what they leave +// behind, not just in scope. `revoke-grant ` is UN-GRANT, +// not un-earn — it drops the capability but leaves the gating tier 'passed', +// so a later re-grant needs no new conformance evidence. `revoke-grant +// ` (no capability) also wipes the tier evidence itself, forcing a +// fresh conformance run before anything can be re-granted. An operator +// revoking because they no longer TRUST the recorded evidence (not just +// withdrawing the grant) wants the whole-record form. -export function revokeGrant({ name, grantsFile }) { - if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant '); return 2; } +export function revokeGrant({ name, capability, grantsFile }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant [capability]'); return 2; } const safeName = stripControl(name); + + if (typeof capability === 'string' && capability) { + const safeCapability = stripControl(capability); + if (!grantableCapability(capability)) { + fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}.`); + return 1; + } + const existed = revokeCapability(name, capability, { file: grantsFile }); + if (existed) { ok(`revoked capability '${safeCapability}' for '${safeName}' — other tiers and capabilities are untouched`); return 0; } + info(`no recorded '${safeCapability}' grant for '${safeName}'`); + return 0; + } + 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}'`); diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index ce724b7..3a25109 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -408,7 +408,7 @@ export async function run({ // `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 (sub === 'revoke-grant') return revokeGrant({ name, capability: positionals[2], grantsFile }); if (!flagEnabled(env)) { fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs index f05f813..31d50e9 100644 --- a/src/lib/adapters/conformance.mjs +++ b/src/lib/adapters/conformance.mjs @@ -39,7 +39,8 @@ import { registerAdmittedExecution, resetAdmittedExecution } from '../execution/ import { executeRunPlan } from '../execution/runner.mjs'; import { have } from '../exec.mjs'; import { - CONFORMANCE_TIERS, TIER_GRANTS, adapterGrantsPath, recordTierResult, recordTierGate, grantedCapabilitiesFor, + CONFORMANCE_TIERS, TIER_GRANTS, adapterGrantsPath, recordTierResult, recordTierGate, recordTierFailure, + grantedCapabilitiesFor, } from './grants.mjs'; export { CONFORMANCE_TIERS, TIER_GRANTS }; @@ -439,8 +440,16 @@ async function checkGrantGatedTier({ * Recording (grants.mjs): a 'passed' tier is recorded via recordTierResult; * a 'gated' tier carrying a `gatedBy` upstream ref (only ever session-driving, * and only when the caller supplies one — see checkSessionDriving) is - * recorded via recordTierGate. 'skipped'/'failed' tiers, and 'gated' tiers - * with no upstream ref, record nothing — there is nothing new to persist. + * recorded via recordTierGate, which also VOIDS a live capability the tier + * used to back at this same hash. A genuinely 'failed' grant-bearing tier + * (one of TIER_GRANTS' keys) is recorded via recordTierFailure — the un-earn + * path: a re-run that fails at the SAME hash a capability was earned at + * voids that capability too, mirroring the 'gated' downgrade (N-1, + * security-review follow-up). A 'skipped' result records nothing, even for a + * grant-bearing tier — the tier was never actually evaluated this run (e.g. + * its own prerequisite didn't pass), which is ambiguous, not a disproof, so + * it must never void a live capability. 'gated' tiers with no upstream ref + * also record nothing — there is nothing new to persist. * `grantsFile` defaults to the REAL adapter-grants.json (matching grants.mjs's * own default-to-real-config-path convention) — callers that don't want a * conformance run to touch the real store (tests, dry runs) must pass an @@ -603,6 +612,21 @@ export async function runTieredConformance({ recordTierResult(resolvedName, tierResult.tier, { hash, evidence: evidence || tierResult.tier }, { file: grantsFile }); } else if (tierResult.status === 'gated' && tierResult.gatedBy) { recordTierGate(resolvedName, tierResult.tier, { hash, gatedBy: tierResult.gatedBy }, { file: grantsFile }); + } else if (tierResult.status === 'failed' && Object.hasOwn(TIER_GRANTS, tierResult.tier)) { + // N-1 (security-review follow-up): a grant-bearing tier that + // RE-RUNS 'failed' at the SAME (unchanged) hash means evidence a + // live capability rests on was just shown non-reproducible — void + // the stored tier and the capability together (grants.mjs's + // recordTierFailure), mirroring recordTierGate's downgrade for + // 'gated' immediately above. Deliberately NOT triggered by + // 'skipped': a skipped tier (e.g. primary-eligible + // short-circuiting because its own prerequisite — activity- + // routing — didn't run/pass THIS run) means the tier was never + // actually EVALUATED this run, which is ambiguous, not disproven + // — downgrading on an ambiguous non-evaluation would void a live + // capability on evidence that says nothing about whether it + // still holds. + recordTierFailure(resolvedName, tierResult.tier, { hash }, { file: grantsFile }); } } catch (error) { tierResult.recordError = error?.message ?? String(error); diff --git a/src/lib/adapters/grants.mjs b/src/lib/adapters/grants.mjs index c2665a2..04937d5 100644 --- a/src/lib/adapters/grants.mjs +++ b/src/lib/adapters/grants.mjs @@ -186,6 +186,55 @@ export function recordTierGate(name, tier, { hash, gatedBy } = {}, { file = adap writeStore(file, store); } +/** Record a genuinely FAILED conformance-tier result for `name` at `hash` — + * the un-earn path recordTierGate already implements for the 'gated' case + * (N-1, security-review follow-up), mirrored here for 'failed': a + * grant-bearing tier (a TIER_GRANTS key) that RE-RUNS 'failed' at the SAME + * hash a capability was previously granted at means the evidence a live + * capability rests on no longer reproduces — the stored 'passed' tier and + * the granted capability must both be voided, not left standing on evidence + * just shown non-reproducible. Same hash-replace semantics as + * recordTierResult/recordTierGate: a DIFFERENT hash means "start fresh" + * (freshRecordAt already wipes every prior tier and capability), so this + * only has anything to drop for the same-hash case. + * + * No `evidence` field: a failure records that the tier no longer passes, not + * new evidence of anything — callers wanting the failure detail keep it in + * their own report (conformance.mjs's `checks`), same as recordTierGate + * keeps no evidence field either. + * + * F-4 (security-review follow-up): this function itself is generic over any + * grant-bearing tier, but conformance.mjs's harness only ever calls it for + * 'primary-eligible' today — 'statusline's own check (checkGrantGatedTier) + * can only report 'passed'/'gated'/'skipped', never 'failed', so this un-earn + * path currently has no way to reach commandStatusline through the + * conformance runner. That is fine while commandStatusline stays inert (no + * runtime reader anywhere in src/ yet) — a stale/non-reproducing statusline + * grant can only be voided by a hash change or an explicit `revoke-grant` + * until commandStatusline's own render path lands and its exercise can + * genuinely fail, not just gate. + * @param {string} name + * @param {string} tier + * @param {{hash?: string}} details + * @param {{file?: string}} [options] + */ +export function recordTierFailure(name, tier, { hash } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'recordTierFailure'); + requireHash(hash, 'recordTierFailure'); + if (!isValidTier(tier)) throw new TypeError(`recordTierFailure requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`); + const store = readStore(file); + const record = freshRecordAt(store, name, hash); + record.tiers[tier] = { status: 'failed', recordedAt: new Date().toISOString() }; + 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); +} + /** Grant `capability` to `name` — the maintainer's act that turns conformance * evidence into an actual capability (ADR-0031 §1). Refuses unless the tier * that gates `capability` is recorded 'passed' AT THE SAME hash: evidence @@ -228,6 +277,30 @@ export function revokeGrants(name, { file = adapterGrantsPath() } = {}) { return true; } +/** Remove ONLY `capability` from `name`'s record.capabilities — every tier + * result (and any OTHER granted capability) is left intact, unlike + * revokeGrants above which wipes the whole record. Refuses (throws + * TypeError) any `capability` that is not one of TIER_GRANTS' values — + * matches grantCapability's own allow-list, so this can never be asked to + * remove a forged/legacy key (e.g. 'aqeProvider') that grantCapability could + * never have written in the first place. Returns whether the capability + * existed beforehand; false (never throws) for a missing/never-recorded + * `name`. */ +export function revokeCapability(name, capability, { file = adapterGrantsPath() } = {}) { + if (typeof name !== 'string' || !name) return false; + if (!Object.values(TIER_GRANTS).includes(capability)) { + throw new TypeError(`revokeCapability: '${capability}' is not a grantable capability (must be one of ${Object.values(TIER_GRANTS).join(', ')})`); + } + const store = readStore(file); + const existing = Object.hasOwn(store, name) ? store[name] : null; + if (!existing || !existing.capabilities || !Object.hasOwn(existing.capabilities, capability)) return false; + const capabilities = { ...existing.capabilities }; + delete capabilities[capability]; + store[name] = { ...existing, tiers: { ...existing.tiers }, capabilities }; + writeStore(file, store); + return true; +} + /** The raw validated record for `name`, or null — missing, corrupt, or * malformed all collapse to null. Never throws. THIS IS A REPORTING SURFACE, * NOT A CAPABILITY READER — see the module-header invariant; runtime diff --git a/src/lib/adapters/lifecycle.mjs b/src/lib/adapters/lifecycle.mjs index 990935c..221aa6e 100644 --- a/src/lib/adapters/lifecycle.mjs +++ b/src/lib/adapters/lifecycle.mjs @@ -51,7 +51,31 @@ export async function runLifecycle(adapterOrRequest, operation, context = {}) { const facts = request.facts ?? await adapter.detect(request); if (action === 'plan') return adapter.plan({ ...request, facts }); if (action === 'apply') { + // F8 (security-review follow-up): fail closed when detect (or plan) + // signals a hook failure — never let apply run against facts/a plan the + // adapter itself refused to produce. The gate fires on ANY truthy + // `.error` on the detect-facts/plan object — an ADMITTED host's hook + // failure is reported this way ({observed:null, error} from + // detect/verify, {changed:false, operations:[], error} from plan — + // lifecycle-registry.mjs's hookFailureResult/unanchoredResult), but a + // SUCCESSFUL hook's own JSON payload is returned verbatim too (same + // file's buildAdmittedLifecycleAdapter, `return payload`), so `error` is + // effectively RESERVED in the detect/plan payload contract: a hook must + // never use it for a non-fatal note, only genuine failure. opencode's own + // detect()/plan() (see opencode.mjs's createOpencodeLifecycleAdapter) + // never set an `.error` key on any path, so this check is a strict no-op + // for opencode regardless. + if (facts?.error) { + return lifecycleResult({ ok: false, changed: false, errors: [facts.error] }); + } const plan = request.plan ?? await adapter.plan({ ...request, facts }); + if (plan?.error) { + return lifecycleResult({ ok: false, changed: false, errors: [plan.error] }); + } + // The abort above intentionally precedes the dryRun preview below: a + // failed detect/plan has no valid plan to preview, so dryRun on a hook + // failure returns this lifecycleResult (no `.dryRun` field) rather than a + // fabricated {dryRun:true, facts, plan}. if (dryRun) return { dryRun: true, facts, plan }; return adapter.apply({ ...request, facts, plan }); } diff --git a/tests/kit/adapter-grants.test.mjs b/tests/kit/adapter-grants.test.mjs index eba948b..824d8fe 100644 --- a/tests/kit/adapter-grants.test.mjs +++ b/tests/kit/adapter-grants.test.mjs @@ -8,7 +8,7 @@ import os from 'node:os'; import path from 'node:path'; import { CONFORMANCE_TIERS, TIER_GRANTS, - recordTierResult, recordTierGate, grantCapability, revokeGrants, + recordTierResult, recordTierGate, recordTierFailure, grantCapability, revokeGrants, revokeCapability, grantsFor, grantedCapabilitiesFor, gatedTiersFor, } from '../../src/lib/adapters/grants.mjs'; @@ -339,3 +339,111 @@ test('F-2: recordTierGate on a tier with no live capability (never granted) is a assert.equal(record.tiers['primary-eligible'].status, 'gated'); assert.equal(record.capabilities, undefined); }); + +// ── N-1 (security-review follow-up): recordTierFailure — the un-earn path +// mirroring recordTierGate's downgrade, but for a genuine RE-FAIL at the +// same hash a capability was earned at. ──────────────────────────────────── + +test('N-1: recordTierFailure 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 }); + + recordTierFailure('acme', 'primary-eligible', { hash: HASH_A }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + 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('N-1: recordTierFailure 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 }); + + recordTierFailure('acme', 'primary-eligible', { hash: HASH_A }, { file }); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); +}); + +test('N-1: recordTierFailure 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 }); + recordTierFailure('acme', 'primary-eligible', { hash: HASH_A }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.equal(record.capabilities, undefined); +}); + +test('N-1: recordTierFailure at a DIFFERENT hash still wipes prior tiers/capabilities via the existing freshRecordAt path', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + + recordTierFailure('acme', 'primary-eligible', { hash: HASH_B }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.hash, HASH_B); + assert.deepEqual(Object.keys(record.tiers), ['primary-eligible']); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.equal(record.capabilities, undefined); + // The old hash's grant is gone too — a hash change voids everything. + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('recordTierFailure throws TypeError on invalid name/tier/hash', () => { + const file = tempFile(); + assert.throws(() => recordTierFailure('', 'primary-eligible', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierFailure('acme', 'not-a-tier', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierFailure('acme', 'primary-eligible', { hash: '' }, { file }), TypeError); +}); + +// ── F-9 (deferred nit, now taken): revokeCapability — per-capability revoke, +// narrower than revokeGrants' whole-record wipe. ─────────────────────────── + +test('revokeCapability removes only the named capability, leaving other tiers/capabilities intact', () => { + 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 }); + + assert.equal(revokeCapability('acme', 'canBePrimary', { file }), true); + + const record = grantsFor('acme', { file }); + assert.ok(!Object.hasOwn(record.capabilities, 'canBePrimary')); + assert.equal(record.capabilities.commandStatusline, true); + // Tiers are untouched — still 'passed', not reverted. + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers.statusline.status, 'passed'); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); +}); + +test('revokeCapability returns false when the capability was never granted, or the adapter has no record', () => { + const file = tempFile(); + assert.equal(revokeCapability('acme', 'canBePrimary', { file }), false); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + // Tier passed but never granted -> capability never existed to revoke. + assert.equal(revokeCapability('acme', 'canBePrimary', { file }), false); +}); + +test('revokeCapability rejects a non-grantable capability, including a forged aqeProvider key', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + assert.throws(() => revokeCapability('acme', 'aqeProvider', { file }), TypeError); + assert.throws(() => revokeCapability('acme', 'transcripts', { file }), TypeError); + // Nothing was touched by the rejected attempts. + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); +}); + +test('revokeCapability on prototype-chain names returns false, never a prototype-chain hit', () => { + const file = tempFile(); + for (const protoName of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + assert.equal(revokeCapability(protoName, 'canBePrimary', { file }), false); + } +}); diff --git a/tests/kit/adapter-lifecycle-conformance.test.mjs b/tests/kit/adapter-lifecycle-conformance.test.mjs index 3fe97a4..16adb9e 100644 --- a/tests/kit/adapter-lifecycle-conformance.test.mjs +++ b/tests/kit/adapter-lifecycle-conformance.test.mjs @@ -102,6 +102,58 @@ test('undo preserves a user value changed after apply', () => { }); }); +// ── F8 (security-review follow-up): fail-closed gate on apply ───────────── +// Mirrors the ADMITTED-host hook-failure shapes exactly (lifecycle-registry.mjs's +// hookFailureResult): detect/verify failure -> {observed:null, error}; plan +// failure -> {changed:false, operations:[], error}. opencode's own detect()/ +// plan() never emit a `.error` key on any path (see opencode.mjs), so the +// gate below only ever fires for an admitted host's genuine hook failure. + +test('apply is aborted when detect returns the admitted-host hook-failure shape ({observed:null, error})', async () => { + let applyCalls = 0; + const adapter = { + id: 'fake-hook-failure', + detect() { return { observed: null, error: 'detect hook exited 1' }; }, + plan() { throw new Error('plan must never run when detect failed'); }, + apply() { applyCalls++; return { changed: true }; }, + verify() { return { observed: null }; }, + undo() { return { changed: false }; }, + }; + const result = await runLifecycle({ adapter, action: 'apply' }); + assert.equal(applyCalls, 0, 'apply must never run after a detect hook failure'); + assert.equal(result.ok, false); + assert.equal(result.changed, false); + assert.deepEqual(result.errors, ['detect hook exited 1']); +}); + +test('apply is aborted when plan returns the admitted-host hook-failure shape ({changed:false, operations:[], error})', async () => { + let applyCalls = 0; + const adapter = { + id: 'fake-hook-failure', + detect() { return { observed: { enabled: false } }; }, + plan() { return { changed: false, operations: [], error: 'plan hook exited 1' }; }, + apply() { applyCalls++; return { changed: true }; }, + verify() { return { observed: { enabled: false } }; }, + undo() { return { changed: false }; }, + }; + const result = await runLifecycle({ adapter, action: 'apply' }); + assert.equal(applyCalls, 0, 'apply must never run after a plan hook failure'); + assert.equal(result.ok, false); + assert.equal(result.changed, false); + assert.deepEqual(result.errors, ['plan hook exited 1']); +}); + +test('the fail-closed gate does not fire for a normal facts/plan shape carrying no `.error` key (opencode-safety proof)', async () => { + // fakeLifecycleAdapter's detect/plan return {observed:{...}} / {changed, + // operations} — exactly the shape opencode's own detect()/plan() return + // (no `.error` key ever) — so apply must run through untouched. + const surface = fakeSurface({ enabled: false }); + const adapter = fakeLifecycleAdapter(surface); + const result = await runLifecycle({ adapter, action: 'apply' }); + assert.equal(result.changed, true, 'apply must still run when neither facts nor plan carry an `.error` key'); + assert.deepEqual(surface.snapshot(), { enabled: true }); +}); + test('undo removes an ak-created value while preserving sibling configuration', () => { assert.deepEqual(undoOwnedValues( { env: { AK_MANAGED: 'yes', USER_KEY: 'keep' } }, diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs index e68e9a1..b099777 100644 --- a/tests/kit/conformance-tiers.test.mjs +++ b/tests/kit/conformance-tiers.test.mjs @@ -12,7 +12,9 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { runTieredConformance, CONFORMANCE_TIERS } from '../../src/lib/adapters/conformance.mjs'; -import { grantsFor, grantCapability, grantedCapabilitiesFor } from '../../src/lib/adapters/grants.mjs'; +import { + grantsFor, grantCapability, grantedCapabilitiesFor, recordTierResult, +} from '../../src/lib/adapters/grants.mjs'; import { lifecycleAdapterFor } from '../../src/lib/adapters/lifecycle-registry.mjs'; const FIXTURE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../fixtures/adapters/acme'); @@ -411,6 +413,114 @@ test('primary-eligible tier: a caller-supplied legacy exercisePrimaryEligible op assert.notEqual(record.tiers['primary-eligible'].evidence, 'FAKE-LAUNDERED-PASS'); }); +// ── N-1 (security-review follow-up): the persist loop's un-earn path ─────── +// recordTierGate already voids a live capability when a grant-bearing tier +// is downgraded to 'gated' at the same hash (F-2 above). These tests prove +// the mirrored path for a genuine RE-FAIL: a grant-bearing tier that runs +// 'failed' at the SAME (unchanged) hash a capability was earned at must void +// both the stored tier and the capability — never leave a live capability +// standing on evidence just shown non-reproducible. + +/** A custom execution.run.hook (inline `node -e`, no fixture file edits) that + * succeeds for activity-routing's own worker (id 'conformance-w1' — never + * matches the 'primary-eligible' prefix check) but genuinely fails for + * primary-eligible's two workers (ids 'primary-eligible-direct'/ + * '-escalation'), gated behind an on-disk flag file the test flips between + * runs. This lets the SAME manifest content (same hash) genuinely PASS + * primary-eligible on one run and genuinely FAIL it on the next, while + * activity-routing — primary-eligible's own real prerequisite — keeps + * genuinely passing both times. No '/' or '\' anywhere in the inline script + * text: commandIsUnanchorable (execution/admitted.mjs) inspects every arg, + * and a slash would make this otherwise-absolute-argv0 command read as + * needing baseDir anchoring, which this mem:// source has none of. */ +function flakyPrimaryEligibleManifest(flagPath) { + const NODE = process.execPath; + const script = 'const fs=require("fs");let i="";process.stdin.on("data",c=>{i+=c});' + + 'process.stdin.on("end",()=>{const id=process.env.AK_WORKER_ID||"";const flag=process.argv[1];' + + 'if(id.indexOf("primary-eligible")===0&&fs.existsSync(flag)){process.stderr.write("deliberate re-run failure");process.exit(3);return}' + + 'process.stdout.write(JSON.stringify({summary:"ok "+id,observedModel:null,provider:"acme-flaky"}))});'; + const raw = rawAcmeManifest(); + // No baseDir exists for this mem:// source, so the fixture's own relative + // lifecycle.detect.hook.command would be refused as unanchorable (F1) — + // orthogonal to what this test isolates, so drop it, same pattern the + // other mem:// tests in this file already use. + delete raw.lifecycle; + raw.execution = { run: { hook: { command: [NODE, '-e', script, flagPath] } } }; + return raw; +} + +test('N-1: a grant-bearing tier that RE-FAILS at the SAME hash voids the stored tier and the live capability, leaving an unrelated grant untouched', async () => { + const grantsFile = tempGrantsFile(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-tiers-refail-')); + const flagPath = path.join(dir, 'fail-after-here'); + const manifestSource = 'mem://acme-flaky-primary-eligible'; + const readManifest = async () => flakyPrimaryEligibleManifest(flagPath); + + // Step 1: earn primary-eligible honestly (no flag file yet -> the flaky + // hook succeeds for every worker id), grant canBePrimary, and separately + // force-record + grant an UNRELATED statusline capability at the same hash + // — the control this test proves survives untouched. + const earned = await runTieredConformance({ + manifestSource, readManifest, tiers: ['primary-eligible'], grantsFile, haveFn: async () => true, + }); + assert.equal(earned.tiers[0].status, 'passed', JSON.stringify(earned.tiers[0].checks, null, 2)); + grantCapability('acme', 'canBePrimary', { hash: earned.hash }, { file: grantsFile }); + recordTierResult('acme', 'statusline', { hash: earned.hash, evidence: 'footer renders' }, { file: grantsFile }); + grantCapability('acme', 'commandStatusline', { hash: earned.hash }, { file: grantsFile }); + assert.deepEqual( + grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), + { canBePrimary: true, commandStatusline: true }, + ); + + // Step 2: flip the flag -> the SAME manifest content (same hash, same + // command) now genuinely fails the primary-eligible exercise, while + // activity-routing (worker id 'conformance-w1') still genuinely passes. + fs.writeFileSync(flagPath, ''); + const refailed = await runTieredConformance({ + manifestSource, readManifest, tiers: ['primary-eligible'], grantsFile, haveFn: async () => true, + }); + assert.equal(refailed.hash, earned.hash, 'sanity: same manifest content, same hash'); + const [tier] = refailed.tiers; + assert.equal(tier.status, 'failed', JSON.stringify(tier.checks, null, 2)); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.ok(!Object.hasOwn(record.capabilities ?? {}, 'canBePrimary'), 'canBePrimary must be voided, not left standing on disproven evidence'); + assert.deepEqual( + grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), + { commandStatusline: true }, + 'an unrelated live grant earned at the same hash must survive a primary-eligible re-fail', + ); +}); + +test('N-1: a SKIPPED primary-eligible result (its own prerequisite did not run/pass this run) does NOT void an already-earned capability', async () => { + const grantsFile = tempGrantsFile(); + // Earn the grant honestly first, same sanctioned flow the F-1 tests above use. + const earned = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['primary-eligible'], grantsFile, + haveFn: async () => true, + }); + assert.equal(earned.tiers[0].status, 'passed', JSON.stringify(earned.tiers[0].checks)); + grantCapability('acme', 'canBePrimary', { hash: earned.hash }, { file: grantsFile }); + assert.deepEqual(grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), { canBePrimary: true }); + + // Re-run WITHOUT the haveFn override -> activity-routing genuinely fails + // this run (acme's detection.bin is never really on a test machine's + // PATH), so primary-eligible short-circuits to 'skipped' — its own + // prerequisite was never evaluated this run, which is ambiguous, not a + // disproof of anything already earned. + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['primary-eligible'], grantsFile, + }); + assert.equal(report.tiers[0].status, 'skipped', JSON.stringify(report.tiers[0].checks)); + + assert.deepEqual( + grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), + { canBePrimary: true }, + 'a skipped (never-evaluated) tier must never void an already-earned capability', + ); +}); + test('statusline tier is skipped/gated (never passed) when the admission prerequisite tier is not part of this run and the manifest never admitted', async () => { const grantsFile = tempGrantsFile(); const report = await runTieredConformance({ diff --git a/tests/kit/external-lifecycle.test.mjs b/tests/kit/external-lifecycle.test.mjs index d4ab26f..cb70480 100644 --- a/tests/kit/external-lifecycle.test.mjs +++ b/tests/kit/external-lifecycle.test.mjs @@ -10,13 +10,14 @@ // setup.mjs and uninstall.mjs's admitted-host branch is reachable through a // real command call (setup.run_machine / uninstall.run). sync.mjs's branch // is additionally gated by `subsystems.has(hostId)`, sourced from -// status.mjs's collect() — which has no admitted-host awareness yet -// (HOST_DETAIL_RENDERERS is hardcoded to opencode; status.mjs is out of this -// wave's scope). That pre-existing gate means an admitted host's row never -// enters sync's plan today, so its lifecycle loop body is honestly -// unreachable through a real `ak sync` — pinned below as documented, current -// behavior (not a P3 regression: the gate and its rationale predate this -// wave, and generalizing status.mjs is a separate follow-up). +// status.mjs's collect() — which (D4, ADR-0031 P3 tracked follow-up) now +// emits a lean, generic subsystem row (subsystem === hostId) for any admitted +// lifecycle host that lifecycleExecutionEnabled() gates in for this run +// (cfg enablement AND the experimental flag — see status.mjs's +// admittedLifecycleFallbackRows). That closes the gap this file used to +// pin as a documented gap: an admitted host's row now enters sync's plan, +// so its already-wired lifecycle loop body runs for real, exactly like +// setup's and uninstall's below. import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -300,9 +301,9 @@ test('uninstall.run --dry-run reports the admitted host generically and runs not } }); -// ── sync: documented, current non-reach (status.mjs has no admitted-host row) ─ +// ── sync: the admitted-host lifecycle branch is reachable (D4) ───────────── -test('sync.run never exercises an admitted host\'s lifecycle hook — status.mjs has no row for it yet (documented, out-of-scope gap)', async () => { +test('sync.run exercises an admitted host\'s lifecycle hook now that status.mjs emits its subsystem row (D4 closes the ADR-0031 P3 gap)', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-ext-lifecycle-sync-')); const prevFlag = process.env[FLAG]; seedHome(cfgWithGlobex(true)); @@ -311,10 +312,10 @@ test('sync.run never exercises an admitted host\'s lifecycle hook — status.mjs try { const { applyMarker } = admitGlobex(tmpDir); const { out } = await withGlobexCli(() => captureLog(() => sync.run({ flags: {}, pkgRoot: PKG_ROOT }))); - assert.ok(!fs.existsSync(applyMarker), - 'status.mjs never emits a subsystem row for an admitted host, so subsystems.has(\'globex\') is always false — ' - + 'sync\'s lifecycle loop is correctly wired but currently unreachable for an admitted host through a real `ak sync`'); - assert.doesNotMatch(out, /globex:/); + assert.ok(fs.existsSync(applyMarker), + 'status.mjs now emits a subsystem row for an admitted lifecycle host that lifecycleExecutionEnabled() gates ' + + 'in, so subsystems.has(\'globex\') is true and sync\'s already-wired admitted-host branch runs for real'); + assert.match(out, /globex: applied — 1 action\(s\)/, `expected the generic one-line summary; got:\n${out}`); } finally { if (prevFlag === undefined) delete process.env[FLAG]; else process.env[FLAG] = prevFlag; resetAdmitted(); diff --git a/tests/kit/host-adapters-cli.test.mjs b/tests/kit/host-adapters-cli.test.mjs index 2edc5e8..92b195e 100644 --- a/tests/kit/host-adapters-cli.test.mjs +++ b/tests/kit/host-adapters-cli.test.mjs @@ -1420,6 +1420,98 @@ test('revoke-grant reports no recorded grants when none existed', async () => { assert.match(cap.text(), /no recorded grants/); }); +// ── revoke-grant with a [capability] positional (F-9, deferred nit) ───── +// Narrower than the whole-record wipe above: only the named capability is +// removed, leaving every other tier result and granted capability intact. + +test('revoke-grant removes only that capability; other tiers and capabilities survive', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'leads a run' }, { file: grantsFile }); + grantCapability('hermes', 'canBePrimary', { hash }, { file: grantsFile }); + recordTierResult('hermes', 'statusline', { hash, evidence: 'footer renders' }, { file: grantsFile }); + grantCapability('hermes', 'commandStatusline', { hash }, { file: grantsFile }); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes', 'canBePrimary'], env: OFF_ENV, cfg: cfgWith([]), grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.match(cap.text(), /revoked capability 'canBePrimary'/); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { commandStatusline: true }); + // The tiers themselves are untouched — still 'passed', not reverted. + const record = grantsFor('hermes', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers.statusline.status, 'passed'); +}); + +test('revoke-grant reports nothing-to-revoke when that capability was never granted', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'leads a run' }, { file: grantsFile }); + // No grantCapability call — the tier passed but was never granted. + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes', 'canBePrimary'], env: OFF_ENV, cfg: cfgWith([]), grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0); + assert.match(cap.text(), /no recorded 'canBePrimary' grant/); + const record = grantsFor('hermes', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'passed', 'the unrelated tier evidence must survive untouched'); +}); + +test('revoke-grant : an invalid/non-grantable capability is rejected, exit 1, nothing changed', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'leads a run' }, { file: grantsFile }); + grantCapability('hermes', 'canBePrimary', { hash }, { file: grantsFile }); + + for (const bogus of ['aqeProvider', 'transcripts']) { + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes', bogus], env: OFF_ENV, cfg: cfgWith([]), grantsFile, flags: {}, + }); + } finally { cap.restore(); } + assert.equal(code, 1, `capability=${bogus}`); + assert.match(cap.text(), /not a grantable capability/); + } + // Nothing touched by the rejected attempts. + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { canBePrimary: true }); +}); + +test('revoke-grant works even when the experimental flag is off (fail-safe, same as the whole-record form)', async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'statusline', { hash, evidence: 'footer renders' }, { file: grantsFile }); + grantCapability('hermes', 'commandStatusline', { hash }, { file: grantsFile }); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes', 'commandStatusline'], env: OFF_ENV, cfg: cfgWith([]), grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); +}); + // ── no exercise/callback surface anywhere in the new commands ─────────── test('the grant/gate/status implementation never destructures or forwards an exercise/callback parameter', () => { diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 67a2868..5c7246c 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -18,6 +18,9 @@ const HOME = sandboxHome('ak-status'); const paths = await import('../../src/lib/paths.mjs'); const status = await import('../../src/commands/status.mjs'); const { loadKitConfig } = await import('../../src/lib/config.mjs'); +const { applyAdmitted, resetAdmitted } = await import('../../src/lib/adapters/admitted.mjs'); +const { registerAdmittedLifecycle, resetAdmittedLifecycle } = await import('../../src/lib/adapters/lifecycle-registry.mjs'); +const { validateAdapterManifest } = await import('../../src/lib/adapters/manifest.mjs'); assertSandboxed(paths, HOME); const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -566,6 +569,137 @@ test('a synthetic fourth host renders through the same host-neutral dispatch loo assert.deepEqual(noRendererRows, []); }); +// D4 (ADR-0031 P3 tracked follow-up): sync.mjs derives its `subsystems` set +// from THIS collector's rows (`new Set(plan.map(p => p.subsystem))`), and its +// admitted-host lifecycle branch is gated on `subsystems.has(hostId) && +// lifecycleExecutionEnabled(hostId, cfg)`. Before this fix, an admitted host +// with no HOST_DETAIL_RENDERERS entry (only opencode has one) produced no row +// at all, so that gate could never open through a real `ak sync` — pinned as +// a documented gap in external-lifecycle.test.mjs. These tests prove +// collect() now emits the row that closes it, with the SAME gating +// lifecycleExecutionEnabled uses (cfg enablement AND the experimental flag). +const SYNC_GAP_FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; +const SYNC_GAP_HOST_ID = 'ak-sync-gap-probe'; + +function syncGapHost(overrides = {}) { + return { + id: SYNC_GAP_HOST_ID, + label: 'Sync Gap Probe', + install: { bin: SYNC_GAP_HOST_ID, externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: false, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +function syncGapManifest() { + return validateAdapterManifest({ + name: SYNC_GAP_HOST_ID, + version: '1.0.0', + contract: 1, + host: syncGapHost(), + detection: { bin: SYNC_GAP_HOST_ID }, + driving: { surfaces: ['acp'] }, + lifecycle: { apply: { hook: { command: [SYNC_GAP_HOST_ID, 'apply'] } } }, + trust: { + changes: [{ + id: 'sync-gap-probe-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'sync-gap-probe', value: 'subprocess hooks', effect: 'run consented lifecycle hooks', + }], + }, + }); +} + +/** Admits the host + registers its lifecycle adapter (no real subprocess ever + * runs here — a status collector must never spawn to decide whether a row + * belongs in the plan, so `runHook` throwing on any call is the correctness + * proof, not just a stub). Cleans up both overlays symmetrically, mirroring + * lifecycle-registry.mjs's own F7 resetAdmittedLifecycle() pairing note. */ +async function withSyncGapHostAdmitted(fn) { + applyAdmitted([{ entry: syncGapHost() }]); + registerAdmittedLifecycle(syncGapManifest(), { + runHook: async () => { throw new Error('a status collector must never spawn a lifecycle hook'); }, + }); + // `await` (not a bare `return fn()`) — the fn is async and `finally` runs + // as soon as control leaves `try`, which for a bare returned promise is + // BEFORE it settles; without the await, resetAdmitted() would tear down + // the overlay while collect() is still mid-flight. + try { return await fn(); } finally { resetAdmitted(); resetAdmittedLifecycle(); } +} + +async function withFlag(value, fn) { + const prev = process.env[SYNC_GAP_FLAG]; + if (value === undefined) delete process.env[SYNC_GAP_FLAG]; else process.env[SYNC_GAP_FLAG] = value; + try { return await fn(); } finally { + if (prev === undefined) delete process.env[SYNC_GAP_FLAG]; else process.env[SYNC_GAP_FLAG] = prev; + } +} + +test('collect(): an admitted lifecycle host enabled + flag on gets a subsystem-tagged, fix-bearing row matching sync\'s gate', async () => { + seedHome(); + const cfg = loadKitConfig(); + cfg.integrations.hosts[SYNC_GAP_HOST_ID] = true; + writeKitConfig(HOME, cfg); + await withFlag('1', async () => withSyncGapHostAdmitted(async () => { + const rows = await collect(); + const hit = rowsFor(rows, SYNC_GAP_HOST_ID); + assert.equal(hit.length, 1, `expected exactly one '${SYNC_GAP_HOST_ID}' row, got ${JSON.stringify(hit)}`); + assert.equal(hit[0].level, 'warn'); + assert.equal(typeof hit[0].fix, 'string'); + assert.match(hit[0].message, /external lifecycle host, enabled/); + // The exact reachability proof: sync.mjs derives subsystems this way. + const subsystems = new Set(rows.filter((r) => r.fix).map((r) => r.subsystem)); + assert.ok(subsystems.has(SYNC_GAP_HOST_ID), + 'sync\'s admitted-host lifecycle branch gates on subsystems.has(hostId) — this row must open it'); + })); +}); + +test('collect(): the experimental flag off (cfg enabled) produces no row for the admitted host', async () => { + seedHome(); + const cfg = loadKitConfig(); + cfg.integrations.hosts[SYNC_GAP_HOST_ID] = true; + writeKitConfig(HOME, cfg); + await withFlag(undefined, async () => withSyncGapHostAdmitted(async () => { + const rows = await collect(); + assert.deepEqual(rowsFor(rows, SYNC_GAP_HOST_ID), []); + })); +}); + +test('collect(): the admitted host enrolled but never enabled in cfg (flag on) produces no row', async () => { + seedHome(); + const cfg = loadKitConfig(); + cfg.integrations.hosts[SYNC_GAP_HOST_ID] = false; + writeKitConfig(HOME, cfg); + await withFlag('1', async () => withSyncGapHostAdmitted(async () => { + const rows = await collect(); + assert.deepEqual(rowsFor(rows, SYNC_GAP_HOST_ID), []); + })); +}); + +test('collect(): with nothing admitted, rows are unaffected (flag-off/no-admitted-host stays byte-identical)', async () => { + seedHome(); + const before = await collect(); + const after = await withFlag('1', async () => collect()); + assert.deepEqual(after, before, 'no admitted lifecycle host registered — the fallback must add nothing'); +}); + +test('collect(): a built-in host (opencode) never gets the generic admitted-host fallback row, even when enabled', async () => { + seedHome(); + const cfg = loadKitConfig(); + cfg.integrations.hosts.opencode = true; + writeKitConfig(HOME, cfg); + const rows = await withFlag('1', async () => collect()); + const stray = rowsFor(rows, 'opencode').find((r) => /external lifecycle host, enabled/.test(r.message)); + assert.equal(stray, undefined, 'opencode already has its own bespoke renderer — the generic fallback must never fire for it'); +}); + // ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT // projected to 'aqe' (unlike ollama, which is) — status must surface that // asymmetry plainly instead of letting it read as a bug.