diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md
new file mode 100644
index 0000000..9918adb
--- /dev/null
+++ b/docs/AUTHORING-HOST-ADAPTERS.md
@@ -0,0 +1,393 @@
+# Authoring a host adapter
+
+A walkthrough for adding a new agent CLI to `ak` without touching `ak`. The running example is
+**Hermes** — the fourth-host candidate this contract was designed against, and the adapter that
+would graduate it. Substitute your own host throughout.
+
+Read this alongside [ADR-0029](adr/0029-host-adapter-extension-point.md) (the contract) and
+[ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md) (how a capability is earned).
+For what the kit does with a host once it knows about one, see
+[HOST-SUPPORT.md](HOST-SUPPORT.md) and [PROVIDERS.md](PROVIDERS.md).
+
+> **Experimental.** The whole surface is inert unless `AK_EXPERIMENTAL_HOST_ADAPTERS=1` is set, and
+> `contract: 1` can still change between alpha releases. See [The freeze](#9-the-freeze-and-why-you-matter).
+
+## 1. What you're actually building
+
+Two things:
+
+- **One JSON manifest** — a description of your host. Every value is JSON-serializable. A manifest
+ that cannot express a closure cannot smuggle one in.
+- **A handful of small hook scripts** — the only part that ever executes.
+
+**Nothing you write runs inside the `ak` process.** Every hook is a subprocess `ak` spawns,
+supervises, and owns the termination of. `ak` never calls `import()` or `require()` on a path you
+supply, and registering your adapter adds no npm dependency to `@pacphi/agentic-kit`.
+
+That constraint is also your leverage. ADR-0029 records what adding a host used to cost: a dedicated
+owner module, native surfaces reverse-engineered from scratch, and edits across roughly a dozen
+files and eight test suites — plus a permanent maintenance obligation on a maintainer who may not
+run your CLI. That collapses to a manifest and some hooks. You write no `ak` code, own no module in
+this repository, and reverse-engineer no internals.
+
+What you give up in exchange is real and is covered in [section 8](#8-what-earning-actually-gets-you-today).
+
+## 2. Write the manifest
+
+Name it whatever you like as a file; name it exactly `ak-adapter.json` at the package root if you
+publish it on npm. Here is a complete, valid one — this shape validates against `ak`'s real
+validator:
+
+```json
+{
+ "name": "hermes",
+ "version": "0.1.0",
+ "contract": 1,
+ "host": {
+ "id": "hermes",
+ "label": "Hermes",
+ "install": { "bin": "hermes", "externalInstallPolicy": "detect-never-overwrite" },
+ "capabilities": {
+ "canDriveSession": false,
+ "canBePrimary": false,
+ "canRouteActivities": true,
+ "commandStatusline": false,
+ "transcripts": false,
+ "usage": false,
+ "nativeMcpConfig": false,
+ "nativeGuidance": false
+ },
+ "trust": { "approvalPolicy": "unchanged", "changes": [] },
+ "enabledByDefault": false,
+ "configProjection": "ruflo",
+ "observability": []
+ },
+ "detection": {
+ "bin": "hermes",
+ "versionArgs": ["--version"],
+ "versionPattern": "\\d+\\.\\d+\\.\\d+"
+ },
+ "driving": { "surfaces": ["cli-subprocess"] },
+ "lifecycle": {
+ "detect": { "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } }
+ },
+ "execution": {
+ "run": { "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 120000 } }
+ },
+ "trust": {
+ "changes": [
+ {
+ "id": "hermes-subprocess-hooks",
+ "kind": "third-party-adapter",
+ "scope": "project",
+ "owner": "hermes",
+ "value": "subprocess hooks",
+ "effect": "run consented lifecycle and execution hooks for hermes"
+ }
+ ]
+ }
+}
+```
+
+Field by field:
+
+| Field | What it's for |
+| --- | --- |
+| `name` / `host.id` | Your host's id. They must agree, and must not collide with a built-in. |
+| `host.label` | Human-readable name shown in status output. |
+| `host.install.bin` | Your CLI's binary name. `externalInstallPolicy: "detect-never-overwrite"` is the honest posture — `ak` finds your CLI, never installs or upgrades it. |
+| `host.capabilities` | All eight keys are **required**. Declare what your host legitimately does. |
+| `host.trust` / `trust.changes` | Up-front disclosure of what your adapter touches. `trust.changes` is what the user reads before consenting. |
+| `detection` | How `ak` proves your CLI is present: the binary, the version arguments, and a regular-expression source for the version. |
+| `driving.surfaces` | Declare `cli-subprocess`. See below. |
+| `lifecycle` / `execution` | Your hooks ([section 3](#3-write-the-hooks)). Both are optional; a manifest with neither is a pure description. |
+
+### Driving surfaces
+
+The vocabulary has three names — `cli-subprocess`, `acp`, `mcp` — but **`cli-subprocess` is the only
+one with a working implementation.** Declare an `execution` block without `cli-subprocess` in
+`driving.surfaces` and you get no execution adapter at all — the registration is refused
+`surface-unsupported`, never silently downgraded to a surface you didn't test against. `acp` and
+`mcp` are reserved for forward compatibility, and nothing drives them today.
+
+### The three capabilities you cannot claim
+
+`canBePrimary`, `commandStatusline`, and `aqeProvider` are **not yours to assert**:
+
+- `canBePrimary` and `commandStatusline` are required keys, and the schema accepts them **only at
+ `false`**. Writing `true` is rejected before anything runs (`cap-can-be-primary`,
+ `cap-command-statusline`). You cannot write down the claim; you **earn** the capability through a
+ conformance tier plus an explicit maintainer grant, recorded outside your manifest entirely
+ (ADR-0031 §1).
+- `host.legacy.aqeProvider` must be absent — `cap-aqe-provider` refuses any value. This one is not
+ earnable at all through `ak`: agentic-qe's provider set is a closed upstream enumeration, so being
+ an AQE provider type is upstream's to grant. See [section 9](#9-the-freeze-and-why-you-matter).
+
+Self-declaration is the attack surface, so it stays closed permanently. The *capability* is a ladder;
+the *declaration* is a wall.
+
+### One structural coupling worth knowing
+
+Declaring `execution` while `canRouteActivities` is `false` is a contradiction the schema refuses
+outright (`execution-not-routable`). The converse is fine: a routable host with no `execution` block
+is legal and degrades honestly at run time as `cli_unavailable`.
+
+## 3. Write the hooks
+
+Hooks are ordinary executables. They read stdin, read a few environment variables, print to stdout,
+and exit with a code. No SDK, no imports from `ak`.
+
+### The `detect` hook (lifecycle)
+
+Reports that your host is present and describes what it found. It prints a JSON object on stdout;
+`ak` parses it and returns it verbatim as the `detect` result.
+
+```js
+// detect-hook.mjs
+process.stdout.write(JSON.stringify({
+ observed: { host: 'hermes', bin: 'hermes', version: '1.4.2' },
+}));
+```
+
+`detect`, `plan`, `apply`, `verify`, and `undo` are the five lifecycle verbs. Declare only the ones
+you need — an undeclared verb is an honest no-op, not a fabricated success. `apply` and `undo` are
+what wire (and unwire) your host's own configuration when the user runs `ak setup` / `ak uninstall`.
+
+### The `execution.run` hook
+
+This is the one that actually drives your host as a worker under `ak run`.
+
+- **stdin** carries the worker prompt.
+- **the environment** carries `AK_WORKER_ID`, `AK_WORKER_ACTIVITY`, `AK_WORKER_ROLE`,
+ `AK_WORKER_MODEL`, and `AK_WORKER_CWD` (the repository being worked on — your hook does *not*
+ spawn there, see below).
+- **stdout** carries either a JSON object — `{summary, observedModel, provider, usage}`, all
+ optional — or plain text, which is taken as the summary.
+- **the exit code** is the sole authority for success.
+
+```js
+// run-hook.mjs
+let prompt = '';
+process.stdin.setEncoding('utf8');
+process.stdin.on('data', (chunk) => { prompt += chunk; });
+process.stdin.on('end', async () => {
+ const outcome = await driveHermes(prompt, {
+ cwd: process.env.AK_WORKER_CWD,
+ model: process.env.AK_WORKER_MODEL || undefined,
+ });
+ if (outcome.needsLogin) process.exit(78);
+ process.stdout.write(JSON.stringify({
+ summary: outcome.text,
+ observedModel: outcome.model,
+ provider: 'hermes',
+ }));
+});
+```
+
+### Exit codes
+
+| Code | Meaning | What the runner does |
+| --- | --- | --- |
+| `0` | Success | Reads your stdout payload. |
+| `77` | Permission refused — you need consent you don't have | Records `blocked` / `permission_required` and **never escalates**. Escalating around a consent boundary is the safety violation ADR-0019 forbids. |
+| `78` | Authentication required | Records `failed` / `auth_required`; the runner may retry on another host. |
+| other non-zero | Ordinary failure | Records `failed` / `worker_error`; may be escalated. |
+
+Codes `77` and `78` exist so you can say "I refused" or "I'm not logged in" honestly, instead of a
+bare non-zero exit that reads as a generic failure.
+
+### Rules `ak` enforces on every hook
+
+These aren't suggestions — they're the supervision that makes running your code safe, and they shape
+how you write it:
+
+- **Anchored working directory.** A hook spawns with its cwd pinned to *your adapter's own resolved
+ directory*, never `ak`'s. So `["node", "run-hook.mjs"]` resolves to **your** `run-hook.mjs`, and a
+ file planted in the operator's cwd is unreachable. This is why `AK_WORKER_CWD` exists: it's how
+ you learn which repository to work on. A *remote*-sourced manifest (`npm:` / `https://`) has no
+ local directory to anchor to, so a relative command from such a source is refused
+ (`execution-unanchored` / `lifecycle-unanchored`) — publish remotely and you must use absolute
+ paths or bare PATH binaries.
+- **Minimal environment.** Your hook gets `PATH`, `HOME`, and whatever `ak` injects for that verb —
+ never `ak`'s full environment. Don't expect to inherit the operator's secrets.
+- **Bounded output.** Captured output is capped at 256 KB and truncated with a marker beyond that.
+- **Bounded time.** Your declared `timeoutMs` and `ak`'s own budget both apply, and the tighter one
+ wins; with neither, a hook gets 30 seconds. On timeout the whole process group is killed — there is
+ no grace period, because the budget is already spent.
+- **No shell.** `command` is an argv array, spawned directly. There is no shell to quote against.
+- **stderr is never promoted.** Diagnostics you write to stderr are captured for the operator but
+ never folded into a downstream worker's prompt. Practical consequence: **write your JSON payload
+ to stdout only** — stderr chatter won't corrupt a valid JSON parse, but a plain-text summary is
+ discarded if you also wrote to stderr.
+- **Results never launder trust.** A `provider` you declare in your payload is stamped `inferred`,
+ never `observed` — `ak` didn't verify it against anything.
+
+## 4. Publish it, and let a user opt in
+
+Ship the manifest any of three ways:
+
+| Source form | Example | Note |
+| --- | --- | --- |
+| File | `~/.config/ak/adapters/hermes.json` | Fully anchored; relative hook commands work. |
+| npm | `npm:@you/ak-adapter-hermes` | Must ship `ak-adapter.json` at the package root. `ak` reads it from the tarball to stdout — nothing is extracted to disk and your package scripts never run. |
+| URL | `https://example.com/hermes.json` | No redirects, bounded time and bytes, no credentials attached. |
+
+A user then registers it in their own `kit.json`:
+
+```json
+{
+ "hostAdapters": [
+ { "name": "hermes", "source": "~/.config/ak/adapters/hermes.json", "contract": 1 }
+ ]
+}
+```
+
+and opts in:
+
+```bash
+export AK_EXPERIMENTAL_HOST_ADAPTERS=1
+ak host adapters trust hermes
+```
+
+`trust` prints the full validated manifest — every hook command that will spawn is right there in it
+— then asks for confirmation before pinning a hash of that content. **Edit the manifest afterwards
+and consent invalidates**: the adapter is not admitted again until the user re-confirms the new
+content. Consent lives outside your code, attached to a specific byte sequence, never to a name your
+content could drift underneath.
+
+Three more notes for your install docs:
+
+- Unattended consent to a **remote** source (`npm:` / `https://`) requires `--yes --expect-hash
+ `. `--yes` alone is refused for a non-file origin, so a CI run can never blanket-consent to
+ whatever the remote happens to serve. Publish the hash alongside your adapter.
+- With the flag unset, a `hostAdapters` entry is parsed and preserved but never admitted. It is
+ reported present-but-inactive, not silently dropped.
+- Your **lifecycle** hooks only run during `ak setup` / `ak sync` / `ak uninstall` when the user has
+ *also* explicitly enabled your host under `integrations.hosts` in `kit.json`. There is no pick-UI
+ for external hosts yet.
+
+## 5. Self-test with the conformance kit
+
+```bash
+ak host adapters conformance hermes
+```
+
+This runs the tiered black-box harness against your **real** host — spawning your actual hooks — and
+prints an honest per-tier verdict. It warns first, listing every hook command it is about to run as
+a real subprocess. Here is a real run against the repository's conformance fixture
+(an adapter shaped exactly like the manifest in section 2):
+
+```text
+host adapter conformance — acme (56fa107674d2)
+admission passed host id 'acme', contract 1
+session-driving skipped not declared — nothing to prove
+activity-routing passed registered for 'acme'
+primary-eligible passed 'acme' completed a direct (non-escalated) run to a succeeded result, and…
+statusline gated ak-local: awaiting maintainer grant for 'commandStatusline' (not an…
+```
+
+(Detail columns elided at the right margin; the real ones run longer.)
+
+What each tier means for you:
+
+| Tier | What it proves | What to expect |
+| --- | --- | --- |
+| `admission` | Your manifest validates, admits through the fail-closed gate, joins the registry, and your `detect` hook runs as a real subprocess. | **Can genuinely pass.** A failure here short-circuits every downstream tier, so nothing gets laundered. |
+| `session-driving` | Gates `canDriveSession`. | `skipped` if you don't declare it. **`gated` if you do** — `ak` has no external session-driving path, and being a native ruflo backend is upstream-owned. |
+| `activity-routing` | Gates `canRouteActivities`. A real one-worker `ak run` routed to your host returns a succeeded `WorkerResult`. | **Can genuinely pass.** |
+| `primary-eligible` | Earns `canBePrimary`. Your host anchors a real run *and* receives a genuine ADR-0019 escalation onto itself — a second real subprocess. | **Can genuinely pass**, with no pre-existing grant. |
+| `statusline` | Earns `commandStatusline`. | **`gated`.** There is no admitted-host footer-render path yet, so even a granted capability has nothing real to drive. |
+
+**A `gated` or `skipped` result on `session-driving` and `statusline` is expected, not your adapter
+failing.** The harness never fabricates a pass, and there is no injection seam through which a caller
+could substitute one. Only `failed` means something is wrong with your adapter.
+
+Evidence is hash-pinned to your manifest, so any edit voids it. And it's a two-way street: if a
+grant-bearing tier later re-runs `failed` at the same manifest hash, the stored evidence *and* the
+live capability are auto-voided.
+
+## 6. Propose it for graduation
+
+You don't need a maintainer to ship. Publish, and any user can opt in behind the flag with pinned
+consent — that path is entirely yours.
+
+To go further, hand a maintainer your conformance report. They:
+
+1. **Re-run the conformance kit** themselves — conformance is objective, and trust rests on
+ reproduced evidence rather than your report.
+2. **Read your hook scripts** — the only part that executes.
+3. **Decide** the tier and the destination.
+
+Two destinations, the maintainer's call:
+
+- **Blessed external adapter** — `ak host adapters bless hermes ` (`grant` is the same
+ command). Your adapter stays out-of-tree and experimental, holding exactly the capabilities its
+ tiers earned. A grant is refused unless the gating tier is recorded `passed` at the current
+ manifest hash, and it's re-checked at read time, not just at write time.
+- **Promoted built-in** — your host descriptor is adopted into the first-party registry. This is now
+ an ordinary PR: a registry entry, a lifecycle adapter, an About card. Once built-in, the caps no
+ longer apply, because it is first-party code the maintainer vouches for. That's what promotion
+ means.
+
+A tier you can't clear because the capability lives upstream is recorded as
+**`gated: #NNN`**, not failed:
+
+```bash
+ak host adapters gate hermes session-driving ruvnet/ruflo#1234
+ak host adapters status hermes
+```
+
+The maintainer champions that request upstream with a named extension point, and `status` shows
+exactly what your adapter is waiting on.
+
+## 7. The commands, in one place
+
+```bash
+ak host adapters list # configured adapters and their consent state
+ak host adapters trust # disclose the manifest, confirm, pin the hash
+ # (--yes --expect-hash for unattended/remote)
+ak host adapters revoke # withdraw consent (works with the flag off)
+ak host adapters conformance # run the tiered harness
+ak host adapters status # per-tier state + granted capabilities
+ak host adapters grant # maintainer: confer an earned capability (alias: bless)
+ak host adapters revoke-grant [cap] # withdraw a granted capability
+ak host adapters gate # record an upstream blocker against a tier
+```
+
+## 8. What earning actually gets you today
+
+Be clear-eyed about this, because the machinery is ahead of its consumers:
+
+- **A granted `canBePrimary`** raises the capability on your host's effective registry entry, so
+ `hostTierLabel` and the primary-eligible host set reflect it. But **no path yet *selects* an
+ external host as primary** — `ak host pick` is still built-in-scoped. You show as eligible; nothing
+ acts on it.
+- **A granted `commandStatusline` is currently inert.** There is no runtime reader for an
+ admitted host's command-backed footer. The grant records what you earned; nothing renders it yet.
+
+Both gaps are disclosed at grant time, and both are `ak`-local work rather than upstream ceilings —
+they will light up without needing anything from you. What works end-to-end today is
+`activity-routing`: a real `ak run` worker on your host, supervised, with a structured result.
+
+## 9. The freeze, and why you matter
+
+Two ceilings are genuinely not `ak`'s to lift:
+
+- **Native ruflo backend** (`session-driving`). ruflo's backend enablement is per-host and defined
+ inside ruflo, not through an outside registration surface. *Interim:* your host runs through `ak`'s
+ own supervised execution — just not as a ruflo-native backend.
+- **agentic-qe provider type** (`aqeProvider`). agentic-qe's provider set is a closed upstream
+ enumeration, extended only by an upstream code change. *Interim:* quality still runs through the
+ model provider underneath your host, so QE isn't blocked — only your host's own AQE identity is.
+
+Neither is faked, hidden, or shimmed. Each gets a tracked capability request and an honest interim
+behaviour ([ADR-0031 §4](adr/0031-capability-graduation-and-upstream-requests.md)).
+
+And the contract itself stays experimental until a **real external adapter clears the full
+conformance kit and survives one release of soak in the field with no contract-shape change**. That
+adapter could be yours. Until then, `contract: 1` may change between alpha releases — bounded
+instability that exists precisely so the first real adapter's conformance run can surface shape
+problems cheaply.
+
+So a real Hermes isn't just a beneficiary of this machinery. It's the test that graduates the
+contract from experimental to frozen.
diff --git a/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md
new file mode 100644
index 0000000..5004312
--- /dev/null
+++ b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md
@@ -0,0 +1,76 @@
+# Host-adapter contract freeze checklist (ADR-0029 §graduation, ADR-0031 §6)
+
+The external host-adapter contract (`contract: 1`) is **experimental** and behind
+`AK_EXPERIMENTAL_HOST_ADAPTERS=1`. This is the falsifiable checklist for freezing it —
+turning `contract: 1` into a guaranteed-stable shape and dropping the flag. **Do not claim
+the freeze until every box is genuinely checked against a real adapter.** The freeze is not
+a maintainer's assertion; it is an earned, evidenced event.
+
+## The freeze criterion (ADR-0031 §6, ADR-0029 graduation gate)
+
+Freeze requires **all** of:
+
+1. A **real external adapter** — maintained *outside this repository, by someone other than
+ an agentic-kit maintainer* (Hermes is the expected first) — passes the full conformance
+ kit against its real host.
+2. That adapter completes **one full release's worth of soak** in the field with **no
+ contract-shape change** required to keep it working.
+3. Only then does the manifest **contract integer** freeze: `contract: 1` becomes stable,
+ and any later breaking change ships as `contract: 2`, admitted alongside `contract: 1`.
+
+Tier graduation and the contract freeze are distinct: an adapter can earn capability tiers
+while the contract is still experimental.
+
+## Readiness — machinery the freeze rests on (all shipped; verify before soak)
+
+- [ ] Admission gate, hash-pinned consent, subprocess hook-runner, `admission` tier
+ (ADR-0029).
+- [ ] `ak host adapters trust` / `list` / `revoke` (+ `--expect-hash`).
+- [ ] Remote manifest sources (file / `https` / `npm:`), resolve-before-hash.
+- [ ] `ak run` drives an admitted routable host (cwd-anchored, exit-code authority,
+ reserved 77/78, no trust laundering).
+- [ ] Admitted lifecycle execution through setup / sync / uninstall.
+- [ ] Tiered conformance kit: `admission`, `activity-routing`, `primary-eligible` pass
+ against a real fixture; `session-driving` / `statusline` honestly gated.
+- [ ] Capability-grant store + `grant`/`bless`, `gate`, `status`, `revoke-grant`; earned
+ capability enforced at read time; granted caps live in the effective registry.
+
+## Freeze gate — the real-adapter run (fill in with evidence, not intent)
+
+- [ ] **Real external adapter identified**, maintained outside this repo by a non-maintainer.
+ Adapter: `__________` Maintainer: `__________` Source: `__________`
+- [ ] **Conformance report captured** from `ak host adapters conformance ` against the
+ real host (attach or link the per-tier verdict).
+ - [ ] `admission` passed
+ - [ ] `activity-routing` passed
+ - [ ] `primary-eligible` passed *(or consciously out of scope for this adapter)*
+ - [ ] `session-driving` — passed **iff** the upstream ruflo backend-registration request
+ (`docs/upstream-requests/ruflo-backend-registration.md`) has shipped; otherwise
+ legitimately `gated` and recorded via `ak host adapters gate`.
+ - [ ] `statusline` — `gated` remains acceptable at freeze (its render path is a later wave);
+ the freeze is of the **contract shape**, not of every tier passing.
+- [ ] **Hooks read** by a maintainer (the only executing part).
+- [ ] **Grant/bless decision** recorded (blessed external adapter, or promoted built-in).
+- [ ] **Soak: one full release** elapsed with the adapter in the field and **no
+ contract-shape change** required. Release soaked through: `__________`.
+- [ ] **No `contract: 1` shape change** was needed during soak (if one was, the clock resets).
+
+## Only when every box above is genuinely checked
+
+- [ ] Set the manifest `contract` shape to frozen; document that a breaking change now ships
+ as `contract: 2` admitted alongside `contract: 1`.
+- [ ] Drop `AK_EXPERIMENTAL_HOST_ADAPTERS` gating for the frozen surface (or flip its default),
+ per the ADR-0029/0031 freeze decision.
+- [ ] Update ADR-0029 (graduation gate) and ADR-0031 §6 status to **frozen**, dated, with the
+ adapter + release that earned it.
+
+## Honest ceilings that do NOT block the freeze
+
+The freeze is of the **contract shape**, not of universal tier passage. These stay open by
+design and are recorded as gated, not as failures:
+
+- `session-driving` until ruflo ships a backend-registration surface (upstream request drafted).
+- An `aqeProvider` identity until agentic-qe ships a provider-plugin API (upstream request drafted).
+- `statusline` runtime rendering (no `ak` render surface for a third-party TUI yet).
+- Grant *consumption* last-mile: selecting an external host as primary, and a `commandStatusline`
+ runtime reader.
diff --git a/docs/HOST-EXTENSIBILITY-EXPLAINER.html b/docs/HOST-EXTENSIBILITY-EXPLAINER.html
index 5c4fd3a..f079c33 100644
--- a/docs/HOST-EXTENSIBILITY-EXPLAINER.html
+++ b/docs/HOST-EXTENSIBILITY-EXPLAINER.html
@@ -199,7 +199,7 @@
Is this what lets us use Gemini CLI, Hermes, and others?
-
Yes — this is the foundation that makes those possible.
- It is not yet a finished on-ramp an external assistant can be driven across today. What exists now:
- the kit can recognize and register an external host from a manifest, safely and
- reversibly, behind an experimental switch. What comes next is turning that recognition into
- running work through it. The staging is spelled out in
- section 5 — honestly, so nobody expects more than is there.
+
Yes — and the on-ramp is open. Behind an experimental switch, an
+ external host described by a manifest can be approved
+ (ak host adapters trust discloses it and pins its exact content), run
+ (ak run drives it as a supervised worker, as a real subprocess), and
+ certified (a tiered conformance kit exercises it against your actual CLI). What's
+ still genuinely ahead is narrower than it used to be: two conformance tiers remain gated
+ — session-driving waits on upstream, statusline on a render surface that isn't
+ built — and an earned capability is only partly consumed, since nothing yet selects an external
+ host as primary. The one thing the kit can't supply is the adapter itself: no Hermes or Gemini CLI
+ manifest exists yet, so using one of those still starts with someone writing it
+ (the authoring guide is the walkthrough).
+ Section 5 has the honest limits, stated plainly.
2 · Two kinds of host
@@ -308,13 +314,14 @@
Built-in hosts here now
-
External adapters the foundation
+
External adapters experimental
Hermes · Gemini CLI · anything CLI-shaped
Described by a manifest the adapter author publishes — not bundled in the kit.
Enter only through the admission gate, only behind an experimental switch.
Cannot self-declare that they lead, bill, or own a status line.
-
Today: can be recognized and registered. Running work through them is the next step.
+
Today: trusted, run via ak run, and able to earn capabilities through
+ conformance — behind the experimental flag.
@@ -476,8 +483,8 @@
What the kit guarantees about an external host
status line; OpenCode: none). "Primary" means leading the session — a separate axis from
taking part in ak run, which OpenCode does as a supervised worker (unpacked in
"Leading vs. being supervised" just below). The safety point stands:
-an external adapter cannot self-declare any of these — the schema has no field to say so —
-but a capability can be earned via conformance
+an external adapter cannot self-declare any of these — the schema refuses the claim outright,
+before anything runs — but a capability can be earned via conformance
(section 7).
Leading vs. being supervised
@@ -572,36 +579,39 @@
Leading vs. being supervised
do is lead one — be the default, own the reasoning roles, or be the host escalation resolves
toward. That authority stays with a host that can be primary.
-
5 · Where we are — and what's honestly next
-
The door is built and its safety is proven. But a door a host can be registered through is
-not yet a door work can be run through. Here is the truthful staging.
+
5 · Where we are today
+
The door is built, its safety is proven, and work now runs through it. What remains is not
+plumbing — it's evidence: a real outside adapter has to walk the whole path before the
+contract can stop being experimental. Here is the truthful staging.
-Foundation laid, on-ramp staged. Only the first box is done. Each later
-box is a small, self-contained increment behind the same experimental switch — so nobody is exposed
-to a half-built external host by accident.
+On-ramp open; the proof is what's left. Admitting, approving, and running
+an external host all work today. The last two boxes can't be built — they have to be
+earned, by a real outside adapter clearing the conformance kit and then surviving a release
+in the field. The experimental switch stays on until that happens, so nobody meets a half-proven
+external host by accident.
What can be expected today
@@ -631,36 +643,42 @@
What can be expected today
shipped payoff, and it needs no flag.
The internals are ready for more — a new host is a registry entry or a
manifest, not a rewrite.
-
An external host can be recognized and registered from a manifest, safely and
- reversibly, behind AK_EXPERIMENTAL_HOST_ADAPTERS=1.
+
An external host can be trusted, run, and can earn capabilities — from a
+ manifest, safely and reversibly, behind AK_EXPERIMENTAL_HOST_ADAPTERS=1:
+ ak host adapters trust records hash-pinned consent, ak run drives it as a
+ supervised subprocess, and the conformance kit certifies it tier by tier.
Limitations — read before expecting an external host to "just work"
-
Work can't be run through an external host yet. The kit can admit one into
- its registry, but driving a task through it (the ak run path) is not built — an
- admitted external host currently reports "no execution available" rather than running.
-
There's no command to approve one yet. Approval is checked at admission, but
- the ak host adapters trust command that would record it isn't built — so in practice
- admission refuses until that lands. This is the intended next step.
-
Manifests are local files only. Fetching an adapter from npm or a URL isn't
- built; a manifest is a file path today.
An external host can't self-declare that it's primary, an AQE provider, or a
status-line owner. That block on self-declaration is permanent — it's the safety
invariant. But the capability itself is earnable: pass the matching conformance tier and
- get a maintainer's grant, and an adapter reaches full parity (the graduation ladder in
- section 7). One honest exception — being an AQE provider
- type isn't the kit's to grant; agentic-qe's provider list is defined upstream
- (section 8).
-
It's experimental. The contract version can still change; nothing about the
- external-adapter surface is promised stable until the final "Open" stage above.
+ get a maintainer's grant (the graduation ladder in section 7). One
+ honest exception — being an AQE provider type isn't the kit's to grant; agentic-qe's
+ provider list is defined upstream (section 8).
+
Two conformance tiers can't be cleared yet, by design.session-driving
+ is gated on an upstream ruflo backend, and statusline is gated
+ because the kit has no footer-render surface for a third-party host. They report
+ gated/skipped honestly — never a fabricated pass.
+
An earned capability isn't fully consumed yet. A granted
+ canBePrimary makes a host show as primary-eligible, but no path yet selects
+ an external host as primary (ak host pick stays built-in-scoped); a granted
+ commandStatusline is currently inert (nothing reads it at runtime).
+
It's experimental.contract: 1 can still change between alpha
+ releases; nothing about the external-adapter surface is promised stable until a real external
+ adapter clears the full kit and soaks — the final "Open" stage above.
6 · For implementers: the shape of an adapter
-
When the on-ramp is complete, authoring a host adapter will mean shipping one manifest and a few
-small hook scripts. The manifest looks like this (illustrative):
+
Authoring a host adapter means shipping one JSON manifest and a few small
+hook scripts — nothing else, and no code of yours ever runs inside the kit's own process.
+The step-by-step walkthrough lives in
+docs/AUTHORING-HOST-ADAPTERS.md; this is the
+shape of it.
+
The manifest (abridged — the guide has a complete, validating one):
+]
+
+$ export AK_EXPERIMENTAL_HOST_ADAPTERS=1
+$ ak host adapters trust hermes # discloses the manifest, then pins its hash
+
A manifest can also be published as an npm package shipping ak-adapter.json at its
+root, or as an https URL.
-
The conformance kit is the contract. A committed test harness admits a real
- fixture adapter, runs its hooks as real subprocesses, and refuses a corpus of bad manifests with
- exact reasons — the same bar a real adapter (Hermes first) must clear to graduate.
+
The hooks are the only code that runs — as supervised subprocesses. A hook
+ reads the worker prompt on stdin, gets the worker's identity in its environment, prints a JSON
+ result, and exits: 0 for success, 77 to refuse on a consent boundary
+ (never escalated), 78 for "not authenticated". The kit pins each hook's working
+ directory to the adapter's own bundle, hands it a minimal environment, bounds its output and its
+ time, and never promotes its stderr into another worker's prompt.
Approval is pinned to the bytes. Consent is a hash of the exact manifest;
edit one character and the kit asks again. Content is never approved unseen.
Refusals are specific. An unknown field, a forbidden capability, a bad
contract version, a name that collides with a built-in — each is refused by name, and one bad
adapter never disturbs the others or the built-ins.
+
Self-test before you ask anyone for anything.
+ ak host adapters conformance hermes runs the tiered harness against your real host and
+ reports each tier honestly. admission, activity-routing and
+ primary-eligible can genuinely pass — the last one by driving a real run and receiving
+ a real escalation onto your host. session-driving and statusline come back
+ gated (or skipped, if you never declared the capability) because those paths are
+ not built; that is expected, not your adapter failing. The harness never fabricates a pass.
+
+
What an author should not expect yet
+
+
cli-subprocess is the only working driving surface.
+ acp and mcp are named in the vocabulary for forward compatibility;
+ declaring one is refused, never silently downgraded.
+
An earned canBePrimary is only partly consumed. A grant does
+ raise the capability, so the host shows as primary-eligible — but no path yet selects an
+ external host as primary. An earned commandStatusline is currently inert: there is no
+ runtime reader for an admitted host's footer.
+
The contract is still experimental.contract: 1 can change
+ between alpha releases, and freezes only once a real external adapter clears the full kit and
+ survives a release of soak.
+
+
7 · From a contributor's idea to a built-in host
This is the part that ties it together: the exact sequence from "someone wants to add Hermes" to
@@ -1010,8 +1066,8 @@
What it doesn't — and who owns it
point and its safety model are ADR-0029 (which supersedes ADR-0016's original "closed registry"
stance, narrowly — nothing under a manifest is ever loaded into the kit's process). The graduation
ladder — experimental external adapter, earned capabilities via conformance tiers, promotion to
-built-in, and the upstream capability-request path to ruflo and agentic-qe — is the proposed next
-decision (a prospective ADR-0031), not yet ratified. Upstream facts (agentic-qe's closed provider
+built-in, and the upstream capability-request path to ruflo and agentic-qe — is ADR-0031, accepted as
+a governance decision with its machinery staged and its own implementation-status table. Upstream facts (agentic-qe's closed provider
enum; ruflo's ENABLE_* backend model) are grounded in a source-cited research sweep of
agentic-qe@3.13.10 and ruvnet/ruflo@45e65b5. Hermes and Gemini CLI are named
here as illustrative candidates for the external door, not as hosts the kit supports today.
diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md
index 2c9cdb1..46c5cdb 100644
--- a/docs/HOST-SUPPORT.md
+++ b/docs/HOST-SUPPORT.md
@@ -6,11 +6,18 @@ RuvNet Brain without treating those independent layers as interchangeable.
Behind an experimental flag, agentic-kit can also admit **external host adapters**
that extend this set with a host not shipped in-tree — see
-[External host adapters](PROVIDERS.md#external-host-adapters-experimental) and
-[ADR-0029](adr/0029-host-adapter-extension-point.md). An admitted external host
-picks up the same capability-driven treatment described here, but it is not one
-of the three built-ins this reference compares, and it can never claim
-primary-host, AQE-provider, or status-line status.
+[External host adapters](PROVIDERS.md#external-host-adapters-experimental),
+[AUTHORING-HOST-ADAPTERS.md](AUTHORING-HOST-ADAPTERS.md),
+[ADR-0029](adr/0029-host-adapter-extension-point.md), and
+[ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md). An admitted
+external host picks up the same capability-driven treatment described here, but it
+is not one of the three built-ins this reference compares. It can never
+**self-declare** primary-host, AQE-provider, or status-line status — that ban is
+permanent — while `canBePrimary` and `commandStatusline` are **earnable** through
+a passed conformance tier plus an explicit maintainer grant. `aqeProvider` stays
+upstream-owned and is never `ak`-grantable. See
+[External host adapters](#external-host-adapters) below for what a grant does and
+does not buy today.
Evidence cutoff: **2026-08-04**. The comparison was checked against agentic-kit
`4.0.0-alpha.36`, Ruflo `3.34.0`, agentic-qe `3.13.x`, RuvNet Brain `4.0.7`,
@@ -218,6 +225,42 @@ subagent permission rules being ignored
`AGENTS.md` guidance being forgotten
([#40348](https://github.com/anomalyco/opencode/issues/40348)).
+## External host adapters
+
+An external adapter is data, not code: a hash-pinned manifest plus subprocess
+hooks, admitted only behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1` and only after
+`ak host adapters trust ` discloses the full validated manifest and records
+your consent. `contract: 1` is still experimental and **not frozen** — the freeze
+waits on a real external adapter clearing the conformance kit and soaking.
+
+`ak host adapters conformance ` reports each graduation tier honestly:
+
+| Tier | Status today | Gates | Why |
+| --- | --- | --- | --- |
+| `admission` | Genuinely passes | — | Manifest validation and consent are built |
+| `activity-routing` | Genuinely passes | — | Real supervised subprocess worker via `ak run` |
+| `primary-eligible` | Genuinely passes | `canBePrimary` | Observes a real escalation |
+| `session-driving` | **Gated** | — | Being a native Ruflo backend is upstream's to grant |
+| `statusline` | **Gated** | `commandStatusline` | `ak` has no render surface for it yet |
+
+The two gated tiers are honest ceilings, not failures — they report `gated` or
+`skipped` and never `passed`, with `ak host adapters gate #NNN`
+recording the upstream issue each waits on.
+
+What a maintainer grant (`ak host adapters grant`, alias `bless`) buys today, stated
+narrowly: the capability goes live in the effective host registry from the next
+flagged invocation, so the host's tier label reflects it and it joins
+primary-eligibility. No path yet **selects** an external host as primary — `ak host
+pick` stays built-in-scoped — and `commandStatusline` has no runtime reader, so a
+granted `commandStatusline` is currently inert. Grants are withdrawable with
+`revoke-grant`, and every tier result is stale-marked the moment the manifest
+changes.
+
+A graduated adapter ends in one of two places: a **blessed external adapter** that
+stays out-of-tree holding exactly the capabilities its tiers earned, or a
+**promoted built-in** whose descriptor a maintainer adopts as a first-party registry
+entry — an ordinary pull request, not a command.
+
## Known contract discrepancies
These are intentionally visible rather than hidden behind an over-broad “supported”
@@ -239,7 +282,12 @@ ADR-0020 is **Implemented** as of 2026-07-30; ADR-0021 is **Accepted** and was
updated 2026-08-03. See [ADR-0017](adr/0017-opencode-host.md),
[ADR-0018](adr/0018-generalized-host-worker-execution.md),
[ADR-0020](adr/0020-ga-stable-surfaces.md), and
-[ADR-0021](adr/0021-inference-provider-provenance.md).
+[ADR-0021](adr/0021-inference-provider-provenance.md). For the external-adapter
+section above, [ADR-0029](adr/0029-host-adapter-extension-point.md) is the
+extension point and [ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md)
+amends it with capability graduation — replacing ADR-0029's permanent
+capability caps with the earn-then-grant model, except for the permanent ban on
+self-declaring them.
## Operational guidance
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index 3a22058..11ff5c9 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -88,14 +88,58 @@ like Hermes, say? Set `AK_EXPERIMENTAL_HOST_ADAPTERS=1` and declare it as **data
```
An adapter is a manifest plus a handful of subprocess hooks — nothing an adapter declares ever
-runs inside the `ak` process itself. Registering one asks you to confirm a content hash of the
-manifest; edit the manifest afterward and that consent is invalidated until you confirm again. A
-broken adapter is reported and skipped — it never takes down the hosts that already work.
-
-An external adapter can never claim to be the primary host, an AQE provider, or the status-line
-owner; those stay first-party. Nothing here installs itself: you declare the adapter, you consent
-to it, and teardown remains reversible. See
-[ADR-0029](adr/0029-host-adapter-extension-point.md).
+runs inside the `ak` process itself. `source` may be a local file path, an `https://` URL (HTTPS
+only, no redirects, bounded), or `npm:[@version]`, fetched with `npm pack --ignore-scripts`
+and extracted to stdout — nothing runs and nothing lands on disk. The source is resolved *before*
+hashing, so a mutated remote surfaces as `consent-stale` rather than sliding in quietly.
+
+Consent is explicit and hash-pinned. `ak host adapters trust ` discloses the full validated
+manifest and records your consent against its content hash (`--expect-hash` pins it
+non-interactively); `list` shows trust state, and `revoke` works even with the flag off. Once a
+host is admitted *and* explicitly enabled in `kit.json`, its lifecycle hooks run through
+`ak setup`, `ak sync`, and uninstall — a failed detect or plan aborts the apply. A broken adapter
+is reported and skipped; it never takes down the hosts that already work.
+
+### What an external adapter can earn
+
+An external adapter can never **self-declare** that it is the primary host, an AQE provider, or
+the status-line owner. That ban is permanent — it is the safety invariant the whole extension
+point rests on. But `canBePrimary` and `commandStatusline` are **earnable**: passing the gating
+conformance tier is evidence, and `ak host adapters grant ` (alias `bless`) is
+a maintainer's explicit grant, refused unless that tier is recorded passed at the adapter's
+current manifest hash. `aqeProvider` stays upstream-owned and is never `ak`-grantable.
+
+`ak host adapters conformance ` runs the tiered black-box kit:
+
+| Tier | Status today | Gates |
+| --- | --- | --- |
+| `admission` | Genuinely passes against a real adapter | — |
+| `activity-routing` | Genuinely passes — real supervised subprocess worker | — |
+| `primary-eligible` | Genuinely passes — observes a real escalation | `canBePrimary` |
+| `session-driving` | **Gated** — a native Ruflo backend is upstream's to grant | — |
+| `statusline` | **Gated** — `ak` has no render surface for it yet | `commandStatusline` |
+
+The two gated tiers are honest ceilings, not failures: they report `gated`/`skipped` and never
+`passed`. `ak host adapters gate #NNN` records the upstream issue the ceiling
+is waiting on, `status [name]` shows per-tier state (stale-marked the moment the manifest changes)
+alongside granted capabilities, and `revoke-grant [capability]` withdraws one or all.
+
+Be precise about what a grant buys **today**. A granted capability goes live in the effective host
+registry from the next flagged invocation — the host's tier label reflects it and it joins
+primary-eligibility. But no path yet *selects* an external host as primary (`ak host pick` stays
+built-in-scoped), and `commandStatusline` has no runtime reader. So a granted `canBePrimary` is
+visible and eligible, while a granted `commandStatusline` is currently inert.
+
+Graduation has two destinations: a **blessed external adapter** stays out-of-tree holding exactly
+the capabilities its tiers earned, or a maintainer **promotes it to a built-in** by adopting its
+descriptor as a first-party registry entry — an ordinary PR, not a command.
+
+Nothing here installs itself: you declare the adapter, you consent to it, and teardown remains
+reversible. `contract: 1` is still experimental and **not frozen** — freezing waits on a real
+external adapter clearing the conformance kit and soaking. Writing one? See
+[AUTHORING-HOST-ADAPTERS.md](AUTHORING-HOST-ADAPTERS.md). The governing decisions are
+[ADR-0029](adr/0029-host-adapter-extension-point.md) and
+[ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md).
---
@@ -391,6 +435,8 @@ just makes the good default automatic and the customization reversible.
- Capability-driven integration axes, bindings, and provenance:
[ADR-0016](adr/0016-capability-driven-integration-adapters.md).
- The generic local OpenAI-compatible provider: [ADR-0028](adr/0028-local-openai-compatible-providers.md).
-- External host adapters (experimental): [ADR-0029](adr/0029-host-adapter-extension-point.md).
+- External host adapters (experimental): [ADR-0029](adr/0029-host-adapter-extension-point.md),
+ amended by [ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md) — capability
+ graduation. Authoring guide: [AUTHORING-HOST-ADAPTERS.md](AUTHORING-HOST-ADAPTERS.md).
- Host env flags (`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`): upstream ruflo
ADR-034, "Optional MCP Backends".
diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md
index 1998718..36ac07a 100644
--- a/docs/adr/0029-host-adapter-extension-point.md
+++ b/docs/adr/0029-host-adapter-extension-point.md
@@ -141,7 +141,45 @@ names:
An adapter manifest declaring `acp` or `mcp` today fails admission with an explicit "surface not yet
supported" diagnostic. It is never silently downgraded to `cli-subprocess` — a silent downgrade
would run a hook the adapter author never tested against that surface, exactly the kind of guessed
-success ADR-0016 §5 and ADR-0023 already forbid elsewhere.
+success ADR-0016 §5 and ADR-0023 already forbid elsewhere. An admitted host is given a
+`cli-subprocess` **execution** adapter only when it declares that surface; a manifest missing it is
+refused (`surface-unsupported`) rather than downgraded.
+
+**`cli-subprocess` execution details** (settled while wiring [ADR-0031](0031-capability-graduation-and-upstream-requests.md)'s
+external-execution row, after an adversarial review of the surface):
+
+- **Command resolution is anchored, not ambient.** A hook subprocess runs with its working
+ directory pinned to the adapter's own resolved directory (a file-sourced manifest's `realpath`
+ directory), never `ak`'s current working directory — so a manifest declaring
+ `["node", "run-hook.mjs"]` runs *the adapter's* `run-hook.mjs`, and a file planted in the
+ operator's cwd is unreachable. A remote-sourced manifest (`npm:`/`https://`) has no persistent
+ local directory, so a *relative* hook command from such a source is refused
+ (`execution-unanchored`) rather than resolved against an ambient path; a bare PATH binary
+ (`node`, `hermes`) stays legal. The consent hash still pins the manifest text verbatim; the
+ resolution is a pure function of that text plus the (already-pinned) source, so it cannot drift
+ without the hash changing.
+ - *Boundary of the anchorability check for remote sources.* When a remote-sourced adapter has no
+ local directory to anchor to, its hook command spawns in the repository `ak run` was invoked in
+ (which the operator already runs at full trust, per ADR-0018), and the `execution-unanchored`
+ refusal is a **best-effort** screen for path-shaped tokens (separators, script extensions, flag
+ values), not a complete one: an *extensionless, separator-free* relative token
+ (`["node", "runhook"]`) is indistinguishable by inspection from an ordinary positional argument
+ (`["hermes-run", "build"]`), so it is not refused and would resolve against the repo. A complete
+ rule would have to reject every non-absolute, non-flag argument, which would also reject
+ legitimate positional arguments — a false-positive cost this contract does not pay by default.
+ The exposure is bounded on every axis that matters: it requires a remote (`npm:`/`https://`)
+ source, a consented manifest the operator hash-pinned with that exact relative token, and write
+ access to the operator's repo. A **file-sourced** adapter — the fixture, and every adapter that
+ ships a bundle — is fully anchored and unaffected. A remote-sourced adapter should declare
+ absolute paths or PATH binaries; a future contract revision may make that a hard requirement.
+- **Reserved exit codes carry consent/auth boundaries.** Hook exit `77` maps to
+ `permission_required` (a blocked, never-escalated result — escalating around a consent boundary
+ is the safety violation ADR-0019 already forbids) and `78` to `auth_required`. This gives an
+ external host an honest way to say "I refused" or "I am not logged in" instead of a bare
+ non-zero exit that would be re-run on another host.
+- **Results never launder trust.** Exit code is the sole authority for success; a self-declared
+ `provider` in hook stdout is stamped `inferred`, never `observed`; stderr is never promoted into
+ a downstream worker's prompt; and handoff data is redacted from public `WorkerResult`s.
### 3. Capability caps are schema-structural, not runtime-checked
diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md
index c912d9a..9d0b908 100644
--- a/docs/adr/0031-capability-graduation-and-upstream-requests.md
+++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md
@@ -68,6 +68,16 @@ host, assert the real behaviour, against an installed layout — that gates one
Passing a tier records evidence; the maintainer's grant turns evidence into capability. A tier the
adapter cannot meet because the capability is upstream is marked **gated** (§4), not failed.
+The recorded evidence is hash-pinned to the **manifest** — the same content pin consent uses
+(ADR-0029 §6) — so any edit to the manifest voids the evidence and the grants that rest on it. It is
+**not** pinned to the bytes of the hook *scripts* the manifest references: a file-sourced adapter can
+rewrite `run-hook.mjs` under an unchanged manifest hash. This is the identical boundary ADR-0029
+already accepted for consent, carried forward here; the grant confirmation states it explicitly so a
+maintainer granting `primary-eligible`/`statusline` knows the pin covers the declaration, not the
+script that produced the observation. Tightening this — hashing the referenced hook files into the
+tier evidence and re-verifying at grant time — is a tracked follow-up, warranted before the freeze
+(§6) if a real adapter's graduation depends on it.
+
### 3. Two graduation destinations
A conformed adapter lands in one of two places, the maintainer's call:
@@ -149,16 +159,16 @@ Per the ADR discipline this repository adopted (a dated, self-graded table befor
rests on delivery): the **governance decision** is accepted; the **machinery** is staged and mostly
unbuilt. This table is the source of truth for what is real.
-| Piece | Status (2026-08-16) | Note |
+| Piece | Status | Note |
| ----- | ------------------- | ---- |
| Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) |
-| `ak host adapters trust` CLI (records consent/grants) | **Proposed — not built** | Smallest next step; today admission refuses `consent-required` |
-| External execution (`ak run` drives an admitted host) | **Proposed — not built** | The seam is a comment-only lookup today |
-| External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized |
-| Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit |
-| Capability-grant store + promotion command | **Proposed — not built** | Hash-pinned, mirrors consent |
-| Remote manifest sources (npm / URL) + resolve→hash ordering | **Proposed — not built** | File-path manifests only today |
-| Upstream request tracking (`gated: #NNN` against a tier) | **Proposed — not built** | Needs a place to record per-tier gating |
+| `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. `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 |
| A real external adapter (Hermes) clearing the kit → contract freeze | **Not started** | Freeze criterion (§6) |
## Alternatives considered
diff --git a/docs/upstream-requests/agentic-qe-provider-plugin.md b/docs/upstream-requests/agentic-qe-provider-plugin.md
new file mode 100644
index 0000000..02c2ac2
--- /dev/null
+++ b/docs/upstream-requests/agentic-qe-provider-plugin.md
@@ -0,0 +1,72 @@
+
+
+# Feature request: a provider-plugin registration API for `AQE_LLM_PROVIDER`
+
+## Summary
+
+agentic-qe's LLM provider set is a closed, upstream-defined enumeration. A downstream
+integrator (agentic-kit) can select any *existing* provider via `AQE_LLM_PROVIDER`, but
+cannot introduce a *new* provider type without an upstream code change. We'd like a
+documented registration surface so a host that a downstream tool supervises can be
+recognized as its own provider identity, rather than only borrowing the model provider
+underneath it.
+
+## Where this stands today (please re-verify against HEAD)
+
+Grounded against `agentic-qe@3.13.10` (as cited in agentic-kit's ADR-0031):
+
+- `ALL_PROVIDER_TYPES` is a fixed union of provider type strings.
+- `createProvider` is a `switch` over those types; an unrecognized type has no arm.
+- So the provider set is extended only by editing that enum + switch — there is no
+ runtime/plugin registration path.
+
+If any of this has changed on HEAD (a registration hook, an open provider map, a plugin
+entrypoint), this request may already be satisfied — please close it as such.
+
+## The concrete ask
+
+A minimal, documented way for a downstream tool to register an additional provider type at
+runtime, e.g.:
+
+- a `registerProvider(type, factory)` entrypoint (factory conforming to the same interface
+ `createProvider` returns), and inclusion of registered types in `ALL_PROVIDER_TYPES`
+ for validation; **or**
+- a documented "external provider" adapter interface a downstream package can implement
+ and hand to the `HybridRouter` / `ProviderManager`.
+
+We are not asking to widen the *default* provider set — only for a sanctioned extension
+point so `AQE_LLM_PROVIDER=` can resolve to a provider we supply, instead of us
+either forking the enum or projecting our host onto an unrelated provider identity (which
+would misrepresent which vendor served the work).
+
+## Why (the downstream context)
+
+agentic-kit admits external host adapters (declarative manifest + consented subprocess
+hooks) and runs a tiered conformance kit against them. Quality still runs through the
+*model provider underneath* a host, so QE is never blocked — but a host cannot earn an
+**AQE-provider identity** of its own, because that identity is yours to define, not ours to
+extend. agentic-kit deliberately refuses to fabricate one (projecting an unknown host into
+your config would assert an identity you never declared you understood). This request is
+the honest alternative: a real extension point, so the capability can light up when you
+release it.
+
+## Non-goals
+
+- Not asking agentic-kit's hosts to be bundled into agentic-qe.
+- Not asking to bypass any provider validation — registered providers should be validated
+ exactly like built-in ones.
+
+## References
+
+- agentic-kit ADR-0031 §4 (the upstream-request path) and ADR-0029 (the host-adapter
+ contract): the design that motivates this.
+- `AQE_LLM_PROVIDER` / `HybridRouter` / `createProvider` / `ALL_PROVIDER_TYPES` in
+ agentic-qe (re-verify paths against HEAD).
diff --git a/docs/upstream-requests/ruflo-backend-registration.md b/docs/upstream-requests/ruflo-backend-registration.md
new file mode 100644
index 0000000..c9f46e2
--- /dev/null
+++ b/docs/upstream-requests/ruflo-backend-registration.md
@@ -0,0 +1,65 @@
+
+
+# Feature request: a documented backend-registration surface for host CLIs
+
+## Summary
+
+ruflo's set of agent **backends** (the host CLIs that drive the loop) is enabled per-host
+through fixed `ENABLE_*` flags. There is no documented way for a downstream tool to
+register an *additional* backend, so a host CLI that a downstream integrator supervises
+cannot become a ruflo-native backend without an upstream code change. We'd like a
+documented registration surface for that.
+
+## Where this stands today (please re-verify against HEAD)
+
+Grounded against `ruvnet/ruflo@45e65b5` (as cited in agentic-kit's ADR-0031):
+
+- Backend enablement is per-host and fixed: `ENABLE_CLAUDE_CODE`, `ENABLE_CODEX`,
+ `ENABLE_GEMINI_MCP` (ADR-034 "Optional MCP Backends").
+- A new backend is added by defining a new `ENABLE_*` target inside ruflo — there is no
+ outside/plugin registration path.
+
+If HEAD already exposes a backend-registration API or a documented plugin entrypoint, this
+request may already be satisfied — please close it as such.
+
+## The concrete ask
+
+A documented way to register an additional agent backend from outside the ruflo tree, e.g.:
+
+- a backend descriptor interface (how ruflo detects the CLI, launches an interactive /
+ oneshot session, and observes completion) that a downstream package can implement and
+ register, honored by the same loop the `ENABLE_*` backends drive; **or**
+- a documented convention for an external backend entrypoint ruflo will discover and drive.
+
+The goal is that a host CLI can *drive a ruflo session* as a first-class backend, not only
+run under a downstream tool's own supervision.
+
+## Why (the downstream context)
+
+agentic-kit admits external host adapters and certifies them with a tiered conformance kit.
+The `session-driving` tier — "the host actually drives an interactive/oneshot session" —
+is the one an external adapter cannot clear on its own, because being a ruflo-native
+backend is defined inside ruflo, not registered from outside. agentic-kit's honest interim
+is to run such a host through its *own* supervised execution (`ak run`), and to mark the
+tier `gated` on this request rather than fake a pass. A documented backend-registration
+surface upstream is what lets that tier genuinely light up.
+
+## Non-goals
+
+- Not asking to bundle agentic-kit's hosts into ruflo.
+- Not asking to loosen the trust model — a registered backend should be subject to the same
+ enablement/consent posture as the built-in `ENABLE_*` backends.
+
+## References
+
+- agentic-kit ADR-0031 §4 (the upstream-request path) and ADR-0029 (the host-adapter
+ contract).
+- ruflo ADR-034 "Optional MCP Backends" and the `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` /
+ `ENABLE_GEMINI_MCP` backend model (re-verify paths against HEAD).
diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs
index a285216..6f8a9e8 100644
--- a/src/commands/setup.mjs
+++ b/src/commands/setup.mjs
@@ -14,7 +14,8 @@ import { reconcileGuidance } from '../lib/blocks.mjs';
import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs';
import { reconcileOpencodeGuidance } from '../lib/opencode.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
-import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
+import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs';
+import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs';
import { installedVersion } from '../lib/versions.mjs';
@@ -29,6 +30,17 @@ import {
import * as paths from '../lib/paths.mjs';
import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs';
+/** Prints one lifecycle-render.mjs report line at its own level — 'fail'
+ * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`,
+ * so a genuinely failed opencode sub-surface never reads as merely
+ * informational. */
+function printReportLine(line) {
+ if (line.level === 'ok') ok(line.text);
+ else if (line.level === 'warn') warn(line.text);
+ else if (line.level === 'fail') fail(line.text);
+ else info(line.text);
+}
+
export const options = {
'dry-run': { type: 'boolean', default: false },
yes: { type: 'boolean', default: false },
@@ -215,21 +227,23 @@ export async function run_machine({ flags, pkgRoot, cfg }) {
// 6b. host lifecycle wiring — config-file MCP + skills, lifecycle plugin,
// converted agents, platform skill (each adapter owns its own surfaces
- // — opencode.mjs for opencode). Registry-driven: loops
- // builtinHostsWithLifecycle() rather than naming opencode, so a second
- // BUILT-IN lifecycle host needs no new branch here. Only when the CLI
- // is actually present: a declined/failed install must not leave a
- // freshly-created config home behind (codex-review #4). The result
- // SHAPE consumed below (stack.oc/plugin/agents/skill) is still
- // opencode's own — the lifecycle contract doesn't mandate a common
- // `apply()` result shape across hosts. builtinHostsWithLifecycle()
- // (not hostsWithLifecycle()) deliberately excludes admitted external
- // hosts: this loop body is opencode-shaped, and external lifecycle
- // execution graduates in a later wave alongside a shape-agnostic body
- // (see lifecycle-registry.mjs's registerAdmittedLifecycle comment).
- for (const hostId of builtinHostsWithLifecycle()) {
- if (!cfg.integrations?.hosts?.[hostId]) continue;
- if (!(await have(hostId))) {
+ // — opencode.mjs for opencode; a subprocess hook for an admitted
+ // external — see lifecycle-registry.mjs's buildAdmittedLifecycleAdapter).
+ // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted,
+ // ADR-0031 P3) rather than naming opencode, so a second lifecycle host
+ // — built-in or admitted — needs no new branch here. lifecycleExecutionEnabled
+ // gates each host: a built-in only needs cfg enablement (unchanged); an
+ // admitted external ALSO needs the experimental flag — an admitted host
+ // is opt-in exactly like opencode, and this never auto-enables anything.
+ // Only when the CLI is actually present: a declined/failed install must
+ // not leave a freshly-created config home behind (codex-review #4).
+ // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle
+ // result's own shape (opencode's rich per-surface shape vs. an admitted
+ // host's generic lifecycleResult), so this loop body never destructures
+ // a host-specific result directly.
+ for (const hostId of hostsWithLifecycle()) {
+ if (!lifecycleExecutionEnabled(hostId, cfg)) continue;
+ if (!(await have(detectionBinFor(hostId)))) {
const pkg = HOSTS.find((h) => h.id === hostId)?.pkg ?? hostId;
warn(`${hostId}: enabled but CLI not installed — wiring skipped (re-run \`ak sync\` after installing ${pkg})`);
continue;
@@ -237,23 +251,22 @@ export async function run_machine({ flags, pkgRoot, cfg }) {
const lifecycle = await runLifecycle({
adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot },
});
- const stack = lifecycle.result;
- (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`);
- if (stack.oc.fatal) {
- warn(`opencode plugin/agents/skill/guidance skipped — ${stack.oc.detail}`);
- return false;
+ const report = renderApplyReport(hostId, lifecycle);
+ for (const line of report.lines) printReportLine(line);
+ if (report.fatal) return false;
+ // guidance blocks + the startup-reload note are opencode-specific surfaces
+ // (AGENTS.md blocks, opencode's own load-once-at-startup behavior) with no
+ // equivalent in the generic hook contract — stays gated on the rich shape.
+ if (report.shape === 'opencode') {
+ // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18)
+ // — not on the next status-driven reconcile. Same shared reconcile pick
+ // and off use, so every command converges guidance identically.
+ const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true });
+ ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`);
+ // opencode loads config/plugins/MCP/agents once at startup — say so now,
+ // or the user files "hooks don't work" issues (observed live).
+ info('restart opencode to load the hooks + MCP servers (loaded once at startup)');
}
- ok(`opencode plugin: ${stack.plugin.detail}`);
- ok(`opencode agents: ${stack.agents.detail}`);
- if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`);
- // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18)
- // — not on the next status-driven reconcile. Same shared reconcile pick
- // and off use, so every command converges guidance identically.
- const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true });
- ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`);
- // opencode loads config/plugins/MCP/agents once at startup — say so now,
- // or the user files "hooks don't work" issues (observed live).
- info('restart opencode to load the hooks + MCP servers (loaded once at startup)');
}
// 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`)
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/sync.mjs b/src/commands/sync.mjs
index 9f44e46..3160dfa 100644
--- a/src/commands/sync.mjs
+++ b/src/commands/sync.mjs
@@ -9,7 +9,8 @@ import { fixStatusline, helperStampStale } from '../lib/statusline.mjs';
import { reconcileGuidance } from '../lib/blocks.mjs';
import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
-import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
+import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs';
+import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs';
import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs';
@@ -24,6 +25,17 @@ import * as paths from '../lib/paths.mjs';
import { ok, warn, fail, info, bold, dim, withProgress, reportOutcome } from '../lib/output.mjs';
import { applyCodexStatusline, projectionFor } from '../lib/codex-statusline.mjs';
+/** Prints one lifecycle-render.mjs report line at its own level — 'fail'
+ * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`,
+ * so a genuinely failed opencode sub-surface never reads as merely
+ * informational. */
+function printReportLine(line) {
+ if (line.level === 'ok') ok(line.text);
+ else if (line.level === 'warn') warn(line.text);
+ else if (line.level === 'fail') fail(line.text);
+ else info(line.text);
+}
+
export const options = {
'dry-run': { type: 'boolean', default: false },
'no-upgrade': { type: 'boolean', default: false },
@@ -188,32 +200,29 @@ export async function run({ flags, pkgRoot, fetchLatest }) {
// Runs BEFORE the blocks branch: the agents-opencode guidance target is gated
// on the config home this branch creates — this order lets a fresh enable
// converge guidance in the SAME sync (a second sync is then a true no-op).
- // Registry-driven: loops builtinHostsWithLifecycle() rather than naming
- // opencode, so a second BUILT-IN lifecycle host needs no new branch here.
- // Only opencode is registered today, so this loop runs exactly once —
- // byte-identical to the single-host branch it replaces. The result SHAPE
- // consumed below (stack.oc/plugin/agents/skill) is still opencode's own —
- // the lifecycle contract doesn't mandate a common `apply()` result shape
- // across hosts. builtinHostsWithLifecycle() (not hostsWithLifecycle())
- // deliberately excludes admitted external hosts — see lifecycle-registry.mjs.
- for (const hostId of builtinHostsWithLifecycle()) {
- if (!subsystems.has(hostId) || !cfg.integrations?.hosts?.[hostId]) continue;
- if (!(await have(hostId))) {
+ // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted,
+ // ADR-0031 P3) rather than naming opencode, so a second lifecycle host —
+ // built-in or admitted — needs no new branch here. lifecycleExecutionEnabled
+ // gates each host exactly as setup.mjs does (built-in: cfg enablement only;
+ // admitted: cfg enablement AND the experimental flag — never auto-enabled).
+ // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle
+ // result's own shape, so this loop body never destructures a host-specific
+ // result directly; opencode's per-surface lines render exactly as before.
+ for (const hostId of hostsWithLifecycle()) {
+ if (!subsystems.has(hostId) || !lifecycleExecutionEnabled(hostId, cfg)) continue;
+ if (!(await have(detectionBinFor(hostId)))) {
info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`);
continue;
}
const lifecycle = await runLifecycle({
adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot },
});
- const stack = lifecycle.result;
+ const applyReport = renderApplyReport(hostId, lifecycle);
// persist the markers on ANY refresh (a converged file whose kit.json
// markers are stale/missing still needs the save, or the next teardown
// cannot prove ownership — codex-review r3), not only on file changes.
- if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg);
- if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc);
- report('opencode plugin', stack.plugin);
- report('opencode agents', stack.agents);
- if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill);
+ if (applyReport.ocChanged || applyReport.markersChanged) saveKitConfig(cfg);
+ for (const line of applyReport.lines) printReportLine(line);
}
// The 'opencode' guard: the opencode branch above can CREATE the config home
// that activates the agents-opencode guidance target — a machine whose other
diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs
index 721e129..74ab88f 100644
--- a/src/commands/uninstall.mjs
+++ b/src/commands/uninstall.mjs
@@ -11,12 +11,25 @@ import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs';
import { unregister } from '../lib/mcp.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
-import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
+import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, isBuiltinHost } from '../lib/adapters/lifecycle-registry.mjs';
+import { renderUndoReport } from '../lib/adapters/lifecycle-render.mjs';
import { present as rbPresent } from '../lib/ruvnet-brain.mjs';
import * as paths from '../lib/paths.mjs';
-import { ok, warn, info } from '../lib/output.mjs';
+import { ok, warn, fail, info } from '../lib/output.mjs';
import { removeCodexStatusline } from '../lib/codex-statusline.mjs';
+/** Prints one lifecycle-render.mjs report line at its own level — mirrors
+ * setup.mjs/sync.mjs's own printReportLine (N-2, Wave C security review
+ * follow-up): renderUndoReport only ever emits 'ok'/'warn' today, so this is
+ * latent, but the day an undo renderer adopts levelForResult (F5's mapping)
+ * a 'fail' line must reach fail(), not be silently downgraded to warn(). */
+export function printReportLine(line) {
+ if (line.level === 'ok') ok(line.text);
+ else if (line.level === 'warn') warn(line.text);
+ else if (line.level === 'fail') fail(line.text);
+ else info(line.text);
+}
+
export const options = {
'dry-run': { type: 'boolean', default: false },
'this-project': { type: 'boolean', default: false },
@@ -128,23 +141,31 @@ export async function run({ flags }) {
// removes kit.json below, so persisting cfg here would recreate it. Each
// adapter's own undo() already honors ownership/receipts (opencode's
// undoOpencode no-ops when it never held mcp:'ak', and marker-gates
- // artifact removal independent of that), so this call is unconditional per
- // host — the only kit-side gate is "did anything actually happen", to
- // avoid a no-op teardown line (and a needless kit.json rewrite) on a host
- // that was never enabled. builtinHostsWithLifecycle() (not
- // hostsWithLifecycle()) deliberately excludes admitted external hosts:
- // this loop body destructures an opencode-shaped result (ret.undo,
- // ret.artifacts) — see lifecycle-registry.mjs's registerAdmittedLifecycle
- // comment for why external lifecycle execution isn't wired through here yet.
- for (const hostId of builtinHostsWithLifecycle()) {
+ // artifact removal independent of that), so a BUILT-IN's call is
+ // unconditional per host, same as before ADR-0031 P3 — the only kit-side
+ // gate is "did anything actually happen", to avoid a no-op teardown line
+ // (and a needless kit.json rewrite) on a host that was never enabled. An
+ // ADMITTED external host is different: there is no "always safe, always
+ // idempotent" guarantee for an arbitrary third-party hook the way there is
+ // for opencode's own undo, so an admitted host's teardown is gated by
+ // lifecycleExecutionEnabled (cfg enablement AND the experimental flag) —
+ // an admitted host that was never enabled/consented for this run is never
+ // invoked. hostsWithLifecycle() (built-ins + admitted, ADR-0031 P3) is safe
+ // to loop unconditionally now: lifecycle-render.mjs's renderUndoReport
+ // dispatches on the runLifecycle result's own shape, so this loop body
+ // never destructures a host-specific result directly.
+ for (const hostId of hostsWithLifecycle()) {
+ if (!isBuiltinHost(hostId) && !lifecycleExecutionEnabled(hostId, cfg)) continue;
const adapter = lifecycleAdapterFor(hostId);
if (dry) {
- info(`[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`);
+ info(isBuiltinHost(hostId)
+ ? `[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`
+ : `[dry-run] stripped ak-managed ${hostId} wiring + artifacts (hook-declared undo)`);
continue;
}
const retired = await runLifecycle({ adapter, action: 'undo', cfg });
- const ret = retired.result;
- ownershipTeardownOk = ownershipTeardownOk && ret.ok;
+ const undoReport = renderUndoReport(hostId, retired);
+ ownershipTeardownOk = ownershipTeardownOk && undoReport.ok;
// Persist markers unconditionally, exactly like x/host.mjs's off()/pick():
// undo() mutates cfg's ownership markers in memory even when it rewrote
// no file (`undo.changed` measures the FILE, not cfg), so gating the save
@@ -152,11 +173,7 @@ export async function run({ flags }) {
// quiet-success path. Only the human-facing line stays gated on "did
// anything observable happen".
if (!flags.purge) saveKitConfig(cfg);
- if (ret.undo.changed || ret.artifacts.changed || !ret.ok) {
- (ret.ok ? ok : warn)(ret.ok
- ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`
- : `${hostId} teardown incomplete — ${ret.undo.detail}`);
- }
+ for (const line of undoReport.lines) printReportLine(line);
}
if (flags.purge && fs.existsSync(paths.kitConfigPath())) {
if (ownershipTeardownOk) act('removed kit.json', () => fs.rmSync(paths.kitConfigPath()));
diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs
new file mode 100644
index 0000000..b2b5be4
--- /dev/null
+++ b/src/commands/x/host-adapters-grants.mjs
@@ -0,0 +1,296 @@
+// 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, revokeCapability,
+ 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];
+}
+
+/** 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,
+}) {
+ if (typeof name !== 'string' || !name || typeof capability !== 'string' || !capability) {
+ fail('usage: ak host adapters grant ');
+ return 2;
+ }
+ const safeCapability = stripControl(capability);
+ if (!grantableCapability(capability)) {
+ fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}. 'aqeProvider' in particular is an upstream-owned identity (agentic-qe's own provider enumeration) and is never ak-grantable — see ADR-0031 §4.`);
+ return 1;
+ }
+ const safeName = stripControl(name);
+ const entry = findEntry(cfg, name);
+ if (!entry) { fail(`no host adapter named '${safeName}' in kit.json hostAdapters`); return 1; }
+
+ const loaded = await loadAndHash(entry, { reader });
+ if (!loaded.ok) {
+ fail(`'${safeName}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`);
+ return 1;
+ }
+ const { manifest, hash } = loaded;
+ const tier = gatingTierFor(capability);
+ const safeTier = stripControl(tier);
+
+ // F-3 (security review): this is the highest-privilege act in the model —
+ // a hex hash alone told the operator nothing about what they were actually
+ // converting to a live capability. Disclose the ACTUAL evidence backing
+ // the gating tier, the manifest's declared hooks, and the manifest's trust
+ // state, all BEFORE the confirmation prompt.
+ const record = grantsFor(name, { file: grantsFile, currentHash: hash });
+ const tierEntry = record?.tiers?.[tier];
+ const evidence = tierEntry?.status === 'passed' ? tierEntry.evidence : undefined;
+
+ console.log(bold(`grant '${safeCapability}' to '${safeName}'`));
+ console.log(` gating tier: ${safeTier}`);
+ console.log(` manifest hash: ${hash}`);
+ console.log(` tier evidence: ${evidence ? stripControl(evidence) : '(no passed-tier evidence recorded at this hash — the grant below will be refused)'}`);
+ const hooks = hookCommandsFor(manifest);
+ console.log(` manifest hooks:${hooks.length ? '' : ' (none)'}`);
+ for (const line of hooks) console.log(` ${stripControl(line)}`);
+ // N-1 (security re-review): derive trust state from the hash this call
+ // ALREADY resolved above — never re-resolve the manifest source (stateFor
+ // would call loadAndHash a second time). On a mutable remote source
+ // (https:/npm:) a second resolve can return different bytes than the
+ // first, which would (a) disclose a trust state describing content that
+ // isn't what's actually being granted, and (b) double the fetch cost (an
+ // npm: source runs `npm pack` twice). Same three-state logic as stateFor,
+ // just computed from data already in hand.
+ let trustState;
+ try {
+ const recordedHash = consent.recordedHashFor(name);
+ if (recordedHash === null || recordedHash === undefined) trustState = 'not consented';
+ else trustState = recordedHash === hash ? 'trusted' : 'consent-stale';
+ } catch (error) {
+ trustState = `manifest error (consent-error: ${error?.message ?? String(error)})`;
+ }
+ console.log(` manifest trust state: ${trustState}`);
+ info('this grant pins the MANIFEST content (its hash), not the hook script bytes it references — see ADR-0031 §2 for that boundary.');
+ info("granting a capability is a trust act, same posture as 'trust': it takes effect in the effective host registry from the next ak invocation.");
+ info(capabilityStatusNote(capability));
+
+ 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-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;
+}
+
+// ── gate (P7): record an upstream capability request ────────────────────
+
+export async function gate({
+ name, tier, ref, cfg, reader, grantsFile,
+}) {
+ if (typeof name !== 'string' || !name || typeof tier !== 'string' || !tier || typeof ref !== 'string' || !ref) {
+ fail('usage: ak host adapters gate ');
+ return 2;
+ }
+ const safeName = stripControl(name);
+ const safeTier = stripControl(tier);
+ if (!CONFORMANCE_TIERS.includes(tier)) {
+ fail(`'${safeTier}' is not a valid conformance tier (one of ${CONFORMANCE_TIERS.join(', ')})`);
+ return 1;
+ }
+ const entry = findEntry(cfg, name);
+ if (!entry) { fail(`no host adapter named '${safeName}' in kit.json hostAdapters`); return 1; }
+
+ const loaded = await loadAndHash(entry, { reader });
+ if (!loaded.ok) {
+ fail(`'${safeName}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`);
+ return 1;
+ }
+
+ try {
+ recordTierGate(name, tier, { hash: loaded.hash, gatedBy: ref }, { file: grantsFile });
+ } catch (error) {
+ fail(`gate refused: ${stripControl(error?.message ?? String(error))}`);
+ return 1;
+ }
+
+ ok(`recorded '${safeName}' tier '${safeTier}' as gated on ${stripControl(ref)} at ${loaded.hash}`);
+ info(`\`ak host adapters status ${safeName}\` will show this as waiting-on-upstream until the tracked issue ships and the gate is cleared`);
+ return 0;
+}
+
+// ── status (P7 display) ───────────────────────────────────────────────
+// A read-only report over grants.mjs's reporting surfaces — never the
+// hash-unaware capability reader (see grants.mjs's module-header invariant).
+// Staleness (a manifest edit since evidence was recorded) must be obvious,
+// not buried: it voids every passed tier and every grant until re-earned.
+
+async function statusOne(name, entry, { reader, grantsFile }) {
+ const safeName = stripControl(name);
+ console.log(bold(`host adapter status — ${safeName}`));
+ const loaded = await loadAndHash(entry, { reader });
+ if (!loaded.ok) {
+ fail(` manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`);
+ return 1;
+ }
+ const { hash } = loaded;
+ console.log(` manifest hash: ${hash}`);
+
+ const record = grantsFor(name, { file: grantsFile, currentHash: hash });
+ const passed = record ? Object.entries(record.tiers).filter(([, t]) => t?.status === 'passed') : [];
+ if (!passed.length) {
+ info(' passed tiers: (none)');
+ } else {
+ console.log(' passed tiers:');
+ for (const [tier] of passed) {
+ const line = ` ${stripControl(tier)}${record.stale ? ' — STALE (manifest changed since this evidence was recorded; void until re-earned)' : ''}`;
+ if (record.stale) warn(line); else ok(line);
+ }
+ }
+
+ const gated = gatedTiersFor(name, { file: grantsFile, currentHash: hash });
+ if (!gated.length) {
+ info(' gated tiers: (none)');
+ } else {
+ console.log(' gated tiers (waiting on upstream):');
+ for (const { tier, gatedBy } of gated) info(` ${stripControl(tier)} -> ${stripControl(gatedBy)}`);
+ }
+
+ const granted = Object.keys(grantedCapabilitiesFor(name, hash, { file: grantsFile }));
+ console.log(` granted capabilities: ${granted.length ? granted.map(stripControl).join(', ') : '(none)'}`);
+ return 0;
+}
+
+export async function status({
+ name, cfg, reader, grantsFile,
+}) {
+ const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : [];
+ if (typeof name === 'string' && name) {
+ const entry = findEntry(cfg, name);
+ if (!entry) { fail(`no host adapter named '${stripControl(name)}' in kit.json hostAdapters`); return 1; }
+ return statusOne(name, entry, { reader, grantsFile });
+ }
+ if (!entries.length) { info('no host adapters configured in kit.json'); return 0; }
+ for (const entry of entries) {
+ await statusOne(entry?.name ?? '(unnamed)', entry, { reader, grantsFile });
+ console.log('');
+ }
+ return 0;
+}
+
+// ── revoke-grant: withdraw conformance evidence + grants ────────────────
+// Distinct from `revoke`, which only touches the adapter-consent store
+// (whether admission accepts the manifest at all). This touches
+// adapter-grants.json (tier evidence, upstream gates, granted capabilities)
+// — an operator revoking a manifest's TRUST does not automatically revoke
+// capabilities EARNED separately, and vice versa; they are separate stores
+// for separate questions. Fail-safe like `revoke`: reachable with the
+// experimental flag off, so a disabled surface can still be voided.
+//
+// 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, 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}'`);
+ return 0;
+}
diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs
new file mode 100644
index 0000000..3a25109
--- /dev/null
+++ b/src/commands/x/host-adapters.mjs
@@ -0,0 +1,451 @@
+// x host adapters — the trust CLI for external host-adapter manifests (ADR-0031
+// P1) plus the tiered conformance runner (P4). The maintainer's grant/gate CLI
+// (P5) and the upstream-gate display (P7) live in the sibling
+// host-adapters-grants.mjs (split out to stay under the house 500-line-per-file
+// budget) and are re-exported into this file's `run()` dispatch below — from
+// the operator's point of view it is all one `ak host adapters` surface.
+// Nothing in admission.mjs ever writes consent; this closes that gap by
+// recording hash-pinned consent the same way Codex pins an MCP server's
+// content (ADR-0029 §6). No adapter code ever executes here — this module
+// only reads, validates, hashes, discloses, and (on confirmation) records.
+//
+// Mirrors admitOne's refusal semantics exactly: a manifest is hashed only
+// after validateAdapterManifest accepts it (hashManifest(validateAdapterManifest(raw))),
+// so the hash a user consents to is always the VALIDATED shape, never the
+// raw file — an invalid manifest (e.g. one claiming canBePrimary) is refused
+// with its .reason and nothing is ever recorded for it.
+import readline from 'node:readline/promises';
+import { hashManifest, SUPPORTED_CONTRACT } from '../../lib/adapters/admission.mjs';
+import { validateAdapterManifest } from '../../lib/adapters/manifest.mjs';
+import { HOST_REGISTRY } from '../../lib/adapters/registries.mjs';
+import * as consentStore from '../../lib/adapters/consent.mjs';
+import { runTieredConformance as defaultRunTieredConformance } from '../../lib/adapters/conformance.mjs';
+import { loadKitConfig } from '../../lib/config.mjs';
+import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs';
+import {
+ grant as grantCap, gate as gateTier, status as statusReport, revokeGrant,
+} from './host-adapters-grants.mjs';
+
+const FLAG_ENV_VAR = 'AK_EXPERIMENTAL_HOST_ADAPTERS';
+
+const flagEnabled = (env) => env?.[FLAG_ENV_VAR] === '1';
+
+/** Lazy dynamic import so this file loads even before sources.mjs lands (a
+ * sibling work item this same wave) and so tests never pay for it unless
+ * they choose to — same pattern admission.mjs uses for consent.mjs. */
+async function defaultReader(source) {
+ const { resolveManifestSource } = await import('../../lib/adapters/sources.mjs');
+ return resolveManifestSource(source);
+}
+
+async function defaultAsk(question) {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+ const answer = (await rl.question(question)).trim().toLowerCase();
+ rl.close();
+ return answer === 'y' || answer === 'yes';
+}
+
+/** Untrusted text (manifest trust.changes fields, reader/validator error
+ * detail) is printed verbatim ahead of a consent prompt — a crafted string
+ * carrying cursor-movement/erase ANSI escapes or extra newlines could
+ * visually rewrite the disclosure the operator is about to agree to. A
+ * codepoint loop, not a control-character regex class (that trips
+ * `no-control-regex` AND is what sources.mjs's sanitizeDetail already uses,
+ * so this stays lint-clean the same way). Strips C0 (0x00-0x1F), DEL
+ * (0x7F), and C1 (0x80-0x9F, e.g. U+009B CSI — a real terminal control that
+ * JSON.stringify does NOT escape, unlike C0) outright; \n/\t/\r collapse to
+ * a single space instead of vanishing, so a multi-line/tabbed string stays
+ * readable as one line rather than silently losing a word boundary. */
+export function stripControl(value) {
+ const input = String(value ?? '');
+ let out = '';
+ for (const ch of input) {
+ const code = ch.codePointAt(0);
+ if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; }
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue;
+ out += ch;
+ }
+ return out;
+}
+
+export function findEntry(cfg, name) {
+ return (Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []).find((e) => e?.name === name) ?? null;
+}
+
+/** Read + validate + hash one adapter entry. Never throws — reports
+ * {ok:false, reason, detail} on any failure, the same per-entry isolation
+ * posture admission.mjs's admitOne holds. `reader` may return either the
+ * sources.mjs `{raw, origin}` shape or a bare raw document (tests are free
+ * to stub either). */
+export async function loadAndHash(entry, { reader }) {
+ let raw;
+ // Fail-closed, not fail-open: a bare-raw reader result (no {raw,origin}
+ // wrapper — including one whose `origin` is missing/malformed) is
+ // 'unknown', never assumed to be 'file'. Defaulting to 'file' would let
+ // --yes silently skip the --expect-hash pin (finding 8) for a source that
+ // was never actually proven local; only an explicit origin:'file' counts.
+ let origin = 'unknown';
+ try {
+ const resolved = await reader(entry.source);
+ if (resolved && typeof resolved === 'object' && 'raw' in resolved) {
+ raw = resolved.raw;
+ origin = typeof resolved.origin === 'string' && resolved.origin ? resolved.origin : 'unknown';
+ } else {
+ raw = resolved;
+ }
+ } catch (error) {
+ return { ok: false, reason: error?.reason ?? 'manifest-unreadable', detail: error?.message ?? String(error) };
+ }
+
+ // Same distinct failure admitOne draws: cfg's pinned contract disagreeing
+ // with the manifest's self-declared one means the file changed underneath
+ // the operator, not merely "unsupported version".
+ if (entry.contract !== undefined && raw?.contract !== undefined && entry.contract !== raw.contract) {
+ return {
+ ok: false, reason: 'contract-mismatch',
+ detail: `cfg declares contract ${entry.contract}, manifest declares ${raw.contract}`,
+ };
+ }
+
+ let manifest;
+ try {
+ manifest = validateAdapterManifest(raw);
+ } catch (error) {
+ return { ok: false, reason: error?.reason ?? 'manifest-invalid', detail: error?.message ?? String(error) };
+ }
+
+ if (manifest.contract !== SUPPORTED_CONTRACT) {
+ return { ok: false, reason: 'contract-version', detail: `unsupported contract ${manifest.contract}` };
+ }
+ if (manifest.host.id !== entry.name) {
+ return {
+ ok: false, reason: 'name-mismatch',
+ detail: `cfg entry '${entry.name}' does not match manifest host id '${manifest.host.id}'`,
+ };
+ }
+ // Same check admitOne applies before ever computing a hash: a manifest
+ // whose host id collides with a built-in can never actually be admitted,
+ // so trusting it would record a standing consent for content admission
+ // will always refuse — misleading UX for nothing gained.
+ if (HOST_REGISTRY.some((host) => host.id === manifest.host.id)) {
+ return { ok: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` };
+ }
+
+ return { ok: true, manifest, hash: hashManifest(manifest), origin };
+}
+
+/** Trust state for one entry — never throws. One of 'trusted', 'consent-stale',
+ * 'not consented', or 'manifest error ()'. */
+export async function stateFor(entry, { consent, reader }) {
+ if (typeof entry?.name !== 'string' || !entry.name) return 'manifest error (invalid-entry: missing name)';
+ const loaded = await loadAndHash(entry, { reader });
+ if (!loaded.ok) return `manifest error (${stripControl(loaded.reason)})`;
+ let recorded;
+ try {
+ recorded = consent.recordedHashFor(entry.name);
+ } catch (error) {
+ return `manifest error (consent-error: ${error?.message ?? String(error)})`;
+ }
+ if (recorded === null || recorded === undefined) return 'not consented';
+ return recorded === loaded.hash ? 'trusted' : 'consent-stale';
+}
+
+async function list({ cfg, consent, reader }) {
+ const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : [];
+ if (!entries.length) { info('no host adapters configured in kit.json'); return 0; }
+ console.log(bold('host adapters') + dim(' (trust state)'));
+ for (const entry of entries) {
+ const name = entry?.name ?? '(unnamed)';
+ console.log(` ${String(name).padEnd(16)} ${dim(entry?.source ?? '')}`);
+ console.log(` ${await stateFor(entry, { consent, reader })}`);
+ }
+ return 0;
+}
+
+function discloseManifest(name, manifest, hash) {
+ console.log(bold(`host adapter manifest — ${name}`));
+ // Full content FIRST, decision-critical summary LAST (finding 16): a
+ // large-but-legal manifest can run many screens of JSON, and whatever
+ // prints immediately before the [y/N] prompt is what the operator's eyes
+ // are actually on. Burying the summary above the JSON block meant it
+ // scrolled off-screen entirely on a big manifest, well before the prompt.
+ //
+ // Consent is granted over the WHOLE validated manifest (ADR-0029 §6),
+ // including fields the curated summary below doesn't call out by name
+ // (host.legacy.*, host.trust.approvalPolicy, detection, driving.surfaces,
+ // ...) — printing it in full means nothing hashed is ever hidden from the
+ // thing being consented to.
+ console.log(bold('full manifest (exactly the content being hashed):'));
+ // JSON.stringify escapes every C0 control character (0x00-0x1F) WITHIN A
+ // STRING VALUE by construction, but leaves C1 (0x80-0x9F, e.g. U+009B
+ // CSI) untouched — those can only reach this text as a raw byte that
+ // leaked from an untrusted manifest field, never from the pretty-printer's
+ // own structural indentation (spaces/real newlines are the ONLY raw
+ // control-range bytes stringify itself emits). Splitting on that
+ // structural newline before sanitizing each line and rejoining after
+ // means stripControl only ever sees within-line content — it strips a
+ // leaked C1 byte (and is a no-op on \n/\t/\r, since none survive stringify
+ // raw inside a line) without collapsing the block's own line breaks.
+ console.log(JSON.stringify(manifest, null, 2).split('\n').map(stripControl).join('\n'));
+ console.log('');
+ console.log(` version: ${manifest.version}`);
+ console.log(` contract: ${manifest.contract}`);
+ console.log(` host id: ${manifest.host.id}`);
+ const trueCaps = Object.entries(manifest.host.capabilities ?? {})
+ .filter(([, v]) => v === true).map(([k]) => k);
+ console.log(` capabilities: ${trueCaps.length ? trueCaps.join(', ') : '(none)'}`);
+ const lifecycle = manifest.lifecycle ?? {};
+ const verbs = Object.keys(lifecycle);
+ console.log(` lifecycle hooks:${verbs.length ? '' : ' (none)'}`);
+ for (const verb of verbs) {
+ const { hook } = lifecycle[verb];
+ const timeout = hook.timeoutMs !== undefined ? ` (timeout ${hook.timeoutMs}ms)` : '';
+ console.log(` ${verb}: ${JSON.stringify(hook.command)}${timeout}`);
+ }
+ const changes = manifest.trust?.changes ?? [];
+ console.log(` trust changes:${changes.length ? '' : ' (none)'}`);
+ for (const change of changes) {
+ console.log(` [${change.scope}] ${stripControl(change.owner)}: ${stripControl(change.value)} — ${stripControl(change.effect)}`);
+ }
+ console.log(` sha256: ${hash}`);
+}
+
+async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash }) {
+ if (typeof name !== 'string' || !name) { fail('usage: ak host adapters trust '); return 2; }
+ const entry = findEntry(cfg, name);
+ if (!entry) { fail(`no host adapter named '${name}' in kit.json hostAdapters`); return 1; }
+
+ const loaded = await loadAndHash(entry, { reader });
+ if (!loaded.ok) {
+ fail(`'${name}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`);
+ return 1;
+ }
+ const { manifest, hash, origin } = loaded;
+
+ // --expect-hash pinning (finding 8): required whenever --yes is paired
+ // with a non-file origin, so an unattended (CI) run can never blanket-
+ // consent to "whatever content the remote currently serves" — that
+ // defeats hash pinning exactly where it matters. Checked before any
+ // consent-store lookup or disclosure.
+ if (yes && origin !== 'file' && !expectHash) {
+ fail(`'${name}' resolved from a non-file origin ('${stripControl(origin)}') — --yes needs --expect-hash so an unattended run pins exact content instead of trusting whatever the remote serves right now (drop --yes to review interactively, or pass the hash from a prior interactive trust)`);
+ return 2;
+ }
+ if (expectHash !== undefined && expectHash !== hash) {
+ fail(`'${name}' hash mismatch — --expect-hash ${expectHash} does not match the resolved manifest hash ${hash}; refusing to record consent for unexpected content`);
+ return 1;
+ }
+
+ let recorded;
+ try {
+ recorded = consent.recordedHashFor(name);
+ } catch (error) {
+ fail(`consent store error: ${error?.message ?? String(error)}`);
+ return 1;
+ }
+ if (recorded === hash) {
+ ok(`'${name}' is already trusted at this exact content (${hash}) — nothing to do`);
+ return 0;
+ }
+ if (recorded !== null && recorded !== undefined) {
+ warn(`'${name}' consent is stale — previously trusted hash ${recorded}, current manifest hash ${hash}`);
+ }
+
+ discloseManifest(name, manifest, hash);
+
+ if (!yes) {
+ if (!isTTY) {
+ fail('trust needs confirmation — re-run with --yes after reviewing the manifest above (non-interactive session)');
+ return 2;
+ }
+ const confirmed = await ask('Trust this manifest content exactly as disclosed above? [y/N] ');
+ if (!confirmed) { info(`consent for '${name}' left unchanged`); return 0; }
+ }
+
+ consent.recordConsent(name, hash);
+ ok(`consent recorded for '${name}' at ${hash}`);
+ info('admission will now accept this exact manifest content — ANY edit to the manifest invalidates this consent; re-run `ak host adapters trust` after an edit');
+ return 0;
+}
+
+function revoke({ name, consent }) {
+ if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke '); return 2; }
+ const existed = consent.revokeConsent(name);
+ if (existed) { ok(`revoked consent for '${name}'`); return 0; }
+ info(`no recorded consent for '${name}'`);
+ return 0;
+}
+
+/** Unwrap a reader result down to the raw manifest document —
+ * runTieredConformance's `readManifest` follows admitAdapters' own contract
+ * ((source) => Promise raw), while `reader` here may return either the
+ * sources.mjs `{raw, origin}` shape or a bare raw document (same tolerance
+ * loadAndHash above applies). */
+function toRawManifestReader(reader) {
+ return async (source) => {
+ const resolved = await reader(source);
+ return resolved && typeof resolved === 'object' && 'raw' in resolved ? resolved.raw : resolved;
+ };
+}
+
+function tierLine(tier) {
+ const detail = tier.detail
+ ?? tier.checks.find((c) => !c.ok)?.detail
+ ?? tier.checks[0]?.detail
+ ?? '';
+ return `${String(tier.tier).padEnd(18)} ${String(tier.status).padEnd(8)} ${dim(stripControl(detail))}`;
+}
+
+export function hookCommandsFor(manifest) {
+ const hooks = [];
+ for (const [verb, def] of Object.entries(manifest.lifecycle ?? {})) {
+ if (def?.hook?.command) hooks.push(`lifecycle.${verb}: ${JSON.stringify(def.hook.command)}`);
+ }
+ if (manifest.execution?.run?.hook?.command) {
+ hooks.push(`execution.run: ${JSON.stringify(manifest.execution.run.hook.command)}`);
+ }
+ return hooks;
+}
+
+/** F6 (Wave C security review): `conformance` self-consents — it records its
+ * own temporary consent and spawns declared hooks with only the experimental
+ * flag plus a kit.json entry as gates, needing no prior
+ * `ak host adapters trust`. That is the intended self-test posture (ADR-0031
+ * §5), not a consent bypass, but the operator/maintainer must not be
+ * surprised by it: disclose every hook command about to run as a REAL
+ * subprocess before the harness does anything. Best-effort and non-fatal —
+ * a manifest that can't be pre-read/validated here still gets a generic
+ * warning; the harness's own admission tier reports the real failure reason
+ * either way, this is disclosure only, never a gate. */
+async function warnAboutHooks(name, entry, rawReader) {
+ warn(`'${name}' conformance is a SELF-TEST (ADR-0031 §5) — it records its own temporary consent and needs no prior 'ak host adapters trust'.`);
+ let manifest;
+ try {
+ manifest = validateAdapterManifest(await rawReader(entry.source));
+ } catch {
+ warn(' the manifest could not be pre-disclosed here; any declared hooks still run as REAL subprocesses.');
+ return;
+ }
+ const hooks = hookCommandsFor(manifest);
+ if (!hooks.length) {
+ info(` '${name}' declares no lifecycle/execution hooks — nothing will be spawned.`);
+ return;
+ }
+ warn(' the following hooks will run as REAL subprocesses:');
+ // N-1 (Wave C security review): hook.command is arbitrary validated
+ // strings — JSON.stringify escapes C0 (0x00-0x1F) but leaves C1
+ // (0x80-0x9F, e.g. U+009B CSI) and DEL (0x7F) untouched, same gap F3
+ // closed elsewhere in this file. This is the one line whose entire job is
+ // to be trustworthy before any code runs, so it routes through the same
+ // stripControl every other untrusted-string sink here already uses.
+ for (const line of hooks) console.log(` ${dim(stripControl(line))}`);
+}
+
+/** `ak host adapters conformance ` — runs the ADR-0031 §2 tiered
+ * conformance harness against one configured adapter entry and prints a
+ * per-tier table. Recording into the grant store happens INSIDE
+ * runTieredConformance itself (conformance.mjs) — this command's job is
+ * resolving the cfg entry, wiring a real reader, disclosing what is about to
+ * spawn, and rendering the report; see conformance.mjs's own header for the
+ * recording semantics (passed -> recordTierResult, upstream-gated ->
+ * recordTierGate, everything else persists nothing). */
+async function conformance({
+ name, cfg, reader, runTiered, consentFile, grantsFile,
+}) {
+ if (typeof name !== 'string' || !name) { fail('usage: ak host adapters conformance '); return 2; }
+ const entry = findEntry(cfg, name);
+ if (!entry) { fail(`no host adapter named '${name}' in kit.json hostAdapters`); return 1; }
+
+ const rawReader = toRawManifestReader(reader);
+ await warnAboutHooks(name, entry, rawReader);
+
+ let report;
+ try {
+ report = await runTiered({
+ name,
+ manifestSource: entry.source,
+ readManifest: rawReader,
+ consentFile,
+ grantsFile,
+ });
+ } catch (error) {
+ fail(`'${name}' conformance run failed: ${stripControl(error?.message ?? String(error))}`);
+ return 1;
+ }
+
+ console.log(bold(`host adapter conformance — ${report.name}`) + (report.hash ? dim(` (${report.hash})`) : ''));
+ let anyFailed = false;
+ for (const tier of report.tiers) {
+ const line = tierLine(tier);
+ if (tier.status === 'passed') ok(line);
+ else if (tier.status === 'failed') { fail(line); anyFailed = true; }
+ else if (tier.status === 'gated') warn(line);
+ else info(line);
+ }
+ return anyFailed ? 1 : 0;
+}
+
+/**
+ * @param {{ positionals?: string[], flags?: any, env?: NodeJS.ProcessEnv,
+ * consent?: { recordedHashFor(name:string): string|null, recordConsent(name:string, hash:string): void, revokeConsent(name:string): boolean },
+ * reader?: (source: string) => Promise, ask?: (question: string) => Promise,
+ * isTTY?: boolean, cfg?: any, runTieredConformance?: (options: any) => Promise,
+ * consentFile?: string, grantsFile?: string }} [args]
+ */
+export async function run({
+ positionals = [], flags = {}, env = process.env,
+ consent = consentStore, reader = defaultReader, ask = defaultAsk,
+ isTTY = process.stdin.isTTY === true, cfg,
+ runTieredConformance = defaultRunTieredConformance, consentFile, grantsFile,
+} = {}) {
+ const sub = positionals[0] ?? 'list';
+ const name = positionals[1];
+
+ // Revocation is fail-safe and stays reachable regardless of the
+ // experimental flag: an operator who turns the flag OFF must still be
+ // able to withdraw a standing consent or grant record, or it silently
+ // reactivates the next time the flag is turned back on. `list`/`trust`/
+ // `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, capability: positionals[2], grantsFile });
+
+ if (!flagEnabled(env)) {
+ fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`);
+ return 2;
+ }
+
+ const resolvedCfg = cfg ?? loadKitConfig();
+
+ if (sub === 'list') return list({ cfg: resolvedCfg, consent, reader });
+ if (sub === 'trust') {
+ return trust({
+ name, cfg: resolvedCfg, consent, reader, ask, isTTY,
+ yes: !!flags.yes, expectHash: flags['expect-hash'],
+ });
+ }
+ if (sub === 'conformance') {
+ return conformance({
+ name, cfg: resolvedCfg, reader, runTiered: runTieredConformance, consentFile, grantsFile,
+ });
+ }
+ // F-8 (security review, ADR-0031-accurate naming): `bless` is the alias —
+ // §3 calls this exact out-of-tree grant path a "Blessed external adapter";
+ // "Promoted built-in" is the separate manual registry PR this command does
+ // NOT do. `grant` stays the primary spelling; `promote` was dropped.
+ if (sub === 'grant' || sub === 'bless') {
+ return grantCap({
+ name, capability: positionals[2], cfg: resolvedCfg, consent, reader, ask, isTTY,
+ yes: !!flags.yes, grantsFile,
+ });
+ }
+ if (sub === 'gate') {
+ return gateTier({
+ name, tier: positionals[2], ref: positionals[3], cfg: resolvedCfg, reader, grantsFile,
+ });
+ }
+ if (sub === 'status') return statusReport({ name, cfg: resolvedCfg, reader, grantsFile });
+
+ fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`);
+ return 2;
+}
diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs
index 5e48455..bcbc8e4 100644
--- a/src/commands/x/host.mjs
+++ b/src/commands/x/host.mjs
@@ -50,6 +50,7 @@ export const options = {
provider: { type: 'string' }, // csv of ruflo API providers, optional id:model (openai:gpt-5.6)
route: { type: 'string', multiple: true }, // repeatable: 'activity:host[:model]' per-activity routing override
activity: { type: 'string' }, // refresh: csv of activities to re-seed (default = prompt)
+ 'expect-hash': { type: 'string' }, // adapters trust: required sha256 pin when --yes resolves a non-file source
yes: { type: 'boolean', default: false },
json: { type: 'boolean', default: false },
};
@@ -81,6 +82,12 @@ Subcommands:
(per-activity, opt-in; user pins are never touched, and \`ak sync\`
never does this for you)
off reversible teardown (reset to claude-only; strip managed env keys)
+ adapters record hash-pinned consent for external host-adapter manifests
+ (experimental — set AK_EXPERIMENTAL_HOST_ADAPTERS=1; revoke
+ always works, list/trust need the flag)
+ list show each configured adapter's trust state (default)
+ trust [--expect-hash ] grant consent (required
+ with --yes against a non-file source); revoke
Options (pick, all optional — omit for interactive):
--host the complete desired enabled-host set, e.g.
@@ -148,8 +155,12 @@ export async function run({ flags, positionals, pkgRoot }) {
if (sub === 'off') return off({ cwd, pkgRoot });
if (sub === 'pick') return pick({ flags, cwd, pkgRoot });
if (sub === 'refresh') return refresh({ flags, cwd });
+ if (sub === 'adapters') {
+ const { run: runHostAdapters } = await import('./host-adapters.mjs');
+ return runHostAdapters({ flags, positionals: positionals.slice(1) });
+ }
- fail(`unknown host subcommand: ${sub} (status|pick|refresh|off)`);
+ fail(`unknown host subcommand: ${sub} (status|pick|refresh|off|adapters)`);
return 2;
}
diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs
index a35d812..84bbe7a 100644
--- a/src/lib/adapters/admission.mjs
+++ b/src/lib/adapters/admission.mjs
@@ -7,11 +7,36 @@
// entry or the built-in registries (try/caught per entry, in admitOne AND as
// a belt-and-suspenders net in admitAdapters).
import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
import { HOST_REGISTRY } from './registries.mjs';
import { validateAdapterManifest } from './manifest.mjs';
export const SUPPORTED_CONTRACT = 1;
+/** The adapter's own directory (F-1, ADR-0031): where its execution/lifecycle
+ * hooks resolve a relative command FROM, never the operator's process.cwd()
+ * when `ak run` was invoked. A file-sourced manifest anchors to its own
+ * directory — `fs.realpathSync` so a symlinked manifest can't relocate that
+ * pin out from under consent. An npm/https source has no persistent local
+ * bundle (resolved, hashed, and discarded per admission pass — sources.mjs)
+ * so there is nothing to anchor to: `null`. buildAdmittedExecutionAdapter
+ * (execution/admitted.mjs) then refuses a relative hook command outright
+ * for a `null` baseDir rather than guessing a cwd. An unreadable/vanished
+ * file source also resolves to `null` — the same honest refusal, not a
+ * silent fallback to process.cwd(). */
+function baseDirForSource(source) {
+ if (typeof source !== 'string' || !source
+ || source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) {
+ return null;
+ }
+ try {
+ return path.dirname(fs.realpathSync(source));
+ } catch {
+ return null;
+ }
+}
+
/** Deterministic, key-sorted JSON — same stable-stringify shape used
* elsewhere in this codebase (e.g. opencode.mjs's deepEqual) so two manifests
* that differ only in key order or incidental whitespace hash identically.
@@ -128,9 +153,18 @@ export async function admitAdapters({ cfg, readManifest, consent }) {
}
async function defaultReadManifest(source) {
- const fs = await import('node:fs/promises');
- const raw = await fs.readFile(source, 'utf8');
- return JSON.parse(raw);
+ // Lazy dynamic import — keeps bootstrapHostAdapters's flag-off zero-cost
+ // property intact (no import happens unless a readManifest call actually
+ // runs, which only happens during admission with the flag set and
+ // adapters configured). Ordering is mandated by security review: resolve
+ // -> validate -> hash -> consent. The resolver (sources.mjs) runs here,
+ // BEFORE admitOne ever hashes the manifest, so consent pins the RESOLVED
+ // bytes — a mutable remote source (an npm dist-tag moving, a URL's
+ // content changing) invalidates consent automatically ('consent-stale')
+ // on the next admission pass, rather than being silently re-trusted.
+ const { resolveManifestSource } = await import('./sources.mjs');
+ const { raw } = await resolveManifestSource(source);
+ return raw;
}
/**
@@ -183,7 +217,134 @@ 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
+ // the raw cfg entry that named where it came from). Shared by both the
+ // execution- and lifecycle-registration blocks below — one map, not a
+ // second copy — so a caller correcting F-1 in one place can't drift from
+ // the other.
+ const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source]));
+
+ // P2 (ADR-0031): an admitted manifest declaring both an execution block
+ // and host.capabilities.canRouteActivities gets its execution adapter
+ // derived and registered here, so `ak run` can route to it. Same
+ // guarded, non-fatal posture as the rest of bootstrap: one adapter's
+ // registration failure never blocks the others or the admission result.
+ const executionCandidates = admitted.filter((result) => (
+ result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true
+ ));
+ if (executionCandidates.length) {
+ try {
+ const { registerAdmittedExecution } = await import('../execution/admitted.mjs');
+ for (const result of executionCandidates) {
+ // F-5 (ADR-0029 §2): a manifest that never declared the
+ // cli-subprocess driving surface gets no cli-subprocess execution
+ // adapter — refused with its own reason, before even attempting
+ // registration (buildAdmittedExecutionAdapter re-checks this too,
+ // defence-in-depth for any caller that bypasses this filter).
+ if (!result.manifest?.driving?.surfaces?.includes('cli-subprocess')) {
+ warnings.push({
+ name: result.name, reason: 'surface-unsupported',
+ detail: `'${result.name}' declares an execution block but not driving.surfaces including 'cli-subprocess'`,
+ });
+ continue;
+ }
+ try {
+ registerAdmittedExecution(result.manifest, { baseDir: baseDirForSource(sourceByName.get(result.name)) });
+ } catch (error) {
+ warnings.push({ name: result.name, reason: error?.reason ?? 'execution-registration-failed', detail: error?.message ?? String(error) });
+ }
+ }
+ } catch (error) {
+ for (const result of executionCandidates) {
+ warnings.push({ name: result.name, reason: 'execution-registration-failed', detail: error?.message ?? String(error) });
+ }
+ }
+ }
+
+ // P3 (ADR-0031): an admitted manifest declaring a lifecycle block gets its
+ // derived lifecycle adapter registered here, so it appears in
+ // hostsWithLifecycle() and setup/sync/uninstall's lifecycle loops can
+ // drive it (gated per-run by lifecycleExecutionEnabled — registration
+ // alone never runs a hook). Same guarded, non-fatal posture as the
+ // execution-registration block above: one adapter's registration failure
+ // never blocks the others or the admission result. F-1 (same as
+ // execution above): baseDir anchors a relative lifecycle hook command to
+ // the adapter's own directory — without it, a relative command would
+ // resolve against the OPERATOR's cwd, arbitrary-code-execution with the
+ // consent hash unchanged. Unlike execution (one hook, all-or-nothing),
+ // lifecycle has five independently-optional verbs, so an unanchorable
+ // one is refused per-verb (buildAdmittedLifecycleAdapter never wires it
+ // to spawn) rather than failing the whole registration — the other,
+ // anchored/PATH-binary verbs still register and work.
+ const lifecycleCandidates = admitted.filter((result) => !!result.manifest?.lifecycle);
+ if (lifecycleCandidates.length) {
+ try {
+ const { registerAdmittedLifecycle } = await import('./lifecycle-registry.mjs');
+ for (const result of lifecycleCandidates) {
+ try {
+ const baseDir = baseDirForSource(sourceByName.get(result.name));
+ const adapter = registerAdmittedLifecycle(result.manifest, { baseDir });
+ if (adapter.unanchoredVerbs.length) {
+ warnings.push({
+ name: result.name, reason: 'lifecycle-unanchored',
+ detail: `'${result.name}' lifecycle hook(s) refused (relative command, no anchored adapter `
+ + `base directory): ${adapter.unanchoredVerbs.join(', ')}`,
+ });
+ }
+ } catch (error) {
+ warnings.push({ name: result.name, reason: error?.reason ?? 'lifecycle-registration-failed', detail: error?.message ?? String(error) });
+ }
+ }
+ } catch (error) {
+ for (const result of lifecycleCandidates) {
+ warnings.push({ name: result.name, reason: 'lifecycle-registration-failed', detail: error?.message ?? String(error) });
+ }
+ }
+ }
}
return { active: true, admitted, warnings };
diff --git a/src/lib/adapters/admitted.mjs b/src/lib/adapters/admitted.mjs
index 21838fe..cad384b 100644
--- a/src/lib/adapters/admitted.mjs
+++ b/src/lib/adapters/admitted.mjs
@@ -30,6 +30,54 @@ 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|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
@@ -37,10 +85,28 @@ function conformOne(item) {
* 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} entries
+ * @param {{ grantsByName?: Record> }} [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;
}
@@ -62,3 +128,46 @@ export function effectiveHostRegistry() {
if (!applied || admittedEntries.length === 0) return HOST_REGISTRY;
return Object.freeze([...HOST_REGISTRY, ...admittedEntries]);
}
+
+/** Built-ins ∪ admitted hosts whose manifest declares
+ * capabilities.canRouteActivities — the LAZY set every routing VALIDATION
+ * path (routing.mjs's isRoutableHost, validateRoute, materializeRunPlan)
+ * must consult (P2, ADR-0031). routing.mjs's `HOSTS` constant stays frozen
+ * at import time and built-ins-only — it is display strings only now, never
+ * a validation source. Fresh on every call, like admittedHostIds() above. */
+export function effectiveRoutableHostIds() {
+ return effectiveHostRegistry()
+ .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);
+}
diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs
new file mode 100644
index 0000000..31d50e9
--- /dev/null
+++ b/src/lib/adapters/conformance.mjs
@@ -0,0 +1,652 @@
+// Tiered conformance harness (ADR-0031 §2, §5) — generalizes the single
+// admission-tier black-box report (tests/kit/adapter-conformance.test.mjs's
+// runConformanceReport) into the five graduation tiers named there: admission,
+// session-driving, activity-routing, primary-eligible, statusline. Each tier
+// is a black-box check against a REAL installed adapter layout — real
+// manifest, real admission, real subprocess hooks — no third-party code ever
+// runs in-process, matching every other module under adapters/.
+//
+// Reuse, not reimplementation: this module composes the SAME library calls
+// runConformanceReport uses (admitAdapters, applyAdmitted, effectiveHostRegistry,
+// registerAdmittedLifecycle, registerAdmittedExecution, executeRunPlan) rather
+// than importing that test file directly. A *.test.mjs module registers
+// node:test hooks/tests as an IMPORT-TIME side effect (its own top-level
+// `before`/`test` calls run the instant the module loads) — importing it from
+// a CLI command would fire that TAP output on every `ak host adapters
+// conformance` invocation and re-run its whole negative corpus for nothing
+// this command needs. The admission tier below is a leaner subset of the same
+// sequence (manifest validates -> admits -> joins the registry -> a declared
+// detect hook runs as a real subprocess); the negative corpus and
+// edit-invalidation checks stay owned by adapter-conformance.test.mjs, which
+// exercises the admission GATE itself, not one adapter's conformance.
+//
+// Passing a tier RECORDS EVIDENCE (grants.mjs's recordTierResult/recordTierGate)
+// — it never grants a capability. That stays the maintainer's explicit act
+// (ADR-0031 §1), landing with the promotion command in a later wave.
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { validateAdapterManifest } from './manifest.mjs';
+import { admitAdapters, hashManifest } from './admission.mjs';
+import {
+ applyAdmitted, resetAdmitted, effectiveHostRegistry, admittedHostIds,
+} from './admitted.mjs';
+import {
+ recordConsent, recordedHashFor as consentRecordedHashFor, isTrusted as consentIsTrusted,
+} from './consent.mjs';
+import { registerAdmittedLifecycle, resetAdmittedLifecycle } from './lifecycle-registry.mjs';
+import { registerAdmittedExecution, resetAdmittedExecution } from '../execution/admitted.mjs';
+import { executeRunPlan } from '../execution/runner.mjs';
+import { have } from '../exec.mjs';
+import {
+ CONFORMANCE_TIERS, TIER_GRANTS, adapterGrantsPath, recordTierResult, recordTierGate, recordTierFailure,
+ grantedCapabilitiesFor,
+} from './grants.mjs';
+
+export { CONFORMANCE_TIERS, TIER_GRANTS };
+
+const nowIso = () => new Date().toISOString();
+
+/** admitAdapters' readManifest contract: `(source) => Promise` — a real
+ * resolve, matching admission.mjs's own default (dynamic import so this
+ * module's own load stays cheap for callers that always inject a reader). */
+async function defaultReadManifest(source) {
+ const { resolveManifestSource } = await import('./sources.mjs');
+ const { raw } = await resolveManifestSource(source);
+ return raw;
+}
+
+/** The adapter's own directory, for the execution adapter's F-1 cwd-anchoring
+ * (execution/admitted.mjs) — same derivation admission.mjs's own
+ * baseDirForSource uses (not exported there, so mirrored here rather than
+ * reaching into that module's private internals). A remote (npm/https)
+ * source has no persistent local bundle: null, honestly, never a guessed cwd. */
+function baseDirForSource(source) {
+ if (typeof source !== 'string' || !source
+ || source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) {
+ return null;
+ }
+ try {
+ return path.dirname(fs.realpathSync(source));
+ } catch {
+ return null;
+ }
+}
+
+async function runCheck(checks, name, fn) {
+ try {
+ const detail = await fn();
+ checks.push({ name, ok: true, detail: typeof detail === 'string' ? detail : undefined });
+ } catch (error) {
+ checks.push({ name, ok: false, detail: error?.message ?? String(error) });
+ }
+}
+
+function evidenceFromChecks(checks) {
+ return checks.map((c) => c.detail).filter(Boolean).join('; ');
+}
+
+// ── admission tier ──────────────────────────────────────────────────────
+// manifest validates -> admits through admitAdapters with a real on-disk
+// consent record -> joins effectiveHostRegistry() -> a declared detect hook
+// runs as a real subprocess and its JSON payload flows back (ADR-0031 §2's
+// evidence shape for this tier, and the shipped ADR-0029 admission gate).
+async function checkAdmission({
+ name, source, readManifest, consentFile, baseDir,
+}) {
+ const checks = [];
+ let manifest = null;
+ let admittedResult = null;
+
+ await runCheck(checks, 'manifest reads and validates', async () => {
+ const raw = await readManifest(source);
+ manifest = validateAdapterManifest(raw);
+ return `host id '${manifest.host.id}', contract ${manifest.contract}`;
+ });
+
+ if (!manifest) return { checks, manifest: null, hash: null };
+
+ const hash = hashManifest(manifest);
+ const resolvedName = name ?? manifest.host.id;
+
+ await runCheck(checks, 'admits through admitAdapters with a real on-disk consent record', async () => {
+ recordConsent(resolvedName, hash, { file: consentFile });
+ const results = await admitAdapters({
+ cfg: { hostAdapters: [{ name: resolvedName, source }] },
+ readManifest,
+ consent: {
+ recordedHashFor: (n) => consentRecordedHashFor(n, { file: consentFile }),
+ isTrusted: (n, h) => consentIsTrusted(n, h, { file: consentFile }),
+ },
+ });
+ admittedResult = results[0];
+ if (!admittedResult?.admitted) throw new Error(admittedResult?.detail ?? `admission refused: ${admittedResult?.reason}`);
+ return `admitted as '${admittedResult.entry.id}'`;
+ });
+
+ if (admittedResult?.admitted) {
+ await runCheck(checks, "joins effectiveHostRegistry()", async () => {
+ applyAdmitted([admittedResult]);
+ if (!effectiveHostRegistry().some((host) => host.id === resolvedName)) throw new Error('not present in effectiveHostRegistry()');
+ if (!admittedHostIds().includes(resolvedName)) throw new Error('not present in admittedHostIds()');
+ return 'joined effectiveHostRegistry()';
+ });
+ }
+
+ const detectHook = manifest.lifecycle?.detect?.hook;
+ if (detectHook) {
+ await runCheck(checks, 'declared detect hook runs as a real subprocess', async () => {
+ // No runHook injection: exercises buildAdmittedLifecycleAdapter's real
+ // default, a dynamic import of the real hook-runner.mjs — the whole
+ // chain (derived adapter -> hook runner -> spawned process -> stdout
+ // JSON) actually runs, not just wired (mirrors runConformanceReport).
+ // F1 (Wave C security review): baseDir MUST be threaded through here —
+ // with no baseDir, buildAdmittedLifecycleAdapter's own F-1 anchoring
+ // check (lifecycle-registry.mjs) refuses ANY relative lifecycle hook
+ // command outright (adapter.unanchoredVerbs), which would report every
+ // realistically-authored file-sourced adapter — including the repo's
+ // own acme fixture — as FAILED here for a reason that has nothing to
+ // do with the adapter's actual conformance.
+ const adapter = registerAdmittedLifecycle(manifest, { baseDir });
+ const detected = await adapter.detect({});
+ if (!detected || typeof detected !== 'object') throw new Error('detect hook returned no observation');
+ if (detected.error) throw new Error(`detect hook reported an error: ${detected.error}`);
+ return 'detect hook produced an observation';
+ });
+ } else {
+ checks.push({ name: 'declared detect hook runs as a real subprocess', ok: true, detail: 'no detect hook declared — nothing to prove' });
+ }
+
+ return { checks, manifest, hash };
+}
+
+// ── session-driving tier ────────────────────────────────────────────────
+// Gates canDriveSession. A manifest that never declares it has nothing to
+// prove (skipped). A manifest that DOES declare it cannot be proven today:
+// ak has no external session-driving execution path, and being a native
+// ruflo backend is upstream-owned (ADR-0031 §4, grounded against
+// ruvnet/ruflo@45e65b5's per-host ENABLE_* backend model, not an outside
+// registration surface) — honestly 'gated', never faked. `upstreamRef`
+// (caller-supplied, `#` form) is ONLY set once a real tracking
+// issue exists to file the backend-registration request against; omitted, the
+// report still says 'gated' but carries no `gatedBy` to persist — there is
+// nothing yet to pin a grants.mjs recordTierGate() call to.
+function checkSessionDriving({ manifest, upstreamRef }) {
+ if (!manifest) {
+ return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] };
+ }
+ if (manifest.host?.capabilities?.canDriveSession !== true) {
+ return {
+ status: 'skipped',
+ checks: [{ name: 'canDriveSession declared', ok: true, detail: 'not declared — nothing to prove' }],
+ };
+ }
+ const detail = "ak has no external session-driving execution path yet — being a native ruflo backend is "
+ + 'upstream-owned (ADR-0031 §4: ruvnet/ruflo\'s per-host ENABLE_* backend model, not an outside '
+ + "registration surface); interim behaviour: the host runs through ak's own supervised execution, "
+ + 'just not as a ruflo-native backend';
+ return {
+ status: 'gated',
+ checks: [{ name: 'external session-driving execution path exists', ok: false, detail }],
+ detail,
+ ...(typeof upstreamRef === 'string' && upstreamRef ? { gatedBy: upstreamRef } : {}),
+ };
+}
+
+// ── activity-routing tier ───────────────────────────────────────────────
+// Gates canRouteActivities. Requires an execution.run hook. Admits, derives
+// the real execution adapter, and drives a real one-worker executeRunPlan
+// routed to the host — asserting a succeeded WorkerResult under the runner's
+// contract (ADR-0018). This is the tier P2 (execution/admitted.mjs) makes
+// real, so it is the one tier expected to genuinely PASS against a conforming
+// fixture today.
+async function checkActivityRouting({
+ manifest, name, baseDir, haveFn, clock,
+}) {
+ if (!manifest) {
+ return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] };
+ }
+ const canRoute = manifest.host?.capabilities?.canRouteActivities === true;
+ const hasHook = !!manifest.execution?.run?.hook;
+ if (!canRoute || !hasHook) {
+ return {
+ status: 'skipped',
+ checks: [{ name: 'canRouteActivities + execution.run.hook declared', ok: true, detail: 'not declared — nothing to prove' }],
+ };
+ }
+
+ const checks = [];
+ let succeeded = null;
+
+ await runCheck(checks, 'registerAdmittedExecution derives and registers a real execution adapter', async () => {
+ resetAdmittedExecution();
+ registerAdmittedExecution(manifest, { haveFn, baseDir });
+ return `registered for '${name}'`;
+ });
+
+ await runCheck(checks, "a real one-worker executeRunPlan routed to the host returns a succeeded WorkerResult (ADR-0018)", async () => {
+ // No runHook injection: exercises executeRunPlan's default adapter lookup
+ // (executionAdapterFor -> admittedExecutionAdapterFor) end-to-end, same
+ // discipline as runConformanceReport's P2 check — a genuine spawned
+ // subprocess, not a stub.
+ const plan = {
+ workers: [{
+ id: 'conformance-w1', activity: 'implementation', role: 'coder', host: name, prompt: 'conformance harness probe',
+ }],
+ };
+ const [result] = await executeRunPlan(plan, { clock });
+ if (result.status !== 'succeeded') throw new Error(result.failure?.reason ?? `expected a succeeded WorkerResult, got '${result.status}'`);
+ succeeded = result;
+ return `worker '${result.workerId}' succeeded via host '${result.host}' (exitCategory=${result.exitCategory})`;
+ });
+
+ resetAdmittedExecution();
+
+ const status = checks.every((c) => c.ok) ? 'passed' : 'failed';
+ return {
+ status,
+ checks,
+ ...(succeeded ? { evidence: `worker succeeded via host '${succeeded.host}', exitCategory=${succeeded.exitCategory}, provider=${succeeded.provider ?? 'unknown'}` } : {}),
+ };
+}
+
+// ── primary-eligible tier: real exercise (ADR-0031 §2, ADR-0019) ───────────
+// The chicken-and-egg this tier exists to break: earning canBePrimary needs
+// evidence of leading a run and receiving an escalation, but ak's real
+// primary-host machinery (routing.mjs) only ever selects a host that has
+// ALREADY got canBePrimary — proving it through that live path would need
+// the grant this tier exists to justify. The harness sidesteps that by
+// building the lead/escalation plan directly in its OWN sandbox and driving
+// it through the real executeRunPlan (ADR-0018/0019) — genuine subprocess
+// behaviour, not the live primary-host registry.
+//
+// F-1 (security review, HIGH, fixed): this exercise runs UNCONDITIONALLY —
+// never gated on an existing canBePrimary grant. ADR-0031 §2's own sequence
+// is evidence FIRST, grant SECOND ("passing a tier records evidence; the
+// maintainer's grant turns evidence into capability"): grantCapability
+// (grants.mjs) refuses unless a 'passed' primary-eligible tier is ALREADY
+// recorded at this hash, so gating the exercise on the grant it is meant to
+// justify is a deadlock — the only way out would be hand-seeding a 'passed'
+// tier before ever running the exercise, which defeats the entire earned-
+// evidence point. checkPrimaryEligible below records 'passed' straight off a
+// genuine exercise result; a maintainer's subsequent `ak host adapters grant
+// canBePrimary` is the ONLY thing that turns that recorded evidence
+// into a live capability via the keystone overlay (admitted.mjs) — that
+// separation, not a pre-exercise grant check, is what ADR-0031 §1 actually
+// asks for. (The grant check this replaced existed to stop a caller-supplied
+// exercise result from being self-evidence — moot now: there is no
+// caller-injectable exercise at all, see below.)
+//
+// Two things the exercise proves, and what it does NOT claim (F-7):
+// - A direct, non-escalated worker completes on the admitted host — the same
+// worker-execution path activity-routing already proved, run again here as
+// this tier's own independent evidence.
+// - A SEPARATE worker starts on a host id this harness invents on the spot —
+// never a built-in, never admitted, never registered in ANY adapter
+// overlay — which the real runner genuinely cannot route: a real
+// `cli_unavailable` WorkerResult, not a fabricated one (no hook trickery,
+// no fixture changes needed: the failure is architectural — no adapter
+// exists for that host id at all). That worker's one-rung escalation
+// ladder points at the admitted host, so a real escalatable failure
+// (runner.mjs's ESCALATABLE_STATUSES/BLOCKING_CATEGORIES) genuinely
+// advances onto it (ADR-0019) — the `attempts` trail proves more than one
+// attempt ran and the admitted host is the LAST rung, succeeded.
+// Both completions are the SAME execution.run hook exiting 0 twice, driven
+// through the real derived execution adapter — this demonstrates the
+// escalation ladder and execution-adapter contract plumbing over an
+// already-proven activity-routing path. It is NOT evidence the host can
+// "lead agentic work" in any qualitative sense — the evidence string below
+// is worded to that precision, not more.
+//
+// This needs the host's activity-routing to actually work (it runs real
+// workers through the same derived execution adapter), so this tier depends
+// on activity-routing genuinely passing THIS run — enforced by the caller
+// (runTieredConformance) before this function is ever reached.
+//
+// No caller-injectable seam: unlike the placeholder this replaces, there is
+// no `exercisePrimaryEligible` option anymore — the ONLY exercise that can
+// ever run is this real one, so a caller cannot launder a fabricated
+// {ok:true} into a pass (Wave C's off-the-CLI constraint, hardened: not just
+// unreachable from the CLI, structurally unreachable from ANY caller).
+const PRIMARY_ELIGIBLE_UNROUTED_HOST_SUFFIX = 'conformance-unrouted-rung';
+
+async function runPrimaryEligibleExercise({
+ manifest, name, baseDir, haveFn, clock,
+}) {
+ const unroutedHost = `${PRIMARY_ELIGIBLE_UNROUTED_HOST_SUFFIX}-${name}`;
+ resetAdmittedExecution();
+ try {
+ registerAdmittedExecution(manifest, { haveFn, baseDir });
+ const plan = {
+ workers: [
+ {
+ id: 'primary-eligible-direct', activity: 'implementation', role: 'coder', host: name,
+ prompt: 'conformance harness probe: primary-eligible direct run (non-escalated)',
+ },
+ {
+ id: 'primary-eligible-escalation', activity: 'implementation', role: 'coder', host: unroutedHost,
+ prompt: 'conformance harness probe: primary-eligible escalation (receives an escalation)',
+ escalate: [{ host: name, model: null }],
+ },
+ ],
+ };
+ const [direct, escalation] = await executeRunPlan(plan, { clock, escalate: true });
+
+ if (direct?.status !== 'succeeded' || direct.host !== name) {
+ return { ok: false, detail: `direct (non-escalated) worker did not complete via '${name}' (status=${direct?.status})` };
+ }
+ const attempts = escalation?.attempts ?? [];
+ const lastAttempt = attempts[attempts.length - 1];
+ const genuinelyEscalated = attempts.length > 1
+ && lastAttempt?.host === name && lastAttempt?.status === 'succeeded'
+ && attempts.slice(0, -1).every((a) => a.host !== name);
+ if (escalation?.status !== 'succeeded' || escalation.host !== name || !genuinelyEscalated) {
+ return {
+ ok: false,
+ detail: `escalation worker did not genuinely advance onto '${name}' via ADR-0019 (status=${escalation?.status}, attempts=${attempts.length})`,
+ };
+ }
+ return {
+ ok: true,
+ detail: `'${name}' completed a direct (non-escalated) run to a succeeded result, and separately received a `
+ + `genuine ADR-0019 escalation onto itself after an unrouted host's real cli_unavailable failure `
+ + `(${attempts.length} attempts: ${attempts.map((a) => `${a.host}=${a.status}`).join(' -> ')}) — both runs `
+ + "completed via the adapter's real execution.run hook (escalation-ladder and execution-contract plumbing "
+ + 'over the already-proven activity-routing path, not a claim about agentic task quality)',
+ };
+ } catch (error) {
+ return { ok: false, detail: `primary-eligible exercise threw: ${error?.message ?? String(error)}` };
+ } finally {
+ resetAdmittedExecution();
+ }
+}
+
+/**
+ * primary-eligible's own check (ADR-0031 §2, ADR-0019) — deliberately NOT
+ * checkGrantGatedTier: F-1 (security review) found that gating this
+ * exercise on an EXISTING canBePrimary grant deadlocks against
+ * grantCapability's own requirement of an already-'passed' tier. This runs
+ * the real exercise unconditionally (once admission and activity-routing
+ * have genuinely passed) and reports 'passed' straight off a genuine result
+ * — recordTierResult (the caller's shared persist step) writes that evidence
+ * regardless of whether anything is granted yet, which is precisely what
+ * lets a maintainer's later grantCapability succeed at all.
+ */
+async function checkPrimaryEligible({
+ manifest, name, baseDir, haveFn, clock,
+}) {
+ const exerciseLabel = 'leads a run and receives an escalation (ADR-0019)';
+ const outcome = await runPrimaryEligibleExercise({
+ manifest, name, baseDir, haveFn, clock,
+ });
+ if (outcome.ok) {
+ return { status: 'passed', checks: [{ name: exerciseLabel, ok: true, detail: outcome.detail }], evidence: outcome.detail };
+ }
+ return { status: 'failed', checks: [{ name: exerciseLabel, ok: false, detail: outcome.detail }] };
+}
+
+// ── statusline tier ─────────────────────────────────────────────────────
+// Gates a capability the manifest can NEVER declare (commandStatusline is
+// inexpressible in the schema — ADR-0029/0031). Cannot be exercised
+// end-to-end today: there is no admitted-host statusline footer-render path
+// anywhere in src/ yet (grep-verified against this wave) — so even a granted
+// capability has nothing real to drive through. `exercise` is the injection
+// point for the day that path lands: the honest default reports 'gated'
+// rather than fabricate a pass, per ADR-0031's "do not fake a pass"
+// discipline. Ungranted is reported 'gated' too (an ak-local wait on the
+// maintainer's promotion-command grant, Wave D — never an upstream ceiling,
+// so it is never persisted via grants.mjs's recordTierGate, which is
+// reserved for genuinely upstream gates).
+async function checkGrantGatedTier({
+ capability, manifest, name, hash, grantsFile, exercise, exerciseLabel,
+}) {
+ if (!manifest) {
+ return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] };
+ }
+ const granted = grantedCapabilitiesFor(name, hash, { file: grantsFile })[capability] === true;
+ if (!granted) {
+ const detail = `no '${capability}' grant recorded at this manifest hash — conferred only by an explicit `
+ + 'maintainer grant on top of passed conformance evidence (ADR-0031 §1), via the promotion command';
+ return {
+ status: 'gated',
+ checks: [{ name: `${capability} granted`, ok: false, detail }],
+ detail: `ak-local: awaiting maintainer grant for '${capability}' (not an upstream ceiling)`,
+ };
+ }
+
+ const outcome = exercise
+ ? await exercise({ manifest, name, hash })
+ : { ok: false, detail: `${exerciseLabel} — no runtime path built yet` };
+
+ if (outcome?.ok) {
+ const detail = typeof outcome.detail === 'string' ? outcome.detail : exerciseLabel;
+ return { status: 'passed', checks: [{ name: exerciseLabel, ok: true, detail }], evidence: detail };
+ }
+ return {
+ status: 'gated',
+ checks: [{ name: exerciseLabel, ok: false, detail: outcome?.detail ?? `${exerciseLabel} — no runtime path built yet` }],
+ detail: `ak-local: '${capability}' is granted, but the runtime path to exercise it is not built yet`,
+ };
+}
+
+/**
+ * Run the ADR-0031 §2 tiered conformance sequence against one adapter
+ * manifest and return a structured, per-tier report. Self-contained: builds
+ * its own temp consent store (unless `consentFile` is supplied) and cleans
+ * it up, resets the admitted/execution overlays on the way out, and is safe
+ * to call more than once in the same process — same discipline as
+ * runConformanceReport (tests/kit/adapter-conformance.test.mjs).
+ *
+ * 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, 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
+ * explicit path. Pass `persist: false` to skip recording altogether.
+ *
+ * primary-eligible has no caller-injectable exercise (unlike statusline,
+ * still a placeholder this wave): it always runs runPrimaryEligibleExercise,
+ * a real escalation-plus-direct-run probe driven through executeRunPlan
+ * (ADR-0018/0019), UNCONDITIONALLY — never gated on an existing canBePrimary
+ * grant (F-1, security review: gating the exercise on the grant it exists to
+ * justify is a deadlock against grantCapability's own "already passed"
+ * requirement). It depends on activity-routing genuinely passing in THIS run
+ * (computed regardless of whether 'activity-routing' is in `tiers`) —
+ * 'skipped' with "activity-routing did not pass" otherwise, mirroring the
+ * admission short-circuit above it. A genuine pass is recorded via
+ * recordTierResult like any other tier; a maintainer's subsequent
+ * grantCapability call is what turns that evidence into a live capability.
+ *
+ * @param {{
+ * fixtureRoot?: string, manifestSource?: string, name?: string,
+ * tiers?: readonly string[], readManifest?: (source: string) => Promise,
+ * consentFile?: string, grantsFile?: string, persist?: boolean,
+ * haveFn?: (cmd: string, opts?: any) => Promise, baseDir?: string|null,
+ * sessionDrivingUpstreamRef?: string,
+ * exerciseStatusline?: (ctx: {manifest:any,name:string,hash:string}) => Promise<{ok:boolean,detail?:string}>,
+ * clock?: () => string,
+ * }} [options]
+ * @returns {Promise<{ name: string, hash: string|null,
+ * tiers: Array<{ tier: string, status: 'passed'|'failed'|'gated'|'skipped',
+ * checks: Array<{name:string, ok:boolean, detail?:string}>,
+ * detail?: string, gatedBy?: string, evidence?: string, recordError?: string }> }>}
+ */
+export async function runTieredConformance({
+ fixtureRoot,
+ manifestSource = fixtureRoot ? path.join(fixtureRoot, 'manifest.json') : undefined,
+ name,
+ tiers = CONFORMANCE_TIERS,
+ readManifest = defaultReadManifest,
+ consentFile,
+ grantsFile = adapterGrantsPath(),
+ persist = true,
+ haveFn = have,
+ baseDir,
+ sessionDrivingUpstreamRef,
+ exerciseStatusline,
+ clock = nowIso,
+} = {}) {
+ if (typeof manifestSource !== 'string' || !manifestSource) {
+ throw new TypeError('runTieredConformance requires fixtureRoot or manifestSource');
+ }
+
+ let tempDir = null;
+ let consentFileUsed = consentFile;
+ if (!consentFileUsed) {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-conformance-tiers-'));
+ consentFileUsed = path.join(tempDir, 'adapter-consent.json');
+ }
+
+ try {
+ // F1 (Wave C): baseDir is computed BEFORE checkAdmission so it can thread
+ // straight into registerAdmittedLifecycle for the detect-hook check —
+ // the same anchoring the execution tier already needed, just derived
+ // earlier now that admission needs it too.
+ const derivedBaseDir = baseDir !== undefined ? baseDir : baseDirForSource(manifestSource);
+ const admission = await checkAdmission({
+ name, source: manifestSource, readManifest, consentFile: consentFileUsed, baseDir: derivedBaseDir,
+ });
+ const resolvedName = name ?? admission.manifest?.host?.id ?? '(unknown)';
+ const { hash } = admission;
+ // F2 (Wave C, BLOCKER): every post-admission tier gated on manifest
+ // validity alone (`admission.manifest != null`) would still exercise a
+ // manifest that schema-validated but whose REAL admission (admitAdapters
+ // — the consent/contract/builtin-shadow gate) was refused: e.g. stale or
+ // missing consent. That let a refused adapter's execution.run hook be
+ // spawned for real and its result recorded 'passed' into grantsFile
+ // (which defaults to the operator's REAL adapter-grants.json). Gating on
+ // the WHOLE admission tier passing — not just the manifest parsing —
+ // closes that: a manifest whose admission failed is treated identically
+ // to one that never validated at all, for every downstream tier.
+ const admissionPassed = admission.checks.length > 0 && admission.checks.every((c) => c.ok);
+ const effectiveManifest = admissionPassed ? admission.manifest : null;
+ const wantTier = (tier) => tiers.includes(tier);
+
+ const tierResults = [];
+
+ if (wantTier('admission')) {
+ tierResults.push({
+ tier: 'admission',
+ status: admissionPassed ? 'passed' : 'failed',
+ checks: admission.checks,
+ });
+ }
+
+ if (wantTier('session-driving')) {
+ tierResults.push({ tier: 'session-driving', ...checkSessionDriving({ manifest: effectiveManifest, upstreamRef: sessionDrivingUpstreamRef }) });
+ }
+
+ // primary-eligible's real exercise runs actual workers through the
+ // admitted host's derived execution adapter, so it needs activity-routing
+ // to have genuinely passed THIS run — not merely to have been requested.
+ // Computed here, once, whenever either tier needs it, so a caller asking
+ // for `tiers: ['primary-eligible']` alone still gets the real dependency
+ // check rather than an unconditioned pass/skip.
+ let activityRoutingResult = null;
+ if (wantTier('activity-routing') || wantTier('primary-eligible')) {
+ activityRoutingResult = await checkActivityRouting({
+ manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock,
+ });
+ if (wantTier('activity-routing')) {
+ tierResults.push({ tier: 'activity-routing', ...activityRoutingResult });
+ }
+ }
+
+ if (wantTier('primary-eligible')) {
+ // F-1 (security review): NOT checkGrantGatedTier — that gates the
+ // exercise on an already-existing canBePrimary grant, which deadlocks
+ // against grantCapability's own requirement of an already-'passed'
+ // tier (see checkPrimaryEligible's header comment). checkPrimaryEligible
+ // runs the real exercise unconditionally once its own prerequisites
+ // (admission, activity-routing) are met — evidence first, grant second,
+ // per ADR-0031 §1/§2.
+ let result;
+ if (!effectiveManifest) {
+ result = { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] };
+ } else if (activityRoutingResult?.status !== 'passed') {
+ // Mirrors the admission short-circuit above: when admission passed
+ // but activity-routing (this tier's own real prerequisite) did not,
+ // report the SAME 'skipped' shape with a distinct reason, rather
+ // than attempting an exercise the host cannot actually support yet.
+ result = {
+ status: 'skipped',
+ checks: [{ name: 'activity-routing prerequisite', ok: false, detail: 'activity-routing did not pass — cannot evaluate' }],
+ };
+ } else {
+ result = await checkPrimaryEligible({
+ manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock,
+ });
+ }
+ tierResults.push({ tier: 'primary-eligible', ...result });
+ }
+
+ if (wantTier('statusline')) {
+ const result = await checkGrantGatedTier({
+ capability: 'commandStatusline',
+ manifest: effectiveManifest,
+ name: resolvedName,
+ hash,
+ grantsFile,
+ exercise: exerciseStatusline,
+ exerciseLabel: 'renders and refreshes a command-backed footer',
+ });
+ tierResults.push({ tier: 'statusline', ...result });
+ }
+
+ if (persist && hash) {
+ for (const tierResult of tierResults) {
+ try {
+ if (tierResult.status === 'passed') {
+ const evidence = tierResult.evidence ?? evidenceFromChecks(tierResult.checks) ?? tierResult.tier;
+ 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);
+ }
+ }
+ }
+
+ return { name: resolvedName, hash, tiers: tierResults };
+ } finally {
+ // F7 (Wave C security review): all THREE overlays a conformance run can
+ // touch — the host overlay, the execution overlay, and the lifecycle
+ // registration checkAdmission's detect-hook check creates via
+ // registerAdmittedLifecycle — must reset together. Resetting only the
+ // first two (as before) left an already-registered lifecycle adapter
+ // live for a host id this run's own admission may have just refused,
+ // the same live-edge class F-9 (execution/admitted.mjs's
+ // resetAllAdmitted) already closed for the other two overlays.
+ resetAdmitted();
+ resetAdmittedExecution();
+ resetAdmittedLifecycle();
+ if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } }
+ }
+}
diff --git a/src/lib/adapters/grants.mjs b/src/lib/adapters/grants.mjs
new file mode 100644
index 0000000..04937d5
--- /dev/null
+++ b/src/lib/adapters/grants.mjs
@@ -0,0 +1,393 @@
+// Hash-pinned capability-grant store (ADR-0031 §1, §2, §4). A JSON map of
+// adapter name -> record under the kit config dir, mirroring adapter-consent's
+// edit-invalidation model: a capability is earned by passing a conformance
+// tier and GRANTED by the maintainer at a specific manifest hash, never
+// self-declared in the adapter's own manifest (the permanent safety invariant
+// this store exists to keep honest — see admission.mjs's schema allow-list).
+// Change the manifest and the hash changes, so every prior tier result and
+// every granted capability is void until re-earned at the new hash.
+//
+// No interactive prompting lives here — grants are RECORDED elsewhere (a
+// future `ak host adapters trust` / `ak host adapters grant` command). This
+// module is the programmatic seam those commands call.
+//
+// INVARIANT for callers wiring capabilities into runtime behaviour:
+// grantedCapabilitiesFor() is the ONLY reader that may feed a capability
+// decision. grantsFor() and gatedTiersFor() are reporting/inspection
+// surfaces — grantsFor() can return a record pinned to a stale hash (flagged
+// `stale: true` when a currentHash is supplied) precisely so a status
+// display CAN show stale evidence as stale, not hide it. Reading
+// grantsFor(name).capabilities directly to decide what a host may do would
+// reintroduce the exact bug grantedCapabilitiesFor's hash pin exists to
+// prevent.
+import fs from 'node:fs';
+import path from 'node:path';
+import { configDir } from '../paths.mjs';
+
+/** The five conformance tiers, in graduation order (ADR-0031 §2). */
+export const CONFORMANCE_TIERS = Object.freeze([
+ 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline',
+]);
+
+/** The only capabilities `ak` can grant. `session-driving` and
+ * `activity-routing` gate capabilities a manifest may already express
+ * (canDriveSession, canRouteActivities) — their tier records are evidence,
+ * not grants, so they have no entry here. `aqeProvider` is never grantable by
+ * `ak` at all (upstream-owned enumeration, ADR-0031 §4) and MUST NOT appear
+ * in this map. */
+export const TIER_GRANTS = Object.freeze({
+ 'primary-eligible': 'canBePrimary',
+ statusline: 'commandStatusline',
+});
+
+/** Reverse of TIER_GRANTS: capability -> its gating tier, or undefined if
+ * `capability` is not one `ak` can actually grant (e.g. a forged/legacy
+ * 'aqeProvider' key sitting in a hand-edited or merged store). Shared by
+ * grantCapability (write-time check) and grantedCapabilitiesFor (read-time
+ * re-check — F-1: the write-time gate alone does not protect a flat JSON
+ * file an operator can edit directly). */
+function gatingTierForCapability(capability) {
+ return Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0];
+}
+
+export const adapterGrantsPath = () => path.join(configDir(), 'adapter-grants.json');
+
+function readStore(file) {
+ let raw;
+ try {
+ raw = fs.readFileSync(file, 'utf8');
+ } catch {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+/** Atomic tmp+rename write at 0600, matching consent.mjs and the rest of the
+ * kit's user-owned config writes. */
+function writeStore(file, store) {
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
+ let renamed = false;
+ try {
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
+ fs.renameSync(tmp, file);
+ renamed = true;
+ } finally {
+ if (!renamed) {
+ try { fs.rmSync(tmp, { force: true }); } catch { /* cleanup must not mask the write error */ }
+ }
+ }
+ try { fs.chmodSync(file, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
+}
+
+const GATED_BY_RE = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?#[1-9][0-9]*$/;
+
+function isValidTier(tier) {
+ return typeof tier === 'string' && CONFORMANCE_TIERS.includes(tier);
+}
+
+function requireName(name, fnName) {
+ if (typeof name !== 'string' || !name) throw new TypeError(`${fnName} requires an adapter name`);
+}
+
+function requireHash(hash, fnName) {
+ if (typeof hash !== 'string' || !hash) throw new TypeError(`${fnName} requires a hash`);
+}
+
+function validRecord(value) {
+ return !!value && typeof value === 'object'
+ && typeof value.hash === 'string' && value.hash.length > 0
+ && !!value.tiers && typeof value.tiers === 'object' && !Array.isArray(value.tiers);
+}
+
+/** The record for `name` at exactly `hash`, own-property only (Object.hasOwn,
+ * never `in` — a store containing 'constructor' or '__proto__' must not read
+ * as a record via the prototype chain). A hash mismatch (or no record at all)
+ * means "start fresh": the caller REPLACES the whole entry, since stale-hash
+ * evidence and capabilities must never coexist with fresh-hash evidence. */
+function freshRecordAt(store, name, hash) {
+ const existing = Object.hasOwn(store, name) ? store[name] : null;
+ if (existing && existing.hash === hash) return { ...existing, tiers: { ...existing.tiers } };
+ return { hash, tiers: {} };
+}
+
+/** Record a passing conformance-tier result for `name` at `hash`. Evidence is
+ * truncated at 2048 chars — bounded so a runaway harness output can never
+ * blow up the store. For a grant-bearing tier (one of TIER_GRANTS' keys —
+ * 'primary-eligible', 'statusline') non-empty evidence is REQUIRED: ADR-0031
+ * §1 is "conformance evidence plus an explicit maintainer grant confers
+ * [the capability]", so a grant must never trace back to an empty evidence
+ * string. Evidence-only tiers ('admission', 'session-driving',
+ * 'activity-routing') keep it optional. Recording at a hash different from
+ * the stored record silently REPLACES the whole entry: prior tiers and any
+ * granted capabilities are void (they were earned against a manifest that no
+ * longer exists at this hash).
+ * @param {string} name
+ * @param {string} tier
+ * @param {{hash?: string, evidence?: string}} details
+ * @param {{file?: string}} [options]
+ */
+export function recordTierResult(name, tier, { hash, evidence } = {}, { file = adapterGrantsPath() } = {}) {
+ requireName(name, 'recordTierResult');
+ requireHash(hash, 'recordTierResult');
+ if (!isValidTier(tier)) throw new TypeError(`recordTierResult requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`);
+ if (Object.hasOwn(TIER_GRANTS, tier) && (typeof evidence !== 'string' || evidence.trim().length === 0)) {
+ throw new TypeError(`recordTierResult requires non-empty evidence for grant-bearing tier '${tier}' (gates '${TIER_GRANTS[tier]}')`);
+ }
+ const store = readStore(file);
+ const record = freshRecordAt(store, name, hash);
+ record.tiers[tier] = {
+ status: 'passed',
+ recordedAt: new Date().toISOString(),
+ evidence: typeof evidence === 'string' ? evidence.slice(0, 2048) : '',
+ };
+ store[name] = record;
+ writeStore(file, store);
+}
+
+/** Record that `tier` cannot be met because the capability is upstream
+ * (ADR-0031 §4) — gated, not failed. `gatedBy` pins the tracking issue, e.g.
+ * 'agentic-qe#563' or 'ruvnet/ruflo#2962'. Same hash-replace semantics as
+ * recordTierResult.
+ *
+ * F-2: if `tier` is grant-bearing (a TIER_GRANTS key) and currently backs a
+ * LIVE capability at this same hash, downgrading it to 'gated' must also
+ * drop that capability — evidence and grant must never disagree (a status
+ * display must never show a tier as 'gated' while the capability it used to
+ * back is still listed as granted). A hash change already wipes capabilities
+ * via freshRecordAt, so this only matters for the same-hash downgrade case.
+ * @param {string} name
+ * @param {string} tier
+ * @param {{hash?: string, gatedBy?: string}} details
+ * @param {{file?: string}} [options]
+ */
+export function recordTierGate(name, tier, { hash, gatedBy } = {}, { file = adapterGrantsPath() } = {}) {
+ requireName(name, 'recordTierGate');
+ requireHash(hash, 'recordTierGate');
+ if (!isValidTier(tier)) throw new TypeError(`recordTierGate requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`);
+ if (typeof gatedBy !== 'string' || !GATED_BY_RE.test(gatedBy)) {
+ throw new TypeError(`recordTierGate requires gatedBy in '#' form (e.g. 'agentic-qe#563'), got: ${gatedBy}`);
+ }
+ const store = readStore(file);
+ const record = freshRecordAt(store, name, hash);
+ record.tiers[tier] = { status: 'gated', recordedAt: new Date().toISOString(), gatedBy };
+ const capability = TIER_GRANTS[tier];
+ if (capability && record.capabilities && Object.hasOwn(record.capabilities, capability)) {
+ const capabilities = { ...record.capabilities };
+ delete capabilities[capability];
+ record.capabilities = capabilities;
+ }
+ store[name] = record;
+ writeStore(file, store);
+}
+
+/** 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
+ * plus an explicit grant confers capability, never a grant on its own, and
+ * never evidence recorded against a manifest that has since changed.
+ * @param {string} name
+ * @param {string} capability
+ * @param {{hash?: string}} details
+ * @param {{file?: string}} [options]
+ */
+export function grantCapability(name, capability, { hash } = {}, { file = adapterGrantsPath() } = {}) {
+ requireName(name, 'grantCapability');
+ requireHash(hash, 'grantCapability');
+ const tier = gatingTierForCapability(capability);
+ if (!tier) {
+ throw new TypeError(`grantCapability: '${capability}' is not a grantable capability (must be one of ${Object.values(TIER_GRANTS).join(', ')})`);
+ }
+ const store = readStore(file);
+ const existing = Object.hasOwn(store, name) ? store[name] : null;
+ const tierEntry = existing && existing.hash === hash ? existing.tiers?.[tier] : undefined;
+ if (!tierEntry || tierEntry.status !== 'passed') {
+ throw new Error(`grantCapability: '${name}' has no passed '${tier}' tier recorded at hash ${hash}`);
+ }
+ const record = { ...existing, tiers: { ...existing.tiers }, capabilities: { ...(existing.capabilities ?? {}) } };
+ record.capabilities[capability] = true;
+ record.grantedAt = new Date().toISOString();
+ store[name] = record;
+ writeStore(file, store);
+}
+
+/** Remove the whole record for `name` — every tier result, gate, and granted
+ * capability. Returns whether an entry existed (Object.hasOwn semantics: a
+ * prototype-chain name like 'constructor' never reads as existing). */
+export function revokeGrants(name, { file = adapterGrantsPath() } = {}) {
+ if (typeof name !== 'string' || !name) return false;
+ const store = readStore(file);
+ if (!Object.hasOwn(store, name)) return false;
+ delete store[name];
+ writeStore(file, store);
+ 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
+ * capability decisions must go through grantedCapabilitiesFor().
+ *
+ * `currentHash` is optional and only changes what gets ANNOTATED, never what
+ * gets returned or hidden: omitted, the raw record comes back with no
+ * `stale` field at all (a caller not doing hash-aware reporting shouldn't
+ * have to reason about it). Supplied, the record comes back with `stale:
+ * true`/`false` so a status display can show stale evidence as stale
+ * (rather than silently dropping it) while still being unambiguous about
+ * whether it's current.
+ * @param {string} name
+ * @param {{file?: string, currentHash?: string}} [options]
+ * @returns {any}
+ */
+export function grantsFor(name, { file = adapterGrantsPath(), currentHash } = {}) {
+ if (typeof name !== 'string' || !name) return null;
+ try {
+ const store = readStore(file);
+ const entry = Object.hasOwn(store, name) ? store[name] : null;
+ if (!validRecord(entry)) return null;
+ if (currentHash === undefined) return entry;
+ return { ...entry, stale: entry.hash !== currentHash };
+ } catch {
+ return null;
+ }
+}
+
+/** The granted capabilities for `name`, but ONLY when the record's pinned
+ * hash matches `currentHash` — a manifest edit silently voids every grant
+ * until re-earned, exactly consent's pin model. {} on any mismatch, missing
+ * record, or read failure. Never throws. This is the ONLY reader capability-
+ * wiring code may consume — see the module-header invariant.
+ *
+ * F-1: this re-derives the grant invariant at READ time rather than trusting
+ * record.capabilities verbatim. adapter-grants.json is a flat JSON file an
+ * operator (or a bad merge) can hand-edit directly — grantCapability's
+ * write-time gate does not protect against that. A capability is only ever
+ * returned when it (a) is a real TIER_GRANTS value — never e.g. a forged
+ * 'aqeProvider' key — AND (b) its gating tier is recorded 'passed' at this
+ * exact hash. This raises store forgery from "add one key" to "also forge a
+ * matching passed tier", and makes aqeProvider unreturnable even if present
+ * in the raw file. */
+export function grantedCapabilitiesFor(name, currentHash, { file = adapterGrantsPath() } = {}) {
+ try {
+ const record = grantsFor(name, { file });
+ if (!record || record.hash !== currentHash) return {};
+ const stored = record.capabilities && typeof record.capabilities === 'object' && !Array.isArray(record.capabilities)
+ ? record.capabilities
+ : {};
+ const out = {};
+ for (const [capability, value] of Object.entries(stored)) {
+ if (value !== true) continue;
+ const tier = gatingTierForCapability(capability);
+ if (!tier || record.tiers?.[tier]?.status !== 'passed') continue;
+ out[capability] = true;
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
+/** The tiers currently marked 'gated' for `name` — what it's waiting on
+ * upstream. [] on missing/corrupt/no-gated-tiers. Never throws.
+ * `currentHash` is optional; when supplied and it mismatches the record's
+ * pinned hash, returns [] — a changed manifest voids its gated-tier records
+ * exactly as it voids grants (they describe a manifest that no longer
+ * exists at this hash), so a hash-aware caller must never surface them as
+ * live open requests. Omitted, returns every gated tier regardless of hash
+ * (the same raw-reporting behaviour as grantsFor with no currentHash).
+ * @param {string} name
+ * @param {{file?: string, currentHash?: string}} [options]
+ * @returns {Array<{tier: string, gatedBy: string, recordedAt: string}>}
+ */
+export function gatedTiersFor(name, { file = adapterGrantsPath(), currentHash } = {}) {
+ try {
+ const record = grantsFor(name, { file });
+ if (!record) return [];
+ if (currentHash !== undefined && record.hash !== currentHash) return [];
+ const out = [];
+ for (const [tier, entry] of Object.entries(record.tiers)) {
+ if (entry && entry.status === 'gated') out.push({ tier, gatedBy: entry.gatedBy, recordedAt: entry.recordedAt });
+ }
+ return out;
+ } catch {
+ return [];
+ }
+}
diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs
index 168d6bd..280e1ff 100644
--- a/src/lib/adapters/hook-runner.mjs
+++ b/src/lib/adapters/hook-runner.mjs
@@ -8,6 +8,7 @@
// summary capture, no graceful-then-forced two-step shutdown. A timed-out
// adapter hook gets no cleanup grace period; it already spent its budget.
import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process';
+import { isAbsolute as pathIsAbsolute } from 'node:path';
const DEFAULT_TIMEOUT_MS = 30_000;
const OUTPUT_CAP_BYTES = 256 * 1024;
@@ -88,6 +89,15 @@ function mergeCapture(stdout, stderr) {
return `${kept}${TRUNCATION_MARKER}`;
}
+/** stderr alone, with the same per-stream truncation marker `mergeCapture`
+ * would append — never folded into `stdout`. F-4: a caller that parses
+ * `stdout` as a structured payload (e.g. the admitted execution adapter)
+ * must never see raw stderr promoted into that parse; this is the field it
+ * reads instead when it needs the process's diagnostic chatter. */
+function boundedText({ text, truncated }) {
+ return truncated ? `${text}${TRUNCATION_MARKER}` : text;
+}
+
function describeFailure(hostId, verb, error) {
const reason = error?.code ? `${error.code} (${error.message ?? 'no message'})` : (error?.message ?? String(error));
return `${hostId}:${verb} adapter hook failed to start: ${reason}`;
@@ -104,7 +114,12 @@ function raceTimeout(promise, ms) {
* child — a timed-out adapter hook may have spawned descendants of its own.
* POSIX: the child was spawned detached so its pid is also its process group
* id; signalling `-pid` reaches the whole group. Windows has no portable
- * signal for arbitrary console trees, so `taskkill /T /F` owns it there. */
+ * signal for arbitrary console trees, so `taskkill /T /F` owns it there.
+ * F-2: this cannot PROVE a double-forked or re-`setsid`'d grandchild died —
+ * a signal sent is not a death confirmed. Callers that need that proof (the
+ * admitted execution adapter's cancel path) must treat an unresolved launch
+ * as honestly unproven (`orphaned`), not assume this function's return means
+ * the tree is gone. */
async function killGroup(child) {
if (!Number.isInteger(child?.pid)) return;
if (isWindows) {
@@ -127,31 +142,49 @@ async function killGroup(child) {
* (missing/invalid `hook`, `hostId`, or `verb`) throw synchronously.
*
* @param {{hook:{command:string[], timeoutMs?:number}, hostId:string,
- * verb:string, timeoutMs?:number, env?:Record}} options
- * @returns {Promise<{ok:boolean, stdout:string, exitCode:number|null, detail:string|null}>}
+ * verb:string, timeoutMs?:number, env?:Record, stdin?:string,
+ * cwd?:string}} options
+ * @returns {Promise<{ok:boolean, stdout:string, stdoutText:string, stderrText:string,
+ * exitCode:number|null, detail:string|null}>}
*/
-export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @type {any} */ ({})) {
+export async function runAdapterHook({
+ hook, hostId, verb, timeoutMs, env, stdin, cwd,
+} = /** @type {any} */ ({})) {
if (!hook || !Array.isArray(hook.command) || hook.command.length === 0
|| !hook.command.every((part) => typeof part === 'string' && part.length > 0)) {
throw new TypeError('runAdapterHook requires hook.command as a non-empty array of non-empty strings');
}
if (typeof hostId !== 'string' || !hostId) throw new TypeError('runAdapterHook requires a hostId');
if (typeof verb !== 'string' || !verb) throw new TypeError('runAdapterHook requires a verb');
+ // F-1: a relative cwd would resolve against wherever the ak process
+ // happens to be running, defeating the whole point of pinning the child to
+ // the adapter's own directory — refused synchronously, same class as the
+ // arg-shape checks above, never silently reinterpreted as "inherit".
+ if (cwd !== undefined && (typeof cwd !== 'string' || !cwd || !pathIsAbsolute(cwd))) {
+ throw new TypeError('runAdapterHook requires cwd to be an absolute path when provided');
+ }
const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs);
const [argv0, ...args] = hook.command;
const childEnv = minimalEnv(env);
+ const wantsStdin = typeof stdin === 'string';
let child;
try {
child = nodeSpawn(argv0, args, {
env: childEnv,
shell: false,
- stdio: ['ignore', 'pipe', 'pipe'],
+ stdio: [wantsStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
detached: !isWindows,
+ // Absent cwd falls through to Node's own default (inherit
+ // process.cwd()) — today's behavior for callers that don't pass one
+ // yet (B2 threads the real adapter-base-dir cwd through this wave).
+ ...(cwd === undefined ? {} : { cwd }),
});
} catch (error) {
- return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) };
+ return {
+ ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, error),
+ };
}
const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES);
@@ -159,6 +192,21 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /*
child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk));
child.stderr?.on('data', (chunk) => stderrCollector.write(chunk));
+ if (wantsStdin) {
+ // A child that exits before (or without) reading stdin makes the pipe
+ // write EPIPE — that is a normal outcome (the process's own exit code
+ // already reports what happened), never a reason to crash or reject
+ // runAdapterHook's promise. The 'close' handler below still fires and
+ // resolves the race normally regardless of whether this write lands.
+ child.stdin?.on('error', () => {});
+ try {
+ child.stdin?.end(stdin);
+ } catch {
+ // Synchronous throw from an already-closed stream — same non-fatal
+ // treatment as the async 'error' event above.
+ }
+ }
+
let settled = false;
let spawnError = null;
const closeResult = new Promise((resolve) => {
@@ -175,27 +223,40 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /*
if (raced === TIMEOUT_SENTINEL) {
await killGroup(child);
await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused
+ const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() };
+ const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() };
return {
ok: false,
exitCode: null,
- stdout: mergeCapture(
- { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
- { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
- ),
+ stdout: mergeCapture(stdoutCaptured, stderrCaptured),
+ stdoutText: boundedText(stdoutCaptured),
+ stderrText: boundedText(stderrCaptured),
detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`,
};
}
if (spawnError) {
- return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError) };
+ return {
+ ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError),
+ };
}
const { code } = raced;
- const stdout = mergeCapture(
- { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
- { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
- );
+ // F-4/R-1: stdout stays the combined stream for diagnostics/back-compat,
+ // but a caller parsing stdout as a structured payload (the admitted
+ // execution adapter) must read stdoutText instead — stdout has stderr
+ // folded in after a separator, which breaks JSON.parse the instant the
+ // hook writes anything to stderr at all. stderrText is diagnostics-only.
+ const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() };
+ const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() };
+ const stdout = mergeCapture(stdoutCaptured, stderrCaptured);
+ const stdoutText = boundedText(stdoutCaptured);
+ const stderrText = boundedText(stderrCaptured);
return code === 0
- ? { ok: true, stdout, exitCode: 0, detail: null }
- : { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` };
+ ? {
+ ok: true, stdout, stdoutText, stderrText, exitCode: 0, detail: null,
+ }
+ : {
+ ok: false, stdout, stdoutText, stderrText, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`,
+ };
}
diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs
index 59b6188..ee0d6e0 100644
--- a/src/lib/adapters/lifecycle-registry.mjs
+++ b/src/lib/adapters/lifecycle-registry.mjs
@@ -12,12 +12,20 @@
// — so this is a new, one-way edge (adapters/* -> opencode.mjs) that never
// cycles back (opencode.mjs has no reason to import this module: callers
// reach it through lifecycleAdapterFor/hostsWithLifecycle instead).
+import path from 'node:path';
import { validateLifecycleAdapter, LIFECYCLE_OPERATIONS, lifecycleResult } from './lifecycle.mjs';
import { HOST_REGISTRY } from './registries.mjs';
import { effectiveHostRegistry } from './admitted.mjs';
import { OPENCODE_LIFECYCLE_ADAPTER } from '../opencode.mjs';
const LIFECYCLE_ADAPTERS = new Map();
+// F7 (Wave C security review — prioritized for P4's paired-overlay-reset
+// integration, mirroring execution/admitted.mjs's own resetAllAdmitted
+// pairing): tracks which LIFECYCLE_ADAPTERS keys came from
+// registerAdmittedLifecycle (never a built-in) so resetAdmittedLifecycle()
+// below can clear exactly those without disturbing 'opencode' (or any other
+// built-in registered via registerBuiltinLifecycle).
+const ADMITTED_LIFECYCLE_IDS = new Set();
/**
* Register a built-in host's lifecycle adapter. Internal — called by this
@@ -68,24 +76,74 @@ export function hostsWithLifecycle() {
/**
* Host ids with a registered lifecycle adapter, restricted to BUILT-IN hosts
* (LIFECYCLE_ADAPTERS keys intersected with HOST_REGISTRY — never an
- * admitted external, even after applyAdmitted). setup.mjs/sync.mjs/
- * uninstall.mjs's lifecycle loops destructure an opencode-SHAPED result
- * (stack.oc/.plugin/.agents/.skill, ret.undo/.artifacts) — those loop bodies
- * are not generic across hosts yet. Today that's unreachable dead code,
- * because registerAdmittedLifecycle is never called in production — but it
- * is ARMED: the moment something wires an admitted external's lifecycle
- * adapter in (registerAdmittedLifecycle exists for exactly that), a
- * non-opencode-shaped host would enter hostsWithLifecycle() and crash one of
- * those command loops on the first opencode-specific destructure. Command
- * loops use this function instead until external lifecycle EXECUTION and a
- * shape-agnostic loop body graduate together, in a later wave.
- * hostsWithLifecycle() above stays for pure registry queries, unaffected.
+ * admitted external, even after applyAdmitted). Kept as a pure, built-ins-
+ * only registry query — e.g. for an install-hint lookup that only makes
+ * sense against HOSTS' own package metadata.
+ *
+ * setup.mjs/sync.mjs/uninstall.mjs no longer use this to pick their loop's
+ * iteration source (ADR-0031 P3): their lifecycle loops used to destructure
+ * an opencode-SHAPED result (stack.oc/.plugin/.agents/.skill,
+ * ret.undo/.artifacts) directly, which would have crashed the moment a
+ * non-opencode-shaped admitted host entered the loop. That crash risk is
+ * exactly what lifecycle-render.mjs's shape-dispatching renderer removes —
+ * the command loops now iterate hostsWithLifecycle() (built-ins + admitted)
+ * and render through renderApplyReport/renderUndoReport instead of raw
+ * destructuring, gated per-host by lifecycleExecutionEnabled() below.
* @returns {string[]}
*/
export function builtinHostsWithLifecycle() {
return HOST_REGISTRY.filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id);
}
+/**
+ * True when hostId is one of HOST_REGISTRY's own entries (never an admitted
+ * external, even after applyAdmitted). Used by lifecycleExecutionEnabled and
+ * detectionBinFor to tell a built-in from an admitted host without a command
+ * reaching into registries.mjs directly.
+ * @param {string} hostId
+ * @returns {boolean}
+ */
+export function isBuiltinHost(hostId) {
+ return HOST_REGISTRY.some((host) => host.id === hostId);
+}
+
+const EXPERIMENTAL_HOST_ADAPTERS_FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS';
+
+/**
+ * Whether setup/sync/uninstall should actually exercise hostId's lifecycle
+ * adapter this run (ADR-0031 P3). A BUILT-IN host is gated only by cfg's own
+ * enablement — unchanged from before this wave. An ADMITTED external host
+ * needs BOTH: explicit cfg enablement (opt-in exactly like opencode — there
+ * is no pick-UI for external hosts yet, so enablement is operator-set in
+ * kit.json) AND the experimental flag. Neither condition is ever inferred or
+ * set here — this function only reads, never auto-enables anything.
+ * @param {string} hostId
+ * @param {any} cfg
+ * @param {NodeJS.ProcessEnv} [env]
+ * @returns {boolean}
+ */
+export function lifecycleExecutionEnabled(hostId, cfg, env = process.env) {
+ if (!cfg?.integrations?.hosts?.[hostId]) return false;
+ if (isBuiltinHost(hostId)) return true;
+ return env?.[EXPERIMENTAL_HOST_ADAPTERS_FLAG] === '1';
+}
+
+/**
+ * The binary name a command loop should probe for hostId's CLI presence
+ * before exercising its lifecycle adapter (mirrors the built-in convention
+ * `have(hostId)` — a built-in's host id IS its binary name). An admitted
+ * host's binary name is whatever its manifest declared
+ * (manifest.detection.bin, always present on a validated manifest) —
+ * buildAdmittedLifecycleAdapter stashes it on the registered adapter as
+ * `.detectionBin` so this never needs a second store keyed by host id.
+ * @param {string} hostId
+ * @returns {string}
+ */
+export function detectionBinFor(hostId) {
+ if (isBuiltinHost(hostId)) return hostId;
+ return lifecycleAdapterFor(hostId)?.detectionBin ?? hostId;
+}
+
// ── admitted (external) lifecycle adapters ─────────────────────────────────
// A derived adapter never runs third-party code in-process: each declared
// verb is a thin wrapper that shells out through the sibling's hook runner
@@ -96,18 +154,93 @@ export function builtinHostsWithLifecycle() {
// manifest never declared gets an honest no-op: it never ran, so nothing
// changed.
+// F4 (Wave C security review — the un-applied Wave B R-1 twin): `result.stdout`
+// is the MERGED stdout+stderr (hook-runner.mjs's mergeCapture) — any stderr
+// output (a deprecation warning, interpreter noise) breaks JSON.parse even
+// when the hook fully succeeded, so a genuinely successful apply/undo would
+// misreport as failed. `result.stdoutText` is the UNMERGED stdout only
+// (hook-runner.mjs's boundedText(stdoutCaptured), the same field execution's
+// own R-1 fix reads) — reading it here is the lifecycle-side twin of that
+// fix, never applied to this file until now.
function parseHookPayload(result) {
- if (!result || typeof result.stdout !== 'string') return null;
+ if (!result || typeof result.stdoutText !== 'string') return null;
try {
- const parsed = JSON.parse(result.stdout);
+ const parsed = JSON.parse(result.stdoutText);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
+// Minimal UTF-8-safe truncation (mirrors execution/handoff.mjs's own
+// truncateUtf8 — that copy is canonical; not exported there, so this is a
+// local twin) so a failure detail bounded from raw hook stdout can never
+// promote an unbounded blob into lifecycle-render.mjs's print path (which
+// feeds F3's own control-char stripping, but a huge string is still a
+// separate flooding/DoS-adjacent concern worth bounding at the source).
+function truncateUtf8(value, maxBytes) {
+ const bytes = (v) => Buffer.byteLength(v, 'utf8');
+ if (bytes(value) <= maxBytes) return value;
+ if (maxBytes <= 3) return '.'.repeat(Math.max(0, maxBytes));
+ let out = '';
+ for (const char of value) {
+ if (bytes(`${out}${char}…`) > maxBytes) break;
+ out += char;
+ }
+ return `${out}…`;
+}
+
function hookFailureResult(verb, result) {
- const detail = result?.detail || (result?.stdout || '').trim() || `hook exited ${result?.exitCode ?? 'unknown'}`;
+ const detail = result?.detail
+ || truncateUtf8((result?.stdoutText || '').trim(), 240)
+ || `hook exited ${result?.exitCode ?? 'unknown'}`;
+ if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail };
+ if (verb === 'plan') return { changed: false, operations: [], error: detail };
+ return lifecycleResult({ ok: false, changed: false, errors: [detail] });
+}
+
+// F-1 (mirrors execution/admitted.mjs's own F-1 — that copy is canonical;
+// this is a minimal, lifecycle-scoped replica since the check isn't exported
+// there): a relative lifecycle hook command resolves against whatever `cwd`
+// the child spawns with. With no anchored adapter base directory (a remote
+// npm/https source has no persistent local bundle), that would fall back to
+// the OPERATOR's process.cwd() — arbitrary-code-execution by planting a
+// same-named file, with the consent hash unchanged. A bare interpreter/
+// binary name found through PATH (node, hermes) is unaffected by cwd and
+// stays legal; only a path-separator-bearing or script-looking bare token is
+// refused. Not shared as an import (yet) — a later refactor can hoist this
+// into a common module once a second caller needs it; for now the small
+// duplication keeps this file independent of execution/admitted.mjs.
+// F9 (Windows parity): exe/bat/cmd/com/ps1 included alongside the script
+// extensions — Windows' CreateProcess searches the CURRENT DIRECTORY before
+// PATH for a bare relative executable name, so a null baseDir with e.g.
+// `hook.bat` is exactly as exploitable there as a relative `.mjs` is on
+// POSIX and must be refused the same way.
+const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i;
+
+function looksRelative(token) {
+ if (typeof token !== 'string' || !token) return false;
+ if (path.isAbsolute(token)) return false;
+ if (token.includes('/') || token.includes('\\')) return true;
+ return SCRIPT_LIKE_RE.test(token);
+}
+
+function commandIsUnanchorable(command) {
+ const [argv0, ...args] = command;
+ if (looksRelative(argv0)) return true;
+ return args.some((arg) => looksRelative(arg));
+}
+
+/** Honest refusal for a declared verb whose hook command is relative with no
+ * adapter base directory to anchor it — the hook subprocess is NEVER
+ * spawned for this verb (see commandIsUnanchorable above), so this can only
+ * ever be a refusal, never a fabricated success. Shaped exactly like
+ * hookFailureResult so callers (lifecycle-render.mjs's generic summary,
+ * runLifecycle) treat it identically to any other honest per-verb failure. */
+function unanchoredResult(verb, hostId, command) {
+ const detail = `'${hostId}' declares a relative lifecycle.${verb}.hook.command `
+ + `(${JSON.stringify(command)}) with no anchored adapter base directory (a remote npm/https `
+ + 'source has no persistent local bundle) — use an absolute path or a PATH binary';
if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail };
if (verb === 'plan') return { changed: false, operations: [], error: detail };
return lifecycleResult({ ok: false, changed: false, errors: [detail] });
@@ -121,24 +254,48 @@ function hookFailureResult(verb, result) {
* defaults to a dynamic import of ./hook-runner.mjs (the sibling module),
* fetched lazily so this factory (and this whole file) loads cleanly even
* before that module exists, and so tests never pay for the import unless
- * they omit the injection on purpose.
+ * they omit the injection on purpose. `baseDir` (F-1) is the adapter's own
+ * directory — derived by the caller (admission.mjs) from the manifest's
+ * `source` at registration time, `null` for a source with no persistent
+ * local bundle (npm/https) — never process.cwd(). A verb whose hook.command
+ * is relative and has no baseDir to anchor it is NEVER wired to spawn —
+ * `adapter.unanchoredVerbs` names every verb refused this way, so a caller
+ * (registerAdmittedLifecycle's bootstrap caller) can surface it as a warning
+ * without needing to re-derive the check itself.
* @param {any} manifest — validateAdapterManifest's return shape
- * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}> }} [opts]
+ * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}>,
+ * baseDir?: string|null }} [opts]
*/
-export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) {
+export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = null } = {}) {
const hostId = manifest.host.id;
const declared = manifest.lifecycle ?? {};
- const adapter = { id: hostId };
+ // Stashed alongside the verb functions (validateLifecycleAdapter only
+ // requires id + the five verbs — extra own-properties are untouched) so
+ // detectionBinFor(hostId) can find the manifest's own CLI binary name
+ // without a second store keyed by host id.
+ const adapter = { id: hostId, detectionBin: manifest.detection?.bin ?? hostId, unanchoredVerbs: [] };
for (const verb of LIFECYCLE_OPERATIONS) {
const hookEntry = declared[verb]?.hook;
if (!hookEntry) {
adapter[verb] = async () => lifecycleResult({ ok: true, changed: false, facts: null });
continue;
}
+ if (baseDir == null && commandIsUnanchorable(hookEntry.command)) {
+ adapter.unanchoredVerbs.push(verb);
+ adapter[verb] = async () => unanchoredResult(verb, hostId, hookEntry.command);
+ continue;
+ }
adapter[verb] = async (context = {}) => {
const run = runHook ?? (await import('./hook-runner.mjs')).runAdapterHook;
const result = await run({
hook: hookEntry, hostId, verb, timeoutMs: hookEntry.timeoutMs, env: context.env,
+ // F-1: anchor a relative command to the adapter's own directory when
+ // one was declared; with no baseDir, the check above already proved
+ // this command has no relative component a cwd could redirect (bare
+ // PATH binaries only), so omitting cwd (Node's own default — inherit
+ // ak's process.cwd()) is safe and never reopens the arbitrary-code-
+ // execution vector this exists to close.
+ ...(baseDir == null ? {} : { cwd: baseDir }),
});
if (!result?.ok) return hookFailureResult(verb, result);
const payload = parseHookPayload(result);
@@ -156,24 +313,43 @@ export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) {
* an admitted-but-not-yet-overlaid host id would otherwise never pass the
* built-ins-only check.
*
- * Not called anywhere in production yet: registering an external host here
- * makes it appear in hostsWithLifecycle() (registry-query, all hosts), but
- * setup.mjs/sync.mjs/uninstall.mjs deliberately read builtinHostsWithLifecycle()
- * instead, which stays built-ins-only. External lifecycle EXECUTION and a
- * shape-agnostic command-loop body (today's loops assume the opencode result
- * shape) graduate together, in a later wave — wiring a real caller for this
- * function ahead of that loop-body rewrite would crash setup/sync/uninstall
- * the moment a non-opencode-shaped host is admitted.
+ * Called from admission.mjs's bootstrapHostAdapters (ADR-0031 P3), as a
+ * sibling to the execution-registration block: any admitted manifest
+ * declaring a lifecycle block gets its adapter registered here so it appears
+ * in hostsWithLifecycle(). Registration alone never runs a hook — a
+ * registered admitted host is only actually EXERCISED by setup/sync/
+ * uninstall's loops once lifecycleExecutionEnabled() also passes (the
+ * experimental flag AND explicit cfg enablement) for that run. `baseDir`
+ * (F-1) is threaded straight through to buildAdmittedLifecycleAdapter — the
+ * caller derives it from the manifest's own source the same way the
+ * execution-registration block does (baseDirForSource).
* @param {any} manifest
- * @param {{ runHook?: (args: any) => Promise }} [opts]
+ * @param {{ runHook?: (args: any) => Promise, baseDir?: string|null }} [opts]
*/
-export function registerAdmittedLifecycle(manifest, { runHook } = {}) {
+export function registerAdmittedLifecycle(manifest, { runHook, baseDir = null } = {}) {
const hostId = manifest.host.id;
if (!effectiveHostRegistry().some((host) => host.id === hostId)) {
throw new TypeError(`lifecycle registry: unknown host id '${hostId}' — not present in effectiveHostRegistry`);
}
- const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook });
+ const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir });
validateLifecycleAdapter(adapter);
LIFECYCLE_ADAPTERS.set(hostId, adapter);
+ ADMITTED_LIFECYCLE_IDS.add(hostId);
return adapter;
}
+
+/**
+ * Clear every ADMITTED lifecycle registration (never a built-in — 'opencode'
+ * and any other registerBuiltinLifecycle entry survive untouched). Pairs
+ * with adapters/admitted.mjs's resetAdmitted() and execution/admitted.mjs's
+ * resetAdmittedExecution() the same way execution/admitted.mjs's own
+ * resetAllAdmitted() pairs those two: a test (or a future paired-reset
+ * helper) that resets the host overlay without also resetting this one would
+ * otherwise leave a stale, unreachable-but-still-registered lifecycle
+ * adapter behind for a host id that effectiveHostRegistry() no longer knows
+ * about.
+ */
+export function resetAdmittedLifecycle() {
+ for (const hostId of ADMITTED_LIFECYCLE_IDS) LIFECYCLE_ADAPTERS.delete(hostId);
+ ADMITTED_LIFECYCLE_IDS.clear();
+}
diff --git a/src/lib/adapters/lifecycle-render.mjs b/src/lib/adapters/lifecycle-render.mjs
new file mode 100644
index 0000000..e976f20
--- /dev/null
+++ b/src/lib/adapters/lifecycle-render.mjs
@@ -0,0 +1,200 @@
+// Shape-agnostic lifecycle report renderer (ADR-0031 P3). setup.mjs, sync.mjs
+// and uninstall.mjs used to destructure a runLifecycle() result directly
+// (stack.oc/.plugin/.agents/.skill for apply, ret.undo/.artifacts for undo) —
+// a shape only OPENCODE_LIFECYCLE_ADAPTER produces. That worked only because
+// the command loops iterated builtinHostsWithLifecycle() (opencode-only).
+// Now that the loops iterate hostsWithLifecycle() (built-ins + admitted
+// externals — see lifecycle-registry.mjs), an admitted host's adapter
+// (buildAdmittedLifecycleAdapter) returns the GENERIC lifecycleResult shape
+// instead ({ok, changed, facts, actions, ownership, warnings, errors} — see
+// lifecycle.mjs) and the raw destructure would throw on `stack.oc`. This
+// module is the single place that tells the two shapes apart and turns
+// either one into print-ready lines, so no command needs to know which shape
+// it got.
+//
+// opencode's rich shape renders the lines the three commands already printed
+// before this wave (same text, same conditions) — pinned by the existing
+// setup/sync/uninstall test suites — plus a level fix (Wave C security
+// review F5): plugin/agents/skill now carry their OWN ok/status into the
+// line's level instead of a hard-coded 'ok', so a failed sub-surface renders
+// as failed rather than a fabricated green checkmark. A generic admitted
+// host renders one honest summary line instead: ok/changed plus a short
+// detail pulled from actions/errors/warnings, never a per-surface breakdown
+// the manifest never promised.
+
+/** True when `lifecycle` is opencode's apply() shape: `{changed, result:
+ * {oc, plugin, agents, skill, markersChanged}}`. Any other shape (including
+ * a bare generic lifecycleResult, which has no `.result` at all) is generic. */
+function isOpencodeApplyShape(lifecycle) {
+ return !!(lifecycle && lifecycle.result && lifecycle.result.oc);
+}
+
+/** True when `lifecycle` is opencode's undo() shape: `{changed, result:
+ * {undo, artifacts, ok}}`. */
+function isOpencodeUndoShape(lifecycle) {
+ return !!(lifecycle && lifecycle.result && lifecycle.result.undo);
+}
+
+// F3 (Wave C security review, BLOCKER — ANSI/control-char smuggling): a
+// hostile hook's stdout (a manifest's own detect/apply/undo hook, or a
+// user-editable error string that eventually lands in one of these lines)
+// can carry raw ANSI control sequences — e.g. ESC[2K (erase line) + ESC[1A
+// (cursor up) followed by a forged "✓ opencode: … in sync" — which a
+// terminal would happily execute, erasing the real (failing) line and
+// forging a fake green one. Every line this module builds funnels through
+// line() below, so stripping there is the one choke point that closes it
+// for every caller, opencode-shaped or generic alike. Reused shape (not
+// imported — command/lib boundary): src/commands/x/host-adapters.mjs's own
+// ~8-line stripControl is the canonical copy; this is the lib-side twin.
+function stripControl(value) {
+ const input = String(value ?? '');
+ let out = '';
+ for (const ch of input) {
+ const code = ch.codePointAt(0);
+ // Tab/LF/CR become a space rather than vanishing — this is also what
+ // clamps every line to a SINGLE line (no embedded newline can smuggle a
+ // second, attacker-controlled "line" into the terminal).
+ if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; }
+ // C0 (0x00-0x1f, includes ESC 0x1b) and C1 (0x7f-0x9f, includes DEL
+ // 0x7f) are dropped outright — an ESC-led CSI sequence loses its ESC
+ // byte and the rest (e.g. "[2K") survives only as inert, visible text.
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue;
+ out += ch;
+ }
+ return out;
+}
+
+/** Typed constructor for one report line — keeps `level` a literal union
+ * instead of widening to `string` the moment it comes from a ternary, AND
+ * is the single choke point every line's text passes through (F3, above).
+ * @param {'ok'|'warn'|'info'|'fail'} level
+ * @param {string} text
+ * @returns {{level:'ok'|'warn'|'info'|'fail', text:string}} */
+function line(level, text) {
+ return { level, text: stripControl(text) };
+}
+
+// F5 (Wave C security review, BLOCKER — built-in sync regression): mirrors
+// output.mjs's reportOutcome exactly (result.status ?? (ok?'ok':'failed'),
+// then ok/degraded/skipped/anything-else -> ok/warn/info/fail) so a
+// FAILED opencode sub-surface (plugin/agents/skill/oc itself) renders at its
+// own real level instead of a level this renderer fabricates. Pre-wave sync
+// read every opencode sub-result through reportOutcome directly; this is
+// that same mapping, now shared by setup AND sync from the one renderer.
+function levelForResult(result) {
+ const status = result?.status ?? (result?.ok ? 'ok' : 'failed');
+ if (status === 'ok') return 'ok';
+ if (status === 'degraded') return 'warn';
+ if (status === 'skipped') return 'info';
+ return 'fail';
+}
+
+/** A short, honest one-line detail for a generic lifecycleResult: the first
+ * error, else the first warning, else an action count, else "no changes" —
+ * never fabricated, never a per-surface guess. */
+function summarizeGeneric(result) {
+ if (Array.isArray(result?.errors) && result.errors.length) return result.errors[0];
+ if (Array.isArray(result?.warnings) && result.warnings.length) return result.warnings[0];
+ if (Array.isArray(result?.actions) && result.actions.length) return `${result.actions.length} action(s)`;
+ return 'no changes';
+}
+
+/** @returns {{shape:'opencode', fatal:boolean, ok:boolean, changed:boolean,
+ * ocChanged:boolean, markersChanged:boolean,
+ * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */
+function renderOpencodeApply(hostId, lifecycle) {
+ const stack = lifecycle.result;
+ const lines = [line(levelForResult(stack.oc), `${hostId}: ${stack.oc.detail}`)];
+ if (stack.oc.fatal) {
+ lines.push(line('warn', `${hostId} plugin/agents/skill/guidance skipped — ${stack.oc.detail}`));
+ return {
+ shape: 'opencode', fatal: true, ok: !!stack.oc.ok, changed: !!lifecycle.changed,
+ ocChanged: !!stack.oc.changed, markersChanged: !!stack.markersChanged, lines,
+ };
+ }
+ lines.push(line(levelForResult(stack.plugin), `${hostId} plugin: ${stack.plugin.detail}`));
+ lines.push(line(levelForResult(stack.agents), `${hostId} agents: ${stack.agents.detail}`));
+ // F5: restored the `|| !skill.ok` half of the gate — a failed skill write
+ // (ok:false, changed:false, e.g. opencode.mjs's adoptionBlocked path) must
+ // still be reported, not silently dropped because nothing "changed".
+ if (stack.skill.changed || !stack.skill.ok) {
+ lines.push(line(levelForResult(stack.skill), `${hostId} skill: ${stack.skill.detail}`));
+ }
+ return {
+ shape: 'opencode', fatal: false, ok: !!stack.oc.ok, changed: !!lifecycle.changed,
+ ocChanged: !!stack.oc.changed, markersChanged: !!stack.markersChanged, lines,
+ };
+}
+
+/** @returns {{shape:'generic', fatal:boolean, ok:boolean, changed:boolean,
+ * ocChanged:boolean, markersChanged:boolean,
+ * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */
+function renderGenericApply(hostId, result) {
+ const ok = !!result?.ok;
+ const changed = !!result?.changed;
+ const verdict = ok ? (changed ? 'applied' : 'in sync') : 'apply failed';
+ const lines = [line(ok ? 'ok' : 'warn', `${hostId}: ${verdict} — ${summarizeGeneric(result)}`)];
+ return {
+ shape: 'generic', fatal: false, ok, changed, ocChanged: false, markersChanged: false, lines,
+ };
+}
+
+/**
+ * Turn a runLifecycle({action:'apply', ...}) result into print-ready lines.
+ * Dispatches on shape (see module doc) — the caller never inspects the
+ * result's own fields, only this report's normalized ones.
+ * @param {string} hostId
+ * @param {any} lifecycle — runLifecycle's return value
+ * @returns {{shape:'opencode'|'generic', fatal:boolean, ok:boolean,
+ * changed:boolean, ocChanged:boolean, markersChanged:boolean,
+ * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}}
+ */
+export function renderApplyReport(hostId, lifecycle) {
+ return isOpencodeApplyShape(lifecycle)
+ ? renderOpencodeApply(hostId, lifecycle)
+ : renderGenericApply(hostId, lifecycle);
+}
+
+/** @returns {{shape:'opencode', ok:boolean, changed:boolean,
+ * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */
+function renderOpencodeUndo(hostId, lifecycle) {
+ const ret = lifecycle.result;
+ const ok = !!ret.ok;
+ const changed = !!(ret.undo.changed || ret.artifacts.changed);
+ const lines = (changed || !ok) ? [line(
+ ok ? 'ok' : 'warn',
+ ok
+ ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`
+ : `${hostId} teardown incomplete — ${ret.undo.detail}`,
+ )] : [];
+ return { shape: 'opencode', ok, changed, lines };
+}
+
+/** @returns {{shape:'generic', ok:boolean, changed:boolean,
+ * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */
+function renderGenericUndo(hostId, result) {
+ const ok = !!result?.ok;
+ const changed = !!result?.changed;
+ const lines = (changed || !ok) ? [line(
+ ok ? 'ok' : 'warn',
+ ok
+ ? `${hostId}: undo complete — ${summarizeGeneric(result)}`
+ : `${hostId} teardown incomplete — ${summarizeGeneric(result)}`,
+ )] : [];
+ return { shape: 'generic', ok, changed, lines };
+}
+
+/**
+ * Turn a runLifecycle({action:'undo', ...}) result into print-ready lines.
+ * Same dispatch as renderApplyReport. `ok` on the return is the caller's
+ * ownership-teardown signal (uninstall.mjs ANDs it across every host).
+ * @param {string} hostId
+ * @param {any} lifecycle — runLifecycle's return value
+ * @returns {{shape:'opencode'|'generic', ok:boolean, changed:boolean,
+ * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}}
+ */
+export function renderUndoReport(hostId, lifecycle) {
+ return isOpencodeUndoShape(lifecycle)
+ ? renderOpencodeUndo(hostId, lifecycle)
+ : renderGenericUndo(hostId, lifecycle);
+}
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/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs
index 801f89f..a22c22b 100644
--- a/src/lib/adapters/manifest.mjs
+++ b/src/lib/adapters/manifest.mjs
@@ -52,7 +52,7 @@ export class ManifestRejected extends TypeError {
// validateHostAdapter, src/lib/hosts.mjs, src/lib/providers.mjs) consume —
// widen them only alongside a new legitimate consumer, never speculatively.
const MANIFEST_ALLOWED_KEYS = Object.freeze([
- 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust',
+ 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', 'execution',
]);
// Everything validateHostAdapter itself reads (id, label, install,
// capabilities, trust, enabledByDefault, configProjection, observability)
@@ -172,6 +172,30 @@ function validateManifestLifecycle(value) {
return structuredClone(value);
}
+// execution.run.hook is the single subprocess `ak run` spawns to drive an
+// admitted host as a worker (P2, ADR-0031). Same hook shape and validation
+// discipline as a lifecycle verb's hook, but there is exactly one verb
+// ('run'), never a caller-named one, so the allowlists are inlined rather
+// than looped like validateManifestLifecycle's verb map.
+function validateExecution(value) {
+ assertRecord(value, 'execution');
+ assertNoUnknownKeys(value, ['run'], 'execution');
+ assertRecord(value.run, 'execution.run');
+ assertNoUnknownKeys(value.run, ['hook'], 'execution.run');
+ assertRecord(value.run.hook, 'execution.run.hook');
+ assertNoUnknownKeys(value.run.hook, ['command', 'timeoutMs'], 'execution.run.hook');
+ try {
+ assertStringArray(value.run.hook.command, 'execution.run.hook.command', { allowEmpty: false });
+ } catch (error) {
+ throw new ManifestRejected('invalid-execution', error.message);
+ }
+ if (value.run.hook.timeoutMs !== undefined
+ && (!Number.isInteger(value.run.hook.timeoutMs) || value.run.hook.timeoutMs <= 0)) {
+ throw new ManifestRejected('invalid-execution', 'execution.run.hook.timeoutMs must be a positive integer');
+ }
+ return structuredClone(value);
+}
+
function validateManifestTrust(value) {
assertRecord(value, 'trust');
assertNoUnknownKeys(value, ['changes'], 'trust');
@@ -283,11 +307,19 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob
throw new ManifestRejected('invalid-guidance-file', error.message);
}
}
+ // P2 structural coupling (ADR-0031): an execution hook on a host that
+ // cannot route activities is a contradiction the schema refuses outright,
+ // never silently ignores. The converse — routable, no execution block — is
+ // legal and degrades honestly at run time (cli_unavailable).
+ if (value.execution !== undefined && host.capabilities.canRouteActivities !== true) {
+ throw new ManifestRejected('execution-not-routable', 'manifest.execution requires host.capabilities.canRouteActivities: true');
+ }
const detection = validateDetection(value.detection);
const driving = validateDriving(value.driving);
const lifecycle = value.lifecycle === undefined ? undefined : validateManifestLifecycle(value.lifecycle);
const trust = validateManifestTrust(value.trust);
+ const execution = value.execution === undefined ? undefined : validateExecution(value.execution);
return immutable({
name: value.name,
@@ -297,6 +329,11 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob
detection,
driving,
...(lifecycle === undefined ? {} : { lifecycle }),
+ // execution rides in the same validated-output object hashManifest
+ // (admission.mjs) canonicalizes and hashes, so declaring/editing an
+ // execution block changes consent's covered hash automatically — no
+ // separate hashing path to keep in sync.
+ ...(execution === undefined ? {} : { execution }),
trust,
});
}
diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs
index 29fb535..6a2e67f 100644
--- a/src/lib/adapters/registries.mjs
+++ b/src/lib/adapters/registries.mjs
@@ -371,6 +371,20 @@ export function providersWithCapability(entriesOrCapability, maybeCapability) {
return entries.filter((provider) => provider.capabilities?.[capability] === true);
}
+// Built-in-only BY DESIGN, not by oversight (F-4, D2 keystone security
+// review): this defaults `hosts` to HOST_REGISTRY, so a caller invoking it
+// with no second argument will refuse an admitted-and-granted external host
+// even though it has earned canBePrimary. registries.mjs has no knowledge of
+// the admitted-overlay/grant machinery (admitted.mjs imports FROM this file,
+// not the reverse — see admitted.mjs's effectivePrimaryHostIds() comment),
+// so it cannot default to the grant-aware set itself. There is no production
+// caller of this function today (fail-closed by omission, not a leak) — but
+// if one is ever wired up for an admitted host's eligibility, it MUST pass
+// `hosts: effectiveHostRegistry()` explicitly (mirroring how
+// materializeRunPlan calls the routing sibling, validateActivityHost, in
+// routing.mjs), or use admitted.mjs's effectivePrimaryHostIds() ids-only
+// check instead. Do NOT wire this function into a granted-host path using
+// its bare default.
export function validatePrimaryHost(id, hosts = HOST_REGISTRY) {
const host = hosts.find((entry) => entry.id === id);
if (!host) return { ok: false, reason: 'unknown-host' };
diff --git a/src/lib/adapters/sources.mjs b/src/lib/adapters/sources.mjs
new file mode 100644
index 0000000..ed98fff
--- /dev/null
+++ b/src/lib/adapters/sources.mjs
@@ -0,0 +1,420 @@
+// Remote manifest sources (ADR-0031, work item P6): resolves an adapter
+// manifest's `source` (kit.json's hostAdapters[].source) into raw parsed
+// JSON bytes. This module is a pure fetch/parse layer — validation, hashing,
+// and consent stay admission.mjs's job.
+//
+// Security-review-mandated ordering: this resolver runs BEFORE hashing.
+// admission.mjs's admitOne calls readManifest() (which delegates here) and
+// only afterward validates + hashes the result. That means consent pins the
+// RESOLVED content, not a name: a mutable remote (an npm dist-tag moving, a
+// URL's content changing) produces different bytes on the next admission
+// pass, which hashes differently and is refused as 'consent-stale' rather
+// than being silently re-trusted. Nothing in this file may cache or memoize
+// across calls — every call re-resolves from scratch, which is what makes
+// that invalidation automatic instead of something callers must remember to
+// force.
+//
+// Three source forms, dispatched on prefix:
+// - a file path (no recognized prefix) — no network, ever (offline-first
+// is a repo invariant); an `lstat` gate refuses anything that is not a
+// plain regular file (symlinks, directories, FIFOs, devices) and
+// enforces `maxBytes` before the content is ever read, then JSON.parse.
+// - https://... — HTTPS only, no redirects followed, bounded time and
+// bytes (a Content-Length pre-check, a streamed hard cap, and a single
+// timeout budget that stays armed across BOTH the initial request and
+// the full body read — a response that sends headers and then never
+// delivers the rest of the body must not hang forever).
+// - npm:[@version|tag] — `npm pack` (never executing the package's
+// own scripts) into a throwaway temp dir, then `tar` extracts exactly
+// `package/ak-adapter.json` STRAIGHT TO STDOUT — never to disk. A
+// tarball member can be a symlink (arbitrary local file read via a
+// followed link), a FIFO (extraction hang), or a device node; -O
+// sidesteps that whole class by never writing extracted content to the
+// filesystem at all, and the subprocess's own `maxBuffer` — bounded
+// tight to `maxBytes` for this one call, not the generic subprocess cap
+// used elsewhere — rejects an oversized member before it fully lands in
+// process memory, closing the decompression-bomb angle a disk-based
+// extraction would otherwise have to police after the fact.
+//
+// Why npm tarball extraction shells npm+tar rather than adding a `tar`
+// dependency: this package is zero-runtime-dependency by ADR-0016, npm is
+// already shelled elsewhere in this codebase (providers.mjs's installHost,
+// heal.mjs's upgradePackage/selfUpdate), and `npm pack` of a remote spec
+// downloads the package without ever running ITS scripts (belt:
+// --ignore-scripts, on top of the fact that `npm pack` only runs
+// prepare/prepack/postpack in the first place — this flag closes that off
+// too). Reaching for a tar library would trade a well-audited system binary
+// for a new supply-chain dependency to buy nothing extraction-to-stdout
+// couldn't already do more simply.
+import { execFile as nodeExecFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import fs from 'node:fs/promises';
+import fsSync from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { resolveShim } from '../exec.mjs';
+
+const DEFAULT_TIMEOUT_MS = 10_000;
+const DEFAULT_MAX_BYTES = 262_144;
+// Subprocess stdout/stderr capture cap for `npm pack` itself — its own
+// textual/progress output, never the manifest content (which goes through a
+// separately, tightly bounded maxBuffer — see resolveNpmSource).
+const SUBPROCESS_MAX_BUFFER = 16 * 1024 * 1024;
+// SourceError detail text is bounded to this length, matching
+// src/lib/execution/runner.mjs's boundedFailure precedent.
+const DETAIL_MAX_LENGTH = 240;
+
+const defaultExecFile = promisify(nodeExecFile);
+
+export class SourceError extends Error {
+ constructor(reason, detail) {
+ super(detail ? `${reason}: ${detail}` : reason);
+ this.name = 'SourceError';
+ this.reason = reason;
+ }
+}
+
+/** Strips C0 control characters (0x00–0x1F) and the full C1 range
+ * (0x7F–0x9F inclusive — DEL plus every C1 control, e.g. U+009B CSI) from
+ * external text before it can enter a SourceError detail — subprocess
+ * stderr, thrown-error messages, and JSON-parse error snippets are all
+ * attacker-influenced text (npm/tar output, a remote server's response,
+ * error text derived from a hostile manifest) that later gets printed raw
+ * by consumers (the bin warning path, the trust CLI). '\n'/'\t'/'\r'
+ * collapse to a single space rather than vanish, so a multi-line message
+ * stays readable as one line; every other C0/C1 byte is dropped outright —
+ * this defuses ANSI escape sequences (ESC-prefixed AND the single-byte C1
+ * form, e.g. U+009B in place of ESC+'[') and terminal-control tricks, not
+ * just newlines. Bounded to DETAIL_MAX_LENGTH chars afterward. */
+function sanitizeDetail(text) {
+ const input = String(text ?? '');
+ let out = '';
+ for (const ch of input) {
+ const code = ch.codePointAt(0);
+ if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; }
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue;
+ out += ch;
+ }
+ return out.trim().slice(0, DETAIL_MAX_LENGTH);
+}
+
+/** Single choke point for constructing a SourceError: every detail string,
+ * however it was built, passes through sanitizeDetail here — callers never
+ * need to remember to sanitize at each throw site individually. */
+function sourceError(reason, detail) {
+ return new SourceError(reason, sanitizeDetail(detail));
+}
+
+// Every character an npm package spec (name[@version-or-tag]) may legally
+// contain, checked against the WHOLE spec string before any parsing —
+// command-injection surface. A spec containing ';', '$', '`', whitespace,
+// or any other shell/argv-hostile character is rejected here, before it is
+// ever assembled into an argv array (even though execFile+shell:false
+// already blocks shell interpretation — this is belt-and-suspenders, the
+// same posture as assertId elsewhere in this codebase).
+const NPM_SPEC_CHARS_RE = /^[A-Za-z0-9@/._-]+$/;
+// A conservative structural shape for the package-name portion only
+// (scope optional): must start with an alphanumeric, never '.' or '_'.
+const NPM_NAME_RE = /^(?:@[A-Za-z0-9][\w.-]*\/)?[A-Za-z0-9][\w.-]*$/;
+// Exact semver only (no ranges — a range is meaningless for "pin one
+// tarball"), mirroring manifest.mjs's SEMVER_RE.
+const NPM_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
+// A dist-tag ('latest', 'next', 'beta', ...): no '/' — that's the exact
+// shape npm's own git-shorthand ("user/repo") and local-path ("../x")
+// resolvers key off, so excluding '/' here is what keeps `npm:pkg@version`
+// from silently reinterpreting the version half as a different resolver
+// entirely (finding 7).
+const NPM_DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9_.-]*$/;
+
+/** Splits "name[@version-or-tag]" into { name, version }. Scoped names
+ * (`@scope/pkg`) have a leading '@' that is not a version separator, so the
+ * search for the separating '@' starts after the scope's '/'. */
+function parseNpmSpec(spec) {
+ const scoped = spec.startsWith('@');
+ const rest = scoped ? spec.slice(1) : spec;
+ const at = rest.indexOf('@');
+ if (at === -1) return { name: spec, version: undefined };
+ const name = scoped ? `@${rest.slice(0, at)}` : rest.slice(0, at);
+ const version = rest.slice(at + 1);
+ return { name, version };
+}
+
+function isMaxBufferError(error) {
+ return error?.code === 'ERR_CHILD_PROCESS_STDOUT_MAXBUFFER' || /maxBuffer/i.test(error?.message ?? '');
+}
+
+async function resolveFileSource(source, maxBytes) {
+ // lstat, not stat: a symlink must be refused as itself (isFile() false on
+ // the link), never silently followed to whatever it points at — the same
+ // "no arbitrary local read via a followed link" posture the npm path's
+ // stdout-only extraction enforces (finding 6 mirrors finding 2).
+ let stat;
+ try {
+ stat = await fs.lstat(source);
+ } catch (error) {
+ throw sourceError('source-unreachable', error?.message ?? String(error));
+ }
+ if (!stat.isFile()) {
+ throw sourceError('source-invalid', `'${source}' is not a regular file — symlinks, directories, and special files are refused`);
+ }
+ if (stat.size > maxBytes) {
+ throw sourceError('source-too-large', `'${source}' is ${stat.size} bytes, exceeding the ${maxBytes}-byte cap`);
+ }
+ let text;
+ try {
+ text = await fs.readFile(source, 'utf8');
+ } catch (error) {
+ throw sourceError('source-unreachable', error?.message ?? String(error));
+ }
+ try {
+ /** @type {'file'} */
+ const origin = 'file';
+ return { raw: JSON.parse(text), origin };
+ } catch (error) {
+ throw sourceError('source-invalid-json', error?.message ?? String(error));
+ }
+}
+
+/** Streamed read with a hard byte cap, mirroring the bounded-read pattern in
+ * src/lib/execution/opencode.mjs's responseJson: cancel the reader the
+ * moment the cap is crossed, on top of an early Content-Length rejection.
+ * Abort-aware: `signal` is the SAME AbortSignal that bounds the whole
+ * resolve (see resolveHttpsSource) — every reader.read() races against it,
+ * so a response that sends headers and then never delivers the rest of the
+ * body still gets cut off at `timeoutMs`, not left to hang forever. */
+async function readBoundedBody(response, maxBytes, label,
+ { signal, timeoutMs } = /** @type {{signal?: AbortSignal, timeoutMs?: number}} */ ({})) {
+ const declared = Number(response.headers?.get?.('content-length'));
+ if (Number.isFinite(declared) && declared > maxBytes) {
+ throw sourceError('source-too-large', `${label} declared Content-Length ${declared} exceeds ${maxBytes} bytes`);
+ }
+ if (!response.body?.getReader) {
+ throw sourceError('source-unreachable', `${label} did not expose a bounded response stream`);
+ }
+ const reader = response.body.getReader();
+ const chunks = [];
+ let total = 0;
+ const abortedPromise = signal
+ ? new Promise((_, reject) => {
+ const onAbort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+ if (signal.aborted) { onAbort(); return; }
+ signal.addEventListener('abort', onAbort, { once: true });
+ })
+ : null;
+ try {
+ for (;;) {
+ const step = abortedPromise ? Promise.race([reader.read(), abortedPromise]) : reader.read();
+ const { done, value } = await step;
+ if (done) break;
+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
+ total += chunk.byteLength;
+ if (total > maxBytes) {
+ await reader.cancel().catch(() => {});
+ throw sourceError('source-too-large', `${label} response exceeded ${maxBytes} bytes`);
+ }
+ chunks.push(chunk);
+ }
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ await reader.cancel().catch(() => {});
+ throw sourceError('source-unreachable', `${label} timed out after ${timeoutMs}ms`);
+ }
+ throw error;
+ } finally {
+ reader.releaseLock?.();
+ }
+ const combined = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
+ return new TextDecoder().decode(combined);
+}
+
+async function resolveHttpsSource(url, { fetchFn, timeoutMs, maxBytes }) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ // The timer/controller stay alive for the ENTIRE resolve — fetch AND the
+ // full body read — cleared only in this outer finally. Clearing it right
+ // after the header fetch (the prior shape) let a 200 response with a
+ // body that never finishes arriving hang resolveManifestSource forever;
+ // since bootstrapHostAdapters runs on every `ak` command with the
+ // experimental flag on, that hang bricked the whole CLI (finding 1).
+ try {
+ let response;
+ try {
+ // redirect: 'manual' — a redirect is REFUSED outright, never
+ // followed. Fail-closed and explicit beats chasing a redirect chain
+ // to who-knows-where (ADR-0023 posture): pin the final URL instead.
+ response = await fetchFn(url, { redirect: 'manual', signal: controller.signal });
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ throw sourceError('source-unreachable', `${url} timed out after ${timeoutMs}ms`);
+ }
+ throw sourceError('source-unreachable', error?.message ?? String(error));
+ }
+
+ // redirect:'manual' fetch implementations report a redirect either as
+ // an opaque 'opaqueredirect' response (status 0, browser-shaped fetch)
+ // or a plain 3xx status (some Node fetch implementations) — cover both.
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
+ throw sourceError('source-unreachable', 'redirects are not followed for adapter manifests — pin the final URL');
+ }
+ if (!response.ok) {
+ throw sourceError('source-unreachable', `${url} responded with HTTP ${response.status}`);
+ }
+
+ const text = await readBoundedBody(response, maxBytes, url, { signal: controller.signal, timeoutMs });
+ try {
+ /** @type {'url'} */
+ const origin = 'url';
+ return { raw: JSON.parse(text), origin };
+ } catch (error) {
+ throw sourceError('source-invalid-json', error?.message ?? String(error));
+ }
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Run `npm pack` as a bounded subprocess. Resolves the command through
+ * `resolveShimFn` — production always passes src/lib/exec.mjs's real
+ * resolveShim (a no-op on POSIX, but the difference between a working and
+ * ENOENT'd npm invocation on Windows); tests inject a passthrough so their
+ * execFileFn stubs can assert the LOGICAL argv without also having to model
+ * Windows' PowerShell-wrapped shim shape (that shape is exec.mjs's own test
+ * responsibility, not this module's). Never shell:true; argv arrays only. */
+async function execBounded(execFileFn, cmd, args, { cwd, timeoutMs, maxBuffer }, label, resolveShimFn) {
+ const invocation = resolveShimFn(cmd, args);
+ if (invocation.resolved === false) {
+ throw sourceError('source-unreachable', `no safe invocation found for ${cmd}`);
+ }
+ try {
+ await execFileFn(invocation.command, invocation.args, {
+ cwd, timeout: timeoutMs, maxBuffer, shell: false,
+ });
+ } catch (error) {
+ throw sourceError('source-unreachable', `${label} failed: ${error?.message ?? String(error)}`);
+ }
+}
+
+async function resolveNpmSource(spec, {
+ execFileFn, timeoutMs, maxBytes, tmpDir, resolveShimFn,
+}) {
+ if (!NPM_SPEC_CHARS_RE.test(spec)) {
+ throw sourceError('source-invalid', `npm spec contains disallowed characters: ${spec}`);
+ }
+ const { name, version } = parseNpmSpec(spec);
+ if (!name || !NPM_NAME_RE.test(name)) {
+ throw sourceError('source-invalid', `not a valid npm package name: ${name || '(empty)'}`);
+ }
+ // The version/tag half is validated SEPARATELY from the character-class
+ // gate above: that gate alone still permits e.g. 'pkg@attacker/repo' (npm
+ // git-shorthand) or 'pkg@../../x' (npm's local-path resolver) through,
+ // silently swapping which resolver npm uses for something that reads like
+ // a version pin. Restricting it to exact semver or a slash-free dist-tag
+ // keeps `npm:` sources anchored to "one tarball, from the registry".
+ if (version !== undefined && !(NPM_SEMVER_RE.test(version) || NPM_DIST_TAG_RE.test(version))) {
+ throw sourceError('source-invalid', `not a version or dist-tag: ${version}`);
+ }
+
+ const dir = fsSync.mkdtempSync(path.join(tmpDir ?? os.tmpdir(), 'ak-adapter-src-'));
+ try {
+ await execBounded(
+ execFileFn,
+ 'npm',
+ ['pack', spec, '--ignore-scripts', '--pack-destination', dir],
+ { cwd: dir, timeoutMs, maxBuffer: SUBPROCESS_MAX_BUFFER },
+ 'npm pack',
+ resolveShimFn,
+ );
+
+ const entries = await fs.readdir(dir);
+ const tarball = entries.find((entry) => entry.endsWith('.tgz'));
+ if (!tarball) {
+ throw sourceError('source-invalid', `npm pack for '${spec}' produced no tarball`);
+ }
+
+ // Extract ONLY package/ak-adapter.json, straight to stdout (-O) —
+ // never to disk. See the module header for why.
+ const invocation = resolveShimFn('tar', ['-xzOf', tarball, 'package/ak-adapter.json']);
+ if (invocation.resolved === false) {
+ throw sourceError('source-unreachable', 'no safe invocation found for tar');
+ }
+ let stdout;
+ try {
+ // maxBuffer bounded tight to maxBytes (plus small encoding slack),
+ // not the generic SUBPROCESS_MAX_BUFFER above: this stdout IS the
+ // manifest content, so an oversized member is rejected by execFile's
+ // own buffer ceiling before it fully lands in process memory.
+ ({ stdout } = await execFileFn(invocation.command, invocation.args, {
+ cwd: dir, timeout: timeoutMs, maxBuffer: maxBytes + 4096, shell: false,
+ }));
+ } catch (error) {
+ if (isMaxBufferError(error)) {
+ throw sourceError('source-too-large', `ak-adapter.json for '${spec}' exceeds ${maxBytes} bytes`);
+ }
+ // npm pack already proved the package itself was reachable; a
+ // subsequent failure extracting this ONE specific member is, in
+ // practice, always "the member isn't in the archive" — refuse it as
+ // an invalid source, not an unreachable one.
+ throw sourceError('source-invalid', `package '${spec}' does not ship an ak-adapter.json at its root`);
+ }
+
+ // A symlink (or other non-regular) tar entry carries no data blocks, so
+ // -O extraction of one yields empty stdout. Refuse that explicitly and
+ // honestly, rather than letting an opaque JSON.parse('') error stand in
+ // for "this entry produced nothing, on purpose".
+ if (!stdout || !stdout.trim()) {
+ throw sourceError('source-invalid', `package '${spec}' member 'package/ak-adapter.json' produced no content — symlinks and other non-regular tar entries are not followed`);
+ }
+ if (Buffer.byteLength(stdout, 'utf8') > maxBytes) {
+ throw sourceError('source-too-large', `ak-adapter.json for '${spec}' exceeds ${maxBytes} bytes`);
+ }
+
+ try {
+ /** @type {'npm'} */
+ const origin = 'npm';
+ return { raw: JSON.parse(stdout), origin };
+ } catch (error) {
+ throw sourceError('source-invalid-json', error?.message ?? String(error));
+ }
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+}
+
+/**
+ * Resolve an adapter manifest `source` string into raw parsed JSON.
+ * Dispatches on prefix: `https://...`, `npm:[@version|tag]`, or (no
+ * recognized prefix) a local file path. Throws SourceError with a named
+ * `.reason` on any failure — never returns partial or best-effort content.
+ *
+ * @param {string} source
+ * @param {{fetchFn?:typeof fetch, execFileFn?:(cmd:string,args:string[],opts:any)=>Promise<{stdout:string,stderr:string}>,
+ * resolveShimFn?:(cmd:string,args:string[])=>{command:string,args:string[],resolved:boolean},
+ * timeoutMs?:number, maxBytes?:number, tmpDir?:string}} [options]
+ * @returns {Promise<{raw:any, origin:'file'|'url'|'npm'}>}
+ */
+export async function resolveManifestSource(source, {
+ fetchFn = globalThis.fetch,
+ execFileFn = defaultExecFile,
+ resolveShimFn = resolveShim,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ maxBytes = DEFAULT_MAX_BYTES,
+ tmpDir,
+} = {}) {
+ if (typeof source !== 'string' || !source) {
+ throw sourceError('source-invalid', 'source must be a non-empty string');
+ }
+ if (source.startsWith('https://')) {
+ return resolveHttpsSource(source, { fetchFn, timeoutMs, maxBytes });
+ }
+ if (source.startsWith('http://')) {
+ throw sourceError('source-insecure', 'http:// sources are not permitted for adapter manifests — use https://');
+ }
+ if (source.startsWith('npm:')) {
+ return resolveNpmSource(source.slice('npm:'.length), {
+ execFileFn, timeoutMs, maxBytes, tmpDir, resolveShimFn,
+ });
+ }
+ return resolveFileSource(source, maxBytes);
+}
diff --git a/src/lib/execution/adapters.mjs b/src/lib/execution/adapters.mjs
index 27e9bb9..3afde5e 100644
--- a/src/lib/execution/adapters.mjs
+++ b/src/lib/execution/adapters.mjs
@@ -4,6 +4,7 @@ import { OPENCODE_EXECUTION_ADAPTER } from './opencode.mjs';
import { CLAUDE_EXECUTION_ADAPTER } from './claude.mjs';
import { CODEX_EXECUTION_ADAPTER } from './codex.mjs';
import { routableHostIds } from '../adapters/index.mjs';
+import { admittedExecutionAdapterFor } from './admitted.mjs';
export const EXECUTION_ADAPTERS = Object.freeze(new Map([
['claude', CLAUDE_EXECUTION_ADAPTER],
@@ -43,11 +44,10 @@ assertBuiltinAdaptersRoutable();
* single worker instead of failing an entire run. */
export function executionAdapterFor(hostId) {
if (EXECUTION_ADAPTERS.has(hostId)) return EXECUTION_ADAPTERS.get(hostId);
- // Wave 4 (adapter door): an admitted external host whose manifest declares
- // driving.surfaces including 'cli-subprocess' MAY, in a later wave, get a
- // constructed execution adapter here. This wave builds none: admitted
- // hosts have no execution adapter yet, full stop, so they fall through to
- // the same null return (and the runner's existing cli_unavailable
- // degradation) as any other routable-but-unadapted host.
- return null;
+ // P2 (ADR-0031): an admitted external host whose manifest declared an
+ // execution block gets its adapter derived and registered at bootstrap
+ // (admission.mjs) into execution/admitted.mjs's overlay. A routable host
+ // with no execution block (or nothing admitted at all) still returns null
+ // here — the runner's existing cli_unavailable degradation, unchanged.
+ return admittedExecutionAdapterFor(hostId);
}
diff --git a/src/lib/execution/admitted.mjs b/src/lib/execution/admitted.mjs
new file mode 100644
index 0000000..0854f4a
--- /dev/null
+++ b/src/lib/execution/admitted.mjs
@@ -0,0 +1,366 @@
+// Derived execution adapter for an ADMITTED external host (P2, ADR-0031).
+// This is the ONLY place an admitted manifest's execution.run.hook ever runs:
+// a single-shot supervised subprocess through hook-runner.mjs — no in-process
+// third-party code, ever. Deliberately simpler than opencode.mjs: one spawn,
+// no server lifecycle, no streaming — launch() completes when the child exits.
+import path from 'node:path';
+import { runAdapterHook } from '../adapters/hook-runner.mjs';
+import { resetAdmitted } from '../adapters/admitted.mjs';
+import { have } from '../exec.mjs';
+import { validateExecutionAdapter, validateWorkerResult } from './schema.mjs';
+import { redactHandoffData } from './handoff.mjs';
+
+const DEFAULT_TIMEOUT_MS = 120_000;
+// hook-runner's own inner timeout is what actually kills the child; the
+// runner's outer phase deadline must never race it. Both timers would be
+// registered for the same duration if we handed hook-runner the raw
+// remaining budget, but hook-runner's timer starts a beat later (spawn
+// overhead) — so it would lose that race. Shaving a small margin off what we
+// hand the hook keeps the inner kill strictly first.
+const LAUNCH_TIMEOUT_MARGIN_MS = 250;
+const REASON_MAX_BYTES = 240;
+const PROVIDER_MAX_CHARS = 64;
+// Wave B security review (F-6): reserved hook exit codes give an admitted
+// host an honest consent/auth boundary instead of a silent re-run — part of
+// the adapter contract, alongside stdin/env/exit-0 in the module header.
+// 77 -> the hook needs interactive/out-of-band consent it does not have
+// (mirrors sysexits.h EX_NOPERM in spirit): status 'blocked',
+// exitCategory 'permission_required' — already non-escalating
+// (runner.mjs's BLOCKING_CATEGORIES).
+// 78 -> the hook needs authentication it does not have: status 'failed',
+// exitCategory 'auth_required'.
+const EXIT_PERMISSION_REQUIRED = 77;
+const EXIT_AUTH_REQUIRED = 78;
+
+const nowIso = () => new Date().toISOString();
+
+function boundedReason(text, maxBytes = REASON_MAX_BYTES) {
+ const value = typeof text === 'string' ? text : String(text ?? '');
+ return value.length > maxBytes ? `${value.slice(0, maxBytes - 1)}…` : value;
+}
+
+function execError(reason, message) {
+ return Object.assign(new Error(message), { reason });
+}
+
+// F-1: a relative hook command resolves against whatever `cwd` the child
+// spawns with. With no anchored adapter base directory (a remote npm/https
+// source has no persistent local bundle), that would fall back to the
+// OPERATOR's process.cwd() when `ak run` was invoked — arbitrary-code-
+// execution by planting a same-named file, with the consent hash unchanged.
+// A bare interpreter/binary name found through PATH (node, hermes) is
+// unaffected by cwd and stays legal; only a path-separator-bearing or
+// script-looking bare token is refused.
+// Windows executable/script extensions (exe|bat|cmd|com|ps1) are included
+// because Windows CreateProcess searches the current directory, so a bare
+// `hook.bat` with no anchoring base directory would resolve from the
+// operator's cwd — the same planted-file vector as a relative script on POSIX.
+const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i;
+
+function looksRelative(token) {
+ if (typeof token !== 'string' || !token) return false;
+ if (path.isAbsolute(token)) return false;
+ if (token.includes('/') || token.includes('\\')) return true;
+ return SCRIPT_LIKE_RE.test(token);
+}
+
+// R-3: EVERY arg is inspected, not just non-flag ones — a `--flag=value`
+// token starts with '-' but its value half can still be a relative path
+// (`--import=./evil.mjs`), and looksRelative's own `.includes('/')` check
+// already catches that once the whole token is examined (no separate '='
+// split needed: the token as a whole still contains '/'). Skipping
+// '-'-prefixed args entirely (the pre-R-3 shape) let exactly this slip past
+// with a null baseDir. A bare flag with no path-like value (`--verbose`,
+// `-v`) is unaffected — it matches neither check and stays legal.
+function commandIsUnanchorable(command) {
+ const [argv0, ...args] = command;
+ if (looksRelative(argv0)) return true;
+ return args.some((arg) => looksRelative(arg));
+}
+
+/** stdout is EITHER a JSON object with optional {summary, observedModel,
+ * provider, usage} — read when the whole trimmed `stdoutText` (R-1: the
+ * UNMERGED stdout hook-runner reports; never `stdout`, which folds stderr
+ * in after a separator and would break JSON.parse the instant the hook
+ * writes anything to stderr at all) parses as a JSON object — OR plain
+ * text, treated as the summary capture (ADR-0029 §2).
+ * F-4: `stderrText` gates ONLY the plain-text path — a hook that wrote to
+ * stderr (warnings, a crash trace, interpreter noise) never has that text
+ * silently promoted into the cross-vendor dependency handoff. A genuine
+ * JSON payload parses and is trusted REGARDLESS of stderr (R-1: a stray
+ * deprecation warning must not blank out an otherwise-valid summary and
+ * cascade into "required worker handoff was missing" for every dependent).
+ * F-7: a self-declared `provider` is bounded (a payload cannot claim an
+ * unbounded-length vendor name). */
+function parseStdout(stdoutText, stderrText) {
+ const trimmed = typeof stdoutText === 'string' ? stdoutText.trim() : '';
+ const hasStderr = typeof stderrText === 'string' && stderrText.trim() !== '';
+ if (!trimmed) return { summary: null, observedModel: null, provider: null, usage: null };
+ let parsed;
+ try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; }
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return {
+ summary: typeof parsed.summary === 'string' ? parsed.summary : null,
+ observedModel: typeof parsed.observedModel === 'string' ? parsed.observedModel : null,
+ provider: typeof parsed.provider === 'string' ? parsed.provider.slice(0, PROVIDER_MAX_CHARS) : null,
+ usage: parsed.usage && typeof parsed.usage === 'object' && !Array.isArray(parsed.usage) ? parsed.usage : null,
+ };
+ }
+ if (hasStderr) return { summary: null, observedModel: null, provider: null, usage: null };
+ return { summary: trimmed, observedModel: null, provider: null, usage: null };
+}
+
+/**
+ * Build a host-neutral execution adapter for one admitted manifest declaring
+ * `execution.run.hook`. `runHook`/`haveFn`/`clock` are injectable for tests;
+ * production defaults spawn the real subprocess. `baseDir` (F-1) is the
+ * adapter's own directory — derived by the caller (admission.mjs) from the
+ * manifest's `source` at registration time, `null` for a source with no
+ * persistent local bundle (npm/https) — never process.cwd().
+ */
+export function buildAdmittedExecutionAdapter(manifest, {
+ runHook = runAdapterHook, haveFn = have, clock = nowIso, baseDir = null,
+} = {}) {
+ if (!manifest || typeof manifest !== 'object') throw new TypeError('buildAdmittedExecutionAdapter requires a manifest');
+ const hostId = manifest.host?.id;
+ if (typeof hostId !== 'string' || !hostId) throw new TypeError('buildAdmittedExecutionAdapter requires manifest.host.id');
+ // F-8 (defence-in-depth, INEXPRESSIBLE-not-refused doctrine): the manifest
+ // schema already couples execution -> canRouteActivities (manifest.mjs's
+ // 'execution-not-routable'), but this is the construction site that
+ // actually wires a subprocess spawn — it re-asserts the invariant itself
+ // rather than trusting every caller to have validated upstream.
+ if (manifest.host?.capabilities?.canRouteActivities !== true) {
+ throw execError('not-routable', `'${hostId}' execution adapter requires host.capabilities.canRouteActivities:true`);
+ }
+ // F-5 (ADR-0029 §2): a manifest that never declared the cli-subprocess
+ // driving surface gets no cli-subprocess execution adapter — refused, not
+ // silently downgraded. Re-checked here even though the bootstrap filter
+ // (admission.mjs) already screens candidates, so no other caller can skip it.
+ if (!Array.isArray(manifest.driving?.surfaces) || !manifest.driving.surfaces.includes('cli-subprocess')) {
+ throw execError('surface-unsupported', `'${hostId}' execution adapter requires driving.surfaces to include 'cli-subprocess'`);
+ }
+ const hook = manifest.execution?.run?.hook;
+ if (!hook || !Array.isArray(hook.command) || hook.command.length === 0) {
+ throw new TypeError(`buildAdmittedExecutionAdapter requires manifest.execution.run.hook for '${hostId}'`);
+ }
+ const detectionBin = manifest.detection?.bin;
+ if (typeof detectionBin !== 'string' || !detectionBin) {
+ throw new TypeError(`buildAdmittedExecutionAdapter requires manifest.detection.bin for '${hostId}'`);
+ }
+ if (baseDir == null && commandIsUnanchorable(hook.command)) {
+ throw execError('execution-unanchored',
+ `'${hostId}' declares a relative execution.run.hook.command with no anchored adapter base directory `
+ + '(a remote npm/https source has no persistent local bundle) — use an absolute path or a PATH binary');
+ }
+
+ function terminalResult(state, base) {
+ return validateWorkerResult({
+ workerId: state.worker.id, activity: state.worker.activity, role: state.worker.role, host: hostId,
+ startedAt: state.startedAt, endedAt: clock(),
+ durationMs: Math.max(0, Date.parse(clock()) - Date.parse(state.startedAt)),
+ provider: null, providerProvenance: 'unknown', configuredModel: state.worker.configuredModel ?? null,
+ observedModel: null, sessionId: null, transcriptRefs: [], failure: null, usage: null,
+ ...base,
+ });
+ }
+
+ const adapter = {
+ id: `${hostId}-adapter`,
+
+ async readiness({ signal, timeoutMs } = /** @type {{signal?:AbortSignal,timeoutMs?:number}} */ ({})) {
+ signal?.throwIfAborted?.();
+ const ready = await haveFn(detectionBin, { signal, timeout: timeoutMs });
+ signal?.throwIfAborted?.();
+ return ready ? { ready: true } : { ready: false, exitCategory: 'cli_unavailable' };
+ },
+
+ async prepare({ worker, cwd = process.cwd() } = /** @type {{worker?:any,cwd?:string}} */ ({})) {
+ if (!worker || worker.host !== hostId) throw new TypeError(`${hostId} adapter requires a ${hostId} worker`);
+ // R-2: state.cwd doubles as the fallback spawn cwd (below) AND rides
+ // in AK_WORKER_CWD — runAdapterHook itself throws on a non-absolute
+ // cwd, so this assertion (mirroring opencode.mjs's own worker-cwd
+ // check) is what makes that downstream guarantee hold, not an
+ // incidental duplicate of it.
+ if (!path.isAbsolute(cwd)) throw new TypeError(`${hostId} worker cwd must be absolute`);
+ return { worker, cwd, prompt: worker.prompt, startedAt: clock() };
+ },
+
+ async launch(state, { timeoutMs, signal } = /** @type {{timeoutMs?:number,signal?:AbortSignal}} */ ({})) {
+ signal?.throwIfAborted?.();
+ const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
+ const innerTimeoutMs = Math.max(1, budget - LAUNCH_TIMEOUT_MARGIN_MS);
+ const env = {
+ AK_WORKER_ID: state.worker.id,
+ AK_WORKER_ACTIVITY: state.worker.activity,
+ AK_WORKER_ROLE: state.worker.role,
+ AK_WORKER_MODEL: state.worker.configuredModel ?? '',
+ // R-2: F-1's cwd pin (below) took away the hook's only implicit
+ // channel for learning which repo it's working on — the spawn cwd is
+ // now the adapter's own baseDir, not the caller's. Told explicitly
+ // instead, so a hook that needs the target repo can still find it
+ // without reopening F-1 by spawning there.
+ AK_WORKER_CWD: state.cwd,
+ };
+ state.hookResult = await runHook({
+ hook, hostId, verb: 'run', timeoutMs: innerTimeoutMs, env, stdin: state.prompt,
+ // R-2: the spawn cwd is uniform and explicit, never Node's own
+ // "inherit ak's process.cwd()" default (runAdapterHook's own
+ // fallback for an omitted cwd) — baseDir anchors a relative script
+ // to the adapter's own directory when one was declared (F-1);
+ // otherwise the construction-time check above already proved the
+ // command has no relative component that a cwd could redirect, so
+ // falling back to the repo cwd (state.cwd) here is safe (only bare
+ // PATH binaries reach this branch) and gives the hook a normal
+ // "run me from the target repo" default.
+ cwd: baseDir ?? state.cwd,
+ });
+ return state;
+ },
+
+ async observe(state) {
+ const result = state.hookResult;
+ if (!result) throw new Error(`${hostId} adapter observed before launch completed`);
+ return {
+ type: 'exit', ok: result.ok, stdout: result.stdout,
+ // R-1: stdoutText is the UNMERGED stdout — see parseStdout's header
+ // comment for why the payload must never be parsed from `stdout`.
+ stdoutText: result.stdoutText ?? '',
+ exitCode: result.exitCode, detail: result.detail,
+ stderrText: result.stderrText ?? result.stderr ?? '',
+ };
+ },
+
+ interpret(state, observation) {
+ // Runner-injected terminal events (outer phase deadline aborting
+ // launch/observe before our own {type:'exit'} observation lands, or an
+ // unexpected cancel/cleanup failure). See LAUNCH_TIMEOUT_MARGIN_MS above
+ // for why the 'exit' path is the expected one in practice.
+ if (observation?.type === 'timeout') {
+ return terminalResult(state, { status: 'timed_out', exitCategory: 'timeout', failure: { reason: boundedReason(observation.reason ?? 'timeout') } });
+ }
+ if (observation?.type === 'orphaned') {
+ return terminalResult(state, { status: 'failed', exitCategory: 'orphaned', failure: { reason: 'admitted host subprocess did not terminate' } });
+ }
+ if (observation?.type !== 'exit') {
+ return terminalResult(state, { status: 'failed', exitCategory: 'protocol_error', failure: { reason: 'unrecognized observation from admitted host adapter' } });
+ }
+
+ const {
+ ok, exitCode, stdout, stdoutText, detail, stderrText,
+ } = observation;
+ if (ok && exitCode === 0) {
+ const parsed = parseStdout(stdoutText, stderrText);
+ return terminalResult(state, {
+ status: 'succeeded', exitCategory: 'success', failure: null,
+ observedModel: parsed.observedModel,
+ provider: parsed.provider,
+ // F-7: a payload's self-declared provider is NEVER 'observed' — the
+ // hook asserted it, ak didn't verify it against anything.
+ providerProvenance: parsed.provider ? 'inferred' : 'unknown',
+ usage: parsed.usage,
+ });
+ }
+ // F-6: reserved hook exit codes (see module header) — checked ahead of
+ // the generic non-zero-exit fallback below.
+ if (exitCode === EXIT_PERMISSION_REQUIRED) {
+ return terminalResult(state, {
+ status: 'blocked', exitCategory: 'permission_required',
+ failure: { reason: boundedReason(detail ?? 'adapter hook requires consent it does not have (exit 77)') },
+ });
+ }
+ if (exitCode === EXIT_AUTH_REQUIRED) {
+ return terminalResult(state, {
+ status: 'failed', exitCategory: 'auth_required',
+ failure: { reason: boundedReason(detail ?? 'adapter hook requires authentication it does not have (exit 78)') },
+ });
+ }
+ if (exitCode === null && typeof detail === 'string' && /timed out/i.test(detail)) {
+ return terminalResult(state, { status: 'timed_out', exitCategory: 'timeout', failure: { reason: boundedReason(detail) } });
+ }
+ if (exitCode === null) {
+ return terminalResult(state, { status: 'failed', exitCategory: 'cli_unavailable', failure: { reason: boundedReason(detail ?? 'adapter hook failed to start') } });
+ }
+ // F-3: the stdout tail folded into a worker_error reason is untrusted
+ // hook output — it may itself contain a `` block (or
+ // other private-protocol payload) that must never leak through a
+ // public WorkerResult's failure.reason. Redact before bounding, same
+ // as subprocess.mjs's failureFor.
+ const tail = typeof stdout === 'string' ? stdout.trim().slice(-200) : '';
+ const raw = tail ? `${detail} — ${tail}` : (detail ?? `adapter hook exited with code ${exitCode}`);
+ return terminalResult(state, { status: 'failed', exitCategory: 'worker_error', failure: { reason: boundedReason(redactHandoffData(raw)) } });
+ },
+
+ // The hook script's own protocol is simpler than the tagged-block one
+ // LLM-driven built-in hosts use: a JSON payload's `summary` field (or
+ // plain-text stdout, when stderr is empty) IS the outcome, verbatim — no
+ // `` parsing. Wrap it into normalizeHandoff's exact
+ // accepted shape rather than returning the bare `{summary}` it was read
+ // from.
+ summarize(_state, observation) {
+ if (observation?.type !== 'exit' || !observation.ok || observation.exitCode !== 0) return null;
+ const { summary } = parseStdout(observation.stdoutText, observation.stderrText);
+ if (!summary) return null;
+ return { outcome: summary, artifacts: [], decisions: [], risks: [] };
+ },
+
+ // hook-runner owns the kill (group SIGKILL on its own inner timeout);
+ // launch() only resolves once the subprocess has already exited, so by
+ // the time cancel/cleanup can run there is normally no live resource
+ // left to terminate. The one exception (F-2): if the runner's OUTER
+ // phase deadline fires WHILE launch() is still pending — state.hookResult
+ // never got set — the subprocess may still be alive and its termination
+ // is unproven. Reporting plain 'cancelled' there would let the runner's
+ // escalation ladder treat this as an ordinary escalatable timed_out and
+ // fire a SECOND attempt against a worker that might still be running.
+ // 'orphaned' is honest (uncertain, non-escalating) and matches
+ // runner.mjs's own BLOCKING_CATEGORIES.
+ async cancel(state) {
+ if (!state?.hookResult) return { type: 'cancelled', orphaned: true };
+ return { type: 'cancelled' };
+ },
+ async cleanup() { return { cleaned: true }; },
+ };
+
+ return validateExecutionAdapter(adapter);
+}
+
+// ── overlay registry (mirrors adapters/admitted.mjs's discipline) ──────────
+// Module-level Map, not the built-in EXECUTION_ADAPTERS Map: an admitted
+// external host's adapter is DERIVED from its manifest at bootstrap time, not
+// hand-authored in-tree. A second register for the same host id replaces the
+// first (re-admission, or a test re-registering); production populates this
+// only from bootstrapHostAdapters (admission.mjs).
+let admittedExecutionAdapters = new Map();
+
+/** Build and register an execution adapter for an admitted manifest. Returns
+ * the built adapter. Throws (uncaught) on a manifest with no execution
+ * block or a malformed one — callers (bootstrap) are expected to guard this
+ * per-adapter and treat a throw as a non-fatal warning, not a crash. */
+export function registerAdmittedExecution(manifest, options) {
+ const adapter = buildAdmittedExecutionAdapter(manifest, options);
+ admittedExecutionAdapters.set(manifest.host.id, adapter);
+ return adapter;
+}
+
+/** Test-only reset back to the unregistered, built-ins-only state. */
+export function resetAdmittedExecution() {
+ admittedExecutionAdapters = new Map();
+}
+
+/** One admitted host's execution adapter, or null when none is registered —
+ * never throws, mirroring executionAdapterFor's degrade-one-worker posture. */
+export function admittedExecutionAdapterFor(hostId) {
+ return admittedExecutionAdapters.get(hostId) ?? null;
+}
+
+// F-9: the host overlay (adapters/admitted.mjs) and this execution overlay
+// are two independently-mutable module singletons that a caller could reset
+// out of step (e.g. a test resetting only one), leaving the other stale — an
+// admitted-but-execution-orphaned or execution-registered-but-unadmitted
+// state neither overlay's own reset guards against alone. Pairing them here
+// (rather than reaching into adapters/admitted.mjs to add an upward
+// dependency on this module) keeps that file untouched.
+export function resetAllAdmitted() {
+ resetAdmittedExecution();
+ resetAdmitted();
+}
diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs
index ee86490..8d6bc5c 100644
--- a/src/lib/hosts.mjs
+++ b/src/lib/hosts.mjs
@@ -23,7 +23,7 @@
// ~/.codex/auth.json (key overrides login). claude auth on macOS lives in the
// Keychain (no readable file); ANTHROPIC_API_KEY, when used, is not a simple
// override of a subscription login, so we label it conservatively.
-import { HOST_REGISTRY, effectiveHostRegistry } from './adapters/index.mjs';
+import { HOST_REGISTRY, effectiveHostRegistry, effectivePrimaryHostIds } from './adapters/index.mjs';
/** Per-host adapter descriptors. Logical names (`guidanceFile`, `loginFile`
* segments) are resolved to real paths by callers so this stays pure. */
@@ -53,6 +53,11 @@ export function adapterFor(id) {
* (heuristic) → configured primary (kit.json routing.primaryHost) → 'claude'.
* Pure: reads only env + the passed cfg; never spawns.
*
+ * Always returns a host id HOST_ADAPTERS/adapterFor can resolve (a built-in,
+ * canDriveSession host) — never an id that is merely eligible per
+ * effectivePrimaryHostIds() (below) but that this module has no session-
+ * driving descriptor for.
+ *
* @param {NodeJS.ProcessEnv} [env]
* @param {any} [cfg] kit.json config (for routing.primaryHost)
* @returns {'claude'|'codex'|'opencode'}
@@ -65,8 +70,27 @@ export function drivingHost(env = process.env, cfg = null) {
// heuristic because the fallback below covers the miss.
if (Object.keys(env).some((k) => k.startsWith('CODEX_'))) return 'codex';
const primary = cfg?.routing?.primaryHost;
- const primaryCapable = HOST_REGISTRY.some((host) => host.id === primary && host.capabilities.canBePrimary);
- if (primary && primaryCapable) return /** @type {'claude'|'codex'} */ (primary);
+ // effectivePrimaryHostIds() reads the EFFECTIVE (built-in + grant-overlaid
+ // admitted) set for ELIGIBILITY, not HOST_REGISTRY directly (ADR-0031 §1) —
+ // that primitive is live, so a hand-edited kit.json pointing
+ // routing.primaryHost at a host that has since earned a canBePrimary grant
+ // is recognized as eligible here. (No production path drives kit.json's
+ // primaryHost to an admitted external id today — `ak host pick`'s
+ // selection menu stays built-in-scoped this wave — so this is a live
+ // primitive with no live caller yet, not an active privilege boundary.)
+ //
+ // Eligibility alone is not enough to RETURN the host, though: HOST_ADAPTERS
+ // (built from HOST_REGISTRY, filtered by canDriveSession) is what this
+ // module actually knows how to drive a session for — guidanceFile,
+ // statusline, envMarkers, auth — and stays built-in-only; no admitted
+ // external host has that wiring registered. Without the adapterFor(primary)
+ // guard, an eligible-but-unresolvable host would be returned here and a
+ // caller doing `adapterFor(drivingHost(...)).guidanceFile` would crash on
+ // null. Both checks must agree, so this function's contract (always a
+ // HOST_ADAPTERS-resolvable id) holds even once effectivePrimaryHostIds()
+ // can name a host HOST_ADAPTERS doesn't.
+ const primaryCapable = effectivePrimaryHostIds().includes(primary);
+ if (primary && primaryCapable && adapterFor(primary)) return /** @type {'claude'|'codex'} */ (primary);
return 'claude';
}
diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs
index 2f95f8b..6b91a5b 100644
--- a/src/lib/routing.mjs
+++ b/src/lib/routing.mjs
@@ -6,7 +6,9 @@
// (no I/O) so the projectors and defaults are unit-testable in isolation; the
// writers/UX that consume it live in providers.mjs / the commands.
import { vendorOf } from './qeCourt.mjs';
-import { routableHostIds, primaryHostIds, validateActivityHost } from './adapters/index.mjs';
+import {
+ routableHostIds, primaryHostIds, validateActivityHost, effectiveHostRegistry, effectiveRoutableHostIds,
+} from './adapters/index.mjs';
// ── Vocabulary ───────────────────────────────────────────────────────────────
// Canonical development activities ak routes (ADR-0002). Array order = display order.
@@ -21,8 +23,14 @@ export const AK_ORIGINATED = new Set(['packaging', 'release']);
// Host → aqe/router provider type. OpenCode deliberately has no entry: its
// execution provider is observed per worker and must never be inferred from the
-// host or silently projected into AQE's separate provider vocabulary.
+// host or silently projected into AQE's separate provider vocabulary. An
+// admitted external host (P2, ADR-0031) gets no entry either, same reasoning.
export const HOST_PROVIDER = { claude: 'claude-code', codex: 'codex' };
+// Frozen at import time — built-ins only. Display strings and built-in
+// listings ONLY (formatModelHelp, model catalogs below): every VALIDATION
+// path (isRoutableHost, validateRoute, materializeRunPlan) consults the lazy
+// effectiveRoutableHostIds()/effectiveHostRegistry() instead, so an admitted
+// external host routes without this constant ever needing to change.
export const HOSTS = routableHostIds();
// Providers aqe's ProviderManager can construct — grounded in agentic-qe 3.13.1
@@ -164,6 +172,20 @@ export function formatModelHelp() {
// reasoning roles). When codex is chosen as PRIMARY, we mirror each default route
// to the opposite host so codex takes the lead and claude becomes the alternate —
// a defaults/policy change only (DualModeOrchestrator workers are symmetric).
+// Frozen at import time — built-ins only, deliberately (audited for the D2
+// keystone wave, ADR-0031 §1). Every current reader of PRIMARY_HOSTS
+// (providers.mjs's applySetupHostFlags `--primary-host` validation, and
+// x/host.mjs's `ak host pick` primary-host flag + selection menu) is the
+// primary-host SELECTION UX, not an eligibility-VALIDATION path — extending
+// that picker surface to an admitted external host is explicitly out of
+// scope this wave (the deferred pick surface), mirroring how HOSTS above
+// stays display-only. hosts.mjs's drivingHost() does NOT use this constant —
+// it consults admitted.mjs's effectivePrimaryHostIds() (fresh per call)
+// instead, so the eligibility PRIMITIVE is live there. That said, no
+// production path drives kit.json's routing.primaryHost to an admitted
+// external id today (the picker that writes it stays built-in-only, as
+// above), so this is a live primitive with no live privileged caller yet —
+// not an active validation gate anything currently depends on.
export const PRIMARY_HOSTS = primaryHostIds();
export const DEFAULT_PRIMARY_HOST = 'claude';
@@ -234,9 +256,12 @@ export const AGENT_ACTIVITY_MAP = {
// ── Policy resolution + projections (pure) ──────────────────────────────────
-/** True when both frontier hosts appear in a route (a route's host is valid). */
+/** True when `host` is routable: a built-in, or an admitted external host
+ * whose manifest declared capabilities.canRouteActivities (P2, ADR-0031).
+ * Lazy — re-reads the effective registry on every call, so it reflects an
+ * overlay applied after this module first loaded. */
export function isRoutableHost(host) {
- return HOSTS.includes(host);
+ return effectiveRoutableHostIds().includes(host);
}
/** Substitute a retired model for its replacement, recording what was swapped.
@@ -576,11 +601,15 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = '
const nodes = RUN_TEMPLATES[template];
if (!nodes) throw new Error(`unknown template "${template}" (expected: ${RUN_TEMPLATE_NAMES.join(', ')})`);
const routes = resolveRoutes(policy);
+ // Snapshot once per materialization (not per validateActivityHost call): an
+ // admitted overlay applied mid-call must not be able to make one worker's
+ // eligibility check see a different registry than another's in the same plan.
+ const hosts = effectiveHostRegistry();
return {
template,
workers: nodes.map((n) => {
const r = routes[n.activity];
- const eligibility = validateActivityHost(r.host);
+ const eligibility = validateActivityHost(r.host, hosts);
if (!eligibility.ok) {
throw new Error(`route for "${n.activity}" cannot materialize: host "${r.host}" requires canRouteActivities`);
}
@@ -591,7 +620,7 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = '
const ladder = (r.escalation ?? [])
.filter((rung) => rung && (rung.host !== r.host || (rung.model ?? null) !== (r.model ?? null)))
.map((rung) => {
- const rungEligibility = validateActivityHost(rung.host);
+ const rungEligibility = validateActivityHost(rung.host, hosts);
if (!rungEligibility.ok) {
throw new Error(`escalation rung for "${n.activity}" cannot materialize: host "${rung.host}" requires canRouteActivities`);
}
@@ -647,7 +676,7 @@ export function routingSummary(policy = {}) {
export function validateRoute(route = {}) {
const { host, model } = route;
const errs = [];
- if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${HOSTS.join('|')})`);
+ if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${effectiveRoutableHostIds().join('|')})`);
else if (HOST_PROVIDER[host] && !AQE_CONSTRUCTIBLE_PROVIDERS.includes(HOST_PROVIDER[host])) errs.push(`host "${host}" maps to a non-constructible provider`);
if (model != null && (typeof model !== 'string' || model.trim() === '')) errs.push('model must be a non-empty string');
return errs;
diff --git a/tests/fixtures/adapters/acme/manifest.json b/tests/fixtures/adapters/acme/manifest.json
index d2919a6..98bd98b 100644
--- a/tests/fixtures/adapters/acme/manifest.json
+++ b/tests/fixtures/adapters/acme/manifest.json
@@ -9,7 +9,7 @@
"capabilities": {
"canDriveSession": false,
"canBePrimary": false,
- "canRouteActivities": false,
+ "canRouteActivities": true,
"commandStatusline": false,
"transcripts": false,
"usage": false,
@@ -32,6 +32,11 @@
"hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 }
}
},
+ "execution": {
+ "run": {
+ "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 5000 }
+ }
+ },
"trust": {
"changes": [
{
diff --git a/tests/fixtures/adapters/acme/run-hook.mjs b/tests/fixtures/adapters/acme/run-hook.mjs
new file mode 100644
index 0000000..31e0b83
--- /dev/null
+++ b/tests/fixtures/adapters/acme/run-hook.mjs
@@ -0,0 +1,28 @@
+// Fixture execution hook for the "acme" conformance adapter (P2, ADR-0031).
+// A real, standalone subprocess — no dependencies, no network, no filesystem
+// writes — invoked exactly as an admitted external adapter's declared
+// execution.run.hook would be, through the real runAdapterHook. It reads the
+// worker prompt from stdin (proving the stdin wiring) and echoes a JSON
+// result whose `summary` names the AK_WORKER_* metadata env vars it received
+// (proving the env wiring), matching the {summary, observedModel, provider}
+// shape buildAdmittedExecutionAdapter (execution/admitted.mjs) parses.
+//
+// ACME_RUN_HOOK_FAIL=1 makes the hook fail deliberately, for a negative test
+// of the worker_error mapping path.
+let input = '';
+process.stdin.setEncoding('utf8');
+process.stdin.on('data', (chunk) => { input += chunk; });
+process.stdin.on('end', () => {
+ if (process.env.ACME_RUN_HOOK_FAIL === '1') {
+ process.stderr.write('acme fixture run hook: deliberate failure\n');
+ process.exit(3);
+ return;
+ }
+ process.stdout.write(JSON.stringify({
+ summary: `acme ran worker=${process.env.AK_WORKER_ID ?? ''} activity=${process.env.AK_WORKER_ACTIVITY ?? ''} `
+ + `role=${process.env.AK_WORKER_ROLE ?? ''} model=${process.env.AK_WORKER_MODEL ?? ''} `
+ + `promptBytes=${Buffer.byteLength(input, 'utf8')}`,
+ observedModel: process.env.AK_WORKER_MODEL || null,
+ provider: 'acme',
+ }));
+});
diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs
index f318ee7..99c8fd1 100644
--- a/tests/kit/adapter-admission.test.mjs
+++ b/tests/kit/adapter-admission.test.mjs
@@ -7,12 +7,16 @@
// undeclared one.
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
import {
admitAdapters, bootstrapHostAdapters, hashManifest, canonicalizeManifest, SUPPORTED_CONTRACT,
} from '../../src/lib/adapters/admission.mjs';
import {
applyAdmitted, resetAdmitted, admittedHostIds, effectiveHostRegistry,
} from '../../src/lib/adapters/admitted.mjs';
+import { recordTierResult, grantCapability } from '../../src/lib/adapters/grants.mjs';
import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs';
import { validateLifecycleAdapter } from '../../src/lib/adapters/lifecycle.mjs';
import {
@@ -236,6 +240,93 @@ test('flag-on with a trusted entry admits it and applies the overlay', async ()
assert.equal(effectiveHostRegistry().length, HOST_REGISTRY.length + 1);
});
+// ── D2 keystone: bootstrapHostAdapters wires REAL grants.mjs grants into the
+// overlay, live (ADR-0031 §1). Unlike admitted-grants.test.mjs (which injects
+// grantsByName directly to test applyAdmitted's own guarantee in isolation),
+// these two exercise the actual bootstrap -> grantedCapabilitiesFor(name,
+// freshly-computed hash) -> applyAdmitted wiring end to end, against the
+// REAL grants.mjs file store — redirected via XDG_CONFIG_HOME to a throwaway
+// directory so they never touch the developer's real
+// ~/.config/agentic-kit/adapter-grants.json.
+
+test('bootstrapHostAdapters: a real, currently-hashed grant for primary-eligible makes canBePrimary live in effectiveHostRegistry()', async (t) => {
+ const prevXdg = process.env.XDG_CONFIG_HOME;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-live-'));
+ process.env.XDG_CONFIG_HOME = dir;
+ t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); });
+
+ const name = 'hermes-grant-live';
+ const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) }));
+ const hash = hashManifest(manifest);
+ recordTierResult(name, 'primary-eligible', { hash, evidence: 'harness-driven lead + escalation, real WorkerResult trail' });
+ grantCapability(name, 'canBePrimary', { hash });
+
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: 'mem://hermes-grant-live' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: trustingConsent({ [name]: hash }),
+ });
+ assert.equal(result.admitted.length, 1);
+ const entry = effectiveHostRegistry().find((h) => h.id === name);
+ assert.ok(entry, 'the admitted host must be in the effective registry');
+ assert.equal(entry.capabilities.canBePrimary, true, 'the real, currently-hashed grant must be live');
+});
+
+test('bootstrapHostAdapters: a grant recorded against a STALE manifest hash never lights up (edit-invalidation)', async (t) => {
+ const prevXdg = process.env.XDG_CONFIG_HOME;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-stale-'));
+ process.env.XDG_CONFIG_HOME = dir;
+ t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); });
+
+ const name = 'hermes-grant-stale';
+ const oldManifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) }));
+ const oldHash = hashManifest(oldManifest);
+ recordTierResult(name, 'primary-eligible', { hash: oldHash, evidence: 'led a run, escalated to' });
+ grantCapability(name, 'canBePrimary', { hash: oldHash });
+
+ // The manifest changes underneath the grant (a real edit — a bumped
+ // version string) WITHOUT the tier being re-earned/re-granted at the new
+ // hash. Bootstrap must compute the hash of the manifest it just admitted
+ // (newHash), never reuse oldHash — so grantedCapabilitiesFor sees a
+ // mismatch and returns {}.
+ const newManifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }), version: '1.0.1' }));
+ const newHash = hashManifest(newManifest);
+ assert.notEqual(oldHash, newHash, 'sanity: the edit must actually change the hash');
+
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: 'mem://hermes-grant-stale' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => newManifest,
+ consent: trustingConsent({ [name]: newHash }),
+ });
+ assert.equal(result.admitted.length, 1);
+ const entry = effectiveHostRegistry().find((h) => h.id === name);
+ assert.equal(entry.capabilities.canBePrimary, false, 'a grant pinned to the OLD hash must not apply to the new manifest');
+});
+
+test('bootstrapHostAdapters: with no grant recorded at all, an admitted host stays at the manifest floor (canBePrimary false)', async (t) => {
+ const prevXdg = process.env.XDG_CONFIG_HOME;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-none-'));
+ process.env.XDG_CONFIG_HOME = dir;
+ t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); });
+
+ const name = 'hermes-grant-none';
+ const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) }));
+ const hash = hashManifest(manifest);
+
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: 'mem://hermes-grant-none' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: trustingConsent({ [name]: hash }),
+ });
+ assert.equal(result.admitted.length, 1);
+ assert.deepEqual(result.warnings, []);
+ const entry = effectiveHostRegistry().find((h) => h.id === name);
+ assert.equal(entry.capabilities.canBePrimary, false);
+});
+
// ── overlay ──────────────────────────────────────────────────────────────
test('effectiveHostRegistry returns the built-in registry (same reference) when nothing is admitted', () => {
@@ -285,7 +376,11 @@ test('buildAdmittedLifecycleAdapter satisfies validateLifecycleAdapter and route
const calls = [];
const runHook = async (args) => {
calls.push(args);
- return { ok: true, stdout: JSON.stringify({ observed: { version: '1.0.0' } }), exitCode: 0 };
+ // F4: parseHookPayload reads stdoutText (the UNMERGED stdout hook-runner
+ // reports), not stdout (stdout+stderr merged) — a real runAdapterHook
+ // call always populates both; this mock does too, to match that contract.
+ const stdout = JSON.stringify({ observed: { version: '1.0.0' } });
+ return { ok: true, stdout, stdoutText: stdout, exitCode: 0 };
};
const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook });
assert.doesNotThrow(() => validateLifecycleAdapter(adapter));
@@ -313,13 +408,47 @@ test('buildAdmittedLifecycleAdapter reports a hook failure honestly instead of f
const manifest = validateAdapterManifest(validManifest({
lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } },
}));
- const runHook = async () => ({ ok: false, stdout: 'boom', exitCode: 1 });
+ // No `.detail` (a real failed runAdapterHook call always sets one — see
+ // hook-runner.mjs — so this specifically exercises hookFailureResult's
+ // OWN fallback: F4's fix reads stdoutText for that fallback, not stdout).
+ const runHook = async () => ({ ok: false, stdout: 'boom', stdoutText: 'boom', exitCode: 1 });
const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook });
const result = await adapter.verify({});
assert.equal(result.observed, null);
assert.equal(result.error, 'boom');
});
+test('F4: hookFailureResult\'s detail fallback reads stdoutText (unmerged), never the merged stdout+stderr blob', async () => {
+ const manifest = validateAdapterManifest(validManifest({
+ lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } },
+ }));
+ // stdout is the MERGED blob (what a real hook-runner would produce when
+ // stderr chatter follows); stdoutText is the real, unmerged signal.
+ const runHook = async () => ({
+ ok: false, stdout: 'clean-stdout\n--- stderr ---\nnoisy stderr chatter', stdoutText: 'clean-stdout', exitCode: 1,
+ });
+ const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook });
+ const result = await adapter.verify({});
+ assert.equal(result.error, 'clean-stdout', 'the fallback must read stdoutText, not the merged stdout blob');
+});
+
+test('F4: a successful hook exit with valid JSON on stdout and unrelated stderr chatter still reports ok:true (Wave B R-1 twin)', async () => {
+ const manifest = validateAdapterManifest(validManifest({
+ lifecycle: { apply: { hook: { command: ['hermes', 'apply'] } } },
+ }));
+ const payload = JSON.stringify({ ok: true, changed: true, actions: ['wired'], ownership: [], warnings: [], errors: [] });
+ // A stray stderr warning would break JSON.parse(stdout) (the merged blob)
+ // pre-fix — the hook still exited 0 with a fully valid JSON payload on its
+ // OWN stdout, so this must report success.
+ const runHook = async () => ({
+ ok: true, stdout: `${payload}\n--- stderr ---\nsome deprecation warning`, stdoutText: payload, exitCode: 0,
+ });
+ const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook });
+ const result = await adapter.apply({});
+ assert.equal(result.ok, true, 'stderr chatter alongside valid stdout JSON must not fail the apply');
+ assert.equal(result.changed, true);
+});
+
test('registerAdmittedLifecycle checks effectiveHostRegistry, not HOST_REGISTRY, and is retrievable via lifecycleAdapterFor', () => {
applyAdmitted([{ entry: validHost({ id: 'hermes' }) }]);
const manifest = validateAdapterManifest(validManifest());
@@ -332,3 +461,119 @@ test('registerAdmittedLifecycle throws for a host id absent from effectiveHostRe
const manifest = validateAdapterManifest(validManifest({ name: 'ghost', host: validHost({ id: 'ghost' }) }));
assert.throws(() => registerAdmittedLifecycle(manifest), /ghost/);
});
+
+// ── P3 (ADR-0031): bootstrapHostAdapters registers an admitted lifecycle ────
+// The sibling block to execution registration (§222-261 above): an admitted
+// manifest declaring a lifecycle block gets its derived adapter registered
+// during bootstrap, guarded and non-fatal, the same posture as execution.
+// Every test here uses its own host id — LIFECYCLE_ADAPTERS is a
+// process-shared Map with no unregister, so reusing 'hermes' would collide
+// with the registerAdmittedLifecycle tests above.
+
+test('bootstrapHostAdapters registers the lifecycle adapter for an admitted manifest that declares one', async () => {
+ const name = 'hermes-boot-lifecycle';
+ const manifest = validateAdapterManifest(validManifest({
+ name, host: validHost({ id: name }),
+ lifecycle: { apply: { hook: { command: [name, 'apply'] } } },
+ }));
+ const hash = hashManifest(manifest);
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: 'mem://hermes-boot-lifecycle' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: trustingConsent({ [name]: hash }),
+ });
+ assert.equal(result.admitted.length, 1);
+ assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`);
+ assert.notEqual(lifecycleAdapterFor(name), null, 'the lifecycle adapter must be registered by bootstrap');
+});
+
+test('bootstrapHostAdapters never registers a lifecycle adapter for an admitted manifest with no lifecycle block', async () => {
+ const name = 'hermes-boot-no-lifecycle';
+ const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) }));
+ const hash = hashManifest(manifest);
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: 'mem://hermes-boot-no-lifecycle' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: trustingConsent({ [name]: hash }),
+ });
+ assert.equal(result.admitted.length, 1);
+ assert.equal(lifecycleAdapterFor(name), null, 'no lifecycle block declared — nothing to register');
+});
+
+// ── F-1 (ADR-0031 P3, critical fix): bootstrap-level baseDir derivation ────
+// Mirrors adapter-execution.test.mjs's own F-1 (bootstrap) tests exactly:
+// a file-sourced manifest's relative lifecycle hook resolves against the
+// manifest's own directory (never the operator's cwd — this is a REAL
+// subprocess spawn, not an injected runHook); a remote (npm/https) source
+// has no persistent local bundle to anchor to, so its relative hook is
+// refused with a surfaced 'lifecycle-unanchored' warning instead.
+
+test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dirname(source)) and a real relative lifecycle hook runs anchored to it', async () => {
+ const name = 'hermes-f1-file';
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-lifecycle-f1-basedir-'));
+ try {
+ // A REAL script, on disk, referenced by a RELATIVE path — proves the
+ // hook actually resolves against the manifest's own directory rather
+ // than wherever this test process happens to be running from.
+ fs.writeFileSync(path.join(tmpDir, 'apply-hook.mjs'),
+ "process.stdout.write(JSON.stringify({ok:true,changed:true,actions:['wired'],ownership:[],warnings:[],errors:[]}));\n");
+ // process.execPath (absolute), not the bare token 'node' — runAdapterHook
+ // spawns with shell:false, and a bare 'node' does not resolve on Windows
+ // (no PATHEXT/shell resolution there), so the subprocess would never
+ // start. process.execPath is the running node's own absolute path,
+ // always spawnable on every OS; the RELATIVE 'apply-hook.mjs' argument
+ // is what this test is actually anchoring, unaffected by the change.
+ const manifest = validateAdapterManifest(validManifest({
+ name, host: validHost({ id: name }),
+ lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } },
+ }));
+ const manifestPath = path.join(tmpDir, 'manifest.json');
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest));
+ const hash = hashManifest(manifest);
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: manifestPath }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: trustingConsent({ [name]: hash }),
+ });
+ assert.equal(result.admitted.length, 1);
+ assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`);
+ const adapter = lifecycleAdapterFor(name);
+ assert.notEqual(adapter, null);
+ assert.deepEqual(adapter.unanchoredVerbs, []);
+ const applied = await adapter.apply({});
+ assert.equal(applied.ok, true, 'the real relative script must have actually run, anchored to the manifest\'s own directory');
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+});
+
+test('F-1 (bootstrap): an npm-sourced admitted manifest with a relative lifecycle hook surfaces a lifecycle-unanchored warning, and the verb is refused (never spawned)', async () => {
+ const name = 'hermes-f1-npm';
+ // process.execPath, not the bare token 'node' — this verb is refused
+ // before ever spawning either way (npm source -> null baseDir -> unanchored),
+ // but kept consistent with the file-sourced test above rather than leaving
+ // a bare token that would misbehave the moment this test's shape changes.
+ const manifest = validateAdapterManifest(validManifest({
+ name, host: validHost({ id: name }),
+ lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } },
+ }));
+ const hash = hashManifest(manifest);
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name, source: 'npm:hermes-f1-npm-adapter@1.0.0' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: trustingConsent({ [name]: hash }),
+ });
+ assert.equal(result.admitted.length, 1, 'the host itself still admits — only the unanchored verb is refused');
+ const warning = result.warnings.find((w) => w.reason === 'lifecycle-unanchored');
+ assert.ok(warning, `expected a 'lifecycle-unanchored' warning; got ${JSON.stringify(result.warnings)}`);
+ const adapter = lifecycleAdapterFor(name);
+ assert.notEqual(adapter, null, 'the adapter still registers — an unanchorable verb refuses itself, not the whole adapter');
+ assert.deepEqual(adapter.unanchoredVerbs, ['apply']);
+ const applied = await adapter.apply({});
+ assert.equal(applied.ok, false, 'the hook must NEVER have been spawned for an unanchored verb');
+ assert.match(applied.errors[0], /no anchored adapter base directory/);
+});
diff --git a/tests/kit/adapter-conformance.test.mjs b/tests/kit/adapter-conformance.test.mjs
index 9483396..c64b8b0 100644
--- a/tests/kit/adapter-conformance.test.mjs
+++ b/tests/kit/adapter-conformance.test.mjs
@@ -28,6 +28,8 @@ import {
recordConsent, recordedHashFor, isTrusted, revokeConsent,
} from '../../src/lib/adapters/consent.mjs';
import { registerAdmittedLifecycle } from '../../src/lib/adapters/lifecycle-registry.mjs';
+import { registerAdmittedExecution, resetAdmittedExecution } from '../../src/lib/execution/admitted.mjs';
+import { executeRunPlan } from '../../src/lib/execution/runner.mjs';
// Resolved relative to THIS file via fileURLToPath, never a hardcoded
// repo-absolute or monorepo-sibling path (ruflo #2912 counter-example) — so
@@ -42,28 +44,42 @@ const NEGATIVE_CORPUS = [
];
/**
- * The manifest contract has no path-resolution policy of its own yet
- * (hook.command is just "a non-empty array of non-empty strings" —
- * manifest.mjs never inspects the values). A real installed adapter would
- * need SOME resolution step to turn a portable manifest's declared command
+ * The manifest contract has no path-resolution policy of its own for
+ * LIFECYCLE hooks (hook.command is just "a non-empty array of non-empty
+ * strings" — manifest.mjs never inspects the values, and lifecycle-registry.mjs
+ * spawns with no `cwd` anchoring). A real installed adapter would need SOME
+ * resolution step to turn a portable manifest's declared lifecycle command
* into a locally-runnable one; that step doesn't exist in src/ today, so
- * this is a minimal, test-owned stand-in: the literal token 'node' becomes
- * process.execPath (never a bare PATH-searched 'node' — the same portability
- * concern the hook-runner tests already document), and a relative *.mjs
- * argument is resolved against the fixture's own directory (where the
- * committed hook script actually lives), not the CWD.
+ * this remains a minimal, test-owned stand-in for lifecycle hooks ONLY: the
+ * literal token 'node' becomes process.execPath, and a relative *.mjs
+ * argument is resolved against the fixture's own directory.
+ *
+ * The EXECUTION hook (`execution.run.hook`) needs no such rewriting — Wave B
+ * security review (F-1) gave admission.mjs a real baseDir-derivation +
+ * cwd-anchoring path (registerAdmittedExecution -> buildAdmittedExecutionAdapter
+ * -> runAdapterHook's `cwd` option), so the fixture's literal, UNREWRITTEN
+ * `["node", "run-hook.mjs"]` resolves correctly through the real resolver:
+ * 'node' via PATH, 'run-hook.mjs' relative to the fixture's own directory
+ * (this manifest's `source` is a file path, so baseDir = FIXTURE_ROOT). A
+ * test-side rewrite here would prove a safer-than-production path, not the
+ * real one — see the negative-corpus/unanchored tests in adapter-execution.test.mjs
+ * for what happens when there is no baseDir to anchor to.
*/
+function resolveHookCommand(command, hookDir) {
+ return command.map((part) => {
+ if (part === 'node') return process.execPath;
+ if (part.endsWith('.mjs') && !path.isAbsolute(part)) return path.join(hookDir, part);
+ return part;
+ });
+}
+
function resolveManifestCommands(raw, hookDir) {
if (!raw || typeof raw !== 'object' || !raw.lifecycle || typeof raw.lifecycle !== 'object') return raw;
const lifecycle = {};
for (const [verb, entry] of Object.entries(raw.lifecycle)) {
- if (!entry?.hook?.command) { lifecycle[verb] = entry; continue; }
- const command = entry.hook.command.map((part) => {
- if (part === 'node') return process.execPath;
- if (part.endsWith('.mjs') && !path.isAbsolute(part)) return path.join(hookDir, part);
- return part;
- });
- lifecycle[verb] = { ...entry, hook: { ...entry.hook, command } };
+ lifecycle[verb] = entry?.hook?.command
+ ? { ...entry, hook: { ...entry.hook, command: resolveHookCommand(entry.hook.command, hookDir) } }
+ : entry;
}
return { ...raw, lifecycle };
}
@@ -151,6 +167,44 @@ export async function runConformanceReport({ fixtureRoot = FIXTURE_ROOT } = {})
assert.equal(typeof detected?.observed?.pid, 'number');
});
+ await run("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)", async () => {
+ if (!validated) throw new Error('prerequisite: manifest was not validated');
+ // No runHook injection here either: registerAdmittedExecution's default
+ // dynamically imports the real hook-runner.mjs, and executeRunPlan's
+ // default adapter lookup (executionAdapterFor) falls through to the
+ // admitted overlay — proof the whole `ak run` path (materialized plan ->
+ // execution seam -> derived adapter -> hook runner -> spawned Node
+ // process -> stdout JSON -> WorkerResult) actually runs end-to-end.
+ // baseDir mirrors exactly what bootstrapHostAdapters derives in
+ // production (F-1) — this call bypasses that bootstrap wiring (it calls
+ // registerAdmittedExecution directly), so it must reproduce the same
+ // derivation rather than a test-only shortcut.
+ resetAdmittedExecution();
+ try {
+ // haveFn only: the fixture's detection.bin ('acme') is a fictitious
+ // binary that will never actually be on a test machine's PATH — that's
+ // orthogonal to what this check proves (the execution.run hook itself
+ // running end-to-end), so readiness is stubbed the same way a real
+ // installed adapter's `acme` binary would report present. runHook
+ // stays the real default: a genuine spawned subprocess.
+ registerAdmittedExecution(validated, {
+ haveFn: async () => true,
+ baseDir: path.dirname(fs.realpathSync(validManifestPath)),
+ });
+ const plan = { workers: [{ id: 'w1', activity: 'implementation', role: 'coder', host: 'acme', prompt: 'do the thing' }] };
+ const [result] = await executeRunPlan(plan, { clock: () => new Date().toISOString() });
+ assert.equal(result.status, 'succeeded', result.failure?.reason ?? 'expected a succeeded WorkerResult');
+ assert.equal(result.host, 'acme');
+ assert.equal(result.exitCategory, 'success');
+ assert.equal(result.provider, 'acme');
+ // F-7: a payload-declared provider is 'inferred', never 'observed' —
+ // ak did not verify the hook's claim against anything.
+ assert.equal(result.providerProvenance, 'inferred');
+ } finally {
+ resetAdmittedExecution();
+ }
+ });
+
for (const [description, file, cfgName, reason] of NEGATIVE_CORPUS) {
await run(`negative corpus: ${description} is refused with reason '${reason}'`, async () => {
const results = await admitAdapters({
@@ -216,6 +270,10 @@ test("the fixture's declared detect hook runs as a real subprocess and its JSON
assertCheck("the fixture's declared detect hook runs as a real subprocess and its JSON payload flows back");
});
+test("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)", () => {
+ assertCheck("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)");
+});
+
for (const [description, , , reason] of NEGATIVE_CORPUS) {
const name = `negative corpus: ${description} is refused with reason '${reason}'`;
test(name, () => assertCheck(name));
@@ -227,8 +285,8 @@ test('edit-invalidation: mutating one byte of the manifest invalidates the prior
test('runConformanceReport reports a clean pass with no failures', () => {
assert.equal(report.failed, 0, JSON.stringify(report.checks.filter((c) => !c.ok), null, 2));
- assert.equal(report.total, 8);
- assert.equal(report.passed, 8);
+ assert.equal(report.total, 9);
+ assert.equal(report.passed, 9);
});
// ── GAP CLOSED (Wave 4 security remediation, P0-A): consent hashing used to
diff --git a/tests/kit/adapter-execution.test.mjs b/tests/kit/adapter-execution.test.mjs
new file mode 100644
index 0000000..92f804a
--- /dev/null
+++ b/tests/kit/adapter-execution.test.mjs
@@ -0,0 +1,597 @@
+// P2 (ADR-0031) — the derived execution adapter for an admitted external
+// host. Unit-level: every case here injects `runHook`/`haveFn`/`clock`, so
+// none of it depends on a real subprocess or on B1's manifest/hook-runner
+// landing timing. The real-subprocess, real-manifest black-box proof lives in
+// adapter-conformance.test.mjs (the acme fixture's execution.run hook).
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ buildAdmittedExecutionAdapter, registerAdmittedExecution, resetAdmittedExecution, admittedExecutionAdapterFor,
+ resetAllAdmitted,
+} from '../../src/lib/execution/admitted.mjs';
+import { executionAdapterFor } from '../../src/lib/execution/adapters.mjs';
+import { validateExecutionAdapter } from '../../src/lib/execution/schema.mjs';
+import { normalizeHandoff, HANDOFF_START, HANDOFF_END } from '../../src/lib/execution/handoff.mjs';
+import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs';
+import { applyAdmitted, effectiveHostRegistry } from '../../src/lib/adapters/admitted.mjs';
+import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs';
+import { isRoutableHost, validateRoute } from '../../src/lib/routing.mjs';
+
+const clock = () => '2026-08-16T00:00:00.000Z';
+
+function validHost(overrides = {}) {
+ return {
+ id: 'hermes',
+ label: 'Hermes',
+ install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' },
+ capabilities: {
+ canDriveSession: false, canBePrimary: false, canRouteActivities: true,
+ commandStatusline: false, transcripts: false, usage: false,
+ nativeMcpConfig: false, nativeGuidance: false,
+ },
+ trust: { approvalPolicy: 'unchanged', changes: [] },
+ enabledByDefault: false,
+ configProjection: 'ruflo',
+ observability: [],
+ ...overrides,
+ };
+}
+
+function hermesManifest(overrides = {}) {
+ return validateAdapterManifest({
+ name: 'hermes',
+ version: '1.0.0',
+ contract: 1,
+ host: validHost(),
+ detection: { bin: 'hermes' },
+ driving: { surfaces: ['cli-subprocess'] },
+ execution: { run: { hook: { command: ['hermes-run'], timeoutMs: 30_000 } } },
+ trust: {
+ changes: [{
+ id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project',
+ owner: 'hermes', value: 'subprocess hooks', effect: 'run consented hooks for hermes',
+ }],
+ },
+ ...overrides,
+ });
+}
+
+const worker = (overrides = {}) => ({
+ id: 'coder', activity: 'implementation', role: 'coder', host: 'hermes',
+ configuredModel: 'hermes-large', prompt: 'implement the thing', ...overrides,
+});
+
+// F-9: pairs both overlay resets so they can never desync between tests.
+beforeEach(() => resetAllAdmitted());
+
+// ── shape ────────────────────────────────────────────────────────────────
+
+test('buildAdmittedExecutionAdapter returns a shape that passes validateExecutionAdapter', () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest());
+ assert.equal(adapter.id, 'hermes-adapter');
+ assert.doesNotThrow(() => validateExecutionAdapter(adapter));
+});
+
+test('buildAdmittedExecutionAdapter requires an execution block and a detection.bin', () => {
+ const routableHost = {
+ id: 'x', capabilities: { canRouteActivities: true },
+ };
+ assert.throws(() => buildAdmittedExecutionAdapter({
+ host: routableHost, driving: { surfaces: ['cli-subprocess'] },
+ }), TypeError);
+ assert.throws(() => buildAdmittedExecutionAdapter({
+ host: routableHost, driving: { surfaces: ['cli-subprocess'] }, execution: { run: { hook: { command: ['x'] } } },
+ }), TypeError, 'missing detection.bin');
+});
+
+// ── readiness ────────────────────────────────────────────────────────────
+
+test('readiness reflects haveFn', async () => {
+ const ready = buildAdmittedExecutionAdapter(hermesManifest(), { haveFn: async () => true });
+ assert.deepEqual(await ready.readiness({}), { ready: true });
+
+ const notReady = buildAdmittedExecutionAdapter(hermesManifest(), { haveFn: async () => false });
+ assert.deepEqual(await notReady.readiness({}), { ready: false, exitCategory: 'cli_unavailable' });
+});
+
+// ── prepare ──────────────────────────────────────────────────────────────
+
+test('prepare rejects a worker for a different host and a relative cwd', async () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock });
+ await assert.rejects(() => adapter.prepare({ worker: worker({ host: 'codex' }), cwd: '/abs' }), TypeError);
+ await assert.rejects(() => adapter.prepare({ worker: worker(), cwd: 'relative/path' }), TypeError);
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ assert.equal(state.prompt, 'implement the thing');
+ assert.equal(state.startedAt, clock());
+});
+
+// ── launch: stdin, env, and the min-wins timeout margin ─────────────────
+
+test('launch invokes runHook with stdin=prompt, AK_WORKER_* env (incl. AK_WORKER_CWD, R-2), and a margin below the phase budget', async () => {
+ const calls = [];
+ const runHook = async (options) => { calls.push(options); return { ok: true, stdout: 'done', stdoutText: 'done', exitCode: 0, detail: null }; };
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock });
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ await adapter.launch(state, { timeoutMs: 3000 });
+
+ assert.equal(calls.length, 1);
+ const call = calls[0];
+ assert.equal(call.hostId, 'hermes');
+ assert.equal(call.verb, 'run');
+ assert.equal(call.stdin, 'implement the thing');
+ assert.deepEqual(call.env, {
+ AK_WORKER_ID: 'coder', AK_WORKER_ACTIVITY: 'implementation', AK_WORKER_ROLE: 'coder', AK_WORKER_MODEL: 'hermes-large',
+ AK_WORKER_CWD: '/abs',
+ });
+ // Inner (hook-runner-facing) timeout must be strictly less than the phase
+ // budget, so hook-runner's own kill always fires first.
+ assert.ok(call.timeoutMs < 3000, 'inner timeout must undercut the phase budget');
+ assert.ok(call.timeoutMs > 0);
+});
+
+test('launch sends an empty string AK_WORKER_MODEL when the worker has no configuredModel', async () => {
+ const calls = [];
+ const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', exitCode: 0, detail: null }; };
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock });
+ const state = await adapter.prepare({ worker: worker({ configuredModel: null }), cwd: '/abs' });
+ await adapter.launch(state, { timeoutMs: 5000 });
+ assert.equal(calls[0].env.AK_WORKER_MODEL, '');
+});
+
+// ── observe / interpret: the full mapping matrix ─────────────────────────
+
+async function runToResult(hookResult, { worker: w = worker() } = {}) {
+ const runHook = async () => hookResult;
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock });
+ const state = await adapter.prepare({ worker: w, cwd: '/abs' });
+ await adapter.launch(state, { timeoutMs: 5000 });
+ const observation = await adapter.observe(state);
+ return { adapter, state, observation, result: adapter.interpret(state, observation) };
+}
+
+test('exit 0 with a JSON payload maps summary/observedModel/provider/usage and providerProvenance=inferred (F-7)', async () => {
+ const stdout = JSON.stringify({
+ summary: 'implemented the thing', observedModel: 'hermes-large-2', provider: 'hermes-vendor', usage: { tokens: 42 },
+ });
+ const { result, adapter, state, observation } = await runToResult({
+ ok: true, stdout, stdoutText: stdout, stderrText: '', exitCode: 0, detail: null,
+ });
+ assert.equal(result.status, 'succeeded');
+ assert.equal(result.exitCategory, 'success');
+ assert.equal(result.failure, null);
+ assert.equal(result.observedModel, 'hermes-large-2');
+ assert.equal(result.provider, 'hermes-vendor');
+ // F-7: a self-declared payload provider is 'inferred', NEVER 'observed' —
+ // ak did not verify the hook's claim against anything.
+ assert.equal(result.providerProvenance, 'inferred');
+ assert.deepEqual(result.usage, { tokens: 42 });
+ assert.equal(result.host, 'hermes');
+
+ const handoff = adapter.summarize(state, observation);
+ assert.deepEqual(handoff, { outcome: 'implemented the thing', artifacts: [], decisions: [], risks: [] });
+ assert.doesNotThrow(() => normalizeHandoff(handoff), 'summarize output must be normalizeHandoff-compatible');
+});
+
+test('F-7: a payload-declared provider is bounded to 64 chars', async () => {
+ const longProvider = 'x'.repeat(200);
+ const stdout = JSON.stringify({ summary: 'ok', provider: longProvider });
+ const { result } = await runToResult({ ok: true, stdout, stdoutText: stdout, stderrText: '', exitCode: 0, detail: null });
+ assert.equal(result.provider.length, 64);
+ assert.equal(result.provider, longProvider.slice(0, 64));
+});
+
+test('exit 0 with plain-text stdout becomes the summary when stderr is empty', async () => {
+ const { result, adapter, state, observation } = await runToResult({
+ ok: true, stdout: 'plain text result', stdoutText: 'plain text result', stderrText: '', exitCode: 0, detail: null,
+ });
+ assert.equal(result.status, 'succeeded');
+ assert.equal(result.provider, null);
+ assert.equal(result.providerProvenance, 'unknown');
+ assert.equal(result.observedModel, null);
+ assert.equal(result.usage, null);
+
+ const handoff = adapter.summarize(state, observation);
+ assert.deepEqual(handoff, { outcome: 'plain text result', artifacts: [], decisions: [], risks: [] });
+});
+
+test('F-4: non-empty stderrText is never auto-promoted into a summary, even with plain-text stdout', async () => {
+ const { adapter, state, observation } = await runToResult({
+ ok: true, stdout: 'looks like a normal result', stdoutText: 'looks like a normal result',
+ stderrText: 'a stray debug line', exitCode: 0, detail: null,
+ });
+ assert.equal(adapter.summarize(state, observation), null,
+ 'stderr chatter must never become the cross-vendor dependency handoff');
+});
+
+// R-1 (HIGH, blocker fix): the pre-R-1 shape parsed the JSON payload from
+// hook-runner's MERGED `stdout` field — the instant a hook wrote anything to
+// stderr (a Node/npm deprecation warning, nothing to do with the hook's own
+// correctness), the merged text was no longer clean JSON, JSON.parse threw,
+// and a perfectly valid summary/provider/usage was silently discarded. Worse
+// downstream: any worker OTHERS depend on (`requireHandoff`) would then throw
+// "required worker handoff was missing" and fail the whole pipeline over one
+// stderr line. Fixed by parsing from `stdoutText` (hook-runner's UNMERGED
+// stdout) instead — a valid JSON payload now parses regardless of stderr;
+// only the PLAIN-TEXT promotion path stays gated on stderr being empty (F-4,
+// unchanged, and still proven by the sibling test above).
+test('R-1: a JSON payload alongside stderr chatter STILL yields its summary, provider, and usage', async () => {
+ const stdoutText = JSON.stringify({ summary: 'built the thing', provider: 'hermes-vendor', usage: { tokens: 7 } });
+ const stdout = `${stdoutText}\n--- stderr ---\nnpm warn deprecated some-pkg@1.0.0`;
+ const { result, adapter, state, observation } = await runToResult({
+ ok: true, stdout, stdoutText, stderrText: 'npm warn deprecated some-pkg@1.0.0', exitCode: 0, detail: null,
+ });
+ assert.equal(result.status, 'succeeded');
+ assert.equal(result.provider, 'hermes-vendor');
+ assert.equal(result.providerProvenance, 'inferred');
+ assert.deepEqual(result.usage, { tokens: 7 });
+
+ const handoff = adapter.summarize(state, observation);
+ assert.deepEqual(handoff, { outcome: 'built the thing', artifacts: [], decisions: [], risks: [] });
+ assert.doesNotThrow(() => normalizeHandoff(handoff));
+});
+
+test('exit 0 with empty stdout summarizes to null (no fabricated handoff)', async () => {
+ const { adapter, state, observation } = await runToResult({
+ ok: true, stdout: '', stdoutText: '', stderrText: '', exitCode: 0, detail: null,
+ });
+ assert.equal(adapter.summarize(state, observation), null);
+});
+
+// R-1 (pipeline-level): the scenario above must not just parse cleanly in
+// isolation — it must not block a DEPENDENT worker either. Pre-R-1, the
+// producer's missing handoff (mustSummarize, since a descendant depends on
+// it) turned executeWorkerAttempt's requireHandoff check into a protocol
+// error, blocking the producer AND its descendant over one stderr line.
+test('R-1 (pipeline): a JSON summary alongside stderr flows to a dependent worker without blocking it', async () => {
+ const { executeRunPlan } = await import('../../src/lib/execution/runner.mjs');
+ const stdoutText = JSON.stringify({ summary: 'built the thing', provider: 'hermes-vendor' });
+ const stdout = `${stdoutText}\n--- stderr ---\nnpm warn deprecated some-pkg@1.0.0`;
+ const hookResult = {
+ ok: true, stdout, stdoutText, stderrText: 'npm warn deprecated some-pkg@1.0.0', exitCode: 0, detail: null,
+ };
+ const runHook = async () => hookResult;
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock, haveFn: async () => true });
+ const plan = {
+ workers: [
+ { id: 'producer', activity: 'implementation', role: 'coder', host: 'hermes', prompt: 'produce' },
+ {
+ id: 'consumer', activity: 'review', role: 'reviewer', host: 'hermes', prompt: 'consume', dependsOn: ['producer'],
+ },
+ ],
+ };
+ const results = await executeRunPlan(plan, { adapters: { hermes: adapter }, clock });
+ const producer = results.find((r) => r.workerId === 'producer');
+ const consumer = results.find((r) => r.workerId === 'consumer');
+ assert.equal(producer.status, 'succeeded', producer.failure?.reason);
+ assert.equal(producer.provider, 'hermes-vendor');
+ assert.equal(consumer.status, 'succeeded', consumer.failure?.reason);
+});
+
+test('non-zero exit maps to failed/worker_error with a bounded reason including the exit code', async () => {
+ const { result, adapter, state, observation } = await runToResult({
+ ok: false, stdout: 'boom on line 1\n--- stderr ---\nstack trace tail', stderrText: 'stack trace tail',
+ exitCode: 1, detail: 'hermes:run adapter hook exited with code 1',
+ });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'worker_error');
+ assert.match(result.failure.reason, /code 1/);
+ assert.ok(result.failure.reason.length <= 240);
+ assert.equal(adapter.summarize(state, observation), null, 'a failed worker never produces a handoff');
+});
+
+test('F-3: a private handoff block embedded in the failing stdout tail is redacted from the worker_error reason', async () => {
+ const leaking = `some output ${HANDOFF_START}{"outcome":"secret","artifacts":[],"decisions":[],"risks":[]}${HANDOFF_END} more`;
+ const { result } = await runToResult({
+ ok: false, stdout: leaking, stderrText: '', exitCode: 1, detail: 'hermes:run adapter hook exited with code 1',
+ });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'worker_error');
+ assert.doesNotMatch(result.failure.reason, /secret/);
+ assert.doesNotMatch(result.failure.reason, /AK_HANDOFF_V1/);
+ assert.match(result.failure.reason, /private handoff withheld/);
+});
+
+// ── F-6: reserved hook exit codes (consent/auth boundary) ────────────────
+
+test('F-6: exit code 77 maps to status blocked / exitCategory permission_required', async () => {
+ const { result } = await runToResult({
+ ok: false, stdout: '', stderrText: '', exitCode: 77, detail: 'hermes:run adapter hook exited with code 77',
+ });
+ assert.equal(result.status, 'blocked');
+ assert.equal(result.exitCategory, 'permission_required');
+});
+
+test('F-6: exit code 78 maps to status failed / exitCategory auth_required', async () => {
+ const { result } = await runToResult({
+ ok: false, stdout: '', stderrText: '', exitCode: 78, detail: 'hermes:run adapter hook exited with code 78',
+ });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'auth_required');
+});
+
+test('a spawn failure (ENOENT-style) maps to failed/cli_unavailable', async () => {
+ const { result } = await runToResult({
+ ok: false, stdout: '', exitCode: null, detail: "hermes:run adapter hook failed to start: ENOENT (spawn hermes-run ENOENT)",
+ });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'cli_unavailable');
+ assert.match(result.failure.reason, /ENOENT/);
+});
+
+test("hook-runner's own timeout detail maps to timed_out/timeout", async () => {
+ const { result } = await runToResult({
+ ok: false, stdout: '', exitCode: null, detail: 'hermes:run adapter hook timed out after 5000ms and was killed',
+ });
+ assert.equal(result.status, 'timed_out');
+ assert.equal(result.exitCategory, 'timeout');
+});
+
+// ── interpret: runner-injected terminal events (outer deadline abort) ────
+
+test('interpret handles a runner-injected {type:"timeout"} terminal event', async () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock });
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ const result = adapter.interpret(state, { type: 'timeout', reason: 'launch exceeded the worker deadline' });
+ assert.equal(result.status, 'timed_out');
+ assert.equal(result.exitCategory, 'timeout');
+});
+
+test('interpret handles a runner-injected {type:"orphaned"} terminal event', async () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock });
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ const result = adapter.interpret(state, { type: 'orphaned' });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'orphaned');
+});
+
+test('interpret treats an unrecognized observation as a protocol error rather than fabricating success', async () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock });
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ const result = adapter.interpret(state, { type: 'idle' });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'protocol_error');
+});
+
+// ── cancel / cleanup: honest post-launch no-ops, honest pre-launch orphan ─
+
+test('cancel after launch has resolved is an honest no-op (hook-runner already owns the kill)', async () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock });
+ const launchedState = { hookResult: { ok: true, stdout: '', stderrText: '', exitCode: 0, detail: null } };
+ assert.deepEqual(await adapter.cancel(launchedState), { type: 'cancelled' });
+ assert.deepEqual(await adapter.cleanup(launchedState), { cleaned: true });
+});
+
+test('F-2: cancel BEFORE launch resolves reports orphaned (non-escalating), never a plain cancelled', async () => {
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock });
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ // state.hookResult was never set — launch() is (hypothetically) still
+ // pending when the runner's outer deadline fires and calls cancel().
+ const cancelled = await adapter.cancel(state);
+ assert.deepEqual(cancelled, { type: 'cancelled', orphaned: true });
+
+ // The runner maps a cancel() reporting orphaned:true into
+ // interpret(state, {type:'orphaned'}) — prove OUR adapter's interpret
+ // honors that terminal shape (already covered generically for 'orphaned'
+ // above, but pinned here specifically as the F-2 consequence).
+ const result = adapter.interpret(state, { type: 'orphaned' });
+ assert.equal(result.status, 'failed');
+ assert.equal(result.exitCategory, 'orphaned');
+});
+
+// ── F-1: relative hook commands anchor to baseDir, or refuse outright ────
+
+test('F-1: a relative hook command with no baseDir is refused at construction (execution-unanchored)', () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } });
+ assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => {
+ assert.equal(error.reason, 'execution-unanchored');
+ return true;
+ });
+});
+
+test('F-1: a relative NON-flag argument with no baseDir is refused (argv0 itself may be a bare PATH binary)', () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'scripts/run.mjs'] } } } });
+ assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => {
+ assert.equal(error.reason, 'execution-unanchored');
+ return true;
+ });
+});
+
+test('F-1/R-3: a bare flag (and a non-path flag value) is never mistaken for a relative path', () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['hermes-run', '--config', 'x', '--verbose'] } } } });
+ assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest));
+});
+
+// R-3 (residual of F-1): the pre-R-3 shape skipped every '-'-prefixed arg
+// entirely before checking whether it looked like a path — so a single
+// `--flag=value` token (still '-'-prefixed as a WHOLE token) slipped past
+// unchecked even when its value half was a relative script. Now every arg is
+// inspected, catching this with a null baseDir exactly like a bare relative
+// argument would be.
+test("R-3: a '--flag=value' token whose value is a relative path is refused with a null baseDir", () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', '--import=./evil.mjs', 'hermes-run'] } } } });
+ assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => {
+ assert.equal(error.reason, 'execution-unanchored');
+ return true;
+ });
+});
+
+test("R-3: the same '--flag=value' token is legal once a baseDir anchors the command", () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', '--import=./evil.mjs', 'hermes-run'] } } } });
+ assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest, { baseDir: '/adapters/hermes' }));
+});
+
+test('F-1: a bare PATH-resolved interpreter/binary command stays legal with no baseDir', () => {
+ const manifest = hermesManifest(); // command: ['hermes-run'] — no separator, no script extension
+ assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest));
+});
+
+test('F-1: an absolute command is always legal, baseDir or not', () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['/usr/bin/hermes-run', '/abs/arg.mjs'] } } } });
+ assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest));
+});
+
+test('F-1: a relative command IS legal once a baseDir anchors it, and launch passes cwd=baseDir to runHook', async () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'] } } } });
+ const calls = [];
+ const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', stderrText: '', exitCode: 0, detail: null }; };
+ const adapter = buildAdmittedExecutionAdapter(manifest, { runHook, clock, baseDir: '/adapters/hermes' });
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ await adapter.launch(state, { timeoutMs: 5000 });
+ assert.equal(calls[0].cwd, '/adapters/hermes');
+});
+
+// R-2: the tempting fix for "the hook lost its cwd signal" would be spawning
+// in the repo cwd unconditionally — but that reopens F-1 (a relative command
+// would resolve against the operator's cwd again). Instead: baseDir anchors
+// a relative command when one was declared; with NO baseDir, the
+// construction-time check already proved the command has no relative
+// component a cwd could redirect (bare PATH binaries only), so falling back
+// to the repo cwd here is safe AND gives the hook a normal spawn location —
+// never Node's own "inherit ak's own process.cwd()" default.
+test('R-2: launch falls back to state.cwd (never omits cwd) when there is no baseDir', async () => {
+ const calls = [];
+ const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', stdoutText: '', stderrText: '', exitCode: 0, detail: null }; };
+ const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); // baseDir defaults to null
+ const state = await adapter.prepare({ worker: worker(), cwd: '/abs' });
+ await adapter.launch(state, { timeoutMs: 5000 });
+ assert.equal(calls[0].cwd, '/abs');
+});
+
+// ── F-1 (bootstrap-level): baseDir derives from entry.source ─────────────
+
+test('F-1 (bootstrap): an npm-sourced execution adapter with a relative command is refused with a surfaced warning', async () => {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } });
+ const { hashManifest } = await import('../../src/lib/adapters/admission.mjs');
+ const hash = hashManifest(manifest);
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name: 'hermes', source: 'npm:hermes-adapter@1.0.0' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: { recordedHashFor: () => hash, isTrusted: () => true },
+ });
+ assert.equal(result.admitted.length, 1, 'the host itself still admits — only execution registration fails');
+ const warning = result.warnings.find((w) => w.reason === 'execution-unanchored');
+ assert.ok(warning, `expected an 'execution-unanchored' warning; got ${JSON.stringify(result.warnings)}`);
+});
+
+test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dirname(source)) and registers cleanly', async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-execution-basedir-'));
+ try {
+ const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'] } } } });
+ const { hashManifest } = await import('../../src/lib/adapters/admission.mjs');
+ const hash = hashManifest(manifest);
+ const manifestPath = path.join(tmpDir, 'manifest.json');
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest));
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name: 'hermes', source: manifestPath }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: { recordedHashFor: () => hash, isTrusted: () => true },
+ });
+ assert.equal(result.admitted.length, 1);
+ assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`);
+ assert.notEqual(admittedExecutionAdapterFor('hermes'), null, 'registration must have succeeded with a real baseDir');
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+});
+
+// ── F-5: driving.surfaces must include 'cli-subprocess' ──────────────────
+
+test("F-5: a manifest whose driving.surfaces omits 'cli-subprocess' gets no execution adapter (surface-unsupported)", () => {
+ const manifest = hermesManifest({ driving: { surfaces: ['mcp'] } });
+ assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => {
+ assert.equal(error.reason, 'surface-unsupported');
+ return true;
+ });
+});
+
+test('F-5 (bootstrap): the execution-candidate filter refuses a non-cli-subprocess manifest with its own reason', async () => {
+ const manifest = hermesManifest({ driving: { surfaces: ['mcp'] } });
+ const { hashManifest } = await import('../../src/lib/adapters/admission.mjs');
+ const hash = hashManifest(manifest);
+ const result = await bootstrapHostAdapters({
+ cfg: { hostAdapters: [{ name: 'hermes', source: '/does/not/matter/manifest.json' }] },
+ env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' },
+ readManifest: async () => manifest,
+ consent: { recordedHashFor: () => hash, isTrusted: () => true },
+ });
+ assert.equal(result.admitted.length, 1);
+ const warning = result.warnings.find((w) => w.reason === 'surface-unsupported');
+ assert.ok(warning, `expected a 'surface-unsupported' warning; got ${JSON.stringify(result.warnings)}`);
+ assert.equal(admittedExecutionAdapterFor('hermes'), null);
+});
+
+// ── F-8: canRouteActivities is re-asserted at the construction site ──────
+
+test('F-8: a raw canRouteActivities:false host object is refused at construction (defence-in-depth)', () => {
+ const manifest = {
+ host: { id: 'hermes', capabilities: { canRouteActivities: false } },
+ detection: { bin: 'hermes' },
+ driving: { surfaces: ['cli-subprocess'] },
+ execution: { run: { hook: { command: ['hermes-run'] } } },
+ };
+ assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => {
+ assert.equal(error.reason, 'not-routable');
+ return true;
+ });
+});
+
+// ── F-9: paired overlay resets ────────────────────────────────────────────
+
+test('F-9: resetAllAdmitted() clears both the host overlay and the execution overlay together', () => {
+ const manifest = hermesManifest({ name: 'acme', host: validHost({ id: 'acme' }) });
+ applyAdmitted([{ entry: manifest.host }]);
+ registerAdmittedExecution(manifest);
+ assert.ok(effectiveHostRegistry().some((host) => host.id === 'acme'));
+ assert.notEqual(admittedExecutionAdapterFor('acme'), null);
+
+ resetAllAdmitted();
+
+ assert.ok(!effectiveHostRegistry().some((host) => host.id === 'acme'));
+ assert.equal(admittedExecutionAdapterFor('acme'), null);
+});
+
+// ── registry: overlay discipline mirrors adapters/admitted.mjs ──────────
+
+test('registerAdmittedExecution/admittedExecutionAdapterFor/resetAdmittedExecution round-trip, and a second register replaces', () => {
+ assert.equal(admittedExecutionAdapterFor('hermes'), null);
+ const first = registerAdmittedExecution(hermesManifest());
+ assert.equal(admittedExecutionAdapterFor('hermes'), first);
+
+ const second = registerAdmittedExecution(hermesManifest({ version: '1.0.1' }));
+ assert.notEqual(second, first);
+ assert.equal(admittedExecutionAdapterFor('hermes'), second, 'a second register replaces, not accumulates');
+
+ resetAdmittedExecution();
+ assert.equal(admittedExecutionAdapterFor('hermes'), null);
+});
+
+// ── seam: executionAdapterFor falls through to the admitted overlay ─────
+
+test('executionAdapterFor falls through to an admitted execution adapter for a non-built-in host', () => {
+ assert.equal(executionAdapterFor('hermes'), null);
+ const registered = registerAdmittedExecution(hermesManifest());
+ assert.equal(executionAdapterFor('hermes'), registered);
+});
+
+// ── byte-zero pins (flag/overlay off vs on) ──────────────────────────────
+
+test('byte-zero: with nothing admitted, executionAdapterFor is null and validateRoute refuses the host', () => {
+ assert.equal(executionAdapterFor('acme'), null);
+ assert.equal(isRoutableHost('acme'), false);
+ assert.ok(validateRoute({ host: 'acme' }).length > 0);
+});
+
+test('with the host + execution overlay applied, executionAdapterFor resolves and validateRoute accepts', () => {
+ const manifest = hermesManifest({ name: 'acme', host: validHost({ id: 'acme' }) });
+ applyAdmitted([{ entry: manifest.host }]);
+ registerAdmittedExecution(manifest);
+
+ assert.equal(isRoutableHost('acme'), true);
+ assert.deepEqual(validateRoute({ host: 'acme' }), []);
+ assert.notEqual(executionAdapterFor('acme'), null);
+});
diff --git a/tests/kit/adapter-grants.test.mjs b/tests/kit/adapter-grants.test.mjs
new file mode 100644
index 0000000..824d8fe
--- /dev/null
+++ b/tests/kit/adapter-grants.test.mjs
@@ -0,0 +1,449 @@
+// Unit tests for the hash-pinned capability-grant store (ADR-0031 §1, §2,
+// §4). Mirrors adapter-conformance.test.mjs's consent-store test patterns:
+// temp files, prototype-chain safety, corrupt/missing-file tolerance.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ CONFORMANCE_TIERS, TIER_GRANTS,
+ recordTierResult, recordTierGate, recordTierFailure, grantCapability, revokeGrants, revokeCapability,
+ grantsFor, grantedCapabilitiesFor, gatedTiersFor,
+} from '../../src/lib/adapters/grants.mjs';
+
+function tempFile() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-grants-'));
+ return path.join(dir, 'adapter-grants.json');
+}
+
+const HASH_A = 'a'.repeat(64);
+const HASH_B = 'b'.repeat(64);
+
+test('exports the five conformance tiers in graduation order', () => {
+ assert.deepEqual(CONFORMANCE_TIERS, [
+ 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline',
+ ]);
+});
+
+test('TIER_GRANTS only maps primary-eligible and statusline; aqeProvider never appears', () => {
+ assert.deepEqual(TIER_GRANTS, { 'primary-eligible': 'canBePrimary', statusline: 'commandStatusline' });
+ assert.ok(!Object.values(TIER_GRANTS).includes('aqeProvider'));
+});
+
+test('record -> grant happy path: passed tier at the same hash grants the capability', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run, escalated to' }, { file });
+ grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file });
+
+ const record = grantsFor('acme', { file });
+ assert.equal(record.hash, HASH_A);
+ assert.equal(record.tiers['primary-eligible'].status, 'passed');
+ assert.equal(record.tiers['primary-eligible'].evidence, 'led a run, escalated to');
+ assert.equal(record.capabilities.canBePrimary, true);
+ assert.ok(typeof record.grantedAt === 'string' && Number.isFinite(Date.parse(record.grantedAt)));
+
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true });
+});
+
+test('grantCapability refused: no tier recorded at all', () => {
+ const file = tempFile();
+ assert.throws(() => grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }), (error) => {
+ assert.match(error.message, /primary-eligible/);
+ return true;
+ });
+});
+
+test('grantCapability refused: tier passed but at a DIFFERENT hash', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ assert.throws(() => grantCapability('acme', 'canBePrimary', { hash: HASH_B }, { file }), (error) => {
+ assert.match(error.message, /primary-eligible/);
+ assert.match(error.message, new RegExp(HASH_B));
+ return true;
+ });
+ // and the never-recorded hash grants nothing either
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {});
+});
+
+test('grantCapability refused: aqeProvider is never a grantable capability', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ assert.throws(() => grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }), TypeError);
+});
+
+test('grantedCapabilitiesFor returns {} on hash mismatch', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file });
+ grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file });
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true });
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_B, { file }), {});
+});
+
+test('grantedCapabilitiesFor returns {} after a re-record at a new hash (edit-invalidation)', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file });
+ grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file });
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true });
+
+ // Manifest changed -> re-record at a new hash.
+ recordTierResult('acme', 'statusline', { hash: HASH_B, evidence: 'new footer render' }, { file });
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {});
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_B, { file }), {});
+});
+
+test('re-record at a new hash wipes prior tiers and capabilities entirely', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file });
+ recordTierResult('acme', 'session-driving', { hash: HASH_A }, { file });
+
+ let record = grantsFor('acme', { file });
+ assert.ok(record.tiers['primary-eligible']);
+ assert.ok(record.tiers['session-driving']);
+ assert.equal(record.capabilities.canBePrimary, true);
+
+ recordTierResult('acme', 'admission', { hash: HASH_B }, { file });
+ record = grantsFor('acme', { file });
+ assert.equal(record.hash, HASH_B);
+ assert.deepEqual(Object.keys(record.tiers), ['admission']);
+ assert.equal(record.capabilities, undefined);
+ assert.equal(record.grantedAt, undefined);
+});
+
+test('recordTierGate validates the gatedBy ref format', () => {
+ const file = tempFile();
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file });
+ recordTierGate('acme', 'statusline', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file });
+ const record = grantsFor('acme', { file });
+ assert.equal(record.tiers['primary-eligible'].status, 'gated');
+ assert.equal(record.tiers['primary-eligible'].gatedBy, 'agentic-qe#563');
+ assert.equal(record.tiers.statusline.gatedBy, 'ruvnet/ruflo#2962');
+
+ for (const bad of ['agentic-qe#0', 'no-hash', 'a#b', 'agentic-qe#', '#123', 'agentic-qe#01']) {
+ assert.throws(
+ () => recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: bad }, { file }),
+ TypeError,
+ `expected recordTierGate to reject gatedBy: ${bad}`,
+ );
+ }
+});
+
+test('gatedTiersFor lists gated entries and [] when none/missing', () => {
+ const file = tempFile();
+ assert.deepEqual(gatedTiersFor('acme', { file }), []);
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file });
+ recordTierResult('acme', 'admission', { hash: HASH_A }, { file });
+ const gated = gatedTiersFor('acme', { file });
+ assert.equal(gated.length, 1);
+ assert.equal(gated[0].tier, 'primary-eligible');
+ assert.equal(gated[0].gatedBy, 'agentic-qe#563');
+});
+
+test('revokeGrants: true when a record existed, false otherwise', () => {
+ const file = tempFile();
+ assert.equal(revokeGrants('acme', { file }), false);
+ recordTierResult('acme', 'admission', { hash: HASH_A }, { file });
+ assert.equal(revokeGrants('acme', { file }), true);
+ assert.equal(grantsFor('acme', { file }), null);
+ assert.equal(revokeGrants('acme', { file }), false);
+});
+
+test("revokeGrants on prototype-chain names ('constructor', '__proto__', 'toString') returns false on an empty store", () => {
+ const file = tempFile();
+ for (const protoName of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
+ assert.equal(revokeGrants(protoName, { file }), false,
+ `revokeGrants('${protoName}') on a never-recorded store must be false, not a prototype-chain hit`);
+ }
+ // A real recorded adapter must still revoke correctly.
+ recordTierResult('acme', 'admission', { hash: HASH_A }, { file });
+ assert.equal(revokeGrants('acme', { file }), true);
+});
+
+test('corrupt or missing file tolerance: grantsFor null, grantedCapabilitiesFor {}, gatedTiersFor []', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-grants-corrupt-'));
+ const missing = path.join(dir, 'does-not-exist.json');
+ assert.equal(grantsFor('acme', { file: missing }), null);
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file: missing }), {});
+ assert.deepEqual(gatedTiersFor('acme', { file: missing }), []);
+
+ const corrupt = path.join(dir, 'adapter-grants.json');
+ fs.writeFileSync(corrupt, '{ not valid json', 'utf8');
+ assert.equal(grantsFor('acme', { file: corrupt }), null);
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file: corrupt }), {});
+ assert.deepEqual(gatedTiersFor('acme', { file: corrupt }), []);
+});
+
+test('recordTierResult / recordTierGate throw TypeError on invalid name/tier/hash', () => {
+ const file = tempFile();
+ assert.throws(() => recordTierResult('', 'admission', { hash: HASH_A }, { file }), TypeError);
+ assert.throws(() => recordTierResult('acme', 'not-a-tier', { hash: HASH_A }, { file }), TypeError);
+ assert.throws(() => recordTierResult('acme', 'admission', { hash: '' }, { file }), TypeError);
+ assert.throws(() => recordTierGate('acme', 'not-a-tier', { hash: HASH_A, gatedBy: 'x#1' }, { file }), TypeError);
+ assert.throws(() => recordTierGate('acme', 'admission', { hash: '', gatedBy: 'x#1' }, { file }), TypeError);
+});
+
+test('evidence is bounded to 2048 characters', () => {
+ const file = tempFile();
+ const huge = 'x'.repeat(3000);
+ recordTierResult('acme', 'admission', { hash: HASH_A, evidence: huge }, { file });
+ const record = grantsFor('acme', { file });
+ assert.equal(record.tiers.admission.evidence.length, 2048);
+});
+
+// ── Finding 10: grantsFor/gatedTiersFor are reporting surfaces, not the
+// capability reader — currentHash lets them ANNOTATE/void staleness for a
+// status display without hiding it. grantedCapabilitiesFor stays the only
+// hash-blind-proof reader. ───────────────────────────────────────────────
+
+test('grantsFor: no currentHash -> no stale field at all (raw reporting)', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'admission', { hash: HASH_A }, { file });
+ const record = grantsFor('acme', { file });
+ assert.equal(Object.hasOwn(record, 'stale'), false);
+});
+
+test('grantsFor: currentHash supplied -> stale:false on match, stale:true on mismatch, record still returned either way', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'admission', { hash: HASH_A, evidence: 'e' }, { file });
+
+ const fresh = grantsFor('acme', { file, currentHash: HASH_A });
+ assert.equal(fresh.stale, false);
+ assert.equal(fresh.hash, HASH_A);
+ assert.ok(fresh.tiers.admission, 'stale annotation must not hide the underlying evidence');
+
+ const stale = grantsFor('acme', { file, currentHash: HASH_B });
+ assert.equal(stale.stale, true);
+ assert.equal(stale.hash, HASH_A);
+ assert.ok(stale.tiers.admission, 'a stale record must still be visible, just flagged, not hidden');
+});
+
+test('gatedTiersFor: currentHash omitted returns every gated tier regardless of hash', () => {
+ const file = tempFile();
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file });
+ assert.equal(gatedTiersFor('acme', { file }).length, 1);
+ assert.equal(gatedTiersFor('acme', { file, currentHash: HASH_A }).length, 1);
+});
+
+test('gatedTiersFor: currentHash mismatch voids gated-tier records, same as grants', () => {
+ const file = tempFile();
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file });
+ assert.deepEqual(gatedTiersFor('acme', { file, currentHash: HASH_B }), []);
+});
+
+// ── Finding 11: a grant-bearing tier ('primary-eligible', 'statusline')
+// must never be recorded 'passed' with empty evidence — a grant must always
+// trace back to real conformance evidence (ADR-0031 §1). ──────────────────
+
+test('recordTierResult on a grant-bearing tier requires non-empty evidence', () => {
+ const file = tempFile();
+ assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A }, { file }), TypeError);
+ assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: '' }, { file }), TypeError);
+ assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: ' ' }, { file }), TypeError);
+ assert.throws(() => recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: '' }, { file }), TypeError);
+ // nothing was recorded by any of the rejected attempts
+ assert.equal(grantsFor('acme', { file }), null);
+});
+
+test('recordTierResult on an evidence-only tier (admission) still accepts empty/missing evidence', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'admission', { hash: HASH_A }, { file });
+ const record = grantsFor('acme', { file });
+ assert.equal(record.tiers.admission.status, 'passed');
+ assert.equal(record.tiers.admission.evidence, '');
+});
+
+// ── F-1 (security review): grantedCapabilitiesFor re-derives the grant
+// invariant at READ time, not just at write time. adapter-grants.json is a
+// flat JSON file an operator (or a bad merge) can hand-edit directly —
+// grantCapability's write-time gate does not protect against that. A
+// capability must only ever be returned when it is a real TIER_GRANTS value
+// AND its gating tier is recorded 'passed' at the exact same hash. ─────────
+
+test('F-1: grantedCapabilitiesFor rejects a forged capability that has no matching passed tier, even at the correct hash', () => {
+ const file = tempFile();
+ // Simulate a hand-edited/forged/merged store: 'canBePrimary' sits in
+ // capabilities with NO 'primary-eligible' tier ever recorded passed.
+ recordTierResult('acme', 'admission', { hash: HASH_A }, { file });
+ const store = JSON.parse(fs.readFileSync(file, 'utf8'));
+ store.acme.capabilities = { canBePrimary: true };
+ fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8');
+
+ assert.deepEqual(
+ grantedCapabilitiesFor('acme', HASH_A, { file }),
+ {},
+ 'a forged capability with no backing passed tier must never be returned',
+ );
+});
+
+test("F-1: grantedCapabilitiesFor rejects a forged 'aqeProvider' key even though it is present in the raw store", () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file });
+ const store = JSON.parse(fs.readFileSync(file, 'utf8'));
+ store.acme.capabilities.aqeProvider = true; // forged — never written by grantCapability
+ fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8');
+
+ const granted = grantedCapabilitiesFor('acme', HASH_A, { file });
+ assert.deepEqual(granted, { canBePrimary: true }, 'the real grant still returns; the forged aqeProvider key must not');
+ assert.ok(!Object.hasOwn(granted, 'aqeProvider'));
+});
+
+test('F-1: grantedCapabilitiesFor rejects a capability whose gating tier was recorded but is not status "passed"', () => {
+ const file = tempFile();
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file });
+ const store = JSON.parse(fs.readFileSync(file, 'utf8'));
+ store.acme.capabilities = { canBePrimary: true }; // forged alongside a merely-gated tier
+ fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8');
+
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {});
+});
+
+// ── F-2 (security review): running `gate` AFTER a grant must downgrade the
+// live capability along with the tier — evidence and grant must never
+// disagree, or a status display would show a self-contradiction (a 'gated'
+// tier backing a still-live capability) and the audit trail would be lost.
+
+test('F-2: recordTierGate on a grant-bearing tier that currently backs a live capability drops that capability', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file });
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true });
+
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file });
+
+ const record = grantsFor('acme', { file });
+ assert.equal(record.tiers['primary-eligible'].status, 'gated');
+ assert.ok(!Object.hasOwn(record.capabilities ?? {}, 'canBePrimary'), 'the stored record must drop the capability, not just the read side');
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {});
+});
+
+test('F-2: recordTierGate on a grant-bearing tier leaves an UNRELATED live capability untouched', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file });
+ recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file });
+ grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file });
+
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file });
+
+ assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true });
+});
+
+test('F-2: recordTierGate on a tier with no live capability (never granted) is a no-op on capabilities', () => {
+ const file = tempFile();
+ recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file });
+ // No grantCapability call — the tier passed but was never granted.
+ recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file });
+ const record = grantsFor('acme', { file });
+ assert.equal(record.tiers['primary-eligible'].status, 'gated');
+ assert.equal(record.capabilities, undefined);
+});
+
+// ── 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-hook-runner.test.mjs b/tests/kit/adapter-hook-runner.test.mjs
index b0f7955..b29143a 100644
--- a/tests/kit/adapter-hook-runner.test.mjs
+++ b/tests/kit/adapter-hook-runner.test.mjs
@@ -127,6 +127,151 @@ test('a non-zero exit is reported as ok:false with the exit code preserved', asy
assert.equal(result.exitCode, 7);
});
+// --- stdin (P2, ADR-0031: worker prompt delivery) ---------------------------
+
+test('stdin: a payload written to the child is delivered and echoed back', async () => {
+ const payload = 'the worker prompt, rendered task + handoffs';
+ const result = await runAdapterHook({
+ hook: { command: [NODE, '-e', 'process.stdin.pipe(process.stdout)'] },
+ hostId: 'claude', verb: 'run', stdin: payload,
+ });
+ assert.equal(result.ok, true);
+ assert.equal(result.stdout, payload);
+});
+
+test('stdin: a child that exits without reading stdin (EPIPE) still yields a normal result', async () => {
+ // A large payload makes it likely the write is still in flight (or at
+ // least unread) when the child exits, so the pipe's write side sees EPIPE.
+ const bigPayload = 'x'.repeat(4 * 1024 * 1024);
+ const result = await runAdapterHook({
+ hook: { command: [NODE, '-e', 'process.exit(3)'] },
+ hostId: 'claude', verb: 'run', stdin: bigPayload,
+ });
+ assert.equal(result.ok, false);
+ assert.equal(result.exitCode, 3);
+});
+
+test('stdin: absent stdin keeps the existing ignore behavior (reading it yields EOF, not a hang)', async () => {
+ const result = await runAdapterHook({
+ hook: {
+ command: [NODE, '-e', "let n=0; process.stdin.on('data', () => { n++; }); process.stdin.on('end', () => console.log('eof', n));"],
+ },
+ hostId: 'claude', verb: 'discover', timeoutMs: 5000,
+ });
+ assert.equal(result.ok, true);
+ assert.ok(result.stdout.includes('eof 0'), result.stdout);
+});
+
+// --- cwd (F-1: pin the child to the adapter's own directory, never the
+// operator's process.cwd() — a relative hook.command like `node
+// run-hook.mjs` must resolve against the adapter bundle, not wherever `ak
+// run` happened to be invoked from) --------------------------------------
+
+test('cwd: when provided, the child actually runs there — not in process.cwd()', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-hook-cwd-'));
+ try {
+ const target = fs.realpathSync(dir); // resolve /private symlink on macOS
+ const result = await runAdapterHook({
+ hook: { command: [NODE, '-e', 'console.log(process.cwd())'] },
+ hostId: 'claude', verb: 'run', cwd: target,
+ });
+ assert.equal(result.ok, true);
+ assert.equal(result.stdout.trim(), target);
+ assert.notEqual(result.stdout.trim(), process.cwd());
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('cwd: a relative path is refused synchronously, same class as the other arg-shape checks', async () => {
+ await assert.rejects(
+ () => runAdapterHook({
+ hook: { command: [NODE, '-e', "console.log('should not run')"] },
+ hostId: 'claude', verb: 'run', cwd: 'relative/dir',
+ }),
+ TypeError,
+ );
+});
+
+test('cwd: a non-string cwd is refused synchronously', async () => {
+ await assert.rejects(
+ () => runAdapterHook({
+ hook: { command: [NODE, '-e', "console.log('should not run')"] },
+ hostId: 'claude', verb: 'run', cwd: 42,
+ }),
+ TypeError,
+ );
+});
+
+test('cwd: omitted keeps the existing inherited-process.cwd() behavior', async () => {
+ const result = await runAdapterHook({
+ hook: { command: [NODE, '-e', 'console.log(process.cwd())'] },
+ hostId: 'claude', verb: 'discover',
+ });
+ assert.equal(result.ok, true);
+ assert.equal(result.stdout.trim(), process.cwd());
+});
+
+// --- stderrText (F-4: stderr must never be silently folded into a caller's
+// parse of `stdout` — the admitted execution adapter reads the payload from
+// stdout alone and diagnostics from stderrText, never a blended blob) ------
+
+test('stderrText: carries only stderr, independent of the combined stdout field', async () => {
+ const result = await runAdapterHook({
+ hook: {
+ command: [NODE, '-e', "process.stdout.write('PAYLOAD'); process.stderr.write('noisy diagnostic log line');"],
+ },
+ hostId: 'claude', verb: 'run',
+ });
+ assert.equal(result.ok, true);
+ assert.equal(result.stderrText, 'noisy diagnostic log line');
+ // the combined `stdout` field still folds both streams together, unchanged
+ // back-compat behavior for lifecycle callers/diagnostics that read it.
+ assert.ok(result.stdout.includes('PAYLOAD'));
+ assert.ok(result.stdout.includes('noisy diagnostic log line'));
+});
+
+test('stderrText: empty when the hook writes nothing to stderr', async () => {
+ const result = await runAdapterHook({
+ hook: { command: [NODE, '-e', "console.log('only stdout')"] },
+ hostId: 'claude', verb: 'run',
+ });
+ assert.equal(result.ok, true);
+ assert.equal(result.stderrText, '');
+});
+
+// --- stdoutText (R-1: mergeCapture's `stdout` folds stderr in after a
+// separator, so a JSON.parse of `stdout` breaks the instant the hook writes
+// ANYTHING to stderr — a warning, a deprecation notice, a logging library
+// defaulting to stderr. stdoutText is the unmerged, stdout-only capture the
+// admitted adapter must parse the payload from instead.) ------------------
+
+test('stdoutText: a JSON payload on stdout plus a warning on stderr — stdoutText parses cleanly, stdout does not', async () => {
+ const result = await runAdapterHook({
+ hook: {
+ command: [NODE, '-e', "process.stdout.write(JSON.stringify({summary:'ok'})); process.stderr.write('deprecation warning');"],
+ },
+ hostId: 'claude', verb: 'run',
+ });
+ assert.equal(result.ok, true);
+ assert.deepEqual(JSON.parse(result.stdoutText), { summary: 'ok' });
+ assert.equal(result.stderrText, 'deprecation warning');
+ // the combined `stdout` field is unchanged back-compat behavior — it folds
+ // both streams, so parsing IT as JSON is exactly the break R-1 fixes.
+ assert.throws(() => JSON.parse(result.stdout));
+ assert.ok(result.stdout.includes('deprecation warning'));
+});
+
+test('stdoutText: equals the plain stdout capture when the hook writes nothing to stderr', async () => {
+ const result = await runAdapterHook({
+ hook: { command: [NODE, '-e', "console.log('only stdout')"] },
+ hostId: 'claude', verb: 'run',
+ });
+ assert.equal(result.ok, true);
+ assert.equal(result.stdoutText.trim(), 'only stdout');
+ assert.equal(result.stdout, result.stdoutText);
+});
+
// --- consent.mjs -----------------------------------------------------------
function sandboxFile() {
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/adapter-manifest.test.mjs b/tests/kit/adapter-manifest.test.mjs
index 33ca128..70aa475 100644
--- a/tests/kit/adapter-manifest.test.mjs
+++ b/tests/kit/adapter-manifest.test.mjs
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import {
validateAdapterManifest, DRIVING_SURFACES, MANIFEST_TRUST_KINDS, ManifestRejected,
} from '../../src/lib/adapters/manifest.mjs';
+import { hashManifest } from '../../src/lib/adapters/admission.mjs';
function validHost(overrides = {}) {
return {
@@ -281,3 +282,70 @@ test('unknown-field: an extra or miscased capability key cannot ride along inert
rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, CanBePrimary: true } }) }), 'unknown-field');
rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, ADMIN: true } }) }), 'unknown-field');
});
+
+// ── execution block (P2, ADR-0031) ──────────────────────────────────────────
+// validHost()'s default capabilities already declare canRouteActivities:true,
+// so an execution block is legal by default; the coupling tests below flip
+// that flag explicitly to exercise both directions.
+
+function withExecution(overrides = {}) {
+ return {
+ run: { hook: { command: ['acme', 'run'], timeoutMs: 5000 } },
+ ...overrides,
+ };
+}
+
+test('execution block round-trips into the validated output', () => {
+ const manifest = validateAdapterManifest(validManifest({ execution: withExecution() }));
+ assert.deepEqual(manifest.execution.run.hook.command, ['acme', 'run']);
+ assert.equal(manifest.execution.run.hook.timeoutMs, 5000);
+ assert.ok(Object.isFrozen(manifest.execution));
+});
+
+test('a manifest with no execution block omits the key entirely', () => {
+ const manifest = validateAdapterManifest(validManifest());
+ assert.equal('execution' in manifest, false);
+});
+
+test('an execution block changes hashManifest\'s value vs. the same manifest without it', () => {
+ const withoutExecution = validateAdapterManifest(validManifest());
+ const withExecutionBlock = validateAdapterManifest(validManifest({ execution: withExecution() }));
+ assert.notEqual(hashManifest(withoutExecution), hashManifest(withExecutionBlock));
+});
+
+test('execution-not-routable: an execution block requires host.capabilities.canRouteActivities:true', () => {
+ rejects(validManifest({
+ host: validHost({ capabilities: { ...validHost().capabilities, canRouteActivities: false } }),
+ execution: withExecution(),
+ }), 'execution-not-routable');
+});
+
+test('canRouteActivities:true without an execution block still validates (legal degrade-at-runtime)', () => {
+ const manifest = validateAdapterManifest(validManifest({
+ host: validHost({ capabilities: { ...validHost().capabilities, canRouteActivities: true } }),
+ }));
+ assert.equal(manifest.host.capabilities.canRouteActivities, true);
+ assert.equal('execution' in manifest, false);
+});
+
+test('unknown-field: an extraneous execution key is refused', () => {
+ rejects(validManifest({ execution: { run: withExecution().run, escalate: true } }), 'unknown-field');
+});
+
+test('unknown-field: an extraneous execution.run key is refused', () => {
+ rejects(validManifest({ execution: { run: { hook: withExecution().run.hook, cwd: '/tmp' } } }), 'unknown-field');
+});
+
+test('unknown-field: an extraneous execution.run.hook key is refused', () => {
+ rejects(validManifest({
+ execution: { run: { hook: { command: ['acme', 'run'], cwd: '/tmp' } } },
+ }), 'unknown-field');
+});
+
+test('invalid-execution: an empty command array is rejected', () => {
+ rejects(validManifest({ execution: withExecution({ run: { hook: { command: [] } } }) }), 'invalid-execution');
+});
+
+test('invalid-execution: a non-positive timeoutMs is rejected', () => {
+ rejects(validManifest({ execution: withExecution({ run: { hook: { command: ['acme', 'run'], timeoutMs: 0 } } }) }), 'invalid-execution');
+});
diff --git a/tests/kit/adapter-sources.test.mjs b/tests/kit/adapter-sources.test.mjs
new file mode 100644
index 0000000..70fd269
--- /dev/null
+++ b/tests/kit/adapter-sources.test.mjs
@@ -0,0 +1,572 @@
+// Remote manifest sources (ADR-0031 P6). All offline: fetchFn/execFileFn are
+// always injected, no real network or npm/tar invocation happens. Verifies
+// the three source forms (file / https / npm), their failure modes each
+// mapped to a named SourceError.reason, and — via admitAdapters — that the
+// resolve-before-hash ordering makes a mutated remote source show up as
+// 'consent-stale' rather than being silently re-trusted.
+//
+// Also carries regression coverage for the security-review fix-first pass:
+// (1) a hanging https body read must be bounded by timeoutMs, not hang
+// forever; (2) npm tar extraction never touches disk (stdout-only);
+// (6) file sources refuse symlinks/directories and enforce maxBytes;
+// (7) the npm version/tag half is validated, not just character-classed;
+// (14) external text is sanitized (control chars stripped, length bounded)
+// before it enters a SourceError detail.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import fsSync from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { resolveManifestSource, SourceError } from '../../src/lib/adapters/sources.mjs';
+import { admitAdapters, hashManifest } from '../../src/lib/adapters/admission.mjs';
+import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs';
+
+function validHost(overrides = {}) {
+ return {
+ id: 'hermes',
+ label: 'Hermes',
+ install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' },
+ capabilities: {
+ canDriveSession: true, canBePrimary: false, canRouteActivities: true,
+ commandStatusline: false, transcripts: true, usage: false,
+ nativeMcpConfig: false, nativeGuidance: false,
+ },
+ trust: { approvalPolicy: 'unchanged', changes: [] },
+ enabledByDefault: false,
+ configProjection: 'ruflo',
+ observability: [],
+ ...overrides,
+ };
+}
+
+function validManifest(overrides = {}) {
+ return {
+ name: 'hermes',
+ version: '1.0.0',
+ contract: 1,
+ host: validHost(),
+ detection: { bin: 'hermes' },
+ driving: { surfaces: ['acp'] },
+ trust: {
+ changes: [{
+ id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project',
+ owner: 'hermes', value: 'subprocess hooks', effect: 'run consented lifecycle hooks for hermes',
+ }],
+ },
+ ...overrides,
+ };
+}
+
+function trustingConsent(trusted = {}) {
+ return {
+ recordedHashFor: (name) => trusted[name] ?? null,
+ isTrusted: (name, hash) => trusted[name] === hash,
+ };
+}
+
+/** Builds a fetch-shaped Response stub: ok/status/headers.get/body.getReader
+ * over the given bytes, delivered as a single chunk. */
+function jsonResponse(payload, { status = 200, declareLength = true } = {}) {
+ const bytes = new TextEncoder().encode(JSON.stringify(payload));
+ return bytesResponse(bytes, { status, declareLength });
+}
+
+function textResponse(text, { status = 200, declareLength = true } = {}) {
+ return bytesResponse(new TextEncoder().encode(text), { status, declareLength });
+}
+
+function bytesResponse(bytes, { status = 200, declareLength = true } = {}) {
+ let sent = false;
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ type: 'basic',
+ headers: { get: (name) => (declareLength && name.toLowerCase() === 'content-length' ? String(bytes.length) : null) },
+ body: {
+ getReader: () => ({
+ read: async () => {
+ if (sent) return { done: true, value: undefined };
+ sent = true;
+ return { done: false, value: bytes };
+ },
+ cancel: async () => {},
+ }),
+ },
+ };
+}
+
+/** A reader that yields multiple chunks, exceeding maxBytes only once all
+ * are combined (so a Content-Length precheck can't catch it — it must be
+ * caught by the streamed cap instead). Tracks whether cancel() was called. */
+function chunkedOversizeResponse(chunks) {
+ let index = 0;
+ let cancelled = false;
+ return {
+ response: {
+ ok: true,
+ status: 200,
+ type: 'basic',
+ headers: { get: () => null }, // no Content-Length declared
+ body: {
+ getReader: () => ({
+ read: async () => {
+ if (index >= chunks.length) return { done: true, value: undefined };
+ const value = chunks[index++];
+ return { done: false, value };
+ },
+ cancel: async () => { cancelled = true; },
+ }),
+ },
+ },
+ wasCancelled: () => cancelled,
+ };
+}
+
+/** A response whose body reader hangs forever — never resolves, never
+ * rejects, and never observes any signal itself. Used to prove
+ * resolveHttpsSource bounds the BODY READ, not just the initial fetch
+ * (regression for finding 1). */
+function hangingBodyResponse() {
+ return {
+ ok: true,
+ status: 200,
+ type: 'basic',
+ headers: { get: () => null },
+ body: {
+ getReader: () => ({
+ read: () => new Promise(() => {}),
+ cancel: async () => {},
+ }),
+ },
+ };
+}
+
+const neverCalled = (label) => (...args) => { throw new Error(`${label} must not be called (args: ${JSON.stringify(args)})`); };
+
+// ── file source ──────────────────────────────────────────────────────────
+
+test('file path source: passthrough read + JSON.parse, no network', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-'));
+ const file = path.join(dir, 'manifest.json');
+ const payload = { name: 'hermes', contract: 1 };
+ await fs.writeFile(file, JSON.stringify(payload), 'utf8');
+ try {
+ const result = await resolveManifestSource(file, { fetchFn: neverCalled('fetchFn') });
+ assert.deepEqual(result.raw, payload);
+ assert.equal(result.origin, 'file');
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('file path source: missing file -> source-unreachable', async () => {
+ await assert.rejects(
+ () => resolveManifestSource('/nonexistent/path/does-not-exist.json'),
+ (error) => error instanceof SourceError && error.reason === 'source-unreachable',
+ );
+});
+
+test('file source (finding 6): a symlink is refused, not followed, even to a valid manifest', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-symlink-'));
+ const real = path.join(dir, 'real.json');
+ const link = path.join(dir, 'link.json');
+ await fs.writeFile(real, JSON.stringify(validManifest()), 'utf8');
+ await fs.symlink(real, link);
+ try {
+ await assert.rejects(
+ () => resolveManifestSource(link),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid',
+ );
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('file source (finding 6): a directory is refused', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-dir-'));
+ try {
+ await assert.rejects(
+ () => resolveManifestSource(dir),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid',
+ );
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('file source (finding 6): an oversized file is refused before being fully read', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-big-'));
+ const file = path.join(dir, 'big.json');
+ await fs.writeFile(file, JSON.stringify({ padding: 'x'.repeat(1000) }), 'utf8');
+ try {
+ await assert.rejects(
+ () => resolveManifestSource(file, { maxBytes: 100 }),
+ (error) => error instanceof SourceError && error.reason === 'source-too-large',
+ );
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+// ── https:// source ──────────────────────────────────────────────────────
+
+test('http:// is refused as source-insecure, before any fetch', async () => {
+ await assert.rejects(
+ () => resolveManifestSource('http://example.com/manifest.json', { fetchFn: neverCalled('fetchFn') }),
+ (error) => error instanceof SourceError && error.reason === 'source-insecure',
+ );
+});
+
+test('a 3xx redirect response is refused, not followed', async () => {
+ const fetchFn = async () => ({
+ type: 'opaqueredirect', status: 0, ok: false, headers: { get: () => null }, body: null,
+ });
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }),
+ (error) => error instanceof SourceError && error.reason === 'source-unreachable'
+ && /redirects are not followed/.test(error.message),
+ );
+});
+
+test('a plain 3xx status (non-opaque) is also refused', async () => {
+ const fetchFn = async () => ({
+ type: 'basic', status: 302, ok: false, headers: { get: () => null }, body: null,
+ });
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }),
+ (error) => error instanceof SourceError && error.reason === 'source-unreachable',
+ );
+});
+
+test('declared Content-Length above maxBytes is rejected before any body read', async () => {
+ let readerCreated = false;
+ const fetchFn = async () => ({
+ ok: true, status: 200, type: 'basic',
+ headers: { get: (name) => (name.toLowerCase() === 'content-length' ? '999999' : null) },
+ body: { getReader: () => { readerCreated = true; return { read: neverCalled('reader.read') }; } },
+ });
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, maxBytes: 100 }),
+ (error) => error instanceof SourceError && error.reason === 'source-too-large',
+ );
+ assert.equal(readerCreated, false, 'the body must never be read once Content-Length alone exceeds the cap');
+});
+
+test('an oversized streamed body (no declared Content-Length) is caught by the streamed cap and cancels the reader', async () => {
+ const chunk = new TextEncoder().encode('x'.repeat(8));
+ const { response, wasCancelled } = chunkedOversizeResponse([chunk, chunk]); // 16 bytes total
+ const fetchFn = async () => response;
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, maxBytes: 10 }),
+ (error) => error instanceof SourceError && error.reason === 'source-too-large',
+ );
+ assert.equal(wasCancelled(), true, 'the reader must be cancelled the moment the cap is crossed');
+});
+
+test('a timeout during the initial fetch aborts and reports source-unreachable', async () => {
+ const fetchFn = (url, opts) => new Promise((_, reject) => {
+ opts.signal.addEventListener('abort', () => {
+ const error = new Error('The operation was aborted');
+ error.name = 'AbortError';
+ reject(error);
+ });
+ });
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, timeoutMs: 20 }),
+ (error) => error instanceof SourceError && error.reason === 'source-unreachable' && /timed out/.test(error.message),
+ );
+});
+
+test('regression (finding 1): a hanging body read is bounded by timeoutMs, not left to hang forever', async () => {
+ const fetchFn = async () => hangingBodyResponse();
+ const start = Date.now();
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, timeoutMs: 300 }),
+ (error) => error instanceof SourceError && error.reason === 'source-unreachable' && /timed out/.test(error.message),
+ );
+ const elapsed = Date.now() - start;
+ assert.ok(elapsed < 1500, `expected the body-read hang to be bounded by timeoutMs, took ${elapsed}ms`);
+});
+
+test('invalid JSON body -> source-invalid-json', async () => {
+ const fetchFn = async () => textResponse('{ this is not valid json');
+ await assert.rejects(
+ () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid-json',
+ );
+});
+
+test('a valid https manifest resolves with origin "url"', async () => {
+ const payload = validManifest();
+ const fetchFn = async () => jsonResponse(payload);
+ const result = await resolveManifestSource('https://example.com/manifest.json', { fetchFn });
+ assert.deepEqual(result.raw, payload);
+ assert.equal(result.origin, 'url');
+});
+
+// ── npm: source ─────────────────────────────────────────────────────
+
+/** Injected into every npm-path test in place of the real resolveShim
+ * (src/lib/exec.mjs). On Windows, the real resolveShim rewrites npm/tar
+ * into PowerShell-shim invocations (a different command and a different
+ * argv shape), which would make these execFileFn stubs — written against
+ * the LOGICAL invocation (cmd === 'npm'/'tar', args starting with
+ * 'pack'/'-xzOf') — see something they don't recognize and fail, even
+ * though production behavior is correct. That Windows-shim rewriting is
+ * already covered by exec.mjs's own test suite; this module's tests only
+ * need to prove sources.mjs's own logic, platform-independently. */
+const passthroughResolveShim = (command, args) => ({ command, args, resolved: true });
+
+/** Stub execFileFn that fabricates what real npm+tar would have produced:
+ * `npm pack` "writes" a .tgz into --pack-destination, and
+ * `tar -xzOf package/ak-adapter.json` "extracts" by returning the
+ * member content on stdout — never touching disk, matching the real
+ * implementation's stdout-only extraction (finding 2). No real npm/tar
+ * binary or network is invoked.
+ * - failTar: simulate tar exiting non-zero (member not in the archive).
+ * - emptyMember: simulate a symlink/non-regular member — tar succeeds
+ * (exit 0) but produces no data on stdout. */
+function fakeNpmExecFileFn(payload, { failTar = false, emptyMember = false } = {}) {
+ return async (_cmd, args) => {
+ if (args[0] === 'pack') {
+ const destIndex = args.indexOf('--pack-destination');
+ const dest = args[destIndex + 1];
+ fsSync.writeFileSync(path.join(dest, 'fake-pkg-1.0.0.tgz'), 'fake tarball bytes');
+ return { stdout: '', stderr: '' };
+ }
+ if (args[0] === '-xzOf') {
+ if (failTar) throw Object.assign(new Error('tar: package/ak-adapter.json not found in archive'), { code: 2 });
+ if (emptyMember) return { stdout: '', stderr: '' };
+ return { stdout: JSON.stringify(payload), stderr: '' };
+ }
+ throw new Error(`unexpected command: ${args.join(' ')}`);
+ };
+}
+
+test('npm: happy path — resolves ak-adapter.json extracted (via stdout) from the package root, and cleans up its temp dir', async () => {
+ const payload = validManifest();
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-'));
+ try {
+ const result = await resolveManifestSource('npm:fake-pkg@1.0.0', {
+ execFileFn: fakeNpmExecFileFn(payload),
+ resolveShimFn: passthroughResolveShim,
+ tmpDir: tmpBase,
+ });
+ assert.deepEqual(result.raw, payload);
+ assert.equal(result.origin, 'npm');
+ const remaining = await fs.readdir(tmpBase);
+ assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed after a successful resolve');
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm: scoped package spec with a version resolves correctly', async () => {
+ const payload = validManifest();
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-scoped-'));
+ try {
+ const result = await resolveManifestSource('npm:@acme/hermes-adapter@2.1.0', {
+ execFileFn: fakeNpmExecFileFn(payload),
+ resolveShimFn: passthroughResolveShim,
+ tmpDir: tmpBase,
+ });
+ assert.deepEqual(result.raw, payload);
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm: exact semver version is accepted', async () => {
+ const payload = validManifest();
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-ver-'));
+ try {
+ const result = await resolveManifestSource('npm:fake-pkg@1.2.3', {
+ execFileFn: fakeNpmExecFileFn(payload), resolveShimFn: passthroughResolveShim, tmpDir: tmpBase,
+ });
+ assert.deepEqual(result.raw, payload);
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm: a dist-tag version ("latest") is accepted', async () => {
+ const payload = validManifest();
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-tag-'));
+ try {
+ const result = await resolveManifestSource('npm:fake-pkg@latest', {
+ execFileFn: fakeNpmExecFileFn(payload), resolveShimFn: passthroughResolveShim, tmpDir: tmpBase,
+ });
+ assert.deepEqual(result.raw, payload);
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm (finding 7): a git-shorthand disguised as a version is rejected before execFileFn is called', async () => {
+ await assert.rejects(
+ () => resolveManifestSource('npm:pkg@attacker/repo', {
+ execFileFn: neverCalled('execFileFn'), resolveShimFn: passthroughResolveShim,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid'
+ && /not a version or dist-tag/.test(error.message),
+ );
+});
+
+test('npm (finding 7): a local-path-shaped version is rejected before execFileFn is called', async () => {
+ await assert.rejects(
+ () => resolveManifestSource('npm:pkg@../../x', {
+ execFileFn: neverCalled('execFileFn'), resolveShimFn: passthroughResolveShim,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid',
+ );
+});
+
+test('npm: package-name injection attempts are rejected before execFileFn is ever called', async () => {
+ const attempts = ['npm:foo; rm -rf /', 'npm:foo$(x)', 'npm:foo bar', 'npm:foo`x`', 'npm:foo|bar'];
+ for (const source of attempts) {
+ await assert.rejects(
+ () => resolveManifestSource(source, {
+ execFileFn: neverCalled(`execFileFn for ${source}`), resolveShimFn: passthroughResolveShim,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid',
+ `expected ${source} to be rejected as source-invalid`,
+ );
+ }
+});
+
+test('npm (finding 2): tar exiting non-zero (member not in archive) -> source-invalid, temp dir cleaned up', async () => {
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-missing-'));
+ try {
+ await assert.rejects(
+ () => resolveManifestSource('npm:fake-pkg@1.0.0', {
+ execFileFn: fakeNpmExecFileFn({}, { failTar: true }),
+ resolveShimFn: passthroughResolveShim,
+ tmpDir: tmpBase,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid'
+ && /does not ship an ak-adapter\.json/.test(error.message),
+ );
+ const remaining = await fs.readdir(tmpBase);
+ assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed even on a failure path');
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm (finding 2): a symlink member (empty stdout, tar exits 0) is refused explicitly and honestly', async () => {
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-symlink-'));
+ try {
+ await assert.rejects(
+ () => resolveManifestSource('npm:fake-pkg@1.0.0', {
+ execFileFn: fakeNpmExecFileFn({}, { emptyMember: true }),
+ resolveShimFn: passthroughResolveShim,
+ tmpDir: tmpBase,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-invalid'
+ && /produced no content/.test(error.message),
+ );
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm: npm pack itself failing -> source-unreachable, temp dir cleaned up', async () => {
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-packfail-'));
+ const execFileFn = async () => { throw new Error('npm ERR! 404 Not Found'); };
+ try {
+ await assert.rejects(
+ () => resolveManifestSource('npm:does-not-exist@9.9.9', {
+ execFileFn, resolveShimFn: passthroughResolveShim, tmpDir: tmpBase,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-unreachable',
+ );
+ const remaining = await fs.readdir(tmpBase);
+ assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed even when npm pack fails');
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('npm (finding 5): oversized extracted manifest (stdout) -> source-too-large', async () => {
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-oversize-'));
+ const bigPayload = { name: 'hermes', padding: 'x'.repeat(1000) };
+ try {
+ await assert.rejects(
+ () => resolveManifestSource('npm:fake-pkg@1.0.0', {
+ execFileFn: fakeNpmExecFileFn(bigPayload),
+ resolveShimFn: passthroughResolveShim,
+ tmpDir: tmpBase,
+ maxBytes: 100,
+ }),
+ (error) => error instanceof SourceError && error.reason === 'source-too-large',
+ );
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+test('regression (finding 14): control characters (C0 and C1, incl. U+009B CSI) are stripped and detail length is bounded before entering a SourceError', async () => {
+ const dirty = `line one\nline two\x1B[8A\x9B2Jmalicious cursor move${'z'.repeat(500)}`;
+ const execFileFn = async () => { throw new Error(dirty); };
+ const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-dirty-'));
+ try {
+ await assert.rejects(
+ () => resolveManifestSource('npm:fake-pkg@1.0.0', {
+ execFileFn, resolveShimFn: passthroughResolveShim, tmpDir: tmpBase,
+ }),
+ (error) => {
+ assert.ok(error instanceof SourceError);
+ assert.ok(!error.message.includes('\x1B'), 'ESC control byte must be stripped');
+ assert.ok(!error.message.includes('\x9b'), 'C1 CSI (U+009B) must be stripped');
+ assert.ok(!error.message.includes('\n'), 'newline must be stripped');
+ assert.ok(error.message.length < 300, `detail must be length-bounded, got ${error.message.length}`);
+ return true;
+ },
+ );
+ } finally {
+ await fs.rm(tmpBase, { recursive: true, force: true });
+ }
+});
+
+// ── integration: admitAdapters + resolve-before-hash ordering ──────────────
+
+test('admitAdapters: a mutated https-sourced manifest since consent was recorded is refused as consent-stale', async () => {
+ const first = validManifest();
+ const second = validManifest({ version: '1.0.1' }); // valid, but different content -> different hash
+
+ // Simulate: consent was recorded against an EARLIER resolve of `first`.
+ const firstValidated = validateAdapterManifest(first);
+ const consentedHash = hashManifest(firstValidated);
+
+ // By the time admission actually runs, the remote now serves `second`.
+ const fetchFn = async () => jsonResponse(second);
+ const readManifest = (source) => resolveManifestSource(source, { fetchFn }).then((r) => r.raw);
+
+ const results = await admitAdapters({
+ cfg: { hostAdapters: [{ name: 'hermes', source: 'https://example.com/hermes.json' }] },
+ readManifest,
+ consent: trustingConsent({ hermes: consentedHash }),
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].admitted, false);
+ assert.equal(results[0].reason, 'consent-stale');
+});
+
+test('admitAdapters: an unchanged https-sourced manifest matching consent is admitted', async () => {
+ const manifest = validManifest();
+ const validated = validateAdapterManifest(manifest);
+ const hash = hashManifest(validated);
+
+ const fetchFn = async () => jsonResponse(manifest);
+ const readManifest = (source) => resolveManifestSource(source, { fetchFn }).then((r) => r.raw);
+
+ const results = await admitAdapters({
+ cfg: { hostAdapters: [{ name: 'hermes', source: 'https://example.com/hermes.json' }] },
+ readManifest,
+ consent: trustingConsent({ hermes: hash }),
+ });
+
+ assert.equal(results[0].admitted, true);
+});
diff --git a/tests/kit/admitted-grants.test.mjs b/tests/kit/admitted-grants.test.mjs
new file mode 100644
index 0000000..a0123b0
--- /dev/null
+++ b/tests/kit/admitted-grants.test.mjs
@@ -0,0 +1,180 @@
+// The D2 keystone (ADR-0031 §1): a maintainer-granted capability
+// (canBePrimary/commandStatusline) must become LIVE in effectiveHostRegistry()
+// once applyAdmitted is handed a per-host grantsByName lookup — and must be
+// impossible to smuggle anything else through that same seam. This file tests
+// admitted.mjs's own overlay guarantee in isolation, with an injected
+// grantsByName object (no fs, no grants.mjs) — the full bootstrap ->
+// grants.mjs -> applyAdmitted wiring (including the manifest-hash-mismatch
+// edit-invalidation case) is covered in adapter-admission.test.mjs instead.
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAdmitted, resetAdmitted, effectiveHostRegistry, effectivePrimaryHostIds,
+} from '../../src/lib/adapters/admitted.mjs';
+import { HOST_REGISTRY, primaryHostIds } from '../../src/lib/adapters/registries.mjs';
+import { hostTierLabel } from '../../src/lib/hosts.mjs';
+
+beforeEach(() => resetAdmitted());
+
+/** A validated, admittable host entry — canBePrimary/commandStatusline start
+ * false (the manifest floor: external adapters may never self-declare
+ * either, see manifest.mjs's cap-can-be-primary/cap-command-statusline
+ * refusals), so every capability these tests observe as true came from the
+ * grant overlay, never the manifest. */
+function admittedHost(overrides = {}) {
+ return {
+ id: 'hermes',
+ label: 'Hermes',
+ install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' },
+ capabilities: {
+ canDriveSession: true, canBePrimary: false, canRouteActivities: true,
+ commandStatusline: false, transcripts: true, usage: false,
+ nativeMcpConfig: false, nativeGuidance: false,
+ },
+ trust: { approvalPolicy: 'unchanged', changes: [] },
+ enabledByDefault: false,
+ configProjection: 'ruflo',
+ observability: [],
+ ...overrides,
+ };
+}
+
+// ── flag-off / nothing-admitted byte-identity ───────────────────────────────
+
+test('nothing admitted: effectiveHostRegistry() is HOST_REGISTRY by identity, effectivePrimaryHostIds() == primaryHostIds()', () => {
+ assert.equal(effectiveHostRegistry(), HOST_REGISTRY);
+ assert.deepEqual(effectivePrimaryHostIds(), primaryHostIds());
+});
+
+test('applyAdmitted with no grantsByName leaves capabilities at the manifest floor (byte-identical entry)', () => {
+ applyAdmitted([{ entry: admittedHost() }]);
+ const entry = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(entry.capabilities.canBePrimary, false);
+ assert.equal(entry.capabilities.commandStatusline, false);
+ assert.equal(effectivePrimaryHostIds().includes('hermes'), false);
+ assert.deepEqual([...effectivePrimaryHostIds()].sort(), [...primaryHostIds()].sort());
+});
+
+test('applyAdmitted({grantsByName: {}}) — no entry for this host — is identical to no grantsByName at all', () => {
+ applyAdmitted([{ entry: admittedHost() }], { grantsByName: {} });
+ const entry = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(entry.capabilities.canBePrimary, false);
+});
+
+// ── the keystone: a granted capability lights up ────────────────────────────
+
+test('a granted canBePrimary makes effectiveHostRegistry() show it true, and effectivePrimaryHostIds() include the host', () => {
+ applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } });
+ const entry = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(entry.capabilities.canBePrimary, true);
+ assert.ok(effectivePrimaryHostIds().includes('hermes'));
+});
+
+test('a granted canBePrimary lights up hostTierLabel — "drives sessions · can lead"', () => {
+ applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } });
+ assert.equal(hostTierLabel('hermes'), 'drives sessions · can lead');
+});
+
+test('without the grant, hostTierLabel reflects the ungranted (routing-only external) tier', () => {
+ applyAdmitted([{ entry: admittedHost() }]);
+ assert.equal(hostTierLabel('hermes'), 'routing only · external adapter · not AQE');
+});
+
+test('a granted commandStatusline flips only that flag, leaving canBePrimary at the manifest floor', () => {
+ applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { commandStatusline: true } } });
+ const entry = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(entry.capabilities.commandStatusline, true);
+ assert.equal(entry.capabilities.canBePrimary, false);
+ assert.equal(effectivePrimaryHostIds().includes('hermes'), false);
+});
+
+test('a host absent from grantsByName stays at its manifest floor while a sibling host in the same call is granted', () => {
+ applyAdmitted([
+ { entry: admittedHost({ id: 'hermes' }) },
+ { entry: admittedHost({ id: 'iris' }) },
+ ], { grantsByName: { hermes: { canBePrimary: true } } });
+ const hosts = effectiveHostRegistry();
+ assert.equal(hosts.find((h) => h.id === 'hermes').capabilities.canBePrimary, true);
+ assert.equal(hosts.find((h) => h.id === 'iris').capabilities.canBePrimary, false);
+});
+
+// ── defensive allow-list: the overlay can NEVER carry anything else ─────────
+
+test('a grantsByName entry cannot flip canRouteActivities, transcripts, or any non-grantable capability', () => {
+ applyAdmitted([{ entry: admittedHost({
+ capabilities: {
+ canDriveSession: true, canBePrimary: false, canRouteActivities: false,
+ commandStatusline: false, transcripts: false, usage: false,
+ nativeMcpConfig: false, nativeGuidance: false,
+ },
+ }) }], {
+ grantsByName: {
+ hermes: {
+ canBePrimary: true, canRouteActivities: true, transcripts: true,
+ usage: true, nativeMcpConfig: true, nativeGuidance: true, canDriveSession: true,
+ },
+ },
+ });
+ const { capabilities } = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(capabilities.canBePrimary, true, 'the one grantable flag actually granted must flip');
+ assert.equal(capabilities.canRouteActivities, false, 'not grantable — must stay at the manifest floor');
+ assert.equal(capabilities.transcripts, false, 'not grantable — must stay at the manifest floor');
+ assert.equal(capabilities.usage, false, 'not grantable — must stay at the manifest floor');
+ assert.equal(capabilities.nativeMcpConfig, false, 'not grantable — must stay at the manifest floor');
+ assert.equal(capabilities.nativeGuidance, false, 'not grantable — must stay at the manifest floor');
+});
+
+test('a grantsByName entry can never introduce a key outside the eight schema-defined capabilities (e.g. aqeProvider)', () => {
+ applyAdmitted([{ entry: admittedHost() }], {
+ grantsByName: { hermes: { canBePrimary: true, aqeProvider: 'claude-code' } },
+ });
+ const { capabilities } = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(capabilities.aqeProvider, undefined, 'aqeProvider must never reach a host capabilities object via a grant');
+ assert.deepEqual(Object.keys(capabilities).sort(), [
+ 'canBePrimary', 'canDriveSession', 'canRouteActivities', 'commandStatusline',
+ 'nativeGuidance', 'nativeMcpConfig', 'transcripts', 'usage',
+ ]);
+});
+
+test('a grant can never flip a capability back to false — it can only raise, never lower', () => {
+ applyAdmitted([{ entry: admittedHost({
+ capabilities: {
+ canDriveSession: true, canBePrimary: false, canRouteActivities: true,
+ commandStatusline: false, transcripts: true, usage: false,
+ nativeMcpConfig: false, nativeGuidance: false,
+ },
+ }) }], { grantsByName: { hermes: { canBePrimary: false } } });
+ const { capabilities } = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.equal(capabilities.canBePrimary, false);
+});
+
+// ── deep-freeze holds on the grant-augmented entry too ──────────────────────
+
+test('the grant-augmented entry (and its capabilities) is still deep-frozen', () => {
+ applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } });
+ const entry = effectiveHostRegistry().find((h) => h.id === 'hermes');
+ assert.ok(Object.isFrozen(entry));
+ assert.ok(Object.isFrozen(entry.capabilities));
+ assert.throws(() => { 'use strict'; entry.capabilities.canBePrimary = false; }, TypeError);
+ assert.equal(entry.capabilities.canBePrimary, true, 'the mutation attempt must not have taken effect');
+});
+
+// ── F-6 (LOW hardening): prototype-chain safety on the grantsByName lookup ──
+// entry.id is a consented-but-adapter-supplied string; a host literally named
+// 'constructor' must never resolve to Object.prototype.constructor via `[]`
+// lookup on a plain object literal (grantsByName here is deliberately `{}`,
+// NOT Object.create(null), to prove the READ side's own Object.hasOwn guard
+// holds independently of admission.mjs building it null-prototype).
+
+test('a host id of "constructor" is never misread as granted via the prototype chain (plain-object grantsByName)', () => {
+ applyAdmitted([{ entry: admittedHost({ id: 'constructor' }) }], { grantsByName: {} });
+ const entry = effectiveHostRegistry().find((h) => h.id === 'constructor');
+ assert.equal(entry.capabilities.canBePrimary, false);
+ assert.equal(entry.capabilities.commandStatusline, false);
+});
+
+test('effectiveHostRegistry() still returns HOST_REGISTRY entries by identity alongside a granted admitted entry', () => {
+ applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } });
+ const combined = effectiveHostRegistry();
+ assert.ok(HOST_REGISTRY.every((host) => combined.includes(host)), 'built-in entries must be unchanged references');
+});
diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs
new file mode 100644
index 0000000..b099777
--- /dev/null
+++ b/tests/kit/conformance-tiers.test.mjs
@@ -0,0 +1,609 @@
+// Tiered conformance harness (ADR-0031 §2, §5) — src/lib/adapters/conformance.mjs.
+// Reuses the acme fixture adapter-conformance.test.mjs established: real
+// admission, real on-disk consent, a real spawned subprocess for both the
+// lifecycle detect hook and the execution.run hook. This file proves the
+// GENERALIZED tiered shape on top of that same fixture: which tiers
+// genuinely pass, and which are honestly 'gated'/'skipped' rather than
+// faked, per ADR-0031's "do not fake a pass" discipline.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+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, 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');
+const VALID_MANIFEST_PATH = path.join(FIXTURE_ROOT, 'manifest.json');
+
+function tempGrantsFile() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-tiers-grants-'));
+ return path.join(dir, 'adapter-grants.json');
+}
+
+/**
+ * Reads the fixture manifest EXACTLY as authored — no command rewriting.
+ * checkAdmission now threads a real baseDir into registerAdmittedLifecycle
+ * (Wave C, F1), so the fixture's own literal, unrewritten
+ * ["node","detect-hook.mjs"] resolves correctly through the real resolver:
+ * 'node' via PATH (matches how the execution.run hook already worked
+ * unrewritten — see the sibling comment this file used to carry), and the
+ * relative 'detect-hook.mjs' anchored to the manifest's own directory via
+ * the derived baseDir. If this rewrite were still needed, that would mean F1
+ * regressed — this reader is deliberately a no-op beyond parsing, so the
+ * admission tier proves the real, shipped resolution path.
+ */
+async function readManifestFromFile(source) {
+ const text = fs.readFileSync(source, 'utf8');
+ return JSON.parse(text);
+}
+
+function rawAcmeManifest(overrides = {}) {
+ const text = fs.readFileSync(VALID_MANIFEST_PATH, 'utf8');
+ const base = JSON.parse(text);
+ return {
+ ...base,
+ ...overrides,
+ host: { ...base.host, ...(overrides.host ?? {}), capabilities: { ...base.host.capabilities, ...(overrides.host?.capabilities ?? {}) } },
+ };
+}
+
+// ── exports sanity ───────────────────────────────────────────────────────
+
+test('re-exports the five ADR-0031 §2 conformance tiers in graduation order', () => {
+ assert.deepEqual(CONFORMANCE_TIERS, [
+ 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline',
+ ]);
+});
+
+// ── admission tier: genuinely passes ────────────────────────────────────
+
+test('admission tier passes against the acme fixture: real admission, real registry join, real detect-hook subprocess', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['admission'],
+ grantsFile,
+ });
+
+ assert.equal(report.name, 'acme');
+ assert.ok(typeof report.hash === 'string' && report.hash.length > 0);
+ assert.equal(report.tiers.length, 1);
+ const [admissionTier] = report.tiers;
+ assert.equal(admissionTier.tier, 'admission');
+ assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2));
+ assert.ok(admissionTier.checks.length >= 3, 'expects manifest-validates, admits, registry-join, detect-hook checks');
+ assert.ok(admissionTier.checks.every((c) => c.ok));
+
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.hash, report.hash);
+ assert.equal(record.tiers.admission.status, 'passed');
+});
+
+test('admission tier fails honestly on an invalid manifest, with no grant-store write', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: 'mem://acme-primary-claim',
+ readManifest: async () => rawAcmeManifest({ host: { capabilities: { canBePrimary: true } } }),
+ tiers: ['admission'],
+ grantsFile,
+ });
+ const [admissionTier] = report.tiers;
+ assert.equal(admissionTier.status, 'failed');
+ assert.ok(admissionTier.checks.some((c) => !c.ok));
+ assert.equal(grantsFor('acme', { file: grantsFile }), null);
+});
+
+// ── F2 (Wave C security review, BLOCKER): a manifest that schema-VALIDATES
+// but whose real admission (admitAdapters — consent/contract/name-mismatch)
+// FAILS must not let any downstream tier still spawn a real hook or record
+// 'passed'. Forcing a genuine admitOne 'name-mismatch' refusal (an explicit
+// cfg `name` that disagrees with the manifest's own host.id) proves the
+// distinction from the previous test: the manifest itself is schema-valid
+// (check 1 in checkAdmission passes), only the SECOND check — the real
+// admission gate — fails. Every downstream tier must see this exactly as if
+// admission had never validated at all.
+
+test('F2: a manifest that schema-validates but fails real admission (name-mismatch) short-circuits every downstream tier to skipped, with no hook spawned and nothing persisted', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ name: 'not-acme', // disagrees with the fixture manifest's own host.id ('acme') -> admitOne refuses 'name-mismatch'
+ tiers: ['admission', 'activity-routing', 'session-driving', 'primary-eligible', 'statusline'],
+ grantsFile,
+ haveFn: async () => true,
+ });
+
+ const admissionTier = report.tiers.find((t) => t.tier === 'admission');
+ assert.equal(admissionTier.status, 'failed', JSON.stringify(admissionTier.checks, null, 2));
+ assert.match(admissionTier.checks.find((c) => !c.ok)?.detail ?? '', /does not match manifest host id/);
+
+ for (const tierName of ['activity-routing', 'session-driving', 'primary-eligible', 'statusline']) {
+ const tier = report.tiers.find((t) => t.tier === tierName);
+ assert.equal(tier.status, 'skipped', `${tierName}: ${JSON.stringify(tier.checks)}`);
+ assert.match(tier.checks[0].detail, /admission tier did not pass/);
+ }
+
+ // Nothing at all persisted — not even the failed admission tier itself.
+ assert.equal(grantsFor('not-acme', { file: grantsFile }), null);
+ assert.equal(grantsFor('acme', { file: grantsFile }), null);
+});
+
+// ── activity-routing tier: genuinely passes (real subprocess worker) ───────
+
+test('activity-routing tier genuinely passes against acme via a real subprocess worker (ADR-0018)', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['activity-routing'],
+ grantsFile,
+ haveFn: async () => true, // acme's detection.bin will never be on a test machine's PATH
+ });
+
+ const [tier] = report.tiers;
+ assert.equal(tier.tier, 'activity-routing');
+ assert.equal(tier.status, 'passed', JSON.stringify(tier.checks, null, 2));
+ assert.match(tier.evidence, /succeeded/);
+ assert.match(tier.evidence, /acme/);
+
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.tiers['activity-routing'].status, 'passed');
+ assert.ok(record.tiers['activity-routing'].evidence.length > 0);
+});
+
+test('activity-routing tier is honestly skipped when the manifest declares neither canRouteActivities nor an execution.run hook', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: 'mem://acme-no-execution',
+ readManifest: async () => {
+ const raw = rawAcmeManifest({ host: { capabilities: { canRouteActivities: false } } });
+ delete raw.execution;
+ // No baseDir exists for a mem:// source, so the fixture's own relative
+ // lifecycle.detect.hook.command would be refused as unanchorable (F1)
+ // and fail admission for a reason unrelated to what this test isolates
+ // (canRouteActivities/execution.run absence) — drop lifecycle too, so
+ // the skip below is provably the activity-routing check's own, not a
+ // side effect of the admission prerequisite failing.
+ delete raw.lifecycle;
+ return raw;
+ },
+ tiers: ['admission', 'activity-routing'],
+ grantsFile,
+ });
+ const admissionTier = report.tiers.find((t) => t.tier === 'admission');
+ const tier = report.tiers.find((t) => t.tier === 'activity-routing');
+ assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2));
+ assert.equal(tier.status, 'skipped');
+ assert.match(tier.checks[0].detail, /not declared/);
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.tiers.admission.status, 'passed'); // admission legitimately recorded
+ assert.equal(record.tiers['activity-routing'], undefined); // the skipped tier recorded nothing
+});
+
+// ── session-driving tier: honest skipped/gated, never faked ────────────────
+
+test('session-driving tier is skipped when the manifest does not declare canDriveSession', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['session-driving'],
+ grantsFile,
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'skipped');
+ assert.notEqual(tier.status, 'passed');
+ assert.equal(grantsFor('acme', { file: grantsFile }), null);
+});
+
+test('session-driving tier is honestly gated (never passed) when canDriveSession is declared but no external session-driving path exists', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: 'mem://acme-session-driving',
+ readManifest: async () => {
+ const raw = rawAcmeManifest({ host: { capabilities: { canDriveSession: true, canRouteActivities: false } } });
+ delete raw.execution;
+ // No baseDir exists for a mem:// source (nothing to fs.realpathSync),
+ // so the fixture's own relative lifecycle.detect.hook.command would be
+ // refused as unanchorable (F1) and fail admission — orthogonal to what
+ // this test isolates (session-driving semantics), so drop lifecycle too.
+ delete raw.lifecycle;
+ return raw;
+ },
+ tiers: ['admission', 'session-driving'],
+ grantsFile,
+ });
+ const admissionTier = report.tiers.find((t) => t.tier === 'admission');
+ const tier = report.tiers.find((t) => t.tier === 'session-driving');
+ assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2));
+ assert.equal(tier.status, 'gated');
+ assert.notEqual(tier.status, 'passed');
+ assert.match(tier.detail, /ruflo/i);
+ // No upstream ref supplied -> nothing persisted for THIS tier (there is no
+ // real tracking issue to pin a recordTierGate() call to yet) — admission
+ // still legitimately recorded, since it genuinely passed.
+ assert.equal(tier.gatedBy, undefined);
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.tiers.admission.status, 'passed');
+ assert.equal(record.tiers['session-driving'], undefined);
+});
+
+test('session-driving tier persists via recordTierGate when a real upstream ref is supplied', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: 'mem://acme-session-driving-ref',
+ readManifest: async () => {
+ const raw = rawAcmeManifest({ host: { capabilities: { canDriveSession: true, canRouteActivities: false } } });
+ delete raw.execution;
+ // No baseDir exists for a mem:// source (nothing to fs.realpathSync),
+ // so the fixture's own relative lifecycle.detect.hook.command would be
+ // refused as unanchorable (F1) and fail admission — orthogonal to what
+ // this test isolates (session-driving semantics), so drop lifecycle too.
+ delete raw.lifecycle;
+ return raw;
+ },
+ tiers: ['session-driving'],
+ grantsFile,
+ sessionDrivingUpstreamRef: 'ruvnet/ruflo#9001',
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'gated');
+ assert.equal(tier.gatedBy, 'ruvnet/ruflo#9001');
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.tiers['session-driving'].status, 'gated');
+ assert.equal(record.tiers['session-driving'].gatedBy, 'ruvnet/ruflo#9001');
+});
+
+// ── statusline: honest gated, ungranted (untouched this wave) ──────────────
+
+test('statusline tier is gated (never passed) against acme with no grant recorded', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['statusline'],
+ grantsFile,
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'gated', JSON.stringify(tier.checks));
+ assert.notEqual(tier.status, 'passed');
+ assert.match(tier.detail, /ak-local/);
+ assert.equal(grantsFor('acme', { file: grantsFile }), null);
+});
+
+// ── primary-eligible: honest activity-routing dependency ───────────────────
+
+test('primary-eligible tier is skipped when activity-routing did not pass this run', async () => {
+ const grantsFile = tempGrantsFile();
+ // No haveFn override: acme's detection.bin will never be on a test
+ // machine's real PATH, so activity-routing genuinely fails this run —
+ // primary-eligible must short-circuit on that.
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['primary-eligible'],
+ grantsFile,
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'skipped', JSON.stringify(tier.checks));
+ assert.notEqual(tier.status, 'passed');
+ assert.match(tier.checks[0].detail, /activity-routing did not pass/);
+ assert.equal(grantsFor('acme', { file: grantsFile }), null);
+});
+
+test('primary-eligible tier is skipped on a missing activity-routing prerequisite even when a real grant was earned earlier through the sanctioned flow', async () => {
+ const grantsFile = tempGrantsFile();
+ // Earn the grant honestly first, via the real sanctioned sequence (ADR-0031
+ // §2): a run with activity-routing genuinely passing records a real
+ // 'passed' primary-eligible tier, which grantCapability (grants.mjs)
+ // requires before it will confer canBePrimary — never hand-seeded.
+ 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 });
+
+ // Re-run WITHOUT the haveFn override — activity-routing genuinely fails
+ // this run, so primary-eligible must short-circuit regardless of the (now
+ // real, honestly earned) grant already sitting in the store.
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['primary-eligible'],
+ grantsFile,
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'skipped', JSON.stringify(tier.checks));
+ assert.match(tier.checks[0].detail, /activity-routing did not pass/);
+});
+
+// ── primary-eligible: the real exercise, evidence-first (F-1 fix) ──────────
+// F-1 (security review, HIGH, fixed): the exercise runs UNCONDITIONALLY —
+// never gated on a pre-existing canBePrimary grant. Gating it on the grant
+// it exists to justify was a deadlock: grantCapability (grants.mjs) refuses
+// unless a 'passed' primary-eligible tier is ALREADY recorded at this hash,
+// so the only way it could ever pass before this fix was hand-seeding a fake
+// tier result first — defeating the whole earned-evidence point of
+// ADR-0031 §2 ("passing a tier records evidence; the maintainer's grant
+// turns evidence into capability" — evidence comes FIRST). These tests prove
+// the corrected, sanctioned sequence with NO hand-seeding anywhere.
+
+test('primary-eligible tier GENUINELY PASSES via the real exercise with NO pre-existing grant — evidence records first, per ADR-0031 §2', async () => {
+ const grantsFile = tempGrantsFile();
+ assert.equal(grantsFor('acme', { file: grantsFile }), null, 'sanity: nothing recorded before this run');
+
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['primary-eligible'],
+ grantsFile,
+ haveFn: async () => true,
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'passed', JSON.stringify(tier.checks, null, 2));
+ // Real evidence, not a fabricated string — worded to what was actually
+ // demonstrated (a direct run plus a genuine ADR-0019 escalation onto the
+ // same host: ladder + execution-contract plumbing), not an overclaim about
+ // agentic task quality (F-7).
+ assert.match(tier.evidence, /'acme' completed a direct \(non-escalated\) run/);
+ assert.match(tier.evidence, /genuine ADR-0019 escalation onto itself/);
+ assert.match(tier.evidence, /2 attempts:/);
+ assert.match(tier.evidence, /=failed -> acme=succeeded/);
+
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.tiers['primary-eligible'].status, 'passed');
+ assert.match(record.tiers['primary-eligible'].evidence, /genuine ADR-0019 escalation/);
+});
+
+test('the sanctioned §2 sequence end to end: a freshly-passed tier with NO prior grant lets grantCapability confer a LIVE canBePrimary', async () => {
+ const grantsFile = tempGrantsFile();
+ assert.equal(grantsFor('acme', { file: grantsFile }), null, 'sanity: nothing recorded before this run');
+
+ // Step 1: the real exercise earns the evidence (no grant exists yet).
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['primary-eligible'],
+ grantsFile,
+ haveFn: async () => true,
+ });
+ assert.equal(report.tiers[0].status, 'passed', JSON.stringify(report.tiers[0].checks));
+ assert.equal(
+ grantedCapabilitiesFor('acme', report.hash, { file: grantsFile }).canBePrimary,
+ undefined,
+ 'passing the tier alone must never itself grant the capability',
+ );
+
+ // Step 2: the maintainer's explicit act (ADR-0031 §1) — only possible now
+ // because the tier is genuinely 'passed' at this hash, not because
+ // anything above hand-seeded it.
+ grantCapability('acme', 'canBePrimary', { hash: report.hash }, { file: grantsFile });
+ assert.equal(grantedCapabilitiesFor('acme', report.hash, { file: grantsFile }).canBePrimary, true);
+});
+
+test('primary-eligible tier: a caller-supplied legacy exercisePrimaryEligible option has no effect — there is no injection seam, only the real exercise ever runs', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['primary-eligible'],
+ grantsFile,
+ haveFn: async () => true,
+ // Not a real option on runTieredConformance anymore (silently ignored) —
+ // proves a caller cannot launder a fabricated pass through what used to
+ // be the injection point.
+ exercisePrimaryEligible: async () => ({ ok: true, detail: 'FAKE-LAUNDERED-PASS' }),
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'passed');
+ assert.notEqual(tier.evidence, 'FAKE-LAUNDERED-PASS');
+ assert.match(tier.evidence, /genuine ADR-0019 escalation onto itself/);
+ const record = grantsFor('acme', { file: grantsFile });
+ 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({
+ manifestSource: 'mem://does-not-admit',
+ readManifest: async () => { throw new Error('unreadable on purpose'); },
+ tiers: ['statusline'],
+ grantsFile,
+ });
+ const [tier] = report.tiers;
+ assert.equal(tier.status, 'skipped');
+ assert.notEqual(tier.status, 'passed');
+});
+
+// ── full sequence + persistence summary ─────────────────────────────────
+
+test('running all five tiers together against acme yields three genuinely-passed tiers (admission, activity-routing, primary-eligible) plus an honestly gated statusline, with nothing faked', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ grantsFile,
+ haveFn: async () => true,
+ });
+ assert.equal(report.tiers.length, 5);
+ const byTier = Object.fromEntries(report.tiers.map((t) => [t.tier, t.status]));
+ assert.equal(byTier.admission, 'passed');
+ assert.equal(byTier['session-driving'], 'skipped'); // acme declares canDriveSession:false
+ assert.equal(byTier['activity-routing'], 'passed');
+ // primary-eligible now genuinely passes in the same run (F-1 fix): its
+ // real exercise is unconditional, not gated on a pre-existing grant — no
+ // grant was seeded anywhere in this test.
+ assert.equal(byTier['primary-eligible'], 'passed');
+ assert.equal(byTier.statusline, 'gated');
+
+ const record = grantsFor('acme', { file: grantsFile });
+ assert.equal(record.tiers.admission.status, 'passed');
+ assert.equal(record.tiers['activity-routing'].status, 'passed');
+ assert.equal(record.tiers['primary-eligible'].status, 'passed');
+ assert.ok(record.tiers['primary-eligible'].evidence.length > 0);
+ assert.equal(record.tiers.statusline, undefined);
+});
+
+// ── persist:false opt-out ───────────────────────────────────────────────
+
+test('persist:false runs the full sequence without writing anything to the grant store', async () => {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH,
+ readManifest: readManifestFromFile,
+ tiers: ['admission'],
+ grantsFile,
+ persist: false,
+ });
+ assert.equal(report.tiers[0].status, 'passed');
+ assert.equal(grantsFor('acme', { file: grantsFile }), null);
+});
+
+// ── self-contained + repeatable ─────────────────────────────────────────
+
+// ── F7 (Wave C security review): the lifecycle overlay must not leak ───────
+// checkAdmission's detect-hook check registers a real lifecycle adapter via
+// registerAdmittedLifecycle. Before F7 the finally block only reset the host
+// and execution overlays, leaving that registration live in
+// LIFECYCLE_ADAPTERS after runTieredConformance returned — an
+// already-bootstrapped-looking 'acme' lifecycle adapter surviving a
+// conformance run that (in another test) may have refused it.
+
+test('does not leak an admitted lifecycle registration after returning (F7)', async () => {
+ const grantsFile = tempGrantsFile();
+ assert.equal(lifecycleAdapterFor('acme'), null, 'sanity: nothing registered before this test runs');
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile,
+ });
+ assert.equal(report.tiers[0].status, 'passed');
+ assert.equal(lifecycleAdapterFor('acme'), null, 'the admitted lifecycle registration must not survive the run');
+});
+
+test('is safe to call repeatedly and cleans up its own temp consent store when none is supplied', async () => {
+ for (let i = 0; i < 2; i += 1) {
+ const grantsFile = tempGrantsFile();
+ const report = await runTieredConformance({
+ manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile,
+ });
+ assert.equal(report.tiers[0].status, 'passed');
+ }
+});
diff --git a/tests/kit/external-lifecycle.test.mjs b/tests/kit/external-lifecycle.test.mjs
new file mode 100644
index 0000000..cb70480
--- /dev/null
+++ b/tests/kit/external-lifecycle.test.mjs
@@ -0,0 +1,439 @@
+// ADR-0031 P3 — external lifecycle execution wired into setup/sync/uninstall.
+// A synthetic ADMITTED host ('globex') with real, standalone node-script
+// lifecycle hooks (apply/undo — no injected runHook, spawned through the
+// REAL hook-runner, same black-box posture as adapter-conformance.test.mjs's
+// acme fixture) proves the three command loops now iterate hostsWithLifecycle()
+// safely: the hook actually runs and lifecycle-render.mjs's generic one-line
+// summary prints, gated by lifecycleExecutionEnabled (cfg enablement AND the
+// experimental flag — an admitted host is never exercised without both).
+//
+// 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 (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';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ sandboxHome, assertSandboxed, captureLog, rmrf, writeKitConfig, offlineKitConfig, fakeGlobalRoot,
+} from './helpers/home-sandbox.mjs';
+
+const HOME = sandboxHome('ak-external-lifecycle');
+const paths = await import('../../src/lib/paths.mjs');
+const setup = await import('../../src/commands/setup.mjs');
+const sync = await import('../../src/commands/sync.mjs');
+const uninstall = await import('../../src/commands/uninstall.mjs');
+const { loadKitConfig } = await import('../../src/lib/config.mjs');
+const { validateAdapterManifest } = await import('../../src/lib/adapters/manifest.mjs');
+const { applyAdmitted, resetAdmitted } = await import('../../src/lib/adapters/admitted.mjs');
+const { registerAdmittedLifecycle, lifecycleAdapterFor } = await import('../../src/lib/adapters/lifecycle-registry.mjs');
+assertSandboxed(paths, HOME);
+
+const PKG_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..');
+const FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS';
+
+function globexHost(overrides = {}) {
+ return {
+ id: 'globex',
+ label: 'Globex',
+ install: { bin: 'globex-cli', 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,
+ };
+}
+
+/** Writes a real, standalone marker-writing hook script (ESM `.mjs`, no
+ * injected runHook — spawned through the REAL hook-runner) into `dir` under
+ * `filename`, and returns the RELATIVE `[process.execPath, filename]`
+ * command anchored to `dir` via baseDir. The marker path lives in FILE
+ * CONTENT, never a spawn argv element — a `node -e '