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 @@ -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 <name> [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: <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 |
Expand Down
47 changes: 47 additions & 0 deletions src/commands/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions src/commands/x/host-adapters-grants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <name>` 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 <name> <capability>` 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
// <name>` (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 <name>'); return 2; }
export function revokeGrant({ name, capability, grantsFile }) {
if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant <name> [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}'`);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/x/host-adapters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
30 changes: 27 additions & 3 deletions src/lib/adapters/conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading