From b7d963e7baa6df1d25e8542374726f03509f9a54 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 24 Aug 2026 11:34:19 -0700 Subject: [PATCH] fix(adapters): pin hook content before execution --- docs/AUTHORING-HOST-ADAPTERS.md | 41 ++-- docs/HOST-ADAPTER-FREEZE-CHECKLIST.md | 8 + docs/adr/0029-host-adapter-extension-point.md | 74 +++--- ...bility-graduation-and-upstream-requests.md | 39 ++-- src/commands/x/host-adapters-grants.mjs | 8 +- src/commands/x/host-adapters.mjs | 45 ++-- src/commands/x/host.mjs | 9 +- src/lib/adapters/admission.mjs | 85 ++----- src/lib/adapters/conformance.mjs | 35 +-- src/lib/adapters/consent.mjs | 5 +- src/lib/adapters/grants.mjs | 7 +- src/lib/adapters/hook-runner.mjs | 22 +- src/lib/adapters/integrity.mjs | 220 ++++++++++++++++++ src/lib/adapters/lifecycle-registry.mjs | 11 +- src/lib/adapters/manifest.mjs | 32 ++- src/lib/execution/admitted.mjs | 10 +- tests/fixtures/adapters/acme/manifest.json | 4 +- tests/kit/adapter-admission.test.mjs | 31 +-- tests/kit/adapter-conformance.test.mjs | 8 +- tests/kit/adapter-execution.test.mjs | 15 +- tests/kit/adapter-integrity.test.mjs | 139 +++++++++++ tests/kit/adapter-manifest.test.mjs | 16 ++ tests/kit/conformance-tiers.test.mjs | 2 +- tests/kit/external-lifecycle.test.mjs | 10 +- tests/kit/host-adapters-cli.test.mjs | 21 +- 25 files changed, 671 insertions(+), 226 deletions(-) create mode 100644 src/lib/adapters/integrity.mjs create mode 100644 tests/kit/adapter-integrity.test.mjs diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md index 93ea76e..3292ca1 100644 --- a/docs/AUTHORING-HOST-ADAPTERS.md +++ b/docs/AUTHORING-HOST-ADAPTERS.md @@ -69,10 +69,10 @@ validator: }, "driving": { "surfaces": ["cli-subprocess"] }, "lifecycle": { - "detect": { "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } } + "detect": { "hook": { "command": ["node", "detect-hook.mjs"], "files": ["detect-hook.mjs"], "timeoutMs": 5000 } } }, "execution": { - "run": { "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 120000 } } + "run": { "hook": { "command": ["node", "run-hook.mjs"], "files": ["run-hook.mjs"], "timeoutMs": 120000 } } }, "trust": { "changes": [ @@ -100,7 +100,7 @@ Field by field: | `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. | +| `lifecycle` / `execution` | Your hooks ([section 3](#3-write-the-hooks)). Both are optional; a manifest with neither is a pure description. A file-backed hook must list its adapter-owned files in `hook.files`. | > **Capabilities describe what the adapter *delivers through `ak`*, not what your host can do in > principle.** A real Hermes adapter's first draft declared `nativeMcpConfig: true` and @@ -217,8 +217,17 @@ how you write it: 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. + (`execution-unanchored` / `lifecycle-unanchored`) — publish remotely and contract v1 requires + path-independent PATH binaries or inline evaluator commands. +- **Declared hook-file integrity.** A relative/script-like hook argument must be covered by that + hook's `files` array, with a path relative to the manifest directory. `ak` reads each declared + regular file, records its SHA-256 digest alongside the manifest identity, discloses the digest during + `trust`, and rechecks it immediately before every spawn. Edit, remove, or replace a declared file + and admission/grants go stale; an edit after admission is refused at spawn time. The inventory is + explicit, not a transitive import scanner: list every adapter-owned file your hook executes. +- **Remote path restriction.** npm/URL manifests are read and discarded rather than retained as a + local bundle. Contract v1 therefore refuses script-like hook paths from those sources; use a PATH + binary or inline evaluator command. A future immutable bundle/signature contract may widen this. - **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. @@ -260,11 +269,11 @@ 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. +`trust` prints the full validated manifest, every declared hook-file digest, and every hook command +that will spawn — then asks for confirmation before pinning a hash of that combined content. **Edit +the manifest or a declared hook file 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: @@ -316,12 +325,17 @@ What each tier means for you: | `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. | +Use `ak host adapters conformance --dev` while iterating. It runs the same real subprocess +checks but loudly persists no consent, tier evidence, or capability grant; a dev run cannot graduate +the adapter. Use the default command when you want the reproduced evidence that a maintainer may +review. + **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 +Evidence is hash-pinned to your combined manifest/file identity, so any declared edit voids it. And it's a two-way street: if a +grant-bearing tier later re-runs `failed` at the same adapter-content hash, the stored evidence *and* the live capability are auto-voided. ## 6. Propose it for graduation @@ -341,7 +355,7 @@ 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. + adapter-content 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 @@ -366,6 +380,7 @@ ak host adapters trust # disclose the manifest, confirm, pi # (--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 conformance --dev # real self-test; persist no evidence or grants 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 diff --git a/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md index a045666..d49892e 100644 --- a/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md +++ b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md @@ -25,6 +25,10 @@ while the contract is still experimental. - [ ] Admission gate, hash-pinned consent, subprocess hook-runner, `admission` tier (ADR-0029). +- [ ] File-backed hooks declare a relative `hook.files` inventory; each path is digest-pinned, + rechecked immediately before spawn, and a changed/missing/non-regular file fails closed. +- [ ] Remote (`npm:` / URL) adapters use only path-independent hooks, or move to a retained, + immutable bundle contract before freeze; no remote hook bytes are assumed immutable. - [ ] `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, @@ -50,6 +54,10 @@ while the contract is still experimental. - [ ] `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). +- [ ] **Hook-byte evidence reproduced** by the maintainer: the disclosed per-path digests match the + files reviewed, and an edit between admission and spawn is observed to fail closed. +- [ ] **Development conformance evidence kept separate**: any `ak host adapters conformance + --dev` run is explicitly non-persistent and is not used for graduation. - [ ] **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: `__________`. diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md index 36ac07a..cf0f3f5 100644 --- a/docs/adr/0029-host-adapter-extension-point.md +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -2,13 +2,13 @@ - **Status:** Accepted (experimental contract) - **Date:** 2026-08-15 -- **Updated:** 2026-08-16 +- **Updated:** 2026-08-24 - **Update note:** [ADR-0031](0031-capability-graduation-and-upstream-requests.md) amends this ADR's "permanent caps" framing. The block on *self-declaring* `canBePrimary` / `aqeProvider` / `commandStatusline` in the manifest is permanent (the safety invariant here), but the *capability* is earnable through a conformance tier plus a maintainer grant recorded outside the manifest — up to promotion to a first-party built-in. The schema, admission gate, consent model, and hook runner - in this ADR are unchanged. + now also pin declared hook-file bytes as described in §6. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (closed-registry clause superseded — see [Supersession](#supersession-of-adr-0016s-closed-registry-clause)), @@ -155,23 +155,15 @@ external-execution row, after an adversarial review of the surface): 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. + (`node`, `hermes`) stays legal. The combined content hash pins the manifest text and any declared + local hook bytes; the resolution is anchored to the source's real directory, so those bytes cannot + drift without the hash changing. + - *Remote sources are path-independent in contract v1.* A remote (`npm:`/`https://`) resolver does + not retain a bundle after extracting the manifest, so admission refuses script-like hook paths + and any declared `hook.files` with `hook-files-unavailable`. Remote adapters must use PATH + binaries or inline evaluator commands. A retained file source may use relative hook paths, but + every adapter-owned file must be listed in that hook's `files` inventory and is hashed before + consent. - **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 @@ -224,14 +216,15 @@ amended gate list below. ### 6. Consent: hash-pinned, edit-invalidated -Registering an adapter computes a content hash over the manifest and every declared subprocess hook -command, discloses the full manifest through the same trust-manifest surface ADR-0018/ADR-0023 -already use before any other mutation, and requires explicit confirmation before persisting -`trust.hash` and `trust.consentedAt`. Every subsequent load re-hashes the manifest and compares: a -mismatch means the manifest changed since consent, and the adapter is **not admitted** until -re-consented. This is Codex's pin-and-invalidate model, applied to `ak`'s own adapter manifests -instead of Codex's MCP servers — consent lives outside the trusted boundary, attached to a specific -byte sequence, never to an identity that content can silently drift underneath. +Registering an adapter computes a content hash over the validated manifest and every declared +subprocess hook command. For a file-sourced adapter, each hook's explicit relative `files` inventory +also contributes a per-path SHA-256 digest; the combined identity is disclosed before confirmation +and stored in `trust.hash`/`trust.consentedAt`. Every subsequent load re-hashes the manifest and +declared files and compares: a mismatch means the adapter is **not admitted** until re-consented. +Every admitted spawn repeats the file check immediately before execution, so a file edited after +admission cannot run under the old consent or grant. This is Codex's pin-and-invalidate model, +extended to the bytes the manifest names. It is not a race-free immutable snapshot; a future retained +bundle/signature design can provide that stronger TOCTOU guarantee. ### 7. No in-process third-party code, ever @@ -333,24 +326,19 @@ A matching one-line update-note has been added to ADR-0016 itself, pointing here ## Self-graded implementation status -Dated 2026-08-15, at Wave 4 doc-authoring time. Rows already covered by the amended gate list are -not repeated; this table grades the mechanism this ADR newly decides — the manifest, admission, -overlay, hook-runner, and consent — none of which existed as code prior to this wave. Grades: -**Working** (implemented and tested in this worktree), **Demo** (implemented, not yet under test), -**TBD** (not yet implemented as of this dating). Per the model set by ruflo's own ADR-015-v2 -practice — a self-graded status table an ADR carries at acceptance time, filled in with real -evidence as implementation lands rather than promised in prose — the lead fills in test counts and -flips remaining TBD cells at Wave 4 integration. +Dated 2026-08-24, after the PR #131 follow-up implementation. Rows already covered by the amended +gate list are not repeated; this table grades the mechanism this ADR newly decides. **Working** means +implemented and tested in this worktree. -| Mechanism | Grade (2026-08-15) | Evidence | +| Mechanism | Grade (2026-08-24) | Evidence | |---|---|---| -| Manifest schema (contract: 1) | TBD | Owned by the sibling contract work package; no schema module present in this worktree as of this dating. | -| Admission gate (fail-closed, per-adapter isolated) | TBD | Owned by the sibling contract work package; not present in this worktree as of this dating. | -| `AK_EXPERIMENTAL_HOST_ADAPTERS` flag gating | TBD | Not present in this worktree as of this dating; no occurrences found under `src/`. | -| Admitted-host overlay (registry-adjacent, non-mutating) | TBD | Depends on the admission gate landing first. | -| Subprocess hook-runner (`cli-subprocess` surface) | TBD | Owned by the sibling hook work package; not present in this worktree as of this dating. | -| Hash-pinned consent + edit-invalidation | TBD | Owned by the sibling hook work package; not present in this worktree as of this dating. | -| Capability-cap schema absence (§3) | TBD | Depends on the manifest schema landing first. | +| Manifest schema (contract: 1) | **Working** | `src/lib/adapters/manifest.mjs`; strict hook `files` inventory validation; manifest tests. | +| Admission gate (fail-closed, per-adapter isolated) | **Working** | `src/lib/adapters/admission.mjs`; admission and integrity tests. | +| `AK_EXPERIMENTAL_HOST_ADAPTERS` flag gating | **Working** | Bootstrap and CLI flag-off tests. | +| Admitted-host overlay (registry-adjacent, non-mutating) | **Working** | `src/lib/adapters/admitted.mjs`; overlay/grant tests. | +| Subprocess hook-runner (`cli-subprocess` surface) | **Working** | `src/lib/adapters/hook-runner.mjs`; bounded real-subprocess tests. | +| Hash-pinned consent + edit-invalidation | **Working** | `src/lib/adapters/integrity.mjs`; manifest + declared hook-file digests, pre-spawn recheck, integrity tests. | +| Capability-cap schema absence (§3) | **Working** | Schema refusal tests and maintainer-only grant allow-list. | | Gate item 1 — import-time invariant | **Working** | `assertBuiltinAdaptersRoutable`, one-directional since W1-B (`src/lib/execution/adapters.mjs`). | | Gate item 2 — uninstall-through-undo | **Working** | Registry-driven `hostsWithLifecycle()` teardown loop (`src/commands/uninstall.mjs`). | | Gate item 3 — permission authorization by host | **Working** | `projectPermissionManifest` union-across-enabled-hosts, F-04 (`src/commands/setup.mjs`). | diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index d0e5cce..3e3e0f5 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -1,7 +1,8 @@ # ADR-0031 — Capability graduation: earned parity for external host adapters, and the upstream request path -- **Status:** Accepted (governance decision; implementation staged) +- **Status:** Accepted (governance decision; implementation active) - **Date:** 2026-08-16 +- **Updated:** 2026-08-24 - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md), [ADR-0018](0018-generalized-host-worker-execution.md), @@ -68,15 +69,15 @@ 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. +The recorded evidence is hash-pinned to the **combined adapter content identity** — the validated +manifest plus each explicitly declared relative hook-file digest — using the same content pin consent +uses (ADR-0029 §6). A manifest edit or declared hook-file edit therefore voids the evidence and the +grants that rest on it. File inventories are explicit rather than a language-specific import scan: +authors must list every adapter-owned file a hook executes. Immediately before every lifecycle or +execution spawn, `ak` re-reads the declared files and fails closed if that identity changed. Remote +manifest sources have no retained bundle and are limited to PATH binaries or inline commands in +contract v1; an immutable bundle/signature design remains a later strengthening for race-free TOCTOU +protection. ### 3. Two graduation destinations @@ -137,8 +138,9 @@ contract is still experimental. ADR-0029 states the three caps are permanent. This ADR amends that: **the block on *self-declaration* is permanent; the *capability* is earnable** through a conformance tier and a -maintainer grant (§1, §2). ADR-0029's schema, admission gate, consent model, and hook runner are -unchanged — capability grants are additive and live outside the manifest. A matching update note is +maintainer grant (§1, §2). ADR-0029's schema, admission gate, consent model, and hook runner remain +the governing path; contract v1 now includes the additive `hook.files` inventory and combined +manifest/file identity. Capability grants still live outside the manifest. A matching update note is added to ADR-0029 pointing here. ## Consequences @@ -156,18 +158,19 @@ added to ADR-0029 pointing here. ## Implementation status Per the ADR discipline this repository adopted (a dated, self-graded table before an Accepted claim -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. +rests on delivery): the **governance decision** is accepted and the core machinery is working behind +the experimental flag. This table is the source of truth for what is real. | 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) | **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 execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B; integrity tightened 2026-08-24) | 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; remote path-backed hooks are refused before admission because no bundle is retained; declared local hook files are rechecked immediately before spawn; 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) | +| 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 adapter-content 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]`. The content identity covers the validated manifest plus declared hook-file bytes; the explicit inventory and immediate pre-spawn recheck are the remaining contract-v1 boundary. `statusline` un-earn lands with its render path | +| Hook-file integrity and development conformance mode | **Working** (2026-08-24, PR #131 follow-up) | `hook.files` validates an explicit relative inventory; `hashAdapterContent` adds per-path SHA-256 digests; admission, consent, grants, and pre-spawn execution use the combined identity; `ak host adapters conformance --dev` runs real probes without persisting evidence or grants. | +| 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 adapter-content hash. **Wave D2 makes a grant live:** at bootstrap the admitted-host overlay reads `grantedCapabilitiesFor` at the fresh current content 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; tightened 2026-08-24) | 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, and remote sources with script-like hook paths are refused because no bundle is retained. | | 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) | diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs index b2b5be4..36d9105 100644 --- a/src/commands/x/host-adapters-grants.mjs +++ b/src/commands/x/host-adapters-grants.mjs @@ -7,7 +7,7 @@ // 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 +// 'passed' at the exact current adapter-content 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 @@ -97,7 +97,7 @@ export async function grant({ console.log(bold(`grant '${safeCapability}' to '${safeName}'`)); console.log(` gating tier: ${safeTier}`); - console.log(` manifest hash: ${hash}`); + console.log(` content hash (manifest + hook files): ${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)'}`); @@ -135,7 +135,7 @@ export async function grant({ 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)`); + 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 adapter-content hash)`); return 1; } @@ -204,7 +204,7 @@ async function statusOne(name, entry, { reader, grantsFile }) { return 1; } const { hash } = loaded; - console.log(` manifest hash: ${hash}`); + console.log(` content hash (manifest + hook files): ${hash}`); const record = grantsFor(name, { file: grantsFile, currentHash: hash }); const passed = record ? Object.entries(record.tiers).filter(([, t]) => t?.status === 'passed') : []; diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 0a75df5..6fe4f45 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -9,14 +9,17 @@ // 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. +// Mirrors admitOne's refusal semantics exactly: the validated manifest and +// declared hook files are hashed only after validateAdapterManifest accepts +// the shape, so the content a user consents to is always the VALIDATED shape +// plus disclosed file bytes, 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 { positiveInt } from '../run.mjs'; -import { hashManifest, SUPPORTED_CONTRACT } from '../../lib/adapters/admission.mjs'; +import { + baseDirForSource, hashAdapterContent, 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'; @@ -132,7 +135,13 @@ export async function loadAndHash(entry, { reader }) { return { ok: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` }; } - return { ok: true, manifest, hash: hashManifest(manifest), origin }; + let integrity; + try { + integrity = hashAdapterContent(manifest, { baseDir: baseDirForSource(entry.source) }); + } catch (error) { + return { ok: false, reason: error?.reason ?? 'hook-integrity', detail: error?.message ?? String(error) }; + } + return { ok: true, manifest, hash: integrity.hash, integrity, origin }; } /** Trust state for one entry — never throws. One of 'trusted', 'consent-stale', @@ -163,7 +172,8 @@ async function list({ cfg, consent, reader }) { return 0; } -function discloseManifest(name, manifest, hash) { +function discloseManifest(name, manifest, integrity) { + const hash = integrity.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 @@ -208,6 +218,8 @@ function discloseManifest(name, manifest, hash) { for (const change of changes) { console.log(` [${change.scope}] ${stripControl(change.owner)}: ${stripControl(change.value)} — ${stripControl(change.effect)}`); } + console.log(` hook file digests: ${integrity.hookFiles.length ? '' : '(none)'}`); + for (const file of integrity.hookFiles) console.log(` ${file.path}: ${file.sha256}`); console.log(` sha256: ${hash}`); } @@ -221,7 +233,7 @@ async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash } fail(`'${name}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); return 1; } - const { manifest, hash, origin } = loaded; + const { manifest, integrity, 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- @@ -233,7 +245,7 @@ async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash } 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`); + fail(`'${name}' hash mismatch — --expect-hash ${expectHash} does not match the resolved adapter content hash ${hash}; refusing to record consent for unexpected content`); return 1; } @@ -249,10 +261,10 @@ async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash } return 0; } if (recorded !== null && recorded !== undefined) { - warn(`'${name}' consent is stale — previously trusted hash ${recorded}, current manifest hash ${hash}`); + warn(`'${name}' consent is stale — previously trusted content hash ${recorded}, current adapter content hash ${hash}`); } - discloseManifest(name, manifest, hash); + discloseManifest(name, manifest, integrity); if (!yes) { if (!isTTY) { @@ -265,7 +277,7 @@ async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash } 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'); + info('admission will now accept this exact manifest and declared hook-file content — edit either and re-run `ak host adapters trust` after reviewing the new digest'); return 0; } @@ -351,7 +363,7 @@ async function warnAboutHooks(name, entry, rawReader) { * recording semantics (passed -> recordTierResult, upstream-gated -> * recordTierGate, everything else persists nothing). */ async function conformance({ - name, cfg, reader, runTiered, consentFile, grantsFile, flags = /** @type {{timeout?:string}} */ ({}), + name, cfg, reader, runTiered, consentFile, grantsFile, flags = /** @type {{timeout?:string,dev?:boolean}} */ ({}), }) { if (typeof name !== 'string' || !name) { fail('usage: ak host adapters conformance '); return 2; } const entry = findEntry(cfg, name); @@ -366,6 +378,9 @@ async function conformance({ } const rawReader = toRawManifestReader(reader); + if (flags.dev) { + warn(`'${name}' conformance DEV MODE — hooks still run as real subprocesses, but no consent, tier evidence, or capability grant is persisted; this run cannot graduate the adapter.`); + } await warnAboutHooks(name, entry, rawReader); let report; @@ -377,6 +392,7 @@ async function conformance({ consentFile, grantsFile, timeoutMs, + persist: !flags.dev, }); } catch (error) { fail(`'${name}' conformance run failed: ${stripControl(error?.message ?? String(error))}`); @@ -384,6 +400,7 @@ async function conformance({ } console.log(bold(`host adapter conformance — ${report.name}`) + (report.hash ? dim(` (${report.hash})`) : '')); + if (flags.dev) info(' [dev: evidence not persisted]'); let anyFailed = false; for (const tier of report.tiers) { const line = tierLine(tier); diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index bb70b04..096b0c4 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -52,6 +52,7 @@ export const options = { 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 timeout: { type: 'string' }, // adapters conformance: outer ms budget override (default: manifest's own execution.run.hook.timeoutMs, else 120000) + dev: { type: 'boolean', default: false }, // adapters conformance: run without persisting evidence/grants yes: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, }; @@ -89,10 +90,10 @@ Subcommands: list show each configured adapter's trust state (default) trust [--expect-hash ] grant consent (required with --yes against a non-file source); revoke - conformance [--timeout ] run the tiered black-box - harness; --timeout overrides the outer per-worker - budget (default: the manifest's own declared - execution.run.hook.timeoutMs, else 120000) + conformance [--timeout ] [--dev] + run the tiered black-box harness; --dev is a loud, + non-persistent self-test and never produces + graduation evidence Options (pick, all optional — omit for interactive): --host the complete desired enabled-host set, e.g. diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index 84bbe7a..520b00c 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -6,56 +6,16 @@ // isolation: one bad adapter is refused in place and never affects any other // 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'; +import { + baseDirForSource, hashAdapterContent, hashManifest, +} from './integrity.mjs'; -export const SUPPORTED_CONTRACT = 1; +export { baseDirForSource, canonicalizeManifest, hashAdapterContent, hashManifest } from './integrity.mjs'; -/** 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. - * The sort comparator is a plain code-unit compare, NOT localeCompare: with - * localeCompare, key order (and therefore the hash) could shift across - * locales (e.g. en_US vs sv_SE collation), so a CI runner or container with - * a different locale than where consent was recorded could see a bogus - * consent-stale refusal for a manifest that never actually changed. */ -export function canonicalizeManifest(value) { - return JSON.stringify(value, (_key, val) => ( - val && typeof val === 'object' && !Array.isArray(val) - ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) - : val - )); -} +export const SUPPORTED_CONTRACT = 1; -export function hashManifest(value) { - return createHash('sha256').update(canonicalizeManifest(value)).digest('hex'); -} const builtinIds = () => new Set(HOST_REGISTRY.map((host) => host.id)); @@ -104,7 +64,13 @@ async function admitOne(entry, { readManifest, consent, builtins }) { return { name, admitted: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` }; } - const hash = hashManifest(manifest); + let integrity; + try { + integrity = hashAdapterContent(manifest, { baseDir: baseDirForSource(entry.source) }); + } catch (error) { + return { name, admitted: false, reason: error?.reason ?? 'hook-integrity', detail: error?.message ?? String(error) }; + } + const { hash } = integrity; let recorded; try { recorded = consent.recordedHashFor(name); @@ -122,16 +88,16 @@ async function admitOne(entry, { readManifest, consent, builtins }) { return { name, admitted: false, reason: 'consent-error', detail: error?.message ?? String(error) }; } if (!trusted || recorded !== hash) { - return { name, admitted: false, reason: 'consent-stale', detail: `manifest hash ${hash} does not match consented ${recorded}` }; + return { name, admitted: false, reason: 'consent-stale', detail: `adapter content hash ${hash} does not match consented ${recorded}` }; } - return { name, admitted: true, entry: manifest.host, manifest }; + return { name, admitted: true, entry: manifest.host, manifest, integrity, contentHash: hash }; } /** * @param {{ cfg: any, readManifest: (source: string) => Promise, * consent: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean } }} args - * @returns {Promise>} + * @returns {Promise>} */ export async function admitAdapters({ cfg, readManifest, consent }) { const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; @@ -226,15 +192,10 @@ export async function bootstrapHostAdapters({ // 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. + // CRITICAL: the hash passed to grantedCapabilitiesFor is the content + // identity produced by admission, not a manifest-only fallback. It pins + // both the validated manifest and any declared hook bytes, so a file edit + // cannot leave a capability grant live under the old content hash. let grantsByName; try { const { grantedCapabilitiesFor } = await import('./grants.mjs'); @@ -247,7 +208,7 @@ export async function bootstrapHostAdapters({ grantsByName = Object.create(null); for (const result of admitted) { try { - grantsByName[result.name] = grantedCapabilitiesFor(result.name, hashManifest(result.manifest)); + grantsByName[result.name] = grantedCapabilitiesFor(result.name, result.contentHash ?? hashManifest(result.manifest)); } catch (error) { warnings.push({ name: result.name, reason: 'grant-lookup-failed', detail: error?.message ?? String(error) }); } @@ -293,7 +254,9 @@ export async function bootstrapHostAdapters({ continue; } try { - registerAdmittedExecution(result.manifest, { baseDir: baseDirForSource(sourceByName.get(result.name)) }); + registerAdmittedExecution(result.manifest, { + baseDir: baseDirForSource(sourceByName.get(result.name)), integrity: result.integrity, + }); } catch (error) { warnings.push({ name: result.name, reason: error?.reason ?? 'execution-registration-failed', detail: error?.message ?? String(error) }); } @@ -327,7 +290,7 @@ export async function bootstrapHostAdapters({ for (const result of lifecycleCandidates) { try { const baseDir = baseDirForSource(sourceByName.get(result.name)); - const adapter = registerAdmittedLifecycle(result.manifest, { baseDir }); + const adapter = registerAdmittedLifecycle(result.manifest, { baseDir, integrity: result.integrity }); if (adapter.unanchoredVerbs.length) { warnings.push({ name: result.name, reason: 'lifecycle-unanchored', diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs index 73e24a4..9a44de9 100644 --- a/src/lib/adapters/conformance.mjs +++ b/src/lib/adapters/conformance.mjs @@ -27,7 +27,7 @@ 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 { admitAdapters, hashAdapterContent } from './admission.mjs'; import { applyAdmitted, resetAdmitted, effectiveHostRegistry, admittedHostIds, } from './admitted.mjs'; @@ -113,7 +113,14 @@ async function checkAdmission({ if (!manifest) return { checks, manifest: null, hash: null }; - const hash = hashManifest(manifest); + let integrity; + try { + integrity = hashAdapterContent(manifest, { baseDir }); + } catch (error) { + checks.push({ name: 'hook files are content-addressed', ok: false, detail: error?.message ?? String(error) }); + return { checks, manifest: null, hash: null, integrity: null }; + } + const { hash } = integrity; const resolvedName = name ?? manifest.host.id; await runCheck(checks, 'admits through admitAdapters with a real on-disk consent record', async () => { @@ -154,7 +161,7 @@ async function checkAdmission({ // 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 adapter = registerAdmittedLifecycle(manifest, { baseDir, integrity }); 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}`); @@ -164,7 +171,7 @@ async function checkAdmission({ 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 }; + return { checks, manifest, hash, integrity }; } // ── session-driving tier ──────────────────────────────────────────────── @@ -208,7 +215,7 @@ function checkSessionDriving({ manifest, upstreamRef }) { // real, so it is the one tier expected to genuinely PASS against a conforming // fixture today. async function checkActivityRouting({ - manifest, name, baseDir, haveFn, clock, cwd, timeoutMs, + manifest, name, baseDir, integrity, haveFn, clock, cwd, timeoutMs, }) { if (!manifest) { return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; @@ -227,7 +234,7 @@ async function checkActivityRouting({ await runCheck(checks, 'registerAdmittedExecution derives and registers a real execution adapter', async () => { resetAdmittedExecution(); - registerAdmittedExecution(manifest, { haveFn, baseDir }); + registerAdmittedExecution(manifest, { haveFn, baseDir, integrity }); return `registered for '${name}'`; }); @@ -318,12 +325,12 @@ async function checkActivityRouting({ const PRIMARY_ELIGIBLE_UNROUTED_HOST_SUFFIX = 'conformance-unrouted-rung'; async function runPrimaryEligibleExercise({ - manifest, name, baseDir, haveFn, clock, cwd, timeoutMs, + manifest, name, baseDir, integrity, haveFn, clock, cwd, timeoutMs, }) { const unroutedHost = `${PRIMARY_ELIGIBLE_UNROUTED_HOST_SUFFIX}-${name}`; resetAdmittedExecution(); try { - registerAdmittedExecution(manifest, { haveFn, baseDir }); + registerAdmittedExecution(manifest, { haveFn, baseDir, integrity }); const plan = { workers: [ { @@ -382,11 +389,11 @@ async function runPrimaryEligibleExercise({ * lets a maintainer's later grantCapability succeed at all. */ async function checkPrimaryEligible({ - manifest, name, baseDir, haveFn, clock, cwd, timeoutMs, + manifest, name, baseDir, integrity, haveFn, clock, cwd, timeoutMs, }) { const exerciseLabel = 'leads a run and receives an escalation (ADR-0019)'; const outcome = await runPrimaryEligibleExercise({ - manifest, name, baseDir, haveFn, clock, cwd, timeoutMs, + manifest, name, baseDir, integrity, haveFn, clock, cwd, timeoutMs, }); if (outcome.ok) { return { status: 'passed', checks: [{ name: exerciseLabel, ok: true, detail: outcome.detail }], evidence: outcome.detail }; @@ -414,7 +421,7 @@ async function checkGrantGatedTier({ } 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 ` + const detail = `no '${capability}' grant recorded at this adapter-content hash — conferred only by an explicit ` + 'maintainer grant on top of passed conformance evidence (ADR-0031 §1), via the promotion command'; return { status: 'gated', @@ -551,7 +558,7 @@ export async function runTieredConformance({ name, source: manifestSource, readManifest, consentFile: consentFileUsed, baseDir: derivedBaseDir, }); const resolvedName = name ?? admission.manifest?.host?.id ?? '(unknown)'; - const { hash } = admission; + const { hash, integrity } = 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 @@ -598,7 +605,7 @@ export async function runTieredConformance({ let activityRoutingResult = null; if (wantTier('activity-routing') || wantTier('primary-eligible')) { activityRoutingResult = await checkActivityRouting({ - manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock, + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, integrity, haveFn, clock, cwd: workerCwd, timeoutMs: effectiveTimeoutMs, }); if (wantTier('activity-routing')) { @@ -628,7 +635,7 @@ export async function runTieredConformance({ }; } else { result = await checkPrimaryEligible({ - manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock, + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, integrity, haveFn, clock, cwd: workerCwd, timeoutMs: effectiveTimeoutMs, }); } diff --git a/src/lib/adapters/consent.mjs b/src/lib/adapters/consent.mjs index a26b94b..a72349d 100644 --- a/src/lib/adapters/consent.mjs +++ b/src/lib/adapters/consent.mjs @@ -1,7 +1,8 @@ // Hash-pinned adapter trust store (Codex-hooks/Hermes-allowlist precedent). // A JSON map of adapter name -> { hash, consentedAt } under the kit config -// dir. Edit-invalidation is inherent: change an adapter's hook command and -// its hash changes, so `isTrusted` fails closed until consent is re-granted. +// dir. Edit-invalidation is inherent: change an adapter's manifest, hook +// command, or declared hook-file bytes and its combined content hash changes, +// so `isTrusted` fails closed until consent is re-granted. // // No interactive prompting lives here — consent is GRANTED elsewhere (a // future `ak host adapters trust ` command). `recordConsent` is the diff --git a/src/lib/adapters/grants.mjs b/src/lib/adapters/grants.mjs index 04937d5..0dba068 100644 --- a/src/lib/adapters/grants.mjs +++ b/src/lib/adapters/grants.mjs @@ -1,11 +1,12 @@ // 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 +// tier and GRANTED by the maintainer at a specific combined adapter-content 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. +// Change the manifest or a declared hook file and the hash changes, so every +// prior tier result and every granted capability is void until re-earned at +// the new content identity. // // No interactive prompting lives here — grants are RECORDED elsewhere (a // future `ak host adapters trust` / `ak host adapters grant` command). This diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs index 280e1ff..ec50a8b 100644 --- a/src/lib/adapters/hook-runner.mjs +++ b/src/lib/adapters/hook-runner.mjs @@ -9,6 +9,7 @@ // 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'; +import { verifyAdapterContent } from './integrity.mjs'; const DEFAULT_TIMEOUT_MS = 30_000; const OUTPUT_CAP_BYTES = 256 * 1024; @@ -143,12 +144,12 @@ async function killGroup(child) { * * @param {{hook:{command:string[], timeoutMs?:number}, hostId:string, * verb:string, timeoutMs?:number, env?:Record, stdin?:string, - * cwd?:string}} options + * cwd?:string, manifest?:object, integrity?:{hash:string}, baseDir?:string|null}} 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, stdin, cwd, + hook, hostId, verb, timeoutMs, env, stdin, cwd, manifest, integrity, baseDir, } = /** @type {any} */ ({})) { if (!hook || !Array.isArray(hook.command) || hook.command.length === 0 || !hook.command.every((part) => typeof part === 'string' && part.length > 0)) { @@ -168,6 +169,23 @@ export async function runAdapterHook({ const [argv0, ...args] = hook.command; const childEnv = minimalEnv(env); + // Adrian's trust-gap finding: the manifest-only hash is not enough when a hook + // points at mutable files. Re-read the declared bytes immediately before + // spawn and fail closed if the content identity no longer matches the + // admitted/consented identity. The check is optional for direct unit-level + // callers that do not represent an admitted adapter; production registration + // always supplies all three values. + if (manifest || integrity) { + try { + verifyAdapterContent(manifest, integrity, { baseDir }); + } catch (error) { + return { + ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, + detail: `${hostId}:${verb} adapter hook integrity check failed: ${error?.message ?? String(error)}`, + }; + } + } + const wantsStdin = typeof stdin === 'string'; let child; try { diff --git a/src/lib/adapters/integrity.mjs b/src/lib/adapters/integrity.mjs new file mode 100644 index 0000000..dd4b440 --- /dev/null +++ b/src/lib/adapters/integrity.mjs @@ -0,0 +1,220 @@ +// Adapter content identity and hook-file integrity (ADR-0029/0031). +// Manifest consent alone cannot pin a file-backed hook: the manifest can stay +// byte-identical while the script it names changes. This module keeps the +// existing manifest hash as the base identity and adds deterministic, +// per-relative-file SHA-256 digests when a hook declares adapter-owned files. +// It deliberately does not attempt to discover a language's transitive +// imports; adapter authors must declare every adapter-owned file they execute. +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i; +const SCRIPT_SOURCE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|ps1)$/i; + +export class AdapterIntegrityError extends Error { + constructor(reason, detail) { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'AdapterIntegrityError'; + this.reason = reason; + } +} + +/** The adapter directory for a persisted local manifest, or null for a + * source whose bundle is not retained locally. A failed realpath is honest + * null: callers must refuse path-backed hooks rather than guessing cwd. */ +export 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, locale-independent JSON used by the existing manifest + * consent hash and by the combined adapter-content hash below. */ +export function canonicalizeManifest(value) { + return JSON.stringify(value, (_key, val) => ( + val && typeof val === 'object' && !Array.isArray(val) + ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : val + )); +} + +export function hashManifest(value) { + return createHash('sha256').update(canonicalizeManifest(value)).digest('hex'); +} + +function hookEntries(manifest) { + const entries = []; + for (const [verb, definition] of Object.entries(manifest?.lifecycle ?? {})) { + if (definition?.hook) entries.push({ label: `lifecycle.${verb}`, hook: definition.hook }); + } + const executionHook = manifest?.execution?.run?.hook; + if (executionHook) entries.push({ label: 'execution.run', hook: executionHook }); + return entries; +} + +function normaliseHookFile(value) { + if (typeof value !== 'string' || !value || value.includes('\0')) { + throw new AdapterIntegrityError('invalid-hook-file', 'hook.files entries must be non-empty relative paths'); + } + const portable = value.replaceAll('\\', '/'); + const normalised = path.posix.normalize(portable); + if (path.posix.isAbsolute(normalised) || path.win32.isAbsolute(value) + || normalised === '.' || normalised === '..' || normalised.startsWith('../')) { + throw new AdapterIntegrityError('invalid-hook-file', `'${value}' must stay below the manifest directory`); + } + return normalised; +} + +/** Return one sorted, de-duplicated inventory of all declared hook files. */ +export function declaredHookFiles(manifest) { + const files = []; + for (const { hook } of hookEntries(manifest)) { + for (const file of hook.files ?? []) files.push(normaliseHookFile(file)); + } + const unique = [...new Set(files)].sort(); + if (unique.length !== files.length) { + throw new AdapterIntegrityError('invalid-hook-file', 'hook.files contains duplicate paths after normalization'); + } + return unique; +} + +function scriptPathToken(token) { + if (typeof token !== 'string' || !token) return null; + const equals = token.indexOf('='); + const value = equals >= 0 ? token.slice(equals + 1) : token; + if (!value) return null; + if (SCRIPT_LIKE_RE.test(value) || value.startsWith('./') || value.startsWith('../') + || value.startsWith('.\\') || value.startsWith('..\\')) return value; + return null; +} + +function pathTokenForInventory(token, baseDir, { argv0 = false } = {}) { + const value = scriptPathToken(token); + if (!value) return null; + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { + // An absolute argv[0] ending in a native executable extension is an + // installed interpreter/binary, not an adapter-owned hook file. Its + // location is already explicit and does not resolve through cwd. This + // keeps Windows `node.exe`/`hermes.exe` commands path-independent while + // absolute script argv[0] values still go through the file inventory. + if (argv0 && !SCRIPT_SOURCE_RE.test(value)) return null; + if (typeof baseDir !== 'string' || !path.isAbsolute(baseDir)) { + throw new AdapterIntegrityError('hook-files-unavailable', `'${token}' is a path-backed hook but no retained adapter directory is available`); + } + const root = path.resolve(baseDir); + const candidate = path.resolve(value); + const outside = path.relative(root, candidate); + if (outside === '..' || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { + throw new AdapterIntegrityError('invalid-hook-file', `'${token}' escapes the adapter directory`); + } + return normaliseHookFile(outside); + } + return normaliseHookFile(value); +} + +/** Find script-like command arguments that must be covered by hook.files. + * Inline evaluator source (`node -e `) is manifest content already; + * treating arbitrary strings inside that source as file paths would create + * false positives and would not improve the manifest pin. */ +function commandFileTokens(manifest, baseDir) { + const tokens = []; + for (const { label, hook } of hookEntries(manifest)) { + const command = hook.command ?? []; + for (let index = 0; index < command.length; index += 1) { + if (index > 0 && ['-e', '--eval', '-p', '--print'].includes(command[index - 1])) continue; + const relative = pathTokenForInventory(command[index], baseDir, { argv0: index === 0 }); + if (relative) tokens.push({ label, token: command[index], relative }); + } + } + return tokens; +} + +function adapterFilePath(baseDir, relative) { + if (typeof baseDir !== 'string' || !path.isAbsolute(baseDir)) { + throw new AdapterIntegrityError('hook-files-unavailable', 'file-backed hooks require an absolute adapter directory'); + } + const root = path.resolve(baseDir); + const candidate = path.resolve(root, ...relative.split('/')); + const outside = path.relative(root, candidate); + if (outside === '..' || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { + throw new AdapterIntegrityError('invalid-hook-file', `'${relative}' escapes the adapter directory`); + } + return candidate; +} + +function digestFile(baseDir, relative) { + const filename = adapterFilePath(baseDir, relative); + let stat; + try { + stat = fs.lstatSync(filename); + } catch (error) { + throw new AdapterIntegrityError('hook-file-unreadable', `'${relative}' could not be read: ${error?.message ?? String(error)}`); + } + if (!stat.isFile()) { + throw new AdapterIntegrityError('hook-file-not-regular', `'${relative}' is not a regular file`); + } + try { + const bytes = fs.readFileSync(filename); + return createHash('sha256').update(bytes).digest('hex'); + } catch (error) { + throw new AdapterIntegrityError('hook-file-unreadable', `'${relative}' could not be read: ${error?.message ?? String(error)}`); + } +} + +/** Compute the content identity used by consent and grants. */ +/** @param {any} manifest @param {{baseDir?: string|null}} [options] */ +export function hashAdapterContent(manifest, { baseDir } = {}) { + const manifestHash = hashManifest(manifest); + const files = declaredHookFiles(manifest); + const commandFiles = commandFileTokens(manifest, baseDir); + + if (files.length > 0 || commandFiles.length > 0) { + if (baseDir == null) { + throw new AdapterIntegrityError( + 'hook-files-unavailable', + 'path-backed adapter hooks require a retained local bundle; remote sources may use PATH binaries or inline commands only', + ); + } + const inventory = new Set(files); + for (const item of commandFiles) { + if (!inventory.has(item.relative)) { + throw new AdapterIntegrityError( + 'hook-file-not-declared', + `${item.label} command references '${item.token}' but hook.files does not declare '${item.relative}'`, + ); + } + } + } + + const hookFiles = files.map((relative) => ({ path: relative, sha256: digestFile(baseDir, relative) })); + const hash = hookFiles.length + ? hashManifest({ manifest, hookFiles }) + : manifestHash; + return { hash, manifestHash, hookFiles }; +} + +/** Re-read the declared files immediately before spawning. This closes the + * trust gap Adrian identified; it is intentionally a pre-spawn check, not a + * claim of race-free snapshotting. Immutable retained bundles are a later + * option if the contract needs stronger TOCTOU guarantees. */ +/** @param {any} manifest @param {{hash: string}} integrity @param {{baseDir?: string|null}} [options] */ +export function verifyAdapterContent(manifest, integrity, { baseDir } = {}) { + if (!integrity || typeof integrity.hash !== 'string' || !integrity.hash) { + throw new AdapterIntegrityError('integrity-missing', 'admitted adapter has no content identity'); + } + const current = hashAdapterContent(manifest, { baseDir }); + if (current.hash !== integrity.hash) { + throw new AdapterIntegrityError( + 'hook-content-changed', + `declared hook content changed after consent (expected ${integrity.hash}, found ${current.hash})`, + ); + } + return current; +} diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs index ee0d6e0..14fa325 100644 --- a/src/lib/adapters/lifecycle-registry.mjs +++ b/src/lib/adapters/lifecycle-registry.mjs @@ -264,9 +264,9 @@ function unanchoredResult(verb, hostId, command) { * 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}>, - * baseDir?: string|null }} [opts] + * baseDir?: string|null, integrity?: object }} [opts] */ -export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = null } = {}) { +export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = null, integrity } = {}) { const hostId = manifest.host.id; const declared = manifest.lifecycle ?? {}; // Stashed alongside the verb functions (validateLifecycleAdapter only @@ -289,6 +289,7 @@ export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = nul const run = runHook ?? (await import('./hook-runner.mjs')).runAdapterHook; const result = await run({ hook: hookEntry, hostId, verb, timeoutMs: hookEntry.timeoutMs, env: context.env, + ...(integrity ? { manifest, integrity } : {}), baseDir, // 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 @@ -324,14 +325,14 @@ export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = nul * 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, baseDir?: string|null }} [opts] + * @param {{ runHook?: (args: any) => Promise, baseDir?: string|null, integrity?: object }} [opts] */ -export function registerAdmittedLifecycle(manifest, { runHook, baseDir = null } = {}) { +export function registerAdmittedLifecycle(manifest, { runHook, baseDir = null, integrity } = {}) { 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, baseDir }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir, integrity }); validateLifecycleAdapter(adapter); LIFECYCLE_ADAPTERS.set(hostId, adapter); ADMITTED_LIFECYCLE_IDS.add(hostId); diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index a22c22b..bcf3b33 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -158,7 +158,7 @@ function validateManifestLifecycle(value) { assertRecord(entry, `lifecycle.${verb}`); assertNoUnknownKeys(entry, ['hook'], `lifecycle.${verb}`); assertRecord(entry.hook, `lifecycle.${verb}.hook`); - assertNoUnknownKeys(entry.hook, ['command', 'timeoutMs'], `lifecycle.${verb}.hook`); + assertNoUnknownKeys(entry.hook, ['command', 'timeoutMs', 'files'], `lifecycle.${verb}.hook`); try { assertStringArray(entry.hook.command, `lifecycle.${verb}.hook.command`, { allowEmpty: false }); } catch (error) { @@ -168,6 +168,7 @@ function validateManifestLifecycle(value) { && (!Number.isInteger(entry.hook.timeoutMs) || entry.hook.timeoutMs <= 0)) { throw new ManifestRejected('invalid-lifecycle-hook', `lifecycle.${verb}.hook.timeoutMs must be a positive integer`); } + validateHookFiles(entry.hook.files, `lifecycle.${verb}.hook.files`, 'invalid-lifecycle-hook'); } return structuredClone(value); } @@ -183,7 +184,7 @@ function validateExecution(value) { 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'); + assertNoUnknownKeys(value.run.hook, ['command', 'timeoutMs', 'files'], 'execution.run.hook'); try { assertStringArray(value.run.hook.command, 'execution.run.hook.command', { allowEmpty: false }); } catch (error) { @@ -193,9 +194,36 @@ function validateExecution(value) { && (!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'); } + validateHookFiles(value.run.hook.files, 'execution.run.hook.files', 'invalid-execution'); return structuredClone(value); } +/** Hook file inventories are portable paths relative to the manifest's own + * directory. Content is hashed later, once admission has resolved that + * directory; schema validation keeps absolute/traversal paths out of the + * contract before they can reach filesystem code. */ +function validateHookFiles(value, field, reason) { + if (value === undefined) return; + try { + assertStringArray(value, field, { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected(reason, error.message); + } + const normalized = value.map((file) => file.replaceAll('\\', '/')); + const invalid = normalized.find((file) => { + const parts = file.split('/'); + return file.startsWith('/') || /^[A-Za-z]:\//.test(file) || file.includes('\0') + || parts.includes('..') || file === '.' || file === './'; + }); + if (invalid !== undefined) { + throw new ManifestRejected(reason, `${field} entry '${value[normalized.indexOf(invalid)]}' must be a relative path without traversal`); + } + const canonical = normalized.map((file) => file.replace(/^\.\//, '')); + if (new Set(canonical).size !== canonical.length) { + throw new ManifestRejected(reason, `${field} contains duplicate paths after normalization`); + } +} + function validateManifestTrust(value) { assertRecord(value, 'trust'); assertNoUnknownKeys(value, ['changes'], 'trust'); diff --git a/src/lib/execution/admitted.mjs b/src/lib/execution/admitted.mjs index 0854f4a..dfb7730 100644 --- a/src/lib/execution/admitted.mjs +++ b/src/lib/execution/admitted.mjs @@ -116,10 +116,15 @@ function parseStdout(stdoutText, stderrText) { * 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(). + * persistent local bundle (npm/https) — never process.cwd(). `integrity` is + * the admission-time combined manifest + hook-file identity; hook-runner + * rechecks it immediately before every spawn. + * @param {any} manifest + * @param {{ runHook?: (args: any) => Promise, haveFn?: (cmd: any, opts?: any) => Promise, + * clock?: () => string, baseDir?: string|null, integrity?: object }} [options] */ export function buildAdmittedExecutionAdapter(manifest, { - runHook = runAdapterHook, haveFn = have, clock = nowIso, baseDir = null, + runHook = runAdapterHook, haveFn = have, clock = nowIso, baseDir = null, integrity, } = {}) { if (!manifest || typeof manifest !== 'object') throw new TypeError('buildAdmittedExecutionAdapter requires a manifest'); const hostId = manifest.host?.id; @@ -203,6 +208,7 @@ export function buildAdmittedExecutionAdapter(manifest, { }; state.hookResult = await runHook({ hook, hostId, verb: 'run', timeoutMs: innerTimeoutMs, env, stdin: state.prompt, + ...(integrity ? { manifest, integrity } : {}), baseDir, // 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 diff --git a/tests/fixtures/adapters/acme/manifest.json b/tests/fixtures/adapters/acme/manifest.json index 98bd98b..b9f8d81 100644 --- a/tests/fixtures/adapters/acme/manifest.json +++ b/tests/fixtures/adapters/acme/manifest.json @@ -29,12 +29,12 @@ "driving": { "surfaces": ["cli-subprocess"] }, "lifecycle": { "detect": { - "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } + "hook": { "command": ["node", "detect-hook.mjs"], "files": ["detect-hook.mjs"], "timeoutMs": 5000 } } }, "execution": { "run": { - "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 5000 } + "hook": { "command": ["node", "run-hook.mjs"], "files": ["run-hook.mjs"], "timeoutMs": 5000 } } }, "trust": { diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs index 99c8fd1..4c4d4f9 100644 --- a/tests/kit/adapter-admission.test.mjs +++ b/tests/kit/adapter-admission.test.mjs @@ -11,7 +11,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { - admitAdapters, bootstrapHostAdapters, hashManifest, canonicalizeManifest, SUPPORTED_CONTRACT, + admitAdapters, bootstrapHostAdapters, hashAdapterContent, hashManifest, canonicalizeManifest, SUPPORTED_CONTRACT, } from '../../src/lib/adapters/admission.mjs'; import { applyAdmitted, resetAdmitted, admittedHostIds, effectiveHostRegistry, @@ -527,11 +527,11 @@ test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dir // 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'] } } }, + lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'], files: ['apply-hook.mjs'] } } }, })); const manifestPath = path.join(tmpDir, 'manifest.json'); fs.writeFileSync(manifestPath, JSON.stringify(manifest)); - const hash = hashManifest(manifest); + const hash = hashAdapterContent(manifest, { baseDir: tmpDir }).hash; const result = await bootstrapHostAdapters({ cfg: { hostAdapters: [{ name, source: manifestPath }] }, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, @@ -550,30 +550,23 @@ test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dir } }); -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 () => { +test('F-1 (bootstrap): an npm-sourced path-backed lifecycle hook is refused before admission because the source has no retained bundle', 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. + // process.execPath, not the bare token 'node' — the path-backed hook is + // refused before any registration because an npm source has no retained + // bundle whose bytes could be pinned. 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 }), + consent: trustingConsent({}), }); - 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/); + assert.equal(result.admitted.length, 0); + const warning = result.warnings.find((w) => w.reason === 'hook-files-unavailable'); + assert.ok(warning, `expected a 'hook-files-unavailable' warning; got ${JSON.stringify(result.warnings)}`); + assert.equal(lifecycleAdapterFor(name), null, 'the adapter must not register an unpinnable remote hook'); }); diff --git a/tests/kit/adapter-conformance.test.mjs b/tests/kit/adapter-conformance.test.mjs index c64b8b0..355c453 100644 --- a/tests/kit/adapter-conformance.test.mjs +++ b/tests/kit/adapter-conformance.test.mjs @@ -20,7 +20,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; -import { admitAdapters, hashManifest } from '../../src/lib/adapters/admission.mjs'; +import { admitAdapters, hashAdapterContent } from '../../src/lib/adapters/admission.mjs'; import { applyAdmitted, resetAdmitted, effectiveHostRegistry, admittedHostIds, } from '../../src/lib/adapters/admitted.mjs'; @@ -135,7 +135,7 @@ export async function runConformanceReport({ fixtureRoot = FIXTURE_ROOT } = {}) await run('valid manifest parses, validates, and is admitted through a real on-disk consent record', async () => { const raw = await readManifestFromFile(validManifestPath); validated = validateAdapterManifest(raw); - const validHash = hashManifest(validated); + const validHash = hashAdapterContent(validated, { baseDir: FIXTURE_ROOT }).hash; recordConsent('acme', validHash, { file: consentFile }); const results = await admitAdapters({ cfg: { hostAdapters: [{ name: 'acme', source: validManifestPath }] }, @@ -228,7 +228,7 @@ export async function runConformanceReport({ fixtureRoot = FIXTURE_ROOT } = {}) assert.notEqual(mutatedText, rawText); const mutatedRaw = resolveManifestCommands(JSON.parse(mutatedText), fixtureRoot); const results = await admitAdapters({ - cfg: { hostAdapters: [{ name: 'acme', source: 'mem://acme-mutated-by-conformance-report' }] }, + cfg: { hostAdapters: [{ name: 'acme', source: validManifestPath }] }, readManifest: async () => mutatedRaw, consent, // same store — still holds the ORIGINAL (pre-mutation) hash }); @@ -324,7 +324,7 @@ test('GAP CLOSED: an added top-level field outside the schema is refused, not si return true; }); - const hash = hashManifest(validated); + const hash = hashAdapterContent(validated, { baseDir: FIXTURE_ROOT }).hash; // Consequence, proven end-to-end: a manifest carrying the extraneous field // is refused by the real admission gate itself — reason 'unknown-field', diff --git a/tests/kit/adapter-execution.test.mjs b/tests/kit/adapter-execution.test.mjs index 92f804a..d7fa832 100644 --- a/tests/kit/adapter-execution.test.mjs +++ b/tests/kit/adapter-execution.test.mjs @@ -462,7 +462,7 @@ test('R-2: launch falls back to state.cwd (never omits cwd) when there is no bas // ── 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 () => { +test('F-1 (bootstrap): an npm-sourced path-backed execution hook is refused before admission because the source has no retained bundle', async () => { const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } }); const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); const hash = hashManifest(manifest); @@ -472,17 +472,18 @@ test('F-1 (bootstrap): an npm-sourced execution adapter with a relative command 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)}`); + assert.equal(result.admitted.length, 0); + const warning = result.warnings.find((w) => w.reason === 'hook-files-unavailable'); + assert.ok(warning, `expected a 'hook-files-unavailable' 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 manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'], files: ['run-hook.mjs'] } } } }); + fs.writeFileSync(path.join(tmpDir, 'run-hook.mjs'), 'process.stdout.write(JSON.stringify({summary:"ok"}));\n'); + const { hashAdapterContent } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashAdapterContent(manifest, { baseDir: tmpDir }).hash; const manifestPath = path.join(tmpDir, 'manifest.json'); fs.writeFileSync(manifestPath, JSON.stringify(manifest)); const result = await bootstrapHostAdapters({ diff --git a/tests/kit/adapter-integrity.test.mjs b/tests/kit/adapter-integrity.test.mjs new file mode 100644 index 0000000..18da4af --- /dev/null +++ b/tests/kit/adapter-integrity.test.mjs @@ -0,0 +1,139 @@ +// Hook-file integrity proofs for the adapter consent boundary (Adrian's PR +// 131 follow-up). These tests deliberately exercise the real filesystem and +// hook-runner seam, while never allowing the mutated hook to execute. +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 { + admitAdapters, hashAdapterContent, hashManifest, +} from '../../src/lib/adapters/admission.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { runAdapterHook } from '../../src/lib/adapters/hook-runner.mjs'; + +function validHost(id = 'hermes') { + return { + id, + 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: [], + }; +} + +function fileManifest(id = 'hermes') { + return validateAdapterManifest({ + name: id, + version: '1.0.0', + contract: 1, + host: validHost(id), + detection: { bin: 'hermes' }, + driving: { surfaces: ['cli-subprocess'] }, + lifecycle: { + detect: { hook: { command: [process.execPath, 'detect-hook.mjs'], files: ['detect-hook.mjs'] } }, + }, + trust: { changes: [] }, + }); +} + +test('hashAdapterContent combines the validated manifest with sorted per-file digests', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-integrity-')); + try { + fs.writeFileSync(path.join(dir, 'detect-hook.mjs'), 'process.stdout.write("one");\n'); + const manifest = fileManifest(); + const first = hashAdapterContent(manifest, { baseDir: dir }); + assert.equal(first.manifestHash, hashManifest(manifest)); + assert.deepEqual(first.hookFiles.map((file) => file.path), ['detect-hook.mjs']); + + fs.writeFileSync(path.join(dir, 'detect-hook.mjs'), 'process.stdout.write("two");\n'); + const second = hashAdapterContent(manifest, { baseDir: dir }); + assert.notEqual(second.hash, first.hash); + assert.notEqual(second.hookFiles[0].sha256, first.hookFiles[0].sha256); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('path-backed hooks without an explicit inventory are refused before consent can admit them', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-integrity-')); + try { + const raw = structuredClone(fileManifest()); + delete raw.lifecycle.detect.hook.files; + const manifestPath = path.join(dir, 'manifest.json'); + fs.writeFileSync(path.join(dir, 'detect-hook.mjs'), 'process.stdout.write("one");\n'); + fs.writeFileSync(manifestPath, JSON.stringify(raw)); + const result = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: manifestPath }] }, + readManifest: async () => raw, + consent: { recordedHashFor: () => 'anything', isTrusted: () => true }, + }); + assert.equal(result[0].admitted, false); + assert.equal(result[0].reason, 'hook-file-not-declared'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('remote sources refuse path-backed hooks instead of pretending npm/URL bytes are immutable', () => { + const manifest = fileManifest(); + assert.throws( + () => hashAdapterContent(manifest, { baseDir: null }), + (error) => error.reason === 'hook-files-unavailable', + ); +}); + +test('pre-spawn verification fails closed when a declared hook file changes after consent', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-integrity-')); + try { + const manifest = fileManifest(); + const hookPath = path.join(dir, 'detect-hook.mjs'); + fs.writeFileSync(hookPath, 'process.stdout.write("trusted");\n'); + const integrity = hashAdapterContent(manifest, { baseDir: dir }); + fs.writeFileSync(hookPath, 'process.stdout.write("mutated");\n'); + + const result = await runAdapterHook({ + hook: manifest.lifecycle.detect.hook, + hostId: manifest.host.id, + verb: 'detect', + manifest, + integrity, + baseDir: dir, + }); + assert.equal(result.ok, false); + assert.equal(result.exitCode, null); + assert.match(result.detail, /hook-content-changed/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('admission marks consent stale when only a declared hook file changes', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-integrity-')); + try { + const manifest = fileManifest(); + const manifestPath = path.join(dir, 'manifest.json'); + const hookPath = path.join(dir, 'detect-hook.mjs'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + fs.writeFileSync(hookPath, 'process.stdout.write("trusted");\n'); + const trustedHash = hashAdapterContent(manifest, { baseDir: dir }).hash; + fs.writeFileSync(hookPath, 'process.stdout.write("mutated");\n'); + + const result = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: manifestPath }] }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => trustedHash, isTrusted: (_name, hash) => hash === trustedHash }, + }); + assert.equal(result[0].admitted, false); + assert.equal(result[0].reason, 'consent-stale'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/adapter-manifest.test.mjs b/tests/kit/adapter-manifest.test.mjs index 70aa475..56bafdb 100644 --- a/tests/kit/adapter-manifest.test.mjs +++ b/tests/kit/adapter-manifest.test.mjs @@ -269,6 +269,22 @@ test('unknown-field: an extraneous lifecycle hook key is refused', () => { }), 'unknown-field'); }); +test('hook.files round-trips as a relative adapter-owned file inventory', () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { detect: { hook: { command: ['node', 'detect-hook.mjs'], files: ['./detect-hook.mjs'] } } }, + })); + assert.deepEqual(manifest.lifecycle.detect.hook.files, ['./detect-hook.mjs']); +}); + +test('hook.files rejects absolute paths and traversal before filesystem access', () => { + rejects(validManifest({ + lifecycle: { detect: { hook: { command: ['node', 'detect-hook.mjs'], files: ['/tmp/detect-hook.mjs'] } } }, + }), 'invalid-lifecycle-hook'); + rejects(validManifest({ + lifecycle: { detect: { hook: { command: ['node', 'detect-hook.mjs'], files: ['../../detect-hook.mjs'] } } }, + }), 'invalid-lifecycle-hook'); +}); + test('unknown-field: an extraneous trust-change key is refused', () => { rejects(validManifest({ trust: { changes: [{ diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs index 59a48c0..0b1e0b2 100644 --- a/tests/kit/conformance-tiers.test.mjs +++ b/tests/kit/conformance-tiers.test.mjs @@ -84,7 +84,7 @@ function writeProbeAdapter(tempDir, { runHookSource, hookTimeoutMs = 5000 } = {} }, detection: { bin: 'probe', versionArgs: ['--version'], versionPattern: '\\d+\\.\\d+\\.\\d+' }, driving: { surfaces: ['cli-subprocess'] }, - execution: { run: { hook: { command: ['node', 'run-hook.mjs'], timeoutMs: hookTimeoutMs } } }, + execution: { run: { hook: { command: ['node', 'run-hook.mjs'], files: ['run-hook.mjs'], timeoutMs: hookTimeoutMs } } }, trust: { changes: [{ id: 'probe-subprocess-hooks', diff --git a/tests/kit/external-lifecycle.test.mjs b/tests/kit/external-lifecycle.test.mjs index cb70480..96adf5a 100644 --- a/tests/kit/external-lifecycle.test.mjs +++ b/tests/kit/external-lifecycle.test.mjs @@ -89,8 +89,8 @@ function globexManifest({ applyCommand, undoCommand }) { detection: { bin: 'globex-cli' }, driving: { surfaces: ['acp'] }, lifecycle: { - apply: { hook: { command: applyCommand, timeoutMs: 5000 } }, - undo: { hook: { command: undoCommand, timeoutMs: 5000 } }, + apply: { hook: { command: applyCommand, files: ['apply-hook.mjs'], timeoutMs: 5000 } }, + undo: { hook: { command: undoCommand, files: ['undo-hook.mjs'], timeoutMs: 5000 } }, }, trust: { changes: [{ @@ -395,7 +395,7 @@ test('F-1: setup.run_machine anchors a relative apply hook to the adapter direct host: globexHost(), detection: { bin: 'globex-cli' }, driving: { surfaces: ['acp'] }, - lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } }, + lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'], files: ['apply-hook.mjs'] } } }, trust: { changes: [{ id: 'globex-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', @@ -405,8 +405,8 @@ test('F-1: setup.run_machine anchors a relative apply hook to the adapter direct }); fs.writeFileSync(manifestPath, JSON.stringify(manifest)); - const { bootstrapHostAdapters, hashManifest } = await import('../../src/lib/adapters/admission.mjs'); - const hash = hashManifest(manifest); + const { bootstrapHostAdapters, hashAdapterContent } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashAdapterContent(manifest, { baseDir: adapterDir }).hash; const bootstrap = await bootstrapHostAdapters({ cfg: { hostAdapters: [{ name: 'globex', source: manifestPath }] }, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, diff --git a/tests/kit/host-adapters-cli.test.mjs b/tests/kit/host-adapters-cli.test.mjs index b782052..8cdfee7 100644 --- a/tests/kit/host-adapters-cli.test.mjs +++ b/tests/kit/host-adapters-cli.test.mjs @@ -837,6 +837,25 @@ test('conformance: happy path against the real acme fixture prints a per-tier ta assert.equal(record.tiers['primary-eligible'].status, 'passed'); }); +test('conformance: --dev runs real probes but persists no graduation evidence or grants', async () => { + const grantsFile = tmpGrantsFile(); + const cfg = cfgWith([{ name: 'acme', source: ACME_MANIFEST_PATH }]); + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['conformance', 'acme'], env: ON_ENV, cfg, + reader: acmeReader, grantsFile, flags: { dev: true }, + runTieredConformance: (opts) => runTieredConformance({ ...opts, haveFn: async () => true }), + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.match(cap.text(), /DEV MODE/); + assert.match(cap.text(), /evidence not persisted/); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + test('conformance: nothing is recorded when the flag is off, even with a real cfg entry and grantsFile supplied', async () => { const grantsFile = tmpGrantsFile(); const cfg = cfgWith([{ name: 'acme', source: ACME_MANIFEST_PATH }]); @@ -1075,7 +1094,7 @@ test('grant: resolves the manifest exactly once, and the disclosed trust state d // secondRaw's hash (which the consent store never recorded) and show // 'not consented' or 'consent-stale' instead of 'trusted'. assert.match(cap.text(), /manifest trust state: trusted/); - assert.match(cap.text(), new RegExp(`manifest hash: ${firstHash}`)); + assert.match(cap.text(), new RegExp(String.raw`content hash \(manifest \+ hook files\): ${firstHash}`)); assert.deepEqual(grantedCapabilitiesFor('hermes', firstHash, { file: grantsFile }), { canBePrimary: true }); });