Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/adr/0031-capability-graduation-and-upstream-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: <repo>#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: <repo>#NNN` against a tier) | **Working** (2026-08-16, wave D) | `ak host adapters gate <name> <tier> <ref>` 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
Expand Down
241 changes: 241 additions & 0 deletions src/commands/x/host-adapters-grants.mjs
Original file line number Diff line number Diff line change
@@ -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 <name> <capability>');
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 <name> <tier> <ref>');
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 <name>'); 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;
}
Loading
Loading