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
4 changes: 2 additions & 2 deletions docs/adr/0031-capability-graduation-and-upstream-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ unbuilt. This table is the source of truth for what is real.
| `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, 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 | **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) |
| 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 |
| 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: <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) |
Expand Down
40 changes: 33 additions & 7 deletions src/commands/x/host-adapters-grants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ function gatingTierFor(capability) {
return Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0];
}

/** F-2 (security re-review, HIGH): what a grant of `capability` ACTUALLY
* does today — precisely bounded, not overclaimed and not underclaimed. The
* D2 keystone (admission.mjs) makes grantedCapabilitiesFor overlay into
* effectiveHostRegistry() on every AK_EXPERIMENTAL_HOST_ADAPTERS=1
* invocation, so a granted canBePrimary is genuinely live from the next
* invocation: hostTierLabel() shows 'drives sessions · can lead' and the
* host joins effectivePrimaryHostIds(). What is still deferred: no
* production path SELECTS an external host as primary today (`ak host pick
* --primary-host` only accepts claude|codex — built-in-scoped), so a
* granted canBePrimary is visible/eligible but not yet auto-consumed.
* commandStatusline reaches the same overlay but has NO runtime reader
* anywhere in src/ (grep-verified) — its statusline render path is a later
* wave, so it is currently inert. F-5: this is also the one-sentence
* distinction between the two grantable caps the disclosure owes the
* maintainer, so both call sites (pre-confirm disclosure, post-grant
* success) share this single source of truth rather than drifting. */
function capabilityStatusNote(capability) {
return capability === 'canBePrimary'
? "canBePrimary is live: from the next ak invocation this host's tier label shows 'can lead' and it joins effectivePrimaryHostIds() — but no production path yet SELECTS an external host as primary ('ak host pick' stays built-in-scoped), so this is visible/eligible, not yet auto-consumed."
: 'commandStatusline is currently inert: it reaches the effective host registry, but no runtime path reads it anywhere yet — the statusline render path is a later wave.';
}

export async function grant({
name, capability, cfg, consent, reader, ask, isTTY, yes, grantsFile,
}) {
Expand Down Expand Up @@ -98,7 +120,8 @@ export async function grant({
}
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.");
info("granting a capability is a trust act, same posture as 'trust': it takes effect in the effective host registry from the next ak invocation.");
info(capabilityStatusNote(capability));

if (!yes) {
if (!isTTY) {
Expand All @@ -118,12 +141,15 @@ export async function grant({

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");
// F-2 (security re-review, HIGH — corrects the prior F-7 wording, which
// was accurate when written but was made FALSE by the D2 keystone
// (admission.mjs) that landed since: grantedCapabilitiesFor is now read on
// every flagged ak invocation and overlaid into effectiveHostRegistry(),
// so a granted capability is NOT inert in general — see
// capabilityStatusNote's header comment for exactly what is and isn't
// live yet, per capability.
info("this grant takes effect from the next ak invocation (with AK_EXPERIMENTAL_HOST_ADAPTERS=1 set): it is reflected in the effective host registry and this host's tier label.");
info(capabilityStatusNote(capability));
return 0;
}

Expand Down
43 changes: 42 additions & 1 deletion src/lib/adapters/admission.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,48 @@ export async function bootstrapHostAdapters({

if (admitted.length) {
const { applyAdmitted } = await import('./admitted.mjs');
applyAdmitted(admitted);

// Keystone (ADR-0031 §1): build the per-host granted-capability lookup
// BEFORE applying the overlay, so an earned canBePrimary/commandStatusline
// is live in effectiveHostRegistry() from process start. Guarded and
// non-fatal, lazy import — the same posture as the execution/lifecycle
// registration blocks below, and a NEW sibling concern to them (it does
// not touch either). One host's grant lookup failing must never block
// another host's, or the admission result itself.
//
// CRITICAL: the hash passed to grantedCapabilitiesFor is computed FRESH
// here, via hashManifest(result.manifest) — never read from a cache or
// carried over from admitOne's own internal hash — because a stale hash
// would silently defeat grants.mjs's edit-invalidation pin (a manifest
// edited since the grant must re-hash to a different value and come back
// {}, dropping the capability). grantedCapabilitiesFor is the ONLY
// sanctioned reader for this (see grants.mjs's module-header invariant);
// reading grantsFor(name).capabilities directly would return the
// unfiltered set and is exactly the bug that invariant exists to prevent.
let grantsByName;
try {
const { grantedCapabilitiesFor } = await import('./grants.mjs');
// Object.create(null), not {} (F-6): admitted host ids come from
// consented adapter names, which are attacker-influenceable in
// principle — a plain object literal's prototype chain would make
// 'constructor' a live (if inert) key collision. No inherited
// properties at all closes that off entirely; admitted.mjs's own
// Object.hasOwn guard on the read side is the belt to this suspenders.
grantsByName = Object.create(null);
for (const result of admitted) {
try {
grantsByName[result.name] = grantedCapabilitiesFor(result.name, hashManifest(result.manifest));
} catch (error) {
warnings.push({ name: result.name, reason: 'grant-lookup-failed', detail: error?.message ?? String(error) });
}
}
} catch {
// grants.mjs unavailable — proceed ungranted (manifest-floor
// capabilities only), exactly like the flag-off path. Never fatal.
grantsByName = undefined;
}

applyAdmitted(admitted, { grantsByName });

// name -> the cfg entry's own declared source, for F-1's baseDir
// derivation below (admitted results carry the validated manifest, not
Expand Down
101 changes: 99 additions & 2 deletions src/lib/adapters/admitted.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,83 @@ function conformOne(item) {
return validateHostAdapter(item?.entry ?? item);
}

// The ONLY two capabilities a grant may ever flip true (ADR-0031 §1: the
// permanent self-declaration ban — canBePrimary/commandStatusline can never
// come from the manifest itself, see manifest.mjs's cap-can-be-primary /
// cap-command-statusline refusals — so the sole other path to `true` is an
// explicit maintainer grant, applied here). Kept local to this module (not
// imported from grants.mjs's TIER_GRANTS) so this file's own guarantee does
// not depend on grants.mjs staying correct — belt-and-suspenders, per the
// keystone security review.
const GRANTABLE_CAPABILITIES = Object.freeze(['canBePrimary', 'commandStatusline']);

/**
* Overlay ONE validated, admitted host entry with its earned capability
* grants. `granted` is the caller-supplied {capability: true, ...} map for
* THIS host — bootstrapHostAdapters (admission.mjs) is the sole production
* caller, and it sources `granted` exclusively from grants.mjs's
* grantedCapabilitiesFor() (hash-pinned to the CURRENT manifest, already
* intersected with TIER_GRANTS at read time — see that module's header
* invariant). This function does not simply trust that upstream filtering:
* it re-intersects with its own local GRANTABLE_CAPABILITIES allow-list
* below, so even a caller that got it wrong (or a future misuse of
* applyAdmitted) can never smuggle a capability other than
* canBePrimary/commandStatusline into the overlay — e.g. aqeProvider, or
* flipping canRouteActivities, can NEVER reach an entry this way, no matter
* what `granted` contains.
*
* Grants may only flip a capped capability TRUE, never back to false: the
* validated manifest capabilities are the floor. With no grant (or a grant
* that changes nothing — every relevant flag already true, or absent), the
* SAME `entry` reference is returned unchanged, so the byte-identity
* guarantee (no grants ⇒ effectiveHostRegistry() entries equal the validated
* manifest entries) holds without a caller having to special-case it.
* @param {any} entry — an already-validated, already-frozen host entry
* @param {Record<string, boolean>|undefined} granted
*/
function withGrantedCapabilities(entry, granted) {
if (!granted) return entry;
let changed = false;
const capabilities = { ...entry.capabilities };
for (const capability of GRANTABLE_CAPABILITIES) {
if (granted[capability] === true && capabilities[capability] !== true) {
capabilities[capability] = true;
changed = true;
}
}
if (!changed) return entry;
return Object.freeze({ ...entry, capabilities: Object.freeze(capabilities) });
}

/**
* Set the admitted overlay. Accepts either raw host entries or admission
* results ({name, admitted, entry, manifest}) — whichever admitAdapters
* handed back — validates + deep-freezes the host entry from each (throws on
* a non-conforming entry), and stores the result. A second call replaces the
* overlay outright (useful for tests via resetAdmitted()); production calls
* this at most once per process since the env flag gates it.
*
* `grantsByName` (optional) is a `{ [hostId]: { canBePrimary?: true,
* commandStatusline?: true } }` lookup — the keystone that makes an earned
* capability grant LIVE (ADR-0031 §1). Omitted (or a host absent from it),
* the stored entry is exactly the validated manifest entry — the flag-off /
* nothing-granted byte-identity this module has always guaranteed.
* @param {ReadonlyArray<any>} entries
* @param {{ grantsByName?: Record<string, Record<string, boolean>> }} [options]
*/
export function applyAdmitted(entries) {
admittedEntries = Object.freeze((entries ?? []).map(conformOne));
export function applyAdmitted(entries, { grantsByName } = {}) {
admittedEntries = Object.freeze((entries ?? []).map((item) => {
const entry = conformOne(item);
// Object.hasOwn, never bare `grantsByName[entry.id]`/`in` (F-6):
// entry.id is a consented-but-still-adapter-supplied string, so an id
// like 'constructor' must never resolve via the prototype chain to
// Object.prototype.constructor and be misread as a truthy grant record.
// Belt-and-suspenders alongside admission.mjs building grantsByName as
// Object.create(null) — this holds even for a caller (a test, a future
// refactor) that passes a plain object literal instead.
const granted = grantsByName && Object.hasOwn(grantsByName, entry.id) ? grantsByName[entry.id] : undefined;
return withGrantedCapabilities(entry, granted);
}));
applied = true;
return admittedEntries;
}
Expand Down Expand Up @@ -74,3 +140,34 @@ export function effectiveRoutableHostIds() {
.filter((host) => host.capabilities.canRouteActivities === true)
.map((host) => host.id);
}

/** Built-ins ∪ admitted hosts whose EFFECTIVE capabilities (validated
* manifest floor + any live grant overlay from applyAdmitted) include
* canBePrimary — the primary-eligibility sibling to effectiveRoutableHostIds
* above (ADR-0031 §1 keystone: this is what makes a granted canBePrimary
* actually count somewhere). hosts.mjs's drivingHost() consults this for its
* eligibility check — the primitive is live, though no production path
* currently drives kit.json's routing.primaryHost to an admitted external
* id (the picker that writes it stays built-in-only), so today this has no
* live privileged caller.
*
* Deliberately NOT wired into routing.mjs's PRIMARY_HOSTS constant, which
* stays frozen at import time and built-ins-only: PRIMARY_HOSTS backs the
* primary-host SELECTION UX (`ak setup --primary-host`, `ak host pick`),
* and extending that picker to external hosts is out of scope this wave
* (deferred, same as HOSTS staying display-only in routing.mjs) — an
* eligibility reader and a selection-menu source are different concerns
* even though both currently trace back to the same capability.
*
* Homed here (next to effectiveHostRegistry/effectiveRoutableHostIds), not
* in registries.mjs where the built-in-only primaryHostIds() lives: this
* file already imports registries.mjs for HOST_REGISTRY, so an
* effective-registry-aware selector living in registries.mjs would need the
* reverse import and create a real module cycle; lifecycle-registry.mjs's
* own effectiveHostRegistry import mirrors this same choice. Fresh on every
* call, like effectiveRoutableHostIds() above. */
export function effectivePrimaryHostIds() {
return effectiveHostRegistry()
.filter((host) => host.capabilities.canBePrimary === true)
.map((host) => host.id);
}
Loading
Loading