From f54e8418456d48282cccb3c83712e10f1e637703 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 16 Aug 2026 10:16:20 -0600 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20graduation=20machinery=20wave=20A?= =?UTF-8?q?=20=E2=80=94=20trust=20CLI,=20grant=20store,=20remote=20manifes?= =?UTF-8?q?t=20sources=20(ADR-0031=20P1/P5/P6/P7)=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(adapters): hash-pinned capability-grant store (ADR-0031 P5 data layer + P7 gating records) Mirrors the consent store's pin-and-invalidate model for EARNED capabilities: tier evidence (passed/gated) and maintainer grants are recorded against the exact manifest hash; any manifest edit voids the whole record. Grant-bearing tiers (primary-eligible -> canBePrimary, statusline -> commandStatusline) require non-empty evidence; aqeProvider is structurally ungrantable (upstream-owned, ADR-0031 §4). grantedCapabilitiesFor is the only reader capability-wiring code may consume; grantsFor/gatedTiersFor are reporting surfaces with optional staleness annotation. Includes per-tier upstream gating records (gated: #NNN, format-validated). * feat(adapters): remote manifest sources — npm/https resolvers ahead of hashing (ADR-0031 P6) resolveManifestSource dispatches file / https:// / npm:[@version|tag], wired as admission's default reader so the mandated resolve -> validate -> hash -> consent ordering holds structurally: consent pins RESOLVED bytes, and a mutated remote (moved dist-tag, changed URL content) surfaces as consent-stale, never silent re-trust. Security posture (adversarially reviewed, blockers fixed and re-verified): - https: HTTPS-only, redirects refused outright, abort budget spans headers AND body, Content-Length precheck + streamed hard byte cap - npm: spec charset + name + version/dist-tag validation before any argv (no git/dir resolver smuggling), npm pack --ignore-scripts, tar -xzOf stdout-only extraction — nothing lands on disk, symlink/FIFO/bomb members yield no content instead of a read primitive - file: lstat regular-file gate + size cap (symlinks refused) - every external string entering a SourceError detail is C0/C1-stripped and length-bounded * feat(cli): ak host adapters list/trust/revoke — hash-pinned consent recording (ADR-0031 P1) Closes the consent-required dead end: admission could only ever refuse an external adapter because nothing wrote consent. trust reads, validates, and hashes exactly as admitOne does (validated-shape hash; contract-mismatch / name-mismatch / builtin-shadow refused before any disclosure or write), disclosis the FULL validated manifest (control-char-safe, decision-critical summary + sha256 printed last before the prompt), and records consent via the store seam written for this command. --expect-hash pins a hash and is REQUIRED with --yes on non-file origins (origin defaults fail-closed to unknown); revoke works with the experimental flag off (revocation is fail-safe). list reports trusted / consent-stale / not consented / manifest-error per entry, isolated. * docs(adr): ADR-0031 status table — wave A rows (trust CLI, remote sources working; grant store + gating partial) * test(adapters): inject resolveShimFn so npm-path source tests assert the logical invocation Windows CI failed all 7 npm-path tests: resolveShim rewrites npm/tar into PowerShell shim invocations there, so stubs asserting the pre-shim argv saw the shim shape. The shim rewrite is exec.mjs's own tested responsibility; these tests now inject a passthrough resolver and stay platform-independent. --- ...bility-graduation-and-upstream-requests.md | 8 +- src/commands/x/host-adapters.mjs | 308 ++++++++ src/commands/x/host.mjs | 13 +- src/lib/adapters/admission.mjs | 15 +- src/lib/adapters/grants.mjs | 279 +++++++ src/lib/adapters/sources.mjs | 420 ++++++++++ tests/kit/adapter-grants.test.mjs | 254 +++++++ tests/kit/adapter-sources.test.mjs | 572 ++++++++++++++ tests/kit/host-adapters-cli.test.mjs | 715 ++++++++++++++++++ 9 files changed, 2576 insertions(+), 8 deletions(-) create mode 100644 src/commands/x/host-adapters.mjs create mode 100644 src/lib/adapters/grants.mjs create mode 100644 src/lib/adapters/sources.mjs create mode 100644 tests/kit/adapter-grants.test.mjs create mode 100644 tests/kit/adapter-sources.test.mjs create mode 100644 tests/kit/host-adapters-cli.test.mjs diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index c912d9a..038d077 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -152,13 +152,13 @@ unbuilt. This table is the source of truth for what is real. | Piece | Status (2026-08-16) | Note | | ----- | ------------------- | ---- | | Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) | -| `ak host adapters trust` CLI (records consent/grants) | **Proposed — not built** | Smallest next step; today admission refuses `consent-required` | +| `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) | **Proposed — not built** | The seam is a comment-only lookup today | | External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized | | Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit | -| Capability-grant store + promotion command | **Proposed — not built** | Hash-pinned, mirrors consent | -| Remote manifest sources (npm / URL) + resolve→hash ordering | **Proposed — not built** | File-path manifests only today | -| Upstream request tracking (`gated: #NNN` against a tier) | **Proposed — not built** | Needs a place to record per-tier gating | +| Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave | +| Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A) | file / https (no redirects, bounded time+bytes) / `npm:` (`npm pack --ignore-scripts` + `tar -xzOf` stdout-only — nothing extracted to disk, package scripts never run); resolver runs before hashing, so a mutated remote surfaces as `consent-stale`. The https fetch is host-unrestricted by design (the source is operator-authored in user-scope `kit.json`; redirects refused, no credentials attached) | +| Upstream request tracking (`gated: #NNN` against a tier) | **Partial** (2026-08-16, wave A) | Per-tier `gated` records exist in the grant store (`recordTierGate`, ref-format-validated); CLI recording/display pending a later wave | | A real external adapter (Hermes) clearing the kit → contract freeze | **Not started** | Freeze criterion (§6) | ## Alternatives considered diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs new file mode 100644 index 0000000..9ed021f --- /dev/null +++ b/src/commands/x/host-adapters.mjs @@ -0,0 +1,308 @@ +// x host adapters — the trust CLI for external host-adapter manifests (ADR-0031 +// P1). Nothing in admission.mjs ever writes consent; this closes that gap by +// recording hash-pinned consent the same way Codex pins an MCP server's +// content (ADR-0029 §6). No adapter code ever executes here — this module +// only reads, validates, hashes, discloses, and (on confirmation) records. +// +// Mirrors admitOne's refusal semantics exactly: a manifest is hashed only +// after validateAdapterManifest accepts it (hashManifest(validateAdapterManifest(raw))), +// so the hash a user consents to is always the VALIDATED shape, never the +// raw file — an invalid manifest (e.g. one claiming canBePrimary) is refused +// with its .reason and nothing is ever recorded for it. +import readline from 'node:readline/promises'; +import { hashManifest, SUPPORTED_CONTRACT } from '../../lib/adapters/admission.mjs'; +import { validateAdapterManifest } from '../../lib/adapters/manifest.mjs'; +import { HOST_REGISTRY } from '../../lib/adapters/registries.mjs'; +import * as consentStore from '../../lib/adapters/consent.mjs'; +import { loadKitConfig } from '../../lib/config.mjs'; +import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; + +const FLAG_ENV_VAR = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; + +const flagEnabled = (env) => env?.[FLAG_ENV_VAR] === '1'; + +/** Lazy dynamic import so this file loads even before sources.mjs lands (a + * sibling work item this same wave) and so tests never pay for it unless + * they choose to — same pattern admission.mjs uses for consent.mjs. */ +async function defaultReader(source) { + const { resolveManifestSource } = await import('../../lib/adapters/sources.mjs'); + return resolveManifestSource(source); +} + +async function defaultAsk(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = (await rl.question(question)).trim().toLowerCase(); + rl.close(); + return answer === 'y' || answer === 'yes'; +} + +/** Untrusted text (manifest trust.changes fields, reader/validator error + * detail) is printed verbatim ahead of a consent prompt — a crafted string + * carrying cursor-movement/erase ANSI escapes or extra newlines could + * visually rewrite the disclosure the operator is about to agree to. A + * codepoint loop, not a control-character regex class (that trips + * `no-control-regex` AND is what sources.mjs's sanitizeDetail already uses, + * so this stays lint-clean the same way). Strips C0 (0x00-0x1F), DEL + * (0x7F), and C1 (0x80-0x9F, e.g. U+009B CSI — a real terminal control that + * JSON.stringify does NOT escape, unlike C0) outright; \n/\t/\r collapse to + * a single space instead of vanishing, so a multi-line/tabbed string stays + * readable as one line rather than silently losing a word boundary. */ +function stripControl(value) { + const input = String(value ?? ''); + let out = ''; + for (const ch of input) { + const code = ch.codePointAt(0); + if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; } + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + out += ch; + } + return out; +} + +function findEntry(cfg, name) { + return (Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []).find((e) => e?.name === name) ?? null; +} + +/** Read + validate + hash one adapter entry. Never throws — reports + * {ok:false, reason, detail} on any failure, the same per-entry isolation + * posture admission.mjs's admitOne holds. `reader` may return either the + * sources.mjs `{raw, origin}` shape or a bare raw document (tests are free + * to stub either). */ +async function loadAndHash(entry, { reader }) { + let raw; + // Fail-closed, not fail-open: a bare-raw reader result (no {raw,origin} + // wrapper — including one whose `origin` is missing/malformed) is + // 'unknown', never assumed to be 'file'. Defaulting to 'file' would let + // --yes silently skip the --expect-hash pin (finding 8) for a source that + // was never actually proven local; only an explicit origin:'file' counts. + let origin = 'unknown'; + try { + const resolved = await reader(entry.source); + if (resolved && typeof resolved === 'object' && 'raw' in resolved) { + raw = resolved.raw; + origin = typeof resolved.origin === 'string' && resolved.origin ? resolved.origin : 'unknown'; + } else { + raw = resolved; + } + } catch (error) { + return { ok: false, reason: error?.reason ?? 'manifest-unreadable', detail: error?.message ?? String(error) }; + } + + // Same distinct failure admitOne draws: cfg's pinned contract disagreeing + // with the manifest's self-declared one means the file changed underneath + // the operator, not merely "unsupported version". + if (entry.contract !== undefined && raw?.contract !== undefined && entry.contract !== raw.contract) { + return { + ok: false, reason: 'contract-mismatch', + detail: `cfg declares contract ${entry.contract}, manifest declares ${raw.contract}`, + }; + } + + let manifest; + try { + manifest = validateAdapterManifest(raw); + } catch (error) { + return { ok: false, reason: error?.reason ?? 'manifest-invalid', detail: error?.message ?? String(error) }; + } + + if (manifest.contract !== SUPPORTED_CONTRACT) { + return { ok: false, reason: 'contract-version', detail: `unsupported contract ${manifest.contract}` }; + } + if (manifest.host.id !== entry.name) { + return { + ok: false, reason: 'name-mismatch', + detail: `cfg entry '${entry.name}' does not match manifest host id '${manifest.host.id}'`, + }; + } + // Same check admitOne applies before ever computing a hash: a manifest + // whose host id collides with a built-in can never actually be admitted, + // so trusting it would record a standing consent for content admission + // will always refuse — misleading UX for nothing gained. + if (HOST_REGISTRY.some((host) => host.id === manifest.host.id)) { + return { ok: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` }; + } + + return { ok: true, manifest, hash: hashManifest(manifest), origin }; +} + +/** Trust state for one entry — never throws. One of 'trusted', 'consent-stale', + * 'not consented', or 'manifest error ()'. */ +async function stateFor(entry, { consent, reader }) { + if (typeof entry?.name !== 'string' || !entry.name) return 'manifest error (invalid-entry: missing name)'; + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) return `manifest error (${stripControl(loaded.reason)})`; + let recorded; + try { + recorded = consent.recordedHashFor(entry.name); + } catch (error) { + return `manifest error (consent-error: ${error?.message ?? String(error)})`; + } + if (recorded === null || recorded === undefined) return 'not consented'; + return recorded === loaded.hash ? 'trusted' : 'consent-stale'; +} + +async function list({ cfg, consent, reader }) { + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; + if (!entries.length) { info('no host adapters configured in kit.json'); return 0; } + console.log(bold('host adapters') + dim(' (trust state)')); + for (const entry of entries) { + const name = entry?.name ?? '(unnamed)'; + console.log(` ${String(name).padEnd(16)} ${dim(entry?.source ?? '')}`); + console.log(` ${await stateFor(entry, { consent, reader })}`); + } + return 0; +} + +function discloseManifest(name, manifest, hash) { + console.log(bold(`host adapter manifest — ${name}`)); + // Full content FIRST, decision-critical summary LAST (finding 16): a + // large-but-legal manifest can run many screens of JSON, and whatever + // prints immediately before the [y/N] prompt is what the operator's eyes + // are actually on. Burying the summary above the JSON block meant it + // scrolled off-screen entirely on a big manifest, well before the prompt. + // + // Consent is granted over the WHOLE validated manifest (ADR-0029 §6), + // including fields the curated summary below doesn't call out by name + // (host.legacy.*, host.trust.approvalPolicy, detection, driving.surfaces, + // ...) — printing it in full means nothing hashed is ever hidden from the + // thing being consented to. + console.log(bold('full manifest (exactly the content being hashed):')); + // JSON.stringify escapes every C0 control character (0x00-0x1F) WITHIN A + // STRING VALUE by construction, but leaves C1 (0x80-0x9F, e.g. U+009B + // CSI) untouched — those can only reach this text as a raw byte that + // leaked from an untrusted manifest field, never from the pretty-printer's + // own structural indentation (spaces/real newlines are the ONLY raw + // control-range bytes stringify itself emits). Splitting on that + // structural newline before sanitizing each line and rejoining after + // means stripControl only ever sees within-line content — it strips a + // leaked C1 byte (and is a no-op on \n/\t/\r, since none survive stringify + // raw inside a line) without collapsing the block's own line breaks. + console.log(JSON.stringify(manifest, null, 2).split('\n').map(stripControl).join('\n')); + console.log(''); + console.log(` version: ${manifest.version}`); + console.log(` contract: ${manifest.contract}`); + console.log(` host id: ${manifest.host.id}`); + const trueCaps = Object.entries(manifest.host.capabilities ?? {}) + .filter(([, v]) => v === true).map(([k]) => k); + console.log(` capabilities: ${trueCaps.length ? trueCaps.join(', ') : '(none)'}`); + const lifecycle = manifest.lifecycle ?? {}; + const verbs = Object.keys(lifecycle); + console.log(` lifecycle hooks:${verbs.length ? '' : ' (none)'}`); + for (const verb of verbs) { + const { hook } = lifecycle[verb]; + const timeout = hook.timeoutMs !== undefined ? ` (timeout ${hook.timeoutMs}ms)` : ''; + console.log(` ${verb}: ${JSON.stringify(hook.command)}${timeout}`); + } + const changes = manifest.trust?.changes ?? []; + console.log(` trust changes:${changes.length ? '' : ' (none)'}`); + for (const change of changes) { + console.log(` [${change.scope}] ${stripControl(change.owner)}: ${stripControl(change.value)} — ${stripControl(change.effect)}`); + } + console.log(` sha256: ${hash}`); +} + +async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters trust '); return 2; } + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${name}' in kit.json hostAdapters`); return 1; } + + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(`'${name}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + const { manifest, hash, origin } = loaded; + + // --expect-hash pinning (finding 8): required whenever --yes is paired + // with a non-file origin, so an unattended (CI) run can never blanket- + // consent to "whatever content the remote currently serves" — that + // defeats hash pinning exactly where it matters. Checked before any + // consent-store lookup or disclosure. + if (yes && origin !== 'file' && !expectHash) { + fail(`'${name}' resolved from a non-file origin ('${stripControl(origin)}') — --yes needs --expect-hash so an unattended run pins exact content instead of trusting whatever the remote serves right now (drop --yes to review interactively, or pass the hash from a prior interactive trust)`); + return 2; + } + if (expectHash !== undefined && expectHash !== hash) { + fail(`'${name}' hash mismatch — --expect-hash ${expectHash} does not match the resolved manifest hash ${hash}; refusing to record consent for unexpected content`); + return 1; + } + + let recorded; + try { + recorded = consent.recordedHashFor(name); + } catch (error) { + fail(`consent store error: ${error?.message ?? String(error)}`); + return 1; + } + if (recorded === hash) { + ok(`'${name}' is already trusted at this exact content (${hash}) — nothing to do`); + return 0; + } + if (recorded !== null && recorded !== undefined) { + warn(`'${name}' consent is stale — previously trusted hash ${recorded}, current manifest hash ${hash}`); + } + + discloseManifest(name, manifest, hash); + + if (!yes) { + if (!isTTY) { + fail('trust needs confirmation — re-run with --yes after reviewing the manifest above (non-interactive session)'); + return 2; + } + const confirmed = await ask('Trust this manifest content exactly as disclosed above? [y/N] '); + if (!confirmed) { info(`consent for '${name}' left unchanged`); return 0; } + } + + consent.recordConsent(name, hash); + ok(`consent recorded for '${name}' at ${hash}`); + info('admission will now accept this exact manifest content — ANY edit to the manifest invalidates this consent; re-run `ak host adapters trust` after an edit'); + return 0; +} + +function revoke({ name, consent }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke '); return 2; } + const existed = consent.revokeConsent(name); + if (existed) { ok(`revoked consent for '${name}'`); return 0; } + info(`no recorded consent for '${name}'`); + return 0; +} + +/** + * @param {{ positionals?: string[], flags?: any, env?: NodeJS.ProcessEnv, + * consent?: { recordedHashFor(name:string): string|null, recordConsent(name:string, hash:string): void, revokeConsent(name:string): boolean }, + * reader?: (source: string) => Promise, ask?: (question: string) => Promise, + * isTTY?: boolean, cfg?: any }} [args] + */ +export async function run({ + positionals = [], flags = {}, env = process.env, + consent = consentStore, reader = defaultReader, ask = defaultAsk, + isTTY = process.stdin.isTTY === true, cfg, +} = {}) { + const sub = positionals[0] ?? 'list'; + const name = positionals[1]; + + // Revocation is fail-safe and stays reachable regardless of the + // experimental flag: an operator who turns the flag OFF must still be + // able to withdraw a standing consent record, or that record silently + // reactivates the next time the flag is turned back on. `list`/`trust` + // stay gated — they're the surface that reads/records new trust. + if (sub === 'revoke') return revoke({ name, consent }); + + if (!flagEnabled(env)) { + fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); + return 2; + } + + const resolvedCfg = cfg ?? loadKitConfig(); + + if (sub === 'list') return list({ cfg: resolvedCfg, consent, reader }); + if (sub === 'trust') { + return trust({ + name, cfg: resolvedCfg, consent, reader, ask, isTTY, + yes: !!flags.yes, expectHash: flags['expect-hash'], + }); + } + + fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke)`); + return 2; +} diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 5e48455..bcbc8e4 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -50,6 +50,7 @@ export const options = { provider: { type: 'string' }, // csv of ruflo API providers, optional id:model (openai:gpt-5.6) route: { type: 'string', multiple: true }, // repeatable: 'activity:host[:model]' per-activity routing override activity: { type: 'string' }, // refresh: csv of activities to re-seed (default = prompt) + 'expect-hash': { type: 'string' }, // adapters trust: required sha256 pin when --yes resolves a non-file source yes: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, }; @@ -81,6 +82,12 @@ Subcommands: (per-activity, opt-in; user pins are never touched, and \`ak sync\` never does this for you) off reversible teardown (reset to claude-only; strip managed env keys) + adapters record hash-pinned consent for external host-adapter manifests + (experimental — set AK_EXPERIMENTAL_HOST_ADAPTERS=1; revoke + always works, list/trust need the flag) + list show each configured adapter's trust state (default) + trust [--expect-hash ] grant consent (required + with --yes against a non-file source); revoke Options (pick, all optional — omit for interactive): --host the complete desired enabled-host set, e.g. @@ -148,8 +155,12 @@ export async function run({ flags, positionals, pkgRoot }) { if (sub === 'off') return off({ cwd, pkgRoot }); if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); if (sub === 'refresh') return refresh({ flags, cwd }); + if (sub === 'adapters') { + const { run: runHostAdapters } = await import('./host-adapters.mjs'); + return runHostAdapters({ flags, positionals: positionals.slice(1) }); + } - fail(`unknown host subcommand: ${sub} (status|pick|refresh|off)`); + fail(`unknown host subcommand: ${sub} (status|pick|refresh|off|adapters)`); return 2; } diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index a35d812..0230f81 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -128,9 +128,18 @@ export async function admitAdapters({ cfg, readManifest, consent }) { } async function defaultReadManifest(source) { - const fs = await import('node:fs/promises'); - const raw = await fs.readFile(source, 'utf8'); - return JSON.parse(raw); + // Lazy dynamic import — keeps bootstrapHostAdapters's flag-off zero-cost + // property intact (no import happens unless a readManifest call actually + // runs, which only happens during admission with the flag set and + // adapters configured). Ordering is mandated by security review: resolve + // -> validate -> hash -> consent. The resolver (sources.mjs) runs here, + // BEFORE admitOne ever hashes the manifest, so consent pins the RESOLVED + // bytes — a mutable remote source (an npm dist-tag moving, a URL's + // content changing) invalidates consent automatically ('consent-stale') + // on the next admission pass, rather than being silently re-trusted. + const { resolveManifestSource } = await import('./sources.mjs'); + const { raw } = await resolveManifestSource(source); + return raw; } /** diff --git a/src/lib/adapters/grants.mjs b/src/lib/adapters/grants.mjs new file mode 100644 index 0000000..8c13fb0 --- /dev/null +++ b/src/lib/adapters/grants.mjs @@ -0,0 +1,279 @@ +// Hash-pinned capability-grant store (ADR-0031 §1, §2, §4). A JSON map of +// adapter name -> record under the kit config dir, mirroring adapter-consent's +// edit-invalidation model: a capability is earned by passing a conformance +// tier and GRANTED by the maintainer at a specific manifest hash, never +// self-declared in the adapter's own manifest (the permanent safety invariant +// this store exists to keep honest — see admission.mjs's schema allow-list). +// Change the manifest and the hash changes, so every prior tier result and +// every granted capability is void until re-earned at the new hash. +// +// No interactive prompting lives here — grants are RECORDED elsewhere (a +// future `ak host adapters trust` / `ak host adapters grant` command). This +// module is the programmatic seam those commands call. +// +// INVARIANT for callers wiring capabilities into runtime behaviour: +// grantedCapabilitiesFor() is the ONLY reader that may feed a capability +// decision. grantsFor() and gatedTiersFor() are reporting/inspection +// surfaces — grantsFor() can return a record pinned to a stale hash (flagged +// `stale: true` when a currentHash is supplied) precisely so a status +// display CAN show stale evidence as stale, not hide it. Reading +// grantsFor(name).capabilities directly to decide what a host may do would +// reintroduce the exact bug grantedCapabilitiesFor's hash pin exists to +// prevent. +import fs from 'node:fs'; +import path from 'node:path'; +import { configDir } from '../paths.mjs'; + +/** The five conformance tiers, in graduation order (ADR-0031 §2). */ +export const CONFORMANCE_TIERS = Object.freeze([ + 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', +]); + +/** The only capabilities `ak` can grant. `session-driving` and + * `activity-routing` gate capabilities a manifest may already express + * (canDriveSession, canRouteActivities) — their tier records are evidence, + * not grants, so they have no entry here. `aqeProvider` is never grantable by + * `ak` at all (upstream-owned enumeration, ADR-0031 §4) and MUST NOT appear + * in this map. */ +export const TIER_GRANTS = Object.freeze({ + 'primary-eligible': 'canBePrimary', + statusline: 'commandStatusline', +}); + +export const adapterGrantsPath = () => path.join(configDir(), 'adapter-grants.json'); + +function readStore(file) { + let raw; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + return {}; + } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** Atomic tmp+rename write at 0600, matching consent.mjs and the rest of the + * kit's user-owned config writes. */ +function writeStore(file, store) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + let renamed = false; + try { + fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, file); + renamed = true; + } finally { + if (!renamed) { + try { fs.rmSync(tmp, { force: true }); } catch { /* cleanup must not mask the write error */ } + } + } + try { fs.chmodSync(file, 0o600); } catch { /* best-effort on platforms without POSIX perms */ } +} + +const GATED_BY_RE = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?#[1-9][0-9]*$/; + +function isValidTier(tier) { + return typeof tier === 'string' && CONFORMANCE_TIERS.includes(tier); +} + +function requireName(name, fnName) { + if (typeof name !== 'string' || !name) throw new TypeError(`${fnName} requires an adapter name`); +} + +function requireHash(hash, fnName) { + if (typeof hash !== 'string' || !hash) throw new TypeError(`${fnName} requires a hash`); +} + +function validRecord(value) { + return !!value && typeof value === 'object' + && typeof value.hash === 'string' && value.hash.length > 0 + && !!value.tiers && typeof value.tiers === 'object' && !Array.isArray(value.tiers); +} + +/** The record for `name` at exactly `hash`, own-property only (Object.hasOwn, + * never `in` — a store containing 'constructor' or '__proto__' must not read + * as a record via the prototype chain). A hash mismatch (or no record at all) + * means "start fresh": the caller REPLACES the whole entry, since stale-hash + * evidence and capabilities must never coexist with fresh-hash evidence. */ +function freshRecordAt(store, name, hash) { + const existing = Object.hasOwn(store, name) ? store[name] : null; + if (existing && existing.hash === hash) return { ...existing, tiers: { ...existing.tiers } }; + return { hash, tiers: {} }; +} + +/** Record a passing conformance-tier result for `name` at `hash`. Evidence is + * truncated at 2048 chars — bounded so a runaway harness output can never + * blow up the store. For a grant-bearing tier (one of TIER_GRANTS' keys — + * 'primary-eligible', 'statusline') non-empty evidence is REQUIRED: ADR-0031 + * §1 is "conformance evidence plus an explicit maintainer grant confers + * [the capability]", so a grant must never trace back to an empty evidence + * string. Evidence-only tiers ('admission', 'session-driving', + * 'activity-routing') keep it optional. Recording at a hash different from + * the stored record silently REPLACES the whole entry: prior tiers and any + * granted capabilities are void (they were earned against a manifest that no + * longer exists at this hash). + * @param {string} name + * @param {string} tier + * @param {{hash?: string, evidence?: string}} details + * @param {{file?: string}} [options] + */ +export function recordTierResult(name, tier, { hash, evidence } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'recordTierResult'); + requireHash(hash, 'recordTierResult'); + if (!isValidTier(tier)) throw new TypeError(`recordTierResult requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`); + if (Object.hasOwn(TIER_GRANTS, tier) && (typeof evidence !== 'string' || evidence.trim().length === 0)) { + throw new TypeError(`recordTierResult requires non-empty evidence for grant-bearing tier '${tier}' (gates '${TIER_GRANTS[tier]}')`); + } + const store = readStore(file); + const record = freshRecordAt(store, name, hash); + record.tiers[tier] = { + status: 'passed', + recordedAt: new Date().toISOString(), + evidence: typeof evidence === 'string' ? evidence.slice(0, 2048) : '', + }; + store[name] = record; + writeStore(file, store); +} + +/** Record that `tier` cannot be met because the capability is upstream + * (ADR-0031 §4) — gated, not failed. `gatedBy` pins the tracking issue, e.g. + * 'agentic-qe#563' or 'ruvnet/ruflo#2962'. Same hash-replace semantics as + * recordTierResult. + * @param {string} name + * @param {string} tier + * @param {{hash?: string, gatedBy?: string}} details + * @param {{file?: string}} [options] + */ +export function recordTierGate(name, tier, { hash, gatedBy } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'recordTierGate'); + requireHash(hash, 'recordTierGate'); + if (!isValidTier(tier)) throw new TypeError(`recordTierGate requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`); + if (typeof gatedBy !== 'string' || !GATED_BY_RE.test(gatedBy)) { + throw new TypeError(`recordTierGate requires gatedBy in '#' form (e.g. 'agentic-qe#563'), got: ${gatedBy}`); + } + const store = readStore(file); + const record = freshRecordAt(store, name, hash); + record.tiers[tier] = { status: 'gated', recordedAt: new Date().toISOString(), gatedBy }; + store[name] = record; + writeStore(file, store); +} + +/** Grant `capability` to `name` — the maintainer's act that turns conformance + * evidence into an actual capability (ADR-0031 §1). Refuses unless the tier + * that gates `capability` is recorded 'passed' AT THE SAME hash: evidence + * plus an explicit grant confers capability, never a grant on its own, and + * never evidence recorded against a manifest that has since changed. + * @param {string} name + * @param {string} capability + * @param {{hash?: string}} details + * @param {{file?: string}} [options] + */ +export function grantCapability(name, capability, { hash } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'grantCapability'); + requireHash(hash, 'grantCapability'); + const tier = Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0]; + if (!tier) { + throw new TypeError(`grantCapability: '${capability}' is not a grantable capability (must be one of ${Object.values(TIER_GRANTS).join(', ')})`); + } + const store = readStore(file); + const existing = Object.hasOwn(store, name) ? store[name] : null; + const tierEntry = existing && existing.hash === hash ? existing.tiers?.[tier] : undefined; + if (!tierEntry || tierEntry.status !== 'passed') { + throw new Error(`grantCapability: '${name}' has no passed '${tier}' tier recorded at hash ${hash}`); + } + const record = { ...existing, tiers: { ...existing.tiers }, capabilities: { ...(existing.capabilities ?? {}) } }; + record.capabilities[capability] = true; + record.grantedAt = new Date().toISOString(); + store[name] = record; + writeStore(file, store); +} + +/** Remove the whole record for `name` — every tier result, gate, and granted + * capability. Returns whether an entry existed (Object.hasOwn semantics: a + * prototype-chain name like 'constructor' never reads as existing). */ +export function revokeGrants(name, { file = adapterGrantsPath() } = {}) { + if (typeof name !== 'string' || !name) return false; + const store = readStore(file); + if (!Object.hasOwn(store, name)) return false; + delete store[name]; + writeStore(file, store); + return true; +} + +/** The raw validated record for `name`, or null — missing, corrupt, or + * malformed all collapse to null. Never throws. THIS IS A REPORTING SURFACE, + * NOT A CAPABILITY READER — see the module-header invariant; runtime + * capability decisions must go through grantedCapabilitiesFor(). + * + * `currentHash` is optional and only changes what gets ANNOTATED, never what + * gets returned or hidden: omitted, the raw record comes back with no + * `stale` field at all (a caller not doing hash-aware reporting shouldn't + * have to reason about it). Supplied, the record comes back with `stale: + * true`/`false` so a status display can show stale evidence as stale + * (rather than silently dropping it) while still being unambiguous about + * whether it's current. + * @param {string} name + * @param {{file?: string, currentHash?: string}} [options] + * @returns {any} + */ +export function grantsFor(name, { file = adapterGrantsPath(), currentHash } = {}) { + if (typeof name !== 'string' || !name) return null; + try { + const store = readStore(file); + const entry = Object.hasOwn(store, name) ? store[name] : null; + if (!validRecord(entry)) return null; + if (currentHash === undefined) return entry; + return { ...entry, stale: entry.hash !== currentHash }; + } catch { + return null; + } +} + +/** The granted capabilities for `name`, but ONLY when the record's pinned + * hash matches `currentHash` — a manifest edit silently voids every grant + * until re-earned, exactly consent's pin model. {} on any mismatch, missing + * record, or read failure. Never throws. This is the ONLY reader capability- + * wiring code may consume — see the module-header invariant. */ +export function grantedCapabilitiesFor(name, currentHash, { file = adapterGrantsPath() } = {}) { + try { + const record = grantsFor(name, { file }); + if (!record || record.hash !== currentHash) return {}; + return record.capabilities && typeof record.capabilities === 'object' && !Array.isArray(record.capabilities) + ? { ...record.capabilities } + : {}; + } catch { + return {}; + } +} + +/** The tiers currently marked 'gated' for `name` — what it's waiting on + * upstream. [] on missing/corrupt/no-gated-tiers. Never throws. + * `currentHash` is optional; when supplied and it mismatches the record's + * pinned hash, returns [] — a changed manifest voids its gated-tier records + * exactly as it voids grants (they describe a manifest that no longer + * exists at this hash), so a hash-aware caller must never surface them as + * live open requests. Omitted, returns every gated tier regardless of hash + * (the same raw-reporting behaviour as grantsFor with no currentHash). + * @param {string} name + * @param {{file?: string, currentHash?: string}} [options] + * @returns {Array<{tier: string, gatedBy: string, recordedAt: string}>} + */ +export function gatedTiersFor(name, { file = adapterGrantsPath(), currentHash } = {}) { + try { + const record = grantsFor(name, { file }); + if (!record) return []; + if (currentHash !== undefined && record.hash !== currentHash) return []; + const out = []; + for (const [tier, entry] of Object.entries(record.tiers)) { + if (entry && entry.status === 'gated') out.push({ tier, gatedBy: entry.gatedBy, recordedAt: entry.recordedAt }); + } + return out; + } catch { + return []; + } +} diff --git a/src/lib/adapters/sources.mjs b/src/lib/adapters/sources.mjs new file mode 100644 index 0000000..ed98fff --- /dev/null +++ b/src/lib/adapters/sources.mjs @@ -0,0 +1,420 @@ +// Remote manifest sources (ADR-0031, work item P6): resolves an adapter +// manifest's `source` (kit.json's hostAdapters[].source) into raw parsed +// JSON bytes. This module is a pure fetch/parse layer — validation, hashing, +// and consent stay admission.mjs's job. +// +// Security-review-mandated ordering: this resolver runs BEFORE hashing. +// admission.mjs's admitOne calls readManifest() (which delegates here) and +// only afterward validates + hashes the result. That means consent pins the +// RESOLVED content, not a name: a mutable remote (an npm dist-tag moving, a +// URL's content changing) produces different bytes on the next admission +// pass, which hashes differently and is refused as 'consent-stale' rather +// than being silently re-trusted. Nothing in this file may cache or memoize +// across calls — every call re-resolves from scratch, which is what makes +// that invalidation automatic instead of something callers must remember to +// force. +// +// Three source forms, dispatched on prefix: +// - a file path (no recognized prefix) — no network, ever (offline-first +// is a repo invariant); an `lstat` gate refuses anything that is not a +// plain regular file (symlinks, directories, FIFOs, devices) and +// enforces `maxBytes` before the content is ever read, then JSON.parse. +// - https://... — HTTPS only, no redirects followed, bounded time and +// bytes (a Content-Length pre-check, a streamed hard cap, and a single +// timeout budget that stays armed across BOTH the initial request and +// the full body read — a response that sends headers and then never +// delivers the rest of the body must not hang forever). +// - npm:[@version|tag] — `npm pack` (never executing the package's +// own scripts) into a throwaway temp dir, then `tar` extracts exactly +// `package/ak-adapter.json` STRAIGHT TO STDOUT — never to disk. A +// tarball member can be a symlink (arbitrary local file read via a +// followed link), a FIFO (extraction hang), or a device node; -O +// sidesteps that whole class by never writing extracted content to the +// filesystem at all, and the subprocess's own `maxBuffer` — bounded +// tight to `maxBytes` for this one call, not the generic subprocess cap +// used elsewhere — rejects an oversized member before it fully lands in +// process memory, closing the decompression-bomb angle a disk-based +// extraction would otherwise have to police after the fact. +// +// Why npm tarball extraction shells npm+tar rather than adding a `tar` +// dependency: this package is zero-runtime-dependency by ADR-0016, npm is +// already shelled elsewhere in this codebase (providers.mjs's installHost, +// heal.mjs's upgradePackage/selfUpdate), and `npm pack` of a remote spec +// downloads the package without ever running ITS scripts (belt: +// --ignore-scripts, on top of the fact that `npm pack` only runs +// prepare/prepack/postpack in the first place — this flag closes that off +// too). Reaching for a tar library would trade a well-audited system binary +// for a new supply-chain dependency to buy nothing extraction-to-stdout +// couldn't already do more simply. +import { execFile as nodeExecFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveShim } from '../exec.mjs'; + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_BYTES = 262_144; +// Subprocess stdout/stderr capture cap for `npm pack` itself — its own +// textual/progress output, never the manifest content (which goes through a +// separately, tightly bounded maxBuffer — see resolveNpmSource). +const SUBPROCESS_MAX_BUFFER = 16 * 1024 * 1024; +// SourceError detail text is bounded to this length, matching +// src/lib/execution/runner.mjs's boundedFailure precedent. +const DETAIL_MAX_LENGTH = 240; + +const defaultExecFile = promisify(nodeExecFile); + +export class SourceError extends Error { + constructor(reason, detail) { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'SourceError'; + this.reason = reason; + } +} + +/** Strips C0 control characters (0x00–0x1F) and the full C1 range + * (0x7F–0x9F inclusive — DEL plus every C1 control, e.g. U+009B CSI) from + * external text before it can enter a SourceError detail — subprocess + * stderr, thrown-error messages, and JSON-parse error snippets are all + * attacker-influenced text (npm/tar output, a remote server's response, + * error text derived from a hostile manifest) that later gets printed raw + * by consumers (the bin warning path, the trust CLI). '\n'/'\t'/'\r' + * collapse to a single space rather than vanish, so a multi-line message + * stays readable as one line; every other C0/C1 byte is dropped outright — + * this defuses ANSI escape sequences (ESC-prefixed AND the single-byte C1 + * form, e.g. U+009B in place of ESC+'[') and terminal-control tricks, not + * just newlines. Bounded to DETAIL_MAX_LENGTH chars afterward. */ +function sanitizeDetail(text) { + const input = String(text ?? ''); + let out = ''; + for (const ch of input) { + const code = ch.codePointAt(0); + if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; } + if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue; + out += ch; + } + return out.trim().slice(0, DETAIL_MAX_LENGTH); +} + +/** Single choke point for constructing a SourceError: every detail string, + * however it was built, passes through sanitizeDetail here — callers never + * need to remember to sanitize at each throw site individually. */ +function sourceError(reason, detail) { + return new SourceError(reason, sanitizeDetail(detail)); +} + +// Every character an npm package spec (name[@version-or-tag]) may legally +// contain, checked against the WHOLE spec string before any parsing — +// command-injection surface. A spec containing ';', '$', '`', whitespace, +// or any other shell/argv-hostile character is rejected here, before it is +// ever assembled into an argv array (even though execFile+shell:false +// already blocks shell interpretation — this is belt-and-suspenders, the +// same posture as assertId elsewhere in this codebase). +const NPM_SPEC_CHARS_RE = /^[A-Za-z0-9@/._-]+$/; +// A conservative structural shape for the package-name portion only +// (scope optional): must start with an alphanumeric, never '.' or '_'. +const NPM_NAME_RE = /^(?:@[A-Za-z0-9][\w.-]*\/)?[A-Za-z0-9][\w.-]*$/; +// Exact semver only (no ranges — a range is meaningless for "pin one +// tarball"), mirroring manifest.mjs's SEMVER_RE. +const NPM_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/; +// A dist-tag ('latest', 'next', 'beta', ...): no '/' — that's the exact +// shape npm's own git-shorthand ("user/repo") and local-path ("../x") +// resolvers key off, so excluding '/' here is what keeps `npm:pkg@version` +// from silently reinterpreting the version half as a different resolver +// entirely (finding 7). +const NPM_DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9_.-]*$/; + +/** Splits "name[@version-or-tag]" into { name, version }. Scoped names + * (`@scope/pkg`) have a leading '@' that is not a version separator, so the + * search for the separating '@' starts after the scope's '/'. */ +function parseNpmSpec(spec) { + const scoped = spec.startsWith('@'); + const rest = scoped ? spec.slice(1) : spec; + const at = rest.indexOf('@'); + if (at === -1) return { name: spec, version: undefined }; + const name = scoped ? `@${rest.slice(0, at)}` : rest.slice(0, at); + const version = rest.slice(at + 1); + return { name, version }; +} + +function isMaxBufferError(error) { + return error?.code === 'ERR_CHILD_PROCESS_STDOUT_MAXBUFFER' || /maxBuffer/i.test(error?.message ?? ''); +} + +async function resolveFileSource(source, maxBytes) { + // lstat, not stat: a symlink must be refused as itself (isFile() false on + // the link), never silently followed to whatever it points at — the same + // "no arbitrary local read via a followed link" posture the npm path's + // stdout-only extraction enforces (finding 6 mirrors finding 2). + let stat; + try { + stat = await fs.lstat(source); + } catch (error) { + throw sourceError('source-unreachable', error?.message ?? String(error)); + } + if (!stat.isFile()) { + throw sourceError('source-invalid', `'${source}' is not a regular file — symlinks, directories, and special files are refused`); + } + if (stat.size > maxBytes) { + throw sourceError('source-too-large', `'${source}' is ${stat.size} bytes, exceeding the ${maxBytes}-byte cap`); + } + let text; + try { + text = await fs.readFile(source, 'utf8'); + } catch (error) { + throw sourceError('source-unreachable', error?.message ?? String(error)); + } + try { + /** @type {'file'} */ + const origin = 'file'; + return { raw: JSON.parse(text), origin }; + } catch (error) { + throw sourceError('source-invalid-json', error?.message ?? String(error)); + } +} + +/** Streamed read with a hard byte cap, mirroring the bounded-read pattern in + * src/lib/execution/opencode.mjs's responseJson: cancel the reader the + * moment the cap is crossed, on top of an early Content-Length rejection. + * Abort-aware: `signal` is the SAME AbortSignal that bounds the whole + * resolve (see resolveHttpsSource) — every reader.read() races against it, + * so a response that sends headers and then never delivers the rest of the + * body still gets cut off at `timeoutMs`, not left to hang forever. */ +async function readBoundedBody(response, maxBytes, label, + { signal, timeoutMs } = /** @type {{signal?: AbortSignal, timeoutMs?: number}} */ ({})) { + const declared = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + throw sourceError('source-too-large', `${label} declared Content-Length ${declared} exceeds ${maxBytes} bytes`); + } + if (!response.body?.getReader) { + throw sourceError('source-unreachable', `${label} did not expose a bounded response stream`); + } + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + const abortedPromise = signal + ? new Promise((_, reject) => { + const onAbort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + if (signal.aborted) { onAbort(); return; } + signal.addEventListener('abort', onAbort, { once: true }); + }) + : null; + try { + for (;;) { + const step = abortedPromise ? Promise.race([reader.read(), abortedPromise]) : reader.read(); + const { done, value } = await step; + if (done) break; + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + total += chunk.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + throw sourceError('source-too-large', `${label} response exceeded ${maxBytes} bytes`); + } + chunks.push(chunk); + } + } catch (error) { + if (error?.name === 'AbortError') { + await reader.cancel().catch(() => {}); + throw sourceError('source-unreachable', `${label} timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + reader.releaseLock?.(); + } + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(combined); +} + +async function resolveHttpsSource(url, { fetchFn, timeoutMs, maxBytes }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + // The timer/controller stay alive for the ENTIRE resolve — fetch AND the + // full body read — cleared only in this outer finally. Clearing it right + // after the header fetch (the prior shape) let a 200 response with a + // body that never finishes arriving hang resolveManifestSource forever; + // since bootstrapHostAdapters runs on every `ak` command with the + // experimental flag on, that hang bricked the whole CLI (finding 1). + try { + let response; + try { + // redirect: 'manual' — a redirect is REFUSED outright, never + // followed. Fail-closed and explicit beats chasing a redirect chain + // to who-knows-where (ADR-0023 posture): pin the final URL instead. + response = await fetchFn(url, { redirect: 'manual', signal: controller.signal }); + } catch (error) { + if (error?.name === 'AbortError') { + throw sourceError('source-unreachable', `${url} timed out after ${timeoutMs}ms`); + } + throw sourceError('source-unreachable', error?.message ?? String(error)); + } + + // redirect:'manual' fetch implementations report a redirect either as + // an opaque 'opaqueredirect' response (status 0, browser-shaped fetch) + // or a plain 3xx status (some Node fetch implementations) — cover both. + if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + throw sourceError('source-unreachable', 'redirects are not followed for adapter manifests — pin the final URL'); + } + if (!response.ok) { + throw sourceError('source-unreachable', `${url} responded with HTTP ${response.status}`); + } + + const text = await readBoundedBody(response, maxBytes, url, { signal: controller.signal, timeoutMs }); + try { + /** @type {'url'} */ + const origin = 'url'; + return { raw: JSON.parse(text), origin }; + } catch (error) { + throw sourceError('source-invalid-json', error?.message ?? String(error)); + } + } finally { + clearTimeout(timer); + } +} + +/** Run `npm pack` as a bounded subprocess. Resolves the command through + * `resolveShimFn` — production always passes src/lib/exec.mjs's real + * resolveShim (a no-op on POSIX, but the difference between a working and + * ENOENT'd npm invocation on Windows); tests inject a passthrough so their + * execFileFn stubs can assert the LOGICAL argv without also having to model + * Windows' PowerShell-wrapped shim shape (that shape is exec.mjs's own test + * responsibility, not this module's). Never shell:true; argv arrays only. */ +async function execBounded(execFileFn, cmd, args, { cwd, timeoutMs, maxBuffer }, label, resolveShimFn) { + const invocation = resolveShimFn(cmd, args); + if (invocation.resolved === false) { + throw sourceError('source-unreachable', `no safe invocation found for ${cmd}`); + } + try { + await execFileFn(invocation.command, invocation.args, { + cwd, timeout: timeoutMs, maxBuffer, shell: false, + }); + } catch (error) { + throw sourceError('source-unreachable', `${label} failed: ${error?.message ?? String(error)}`); + } +} + +async function resolveNpmSource(spec, { + execFileFn, timeoutMs, maxBytes, tmpDir, resolveShimFn, +}) { + if (!NPM_SPEC_CHARS_RE.test(spec)) { + throw sourceError('source-invalid', `npm spec contains disallowed characters: ${spec}`); + } + const { name, version } = parseNpmSpec(spec); + if (!name || !NPM_NAME_RE.test(name)) { + throw sourceError('source-invalid', `not a valid npm package name: ${name || '(empty)'}`); + } + // The version/tag half is validated SEPARATELY from the character-class + // gate above: that gate alone still permits e.g. 'pkg@attacker/repo' (npm + // git-shorthand) or 'pkg@../../x' (npm's local-path resolver) through, + // silently swapping which resolver npm uses for something that reads like + // a version pin. Restricting it to exact semver or a slash-free dist-tag + // keeps `npm:` sources anchored to "one tarball, from the registry". + if (version !== undefined && !(NPM_SEMVER_RE.test(version) || NPM_DIST_TAG_RE.test(version))) { + throw sourceError('source-invalid', `not a version or dist-tag: ${version}`); + } + + const dir = fsSync.mkdtempSync(path.join(tmpDir ?? os.tmpdir(), 'ak-adapter-src-')); + try { + await execBounded( + execFileFn, + 'npm', + ['pack', spec, '--ignore-scripts', '--pack-destination', dir], + { cwd: dir, timeoutMs, maxBuffer: SUBPROCESS_MAX_BUFFER }, + 'npm pack', + resolveShimFn, + ); + + const entries = await fs.readdir(dir); + const tarball = entries.find((entry) => entry.endsWith('.tgz')); + if (!tarball) { + throw sourceError('source-invalid', `npm pack for '${spec}' produced no tarball`); + } + + // Extract ONLY package/ak-adapter.json, straight to stdout (-O) — + // never to disk. See the module header for why. + const invocation = resolveShimFn('tar', ['-xzOf', tarball, 'package/ak-adapter.json']); + if (invocation.resolved === false) { + throw sourceError('source-unreachable', 'no safe invocation found for tar'); + } + let stdout; + try { + // maxBuffer bounded tight to maxBytes (plus small encoding slack), + // not the generic SUBPROCESS_MAX_BUFFER above: this stdout IS the + // manifest content, so an oversized member is rejected by execFile's + // own buffer ceiling before it fully lands in process memory. + ({ stdout } = await execFileFn(invocation.command, invocation.args, { + cwd: dir, timeout: timeoutMs, maxBuffer: maxBytes + 4096, shell: false, + })); + } catch (error) { + if (isMaxBufferError(error)) { + throw sourceError('source-too-large', `ak-adapter.json for '${spec}' exceeds ${maxBytes} bytes`); + } + // npm pack already proved the package itself was reachable; a + // subsequent failure extracting this ONE specific member is, in + // practice, always "the member isn't in the archive" — refuse it as + // an invalid source, not an unreachable one. + throw sourceError('source-invalid', `package '${spec}' does not ship an ak-adapter.json at its root`); + } + + // A symlink (or other non-regular) tar entry carries no data blocks, so + // -O extraction of one yields empty stdout. Refuse that explicitly and + // honestly, rather than letting an opaque JSON.parse('') error stand in + // for "this entry produced nothing, on purpose". + if (!stdout || !stdout.trim()) { + throw sourceError('source-invalid', `package '${spec}' member 'package/ak-adapter.json' produced no content — symlinks and other non-regular tar entries are not followed`); + } + if (Buffer.byteLength(stdout, 'utf8') > maxBytes) { + throw sourceError('source-too-large', `ak-adapter.json for '${spec}' exceeds ${maxBytes} bytes`); + } + + try { + /** @type {'npm'} */ + const origin = 'npm'; + return { raw: JSON.parse(stdout), origin }; + } catch (error) { + throw sourceError('source-invalid-json', error?.message ?? String(error)); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** + * Resolve an adapter manifest `source` string into raw parsed JSON. + * Dispatches on prefix: `https://...`, `npm:[@version|tag]`, or (no + * recognized prefix) a local file path. Throws SourceError with a named + * `.reason` on any failure — never returns partial or best-effort content. + * + * @param {string} source + * @param {{fetchFn?:typeof fetch, execFileFn?:(cmd:string,args:string[],opts:any)=>Promise<{stdout:string,stderr:string}>, + * resolveShimFn?:(cmd:string,args:string[])=>{command:string,args:string[],resolved:boolean}, + * timeoutMs?:number, maxBytes?:number, tmpDir?:string}} [options] + * @returns {Promise<{raw:any, origin:'file'|'url'|'npm'}>} + */ +export async function resolveManifestSource(source, { + fetchFn = globalThis.fetch, + execFileFn = defaultExecFile, + resolveShimFn = resolveShim, + timeoutMs = DEFAULT_TIMEOUT_MS, + maxBytes = DEFAULT_MAX_BYTES, + tmpDir, +} = {}) { + if (typeof source !== 'string' || !source) { + throw sourceError('source-invalid', 'source must be a non-empty string'); + } + if (source.startsWith('https://')) { + return resolveHttpsSource(source, { fetchFn, timeoutMs, maxBytes }); + } + if (source.startsWith('http://')) { + throw sourceError('source-insecure', 'http:// sources are not permitted for adapter manifests — use https://'); + } + if (source.startsWith('npm:')) { + return resolveNpmSource(source.slice('npm:'.length), { + execFileFn, timeoutMs, maxBytes, tmpDir, resolveShimFn, + }); + } + return resolveFileSource(source, maxBytes); +} diff --git a/tests/kit/adapter-grants.test.mjs b/tests/kit/adapter-grants.test.mjs new file mode 100644 index 0000000..4a71644 --- /dev/null +++ b/tests/kit/adapter-grants.test.mjs @@ -0,0 +1,254 @@ +// Unit tests for the hash-pinned capability-grant store (ADR-0031 §1, §2, +// §4). Mirrors adapter-conformance.test.mjs's consent-store test patterns: +// temp files, prototype-chain safety, corrupt/missing-file tolerance. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + CONFORMANCE_TIERS, TIER_GRANTS, + recordTierResult, recordTierGate, grantCapability, revokeGrants, + grantsFor, grantedCapabilitiesFor, gatedTiersFor, +} from '../../src/lib/adapters/grants.mjs'; + +function tempFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-grants-')); + return path.join(dir, 'adapter-grants.json'); +} + +const HASH_A = 'a'.repeat(64); +const HASH_B = 'b'.repeat(64); + +test('exports the five conformance tiers in graduation order', () => { + assert.deepEqual(CONFORMANCE_TIERS, [ + 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + ]); +}); + +test('TIER_GRANTS only maps primary-eligible and statusline; aqeProvider never appears', () => { + assert.deepEqual(TIER_GRANTS, { 'primary-eligible': 'canBePrimary', statusline: 'commandStatusline' }); + assert.ok(!Object.values(TIER_GRANTS).includes('aqeProvider')); +}); + +test('record -> grant happy path: passed tier at the same hash grants the capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run, escalated to' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.hash, HASH_A); + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers['primary-eligible'].evidence, 'led a run, escalated to'); + assert.equal(record.capabilities.canBePrimary, true); + assert.ok(typeof record.grantedAt === 'string' && Number.isFinite(Date.parse(record.grantedAt))); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); +}); + +test('grantCapability refused: no tier recorded at all', () => { + const file = tempFile(); + assert.throws(() => grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }), (error) => { + assert.match(error.message, /primary-eligible/); + return true; + }); +}); + +test('grantCapability refused: tier passed but at a DIFFERENT hash', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + assert.throws(() => grantCapability('acme', 'canBePrimary', { hash: HASH_B }, { file }), (error) => { + assert.match(error.message, /primary-eligible/); + assert.match(error.message, new RegExp(HASH_B)); + return true; + }); + // and the never-recorded hash grants nothing either + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('grantCapability refused: aqeProvider is never a grantable capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + assert.throws(() => grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }), TypeError); +}); + +test('grantedCapabilitiesFor returns {} on hash mismatch', () => { + const file = tempFile(); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_B, { file }), {}); +}); + +test('grantedCapabilitiesFor returns {} after a re-record at a new hash (edit-invalidation)', () => { + const file = tempFile(); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); + + // Manifest changed -> re-record at a new hash. + recordTierResult('acme', 'statusline', { hash: HASH_B, evidence: 'new footer render' }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_B, { file }), {}); +}); + +test('re-record at a new hash wipes prior tiers and capabilities entirely', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + recordTierResult('acme', 'session-driving', { hash: HASH_A }, { file }); + + let record = grantsFor('acme', { file }); + assert.ok(record.tiers['primary-eligible']); + assert.ok(record.tiers['session-driving']); + assert.equal(record.capabilities.canBePrimary, true); + + recordTierResult('acme', 'admission', { hash: HASH_B }, { file }); + record = grantsFor('acme', { file }); + assert.equal(record.hash, HASH_B); + assert.deepEqual(Object.keys(record.tiers), ['admission']); + assert.equal(record.capabilities, undefined); + assert.equal(record.grantedAt, undefined); +}); + +test('recordTierGate validates the gatedBy ref format', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + recordTierGate('acme', 'statusline', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.equal(record.tiers['primary-eligible'].gatedBy, 'agentic-qe#563'); + assert.equal(record.tiers.statusline.gatedBy, 'ruvnet/ruflo#2962'); + + for (const bad of ['agentic-qe#0', 'no-hash', 'a#b', 'agentic-qe#', '#123', 'agentic-qe#01']) { + assert.throws( + () => recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: bad }, { file }), + TypeError, + `expected recordTierGate to reject gatedBy: ${bad}`, + ); + } +}); + +test('gatedTiersFor lists gated entries and [] when none/missing', () => { + const file = tempFile(); + assert.deepEqual(gatedTiersFor('acme', { file }), []); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const gated = gatedTiersFor('acme', { file }); + assert.equal(gated.length, 1); + assert.equal(gated[0].tier, 'primary-eligible'); + assert.equal(gated[0].gatedBy, 'agentic-qe#563'); +}); + +test('revokeGrants: true when a record existed, false otherwise', () => { + const file = tempFile(); + assert.equal(revokeGrants('acme', { file }), false); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + assert.equal(revokeGrants('acme', { file }), true); + assert.equal(grantsFor('acme', { file }), null); + assert.equal(revokeGrants('acme', { file }), false); +}); + +test("revokeGrants on prototype-chain names ('constructor', '__proto__', 'toString') returns false on an empty store", () => { + const file = tempFile(); + for (const protoName of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + assert.equal(revokeGrants(protoName, { file }), false, + `revokeGrants('${protoName}') on a never-recorded store must be false, not a prototype-chain hit`); + } + // A real recorded adapter must still revoke correctly. + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + assert.equal(revokeGrants('acme', { file }), true); +}); + +test('corrupt or missing file tolerance: grantsFor null, grantedCapabilitiesFor {}, gatedTiersFor []', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-grants-corrupt-')); + const missing = path.join(dir, 'does-not-exist.json'); + assert.equal(grantsFor('acme', { file: missing }), null); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file: missing }), {}); + assert.deepEqual(gatedTiersFor('acme', { file: missing }), []); + + const corrupt = path.join(dir, 'adapter-grants.json'); + fs.writeFileSync(corrupt, '{ not valid json', 'utf8'); + assert.equal(grantsFor('acme', { file: corrupt }), null); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file: corrupt }), {}); + assert.deepEqual(gatedTiersFor('acme', { file: corrupt }), []); +}); + +test('recordTierResult / recordTierGate throw TypeError on invalid name/tier/hash', () => { + const file = tempFile(); + assert.throws(() => recordTierResult('', 'admission', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'not-a-tier', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'admission', { hash: '' }, { file }), TypeError); + assert.throws(() => recordTierGate('acme', 'not-a-tier', { hash: HASH_A, gatedBy: 'x#1' }, { file }), TypeError); + assert.throws(() => recordTierGate('acme', 'admission', { hash: '', gatedBy: 'x#1' }, { file }), TypeError); +}); + +test('evidence is bounded to 2048 characters', () => { + const file = tempFile(); + const huge = 'x'.repeat(3000); + recordTierResult('acme', 'admission', { hash: HASH_A, evidence: huge }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers.admission.evidence.length, 2048); +}); + +// ── Finding 10: grantsFor/gatedTiersFor are reporting surfaces, not the +// capability reader — currentHash lets them ANNOTATE/void staleness for a +// status display without hiding it. grantedCapabilitiesFor stays the only +// hash-blind-proof reader. ─────────────────────────────────────────────── + +test('grantsFor: no currentHash -> no stale field at all (raw reporting)', () => { + const file = tempFile(); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(Object.hasOwn(record, 'stale'), false); +}); + +test('grantsFor: currentHash supplied -> stale:false on match, stale:true on mismatch, record still returned either way', () => { + const file = tempFile(); + recordTierResult('acme', 'admission', { hash: HASH_A, evidence: 'e' }, { file }); + + const fresh = grantsFor('acme', { file, currentHash: HASH_A }); + assert.equal(fresh.stale, false); + assert.equal(fresh.hash, HASH_A); + assert.ok(fresh.tiers.admission, 'stale annotation must not hide the underlying evidence'); + + const stale = grantsFor('acme', { file, currentHash: HASH_B }); + assert.equal(stale.stale, true); + assert.equal(stale.hash, HASH_A); + assert.ok(stale.tiers.admission, 'a stale record must still be visible, just flagged, not hidden'); +}); + +test('gatedTiersFor: currentHash omitted returns every gated tier regardless of hash', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + assert.equal(gatedTiersFor('acme', { file }).length, 1); + assert.equal(gatedTiersFor('acme', { file, currentHash: HASH_A }).length, 1); +}); + +test('gatedTiersFor: currentHash mismatch voids gated-tier records, same as grants', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + assert.deepEqual(gatedTiersFor('acme', { file, currentHash: HASH_B }), []); +}); + +// ── Finding 11: a grant-bearing tier ('primary-eligible', 'statusline') +// must never be recorded 'passed' with empty evidence — a grant must always +// trace back to real conformance evidence (ADR-0031 §1). ────────────────── + +test('recordTierResult on a grant-bearing tier requires non-empty evidence', () => { + const file = tempFile(); + assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: '' }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: ' ' }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: '' }, { file }), TypeError); + // nothing was recorded by any of the rejected attempts + assert.equal(grantsFor('acme', { file }), null); +}); + +test('recordTierResult on an evidence-only tier (admission) still accepts empty/missing evidence', () => { + const file = tempFile(); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers.admission.status, 'passed'); + assert.equal(record.tiers.admission.evidence, ''); +}); diff --git a/tests/kit/adapter-sources.test.mjs b/tests/kit/adapter-sources.test.mjs new file mode 100644 index 0000000..70fd269 --- /dev/null +++ b/tests/kit/adapter-sources.test.mjs @@ -0,0 +1,572 @@ +// Remote manifest sources (ADR-0031 P6). All offline: fetchFn/execFileFn are +// always injected, no real network or npm/tar invocation happens. Verifies +// the three source forms (file / https / npm), their failure modes each +// mapped to a named SourceError.reason, and — via admitAdapters — that the +// resolve-before-hash ordering makes a mutated remote source show up as +// 'consent-stale' rather than being silently re-trusted. +// +// Also carries regression coverage for the security-review fix-first pass: +// (1) a hanging https body read must be bounded by timeoutMs, not hang +// forever; (2) npm tar extraction never touches disk (stdout-only); +// (6) file sources refuse symlinks/directories and enforce maxBytes; +// (7) the npm version/tag half is validated, not just character-classed; +// (14) external text is sanitized (control chars stripped, length bounded) +// before it enters a SourceError detail. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveManifestSource, SourceError } from '../../src/lib/adapters/sources.mjs'; +import { admitAdapters, hashManifest } from '../../src/lib/adapters/admission.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; + +function validHost(overrides = {}) { + return { + id: 'hermes', + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: true, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +function validManifest(overrides = {}) { + return { + name: 'hermes', + version: '1.0.0', + contract: 1, + host: validHost(), + detection: { bin: 'hermes' }, + driving: { surfaces: ['acp'] }, + trust: { + changes: [{ + id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'subprocess hooks', effect: 'run consented lifecycle hooks for hermes', + }], + }, + ...overrides, + }; +} + +function trustingConsent(trusted = {}) { + return { + recordedHashFor: (name) => trusted[name] ?? null, + isTrusted: (name, hash) => trusted[name] === hash, + }; +} + +/** Builds a fetch-shaped Response stub: ok/status/headers.get/body.getReader + * over the given bytes, delivered as a single chunk. */ +function jsonResponse(payload, { status = 200, declareLength = true } = {}) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + return bytesResponse(bytes, { status, declareLength }); +} + +function textResponse(text, { status = 200, declareLength = true } = {}) { + return bytesResponse(new TextEncoder().encode(text), { status, declareLength }); +} + +function bytesResponse(bytes, { status = 200, declareLength = true } = {}) { + let sent = false; + return { + ok: status >= 200 && status < 300, + status, + type: 'basic', + headers: { get: (name) => (declareLength && name.toLowerCase() === 'content-length' ? String(bytes.length) : null) }, + body: { + getReader: () => ({ + read: async () => { + if (sent) return { done: true, value: undefined }; + sent = true; + return { done: false, value: bytes }; + }, + cancel: async () => {}, + }), + }, + }; +} + +/** A reader that yields multiple chunks, exceeding maxBytes only once all + * are combined (so a Content-Length precheck can't catch it — it must be + * caught by the streamed cap instead). Tracks whether cancel() was called. */ +function chunkedOversizeResponse(chunks) { + let index = 0; + let cancelled = false; + return { + response: { + ok: true, + status: 200, + type: 'basic', + headers: { get: () => null }, // no Content-Length declared + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true, value: undefined }; + const value = chunks[index++]; + return { done: false, value }; + }, + cancel: async () => { cancelled = true; }, + }), + }, + }, + wasCancelled: () => cancelled, + }; +} + +/** A response whose body reader hangs forever — never resolves, never + * rejects, and never observes any signal itself. Used to prove + * resolveHttpsSource bounds the BODY READ, not just the initial fetch + * (regression for finding 1). */ +function hangingBodyResponse() { + return { + ok: true, + status: 200, + type: 'basic', + headers: { get: () => null }, + body: { + getReader: () => ({ + read: () => new Promise(() => {}), + cancel: async () => {}, + }), + }, + }; +} + +const neverCalled = (label) => (...args) => { throw new Error(`${label} must not be called (args: ${JSON.stringify(args)})`); }; + +// ── file source ────────────────────────────────────────────────────────── + +test('file path source: passthrough read + JSON.parse, no network', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-')); + const file = path.join(dir, 'manifest.json'); + const payload = { name: 'hermes', contract: 1 }; + await fs.writeFile(file, JSON.stringify(payload), 'utf8'); + try { + const result = await resolveManifestSource(file, { fetchFn: neverCalled('fetchFn') }); + assert.deepEqual(result.raw, payload); + assert.equal(result.origin, 'file'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('file path source: missing file -> source-unreachable', async () => { + await assert.rejects( + () => resolveManifestSource('/nonexistent/path/does-not-exist.json'), + (error) => error instanceof SourceError && error.reason === 'source-unreachable', + ); +}); + +test('file source (finding 6): a symlink is refused, not followed, even to a valid manifest', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-symlink-')); + const real = path.join(dir, 'real.json'); + const link = path.join(dir, 'link.json'); + await fs.writeFile(real, JSON.stringify(validManifest()), 'utf8'); + await fs.symlink(real, link); + try { + await assert.rejects( + () => resolveManifestSource(link), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('file source (finding 6): a directory is refused', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-dir-')); + try { + await assert.rejects( + () => resolveManifestSource(dir), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('file source (finding 6): an oversized file is refused before being fully read', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-big-')); + const file = path.join(dir, 'big.json'); + await fs.writeFile(file, JSON.stringify({ padding: 'x'.repeat(1000) }), 'utf8'); + try { + await assert.rejects( + () => resolveManifestSource(file, { maxBytes: 100 }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +// ── https:// source ────────────────────────────────────────────────────── + +test('http:// is refused as source-insecure, before any fetch', async () => { + await assert.rejects( + () => resolveManifestSource('http://example.com/manifest.json', { fetchFn: neverCalled('fetchFn') }), + (error) => error instanceof SourceError && error.reason === 'source-insecure', + ); +}); + +test('a 3xx redirect response is refused, not followed', async () => { + const fetchFn = async () => ({ + type: 'opaqueredirect', status: 0, ok: false, headers: { get: () => null }, body: null, + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable' + && /redirects are not followed/.test(error.message), + ); +}); + +test('a plain 3xx status (non-opaque) is also refused', async () => { + const fetchFn = async () => ({ + type: 'basic', status: 302, ok: false, headers: { get: () => null }, body: null, + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable', + ); +}); + +test('declared Content-Length above maxBytes is rejected before any body read', async () => { + let readerCreated = false; + const fetchFn = async () => ({ + ok: true, status: 200, type: 'basic', + headers: { get: (name) => (name.toLowerCase() === 'content-length' ? '999999' : null) }, + body: { getReader: () => { readerCreated = true; return { read: neverCalled('reader.read') }; } }, + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, maxBytes: 100 }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + assert.equal(readerCreated, false, 'the body must never be read once Content-Length alone exceeds the cap'); +}); + +test('an oversized streamed body (no declared Content-Length) is caught by the streamed cap and cancels the reader', async () => { + const chunk = new TextEncoder().encode('x'.repeat(8)); + const { response, wasCancelled } = chunkedOversizeResponse([chunk, chunk]); // 16 bytes total + const fetchFn = async () => response; + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, maxBytes: 10 }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + assert.equal(wasCancelled(), true, 'the reader must be cancelled the moment the cap is crossed'); +}); + +test('a timeout during the initial fetch aborts and reports source-unreachable', async () => { + const fetchFn = (url, opts) => new Promise((_, reject) => { + opts.signal.addEventListener('abort', () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, timeoutMs: 20 }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable' && /timed out/.test(error.message), + ); +}); + +test('regression (finding 1): a hanging body read is bounded by timeoutMs, not left to hang forever', async () => { + const fetchFn = async () => hangingBodyResponse(); + const start = Date.now(); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, timeoutMs: 300 }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable' && /timed out/.test(error.message), + ); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1500, `expected the body-read hang to be bounded by timeoutMs, took ${elapsed}ms`); +}); + +test('invalid JSON body -> source-invalid-json', async () => { + const fetchFn = async () => textResponse('{ this is not valid json'); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }), + (error) => error instanceof SourceError && error.reason === 'source-invalid-json', + ); +}); + +test('a valid https manifest resolves with origin "url"', async () => { + const payload = validManifest(); + const fetchFn = async () => jsonResponse(payload); + const result = await resolveManifestSource('https://example.com/manifest.json', { fetchFn }); + assert.deepEqual(result.raw, payload); + assert.equal(result.origin, 'url'); +}); + +// ── npm: source ───────────────────────────────────────────────────── + +/** Injected into every npm-path test in place of the real resolveShim + * (src/lib/exec.mjs). On Windows, the real resolveShim rewrites npm/tar + * into PowerShell-shim invocations (a different command and a different + * argv shape), which would make these execFileFn stubs — written against + * the LOGICAL invocation (cmd === 'npm'/'tar', args starting with + * 'pack'/'-xzOf') — see something they don't recognize and fail, even + * though production behavior is correct. That Windows-shim rewriting is + * already covered by exec.mjs's own test suite; this module's tests only + * need to prove sources.mjs's own logic, platform-independently. */ +const passthroughResolveShim = (command, args) => ({ command, args, resolved: true }); + +/** Stub execFileFn that fabricates what real npm+tar would have produced: + * `npm pack` "writes" a .tgz into --pack-destination, and + * `tar -xzOf package/ak-adapter.json` "extracts" by returning the + * member content on stdout — never touching disk, matching the real + * implementation's stdout-only extraction (finding 2). No real npm/tar + * binary or network is invoked. + * - failTar: simulate tar exiting non-zero (member not in the archive). + * - emptyMember: simulate a symlink/non-regular member — tar succeeds + * (exit 0) but produces no data on stdout. */ +function fakeNpmExecFileFn(payload, { failTar = false, emptyMember = false } = {}) { + return async (_cmd, args) => { + if (args[0] === 'pack') { + const destIndex = args.indexOf('--pack-destination'); + const dest = args[destIndex + 1]; + fsSync.writeFileSync(path.join(dest, 'fake-pkg-1.0.0.tgz'), 'fake tarball bytes'); + return { stdout: '', stderr: '' }; + } + if (args[0] === '-xzOf') { + if (failTar) throw Object.assign(new Error('tar: package/ak-adapter.json not found in archive'), { code: 2 }); + if (emptyMember) return { stdout: '', stderr: '' }; + return { stdout: JSON.stringify(payload), stderr: '' }; + } + throw new Error(`unexpected command: ${args.join(' ')}`); + }; +} + +test('npm: happy path — resolves ak-adapter.json extracted (via stdout) from the package root, and cleans up its temp dir', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-')); + try { + const result = await resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn(payload), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + assert.equal(result.origin, 'npm'); + const remaining = await fs.readdir(tmpBase); + assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed after a successful resolve'); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: scoped package spec with a version resolves correctly', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-scoped-')); + try { + const result = await resolveManifestSource('npm:@acme/hermes-adapter@2.1.0', { + execFileFn: fakeNpmExecFileFn(payload), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: exact semver version is accepted', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-ver-')); + try { + const result = await resolveManifestSource('npm:fake-pkg@1.2.3', { + execFileFn: fakeNpmExecFileFn(payload), resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: a dist-tag version ("latest") is accepted', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-tag-')); + try { + const result = await resolveManifestSource('npm:fake-pkg@latest', { + execFileFn: fakeNpmExecFileFn(payload), resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm (finding 7): a git-shorthand disguised as a version is rejected before execFileFn is called', async () => { + await assert.rejects( + () => resolveManifestSource('npm:pkg@attacker/repo', { + execFileFn: neverCalled('execFileFn'), resolveShimFn: passthroughResolveShim, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid' + && /not a version or dist-tag/.test(error.message), + ); +}); + +test('npm (finding 7): a local-path-shaped version is rejected before execFileFn is called', async () => { + await assert.rejects( + () => resolveManifestSource('npm:pkg@../../x', { + execFileFn: neverCalled('execFileFn'), resolveShimFn: passthroughResolveShim, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + ); +}); + +test('npm: package-name injection attempts are rejected before execFileFn is ever called', async () => { + const attempts = ['npm:foo; rm -rf /', 'npm:foo$(x)', 'npm:foo bar', 'npm:foo`x`', 'npm:foo|bar']; + for (const source of attempts) { + await assert.rejects( + () => resolveManifestSource(source, { + execFileFn: neverCalled(`execFileFn for ${source}`), resolveShimFn: passthroughResolveShim, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + `expected ${source} to be rejected as source-invalid`, + ); + } +}); + +test('npm (finding 2): tar exiting non-zero (member not in archive) -> source-invalid, temp dir cleaned up', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-missing-')); + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn({}, { failTar: true }), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid' + && /does not ship an ak-adapter\.json/.test(error.message), + ); + const remaining = await fs.readdir(tmpBase); + assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed even on a failure path'); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm (finding 2): a symlink member (empty stdout, tar exits 0) is refused explicitly and honestly', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-symlink-')); + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn({}, { emptyMember: true }), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid' + && /produced no content/.test(error.message), + ); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: npm pack itself failing -> source-unreachable, temp dir cleaned up', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-packfail-')); + const execFileFn = async () => { throw new Error('npm ERR! 404 Not Found'); }; + try { + await assert.rejects( + () => resolveManifestSource('npm:does-not-exist@9.9.9', { + execFileFn, resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable', + ); + const remaining = await fs.readdir(tmpBase); + assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed even when npm pack fails'); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm (finding 5): oversized extracted manifest (stdout) -> source-too-large', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-oversize-')); + const bigPayload = { name: 'hermes', padding: 'x'.repeat(1000) }; + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn(bigPayload), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + maxBytes: 100, + }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('regression (finding 14): control characters (C0 and C1, incl. U+009B CSI) are stripped and detail length is bounded before entering a SourceError', async () => { + const dirty = `line one\nline two\x1B[8A\x9B2Jmalicious cursor move${'z'.repeat(500)}`; + const execFileFn = async () => { throw new Error(dirty); }; + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-dirty-')); + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn, resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }), + (error) => { + assert.ok(error instanceof SourceError); + assert.ok(!error.message.includes('\x1B'), 'ESC control byte must be stripped'); + assert.ok(!error.message.includes('\x9b'), 'C1 CSI (U+009B) must be stripped'); + assert.ok(!error.message.includes('\n'), 'newline must be stripped'); + assert.ok(error.message.length < 300, `detail must be length-bounded, got ${error.message.length}`); + return true; + }, + ); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +// ── integration: admitAdapters + resolve-before-hash ordering ────────────── + +test('admitAdapters: a mutated https-sourced manifest since consent was recorded is refused as consent-stale', async () => { + const first = validManifest(); + const second = validManifest({ version: '1.0.1' }); // valid, but different content -> different hash + + // Simulate: consent was recorded against an EARLIER resolve of `first`. + const firstValidated = validateAdapterManifest(first); + const consentedHash = hashManifest(firstValidated); + + // By the time admission actually runs, the remote now serves `second`. + const fetchFn = async () => jsonResponse(second); + const readManifest = (source) => resolveManifestSource(source, { fetchFn }).then((r) => r.raw); + + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'https://example.com/hermes.json' }] }, + readManifest, + consent: trustingConsent({ hermes: consentedHash }), + }); + + assert.equal(results.length, 1); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'consent-stale'); +}); + +test('admitAdapters: an unchanged https-sourced manifest matching consent is admitted', async () => { + const manifest = validManifest(); + const validated = validateAdapterManifest(manifest); + const hash = hashManifest(validated); + + const fetchFn = async () => jsonResponse(manifest); + const readManifest = (source) => resolveManifestSource(source, { fetchFn }).then((r) => r.raw); + + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'https://example.com/hermes.json' }] }, + readManifest, + consent: trustingConsent({ hermes: hash }), + }); + + assert.equal(results[0].admitted, true); +}); diff --git a/tests/kit/host-adapters-cli.test.mjs b/tests/kit/host-adapters-cli.test.mjs new file mode 100644 index 0000000..bd938cd --- /dev/null +++ b/tests/kit/host-adapters-cli.test.mjs @@ -0,0 +1,715 @@ +// x host adapters — the trust CLI (ADR-0031 P1). Covers: flag gating (zero +// reads/writes when off), the trust happy path (consent recorded at +// hashManifest(validateAdapterManifest(raw))), refusal of an invalid +// manifest, the non-interactive-without---yes fail-closed guard, idempotent +// re-trust, stale-hash disclosure, revoke true/false, list states, and an +// unknown adapter name. +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 { run } from '../../src/commands/x/host-adapters.mjs'; +import { hashManifest } from '../../src/lib/adapters/admission.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { + recordedHashFor, recordConsent, revokeConsent, +} from '../../src/lib/adapters/consent.mjs'; +import { HOST_REGISTRY } from '../../src/lib/adapters/registries.mjs'; + +const ON_ENV = { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }; +const OFF_ENV = {}; + +function validHost(overrides = {}) { + return { + id: 'hermes', + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: true, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +function validManifest(overrides = {}) { + return { + name: 'hermes', + version: '1.0.0', + contract: 1, + host: validHost(), + detection: { bin: 'hermes' }, + driving: { surfaces: ['acp'] }, + lifecycle: { detect: { hook: { command: ['hermes', 'detect'], timeoutMs: 5000 } } }, + trust: { + changes: [{ + id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'subprocess hooks', effect: 'run consented lifecycle hooks for hermes', + }], + }, + ...overrides, + }; +} + +function tmpConsentFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-consent-')); + return path.join(dir, 'adapter-consent.json'); +} + +function fileConsent(file) { + return { + recordedHashFor: (name) => recordedHashFor(name, { file }), + recordConsent: (name, hash) => recordConsent(name, hash, { file }), + revokeConsent: (name) => revokeConsent(name, { file }), + }; +} + +function cfgWith(entries) { + return { hostAdapters: entries }; +} + +const neverCalled = (label) => (...args) => { throw new Error(`${label} must not be called (args: ${JSON.stringify(args)})`); }; + +function capture() { + const lines = []; + const orig = console.log; + console.log = (...args) => lines.push(args.join(' ')); + return { + text: () => lines.join('\n'), + lines: () => lines.slice(), + restore: () => { console.log = orig; }, + }; +} + +// ── flag gating ────────────────────────────────────────────────────────── + +test('flag unset refuses list/trust with exit 2 and zero consent-store calls', async () => { + const consent = { + recordedHashFor: neverCalled('recordedHashFor'), + recordConsent: neverCalled('recordConsent'), + revokeConsent: neverCalled('revokeConsent'), + }; + const reader = neverCalled('reader'); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + for (const positionals of [['list'], ['trust', 'hermes'], []]) { + const cap = capture(); + let code; + try { + code = await run({ positionals, env: OFF_ENV, consent, reader, cfg, flags: { yes: true } }); + } finally { + cap.restore(); + } + assert.equal(code, 2, `positionals=${JSON.stringify(positionals)}`); + } +}); + +// revoke is fail-safe (finding 9): an operator who turns the experimental +// flag OFF must still be able to withdraw a standing consent record, or it +// silently reactivates the next time the flag is turned back on. +test('revoke works even when the experimental flag is off (fail-safe)', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + recordConsent('hermes', 'some-hash', { file }); + + const cap = capture(); + let code; + try { + code = await run({ positionals: ['revoke', 'hermes'], env: OFF_ENV, consent, cfg: cfgWith([]), flags: {} }); + } finally { + cap.restore(); + } + + assert.equal(code, 0); + assert.match(cap.text(), /revoked/); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +// ── trust: happy path ─────────────────────────────────────────────────── + +test('trust happy path records consent at hashManifest(validateAdapterManifest(raw))', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const expectedHash = hashManifest(validateAdapterManifest(raw)); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 0); + assert.equal(recordedHashFor('hermes', { file }), expectedHash); + assert.match(cap.text(), /sha256/); + assert.match(cap.text(), /consent recorded/); +}); + +test('trust discloses capabilities, lifecycle hooks (full command + timeout), and trust.changes before confirming', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + let asked = false; + + const cap = capture(); + try { + await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: async () => { asked = true; return true; }, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + assert.ok(asked, 'ask() must be called before recording'); + const text = cap.text(); + assert.match(text, /version:\s+1\.0\.0/); + assert.match(text, /contract:\s+1/); + assert.match(text, /host id:\s+hermes/); + assert.match(text, /canDriveSession/); + assert.match(text, /canRouteActivities/); + assert.doesNotMatch(text, /canBePrimary(?!.*false)/); // only TRUE caps are listed + assert.match(text, /detect: \["hermes","detect"\] \(timeout 5000ms\)/); + assert.match(text, /hermes: subprocess hooks — run consented lifecycle hooks for hermes/); +}); + +// ── trust: full-manifest disclosure (finding 4) ───────────────────────── +// ADR-0029 §6 requires consent over the WHOLE manifest, not a curated +// subset. host.legacy.* and host.trust.approvalPolicy are hashed but the +// curated summary never names them by label — they must still surface, via +// the full JSON block. + +test('trust discloses the complete validated manifest, including host.legacy and host.trust.approvalPolicy', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest({ + host: validHost({ + trust: { approvalPolicy: 'managed', changes: [] }, + legacy: { guidanceFile: 'hermes-guidance', enableEnv: 'HERMES_ENABLE_FLAG' }, + }), + }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + try { + await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + const text = cap.text(); + assert.match(text, /full manifest \(exactly the content being hashed\):/); + assert.match(text, /"approvalPolicy":\s*"managed"/); + assert.match(text, /"guidanceFile":\s*"hermes-guidance"/); + assert.match(text, /"enableEnv":\s*"HERMES_ENABLE_FLAG"/); +}); + +// ── trust: control-char/ANSI sanitization (finding 3, BLOCKER) ────────── +// A crafted trust.changes field carrying cursor-movement/erase escapes plus +// newlines must never be able to rewrite the disclosure the operator is +// about to consent to. + +test('trust strips ANSI escapes and collapses newlines in trust.changes fields before printing', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + // Built via String.fromCharCode, not a \x1b literal in a regex later — + // eslint's no-control-regex flags a control char INSIDE a regex pattern, + // so assertions below check via String#includes, never a regex literal. + const ESC = String.fromCharCode(0x1b); + const hostile = `${ESC}[8A${ESC}[Jfake line\ninjected`; + const raw = validManifest({ + trust: { + changes: [{ + id: 'hostile-change', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'x', effect: hostile, + }], + }, + }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + try { + await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + const text = cap.text(); + assert.ok(!text.includes(ESC), 'no raw ESC byte must reach the terminal'); + assert.ok(!text.includes('fake line\ninjected'), 'the curated-summary line must not contain a raw newline'); + assert.match(text, /fake line injected/, 'newline is collapsed to a single space, not silently dropped'); +}); + +// ── C1 residual (0x80-0x9F, e.g. U+009B CSI) ──────────────────────────── +// JSON.stringify does NOT escape C1 — only C0 (0x00-0x1F) is escaped by +// construction. A raw C1 byte in a manifest field must not survive into +// EITHER disclosure path: the curated summary (via stripControl) or the +// full-manifest JSON block (via the per-line stripControl pass). + +test('a raw C1 control character (U+009B, CSI) is stripped from both the curated summary and the full-manifest JSON block', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const CSI = String.fromCharCode(0x9b); + const raw = validManifest({ + trust: { + changes: [{ + id: 'c1-change', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'x', effect: `before${CSI}after`, + }], + }, + }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + try { + await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + const text = cap.text(); + assert.ok(!text.includes(CSI), 'no raw C1 control character may reach the terminal, in either disclosure path'); + assert.match(text, /beforeafter/, 'the C1 byte is removed outright (it is not \\n/\\t/\\r, so no space is substituted)'); +}); + +// ── disclosure ordering (finding 16, BLOCKER) ─────────────────────────── +// The full-manifest JSON block can run many screens long on a large-but- +// legal manifest — whatever prints immediately before the [y/N] prompt is +// what the operator actually reads, so the decision-critical summary +// (capabilities, lifecycle hooks, trust changes, sha256) must come AFTER +// the JSON block, not before it. + +test('disclosure prints the full-manifest JSON block first and the decision-critical summary last, immediately before the confirm prompt', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let lineCountAtAsk = null; + const ask = async () => { lineCountAtAsk = cap.lines().length; return true; }; + try { + await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + const lines = cap.lines(); + const jsonHeaderIdx = lines.findIndex((l) => l.includes('full manifest (exactly the content being hashed):')); + assert.ok(jsonHeaderIdx >= 0, 'full-manifest header must be present'); + const jsonBlockIdx = jsonHeaderIdx + 1; + assert.match(lines[jsonBlockIdx], /"contract": 1/, 'the line right after the header must be the JSON dump itself'); + + const capsIdx = lines.findIndex((l) => l.includes('capabilities:')); + const hookIdx = lines.findIndex((l) => l.includes('detect: ')); + const sha256Idx = lines.findIndex((l) => l.includes('sha256:')); + + assert.ok(capsIdx > jsonBlockIdx, 'capabilities line must come after the full-manifest block'); + assert.ok(hookIdx > jsonBlockIdx, 'lifecycle hook line must come after the full-manifest block'); + assert.ok(sha256Idx > jsonBlockIdx, 'sha256 line must come after the full-manifest block'); + assert.ok(lineCountAtAsk !== null, 'ask() must have been called'); + assert.equal(sha256Idx, lineCountAtAsk - 1, 'sha256 must be the LAST line printed before the confirm prompt fires'); +}); + +// ── trust: refuses an invalid manifest ────────────────────────────────── + +test('trust refuses a manifest claiming canBePrimary; reason surfaces, nothing recorded', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, canBePrimary: true } }) }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, flags: { yes: true }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 1); + assert.match(cap.text(), /cap-can-be-primary/); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +test('trust refuses a manifest whose host id shadows a built-in host, before any disclosure/confirm/write', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const builtinId = HOST_REGISTRY[0].id; + const raw = validManifest({ name: builtinId, host: validHost({ id: builtinId }) }); + const cfg = cfgWith([{ name: builtinId, source: 'mem://shadow' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', builtinId], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, flags: { yes: true }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 1); + assert.match(cap.text(), /builtin-shadow/); + assert.doesNotMatch(cap.text(), /sha256/, 'must refuse before disclosure, never reach the confirm step'); + assert.equal(recordedHashFor(builtinId, { file }), null); +}); + +test('trust refuses a manifest whose host.id does not match the cfg entry name (confirms admitOne parity)', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + // cfg entry says 'hermes', but the manifest's own host.id says 'not-hermes'. + const raw = validManifest({ host: validHost({ id: 'not-hermes' }) }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, flags: { yes: true }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 1); + assert.match(cap.text(), /name-mismatch/); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +// ── trust: non-interactive without --yes ──────────────────────────────── + +test('trust without --yes in a non-interactive session fails 2 before recording', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: false, flags: {}, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 2); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +// ── trust: idempotent at same hash ────────────────────────────────────── + +test('trust is idempotent when already trusted at the exact same hash (no rewrite, exit 0)', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordConsent('hermes', hash, { file }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 0); + assert.match(cap.text(), /already trusted/); + assert.equal(recordedHashFor('hermes', { file }), hash); +}); + +// ── trust: stale-hash discloses both hashes ───────────────────────────── + +test('trust at a different recorded hash discloses both the stale and current hash before confirming', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const newHash = hashManifest(validateAdapterManifest(raw)); + recordConsent('hermes', 'stale-hash-abc123', { file }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, flags: {}, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 0); + const text = cap.text(); + assert.match(text, /stale-hash-abc123/); + assert.match(text, new RegExp(newHash)); + assert.equal(recordedHashFor('hermes', { file }), newHash); +}); + +// ── trust: --expect-hash pinning (finding 8) ──────────────────────────── +// --yes alone consents to "whatever content the remote serves right now" — +// exactly the wrong thing for an unattended (CI) run. A non-file origin +// under --yes must require an explicit --expect-hash pin. + +test('--yes against a non-file origin without --expect-hash fails 2 before any disclosure or recording', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'https://example.invalid/hermes.json' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => ({ raw, origin: 'url' }), + ask: neverCalled('ask'), isTTY: true, flags: { yes: true }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 2); + assert.doesNotMatch(cap.text(), /full manifest \(exactly the content being hashed\)/, 'must refuse before disclosure'); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +test('--yes against a non-file origin WITH a matching --expect-hash records consent', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const expectedHash = hashManifest(validateAdapterManifest(raw)); + const cfg = cfgWith([{ name: 'hermes', source: 'https://example.invalid/hermes.json' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => ({ raw, origin: 'url' }), + ask: neverCalled('ask'), isTTY: false, flags: { yes: true, 'expect-hash': expectedHash }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 0, cap.text()); + assert.equal(recordedHashFor('hermes', { file }), expectedHash); +}); + +test('a mismatched --expect-hash fails 1 and records nothing', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, + flags: { yes: true, 'expect-hash': 'not-the-real-hash' }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 1); + assert.match(cap.text(), /hash mismatch/); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +test('a file-origin --yes trust with no --expect-hash still works (pin is only required for non-file origins)', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const expectedHash = hashManifest(validateAdapterManifest(raw)); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => ({ raw, origin: 'file' }), // explicit file semantics + ask: neverCalled('ask'), isTTY: true, flags: { yes: true }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 0, cap.text()); + assert.equal(recordedHashFor('hermes', { file }), expectedHash); +}); + +// finding 18: a bare-raw reader result (no {raw,origin} wrapper) must default +// to origin 'unknown', NOT 'file' — 'file' was fail-open (it let --yes skip +// the --expect-hash pin for a source that was never actually proven local). +test('a bare-raw reader result defaults to origin unknown, so --yes without --expect-hash now fails 2', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const raw = validManifest(); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'hermes'], env: ON_ENV, consent, cfg, + reader: async () => raw, // bare-raw, unwrapped — origin defaults to 'unknown' + ask: neverCalled('ask'), isTTY: true, flags: { yes: true }, + }); + } finally { + cap.restore(); + } + + assert.equal(code, 2); + assert.match(cap.text(), /non-file origin/); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +// ── revoke ─────────────────────────────────────────────────────────────── + +test('revoke reports true (existed) when consent was recorded', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + recordConsent('hermes', 'some-hash', { file }); + const cap = capture(); + let code; + try { + code = await run({ positionals: ['revoke', 'hermes'], env: ON_ENV, consent, cfg: cfgWith([]), flags: {} }); + } finally { cap.restore(); } + assert.equal(code, 0); + assert.match(cap.text(), /revoked/); + assert.equal(recordedHashFor('hermes', { file }), null); +}); + +test('revoke reports no recorded consent when none existed', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + const cap = capture(); + let code; + try { + code = await run({ positionals: ['revoke', 'ghost'], env: ON_ENV, consent, cfg: cfgWith([]), flags: {} }); + } finally { cap.restore(); } + assert.equal(code, 0); + assert.match(cap.text(), /no recorded consent/); +}); + +// ── list states ────────────────────────────────────────────────────────── + +test('list reports trusted/stale/not-consented/manifest-error per entry', async () => { + const file = tmpConsentFile(); + const consent = fileConsent(file); + + const trustedRaw = validManifest({ name: 'trusted-one', host: validHost({ id: 'trusted-one' }) }); + const trustedHash = hashManifest(validateAdapterManifest(trustedRaw)); + recordConsent('trusted-one', trustedHash, { file }); + + const staleRaw = validManifest({ name: 'stale-one', host: validHost({ id: 'stale-one' }) }); + recordConsent('stale-one', 'not-the-real-hash', { file }); + + const notConsentedRaw = validManifest({ name: 'fresh-one', host: validHost({ id: 'fresh-one' }) }); + + const cfg = cfgWith([ + { name: 'trusted-one', source: 'mem://trusted-one' }, + { name: 'stale-one', source: 'mem://stale-one' }, + { name: 'fresh-one', source: 'mem://fresh-one' }, + { name: 'broken-one', source: 'mem://broken-one' }, + ]); + + const reader = async (source) => { + if (source === 'mem://trusted-one') return trustedRaw; + if (source === 'mem://stale-one') return staleRaw; + if (source === 'mem://fresh-one') return notConsentedRaw; + throw new Error('ENOENT: no such file'); + }; + + const cap = capture(); + let code; + try { + code = await run({ positionals: ['list'], env: ON_ENV, consent, cfg, reader, flags: {} }); + } finally { cap.restore(); } + + assert.equal(code, 0); + const text = cap.text(); + assert.match(text, /trusted-one[\s\S]*?trusted/); + assert.match(text, /stale-one[\s\S]*?consent-stale/); + assert.match(text, /fresh-one[\s\S]*?not consented/); + assert.match(text, /broken-one[\s\S]*?manifest error/); +}); + +test('list with no configured adapters is a friendly no-op', async () => { + const cap = capture(); + let code; + try { + code = await run({ positionals: ['list'], env: ON_ENV, consent: fileConsent(tmpConsentFile()), cfg: cfgWith([]), reader: neverCalled('reader'), flags: {} }); + } finally { cap.restore(); } + assert.equal(code, 0); + assert.match(cap.text(), /no host adapters configured/); +}); + +// ── unknown adapter name / unknown subcommand ─────────────────────────── + +test('trust of an unknown adapter name fails 1', async () => { + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['trust', 'nope'], env: ON_ENV, cfg: cfgWith([{ name: 'hermes', source: 'mem://hermes' }]), + consent: fileConsent(tmpConsentFile()), reader: neverCalled('reader'), ask: neverCalled('ask'), flags: { yes: true }, + }); + } finally { cap.restore(); } + assert.equal(code, 1); +}); + +test('unknown subcommand fails with usage, exit 2', async () => { + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['bogus'], env: ON_ENV, cfg: cfgWith([]), + consent: fileConsent(tmpConsentFile()), reader: neverCalled('reader'), flags: {}, + }); + } finally { cap.restore(); } + assert.equal(code, 2); +}); From d1d8b4954ea477a1fe17753123d4749fcfea4dd0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 16 Aug 2026 11:07:57 -0600 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20graduation=20machinery=20wave=20B?= =?UTF-8?q?=20=E2=80=94=20external=20execution=20(ak=20run=20drives=20an?= =?UTF-8?q?=20admitted=20host)=20(ADR-0031=20P2)=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(adapters): manifest execution block + hook-runner stdin/cwd (ADR-0031 P2, schema half) Manifest gains an optional execution.run hook, coupled to canRouteActivities (else refused 'execution-not-routable'); it is part of the hashed content. runAdapterHook gains stdin (EPIPE-safe), an absolute-validated cwd, and separate stdoutText/stderrText captures — the substrate the derived execution adapter needs to run a hook without inheriting ak's cwd or promoting stderr into a downstream prompt. * feat(execution): ak run drives an admitted external host as a supervised subprocess (ADR-0031 P2) executionAdapterFor falls through to a manifest-derived subprocess adapter for an admitted host; routing is overlay-aware via a lazy effectiveRoutableHostIds(). No third-party code runs in-process — the hook is one subprocess ak owns. Security-hardened across an adversarial review (blockers + regressions all re-verified closed): - hooks spawn with cwd pinned to the adapter's own resolved directory, never the operator's cwd; a relative hook on a remote (unanchorable) source is refused ('execution-unanchored'). The consent hash still pins the manifest text; resolution is a pure function of it plus the pinned source. - AK_WORKER_CWD carries the target repo without reopening that pin - an unresolved-launch cancellation reports non-escalating 'orphaned', never an escalatable 'timed_out' (no double-run) - the structured payload is parsed from stdout alone, so a stray stderr line never collapses a worker's handoff or leaks logs into a cross-vendor prompt - reserved hook exit codes 77/78 express permission_required/auth_required - a self-declared provider is stamped 'inferred', handoff data is redacted from public results, and driving.surfaces must include cli-subprocess (no downgrade) The black-box conformance test now drives the real command resolver end-to-end: a genuinely spawned subprocess, cwd anchored by production code. * docs(adr): ADR-0029 cli-subprocess execution contract; ADR-0031 external-execution row Working Records the command-resolution policy, reserved exit codes, and the no-trust-laundering rules settled while wiring external execution, plus the honest boundary of the remote-source anchorability screen. --- docs/adr/0029-host-adapter-extension-point.md | 40 +- ...bility-graduation-and-upstream-requests.md | 4 +- src/lib/adapters/admission.mjs | 66 ++ src/lib/adapters/admitted.mjs | 12 + src/lib/adapters/hook-runner.mjs | 95 ++- src/lib/adapters/manifest.mjs | 39 +- src/lib/execution/adapters.mjs | 14 +- src/lib/execution/admitted.mjs | 362 +++++++++++ src/lib/routing.mjs | 29 +- tests/fixtures/adapters/acme/manifest.json | 7 +- tests/fixtures/adapters/acme/run-hook.mjs | 28 + tests/kit/adapter-conformance.test.mjs | 94 ++- tests/kit/adapter-execution.test.mjs | 597 ++++++++++++++++++ tests/kit/adapter-hook-runner.test.mjs | 145 +++++ tests/kit/adapter-manifest.test.mjs | 68 ++ 15 files changed, 1546 insertions(+), 54 deletions(-) create mode 100644 src/lib/execution/admitted.mjs create mode 100644 tests/fixtures/adapters/acme/run-hook.mjs create mode 100644 tests/kit/adapter-execution.test.mjs diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md index 1998718..36ac07a 100644 --- a/docs/adr/0029-host-adapter-extension-point.md +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -141,7 +141,45 @@ names: An adapter manifest declaring `acp` or `mcp` today fails admission with an explicit "surface not yet supported" diagnostic. It is never silently downgraded to `cli-subprocess` — a silent downgrade would run a hook the adapter author never tested against that surface, exactly the kind of guessed -success ADR-0016 §5 and ADR-0023 already forbid elsewhere. +success ADR-0016 §5 and ADR-0023 already forbid elsewhere. An admitted host is given a +`cli-subprocess` **execution** adapter only when it declares that surface; a manifest missing it is +refused (`surface-unsupported`) rather than downgraded. + +**`cli-subprocess` execution details** (settled while wiring [ADR-0031](0031-capability-graduation-and-upstream-requests.md)'s +external-execution row, after an adversarial review of the surface): + +- **Command resolution is anchored, not ambient.** A hook subprocess runs with its working + directory pinned to the adapter's own resolved directory (a file-sourced manifest's `realpath` + directory), never `ak`'s current working directory — so a manifest declaring + `["node", "run-hook.mjs"]` runs *the adapter's* `run-hook.mjs`, and a file planted in the + operator's cwd is unreachable. A remote-sourced manifest (`npm:`/`https://`) has no persistent + local directory, so a *relative* hook command from such a source is refused + (`execution-unanchored`) rather than resolved against an ambient path; a bare PATH binary + (`node`, `hermes`) stays legal. The consent hash still pins the manifest text verbatim; the + resolution is a pure function of that text plus the (already-pinned) source, so it cannot drift + without the hash changing. + - *Boundary of the anchorability check for remote sources.* When a remote-sourced adapter has no + local directory to anchor to, its hook command spawns in the repository `ak run` was invoked in + (which the operator already runs at full trust, per ADR-0018), and the `execution-unanchored` + refusal is a **best-effort** screen for path-shaped tokens (separators, script extensions, flag + values), not a complete one: an *extensionless, separator-free* relative token + (`["node", "runhook"]`) is indistinguishable by inspection from an ordinary positional argument + (`["hermes-run", "build"]`), so it is not refused and would resolve against the repo. A complete + rule would have to reject every non-absolute, non-flag argument, which would also reject + legitimate positional arguments — a false-positive cost this contract does not pay by default. + The exposure is bounded on every axis that matters: it requires a remote (`npm:`/`https://`) + source, a consented manifest the operator hash-pinned with that exact relative token, and write + access to the operator's repo. A **file-sourced** adapter — the fixture, and every adapter that + ships a bundle — is fully anchored and unaffected. A remote-sourced adapter should declare + absolute paths or PATH binaries; a future contract revision may make that a hard requirement. +- **Reserved exit codes carry consent/auth boundaries.** Hook exit `77` maps to + `permission_required` (a blocked, never-escalated result — escalating around a consent boundary + is the safety violation ADR-0019 already forbids) and `78` to `auth_required`. This gives an + external host an honest way to say "I refused" or "I am not logged in" instead of a bare + non-zero exit that would be re-run on another host. +- **Results never launder trust.** Exit code is the sole authority for success; a self-declared + `provider` in hook stdout is stamped `inferred`, never `observed`; stderr is never promoted into + a downstream worker's prompt; and handoff data is redacted from public `WorkerResult`s. ### 3. Capability caps are schema-structural, not runtime-checked diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index 038d077..9472b77 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -149,11 +149,11 @@ Per the ADR discipline this repository adopted (a dated, self-graded table befor rests on delivery): the **governance decision** is accepted; the **machinery** is staged and mostly unbuilt. This table is the source of truth for what is real. -| Piece | Status (2026-08-16) | Note | +| Piece | Status | Note | | ----- | ------------------- | ---- | | Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) | | `ak host adapters trust` CLI (records consent/grants) | **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) | **Proposed — not built** | The seam is a comment-only lookup today | +| External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` | | External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized | | Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit | | Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave | diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index 0230f81..eba98a1 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -7,11 +7,36 @@ // entry or the built-in registries (try/caught per entry, in admitOne AND as // a belt-and-suspenders net in admitAdapters). import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import { HOST_REGISTRY } from './registries.mjs'; import { validateAdapterManifest } from './manifest.mjs'; export const SUPPORTED_CONTRACT = 1; +/** The adapter's own directory (F-1, ADR-0031): where its execution/lifecycle + * hooks resolve a relative command FROM, never the operator's process.cwd() + * when `ak run` was invoked. A file-sourced manifest anchors to its own + * directory — `fs.realpathSync` so a symlinked manifest can't relocate that + * pin out from under consent. An npm/https source has no persistent local + * bundle (resolved, hashed, and discarded per admission pass — sources.mjs) + * so there is nothing to anchor to: `null`. buildAdmittedExecutionAdapter + * (execution/admitted.mjs) then refuses a relative hook command outright + * for a `null` baseDir rather than guessing a cwd. An unreadable/vanished + * file source also resolves to `null` — the same honest refusal, not a + * silent fallback to process.cwd(). */ +function baseDirForSource(source) { + if (typeof source !== 'string' || !source + || source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) { + return null; + } + try { + return path.dirname(fs.realpathSync(source)); + } catch { + return null; + } +} + /** Deterministic, key-sorted JSON — same stable-stringify shape used * elsewhere in this codebase (e.g. opencode.mjs's deepEqual) so two manifests * that differ only in key order or incidental whitespace hash identically. @@ -193,6 +218,47 @@ export async function bootstrapHostAdapters({ if (admitted.length) { const { applyAdmitted } = await import('./admitted.mjs'); applyAdmitted(admitted); + + // P2 (ADR-0031): an admitted manifest declaring both an execution block + // and host.capabilities.canRouteActivities gets its execution adapter + // derived and registered here, so `ak run` can route to it. Same + // guarded, non-fatal posture as the rest of bootstrap: one adapter's + // registration failure never blocks the others or the admission result. + const executionCandidates = admitted.filter((result) => ( + result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true + )); + if (executionCandidates.length) { + // name -> the cfg entry's own declared source, for F-1's baseDir + // derivation below (admitted results carry the validated manifest, not + // the raw cfg entry that named where it came from). + const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source])); + try { + const { registerAdmittedExecution } = await import('../execution/admitted.mjs'); + for (const result of executionCandidates) { + // F-5 (ADR-0029 §2): a manifest that never declared the + // cli-subprocess driving surface gets no cli-subprocess execution + // adapter — refused with its own reason, before even attempting + // registration (buildAdmittedExecutionAdapter re-checks this too, + // defence-in-depth for any caller that bypasses this filter). + if (!result.manifest?.driving?.surfaces?.includes('cli-subprocess')) { + warnings.push({ + name: result.name, reason: 'surface-unsupported', + detail: `'${result.name}' declares an execution block but not driving.surfaces including 'cli-subprocess'`, + }); + continue; + } + try { + registerAdmittedExecution(result.manifest, { baseDir: baseDirForSource(sourceByName.get(result.name)) }); + } catch (error) { + warnings.push({ name: result.name, reason: error?.reason ?? 'execution-registration-failed', detail: error?.message ?? String(error) }); + } + } + } catch (error) { + for (const result of executionCandidates) { + warnings.push({ name: result.name, reason: 'execution-registration-failed', detail: error?.message ?? String(error) }); + } + } + } } return { active: true, admitted, warnings }; diff --git a/src/lib/adapters/admitted.mjs b/src/lib/adapters/admitted.mjs index 21838fe..5f42e25 100644 --- a/src/lib/adapters/admitted.mjs +++ b/src/lib/adapters/admitted.mjs @@ -62,3 +62,15 @@ export function effectiveHostRegistry() { if (!applied || admittedEntries.length === 0) return HOST_REGISTRY; return Object.freeze([...HOST_REGISTRY, ...admittedEntries]); } + +/** Built-ins ∪ admitted hosts whose manifest declares + * capabilities.canRouteActivities — the LAZY set every routing VALIDATION + * path (routing.mjs's isRoutableHost, validateRoute, materializeRunPlan) + * must consult (P2, ADR-0031). routing.mjs's `HOSTS` constant stays frozen + * at import time and built-ins-only — it is display strings only now, never + * a validation source. Fresh on every call, like admittedHostIds() above. */ +export function effectiveRoutableHostIds() { + return effectiveHostRegistry() + .filter((host) => host.capabilities.canRouteActivities === true) + .map((host) => host.id); +} diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs index 168d6bd..280e1ff 100644 --- a/src/lib/adapters/hook-runner.mjs +++ b/src/lib/adapters/hook-runner.mjs @@ -8,6 +8,7 @@ // summary capture, no graceful-then-forced two-step shutdown. A timed-out // adapter hook gets no cleanup grace period; it already spent its budget. import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process'; +import { isAbsolute as pathIsAbsolute } from 'node:path'; const DEFAULT_TIMEOUT_MS = 30_000; const OUTPUT_CAP_BYTES = 256 * 1024; @@ -88,6 +89,15 @@ function mergeCapture(stdout, stderr) { return `${kept}${TRUNCATION_MARKER}`; } +/** stderr alone, with the same per-stream truncation marker `mergeCapture` + * would append — never folded into `stdout`. F-4: a caller that parses + * `stdout` as a structured payload (e.g. the admitted execution adapter) + * must never see raw stderr promoted into that parse; this is the field it + * reads instead when it needs the process's diagnostic chatter. */ +function boundedText({ text, truncated }) { + return truncated ? `${text}${TRUNCATION_MARKER}` : text; +} + function describeFailure(hostId, verb, error) { const reason = error?.code ? `${error.code} (${error.message ?? 'no message'})` : (error?.message ?? String(error)); return `${hostId}:${verb} adapter hook failed to start: ${reason}`; @@ -104,7 +114,12 @@ function raceTimeout(promise, ms) { * child — a timed-out adapter hook may have spawned descendants of its own. * POSIX: the child was spawned detached so its pid is also its process group * id; signalling `-pid` reaches the whole group. Windows has no portable - * signal for arbitrary console trees, so `taskkill /T /F` owns it there. */ + * signal for arbitrary console trees, so `taskkill /T /F` owns it there. + * F-2: this cannot PROVE a double-forked or re-`setsid`'d grandchild died — + * a signal sent is not a death confirmed. Callers that need that proof (the + * admitted execution adapter's cancel path) must treat an unresolved launch + * as honestly unproven (`orphaned`), not assume this function's return means + * the tree is gone. */ async function killGroup(child) { if (!Number.isInteger(child?.pid)) return; if (isWindows) { @@ -127,31 +142,49 @@ async function killGroup(child) { * (missing/invalid `hook`, `hostId`, or `verb`) throw synchronously. * * @param {{hook:{command:string[], timeoutMs?:number}, hostId:string, - * verb:string, timeoutMs?:number, env?:Record}} options - * @returns {Promise<{ok:boolean, stdout:string, exitCode:number|null, detail:string|null}>} + * verb:string, timeoutMs?:number, env?:Record, stdin?:string, + * cwd?:string}} options + * @returns {Promise<{ok:boolean, stdout:string, stdoutText:string, stderrText:string, + * exitCode:number|null, detail:string|null}>} */ -export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @type {any} */ ({})) { +export async function runAdapterHook({ + hook, hostId, verb, timeoutMs, env, stdin, cwd, +} = /** @type {any} */ ({})) { if (!hook || !Array.isArray(hook.command) || hook.command.length === 0 || !hook.command.every((part) => typeof part === 'string' && part.length > 0)) { throw new TypeError('runAdapterHook requires hook.command as a non-empty array of non-empty strings'); } if (typeof hostId !== 'string' || !hostId) throw new TypeError('runAdapterHook requires a hostId'); if (typeof verb !== 'string' || !verb) throw new TypeError('runAdapterHook requires a verb'); + // F-1: a relative cwd would resolve against wherever the ak process + // happens to be running, defeating the whole point of pinning the child to + // the adapter's own directory — refused synchronously, same class as the + // arg-shape checks above, never silently reinterpreted as "inherit". + if (cwd !== undefined && (typeof cwd !== 'string' || !cwd || !pathIsAbsolute(cwd))) { + throw new TypeError('runAdapterHook requires cwd to be an absolute path when provided'); + } const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs); const [argv0, ...args] = hook.command; const childEnv = minimalEnv(env); + const wantsStdin = typeof stdin === 'string'; let child; try { child = nodeSpawn(argv0, args, { env: childEnv, shell: false, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [wantsStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], detached: !isWindows, + // Absent cwd falls through to Node's own default (inherit + // process.cwd()) — today's behavior for callers that don't pass one + // yet (B2 threads the real adapter-base-dir cwd through this wave). + ...(cwd === undefined ? {} : { cwd }), }); } catch (error) { - return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) }; + return { + ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, error), + }; } const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES); @@ -159,6 +192,21 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /* child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk)); child.stderr?.on('data', (chunk) => stderrCollector.write(chunk)); + if (wantsStdin) { + // A child that exits before (or without) reading stdin makes the pipe + // write EPIPE — that is a normal outcome (the process's own exit code + // already reports what happened), never a reason to crash or reject + // runAdapterHook's promise. The 'close' handler below still fires and + // resolves the race normally regardless of whether this write lands. + child.stdin?.on('error', () => {}); + try { + child.stdin?.end(stdin); + } catch { + // Synchronous throw from an already-closed stream — same non-fatal + // treatment as the async 'error' event above. + } + } + let settled = false; let spawnError = null; const closeResult = new Promise((resolve) => { @@ -175,27 +223,40 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /* if (raced === TIMEOUT_SENTINEL) { await killGroup(child); await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused + const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }; + const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }; return { ok: false, exitCode: null, - stdout: mergeCapture( - { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }, - { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }, - ), + stdout: mergeCapture(stdoutCaptured, stderrCaptured), + stdoutText: boundedText(stdoutCaptured), + stderrText: boundedText(stderrCaptured), detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`, }; } if (spawnError) { - return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError) }; + return { + ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError), + }; } const { code } = raced; - const stdout = mergeCapture( - { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }, - { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }, - ); + // F-4/R-1: stdout stays the combined stream for diagnostics/back-compat, + // but a caller parsing stdout as a structured payload (the admitted + // execution adapter) must read stdoutText instead — stdout has stderr + // folded in after a separator, which breaks JSON.parse the instant the + // hook writes anything to stderr at all. stderrText is diagnostics-only. + const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }; + const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }; + const stdout = mergeCapture(stdoutCaptured, stderrCaptured); + const stdoutText = boundedText(stdoutCaptured); + const stderrText = boundedText(stderrCaptured); return code === 0 - ? { ok: true, stdout, exitCode: 0, detail: null } - : { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` }; + ? { + ok: true, stdout, stdoutText, stderrText, exitCode: 0, detail: null, + } + : { + ok: false, stdout, stdoutText, stderrText, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`, + }; } diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index 801f89f..a22c22b 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -52,7 +52,7 @@ export class ManifestRejected extends TypeError { // validateHostAdapter, src/lib/hosts.mjs, src/lib/providers.mjs) consume — // widen them only alongside a new legitimate consumer, never speculatively. const MANIFEST_ALLOWED_KEYS = Object.freeze([ - 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', + 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', 'execution', ]); // Everything validateHostAdapter itself reads (id, label, install, // capabilities, trust, enabledByDefault, configProjection, observability) @@ -172,6 +172,30 @@ function validateManifestLifecycle(value) { return structuredClone(value); } +// execution.run.hook is the single subprocess `ak run` spawns to drive an +// admitted host as a worker (P2, ADR-0031). Same hook shape and validation +// discipline as a lifecycle verb's hook, but there is exactly one verb +// ('run'), never a caller-named one, so the allowlists are inlined rather +// than looped like validateManifestLifecycle's verb map. +function validateExecution(value) { + assertRecord(value, 'execution'); + assertNoUnknownKeys(value, ['run'], 'execution'); + assertRecord(value.run, 'execution.run'); + assertNoUnknownKeys(value.run, ['hook'], 'execution.run'); + assertRecord(value.run.hook, 'execution.run.hook'); + assertNoUnknownKeys(value.run.hook, ['command', 'timeoutMs'], 'execution.run.hook'); + try { + assertStringArray(value.run.hook.command, 'execution.run.hook.command', { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected('invalid-execution', error.message); + } + if (value.run.hook.timeoutMs !== undefined + && (!Number.isInteger(value.run.hook.timeoutMs) || value.run.hook.timeoutMs <= 0)) { + throw new ManifestRejected('invalid-execution', 'execution.run.hook.timeoutMs must be a positive integer'); + } + return structuredClone(value); +} + function validateManifestTrust(value) { assertRecord(value, 'trust'); assertNoUnknownKeys(value, ['changes'], 'trust'); @@ -283,11 +307,19 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob throw new ManifestRejected('invalid-guidance-file', error.message); } } + // P2 structural coupling (ADR-0031): an execution hook on a host that + // cannot route activities is a contradiction the schema refuses outright, + // never silently ignores. The converse — routable, no execution block — is + // legal and degrades honestly at run time (cli_unavailable). + if (value.execution !== undefined && host.capabilities.canRouteActivities !== true) { + throw new ManifestRejected('execution-not-routable', 'manifest.execution requires host.capabilities.canRouteActivities: true'); + } const detection = validateDetection(value.detection); const driving = validateDriving(value.driving); const lifecycle = value.lifecycle === undefined ? undefined : validateManifestLifecycle(value.lifecycle); const trust = validateManifestTrust(value.trust); + const execution = value.execution === undefined ? undefined : validateExecution(value.execution); return immutable({ name: value.name, @@ -297,6 +329,11 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob detection, driving, ...(lifecycle === undefined ? {} : { lifecycle }), + // execution rides in the same validated-output object hashManifest + // (admission.mjs) canonicalizes and hashes, so declaring/editing an + // execution block changes consent's covered hash automatically — no + // separate hashing path to keep in sync. + ...(execution === undefined ? {} : { execution }), trust, }); } diff --git a/src/lib/execution/adapters.mjs b/src/lib/execution/adapters.mjs index 27e9bb9..3afde5e 100644 --- a/src/lib/execution/adapters.mjs +++ b/src/lib/execution/adapters.mjs @@ -4,6 +4,7 @@ import { OPENCODE_EXECUTION_ADAPTER } from './opencode.mjs'; import { CLAUDE_EXECUTION_ADAPTER } from './claude.mjs'; import { CODEX_EXECUTION_ADAPTER } from './codex.mjs'; import { routableHostIds } from '../adapters/index.mjs'; +import { admittedExecutionAdapterFor } from './admitted.mjs'; export const EXECUTION_ADAPTERS = Object.freeze(new Map([ ['claude', CLAUDE_EXECUTION_ADAPTER], @@ -43,11 +44,10 @@ assertBuiltinAdaptersRoutable(); * single worker instead of failing an entire run. */ export function executionAdapterFor(hostId) { if (EXECUTION_ADAPTERS.has(hostId)) return EXECUTION_ADAPTERS.get(hostId); - // Wave 4 (adapter door): an admitted external host whose manifest declares - // driving.surfaces including 'cli-subprocess' MAY, in a later wave, get a - // constructed execution adapter here. This wave builds none: admitted - // hosts have no execution adapter yet, full stop, so they fall through to - // the same null return (and the runner's existing cli_unavailable - // degradation) as any other routable-but-unadapted host. - return null; + // P2 (ADR-0031): an admitted external host whose manifest declared an + // execution block gets its adapter derived and registered at bootstrap + // (admission.mjs) into execution/admitted.mjs's overlay. A routable host + // with no execution block (or nothing admitted at all) still returns null + // here — the runner's existing cli_unavailable degradation, unchanged. + return admittedExecutionAdapterFor(hostId); } diff --git a/src/lib/execution/admitted.mjs b/src/lib/execution/admitted.mjs new file mode 100644 index 0000000..2a3ba8c --- /dev/null +++ b/src/lib/execution/admitted.mjs @@ -0,0 +1,362 @@ +// Derived execution adapter for an ADMITTED external host (P2, ADR-0031). +// This is the ONLY place an admitted manifest's execution.run.hook ever runs: +// a single-shot supervised subprocess through hook-runner.mjs — no in-process +// third-party code, ever. Deliberately simpler than opencode.mjs: one spawn, +// no server lifecycle, no streaming — launch() completes when the child exits. +import path from 'node:path'; +import { runAdapterHook } from '../adapters/hook-runner.mjs'; +import { resetAdmitted } from '../adapters/admitted.mjs'; +import { have } from '../exec.mjs'; +import { validateExecutionAdapter, validateWorkerResult } from './schema.mjs'; +import { redactHandoffData } from './handoff.mjs'; + +const DEFAULT_TIMEOUT_MS = 120_000; +// hook-runner's own inner timeout is what actually kills the child; the +// runner's outer phase deadline must never race it. Both timers would be +// registered for the same duration if we handed hook-runner the raw +// remaining budget, but hook-runner's timer starts a beat later (spawn +// overhead) — so it would lose that race. Shaving a small margin off what we +// hand the hook keeps the inner kill strictly first. +const LAUNCH_TIMEOUT_MARGIN_MS = 250; +const REASON_MAX_BYTES = 240; +const PROVIDER_MAX_CHARS = 64; +// Wave B security review (F-6): reserved hook exit codes give an admitted +// host an honest consent/auth boundary instead of a silent re-run — part of +// the adapter contract, alongside stdin/env/exit-0 in the module header. +// 77 -> the hook needs interactive/out-of-band consent it does not have +// (mirrors sysexits.h EX_NOPERM in spirit): status 'blocked', +// exitCategory 'permission_required' — already non-escalating +// (runner.mjs's BLOCKING_CATEGORIES). +// 78 -> the hook needs authentication it does not have: status 'failed', +// exitCategory 'auth_required'. +const EXIT_PERMISSION_REQUIRED = 77; +const EXIT_AUTH_REQUIRED = 78; + +const nowIso = () => new Date().toISOString(); + +function boundedReason(text, maxBytes = REASON_MAX_BYTES) { + const value = typeof text === 'string' ? text : String(text ?? ''); + return value.length > maxBytes ? `${value.slice(0, maxBytes - 1)}…` : value; +} + +function execError(reason, message) { + return Object.assign(new Error(message), { reason }); +} + +// F-1: a relative hook command resolves against whatever `cwd` the child +// spawns with. With no anchored adapter base directory (a remote npm/https +// source has no persistent local bundle), that would fall back to the +// OPERATOR's process.cwd() when `ak run` was invoked — arbitrary-code- +// execution by planting a same-named file, with the consent hash unchanged. +// A bare interpreter/binary name found through PATH (node, hermes) is +// unaffected by cwd and stays legal; only a path-separator-bearing or +// script-looking bare token is refused. +const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl)$/i; + +function looksRelative(token) { + if (typeof token !== 'string' || !token) return false; + if (path.isAbsolute(token)) return false; + if (token.includes('/') || token.includes('\\')) return true; + return SCRIPT_LIKE_RE.test(token); +} + +// R-3: EVERY arg is inspected, not just non-flag ones — a `--flag=value` +// token starts with '-' but its value half can still be a relative path +// (`--import=./evil.mjs`), and looksRelative's own `.includes('/')` check +// already catches that once the whole token is examined (no separate '=' +// split needed: the token as a whole still contains '/'). Skipping +// '-'-prefixed args entirely (the pre-R-3 shape) let exactly this slip past +// with a null baseDir. A bare flag with no path-like value (`--verbose`, +// `-v`) is unaffected — it matches neither check and stays legal. +function commandIsUnanchorable(command) { + const [argv0, ...args] = command; + if (looksRelative(argv0)) return true; + return args.some((arg) => looksRelative(arg)); +} + +/** stdout is EITHER a JSON object with optional {summary, observedModel, + * provider, usage} — read when the whole trimmed `stdoutText` (R-1: the + * UNMERGED stdout hook-runner reports; never `stdout`, which folds stderr + * in after a separator and would break JSON.parse the instant the hook + * writes anything to stderr at all) parses as a JSON object — OR plain + * text, treated as the summary capture (ADR-0029 §2). + * F-4: `stderrText` gates ONLY the plain-text path — a hook that wrote to + * stderr (warnings, a crash trace, interpreter noise) never has that text + * silently promoted into the cross-vendor dependency handoff. A genuine + * JSON payload parses and is trusted REGARDLESS of stderr (R-1: a stray + * deprecation warning must not blank out an otherwise-valid summary and + * cascade into "required worker handoff was missing" for every dependent). + * F-7: a self-declared `provider` is bounded (a payload cannot claim an + * unbounded-length vendor name). */ +function parseStdout(stdoutText, stderrText) { + const trimmed = typeof stdoutText === 'string' ? stdoutText.trim() : ''; + const hasStderr = typeof stderrText === 'string' && stderrText.trim() !== ''; + if (!trimmed) return { summary: null, observedModel: null, provider: null, usage: null }; + let parsed; + try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + summary: typeof parsed.summary === 'string' ? parsed.summary : null, + observedModel: typeof parsed.observedModel === 'string' ? parsed.observedModel : null, + provider: typeof parsed.provider === 'string' ? parsed.provider.slice(0, PROVIDER_MAX_CHARS) : null, + usage: parsed.usage && typeof parsed.usage === 'object' && !Array.isArray(parsed.usage) ? parsed.usage : null, + }; + } + if (hasStderr) return { summary: null, observedModel: null, provider: null, usage: null }; + return { summary: trimmed, observedModel: null, provider: null, usage: null }; +} + +/** + * Build a host-neutral execution adapter for one admitted manifest declaring + * `execution.run.hook`. `runHook`/`haveFn`/`clock` are injectable for tests; + * production defaults spawn the real subprocess. `baseDir` (F-1) is the + * adapter's own directory — derived by the caller (admission.mjs) from the + * manifest's `source` at registration time, `null` for a source with no + * persistent local bundle (npm/https) — never process.cwd(). + */ +export function buildAdmittedExecutionAdapter(manifest, { + runHook = runAdapterHook, haveFn = have, clock = nowIso, baseDir = null, +} = {}) { + if (!manifest || typeof manifest !== 'object') throw new TypeError('buildAdmittedExecutionAdapter requires a manifest'); + const hostId = manifest.host?.id; + if (typeof hostId !== 'string' || !hostId) throw new TypeError('buildAdmittedExecutionAdapter requires manifest.host.id'); + // F-8 (defence-in-depth, INEXPRESSIBLE-not-refused doctrine): the manifest + // schema already couples execution -> canRouteActivities (manifest.mjs's + // 'execution-not-routable'), but this is the construction site that + // actually wires a subprocess spawn — it re-asserts the invariant itself + // rather than trusting every caller to have validated upstream. + if (manifest.host?.capabilities?.canRouteActivities !== true) { + throw execError('not-routable', `'${hostId}' execution adapter requires host.capabilities.canRouteActivities:true`); + } + // F-5 (ADR-0029 §2): a manifest that never declared the cli-subprocess + // driving surface gets no cli-subprocess execution adapter — refused, not + // silently downgraded. Re-checked here even though the bootstrap filter + // (admission.mjs) already screens candidates, so no other caller can skip it. + if (!Array.isArray(manifest.driving?.surfaces) || !manifest.driving.surfaces.includes('cli-subprocess')) { + throw execError('surface-unsupported', `'${hostId}' execution adapter requires driving.surfaces to include 'cli-subprocess'`); + } + const hook = manifest.execution?.run?.hook; + if (!hook || !Array.isArray(hook.command) || hook.command.length === 0) { + throw new TypeError(`buildAdmittedExecutionAdapter requires manifest.execution.run.hook for '${hostId}'`); + } + const detectionBin = manifest.detection?.bin; + if (typeof detectionBin !== 'string' || !detectionBin) { + throw new TypeError(`buildAdmittedExecutionAdapter requires manifest.detection.bin for '${hostId}'`); + } + if (baseDir == null && commandIsUnanchorable(hook.command)) { + throw execError('execution-unanchored', + `'${hostId}' declares a relative execution.run.hook.command with no anchored adapter base directory ` + + '(a remote npm/https source has no persistent local bundle) — use an absolute path or a PATH binary'); + } + + function terminalResult(state, base) { + return validateWorkerResult({ + workerId: state.worker.id, activity: state.worker.activity, role: state.worker.role, host: hostId, + startedAt: state.startedAt, endedAt: clock(), + durationMs: Math.max(0, Date.parse(clock()) - Date.parse(state.startedAt)), + provider: null, providerProvenance: 'unknown', configuredModel: state.worker.configuredModel ?? null, + observedModel: null, sessionId: null, transcriptRefs: [], failure: null, usage: null, + ...base, + }); + } + + const adapter = { + id: `${hostId}-adapter`, + + async readiness({ signal, timeoutMs } = /** @type {{signal?:AbortSignal,timeoutMs?:number}} */ ({})) { + signal?.throwIfAborted?.(); + const ready = await haveFn(detectionBin, { signal, timeout: timeoutMs }); + signal?.throwIfAborted?.(); + return ready ? { ready: true } : { ready: false, exitCategory: 'cli_unavailable' }; + }, + + async prepare({ worker, cwd = process.cwd() } = /** @type {{worker?:any,cwd?:string}} */ ({})) { + if (!worker || worker.host !== hostId) throw new TypeError(`${hostId} adapter requires a ${hostId} worker`); + // R-2: state.cwd doubles as the fallback spawn cwd (below) AND rides + // in AK_WORKER_CWD — runAdapterHook itself throws on a non-absolute + // cwd, so this assertion (mirroring opencode.mjs's own worker-cwd + // check) is what makes that downstream guarantee hold, not an + // incidental duplicate of it. + if (!path.isAbsolute(cwd)) throw new TypeError(`${hostId} worker cwd must be absolute`); + return { worker, cwd, prompt: worker.prompt, startedAt: clock() }; + }, + + async launch(state, { timeoutMs, signal } = /** @type {{timeoutMs?:number,signal?:AbortSignal}} */ ({})) { + signal?.throwIfAborted?.(); + const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + const innerTimeoutMs = Math.max(1, budget - LAUNCH_TIMEOUT_MARGIN_MS); + const env = { + AK_WORKER_ID: state.worker.id, + AK_WORKER_ACTIVITY: state.worker.activity, + AK_WORKER_ROLE: state.worker.role, + AK_WORKER_MODEL: state.worker.configuredModel ?? '', + // R-2: F-1's cwd pin (below) took away the hook's only implicit + // channel for learning which repo it's working on — the spawn cwd is + // now the adapter's own baseDir, not the caller's. Told explicitly + // instead, so a hook that needs the target repo can still find it + // without reopening F-1 by spawning there. + AK_WORKER_CWD: state.cwd, + }; + state.hookResult = await runHook({ + hook, hostId, verb: 'run', timeoutMs: innerTimeoutMs, env, stdin: state.prompt, + // R-2: the spawn cwd is uniform and explicit, never Node's own + // "inherit ak's process.cwd()" default (runAdapterHook's own + // fallback for an omitted cwd) — baseDir anchors a relative script + // to the adapter's own directory when one was declared (F-1); + // otherwise the construction-time check above already proved the + // command has no relative component that a cwd could redirect, so + // falling back to the repo cwd (state.cwd) here is safe (only bare + // PATH binaries reach this branch) and gives the hook a normal + // "run me from the target repo" default. + cwd: baseDir ?? state.cwd, + }); + return state; + }, + + async observe(state) { + const result = state.hookResult; + if (!result) throw new Error(`${hostId} adapter observed before launch completed`); + return { + type: 'exit', ok: result.ok, stdout: result.stdout, + // R-1: stdoutText is the UNMERGED stdout — see parseStdout's header + // comment for why the payload must never be parsed from `stdout`. + stdoutText: result.stdoutText ?? '', + exitCode: result.exitCode, detail: result.detail, + stderrText: result.stderrText ?? result.stderr ?? '', + }; + }, + + interpret(state, observation) { + // Runner-injected terminal events (outer phase deadline aborting + // launch/observe before our own {type:'exit'} observation lands, or an + // unexpected cancel/cleanup failure). See LAUNCH_TIMEOUT_MARGIN_MS above + // for why the 'exit' path is the expected one in practice. + if (observation?.type === 'timeout') { + return terminalResult(state, { status: 'timed_out', exitCategory: 'timeout', failure: { reason: boundedReason(observation.reason ?? 'timeout') } }); + } + if (observation?.type === 'orphaned') { + return terminalResult(state, { status: 'failed', exitCategory: 'orphaned', failure: { reason: 'admitted host subprocess did not terminate' } }); + } + if (observation?.type !== 'exit') { + return terminalResult(state, { status: 'failed', exitCategory: 'protocol_error', failure: { reason: 'unrecognized observation from admitted host adapter' } }); + } + + const { + ok, exitCode, stdout, stdoutText, detail, stderrText, + } = observation; + if (ok && exitCode === 0) { + const parsed = parseStdout(stdoutText, stderrText); + return terminalResult(state, { + status: 'succeeded', exitCategory: 'success', failure: null, + observedModel: parsed.observedModel, + provider: parsed.provider, + // F-7: a payload's self-declared provider is NEVER 'observed' — the + // hook asserted it, ak didn't verify it against anything. + providerProvenance: parsed.provider ? 'inferred' : 'unknown', + usage: parsed.usage, + }); + } + // F-6: reserved hook exit codes (see module header) — checked ahead of + // the generic non-zero-exit fallback below. + if (exitCode === EXIT_PERMISSION_REQUIRED) { + return terminalResult(state, { + status: 'blocked', exitCategory: 'permission_required', + failure: { reason: boundedReason(detail ?? 'adapter hook requires consent it does not have (exit 77)') }, + }); + } + if (exitCode === EXIT_AUTH_REQUIRED) { + return terminalResult(state, { + status: 'failed', exitCategory: 'auth_required', + failure: { reason: boundedReason(detail ?? 'adapter hook requires authentication it does not have (exit 78)') }, + }); + } + if (exitCode === null && typeof detail === 'string' && /timed out/i.test(detail)) { + return terminalResult(state, { status: 'timed_out', exitCategory: 'timeout', failure: { reason: boundedReason(detail) } }); + } + if (exitCode === null) { + return terminalResult(state, { status: 'failed', exitCategory: 'cli_unavailable', failure: { reason: boundedReason(detail ?? 'adapter hook failed to start') } }); + } + // F-3: the stdout tail folded into a worker_error reason is untrusted + // hook output — it may itself contain a `` block (or + // other private-protocol payload) that must never leak through a + // public WorkerResult's failure.reason. Redact before bounding, same + // as subprocess.mjs's failureFor. + const tail = typeof stdout === 'string' ? stdout.trim().slice(-200) : ''; + const raw = tail ? `${detail} — ${tail}` : (detail ?? `adapter hook exited with code ${exitCode}`); + return terminalResult(state, { status: 'failed', exitCategory: 'worker_error', failure: { reason: boundedReason(redactHandoffData(raw)) } }); + }, + + // The hook script's own protocol is simpler than the tagged-block one + // LLM-driven built-in hosts use: a JSON payload's `summary` field (or + // plain-text stdout, when stderr is empty) IS the outcome, verbatim — no + // `` parsing. Wrap it into normalizeHandoff's exact + // accepted shape rather than returning the bare `{summary}` it was read + // from. + summarize(_state, observation) { + if (observation?.type !== 'exit' || !observation.ok || observation.exitCode !== 0) return null; + const { summary } = parseStdout(observation.stdoutText, observation.stderrText); + if (!summary) return null; + return { outcome: summary, artifacts: [], decisions: [], risks: [] }; + }, + + // hook-runner owns the kill (group SIGKILL on its own inner timeout); + // launch() only resolves once the subprocess has already exited, so by + // the time cancel/cleanup can run there is normally no live resource + // left to terminate. The one exception (F-2): if the runner's OUTER + // phase deadline fires WHILE launch() is still pending — state.hookResult + // never got set — the subprocess may still be alive and its termination + // is unproven. Reporting plain 'cancelled' there would let the runner's + // escalation ladder treat this as an ordinary escalatable timed_out and + // fire a SECOND attempt against a worker that might still be running. + // 'orphaned' is honest (uncertain, non-escalating) and matches + // runner.mjs's own BLOCKING_CATEGORIES. + async cancel(state) { + if (!state?.hookResult) return { type: 'cancelled', orphaned: true }; + return { type: 'cancelled' }; + }, + async cleanup() { return { cleaned: true }; }, + }; + + return validateExecutionAdapter(adapter); +} + +// ── overlay registry (mirrors adapters/admitted.mjs's discipline) ────────── +// Module-level Map, not the built-in EXECUTION_ADAPTERS Map: an admitted +// external host's adapter is DERIVED from its manifest at bootstrap time, not +// hand-authored in-tree. A second register for the same host id replaces the +// first (re-admission, or a test re-registering); production populates this +// only from bootstrapHostAdapters (admission.mjs). +let admittedExecutionAdapters = new Map(); + +/** Build and register an execution adapter for an admitted manifest. Returns + * the built adapter. Throws (uncaught) on a manifest with no execution + * block or a malformed one — callers (bootstrap) are expected to guard this + * per-adapter and treat a throw as a non-fatal warning, not a crash. */ +export function registerAdmittedExecution(manifest, options) { + const adapter = buildAdmittedExecutionAdapter(manifest, options); + admittedExecutionAdapters.set(manifest.host.id, adapter); + return adapter; +} + +/** Test-only reset back to the unregistered, built-ins-only state. */ +export function resetAdmittedExecution() { + admittedExecutionAdapters = new Map(); +} + +/** One admitted host's execution adapter, or null when none is registered — + * never throws, mirroring executionAdapterFor's degrade-one-worker posture. */ +export function admittedExecutionAdapterFor(hostId) { + return admittedExecutionAdapters.get(hostId) ?? null; +} + +// F-9: the host overlay (adapters/admitted.mjs) and this execution overlay +// are two independently-mutable module singletons that a caller could reset +// out of step (e.g. a test resetting only one), leaving the other stale — an +// admitted-but-execution-orphaned or execution-registered-but-unadmitted +// state neither overlay's own reset guards against alone. Pairing them here +// (rather than reaching into adapters/admitted.mjs to add an upward +// dependency on this module) keeps that file untouched. +export function resetAllAdmitted() { + resetAdmittedExecution(); + resetAdmitted(); +} diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs index 2f95f8b..86efc2e 100644 --- a/src/lib/routing.mjs +++ b/src/lib/routing.mjs @@ -6,7 +6,9 @@ // (no I/O) so the projectors and defaults are unit-testable in isolation; the // writers/UX that consume it live in providers.mjs / the commands. import { vendorOf } from './qeCourt.mjs'; -import { routableHostIds, primaryHostIds, validateActivityHost } from './adapters/index.mjs'; +import { + routableHostIds, primaryHostIds, validateActivityHost, effectiveHostRegistry, effectiveRoutableHostIds, +} from './adapters/index.mjs'; // ── Vocabulary ─────────────────────────────────────────────────────────────── // Canonical development activities ak routes (ADR-0002). Array order = display order. @@ -21,8 +23,14 @@ export const AK_ORIGINATED = new Set(['packaging', 'release']); // Host → aqe/router provider type. OpenCode deliberately has no entry: its // execution provider is observed per worker and must never be inferred from the -// host or silently projected into AQE's separate provider vocabulary. +// host or silently projected into AQE's separate provider vocabulary. An +// admitted external host (P2, ADR-0031) gets no entry either, same reasoning. export const HOST_PROVIDER = { claude: 'claude-code', codex: 'codex' }; +// Frozen at import time — built-ins only. Display strings and built-in +// listings ONLY (formatModelHelp, model catalogs below): every VALIDATION +// path (isRoutableHost, validateRoute, materializeRunPlan) consults the lazy +// effectiveRoutableHostIds()/effectiveHostRegistry() instead, so an admitted +// external host routes without this constant ever needing to change. export const HOSTS = routableHostIds(); // Providers aqe's ProviderManager can construct — grounded in agentic-qe 3.13.1 @@ -234,9 +242,12 @@ export const AGENT_ACTIVITY_MAP = { // ── Policy resolution + projections (pure) ────────────────────────────────── -/** True when both frontier hosts appear in a route (a route's host is valid). */ +/** True when `host` is routable: a built-in, or an admitted external host + * whose manifest declared capabilities.canRouteActivities (P2, ADR-0031). + * Lazy — re-reads the effective registry on every call, so it reflects an + * overlay applied after this module first loaded. */ export function isRoutableHost(host) { - return HOSTS.includes(host); + return effectiveRoutableHostIds().includes(host); } /** Substitute a retired model for its replacement, recording what was swapped. @@ -576,11 +587,15 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = ' const nodes = RUN_TEMPLATES[template]; if (!nodes) throw new Error(`unknown template "${template}" (expected: ${RUN_TEMPLATE_NAMES.join(', ')})`); const routes = resolveRoutes(policy); + // Snapshot once per materialization (not per validateActivityHost call): an + // admitted overlay applied mid-call must not be able to make one worker's + // eligibility check see a different registry than another's in the same plan. + const hosts = effectiveHostRegistry(); return { template, workers: nodes.map((n) => { const r = routes[n.activity]; - const eligibility = validateActivityHost(r.host); + const eligibility = validateActivityHost(r.host, hosts); if (!eligibility.ok) { throw new Error(`route for "${n.activity}" cannot materialize: host "${r.host}" requires canRouteActivities`); } @@ -591,7 +606,7 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = ' const ladder = (r.escalation ?? []) .filter((rung) => rung && (rung.host !== r.host || (rung.model ?? null) !== (r.model ?? null))) .map((rung) => { - const rungEligibility = validateActivityHost(rung.host); + const rungEligibility = validateActivityHost(rung.host, hosts); if (!rungEligibility.ok) { throw new Error(`escalation rung for "${n.activity}" cannot materialize: host "${rung.host}" requires canRouteActivities`); } @@ -647,7 +662,7 @@ export function routingSummary(policy = {}) { export function validateRoute(route = {}) { const { host, model } = route; const errs = []; - if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${HOSTS.join('|')})`); + if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${effectiveRoutableHostIds().join('|')})`); else if (HOST_PROVIDER[host] && !AQE_CONSTRUCTIBLE_PROVIDERS.includes(HOST_PROVIDER[host])) errs.push(`host "${host}" maps to a non-constructible provider`); if (model != null && (typeof model !== 'string' || model.trim() === '')) errs.push('model must be a non-empty string'); return errs; diff --git a/tests/fixtures/adapters/acme/manifest.json b/tests/fixtures/adapters/acme/manifest.json index d2919a6..98bd98b 100644 --- a/tests/fixtures/adapters/acme/manifest.json +++ b/tests/fixtures/adapters/acme/manifest.json @@ -9,7 +9,7 @@ "capabilities": { "canDriveSession": false, "canBePrimary": false, - "canRouteActivities": false, + "canRouteActivities": true, "commandStatusline": false, "transcripts": false, "usage": false, @@ -32,6 +32,11 @@ "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } } }, + "execution": { + "run": { + "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 5000 } + } + }, "trust": { "changes": [ { diff --git a/tests/fixtures/adapters/acme/run-hook.mjs b/tests/fixtures/adapters/acme/run-hook.mjs new file mode 100644 index 0000000..31e0b83 --- /dev/null +++ b/tests/fixtures/adapters/acme/run-hook.mjs @@ -0,0 +1,28 @@ +// Fixture execution hook for the "acme" conformance adapter (P2, ADR-0031). +// A real, standalone subprocess — no dependencies, no network, no filesystem +// writes — invoked exactly as an admitted external adapter's declared +// execution.run.hook would be, through the real runAdapterHook. It reads the +// worker prompt from stdin (proving the stdin wiring) and echoes a JSON +// result whose `summary` names the AK_WORKER_* metadata env vars it received +// (proving the env wiring), matching the {summary, observedModel, provider} +// shape buildAdmittedExecutionAdapter (execution/admitted.mjs) parses. +// +// ACME_RUN_HOOK_FAIL=1 makes the hook fail deliberately, for a negative test +// of the worker_error mapping path. +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + if (process.env.ACME_RUN_HOOK_FAIL === '1') { + process.stderr.write('acme fixture run hook: deliberate failure\n'); + process.exit(3); + return; + } + process.stdout.write(JSON.stringify({ + summary: `acme ran worker=${process.env.AK_WORKER_ID ?? ''} activity=${process.env.AK_WORKER_ACTIVITY ?? ''} ` + + `role=${process.env.AK_WORKER_ROLE ?? ''} model=${process.env.AK_WORKER_MODEL ?? ''} ` + + `promptBytes=${Buffer.byteLength(input, 'utf8')}`, + observedModel: process.env.AK_WORKER_MODEL || null, + provider: 'acme', + })); +}); diff --git a/tests/kit/adapter-conformance.test.mjs b/tests/kit/adapter-conformance.test.mjs index 9483396..c64b8b0 100644 --- a/tests/kit/adapter-conformance.test.mjs +++ b/tests/kit/adapter-conformance.test.mjs @@ -28,6 +28,8 @@ import { recordConsent, recordedHashFor, isTrusted, revokeConsent, } from '../../src/lib/adapters/consent.mjs'; import { registerAdmittedLifecycle } from '../../src/lib/adapters/lifecycle-registry.mjs'; +import { registerAdmittedExecution, resetAdmittedExecution } from '../../src/lib/execution/admitted.mjs'; +import { executeRunPlan } from '../../src/lib/execution/runner.mjs'; // Resolved relative to THIS file via fileURLToPath, never a hardcoded // repo-absolute or monorepo-sibling path (ruflo #2912 counter-example) — so @@ -42,28 +44,42 @@ const NEGATIVE_CORPUS = [ ]; /** - * The manifest contract has no path-resolution policy of its own yet - * (hook.command is just "a non-empty array of non-empty strings" — - * manifest.mjs never inspects the values). A real installed adapter would - * need SOME resolution step to turn a portable manifest's declared command + * The manifest contract has no path-resolution policy of its own for + * LIFECYCLE hooks (hook.command is just "a non-empty array of non-empty + * strings" — manifest.mjs never inspects the values, and lifecycle-registry.mjs + * spawns with no `cwd` anchoring). A real installed adapter would need SOME + * resolution step to turn a portable manifest's declared lifecycle command * into a locally-runnable one; that step doesn't exist in src/ today, so - * this is a minimal, test-owned stand-in: the literal token 'node' becomes - * process.execPath (never a bare PATH-searched 'node' — the same portability - * concern the hook-runner tests already document), and a relative *.mjs - * argument is resolved against the fixture's own directory (where the - * committed hook script actually lives), not the CWD. + * this remains a minimal, test-owned stand-in for lifecycle hooks ONLY: the + * literal token 'node' becomes process.execPath, and a relative *.mjs + * argument is resolved against the fixture's own directory. + * + * The EXECUTION hook (`execution.run.hook`) needs no such rewriting — Wave B + * security review (F-1) gave admission.mjs a real baseDir-derivation + + * cwd-anchoring path (registerAdmittedExecution -> buildAdmittedExecutionAdapter + * -> runAdapterHook's `cwd` option), so the fixture's literal, UNREWRITTEN + * `["node", "run-hook.mjs"]` resolves correctly through the real resolver: + * 'node' via PATH, 'run-hook.mjs' relative to the fixture's own directory + * (this manifest's `source` is a file path, so baseDir = FIXTURE_ROOT). A + * test-side rewrite here would prove a safer-than-production path, not the + * real one — see the negative-corpus/unanchored tests in adapter-execution.test.mjs + * for what happens when there is no baseDir to anchor to. */ +function resolveHookCommand(command, hookDir) { + return command.map((part) => { + if (part === 'node') return process.execPath; + if (part.endsWith('.mjs') && !path.isAbsolute(part)) return path.join(hookDir, part); + return part; + }); +} + function resolveManifestCommands(raw, hookDir) { if (!raw || typeof raw !== 'object' || !raw.lifecycle || typeof raw.lifecycle !== 'object') return raw; const lifecycle = {}; for (const [verb, entry] of Object.entries(raw.lifecycle)) { - if (!entry?.hook?.command) { lifecycle[verb] = entry; continue; } - const command = entry.hook.command.map((part) => { - if (part === 'node') return process.execPath; - if (part.endsWith('.mjs') && !path.isAbsolute(part)) return path.join(hookDir, part); - return part; - }); - lifecycle[verb] = { ...entry, hook: { ...entry.hook, command } }; + lifecycle[verb] = entry?.hook?.command + ? { ...entry, hook: { ...entry.hook, command: resolveHookCommand(entry.hook.command, hookDir) } } + : entry; } return { ...raw, lifecycle }; } @@ -151,6 +167,44 @@ export async function runConformanceReport({ fixtureRoot = FIXTURE_ROOT } = {}) assert.equal(typeof detected?.observed?.pid, 'number'); }); + await run("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)", async () => { + if (!validated) throw new Error('prerequisite: manifest was not validated'); + // No runHook injection here either: registerAdmittedExecution's default + // dynamically imports the real hook-runner.mjs, and executeRunPlan's + // default adapter lookup (executionAdapterFor) falls through to the + // admitted overlay — proof the whole `ak run` path (materialized plan -> + // execution seam -> derived adapter -> hook runner -> spawned Node + // process -> stdout JSON -> WorkerResult) actually runs end-to-end. + // baseDir mirrors exactly what bootstrapHostAdapters derives in + // production (F-1) — this call bypasses that bootstrap wiring (it calls + // registerAdmittedExecution directly), so it must reproduce the same + // derivation rather than a test-only shortcut. + resetAdmittedExecution(); + try { + // haveFn only: the fixture's detection.bin ('acme') is a fictitious + // binary that will never actually be on a test machine's PATH — that's + // orthogonal to what this check proves (the execution.run hook itself + // running end-to-end), so readiness is stubbed the same way a real + // installed adapter's `acme` binary would report present. runHook + // stays the real default: a genuine spawned subprocess. + registerAdmittedExecution(validated, { + haveFn: async () => true, + baseDir: path.dirname(fs.realpathSync(validManifestPath)), + }); + const plan = { workers: [{ id: 'w1', activity: 'implementation', role: 'coder', host: 'acme', prompt: 'do the thing' }] }; + const [result] = await executeRunPlan(plan, { clock: () => new Date().toISOString() }); + assert.equal(result.status, 'succeeded', result.failure?.reason ?? 'expected a succeeded WorkerResult'); + assert.equal(result.host, 'acme'); + assert.equal(result.exitCategory, 'success'); + assert.equal(result.provider, 'acme'); + // F-7: a payload-declared provider is 'inferred', never 'observed' — + // ak did not verify the hook's claim against anything. + assert.equal(result.providerProvenance, 'inferred'); + } finally { + resetAdmittedExecution(); + } + }); + for (const [description, file, cfgName, reason] of NEGATIVE_CORPUS) { await run(`negative corpus: ${description} is refused with reason '${reason}'`, async () => { const results = await admitAdapters({ @@ -216,6 +270,10 @@ test("the fixture's declared detect hook runs as a real subprocess and its JSON assertCheck("the fixture's declared detect hook runs as a real subprocess and its JSON payload flows back"); }); +test("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)", () => { + assertCheck("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)"); +}); + for (const [description, , , reason] of NEGATIVE_CORPUS) { const name = `negative corpus: ${description} is refused with reason '${reason}'`; test(name, () => assertCheck(name)); @@ -227,8 +285,8 @@ test('edit-invalidation: mutating one byte of the manifest invalidates the prior test('runConformanceReport reports a clean pass with no failures', () => { assert.equal(report.failed, 0, JSON.stringify(report.checks.filter((c) => !c.ok), null, 2)); - assert.equal(report.total, 8); - assert.equal(report.passed, 8); + assert.equal(report.total, 9); + assert.equal(report.passed, 9); }); // ── GAP CLOSED (Wave 4 security remediation, P0-A): consent hashing used to diff --git a/tests/kit/adapter-execution.test.mjs b/tests/kit/adapter-execution.test.mjs new file mode 100644 index 0000000..92f804a --- /dev/null +++ b/tests/kit/adapter-execution.test.mjs @@ -0,0 +1,597 @@ +// P2 (ADR-0031) — the derived execution adapter for an admitted external +// host. Unit-level: every case here injects `runHook`/`haveFn`/`clock`, so +// none of it depends on a real subprocess or on B1's manifest/hook-runner +// landing timing. The real-subprocess, real-manifest black-box proof lives in +// adapter-conformance.test.mjs (the acme fixture's execution.run hook). +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildAdmittedExecutionAdapter, registerAdmittedExecution, resetAdmittedExecution, admittedExecutionAdapterFor, + resetAllAdmitted, +} from '../../src/lib/execution/admitted.mjs'; +import { executionAdapterFor } from '../../src/lib/execution/adapters.mjs'; +import { validateExecutionAdapter } from '../../src/lib/execution/schema.mjs'; +import { normalizeHandoff, HANDOFF_START, HANDOFF_END } from '../../src/lib/execution/handoff.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { applyAdmitted, effectiveHostRegistry } from '../../src/lib/adapters/admitted.mjs'; +import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs'; +import { isRoutableHost, validateRoute } from '../../src/lib/routing.mjs'; + +const clock = () => '2026-08-16T00:00:00.000Z'; + +function validHost(overrides = {}) { + return { + id: 'hermes', + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +function hermesManifest(overrides = {}) { + return validateAdapterManifest({ + name: 'hermes', + version: '1.0.0', + contract: 1, + host: validHost(), + detection: { bin: 'hermes' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { command: ['hermes-run'], timeoutMs: 30_000 } } }, + trust: { + changes: [{ + id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'subprocess hooks', effect: 'run consented hooks for hermes', + }], + }, + ...overrides, + }); +} + +const worker = (overrides = {}) => ({ + id: 'coder', activity: 'implementation', role: 'coder', host: 'hermes', + configuredModel: 'hermes-large', prompt: 'implement the thing', ...overrides, +}); + +// F-9: pairs both overlay resets so they can never desync between tests. +beforeEach(() => resetAllAdmitted()); + +// ── shape ──────────────────────────────────────────────────────────────── + +test('buildAdmittedExecutionAdapter returns a shape that passes validateExecutionAdapter', () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest()); + assert.equal(adapter.id, 'hermes-adapter'); + assert.doesNotThrow(() => validateExecutionAdapter(adapter)); +}); + +test('buildAdmittedExecutionAdapter requires an execution block and a detection.bin', () => { + const routableHost = { + id: 'x', capabilities: { canRouteActivities: true }, + }; + assert.throws(() => buildAdmittedExecutionAdapter({ + host: routableHost, driving: { surfaces: ['cli-subprocess'] }, + }), TypeError); + assert.throws(() => buildAdmittedExecutionAdapter({ + host: routableHost, driving: { surfaces: ['cli-subprocess'] }, execution: { run: { hook: { command: ['x'] } } }, + }), TypeError, 'missing detection.bin'); +}); + +// ── readiness ──────────────────────────────────────────────────────────── + +test('readiness reflects haveFn', async () => { + const ready = buildAdmittedExecutionAdapter(hermesManifest(), { haveFn: async () => true }); + assert.deepEqual(await ready.readiness({}), { ready: true }); + + const notReady = buildAdmittedExecutionAdapter(hermesManifest(), { haveFn: async () => false }); + assert.deepEqual(await notReady.readiness({}), { ready: false, exitCategory: 'cli_unavailable' }); +}); + +// ── prepare ────────────────────────────────────────────────────────────── + +test('prepare rejects a worker for a different host and a relative cwd', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + await assert.rejects(() => adapter.prepare({ worker: worker({ host: 'codex' }), cwd: '/abs' }), TypeError); + await assert.rejects(() => adapter.prepare({ worker: worker(), cwd: 'relative/path' }), TypeError); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + assert.equal(state.prompt, 'implement the thing'); + assert.equal(state.startedAt, clock()); +}); + +// ── launch: stdin, env, and the min-wins timeout margin ───────────────── + +test('launch invokes runHook with stdin=prompt, AK_WORKER_* env (incl. AK_WORKER_CWD, R-2), and a margin below the phase budget', async () => { + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: 'done', stdoutText: 'done', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 3000 }); + + assert.equal(calls.length, 1); + const call = calls[0]; + assert.equal(call.hostId, 'hermes'); + assert.equal(call.verb, 'run'); + assert.equal(call.stdin, 'implement the thing'); + assert.deepEqual(call.env, { + AK_WORKER_ID: 'coder', AK_WORKER_ACTIVITY: 'implementation', AK_WORKER_ROLE: 'coder', AK_WORKER_MODEL: 'hermes-large', + AK_WORKER_CWD: '/abs', + }); + // Inner (hook-runner-facing) timeout must be strictly less than the phase + // budget, so hook-runner's own kill always fires first. + assert.ok(call.timeoutMs < 3000, 'inner timeout must undercut the phase budget'); + assert.ok(call.timeoutMs > 0); +}); + +test('launch sends an empty string AK_WORKER_MODEL when the worker has no configuredModel', async () => { + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); + const state = await adapter.prepare({ worker: worker({ configuredModel: null }), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + assert.equal(calls[0].env.AK_WORKER_MODEL, ''); +}); + +// ── observe / interpret: the full mapping matrix ───────────────────────── + +async function runToResult(hookResult, { worker: w = worker() } = {}) { + const runHook = async () => hookResult; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); + const state = await adapter.prepare({ worker: w, cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + const observation = await adapter.observe(state); + return { adapter, state, observation, result: adapter.interpret(state, observation) }; +} + +test('exit 0 with a JSON payload maps summary/observedModel/provider/usage and providerProvenance=inferred (F-7)', async () => { + const stdout = JSON.stringify({ + summary: 'implemented the thing', observedModel: 'hermes-large-2', provider: 'hermes-vendor', usage: { tokens: 42 }, + }); + const { result, adapter, state, observation } = await runToResult({ + ok: true, stdout, stdoutText: stdout, stderrText: '', exitCode: 0, detail: null, + }); + assert.equal(result.status, 'succeeded'); + assert.equal(result.exitCategory, 'success'); + assert.equal(result.failure, null); + assert.equal(result.observedModel, 'hermes-large-2'); + assert.equal(result.provider, 'hermes-vendor'); + // F-7: a self-declared payload provider is 'inferred', NEVER 'observed' — + // ak did not verify the hook's claim against anything. + assert.equal(result.providerProvenance, 'inferred'); + assert.deepEqual(result.usage, { tokens: 42 }); + assert.equal(result.host, 'hermes'); + + const handoff = adapter.summarize(state, observation); + assert.deepEqual(handoff, { outcome: 'implemented the thing', artifacts: [], decisions: [], risks: [] }); + assert.doesNotThrow(() => normalizeHandoff(handoff), 'summarize output must be normalizeHandoff-compatible'); +}); + +test('F-7: a payload-declared provider is bounded to 64 chars', async () => { + const longProvider = 'x'.repeat(200); + const stdout = JSON.stringify({ summary: 'ok', provider: longProvider }); + const { result } = await runToResult({ ok: true, stdout, stdoutText: stdout, stderrText: '', exitCode: 0, detail: null }); + assert.equal(result.provider.length, 64); + assert.equal(result.provider, longProvider.slice(0, 64)); +}); + +test('exit 0 with plain-text stdout becomes the summary when stderr is empty', async () => { + const { result, adapter, state, observation } = await runToResult({ + ok: true, stdout: 'plain text result', stdoutText: 'plain text result', stderrText: '', exitCode: 0, detail: null, + }); + assert.equal(result.status, 'succeeded'); + assert.equal(result.provider, null); + assert.equal(result.providerProvenance, 'unknown'); + assert.equal(result.observedModel, null); + assert.equal(result.usage, null); + + const handoff = adapter.summarize(state, observation); + assert.deepEqual(handoff, { outcome: 'plain text result', artifacts: [], decisions: [], risks: [] }); +}); + +test('F-4: non-empty stderrText is never auto-promoted into a summary, even with plain-text stdout', async () => { + const { adapter, state, observation } = await runToResult({ + ok: true, stdout: 'looks like a normal result', stdoutText: 'looks like a normal result', + stderrText: 'a stray debug line', exitCode: 0, detail: null, + }); + assert.equal(adapter.summarize(state, observation), null, + 'stderr chatter must never become the cross-vendor dependency handoff'); +}); + +// R-1 (HIGH, blocker fix): the pre-R-1 shape parsed the JSON payload from +// hook-runner's MERGED `stdout` field — the instant a hook wrote anything to +// stderr (a Node/npm deprecation warning, nothing to do with the hook's own +// correctness), the merged text was no longer clean JSON, JSON.parse threw, +// and a perfectly valid summary/provider/usage was silently discarded. Worse +// downstream: any worker OTHERS depend on (`requireHandoff`) would then throw +// "required worker handoff was missing" and fail the whole pipeline over one +// stderr line. Fixed by parsing from `stdoutText` (hook-runner's UNMERGED +// stdout) instead — a valid JSON payload now parses regardless of stderr; +// only the PLAIN-TEXT promotion path stays gated on stderr being empty (F-4, +// unchanged, and still proven by the sibling test above). +test('R-1: a JSON payload alongside stderr chatter STILL yields its summary, provider, and usage', async () => { + const stdoutText = JSON.stringify({ summary: 'built the thing', provider: 'hermes-vendor', usage: { tokens: 7 } }); + const stdout = `${stdoutText}\n--- stderr ---\nnpm warn deprecated some-pkg@1.0.0`; + const { result, adapter, state, observation } = await runToResult({ + ok: true, stdout, stdoutText, stderrText: 'npm warn deprecated some-pkg@1.0.0', exitCode: 0, detail: null, + }); + assert.equal(result.status, 'succeeded'); + assert.equal(result.provider, 'hermes-vendor'); + assert.equal(result.providerProvenance, 'inferred'); + assert.deepEqual(result.usage, { tokens: 7 }); + + const handoff = adapter.summarize(state, observation); + assert.deepEqual(handoff, { outcome: 'built the thing', artifacts: [], decisions: [], risks: [] }); + assert.doesNotThrow(() => normalizeHandoff(handoff)); +}); + +test('exit 0 with empty stdout summarizes to null (no fabricated handoff)', async () => { + const { adapter, state, observation } = await runToResult({ + ok: true, stdout: '', stdoutText: '', stderrText: '', exitCode: 0, detail: null, + }); + assert.equal(adapter.summarize(state, observation), null); +}); + +// R-1 (pipeline-level): the scenario above must not just parse cleanly in +// isolation — it must not block a DEPENDENT worker either. Pre-R-1, the +// producer's missing handoff (mustSummarize, since a descendant depends on +// it) turned executeWorkerAttempt's requireHandoff check into a protocol +// error, blocking the producer AND its descendant over one stderr line. +test('R-1 (pipeline): a JSON summary alongside stderr flows to a dependent worker without blocking it', async () => { + const { executeRunPlan } = await import('../../src/lib/execution/runner.mjs'); + const stdoutText = JSON.stringify({ summary: 'built the thing', provider: 'hermes-vendor' }); + const stdout = `${stdoutText}\n--- stderr ---\nnpm warn deprecated some-pkg@1.0.0`; + const hookResult = { + ok: true, stdout, stdoutText, stderrText: 'npm warn deprecated some-pkg@1.0.0', exitCode: 0, detail: null, + }; + const runHook = async () => hookResult; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock, haveFn: async () => true }); + const plan = { + workers: [ + { id: 'producer', activity: 'implementation', role: 'coder', host: 'hermes', prompt: 'produce' }, + { + id: 'consumer', activity: 'review', role: 'reviewer', host: 'hermes', prompt: 'consume', dependsOn: ['producer'], + }, + ], + }; + const results = await executeRunPlan(plan, { adapters: { hermes: adapter }, clock }); + const producer = results.find((r) => r.workerId === 'producer'); + const consumer = results.find((r) => r.workerId === 'consumer'); + assert.equal(producer.status, 'succeeded', producer.failure?.reason); + assert.equal(producer.provider, 'hermes-vendor'); + assert.equal(consumer.status, 'succeeded', consumer.failure?.reason); +}); + +test('non-zero exit maps to failed/worker_error with a bounded reason including the exit code', async () => { + const { result, adapter, state, observation } = await runToResult({ + ok: false, stdout: 'boom on line 1\n--- stderr ---\nstack trace tail', stderrText: 'stack trace tail', + exitCode: 1, detail: 'hermes:run adapter hook exited with code 1', + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'worker_error'); + assert.match(result.failure.reason, /code 1/); + assert.ok(result.failure.reason.length <= 240); + assert.equal(adapter.summarize(state, observation), null, 'a failed worker never produces a handoff'); +}); + +test('F-3: a private handoff block embedded in the failing stdout tail is redacted from the worker_error reason', async () => { + const leaking = `some output ${HANDOFF_START}{"outcome":"secret","artifacts":[],"decisions":[],"risks":[]}${HANDOFF_END} more`; + const { result } = await runToResult({ + ok: false, stdout: leaking, stderrText: '', exitCode: 1, detail: 'hermes:run adapter hook exited with code 1', + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'worker_error'); + assert.doesNotMatch(result.failure.reason, /secret/); + assert.doesNotMatch(result.failure.reason, /AK_HANDOFF_V1/); + assert.match(result.failure.reason, /private handoff withheld/); +}); + +// ── F-6: reserved hook exit codes (consent/auth boundary) ──────────────── + +test('F-6: exit code 77 maps to status blocked / exitCategory permission_required', async () => { + const { result } = await runToResult({ + ok: false, stdout: '', stderrText: '', exitCode: 77, detail: 'hermes:run adapter hook exited with code 77', + }); + assert.equal(result.status, 'blocked'); + assert.equal(result.exitCategory, 'permission_required'); +}); + +test('F-6: exit code 78 maps to status failed / exitCategory auth_required', async () => { + const { result } = await runToResult({ + ok: false, stdout: '', stderrText: '', exitCode: 78, detail: 'hermes:run adapter hook exited with code 78', + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'auth_required'); +}); + +test('a spawn failure (ENOENT-style) maps to failed/cli_unavailable', async () => { + const { result } = await runToResult({ + ok: false, stdout: '', exitCode: null, detail: "hermes:run adapter hook failed to start: ENOENT (spawn hermes-run ENOENT)", + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'cli_unavailable'); + assert.match(result.failure.reason, /ENOENT/); +}); + +test("hook-runner's own timeout detail maps to timed_out/timeout", async () => { + const { result } = await runToResult({ + ok: false, stdout: '', exitCode: null, detail: 'hermes:run adapter hook timed out after 5000ms and was killed', + }); + assert.equal(result.status, 'timed_out'); + assert.equal(result.exitCategory, 'timeout'); +}); + +// ── interpret: runner-injected terminal events (outer deadline abort) ──── + +test('interpret handles a runner-injected {type:"timeout"} terminal event', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + const result = adapter.interpret(state, { type: 'timeout', reason: 'launch exceeded the worker deadline' }); + assert.equal(result.status, 'timed_out'); + assert.equal(result.exitCategory, 'timeout'); +}); + +test('interpret handles a runner-injected {type:"orphaned"} terminal event', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + const result = adapter.interpret(state, { type: 'orphaned' }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'orphaned'); +}); + +test('interpret treats an unrecognized observation as a protocol error rather than fabricating success', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + const result = adapter.interpret(state, { type: 'idle' }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'protocol_error'); +}); + +// ── cancel / cleanup: honest post-launch no-ops, honest pre-launch orphan ─ + +test('cancel after launch has resolved is an honest no-op (hook-runner already owns the kill)', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const launchedState = { hookResult: { ok: true, stdout: '', stderrText: '', exitCode: 0, detail: null } }; + assert.deepEqual(await adapter.cancel(launchedState), { type: 'cancelled' }); + assert.deepEqual(await adapter.cleanup(launchedState), { cleaned: true }); +}); + +test('F-2: cancel BEFORE launch resolves reports orphaned (non-escalating), never a plain cancelled', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + // state.hookResult was never set — launch() is (hypothetically) still + // pending when the runner's outer deadline fires and calls cancel(). + const cancelled = await adapter.cancel(state); + assert.deepEqual(cancelled, { type: 'cancelled', orphaned: true }); + + // The runner maps a cancel() reporting orphaned:true into + // interpret(state, {type:'orphaned'}) — prove OUR adapter's interpret + // honors that terminal shape (already covered generically for 'orphaned' + // above, but pinned here specifically as the F-2 consequence). + const result = adapter.interpret(state, { type: 'orphaned' }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'orphaned'); +}); + +// ── F-1: relative hook commands anchor to baseDir, or refuse outright ──── + +test('F-1: a relative hook command with no baseDir is refused at construction (execution-unanchored)', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'execution-unanchored'); + return true; + }); +}); + +test('F-1: a relative NON-flag argument with no baseDir is refused (argv0 itself may be a bare PATH binary)', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'scripts/run.mjs'] } } } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'execution-unanchored'); + return true; + }); +}); + +test('F-1/R-3: a bare flag (and a non-path flag value) is never mistaken for a relative path', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['hermes-run', '--config', 'x', '--verbose'] } } } }); + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest)); +}); + +// R-3 (residual of F-1): the pre-R-3 shape skipped every '-'-prefixed arg +// entirely before checking whether it looked like a path — so a single +// `--flag=value` token (still '-'-prefixed as a WHOLE token) slipped past +// unchecked even when its value half was a relative script. Now every arg is +// inspected, catching this with a null baseDir exactly like a bare relative +// argument would be. +test("R-3: a '--flag=value' token whose value is a relative path is refused with a null baseDir", () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', '--import=./evil.mjs', 'hermes-run'] } } } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'execution-unanchored'); + return true; + }); +}); + +test("R-3: the same '--flag=value' token is legal once a baseDir anchors the command", () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', '--import=./evil.mjs', 'hermes-run'] } } } }); + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest, { baseDir: '/adapters/hermes' })); +}); + +test('F-1: a bare PATH-resolved interpreter/binary command stays legal with no baseDir', () => { + const manifest = hermesManifest(); // command: ['hermes-run'] — no separator, no script extension + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest)); +}); + +test('F-1: an absolute command is always legal, baseDir or not', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['/usr/bin/hermes-run', '/abs/arg.mjs'] } } } }); + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest)); +}); + +test('F-1: a relative command IS legal once a baseDir anchors it, and launch passes cwd=baseDir to runHook', async () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'] } } } }); + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', stderrText: '', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(manifest, { runHook, clock, baseDir: '/adapters/hermes' }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + assert.equal(calls[0].cwd, '/adapters/hermes'); +}); + +// R-2: the tempting fix for "the hook lost its cwd signal" would be spawning +// in the repo cwd unconditionally — but that reopens F-1 (a relative command +// would resolve against the operator's cwd again). Instead: baseDir anchors +// a relative command when one was declared; with NO baseDir, the +// construction-time check already proved the command has no relative +// component a cwd could redirect (bare PATH binaries only), so falling back +// to the repo cwd here is safe AND gives the hook a normal spawn location — +// never Node's own "inherit ak's own process.cwd()" default. +test('R-2: launch falls back to state.cwd (never omits cwd) when there is no baseDir', async () => { + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', stdoutText: '', stderrText: '', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); // baseDir defaults to null + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + assert.equal(calls[0].cwd, '/abs'); +}); + +// ── F-1 (bootstrap-level): baseDir derives from entry.source ───────────── + +test('F-1 (bootstrap): an npm-sourced execution adapter with a relative command is refused with a surfaced warning', async () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } }); + const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'npm:hermes-adapter@1.0.0' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => hash, isTrusted: () => true }, + }); + assert.equal(result.admitted.length, 1, 'the host itself still admits — only execution registration fails'); + const warning = result.warnings.find((w) => w.reason === 'execution-unanchored'); + assert.ok(warning, `expected an 'execution-unanchored' warning; got ${JSON.stringify(result.warnings)}`); +}); + +test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dirname(source)) and registers cleanly', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-execution-basedir-')); + try { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'] } } } }); + const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashManifest(manifest); + const manifestPath = path.join(tmpDir, 'manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: manifestPath }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => hash, isTrusted: () => true }, + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`); + assert.notEqual(admittedExecutionAdapterFor('hermes'), null, 'registration must have succeeded with a real baseDir'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// ── F-5: driving.surfaces must include 'cli-subprocess' ────────────────── + +test("F-5: a manifest whose driving.surfaces omits 'cli-subprocess' gets no execution adapter (surface-unsupported)", () => { + const manifest = hermesManifest({ driving: { surfaces: ['mcp'] } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'surface-unsupported'); + return true; + }); +}); + +test('F-5 (bootstrap): the execution-candidate filter refuses a non-cli-subprocess manifest with its own reason', async () => { + const manifest = hermesManifest({ driving: { surfaces: ['mcp'] } }); + const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: '/does/not/matter/manifest.json' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => hash, isTrusted: () => true }, + }); + assert.equal(result.admitted.length, 1); + const warning = result.warnings.find((w) => w.reason === 'surface-unsupported'); + assert.ok(warning, `expected a 'surface-unsupported' warning; got ${JSON.stringify(result.warnings)}`); + assert.equal(admittedExecutionAdapterFor('hermes'), null); +}); + +// ── F-8: canRouteActivities is re-asserted at the construction site ────── + +test('F-8: a raw canRouteActivities:false host object is refused at construction (defence-in-depth)', () => { + const manifest = { + host: { id: 'hermes', capabilities: { canRouteActivities: false } }, + detection: { bin: 'hermes' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { command: ['hermes-run'] } } }, + }; + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'not-routable'); + return true; + }); +}); + +// ── F-9: paired overlay resets ──────────────────────────────────────────── + +test('F-9: resetAllAdmitted() clears both the host overlay and the execution overlay together', () => { + const manifest = hermesManifest({ name: 'acme', host: validHost({ id: 'acme' }) }); + applyAdmitted([{ entry: manifest.host }]); + registerAdmittedExecution(manifest); + assert.ok(effectiveHostRegistry().some((host) => host.id === 'acme')); + assert.notEqual(admittedExecutionAdapterFor('acme'), null); + + resetAllAdmitted(); + + assert.ok(!effectiveHostRegistry().some((host) => host.id === 'acme')); + assert.equal(admittedExecutionAdapterFor('acme'), null); +}); + +// ── registry: overlay discipline mirrors adapters/admitted.mjs ────────── + +test('registerAdmittedExecution/admittedExecutionAdapterFor/resetAdmittedExecution round-trip, and a second register replaces', () => { + assert.equal(admittedExecutionAdapterFor('hermes'), null); + const first = registerAdmittedExecution(hermesManifest()); + assert.equal(admittedExecutionAdapterFor('hermes'), first); + + const second = registerAdmittedExecution(hermesManifest({ version: '1.0.1' })); + assert.notEqual(second, first); + assert.equal(admittedExecutionAdapterFor('hermes'), second, 'a second register replaces, not accumulates'); + + resetAdmittedExecution(); + assert.equal(admittedExecutionAdapterFor('hermes'), null); +}); + +// ── seam: executionAdapterFor falls through to the admitted overlay ───── + +test('executionAdapterFor falls through to an admitted execution adapter for a non-built-in host', () => { + assert.equal(executionAdapterFor('hermes'), null); + const registered = registerAdmittedExecution(hermesManifest()); + assert.equal(executionAdapterFor('hermes'), registered); +}); + +// ── byte-zero pins (flag/overlay off vs on) ────────────────────────────── + +test('byte-zero: with nothing admitted, executionAdapterFor is null and validateRoute refuses the host', () => { + assert.equal(executionAdapterFor('acme'), null); + assert.equal(isRoutableHost('acme'), false); + assert.ok(validateRoute({ host: 'acme' }).length > 0); +}); + +test('with the host + execution overlay applied, executionAdapterFor resolves and validateRoute accepts', () => { + const manifest = hermesManifest({ name: 'acme', host: validHost({ id: 'acme' }) }); + applyAdmitted([{ entry: manifest.host }]); + registerAdmittedExecution(manifest); + + assert.equal(isRoutableHost('acme'), true); + assert.deepEqual(validateRoute({ host: 'acme' }), []); + assert.notEqual(executionAdapterFor('acme'), null); +}); diff --git a/tests/kit/adapter-hook-runner.test.mjs b/tests/kit/adapter-hook-runner.test.mjs index b0f7955..b29143a 100644 --- a/tests/kit/adapter-hook-runner.test.mjs +++ b/tests/kit/adapter-hook-runner.test.mjs @@ -127,6 +127,151 @@ test('a non-zero exit is reported as ok:false with the exit code preserved', asy assert.equal(result.exitCode, 7); }); +// --- stdin (P2, ADR-0031: worker prompt delivery) --------------------------- + +test('stdin: a payload written to the child is delivered and echoed back', async () => { + const payload = 'the worker prompt, rendered task + handoffs'; + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'process.stdin.pipe(process.stdout)'] }, + hostId: 'claude', verb: 'run', stdin: payload, + }); + assert.equal(result.ok, true); + assert.equal(result.stdout, payload); +}); + +test('stdin: a child that exits without reading stdin (EPIPE) still yields a normal result', async () => { + // A large payload makes it likely the write is still in flight (or at + // least unread) when the child exits, so the pipe's write side sees EPIPE. + const bigPayload = 'x'.repeat(4 * 1024 * 1024); + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'process.exit(3)'] }, + hostId: 'claude', verb: 'run', stdin: bigPayload, + }); + assert.equal(result.ok, false); + assert.equal(result.exitCode, 3); +}); + +test('stdin: absent stdin keeps the existing ignore behavior (reading it yields EOF, not a hang)', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "let n=0; process.stdin.on('data', () => { n++; }); process.stdin.on('end', () => console.log('eof', n));"], + }, + hostId: 'claude', verb: 'discover', timeoutMs: 5000, + }); + assert.equal(result.ok, true); + assert.ok(result.stdout.includes('eof 0'), result.stdout); +}); + +// --- cwd (F-1: pin the child to the adapter's own directory, never the +// operator's process.cwd() — a relative hook.command like `node +// run-hook.mjs` must resolve against the adapter bundle, not wherever `ak +// run` happened to be invoked from) -------------------------------------- + +test('cwd: when provided, the child actually runs there — not in process.cwd()', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-hook-cwd-')); + try { + const target = fs.realpathSync(dir); // resolve /private symlink on macOS + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'console.log(process.cwd())'] }, + hostId: 'claude', verb: 'run', cwd: target, + }); + assert.equal(result.ok, true); + assert.equal(result.stdout.trim(), target); + assert.notEqual(result.stdout.trim(), process.cwd()); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('cwd: a relative path is refused synchronously, same class as the other arg-shape checks', async () => { + await assert.rejects( + () => runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('should not run')"] }, + hostId: 'claude', verb: 'run', cwd: 'relative/dir', + }), + TypeError, + ); +}); + +test('cwd: a non-string cwd is refused synchronously', async () => { + await assert.rejects( + () => runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('should not run')"] }, + hostId: 'claude', verb: 'run', cwd: 42, + }), + TypeError, + ); +}); + +test('cwd: omitted keeps the existing inherited-process.cwd() behavior', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'console.log(process.cwd())'] }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, true); + assert.equal(result.stdout.trim(), process.cwd()); +}); + +// --- stderrText (F-4: stderr must never be silently folded into a caller's +// parse of `stdout` — the admitted execution adapter reads the payload from +// stdout alone and diagnostics from stderrText, never a blended blob) ------ + +test('stderrText: carries only stderr, independent of the combined stdout field', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "process.stdout.write('PAYLOAD'); process.stderr.write('noisy diagnostic log line');"], + }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.equal(result.stderrText, 'noisy diagnostic log line'); + // the combined `stdout` field still folds both streams together, unchanged + // back-compat behavior for lifecycle callers/diagnostics that read it. + assert.ok(result.stdout.includes('PAYLOAD')); + assert.ok(result.stdout.includes('noisy diagnostic log line')); +}); + +test('stderrText: empty when the hook writes nothing to stderr', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('only stdout')"] }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.equal(result.stderrText, ''); +}); + +// --- stdoutText (R-1: mergeCapture's `stdout` folds stderr in after a +// separator, so a JSON.parse of `stdout` breaks the instant the hook writes +// ANYTHING to stderr — a warning, a deprecation notice, a logging library +// defaulting to stderr. stdoutText is the unmerged, stdout-only capture the +// admitted adapter must parse the payload from instead.) ------------------ + +test('stdoutText: a JSON payload on stdout plus a warning on stderr — stdoutText parses cleanly, stdout does not', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "process.stdout.write(JSON.stringify({summary:'ok'})); process.stderr.write('deprecation warning');"], + }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.deepEqual(JSON.parse(result.stdoutText), { summary: 'ok' }); + assert.equal(result.stderrText, 'deprecation warning'); + // the combined `stdout` field is unchanged back-compat behavior — it folds + // both streams, so parsing IT as JSON is exactly the break R-1 fixes. + assert.throws(() => JSON.parse(result.stdout)); + assert.ok(result.stdout.includes('deprecation warning')); +}); + +test('stdoutText: equals the plain stdout capture when the hook writes nothing to stderr', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('only stdout')"] }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.equal(result.stdoutText.trim(), 'only stdout'); + assert.equal(result.stdout, result.stdoutText); +}); + // --- consent.mjs ----------------------------------------------------------- function sandboxFile() { diff --git a/tests/kit/adapter-manifest.test.mjs b/tests/kit/adapter-manifest.test.mjs index 33ca128..70aa475 100644 --- a/tests/kit/adapter-manifest.test.mjs +++ b/tests/kit/adapter-manifest.test.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { validateAdapterManifest, DRIVING_SURFACES, MANIFEST_TRUST_KINDS, ManifestRejected, } from '../../src/lib/adapters/manifest.mjs'; +import { hashManifest } from '../../src/lib/adapters/admission.mjs'; function validHost(overrides = {}) { return { @@ -281,3 +282,70 @@ test('unknown-field: an extra or miscased capability key cannot ride along inert rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, CanBePrimary: true } }) }), 'unknown-field'); rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, ADMIN: true } }) }), 'unknown-field'); }); + +// ── execution block (P2, ADR-0031) ────────────────────────────────────────── +// validHost()'s default capabilities already declare canRouteActivities:true, +// so an execution block is legal by default; the coupling tests below flip +// that flag explicitly to exercise both directions. + +function withExecution(overrides = {}) { + return { + run: { hook: { command: ['acme', 'run'], timeoutMs: 5000 } }, + ...overrides, + }; +} + +test('execution block round-trips into the validated output', () => { + const manifest = validateAdapterManifest(validManifest({ execution: withExecution() })); + assert.deepEqual(manifest.execution.run.hook.command, ['acme', 'run']); + assert.equal(manifest.execution.run.hook.timeoutMs, 5000); + assert.ok(Object.isFrozen(manifest.execution)); +}); + +test('a manifest with no execution block omits the key entirely', () => { + const manifest = validateAdapterManifest(validManifest()); + assert.equal('execution' in manifest, false); +}); + +test('an execution block changes hashManifest\'s value vs. the same manifest without it', () => { + const withoutExecution = validateAdapterManifest(validManifest()); + const withExecutionBlock = validateAdapterManifest(validManifest({ execution: withExecution() })); + assert.notEqual(hashManifest(withoutExecution), hashManifest(withExecutionBlock)); +}); + +test('execution-not-routable: an execution block requires host.capabilities.canRouteActivities:true', () => { + rejects(validManifest({ + host: validHost({ capabilities: { ...validHost().capabilities, canRouteActivities: false } }), + execution: withExecution(), + }), 'execution-not-routable'); +}); + +test('canRouteActivities:true without an execution block still validates (legal degrade-at-runtime)', () => { + const manifest = validateAdapterManifest(validManifest({ + host: validHost({ capabilities: { ...validHost().capabilities, canRouteActivities: true } }), + })); + assert.equal(manifest.host.capabilities.canRouteActivities, true); + assert.equal('execution' in manifest, false); +}); + +test('unknown-field: an extraneous execution key is refused', () => { + rejects(validManifest({ execution: { run: withExecution().run, escalate: true } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous execution.run key is refused', () => { + rejects(validManifest({ execution: { run: { hook: withExecution().run.hook, cwd: '/tmp' } } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous execution.run.hook key is refused', () => { + rejects(validManifest({ + execution: { run: { hook: { command: ['acme', 'run'], cwd: '/tmp' } } }, + }), 'unknown-field'); +}); + +test('invalid-execution: an empty command array is rejected', () => { + rejects(validManifest({ execution: withExecution({ run: { hook: { command: [] } } }) }), 'invalid-execution'); +}); + +test('invalid-execution: a non-positive timeoutMs is rejected', () => { + rejects(validManifest({ execution: withExecution({ run: { hook: { command: ['acme', 'run'], timeoutMs: 0 } } }) }), 'invalid-execution'); +}); From aa09bdd7fce1506951f5414954ee11b1dca5064f Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 16 Aug 2026 12:07:15 -0600 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20graduation=20machinery=20wave=20C?= =?UTF-8?q?=20=E2=80=94=20external=20lifecycle=20execution=20+=20tiered=20?= =?UTF-8?q?conformance=20(ADR-0031=20P3/P4)=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lifecycle): external lifecycle execution in setup/sync/uninstall, shape-agnostic (ADR-0031 P3) The lifecycle loops iterate hostsWithLifecycle() (built-ins + admitted) through a shared shape-dispatching renderer (opencode's rich per-surface shape and an admitted host's generic lifecycleResult both handled in one place), and the admission bootstrap now registers an admitted host's derived lifecycle adapter. An admitted host runs only when explicitly enabled in kit.json AND the experimental flag is set; built-in opencode is unchanged. Security-hardened (adversarial review): - admitted lifecycle hooks are cwd-anchored to the adapter's own resolved directory, per-verb 'lifecycle-unanchored' refusal for a relative hook on a remote source (the F-1 arbitrary-code-execution protection, now on the lifecycle path that P3 makes live) - rendered report lines strip C0/C1/DEL and clamp to one line, so a hostile hook cannot forge or erase a report line - the payload is parsed from stdout alone (stderr never collapses a result) - Windows executable extensions are treated as unanchorable bare tokens Also restores each opencode sub-surface's own failure level in the shared renderer (a failed plugin/agents/skill renders as a failure, not green) — fixing a sync-path regression the generalization would otherwise introduce. Known limitation (documented in ADR-0031): the sync path is wired but not yet reachable through a real `ak sync` — status.mjs's subsystem derivation is still opencode-scoped; setup and uninstall are fully live. * feat(conformance): tiered conformance harness + ak host adapters conformance (ADR-0031 P4) runTieredConformance evaluates the five ADR-0031 tiers black-box against an installed layout and records passed/gated results into the grant store against the manifest hash. admission and activity-routing genuinely pass (real subprocess worker through executeRunPlan); session-driving / primary-eligible / statusline report gated/skipped honestly — their runtime paths are not built and the harness never fabricates a pass. Integrity properties (adversarial review): - the harness derives the adapter baseDir identically to production, so it admits the shipped fixture as authored (no test-side command rewriting) - a failed admission tier short-circuits every downstream tier to skipped and persists nothing — no evidence laundered into the grant store - the CLI discloses every hook that will run as a real subprocess before running - all three admitted overlays (host, execution, lifecycle) reset together * fix(execution): treat Windows executable extensions as unanchorable bare tokens Parity with the lifecycle-path anchorability check: a bare hook.bat/.exe/.cmd/ .com/.ps1 with no anchoring base directory would resolve from the operator's cwd on Windows (CreateProcess searches it), the same planted-file vector a relative POSIX script already refuses. * docs(adr): ADR-0031 status — external lifecycle + tiered conformance rows Working (wave C) * test(lifecycle): make external-lifecycle subprocess fixtures cross-platform Windows CI failed the two real-subprocess setup.run_machine tests: the fake globex CLI was a bare extensionless file on PATH, which Windows executable resolution (PATHEXT) never finds, so have() reported it absent and the lifecycle hook was skipped. Mirror the Windows-green withOpencodeCli shim (bare + .cmd + .ps1 pair, which resolveShim requires together). Also move the marker hooks off inline `node -e` — whose JSON-embedded Windows paths CreateProcess argv-quoting can corrupt — onto real anchored script files, and switch the admission F-1 tests' interpreter from bare 'node' to process.execPath. Test-only; production adapters name real binaries. --- ...bility-graduation-and-upstream-requests.md | 4 +- src/commands/setup.mjs | 77 +-- src/commands/sync.mjs | 45 +- src/commands/uninstall.mjs | 55 ++- src/commands/x/host-adapters.mjs | 126 ++++- src/lib/adapters/admission.mjs | 53 +- src/lib/adapters/conformance.mjs | 459 ++++++++++++++++++ src/lib/adapters/lifecycle-registry.mjs | 236 +++++++-- src/lib/adapters/lifecycle-render.mjs | 200 ++++++++ src/lib/execution/admitted.mjs | 6 +- tests/kit/adapter-admission.test.mjs | 161 +++++- tests/kit/conformance-tiers.test.mjs | 406 ++++++++++++++++ tests/kit/external-lifecycle.test.mjs | 438 +++++++++++++++++ tests/kit/host-adapters-cli.test.mjs | 216 ++++++++- tests/kit/lifecycle-registry.test.mjs | 285 ++++++++++- tests/kit/lifecycle-render.test.mjs | 156 ++++++ tests/kit/uninstall-command.test.mjs | 17 + 17 files changed, 2812 insertions(+), 128 deletions(-) create mode 100644 src/lib/adapters/conformance.mjs create mode 100644 src/lib/adapters/lifecycle-render.mjs create mode 100644 tests/kit/conformance-tiers.test.mjs create mode 100644 tests/kit/external-lifecycle.test.mjs create mode 100644 tests/kit/lifecycle-render.test.mjs diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index 9472b77..a036065 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -154,8 +154,8 @@ unbuilt. This table is the source of truth for what is real. | Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) | | `ak host adapters trust` CLI (records consent/grants) | **Working** (2026-08-16, wave A) | `list`/`trust`/`revoke` + `--expect-hash` pinning; disclosure prints the full validated manifest (control-char-safe); mirrors every pre-hash admission refusal; `revoke` works with the flag off (fail-safe) | | External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` | -| External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized | -| Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit | +| External lifecycle execution wired into setup/sync/uninstall | **Working** (2026-08-16, wave C) | The loops iterate `hostsWithLifecycle()` (built-ins + admitted) through a shape-agnostic renderer; an admitted host's lifecycle runs only when explicitly enabled in `kit.json` **and** the flag is set. Admitted lifecycle hooks are cwd-anchored to the adapter's own directory (per-verb `lifecycle-unanchored` refusal for a relative hook on a remote source), the same F-1 protection as execution. *Known limitation:* the `sync` path is wired but not yet reachable through a real `ak sync` — `status.mjs`'s subsystem derivation is still opencode-scoped; setup and uninstall are fully live. Generalizing `status.mjs` is a tracked follow-up | +| Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, wave C) | `runTieredConformance` + `ak host adapters conformance`: `admission` and `activity-routing` genuinely pass black-box against a real fixture (real subprocess worker); `session-driving`/`primary-eligible`/`statusline` report `gated`/`skipped` honestly because their runtime paths (external session driving, lead/escalation, statusline render) are not built — the harness never fabricates a pass. Passed/gated tiers are recorded into the grant store against the manifest hash; a failed `admission` tier short-circuits every downstream tier so no evidence is laundered | | Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave | | Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A) | file / https (no redirects, bounded time+bytes) / `npm:` (`npm pack --ignore-scripts` + `tar -xzOf` stdout-only — nothing extracted to disk, package scripts never run); resolver runs before hashing, so a mutated remote surfaces as `consent-stale`. The https fetch is host-unrestricted by design (the source is operator-authored in user-scope `kit.json`; redirects refused, no credentials attached) | | Upstream request tracking (`gated: #NNN` against a tier) | **Partial** (2026-08-16, wave A) | Per-tier `gated` records exist in the grant store (`recordTierGate`, ref-format-validated); CLI recording/display pending a later wave | diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index a285216..6f8a9e8 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -14,7 +14,8 @@ import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { reconcileOpencodeGuidance } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; -import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; @@ -29,6 +30,17 @@ import { import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs'; +/** Prints one lifecycle-render.mjs report line at its own level — 'fail' + * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`, + * so a genuinely failed opencode sub-surface never reads as merely + * informational. */ +function printReportLine(line) { + if (line.level === 'ok') ok(line.text); + else if (line.level === 'warn') warn(line.text); + else if (line.level === 'fail') fail(line.text); + else info(line.text); +} + export const options = { 'dry-run': { type: 'boolean', default: false }, yes: { type: 'boolean', default: false }, @@ -215,21 +227,23 @@ export async function run_machine({ flags, pkgRoot, cfg }) { // 6b. host lifecycle wiring — config-file MCP + skills, lifecycle plugin, // converted agents, platform skill (each adapter owns its own surfaces - // — opencode.mjs for opencode). Registry-driven: loops - // builtinHostsWithLifecycle() rather than naming opencode, so a second - // BUILT-IN lifecycle host needs no new branch here. Only when the CLI - // is actually present: a declined/failed install must not leave a - // freshly-created config home behind (codex-review #4). The result - // SHAPE consumed below (stack.oc/plugin/agents/skill) is still - // opencode's own — the lifecycle contract doesn't mandate a common - // `apply()` result shape across hosts. builtinHostsWithLifecycle() - // (not hostsWithLifecycle()) deliberately excludes admitted external - // hosts: this loop body is opencode-shaped, and external lifecycle - // execution graduates in a later wave alongside a shape-agnostic body - // (see lifecycle-registry.mjs's registerAdmittedLifecycle comment). - for (const hostId of builtinHostsWithLifecycle()) { - if (!cfg.integrations?.hosts?.[hostId]) continue; - if (!(await have(hostId))) { + // — opencode.mjs for opencode; a subprocess hook for an admitted + // external — see lifecycle-registry.mjs's buildAdmittedLifecycleAdapter). + // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted, + // ADR-0031 P3) rather than naming opencode, so a second lifecycle host + // — built-in or admitted — needs no new branch here. lifecycleExecutionEnabled + // gates each host: a built-in only needs cfg enablement (unchanged); an + // admitted external ALSO needs the experimental flag — an admitted host + // is opt-in exactly like opencode, and this never auto-enables anything. + // Only when the CLI is actually present: a declined/failed install must + // not leave a freshly-created config home behind (codex-review #4). + // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle + // result's own shape (opencode's rich per-surface shape vs. an admitted + // host's generic lifecycleResult), so this loop body never destructures + // a host-specific result directly. + for (const hostId of hostsWithLifecycle()) { + if (!lifecycleExecutionEnabled(hostId, cfg)) continue; + if (!(await have(detectionBinFor(hostId)))) { const pkg = HOSTS.find((h) => h.id === hostId)?.pkg ?? hostId; warn(`${hostId}: enabled but CLI not installed — wiring skipped (re-run \`ak sync\` after installing ${pkg})`); continue; @@ -237,23 +251,22 @@ export async function run_machine({ flags, pkgRoot, cfg }) { const lifecycle = await runLifecycle({ adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot }, }); - const stack = lifecycle.result; - (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); - if (stack.oc.fatal) { - warn(`opencode plugin/agents/skill/guidance skipped — ${stack.oc.detail}`); - return false; + const report = renderApplyReport(hostId, lifecycle); + for (const line of report.lines) printReportLine(line); + if (report.fatal) return false; + // guidance blocks + the startup-reload note are opencode-specific surfaces + // (AGENTS.md blocks, opencode's own load-once-at-startup behavior) with no + // equivalent in the generic hook contract — stays gated on the rich shape. + if (report.shape === 'opencode') { + // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) + // — not on the next status-driven reconcile. Same shared reconcile pick + // and off use, so every command converges guidance identically. + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true }); + ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`); + // opencode loads config/plugins/MCP/agents once at startup — say so now, + // or the user files "hooks don't work" issues (observed live). + info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); } - ok(`opencode plugin: ${stack.plugin.detail}`); - ok(`opencode agents: ${stack.agents.detail}`); - if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`); - // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) - // — not on the next status-driven reconcile. Same shared reconcile pick - // and off use, so every command converges guidance identically. - const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true }); - ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`); - // opencode loads config/plugins/MCP/agents once at startup — say so now, - // or the user files "hooks don't work" issues (observed live). - info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); } // 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 9f44e46..3160dfa 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -9,7 +9,8 @@ import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; -import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; @@ -24,6 +25,17 @@ import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, bold, dim, withProgress, reportOutcome } from '../lib/output.mjs'; import { applyCodexStatusline, projectionFor } from '../lib/codex-statusline.mjs'; +/** Prints one lifecycle-render.mjs report line at its own level — 'fail' + * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`, + * so a genuinely failed opencode sub-surface never reads as merely + * informational. */ +function printReportLine(line) { + if (line.level === 'ok') ok(line.text); + else if (line.level === 'warn') warn(line.text); + else if (line.level === 'fail') fail(line.text); + else info(line.text); +} + export const options = { 'dry-run': { type: 'boolean', default: false }, 'no-upgrade': { type: 'boolean', default: false }, @@ -188,32 +200,29 @@ export async function run({ flags, pkgRoot, fetchLatest }) { // Runs BEFORE the blocks branch: the agents-opencode guidance target is gated // on the config home this branch creates — this order lets a fresh enable // converge guidance in the SAME sync (a second sync is then a true no-op). - // Registry-driven: loops builtinHostsWithLifecycle() rather than naming - // opencode, so a second BUILT-IN lifecycle host needs no new branch here. - // Only opencode is registered today, so this loop runs exactly once — - // byte-identical to the single-host branch it replaces. The result SHAPE - // consumed below (stack.oc/plugin/agents/skill) is still opencode's own — - // the lifecycle contract doesn't mandate a common `apply()` result shape - // across hosts. builtinHostsWithLifecycle() (not hostsWithLifecycle()) - // deliberately excludes admitted external hosts — see lifecycle-registry.mjs. - for (const hostId of builtinHostsWithLifecycle()) { - if (!subsystems.has(hostId) || !cfg.integrations?.hosts?.[hostId]) continue; - if (!(await have(hostId))) { + // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted, + // ADR-0031 P3) rather than naming opencode, so a second lifecycle host — + // built-in or admitted — needs no new branch here. lifecycleExecutionEnabled + // gates each host exactly as setup.mjs does (built-in: cfg enablement only; + // admitted: cfg enablement AND the experimental flag — never auto-enabled). + // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle + // result's own shape, so this loop body never destructures a host-specific + // result directly; opencode's per-surface lines render exactly as before. + for (const hostId of hostsWithLifecycle()) { + if (!subsystems.has(hostId) || !lifecycleExecutionEnabled(hostId, cfg)) continue; + if (!(await have(detectionBinFor(hostId)))) { info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`); continue; } const lifecycle = await runLifecycle({ adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot }, }); - const stack = lifecycle.result; + const applyReport = renderApplyReport(hostId, lifecycle); // persist the markers on ANY refresh (a converged file whose kit.json // markers are stale/missing still needs the save, or the next teardown // cannot prove ownership — codex-review r3), not only on file changes. - if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg); - if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc); - report('opencode plugin', stack.plugin); - report('opencode agents', stack.agents); - if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill); + if (applyReport.ocChanged || applyReport.markersChanged) saveKitConfig(cfg); + for (const line of applyReport.lines) printReportLine(line); } // The 'opencode' guard: the opencode branch above can CREATE the config home // that activates the agents-opencode guidance target — a machine whose other diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs index 721e129..74ab88f 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -11,12 +11,25 @@ import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs'; import { unregister } from '../lib/mcp.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; -import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, isBuiltinHost } from '../lib/adapters/lifecycle-registry.mjs'; +import { renderUndoReport } from '../lib/adapters/lifecycle-render.mjs'; import { present as rbPresent } from '../lib/ruvnet-brain.mjs'; import * as paths from '../lib/paths.mjs'; -import { ok, warn, info } from '../lib/output.mjs'; +import { ok, warn, fail, info } from '../lib/output.mjs'; import { removeCodexStatusline } from '../lib/codex-statusline.mjs'; +/** Prints one lifecycle-render.mjs report line at its own level — mirrors + * setup.mjs/sync.mjs's own printReportLine (N-2, Wave C security review + * follow-up): renderUndoReport only ever emits 'ok'/'warn' today, so this is + * latent, but the day an undo renderer adopts levelForResult (F5's mapping) + * a 'fail' line must reach fail(), not be silently downgraded to warn(). */ +export function printReportLine(line) { + if (line.level === 'ok') ok(line.text); + else if (line.level === 'warn') warn(line.text); + else if (line.level === 'fail') fail(line.text); + else info(line.text); +} + export const options = { 'dry-run': { type: 'boolean', default: false }, 'this-project': { type: 'boolean', default: false }, @@ -128,23 +141,31 @@ export async function run({ flags }) { // removes kit.json below, so persisting cfg here would recreate it. Each // adapter's own undo() already honors ownership/receipts (opencode's // undoOpencode no-ops when it never held mcp:'ak', and marker-gates - // artifact removal independent of that), so this call is unconditional per - // host — the only kit-side gate is "did anything actually happen", to - // avoid a no-op teardown line (and a needless kit.json rewrite) on a host - // that was never enabled. builtinHostsWithLifecycle() (not - // hostsWithLifecycle()) deliberately excludes admitted external hosts: - // this loop body destructures an opencode-shaped result (ret.undo, - // ret.artifacts) — see lifecycle-registry.mjs's registerAdmittedLifecycle - // comment for why external lifecycle execution isn't wired through here yet. - for (const hostId of builtinHostsWithLifecycle()) { + // artifact removal independent of that), so a BUILT-IN's call is + // unconditional per host, same as before ADR-0031 P3 — the only kit-side + // gate is "did anything actually happen", to avoid a no-op teardown line + // (and a needless kit.json rewrite) on a host that was never enabled. An + // ADMITTED external host is different: there is no "always safe, always + // idempotent" guarantee for an arbitrary third-party hook the way there is + // for opencode's own undo, so an admitted host's teardown is gated by + // lifecycleExecutionEnabled (cfg enablement AND the experimental flag) — + // an admitted host that was never enabled/consented for this run is never + // invoked. hostsWithLifecycle() (built-ins + admitted, ADR-0031 P3) is safe + // to loop unconditionally now: lifecycle-render.mjs's renderUndoReport + // dispatches on the runLifecycle result's own shape, so this loop body + // never destructures a host-specific result directly. + for (const hostId of hostsWithLifecycle()) { + if (!isBuiltinHost(hostId) && !lifecycleExecutionEnabled(hostId, cfg)) continue; const adapter = lifecycleAdapterFor(hostId); if (dry) { - info(`[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`); + info(isBuiltinHost(hostId) + ? `[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)` + : `[dry-run] stripped ak-managed ${hostId} wiring + artifacts (hook-declared undo)`); continue; } const retired = await runLifecycle({ adapter, action: 'undo', cfg }); - const ret = retired.result; - ownershipTeardownOk = ownershipTeardownOk && ret.ok; + const undoReport = renderUndoReport(hostId, retired); + ownershipTeardownOk = ownershipTeardownOk && undoReport.ok; // Persist markers unconditionally, exactly like x/host.mjs's off()/pick(): // undo() mutates cfg's ownership markers in memory even when it rewrote // no file (`undo.changed` measures the FILE, not cfg), so gating the save @@ -152,11 +173,7 @@ export async function run({ flags }) { // quiet-success path. Only the human-facing line stays gated on "did // anything observable happen". if (!flags.purge) saveKitConfig(cfg); - if (ret.undo.changed || ret.artifacts.changed || !ret.ok) { - (ret.ok ? ok : warn)(ret.ok - ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)` - : `${hostId} teardown incomplete — ${ret.undo.detail}`); - } + for (const line of undoReport.lines) printReportLine(line); } if (flags.purge && fs.existsSync(paths.kitConfigPath())) { if (ownershipTeardownOk) act('removed kit.json', () => fs.rmSync(paths.kitConfigPath())); diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 9ed021f..8f62ae4 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -14,6 +14,7 @@ import { hashManifest, SUPPORTED_CONTRACT } from '../../lib/adapters/admission.m import { validateAdapterManifest } from '../../lib/adapters/manifest.mjs'; import { HOST_REGISTRY } from '../../lib/adapters/registries.mjs'; import * as consentStore from '../../lib/adapters/consent.mjs'; +import { runTieredConformance as defaultRunTieredConformance } from '../../lib/adapters/conformance.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; @@ -267,16 +268,127 @@ function revoke({ name, consent }) { return 0; } +/** Unwrap a reader result down to the raw manifest document — + * runTieredConformance's `readManifest` follows admitAdapters' own contract + * ((source) => Promise raw), while `reader` here may return either the + * sources.mjs `{raw, origin}` shape or a bare raw document (same tolerance + * loadAndHash above applies). */ +function toRawManifestReader(reader) { + return async (source) => { + const resolved = await reader(source); + return resolved && typeof resolved === 'object' && 'raw' in resolved ? resolved.raw : resolved; + }; +} + +function tierLine(tier) { + const detail = tier.detail + ?? tier.checks.find((c) => !c.ok)?.detail + ?? tier.checks[0]?.detail + ?? ''; + return `${String(tier.tier).padEnd(18)} ${String(tier.status).padEnd(8)} ${dim(stripControl(detail))}`; +} + +function hookCommandsFor(manifest) { + const hooks = []; + for (const [verb, def] of Object.entries(manifest.lifecycle ?? {})) { + if (def?.hook?.command) hooks.push(`lifecycle.${verb}: ${JSON.stringify(def.hook.command)}`); + } + if (manifest.execution?.run?.hook?.command) { + hooks.push(`execution.run: ${JSON.stringify(manifest.execution.run.hook.command)}`); + } + return hooks; +} + +/** F6 (Wave C security review): `conformance` self-consents — it records its + * own temporary consent and spawns declared hooks with only the experimental + * flag plus a kit.json entry as gates, needing no prior + * `ak host adapters trust`. That is the intended self-test posture (ADR-0031 + * §5), not a consent bypass, but the operator/maintainer must not be + * surprised by it: disclose every hook command about to run as a REAL + * subprocess before the harness does anything. Best-effort and non-fatal — + * a manifest that can't be pre-read/validated here still gets a generic + * warning; the harness's own admission tier reports the real failure reason + * either way, this is disclosure only, never a gate. */ +async function warnAboutHooks(name, entry, rawReader) { + warn(`'${name}' conformance is a SELF-TEST (ADR-0031 §5) — it records its own temporary consent and needs no prior 'ak host adapters trust'.`); + let manifest; + try { + manifest = validateAdapterManifest(await rawReader(entry.source)); + } catch { + warn(' the manifest could not be pre-disclosed here; any declared hooks still run as REAL subprocesses.'); + return; + } + const hooks = hookCommandsFor(manifest); + if (!hooks.length) { + info(` '${name}' declares no lifecycle/execution hooks — nothing will be spawned.`); + return; + } + warn(' the following hooks will run as REAL subprocesses:'); + // N-1 (Wave C security review): hook.command is arbitrary validated + // strings — JSON.stringify escapes C0 (0x00-0x1F) but leaves C1 + // (0x80-0x9F, e.g. U+009B CSI) and DEL (0x7F) untouched, same gap F3 + // closed elsewhere in this file. This is the one line whose entire job is + // to be trustworthy before any code runs, so it routes through the same + // stripControl every other untrusted-string sink here already uses. + for (const line of hooks) console.log(` ${dim(stripControl(line))}`); +} + +/** `ak host adapters conformance ` — runs the ADR-0031 §2 tiered + * conformance harness against one configured adapter entry and prints a + * per-tier table. Recording into the grant store happens INSIDE + * runTieredConformance itself (conformance.mjs) — this command's job is + * resolving the cfg entry, wiring a real reader, disclosing what is about to + * spawn, and rendering the report; see conformance.mjs's own header for the + * recording semantics (passed -> recordTierResult, upstream-gated -> + * recordTierGate, everything else persists nothing). */ +async function conformance({ + name, cfg, reader, runTiered, consentFile, grantsFile, +}) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters conformance '); return 2; } + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${name}' in kit.json hostAdapters`); return 1; } + + const rawReader = toRawManifestReader(reader); + await warnAboutHooks(name, entry, rawReader); + + let report; + try { + report = await runTiered({ + name, + manifestSource: entry.source, + readManifest: rawReader, + consentFile, + grantsFile, + }); + } catch (error) { + fail(`'${name}' conformance run failed: ${stripControl(error?.message ?? String(error))}`); + return 1; + } + + console.log(bold(`host adapter conformance — ${report.name}`) + (report.hash ? dim(` (${report.hash})`) : '')); + let anyFailed = false; + for (const tier of report.tiers) { + const line = tierLine(tier); + if (tier.status === 'passed') ok(line); + else if (tier.status === 'failed') { fail(line); anyFailed = true; } + else if (tier.status === 'gated') warn(line); + else info(line); + } + return anyFailed ? 1 : 0; +} + /** * @param {{ positionals?: string[], flags?: any, env?: NodeJS.ProcessEnv, * consent?: { recordedHashFor(name:string): string|null, recordConsent(name:string, hash:string): void, revokeConsent(name:string): boolean }, * reader?: (source: string) => Promise, ask?: (question: string) => Promise, - * isTTY?: boolean, cfg?: any }} [args] + * isTTY?: boolean, cfg?: any, runTieredConformance?: (options: any) => Promise, + * consentFile?: string, grantsFile?: string }} [args] */ export async function run({ positionals = [], flags = {}, env = process.env, consent = consentStore, reader = defaultReader, ask = defaultAsk, isTTY = process.stdin.isTTY === true, cfg, + runTieredConformance = defaultRunTieredConformance, consentFile, grantsFile, } = {}) { const sub = positionals[0] ?? 'list'; const name = positionals[1]; @@ -284,8 +396,9 @@ export async function run({ // Revocation is fail-safe and stays reachable regardless of the // experimental flag: an operator who turns the flag OFF must still be // able to withdraw a standing consent record, or that record silently - // reactivates the next time the flag is turned back on. `list`/`trust` - // stay gated — they're the surface that reads/records new trust. + // reactivates the next time the flag is turned back on. `list`/`trust`/ + // `conformance` stay gated — they're the surface that reads/records new + // trust or evidence. if (sub === 'revoke') return revoke({ name, consent }); if (!flagEnabled(env)) { @@ -302,7 +415,12 @@ export async function run({ yes: !!flags.yes, expectHash: flags['expect-hash'], }); } + if (sub === 'conformance') { + return conformance({ + name, cfg: resolvedCfg, reader, runTiered: runTieredConformance, consentFile, grantsFile, + }); + } - fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke)`); + fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance)`); return 2; } diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index eba98a1..0702ab6 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -219,6 +219,14 @@ export async function bootstrapHostAdapters({ const { applyAdmitted } = await import('./admitted.mjs'); applyAdmitted(admitted); + // name -> the cfg entry's own declared source, for F-1's baseDir + // derivation below (admitted results carry the validated manifest, not + // the raw cfg entry that named where it came from). Shared by both the + // execution- and lifecycle-registration blocks below — one map, not a + // second copy — so a caller correcting F-1 in one place can't drift from + // the other. + const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source])); + // P2 (ADR-0031): an admitted manifest declaring both an execution block // and host.capabilities.canRouteActivities gets its execution adapter // derived and registered here, so `ak run` can route to it. Same @@ -228,10 +236,6 @@ export async function bootstrapHostAdapters({ result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true )); if (executionCandidates.length) { - // name -> the cfg entry's own declared source, for F-1's baseDir - // derivation below (admitted results carry the validated manifest, not - // the raw cfg entry that named where it came from). - const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source])); try { const { registerAdmittedExecution } = await import('../execution/admitted.mjs'); for (const result of executionCandidates) { @@ -259,6 +263,47 @@ export async function bootstrapHostAdapters({ } } } + + // P3 (ADR-0031): an admitted manifest declaring a lifecycle block gets its + // derived lifecycle adapter registered here, so it appears in + // hostsWithLifecycle() and setup/sync/uninstall's lifecycle loops can + // drive it (gated per-run by lifecycleExecutionEnabled — registration + // alone never runs a hook). Same guarded, non-fatal posture as the + // execution-registration block above: one adapter's registration failure + // never blocks the others or the admission result. F-1 (same as + // execution above): baseDir anchors a relative lifecycle hook command to + // the adapter's own directory — without it, a relative command would + // resolve against the OPERATOR's cwd, arbitrary-code-execution with the + // consent hash unchanged. Unlike execution (one hook, all-or-nothing), + // lifecycle has five independently-optional verbs, so an unanchorable + // one is refused per-verb (buildAdmittedLifecycleAdapter never wires it + // to spawn) rather than failing the whole registration — the other, + // anchored/PATH-binary verbs still register and work. + const lifecycleCandidates = admitted.filter((result) => !!result.manifest?.lifecycle); + if (lifecycleCandidates.length) { + try { + const { registerAdmittedLifecycle } = await import('./lifecycle-registry.mjs'); + for (const result of lifecycleCandidates) { + try { + const baseDir = baseDirForSource(sourceByName.get(result.name)); + const adapter = registerAdmittedLifecycle(result.manifest, { baseDir }); + if (adapter.unanchoredVerbs.length) { + warnings.push({ + name: result.name, reason: 'lifecycle-unanchored', + detail: `'${result.name}' lifecycle hook(s) refused (relative command, no anchored adapter ` + + `base directory): ${adapter.unanchoredVerbs.join(', ')}`, + }); + } + } catch (error) { + warnings.push({ name: result.name, reason: error?.reason ?? 'lifecycle-registration-failed', detail: error?.message ?? String(error) }); + } + } + } catch (error) { + for (const result of lifecycleCandidates) { + warnings.push({ name: result.name, reason: 'lifecycle-registration-failed', detail: error?.message ?? String(error) }); + } + } + } } return { active: true, admitted, warnings }; diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs new file mode 100644 index 0000000..26c0f39 --- /dev/null +++ b/src/lib/adapters/conformance.mjs @@ -0,0 +1,459 @@ +// Tiered conformance harness (ADR-0031 §2, §5) — generalizes the single +// admission-tier black-box report (tests/kit/adapter-conformance.test.mjs's +// runConformanceReport) into the five graduation tiers named there: admission, +// session-driving, activity-routing, primary-eligible, statusline. Each tier +// is a black-box check against a REAL installed adapter layout — real +// manifest, real admission, real subprocess hooks — no third-party code ever +// runs in-process, matching every other module under adapters/. +// +// Reuse, not reimplementation: this module composes the SAME library calls +// runConformanceReport uses (admitAdapters, applyAdmitted, effectiveHostRegistry, +// registerAdmittedLifecycle, registerAdmittedExecution, executeRunPlan) rather +// than importing that test file directly. A *.test.mjs module registers +// node:test hooks/tests as an IMPORT-TIME side effect (its own top-level +// `before`/`test` calls run the instant the module loads) — importing it from +// a CLI command would fire that TAP output on every `ak host adapters +// conformance` invocation and re-run its whole negative corpus for nothing +// this command needs. The admission tier below is a leaner subset of the same +// sequence (manifest validates -> admits -> joins the registry -> a declared +// detect hook runs as a real subprocess); the negative corpus and +// edit-invalidation checks stay owned by adapter-conformance.test.mjs, which +// exercises the admission GATE itself, not one adapter's conformance. +// +// Passing a tier RECORDS EVIDENCE (grants.mjs's recordTierResult/recordTierGate) +// — it never grants a capability. That stays the maintainer's explicit act +// (ADR-0031 §1), landing with the promotion command in a later wave. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { validateAdapterManifest } from './manifest.mjs'; +import { admitAdapters, hashManifest } from './admission.mjs'; +import { + applyAdmitted, resetAdmitted, effectiveHostRegistry, admittedHostIds, +} from './admitted.mjs'; +import { + recordConsent, recordedHashFor as consentRecordedHashFor, isTrusted as consentIsTrusted, +} from './consent.mjs'; +import { registerAdmittedLifecycle, resetAdmittedLifecycle } from './lifecycle-registry.mjs'; +import { registerAdmittedExecution, resetAdmittedExecution } from '../execution/admitted.mjs'; +import { executeRunPlan } from '../execution/runner.mjs'; +import { have } from '../exec.mjs'; +import { + CONFORMANCE_TIERS, TIER_GRANTS, adapterGrantsPath, recordTierResult, recordTierGate, grantedCapabilitiesFor, +} from './grants.mjs'; + +export { CONFORMANCE_TIERS, TIER_GRANTS }; + +const nowIso = () => new Date().toISOString(); + +/** admitAdapters' readManifest contract: `(source) => Promise` — a real + * resolve, matching admission.mjs's own default (dynamic import so this + * module's own load stays cheap for callers that always inject a reader). */ +async function defaultReadManifest(source) { + const { resolveManifestSource } = await import('./sources.mjs'); + const { raw } = await resolveManifestSource(source); + return raw; +} + +/** The adapter's own directory, for the execution adapter's F-1 cwd-anchoring + * (execution/admitted.mjs) — same derivation admission.mjs's own + * baseDirForSource uses (not exported there, so mirrored here rather than + * reaching into that module's private internals). A remote (npm/https) + * source has no persistent local bundle: null, honestly, never a guessed cwd. */ +function baseDirForSource(source) { + if (typeof source !== 'string' || !source + || source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) { + return null; + } + try { + return path.dirname(fs.realpathSync(source)); + } catch { + return null; + } +} + +async function runCheck(checks, name, fn) { + try { + const detail = await fn(); + checks.push({ name, ok: true, detail: typeof detail === 'string' ? detail : undefined }); + } catch (error) { + checks.push({ name, ok: false, detail: error?.message ?? String(error) }); + } +} + +function evidenceFromChecks(checks) { + return checks.map((c) => c.detail).filter(Boolean).join('; '); +} + +// ── admission tier ────────────────────────────────────────────────────── +// manifest validates -> admits through admitAdapters with a real on-disk +// consent record -> joins effectiveHostRegistry() -> a declared detect hook +// runs as a real subprocess and its JSON payload flows back (ADR-0031 §2's +// evidence shape for this tier, and the shipped ADR-0029 admission gate). +async function checkAdmission({ + name, source, readManifest, consentFile, baseDir, +}) { + const checks = []; + let manifest = null; + let admittedResult = null; + + await runCheck(checks, 'manifest reads and validates', async () => { + const raw = await readManifest(source); + manifest = validateAdapterManifest(raw); + return `host id '${manifest.host.id}', contract ${manifest.contract}`; + }); + + if (!manifest) return { checks, manifest: null, hash: null }; + + const hash = hashManifest(manifest); + const resolvedName = name ?? manifest.host.id; + + await runCheck(checks, 'admits through admitAdapters with a real on-disk consent record', async () => { + recordConsent(resolvedName, hash, { file: consentFile }); + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: resolvedName, source }] }, + readManifest, + consent: { + recordedHashFor: (n) => consentRecordedHashFor(n, { file: consentFile }), + isTrusted: (n, h) => consentIsTrusted(n, h, { file: consentFile }), + }, + }); + admittedResult = results[0]; + if (!admittedResult?.admitted) throw new Error(admittedResult?.detail ?? `admission refused: ${admittedResult?.reason}`); + return `admitted as '${admittedResult.entry.id}'`; + }); + + if (admittedResult?.admitted) { + await runCheck(checks, "joins effectiveHostRegistry()", async () => { + applyAdmitted([admittedResult]); + if (!effectiveHostRegistry().some((host) => host.id === resolvedName)) throw new Error('not present in effectiveHostRegistry()'); + if (!admittedHostIds().includes(resolvedName)) throw new Error('not present in admittedHostIds()'); + return 'joined effectiveHostRegistry()'; + }); + } + + const detectHook = manifest.lifecycle?.detect?.hook; + if (detectHook) { + await runCheck(checks, 'declared detect hook runs as a real subprocess', async () => { + // No runHook injection: exercises buildAdmittedLifecycleAdapter's real + // default, a dynamic import of the real hook-runner.mjs — the whole + // chain (derived adapter -> hook runner -> spawned process -> stdout + // JSON) actually runs, not just wired (mirrors runConformanceReport). + // F1 (Wave C security review): baseDir MUST be threaded through here — + // with no baseDir, buildAdmittedLifecycleAdapter's own F-1 anchoring + // check (lifecycle-registry.mjs) refuses ANY relative lifecycle hook + // command outright (adapter.unanchoredVerbs), which would report every + // realistically-authored file-sourced adapter — including the repo's + // own acme fixture — as FAILED here for a reason that has nothing to + // do with the adapter's actual conformance. + const adapter = registerAdmittedLifecycle(manifest, { baseDir }); + const detected = await adapter.detect({}); + if (!detected || typeof detected !== 'object') throw new Error('detect hook returned no observation'); + if (detected.error) throw new Error(`detect hook reported an error: ${detected.error}`); + return 'detect hook produced an observation'; + }); + } else { + checks.push({ name: 'declared detect hook runs as a real subprocess', ok: true, detail: 'no detect hook declared — nothing to prove' }); + } + + return { checks, manifest, hash }; +} + +// ── session-driving tier ──────────────────────────────────────────────── +// Gates canDriveSession. A manifest that never declares it has nothing to +// prove (skipped). A manifest that DOES declare it cannot be proven today: +// ak has no external session-driving execution path, and being a native +// ruflo backend is upstream-owned (ADR-0031 §4, grounded against +// ruvnet/ruflo@45e65b5's per-host ENABLE_* backend model, not an outside +// registration surface) — honestly 'gated', never faked. `upstreamRef` +// (caller-supplied, `#` form) is ONLY set once a real tracking +// issue exists to file the backend-registration request against; omitted, the +// report still says 'gated' but carries no `gatedBy` to persist — there is +// nothing yet to pin a grants.mjs recordTierGate() call to. +function checkSessionDriving({ manifest, upstreamRef }) { + if (!manifest) { + return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } + if (manifest.host?.capabilities?.canDriveSession !== true) { + return { + status: 'skipped', + checks: [{ name: 'canDriveSession declared', ok: true, detail: 'not declared — nothing to prove' }], + }; + } + const detail = "ak has no external session-driving execution path yet — being a native ruflo backend is " + + 'upstream-owned (ADR-0031 §4: ruvnet/ruflo\'s per-host ENABLE_* backend model, not an outside ' + + "registration surface); interim behaviour: the host runs through ak's own supervised execution, " + + 'just not as a ruflo-native backend'; + return { + status: 'gated', + checks: [{ name: 'external session-driving execution path exists', ok: false, detail }], + detail, + ...(typeof upstreamRef === 'string' && upstreamRef ? { gatedBy: upstreamRef } : {}), + }; +} + +// ── activity-routing tier ─────────────────────────────────────────────── +// Gates canRouteActivities. Requires an execution.run hook. Admits, derives +// the real execution adapter, and drives a real one-worker executeRunPlan +// routed to the host — asserting a succeeded WorkerResult under the runner's +// contract (ADR-0018). This is the tier P2 (execution/admitted.mjs) makes +// real, so it is the one tier expected to genuinely PASS against a conforming +// fixture today. +async function checkActivityRouting({ + manifest, name, baseDir, haveFn, clock, +}) { + if (!manifest) { + return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } + const canRoute = manifest.host?.capabilities?.canRouteActivities === true; + const hasHook = !!manifest.execution?.run?.hook; + if (!canRoute || !hasHook) { + return { + status: 'skipped', + checks: [{ name: 'canRouteActivities + execution.run.hook declared', ok: true, detail: 'not declared — nothing to prove' }], + }; + } + + const checks = []; + let succeeded = null; + + await runCheck(checks, 'registerAdmittedExecution derives and registers a real execution adapter', async () => { + resetAdmittedExecution(); + registerAdmittedExecution(manifest, { haveFn, baseDir }); + return `registered for '${name}'`; + }); + + await runCheck(checks, "a real one-worker executeRunPlan routed to the host returns a succeeded WorkerResult (ADR-0018)", async () => { + // No runHook injection: exercises executeRunPlan's default adapter lookup + // (executionAdapterFor -> admittedExecutionAdapterFor) end-to-end, same + // discipline as runConformanceReport's P2 check — a genuine spawned + // subprocess, not a stub. + const plan = { + workers: [{ + id: 'conformance-w1', activity: 'implementation', role: 'coder', host: name, prompt: 'conformance harness probe', + }], + }; + const [result] = await executeRunPlan(plan, { clock }); + if (result.status !== 'succeeded') throw new Error(result.failure?.reason ?? `expected a succeeded WorkerResult, got '${result.status}'`); + succeeded = result; + return `worker '${result.workerId}' succeeded via host '${result.host}' (exitCategory=${result.exitCategory})`; + }); + + resetAdmittedExecution(); + + const status = checks.every((c) => c.ok) ? 'passed' : 'failed'; + return { + status, + checks, + ...(succeeded ? { evidence: `worker succeeded via host '${succeeded.host}', exitCategory=${succeeded.exitCategory}, provider=${succeeded.provider ?? 'unknown'}` } : {}), + }; +} + +// ── primary-eligible / statusline tiers ───────────────────────────────── +// Both gate a capability the manifest can NEVER declare (canBePrimary, +// commandStatusline are inexpressible in the schema — ADR-0029/0031). Neither +// can be exercised end-to-end today: there is no lead-a-run/receive-an- +// escalation runtime path (ADR-0019) and no admitted-host statusline +// footer-render path anywhere in src/ yet (grep-verified against this wave) — +// so even a granted capability has nothing real to drive through. `exercise` +// is the injection point for the day one of those paths lands: the honest +// default reports 'gated' rather than fabricate a pass, per ADR-0031's "do +// not fake a pass" discipline. Ungranted is reported 'gated' too (an +// ak-local wait on the maintainer's promotion-command grant, Wave D — never +// an upstream ceiling, so it is never persisted via grants.mjs's +// recordTierGate, which is reserved for genuinely upstream gates). +async function checkGrantGatedTier({ + capability, manifest, name, hash, grantsFile, exercise, exerciseLabel, +}) { + if (!manifest) { + return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } + const granted = grantedCapabilitiesFor(name, hash, { file: grantsFile })[capability] === true; + if (!granted) { + const detail = `no '${capability}' grant recorded at this manifest hash — conferred only by an explicit ` + + 'maintainer grant on top of passed conformance evidence (ADR-0031 §1), via the promotion command'; + return { + status: 'gated', + checks: [{ name: `${capability} granted`, ok: false, detail }], + detail: `ak-local: awaiting maintainer grant for '${capability}' (not an upstream ceiling)`, + }; + } + + const outcome = exercise + ? await exercise({ manifest, name, hash }) + : { ok: false, detail: `${exerciseLabel} — no runtime path built yet` }; + + if (outcome?.ok) { + const detail = typeof outcome.detail === 'string' ? outcome.detail : exerciseLabel; + return { status: 'passed', checks: [{ name: exerciseLabel, ok: true, detail }], evidence: detail }; + } + return { + status: 'gated', + checks: [{ name: exerciseLabel, ok: false, detail: outcome?.detail ?? `${exerciseLabel} — no runtime path built yet` }], + detail: `ak-local: '${capability}' is granted, but the runtime path to exercise it is not built yet`, + }; +} + +/** + * Run the ADR-0031 §2 tiered conformance sequence against one adapter + * manifest and return a structured, per-tier report. Self-contained: builds + * its own temp consent store (unless `consentFile` is supplied) and cleans + * it up, resets the admitted/execution overlays on the way out, and is safe + * to call more than once in the same process — same discipline as + * runConformanceReport (tests/kit/adapter-conformance.test.mjs). + * + * Recording (grants.mjs): a 'passed' tier is recorded via recordTierResult; + * a 'gated' tier carrying a `gatedBy` upstream ref (only ever session-driving, + * and only when the caller supplies one — see checkSessionDriving) is + * recorded via recordTierGate. 'skipped'/'failed' tiers, and 'gated' tiers + * with no upstream ref, record nothing — there is nothing new to persist. + * `grantsFile` defaults to the REAL adapter-grants.json (matching grants.mjs's + * own default-to-real-config-path convention) — callers that don't want a + * conformance run to touch the real store (tests, dry runs) must pass an + * explicit path. Pass `persist: false` to skip recording altogether. + * + * @param {{ + * fixtureRoot?: string, manifestSource?: string, name?: string, + * tiers?: readonly string[], readManifest?: (source: string) => Promise, + * consentFile?: string, grantsFile?: string, persist?: boolean, + * haveFn?: (cmd: string, opts?: any) => Promise, baseDir?: string|null, + * sessionDrivingUpstreamRef?: string, + * exercisePrimaryEligible?: (ctx: {manifest:any,name:string,hash:string}) => Promise<{ok:boolean,detail?:string}>, + * exerciseStatusline?: (ctx: {manifest:any,name:string,hash:string}) => Promise<{ok:boolean,detail?:string}>, + * clock?: () => string, + * }} [options] + * @returns {Promise<{ name: string, hash: string|null, + * tiers: Array<{ tier: string, status: 'passed'|'failed'|'gated'|'skipped', + * checks: Array<{name:string, ok:boolean, detail?:string}>, + * detail?: string, gatedBy?: string, evidence?: string, recordError?: string }> }>} + */ +export async function runTieredConformance({ + fixtureRoot, + manifestSource = fixtureRoot ? path.join(fixtureRoot, 'manifest.json') : undefined, + name, + tiers = CONFORMANCE_TIERS, + readManifest = defaultReadManifest, + consentFile, + grantsFile = adapterGrantsPath(), + persist = true, + haveFn = have, + baseDir, + sessionDrivingUpstreamRef, + exercisePrimaryEligible, + exerciseStatusline, + clock = nowIso, +} = {}) { + if (typeof manifestSource !== 'string' || !manifestSource) { + throw new TypeError('runTieredConformance requires fixtureRoot or manifestSource'); + } + + let tempDir = null; + let consentFileUsed = consentFile; + if (!consentFileUsed) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-conformance-tiers-')); + consentFileUsed = path.join(tempDir, 'adapter-consent.json'); + } + + try { + // F1 (Wave C): baseDir is computed BEFORE checkAdmission so it can thread + // straight into registerAdmittedLifecycle for the detect-hook check — + // the same anchoring the execution tier already needed, just derived + // earlier now that admission needs it too. + const derivedBaseDir = baseDir !== undefined ? baseDir : baseDirForSource(manifestSource); + const admission = await checkAdmission({ + name, source: manifestSource, readManifest, consentFile: consentFileUsed, baseDir: derivedBaseDir, + }); + const resolvedName = name ?? admission.manifest?.host?.id ?? '(unknown)'; + const { hash } = admission; + // F2 (Wave C, BLOCKER): every post-admission tier gated on manifest + // validity alone (`admission.manifest != null`) would still exercise a + // manifest that schema-validated but whose REAL admission (admitAdapters + // — the consent/contract/builtin-shadow gate) was refused: e.g. stale or + // missing consent. That let a refused adapter's execution.run hook be + // spawned for real and its result recorded 'passed' into grantsFile + // (which defaults to the operator's REAL adapter-grants.json). Gating on + // the WHOLE admission tier passing — not just the manifest parsing — + // closes that: a manifest whose admission failed is treated identically + // to one that never validated at all, for every downstream tier. + const admissionPassed = admission.checks.length > 0 && admission.checks.every((c) => c.ok); + const effectiveManifest = admissionPassed ? admission.manifest : null; + const wantTier = (tier) => tiers.includes(tier); + + const tierResults = []; + + if (wantTier('admission')) { + tierResults.push({ + tier: 'admission', + status: admissionPassed ? 'passed' : 'failed', + checks: admission.checks, + }); + } + + if (wantTier('session-driving')) { + tierResults.push({ tier: 'session-driving', ...checkSessionDriving({ manifest: effectiveManifest, upstreamRef: sessionDrivingUpstreamRef }) }); + } + + if (wantTier('activity-routing')) { + const result = await checkActivityRouting({ + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock, + }); + tierResults.push({ tier: 'activity-routing', ...result }); + } + + if (wantTier('primary-eligible')) { + const result = await checkGrantGatedTier({ + capability: 'canBePrimary', + manifest: effectiveManifest, + name: resolvedName, + hash, + grantsFile, + exercise: exercisePrimaryEligible, + exerciseLabel: 'leads a run and receives an escalation (ADR-0019)', + }); + tierResults.push({ tier: 'primary-eligible', ...result }); + } + + if (wantTier('statusline')) { + const result = await checkGrantGatedTier({ + capability: 'commandStatusline', + manifest: effectiveManifest, + name: resolvedName, + hash, + grantsFile, + exercise: exerciseStatusline, + exerciseLabel: 'renders and refreshes a command-backed footer', + }); + tierResults.push({ tier: 'statusline', ...result }); + } + + if (persist && hash) { + for (const tierResult of tierResults) { + try { + if (tierResult.status === 'passed') { + const evidence = tierResult.evidence ?? evidenceFromChecks(tierResult.checks) ?? tierResult.tier; + recordTierResult(resolvedName, tierResult.tier, { hash, evidence: evidence || tierResult.tier }, { file: grantsFile }); + } else if (tierResult.status === 'gated' && tierResult.gatedBy) { + recordTierGate(resolvedName, tierResult.tier, { hash, gatedBy: tierResult.gatedBy }, { file: grantsFile }); + } + } catch (error) { + tierResult.recordError = error?.message ?? String(error); + } + } + } + + return { name: resolvedName, hash, tiers: tierResults }; + } finally { + // F7 (Wave C security review): all THREE overlays a conformance run can + // touch — the host overlay, the execution overlay, and the lifecycle + // registration checkAdmission's detect-hook check creates via + // registerAdmittedLifecycle — must reset together. Resetting only the + // first two (as before) left an already-registered lifecycle adapter + // live for a host id this run's own admission may have just refused, + // the same live-edge class F-9 (execution/admitted.mjs's + // resetAllAdmitted) already closed for the other two overlays. + resetAdmitted(); + resetAdmittedExecution(); + resetAdmittedLifecycle(); + if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } + } +} diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs index 59b6188..ee0d6e0 100644 --- a/src/lib/adapters/lifecycle-registry.mjs +++ b/src/lib/adapters/lifecycle-registry.mjs @@ -12,12 +12,20 @@ // — so this is a new, one-way edge (adapters/* -> opencode.mjs) that never // cycles back (opencode.mjs has no reason to import this module: callers // reach it through lifecycleAdapterFor/hostsWithLifecycle instead). +import path from 'node:path'; import { validateLifecycleAdapter, LIFECYCLE_OPERATIONS, lifecycleResult } from './lifecycle.mjs'; import { HOST_REGISTRY } from './registries.mjs'; import { effectiveHostRegistry } from './admitted.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER } from '../opencode.mjs'; const LIFECYCLE_ADAPTERS = new Map(); +// F7 (Wave C security review — prioritized for P4's paired-overlay-reset +// integration, mirroring execution/admitted.mjs's own resetAllAdmitted +// pairing): tracks which LIFECYCLE_ADAPTERS keys came from +// registerAdmittedLifecycle (never a built-in) so resetAdmittedLifecycle() +// below can clear exactly those without disturbing 'opencode' (or any other +// built-in registered via registerBuiltinLifecycle). +const ADMITTED_LIFECYCLE_IDS = new Set(); /** * Register a built-in host's lifecycle adapter. Internal — called by this @@ -68,24 +76,74 @@ export function hostsWithLifecycle() { /** * Host ids with a registered lifecycle adapter, restricted to BUILT-IN hosts * (LIFECYCLE_ADAPTERS keys intersected with HOST_REGISTRY — never an - * admitted external, even after applyAdmitted). setup.mjs/sync.mjs/ - * uninstall.mjs's lifecycle loops destructure an opencode-SHAPED result - * (stack.oc/.plugin/.agents/.skill, ret.undo/.artifacts) — those loop bodies - * are not generic across hosts yet. Today that's unreachable dead code, - * because registerAdmittedLifecycle is never called in production — but it - * is ARMED: the moment something wires an admitted external's lifecycle - * adapter in (registerAdmittedLifecycle exists for exactly that), a - * non-opencode-shaped host would enter hostsWithLifecycle() and crash one of - * those command loops on the first opencode-specific destructure. Command - * loops use this function instead until external lifecycle EXECUTION and a - * shape-agnostic loop body graduate together, in a later wave. - * hostsWithLifecycle() above stays for pure registry queries, unaffected. + * admitted external, even after applyAdmitted). Kept as a pure, built-ins- + * only registry query — e.g. for an install-hint lookup that only makes + * sense against HOSTS' own package metadata. + * + * setup.mjs/sync.mjs/uninstall.mjs no longer use this to pick their loop's + * iteration source (ADR-0031 P3): their lifecycle loops used to destructure + * an opencode-SHAPED result (stack.oc/.plugin/.agents/.skill, + * ret.undo/.artifacts) directly, which would have crashed the moment a + * non-opencode-shaped admitted host entered the loop. That crash risk is + * exactly what lifecycle-render.mjs's shape-dispatching renderer removes — + * the command loops now iterate hostsWithLifecycle() (built-ins + admitted) + * and render through renderApplyReport/renderUndoReport instead of raw + * destructuring, gated per-host by lifecycleExecutionEnabled() below. * @returns {string[]} */ export function builtinHostsWithLifecycle() { return HOST_REGISTRY.filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id); } +/** + * True when hostId is one of HOST_REGISTRY's own entries (never an admitted + * external, even after applyAdmitted). Used by lifecycleExecutionEnabled and + * detectionBinFor to tell a built-in from an admitted host without a command + * reaching into registries.mjs directly. + * @param {string} hostId + * @returns {boolean} + */ +export function isBuiltinHost(hostId) { + return HOST_REGISTRY.some((host) => host.id === hostId); +} + +const EXPERIMENTAL_HOST_ADAPTERS_FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; + +/** + * Whether setup/sync/uninstall should actually exercise hostId's lifecycle + * adapter this run (ADR-0031 P3). A BUILT-IN host is gated only by cfg's own + * enablement — unchanged from before this wave. An ADMITTED external host + * needs BOTH: explicit cfg enablement (opt-in exactly like opencode — there + * is no pick-UI for external hosts yet, so enablement is operator-set in + * kit.json) AND the experimental flag. Neither condition is ever inferred or + * set here — this function only reads, never auto-enables anything. + * @param {string} hostId + * @param {any} cfg + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function lifecycleExecutionEnabled(hostId, cfg, env = process.env) { + if (!cfg?.integrations?.hosts?.[hostId]) return false; + if (isBuiltinHost(hostId)) return true; + return env?.[EXPERIMENTAL_HOST_ADAPTERS_FLAG] === '1'; +} + +/** + * The binary name a command loop should probe for hostId's CLI presence + * before exercising its lifecycle adapter (mirrors the built-in convention + * `have(hostId)` — a built-in's host id IS its binary name). An admitted + * host's binary name is whatever its manifest declared + * (manifest.detection.bin, always present on a validated manifest) — + * buildAdmittedLifecycleAdapter stashes it on the registered adapter as + * `.detectionBin` so this never needs a second store keyed by host id. + * @param {string} hostId + * @returns {string} + */ +export function detectionBinFor(hostId) { + if (isBuiltinHost(hostId)) return hostId; + return lifecycleAdapterFor(hostId)?.detectionBin ?? hostId; +} + // ── admitted (external) lifecycle adapters ───────────────────────────────── // A derived adapter never runs third-party code in-process: each declared // verb is a thin wrapper that shells out through the sibling's hook runner @@ -96,18 +154,93 @@ export function builtinHostsWithLifecycle() { // manifest never declared gets an honest no-op: it never ran, so nothing // changed. +// F4 (Wave C security review — the un-applied Wave B R-1 twin): `result.stdout` +// is the MERGED stdout+stderr (hook-runner.mjs's mergeCapture) — any stderr +// output (a deprecation warning, interpreter noise) breaks JSON.parse even +// when the hook fully succeeded, so a genuinely successful apply/undo would +// misreport as failed. `result.stdoutText` is the UNMERGED stdout only +// (hook-runner.mjs's boundedText(stdoutCaptured), the same field execution's +// own R-1 fix reads) — reading it here is the lifecycle-side twin of that +// fix, never applied to this file until now. function parseHookPayload(result) { - if (!result || typeof result.stdout !== 'string') return null; + if (!result || typeof result.stdoutText !== 'string') return null; try { - const parsed = JSON.parse(result.stdout); + const parsed = JSON.parse(result.stdoutText); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; } catch { return null; } } +// Minimal UTF-8-safe truncation (mirrors execution/handoff.mjs's own +// truncateUtf8 — that copy is canonical; not exported there, so this is a +// local twin) so a failure detail bounded from raw hook stdout can never +// promote an unbounded blob into lifecycle-render.mjs's print path (which +// feeds F3's own control-char stripping, but a huge string is still a +// separate flooding/DoS-adjacent concern worth bounding at the source). +function truncateUtf8(value, maxBytes) { + const bytes = (v) => Buffer.byteLength(v, 'utf8'); + if (bytes(value) <= maxBytes) return value; + if (maxBytes <= 3) return '.'.repeat(Math.max(0, maxBytes)); + let out = ''; + for (const char of value) { + if (bytes(`${out}${char}…`) > maxBytes) break; + out += char; + } + return `${out}…`; +} + function hookFailureResult(verb, result) { - const detail = result?.detail || (result?.stdout || '').trim() || `hook exited ${result?.exitCode ?? 'unknown'}`; + const detail = result?.detail + || truncateUtf8((result?.stdoutText || '').trim(), 240) + || `hook exited ${result?.exitCode ?? 'unknown'}`; + if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail }; + if (verb === 'plan') return { changed: false, operations: [], error: detail }; + return lifecycleResult({ ok: false, changed: false, errors: [detail] }); +} + +// F-1 (mirrors execution/admitted.mjs's own F-1 — that copy is canonical; +// this is a minimal, lifecycle-scoped replica since the check isn't exported +// there): a relative lifecycle hook command resolves against whatever `cwd` +// the child spawns with. With no anchored adapter base directory (a remote +// npm/https source has no persistent local bundle), that would fall back to +// the OPERATOR's process.cwd() — arbitrary-code-execution by planting a +// same-named file, with the consent hash unchanged. A bare interpreter/ +// binary name found through PATH (node, hermes) is unaffected by cwd and +// stays legal; only a path-separator-bearing or script-looking bare token is +// refused. Not shared as an import (yet) — a later refactor can hoist this +// into a common module once a second caller needs it; for now the small +// duplication keeps this file independent of execution/admitted.mjs. +// F9 (Windows parity): exe/bat/cmd/com/ps1 included alongside the script +// extensions — Windows' CreateProcess searches the CURRENT DIRECTORY before +// PATH for a bare relative executable name, so a null baseDir with e.g. +// `hook.bat` is exactly as exploitable there as a relative `.mjs` is on +// POSIX and must be refused the same way. +const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i; + +function looksRelative(token) { + if (typeof token !== 'string' || !token) return false; + if (path.isAbsolute(token)) return false; + if (token.includes('/') || token.includes('\\')) return true; + return SCRIPT_LIKE_RE.test(token); +} + +function commandIsUnanchorable(command) { + const [argv0, ...args] = command; + if (looksRelative(argv0)) return true; + return args.some((arg) => looksRelative(arg)); +} + +/** Honest refusal for a declared verb whose hook command is relative with no + * adapter base directory to anchor it — the hook subprocess is NEVER + * spawned for this verb (see commandIsUnanchorable above), so this can only + * ever be a refusal, never a fabricated success. Shaped exactly like + * hookFailureResult so callers (lifecycle-render.mjs's generic summary, + * runLifecycle) treat it identically to any other honest per-verb failure. */ +function unanchoredResult(verb, hostId, command) { + const detail = `'${hostId}' declares a relative lifecycle.${verb}.hook.command ` + + `(${JSON.stringify(command)}) with no anchored adapter base directory (a remote npm/https ` + + 'source has no persistent local bundle) — use an absolute path or a PATH binary'; if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail }; if (verb === 'plan') return { changed: false, operations: [], error: detail }; return lifecycleResult({ ok: false, changed: false, errors: [detail] }); @@ -121,24 +254,48 @@ function hookFailureResult(verb, result) { * defaults to a dynamic import of ./hook-runner.mjs (the sibling module), * fetched lazily so this factory (and this whole file) loads cleanly even * before that module exists, and so tests never pay for the import unless - * they omit the injection on purpose. + * they omit the injection on purpose. `baseDir` (F-1) is the adapter's own + * directory — derived by the caller (admission.mjs) from the manifest's + * `source` at registration time, `null` for a source with no persistent + * local bundle (npm/https) — never process.cwd(). A verb whose hook.command + * is relative and has no baseDir to anchor it is NEVER wired to spawn — + * `adapter.unanchoredVerbs` names every verb refused this way, so a caller + * (registerAdmittedLifecycle's bootstrap caller) can surface it as a warning + * without needing to re-derive the check itself. * @param {any} manifest — validateAdapterManifest's return shape - * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}> }} [opts] + * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}>, + * baseDir?: string|null }} [opts] */ -export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) { +export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = null } = {}) { const hostId = manifest.host.id; const declared = manifest.lifecycle ?? {}; - const adapter = { id: hostId }; + // Stashed alongside the verb functions (validateLifecycleAdapter only + // requires id + the five verbs — extra own-properties are untouched) so + // detectionBinFor(hostId) can find the manifest's own CLI binary name + // without a second store keyed by host id. + const adapter = { id: hostId, detectionBin: manifest.detection?.bin ?? hostId, unanchoredVerbs: [] }; for (const verb of LIFECYCLE_OPERATIONS) { const hookEntry = declared[verb]?.hook; if (!hookEntry) { adapter[verb] = async () => lifecycleResult({ ok: true, changed: false, facts: null }); continue; } + if (baseDir == null && commandIsUnanchorable(hookEntry.command)) { + adapter.unanchoredVerbs.push(verb); + adapter[verb] = async () => unanchoredResult(verb, hostId, hookEntry.command); + continue; + } adapter[verb] = async (context = {}) => { const run = runHook ?? (await import('./hook-runner.mjs')).runAdapterHook; const result = await run({ hook: hookEntry, hostId, verb, timeoutMs: hookEntry.timeoutMs, env: context.env, + // F-1: anchor a relative command to the adapter's own directory when + // one was declared; with no baseDir, the check above already proved + // this command has no relative component a cwd could redirect (bare + // PATH binaries only), so omitting cwd (Node's own default — inherit + // ak's process.cwd()) is safe and never reopens the arbitrary-code- + // execution vector this exists to close. + ...(baseDir == null ? {} : { cwd: baseDir }), }); if (!result?.ok) return hookFailureResult(verb, result); const payload = parseHookPayload(result); @@ -156,24 +313,43 @@ export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) { * an admitted-but-not-yet-overlaid host id would otherwise never pass the * built-ins-only check. * - * Not called anywhere in production yet: registering an external host here - * makes it appear in hostsWithLifecycle() (registry-query, all hosts), but - * setup.mjs/sync.mjs/uninstall.mjs deliberately read builtinHostsWithLifecycle() - * instead, which stays built-ins-only. External lifecycle EXECUTION and a - * shape-agnostic command-loop body (today's loops assume the opencode result - * shape) graduate together, in a later wave — wiring a real caller for this - * function ahead of that loop-body rewrite would crash setup/sync/uninstall - * the moment a non-opencode-shaped host is admitted. + * Called from admission.mjs's bootstrapHostAdapters (ADR-0031 P3), as a + * sibling to the execution-registration block: any admitted manifest + * declaring a lifecycle block gets its adapter registered here so it appears + * in hostsWithLifecycle(). Registration alone never runs a hook — a + * registered admitted host is only actually EXERCISED by setup/sync/ + * uninstall's loops once lifecycleExecutionEnabled() also passes (the + * experimental flag AND explicit cfg enablement) for that run. `baseDir` + * (F-1) is threaded straight through to buildAdmittedLifecycleAdapter — the + * caller derives it from the manifest's own source the same way the + * execution-registration block does (baseDirForSource). * @param {any} manifest - * @param {{ runHook?: (args: any) => Promise }} [opts] + * @param {{ runHook?: (args: any) => Promise, baseDir?: string|null }} [opts] */ -export function registerAdmittedLifecycle(manifest, { runHook } = {}) { +export function registerAdmittedLifecycle(manifest, { runHook, baseDir = null } = {}) { const hostId = manifest.host.id; if (!effectiveHostRegistry().some((host) => host.id === hostId)) { throw new TypeError(`lifecycle registry: unknown host id '${hostId}' — not present in effectiveHostRegistry`); } - const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir }); validateLifecycleAdapter(adapter); LIFECYCLE_ADAPTERS.set(hostId, adapter); + ADMITTED_LIFECYCLE_IDS.add(hostId); return adapter; } + +/** + * Clear every ADMITTED lifecycle registration (never a built-in — 'opencode' + * and any other registerBuiltinLifecycle entry survive untouched). Pairs + * with adapters/admitted.mjs's resetAdmitted() and execution/admitted.mjs's + * resetAdmittedExecution() the same way execution/admitted.mjs's own + * resetAllAdmitted() pairs those two: a test (or a future paired-reset + * helper) that resets the host overlay without also resetting this one would + * otherwise leave a stale, unreachable-but-still-registered lifecycle + * adapter behind for a host id that effectiveHostRegistry() no longer knows + * about. + */ +export function resetAdmittedLifecycle() { + for (const hostId of ADMITTED_LIFECYCLE_IDS) LIFECYCLE_ADAPTERS.delete(hostId); + ADMITTED_LIFECYCLE_IDS.clear(); +} diff --git a/src/lib/adapters/lifecycle-render.mjs b/src/lib/adapters/lifecycle-render.mjs new file mode 100644 index 0000000..e976f20 --- /dev/null +++ b/src/lib/adapters/lifecycle-render.mjs @@ -0,0 +1,200 @@ +// Shape-agnostic lifecycle report renderer (ADR-0031 P3). setup.mjs, sync.mjs +// and uninstall.mjs used to destructure a runLifecycle() result directly +// (stack.oc/.plugin/.agents/.skill for apply, ret.undo/.artifacts for undo) — +// a shape only OPENCODE_LIFECYCLE_ADAPTER produces. That worked only because +// the command loops iterated builtinHostsWithLifecycle() (opencode-only). +// Now that the loops iterate hostsWithLifecycle() (built-ins + admitted +// externals — see lifecycle-registry.mjs), an admitted host's adapter +// (buildAdmittedLifecycleAdapter) returns the GENERIC lifecycleResult shape +// instead ({ok, changed, facts, actions, ownership, warnings, errors} — see +// lifecycle.mjs) and the raw destructure would throw on `stack.oc`. This +// module is the single place that tells the two shapes apart and turns +// either one into print-ready lines, so no command needs to know which shape +// it got. +// +// opencode's rich shape renders the lines the three commands already printed +// before this wave (same text, same conditions) — pinned by the existing +// setup/sync/uninstall test suites — plus a level fix (Wave C security +// review F5): plugin/agents/skill now carry their OWN ok/status into the +// line's level instead of a hard-coded 'ok', so a failed sub-surface renders +// as failed rather than a fabricated green checkmark. A generic admitted +// host renders one honest summary line instead: ok/changed plus a short +// detail pulled from actions/errors/warnings, never a per-surface breakdown +// the manifest never promised. + +/** True when `lifecycle` is opencode's apply() shape: `{changed, result: + * {oc, plugin, agents, skill, markersChanged}}`. Any other shape (including + * a bare generic lifecycleResult, which has no `.result` at all) is generic. */ +function isOpencodeApplyShape(lifecycle) { + return !!(lifecycle && lifecycle.result && lifecycle.result.oc); +} + +/** True when `lifecycle` is opencode's undo() shape: `{changed, result: + * {undo, artifacts, ok}}`. */ +function isOpencodeUndoShape(lifecycle) { + return !!(lifecycle && lifecycle.result && lifecycle.result.undo); +} + +// F3 (Wave C security review, BLOCKER — ANSI/control-char smuggling): a +// hostile hook's stdout (a manifest's own detect/apply/undo hook, or a +// user-editable error string that eventually lands in one of these lines) +// can carry raw ANSI control sequences — e.g. ESC[2K (erase line) + ESC[1A +// (cursor up) followed by a forged "✓ opencode: … in sync" — which a +// terminal would happily execute, erasing the real (failing) line and +// forging a fake green one. Every line this module builds funnels through +// line() below, so stripping there is the one choke point that closes it +// for every caller, opencode-shaped or generic alike. Reused shape (not +// imported — command/lib boundary): src/commands/x/host-adapters.mjs's own +// ~8-line stripControl is the canonical copy; this is the lib-side twin. +function stripControl(value) { + const input = String(value ?? ''); + let out = ''; + for (const ch of input) { + const code = ch.codePointAt(0); + // Tab/LF/CR become a space rather than vanishing — this is also what + // clamps every line to a SINGLE line (no embedded newline can smuggle a + // second, attacker-controlled "line" into the terminal). + if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; } + // C0 (0x00-0x1f, includes ESC 0x1b) and C1 (0x7f-0x9f, includes DEL + // 0x7f) are dropped outright — an ESC-led CSI sequence loses its ESC + // byte and the rest (e.g. "[2K") survives only as inert, visible text. + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + out += ch; + } + return out; +} + +/** Typed constructor for one report line — keeps `level` a literal union + * instead of widening to `string` the moment it comes from a ternary, AND + * is the single choke point every line's text passes through (F3, above). + * @param {'ok'|'warn'|'info'|'fail'} level + * @param {string} text + * @returns {{level:'ok'|'warn'|'info'|'fail', text:string}} */ +function line(level, text) { + return { level, text: stripControl(text) }; +} + +// F5 (Wave C security review, BLOCKER — built-in sync regression): mirrors +// output.mjs's reportOutcome exactly (result.status ?? (ok?'ok':'failed'), +// then ok/degraded/skipped/anything-else -> ok/warn/info/fail) so a +// FAILED opencode sub-surface (plugin/agents/skill/oc itself) renders at its +// own real level instead of a level this renderer fabricates. Pre-wave sync +// read every opencode sub-result through reportOutcome directly; this is +// that same mapping, now shared by setup AND sync from the one renderer. +function levelForResult(result) { + const status = result?.status ?? (result?.ok ? 'ok' : 'failed'); + if (status === 'ok') return 'ok'; + if (status === 'degraded') return 'warn'; + if (status === 'skipped') return 'info'; + return 'fail'; +} + +/** A short, honest one-line detail for a generic lifecycleResult: the first + * error, else the first warning, else an action count, else "no changes" — + * never fabricated, never a per-surface guess. */ +function summarizeGeneric(result) { + if (Array.isArray(result?.errors) && result.errors.length) return result.errors[0]; + if (Array.isArray(result?.warnings) && result.warnings.length) return result.warnings[0]; + if (Array.isArray(result?.actions) && result.actions.length) return `${result.actions.length} action(s)`; + return 'no changes'; +} + +/** @returns {{shape:'opencode', fatal:boolean, ok:boolean, changed:boolean, + * ocChanged:boolean, markersChanged:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderOpencodeApply(hostId, lifecycle) { + const stack = lifecycle.result; + const lines = [line(levelForResult(stack.oc), `${hostId}: ${stack.oc.detail}`)]; + if (stack.oc.fatal) { + lines.push(line('warn', `${hostId} plugin/agents/skill/guidance skipped — ${stack.oc.detail}`)); + return { + shape: 'opencode', fatal: true, ok: !!stack.oc.ok, changed: !!lifecycle.changed, + ocChanged: !!stack.oc.changed, markersChanged: !!stack.markersChanged, lines, + }; + } + lines.push(line(levelForResult(stack.plugin), `${hostId} plugin: ${stack.plugin.detail}`)); + lines.push(line(levelForResult(stack.agents), `${hostId} agents: ${stack.agents.detail}`)); + // F5: restored the `|| !skill.ok` half of the gate — a failed skill write + // (ok:false, changed:false, e.g. opencode.mjs's adoptionBlocked path) must + // still be reported, not silently dropped because nothing "changed". + if (stack.skill.changed || !stack.skill.ok) { + lines.push(line(levelForResult(stack.skill), `${hostId} skill: ${stack.skill.detail}`)); + } + return { + shape: 'opencode', fatal: false, ok: !!stack.oc.ok, changed: !!lifecycle.changed, + ocChanged: !!stack.oc.changed, markersChanged: !!stack.markersChanged, lines, + }; +} + +/** @returns {{shape:'generic', fatal:boolean, ok:boolean, changed:boolean, + * ocChanged:boolean, markersChanged:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderGenericApply(hostId, result) { + const ok = !!result?.ok; + const changed = !!result?.changed; + const verdict = ok ? (changed ? 'applied' : 'in sync') : 'apply failed'; + const lines = [line(ok ? 'ok' : 'warn', `${hostId}: ${verdict} — ${summarizeGeneric(result)}`)]; + return { + shape: 'generic', fatal: false, ok, changed, ocChanged: false, markersChanged: false, lines, + }; +} + +/** + * Turn a runLifecycle({action:'apply', ...}) result into print-ready lines. + * Dispatches on shape (see module doc) — the caller never inspects the + * result's own fields, only this report's normalized ones. + * @param {string} hostId + * @param {any} lifecycle — runLifecycle's return value + * @returns {{shape:'opencode'|'generic', fatal:boolean, ok:boolean, + * changed:boolean, ocChanged:boolean, markersChanged:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} + */ +export function renderApplyReport(hostId, lifecycle) { + return isOpencodeApplyShape(lifecycle) + ? renderOpencodeApply(hostId, lifecycle) + : renderGenericApply(hostId, lifecycle); +} + +/** @returns {{shape:'opencode', ok:boolean, changed:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderOpencodeUndo(hostId, lifecycle) { + const ret = lifecycle.result; + const ok = !!ret.ok; + const changed = !!(ret.undo.changed || ret.artifacts.changed); + const lines = (changed || !ok) ? [line( + ok ? 'ok' : 'warn', + ok + ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)` + : `${hostId} teardown incomplete — ${ret.undo.detail}`, + )] : []; + return { shape: 'opencode', ok, changed, lines }; +} + +/** @returns {{shape:'generic', ok:boolean, changed:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderGenericUndo(hostId, result) { + const ok = !!result?.ok; + const changed = !!result?.changed; + const lines = (changed || !ok) ? [line( + ok ? 'ok' : 'warn', + ok + ? `${hostId}: undo complete — ${summarizeGeneric(result)}` + : `${hostId} teardown incomplete — ${summarizeGeneric(result)}`, + )] : []; + return { shape: 'generic', ok, changed, lines }; +} + +/** + * Turn a runLifecycle({action:'undo', ...}) result into print-ready lines. + * Same dispatch as renderApplyReport. `ok` on the return is the caller's + * ownership-teardown signal (uninstall.mjs ANDs it across every host). + * @param {string} hostId + * @param {any} lifecycle — runLifecycle's return value + * @returns {{shape:'opencode'|'generic', ok:boolean, changed:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} + */ +export function renderUndoReport(hostId, lifecycle) { + return isOpencodeUndoShape(lifecycle) + ? renderOpencodeUndo(hostId, lifecycle) + : renderGenericUndo(hostId, lifecycle); +} diff --git a/src/lib/execution/admitted.mjs b/src/lib/execution/admitted.mjs index 2a3ba8c..0854f4a 100644 --- a/src/lib/execution/admitted.mjs +++ b/src/lib/execution/admitted.mjs @@ -51,7 +51,11 @@ function execError(reason, message) { // A bare interpreter/binary name found through PATH (node, hermes) is // unaffected by cwd and stays legal; only a path-separator-bearing or // script-looking bare token is refused. -const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl)$/i; +// Windows executable/script extensions (exe|bat|cmd|com|ps1) are included +// because Windows CreateProcess searches the current directory, so a bare +// `hook.bat` with no anchoring base directory would resolve from the +// operator's cwd — the same planted-file vector as a relative script on POSIX. +const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i; function looksRelative(token) { if (typeof token !== 'string' || !token) return false; diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs index f318ee7..089c603 100644 --- a/tests/kit/adapter-admission.test.mjs +++ b/tests/kit/adapter-admission.test.mjs @@ -7,6 +7,9 @@ // undeclared one. import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { admitAdapters, bootstrapHostAdapters, hashManifest, canonicalizeManifest, SUPPORTED_CONTRACT, } from '../../src/lib/adapters/admission.mjs'; @@ -285,7 +288,11 @@ test('buildAdmittedLifecycleAdapter satisfies validateLifecycleAdapter and route const calls = []; const runHook = async (args) => { calls.push(args); - return { ok: true, stdout: JSON.stringify({ observed: { version: '1.0.0' } }), exitCode: 0 }; + // F4: parseHookPayload reads stdoutText (the UNMERGED stdout hook-runner + // reports), not stdout (stdout+stderr merged) — a real runAdapterHook + // call always populates both; this mock does too, to match that contract. + const stdout = JSON.stringify({ observed: { version: '1.0.0' } }); + return { ok: true, stdout, stdoutText: stdout, exitCode: 0 }; }; const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); assert.doesNotThrow(() => validateLifecycleAdapter(adapter)); @@ -313,13 +320,47 @@ test('buildAdmittedLifecycleAdapter reports a hook failure honestly instead of f const manifest = validateAdapterManifest(validManifest({ lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } }, })); - const runHook = async () => ({ ok: false, stdout: 'boom', exitCode: 1 }); + // No `.detail` (a real failed runAdapterHook call always sets one — see + // hook-runner.mjs — so this specifically exercises hookFailureResult's + // OWN fallback: F4's fix reads stdoutText for that fallback, not stdout). + const runHook = async () => ({ ok: false, stdout: 'boom', stdoutText: 'boom', exitCode: 1 }); const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); const result = await adapter.verify({}); assert.equal(result.observed, null); assert.equal(result.error, 'boom'); }); +test('F4: hookFailureResult\'s detail fallback reads stdoutText (unmerged), never the merged stdout+stderr blob', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } }, + })); + // stdout is the MERGED blob (what a real hook-runner would produce when + // stderr chatter follows); stdoutText is the real, unmerged signal. + const runHook = async () => ({ + ok: false, stdout: 'clean-stdout\n--- stderr ---\nnoisy stderr chatter', stdoutText: 'clean-stdout', exitCode: 1, + }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const result = await adapter.verify({}); + assert.equal(result.error, 'clean-stdout', 'the fallback must read stdoutText, not the merged stdout blob'); +}); + +test('F4: a successful hook exit with valid JSON on stdout and unrelated stderr chatter still reports ok:true (Wave B R-1 twin)', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { apply: { hook: { command: ['hermes', 'apply'] } } }, + })); + const payload = JSON.stringify({ ok: true, changed: true, actions: ['wired'], ownership: [], warnings: [], errors: [] }); + // A stray stderr warning would break JSON.parse(stdout) (the merged blob) + // pre-fix — the hook still exited 0 with a fully valid JSON payload on its + // OWN stdout, so this must report success. + const runHook = async () => ({ + ok: true, stdout: `${payload}\n--- stderr ---\nsome deprecation warning`, stdoutText: payload, exitCode: 0, + }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const result = await adapter.apply({}); + assert.equal(result.ok, true, 'stderr chatter alongside valid stdout JSON must not fail the apply'); + assert.equal(result.changed, true); +}); + test('registerAdmittedLifecycle checks effectiveHostRegistry, not HOST_REGISTRY, and is retrievable via lifecycleAdapterFor', () => { applyAdmitted([{ entry: validHost({ id: 'hermes' }) }]); const manifest = validateAdapterManifest(validManifest()); @@ -332,3 +373,119 @@ test('registerAdmittedLifecycle throws for a host id absent from effectiveHostRe const manifest = validateAdapterManifest(validManifest({ name: 'ghost', host: validHost({ id: 'ghost' }) })); assert.throws(() => registerAdmittedLifecycle(manifest), /ghost/); }); + +// ── P3 (ADR-0031): bootstrapHostAdapters registers an admitted lifecycle ──── +// The sibling block to execution registration (§222-261 above): an admitted +// manifest declaring a lifecycle block gets its derived adapter registered +// during bootstrap, guarded and non-fatal, the same posture as execution. +// Every test here uses its own host id — LIFECYCLE_ADAPTERS is a +// process-shared Map with no unregister, so reusing 'hermes' would collide +// with the registerAdmittedLifecycle tests above. + +test('bootstrapHostAdapters registers the lifecycle adapter for an admitted manifest that declares one', async () => { + const name = 'hermes-boot-lifecycle'; + const manifest = validateAdapterManifest(validManifest({ + name, host: validHost({ id: name }), + lifecycle: { apply: { hook: { command: [name, 'apply'] } } }, + })); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-boot-lifecycle' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`); + assert.notEqual(lifecycleAdapterFor(name), null, 'the lifecycle adapter must be registered by bootstrap'); +}); + +test('bootstrapHostAdapters never registers a lifecycle adapter for an admitted manifest with no lifecycle block', async () => { + const name = 'hermes-boot-no-lifecycle'; + const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-boot-no-lifecycle' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.equal(lifecycleAdapterFor(name), null, 'no lifecycle block declared — nothing to register'); +}); + +// ── F-1 (ADR-0031 P3, critical fix): bootstrap-level baseDir derivation ──── +// Mirrors adapter-execution.test.mjs's own F-1 (bootstrap) tests exactly: +// a file-sourced manifest's relative lifecycle hook resolves against the +// manifest's own directory (never the operator's cwd — this is a REAL +// subprocess spawn, not an injected runHook); a remote (npm/https) source +// has no persistent local bundle to anchor to, so its relative hook is +// refused with a surfaced 'lifecycle-unanchored' warning instead. + +test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dirname(source)) and a real relative lifecycle hook runs anchored to it', async () => { + const name = 'hermes-f1-file'; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-lifecycle-f1-basedir-')); + try { + // A REAL script, on disk, referenced by a RELATIVE path — proves the + // hook actually resolves against the manifest's own directory rather + // than wherever this test process happens to be running from. + fs.writeFileSync(path.join(tmpDir, 'apply-hook.mjs'), + "process.stdout.write(JSON.stringify({ok:true,changed:true,actions:['wired'],ownership:[],warnings:[],errors:[]}));\n"); + // process.execPath (absolute), not the bare token 'node' — runAdapterHook + // spawns with shell:false, and a bare 'node' does not resolve on Windows + // (no PATHEXT/shell resolution there), so the subprocess would never + // start. process.execPath is the running node's own absolute path, + // always spawnable on every OS; the RELATIVE 'apply-hook.mjs' argument + // is what this test is actually anchoring, unaffected by the change. + const manifest = validateAdapterManifest(validManifest({ + name, host: validHost({ id: name }), + lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } }, + })); + const manifestPath = path.join(tmpDir, 'manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: manifestPath }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`); + const adapter = lifecycleAdapterFor(name); + assert.notEqual(adapter, null); + assert.deepEqual(adapter.unanchoredVerbs, []); + const applied = await adapter.apply({}); + assert.equal(applied.ok, true, 'the real relative script must have actually run, anchored to the manifest\'s own directory'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('F-1 (bootstrap): an npm-sourced admitted manifest with a relative lifecycle hook surfaces a lifecycle-unanchored warning, and the verb is refused (never spawned)', async () => { + const name = 'hermes-f1-npm'; + // process.execPath, not the bare token 'node' — this verb is refused + // before ever spawning either way (npm source -> null baseDir -> unanchored), + // but kept consistent with the file-sourced test above rather than leaving + // a bare token that would misbehave the moment this test's shape changes. + const manifest = validateAdapterManifest(validManifest({ + name, host: validHost({ id: name }), + lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } }, + })); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'npm:hermes-f1-npm-adapter@1.0.0' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1, 'the host itself still admits — only the unanchored verb is refused'); + const warning = result.warnings.find((w) => w.reason === 'lifecycle-unanchored'); + assert.ok(warning, `expected a 'lifecycle-unanchored' warning; got ${JSON.stringify(result.warnings)}`); + const adapter = lifecycleAdapterFor(name); + assert.notEqual(adapter, null, 'the adapter still registers — an unanchorable verb refuses itself, not the whole adapter'); + assert.deepEqual(adapter.unanchoredVerbs, ['apply']); + const applied = await adapter.apply({}); + assert.equal(applied.ok, false, 'the hook must NEVER have been spawned for an unanchored verb'); + assert.match(applied.errors[0], /no anchored adapter base directory/); +}); diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs new file mode 100644 index 0000000..adc69ae --- /dev/null +++ b/tests/kit/conformance-tiers.test.mjs @@ -0,0 +1,406 @@ +// Tiered conformance harness (ADR-0031 §2, §5) — src/lib/adapters/conformance.mjs. +// Reuses the acme fixture adapter-conformance.test.mjs established: real +// admission, real on-disk consent, a real spawned subprocess for both the +// lifecycle detect hook and the execution.run hook. This file proves the +// GENERALIZED tiered shape on top of that same fixture: which tiers +// genuinely pass, and which are honestly 'gated'/'skipped' rather than +// faked, per ADR-0031's "do not fake a pass" discipline. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runTieredConformance, CONFORMANCE_TIERS } from '../../src/lib/adapters/conformance.mjs'; +import { grantsFor, recordTierResult, grantCapability } from '../../src/lib/adapters/grants.mjs'; +import { lifecycleAdapterFor } from '../../src/lib/adapters/lifecycle-registry.mjs'; + +const FIXTURE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../fixtures/adapters/acme'); +const VALID_MANIFEST_PATH = path.join(FIXTURE_ROOT, 'manifest.json'); + +function tempGrantsFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-tiers-grants-')); + return path.join(dir, 'adapter-grants.json'); +} + +/** + * Reads the fixture manifest EXACTLY as authored — no command rewriting. + * checkAdmission now threads a real baseDir into registerAdmittedLifecycle + * (Wave C, F1), so the fixture's own literal, unrewritten + * ["node","detect-hook.mjs"] resolves correctly through the real resolver: + * 'node' via PATH (matches how the execution.run hook already worked + * unrewritten — see the sibling comment this file used to carry), and the + * relative 'detect-hook.mjs' anchored to the manifest's own directory via + * the derived baseDir. If this rewrite were still needed, that would mean F1 + * regressed — this reader is deliberately a no-op beyond parsing, so the + * admission tier proves the real, shipped resolution path. + */ +async function readManifestFromFile(source) { + const text = fs.readFileSync(source, 'utf8'); + return JSON.parse(text); +} + +function rawAcmeManifest(overrides = {}) { + const text = fs.readFileSync(VALID_MANIFEST_PATH, 'utf8'); + const base = JSON.parse(text); + return { + ...base, + ...overrides, + host: { ...base.host, ...(overrides.host ?? {}), capabilities: { ...base.host.capabilities, ...(overrides.host?.capabilities ?? {}) } }, + }; +} + +// ── exports sanity ─────────────────────────────────────────────────────── + +test('re-exports the five ADR-0031 §2 conformance tiers in graduation order', () => { + assert.deepEqual(CONFORMANCE_TIERS, [ + 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + ]); +}); + +// ── admission tier: genuinely passes ──────────────────────────────────── + +test('admission tier passes against the acme fixture: real admission, real registry join, real detect-hook subprocess', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['admission'], + grantsFile, + }); + + assert.equal(report.name, 'acme'); + assert.ok(typeof report.hash === 'string' && report.hash.length > 0); + assert.equal(report.tiers.length, 1); + const [admissionTier] = report.tiers; + assert.equal(admissionTier.tier, 'admission'); + assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2)); + assert.ok(admissionTier.checks.length >= 3, 'expects manifest-validates, admits, registry-join, detect-hook checks'); + assert.ok(admissionTier.checks.every((c) => c.ok)); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.hash, report.hash); + assert.equal(record.tiers.admission.status, 'passed'); +}); + +test('admission tier fails honestly on an invalid manifest, with no grant-store write', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-primary-claim', + readManifest: async () => rawAcmeManifest({ host: { capabilities: { canBePrimary: true } } }), + tiers: ['admission'], + grantsFile, + }); + const [admissionTier] = report.tiers; + assert.equal(admissionTier.status, 'failed'); + assert.ok(admissionTier.checks.some((c) => !c.ok)); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── F2 (Wave C security review, BLOCKER): a manifest that schema-VALIDATES +// but whose real admission (admitAdapters — consent/contract/name-mismatch) +// FAILS must not let any downstream tier still spawn a real hook or record +// 'passed'. Forcing a genuine admitOne 'name-mismatch' refusal (an explicit +// cfg `name` that disagrees with the manifest's own host.id) proves the +// distinction from the previous test: the manifest itself is schema-valid +// (check 1 in checkAdmission passes), only the SECOND check — the real +// admission gate — fails. Every downstream tier must see this exactly as if +// admission had never validated at all. + +test('F2: a manifest that schema-validates but fails real admission (name-mismatch) short-circuits every downstream tier to skipped, with no hook spawned and nothing persisted', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + name: 'not-acme', // disagrees with the fixture manifest's own host.id ('acme') -> admitOne refuses 'name-mismatch' + tiers: ['admission', 'activity-routing', 'session-driving', 'primary-eligible', 'statusline'], + grantsFile, + haveFn: async () => true, + }); + + const admissionTier = report.tiers.find((t) => t.tier === 'admission'); + assert.equal(admissionTier.status, 'failed', JSON.stringify(admissionTier.checks, null, 2)); + assert.match(admissionTier.checks.find((c) => !c.ok)?.detail ?? '', /does not match manifest host id/); + + for (const tierName of ['activity-routing', 'session-driving', 'primary-eligible', 'statusline']) { + const tier = report.tiers.find((t) => t.tier === tierName); + assert.equal(tier.status, 'skipped', `${tierName}: ${JSON.stringify(tier.checks)}`); + assert.match(tier.checks[0].detail, /admission tier did not pass/); + } + + // Nothing at all persisted — not even the failed admission tier itself. + assert.equal(grantsFor('not-acme', { file: grantsFile }), null); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── activity-routing tier: genuinely passes (real subprocess worker) ─────── + +test('activity-routing tier genuinely passes against acme via a real subprocess worker (ADR-0018)', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['activity-routing'], + grantsFile, + haveFn: async () => true, // acme's detection.bin will never be on a test machine's PATH + }); + + const [tier] = report.tiers; + assert.equal(tier.tier, 'activity-routing'); + assert.equal(tier.status, 'passed', JSON.stringify(tier.checks, null, 2)); + assert.match(tier.evidence, /succeeded/); + assert.match(tier.evidence, /acme/); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['activity-routing'].status, 'passed'); + assert.ok(record.tiers['activity-routing'].evidence.length > 0); +}); + +test('activity-routing tier is honestly skipped when the manifest declares neither canRouteActivities nor an execution.run hook', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-no-execution', + readManifest: async () => { + const raw = rawAcmeManifest({ host: { capabilities: { canRouteActivities: false } } }); + delete raw.execution; + // No baseDir exists for a mem:// source, so the fixture's own relative + // lifecycle.detect.hook.command would be refused as unanchorable (F1) + // and fail admission for a reason unrelated to what this test isolates + // (canRouteActivities/execution.run absence) — drop lifecycle too, so + // the skip below is provably the activity-routing check's own, not a + // side effect of the admission prerequisite failing. + delete raw.lifecycle; + return raw; + }, + tiers: ['admission', 'activity-routing'], + grantsFile, + }); + const admissionTier = report.tiers.find((t) => t.tier === 'admission'); + const tier = report.tiers.find((t) => t.tier === 'activity-routing'); + assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2)); + assert.equal(tier.status, 'skipped'); + assert.match(tier.checks[0].detail, /not declared/); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers.admission.status, 'passed'); // admission legitimately recorded + assert.equal(record.tiers['activity-routing'], undefined); // the skipped tier recorded nothing +}); + +// ── session-driving tier: honest skipped/gated, never faked ──────────────── + +test('session-driving tier is skipped when the manifest does not declare canDriveSession', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['session-driving'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped'); + assert.notEqual(tier.status, 'passed'); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +test('session-driving tier is honestly gated (never passed) when canDriveSession is declared but no external session-driving path exists', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-session-driving', + readManifest: async () => { + const raw = rawAcmeManifest({ host: { capabilities: { canDriveSession: true, canRouteActivities: false } } }); + delete raw.execution; + // No baseDir exists for a mem:// source (nothing to fs.realpathSync), + // so the fixture's own relative lifecycle.detect.hook.command would be + // refused as unanchorable (F1) and fail admission — orthogonal to what + // this test isolates (session-driving semantics), so drop lifecycle too. + delete raw.lifecycle; + return raw; + }, + tiers: ['admission', 'session-driving'], + grantsFile, + }); + const admissionTier = report.tiers.find((t) => t.tier === 'admission'); + const tier = report.tiers.find((t) => t.tier === 'session-driving'); + assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2)); + assert.equal(tier.status, 'gated'); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /ruflo/i); + // No upstream ref supplied -> nothing persisted for THIS tier (there is no + // real tracking issue to pin a recordTierGate() call to yet) — admission + // still legitimately recorded, since it genuinely passed. + assert.equal(tier.gatedBy, undefined); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers.admission.status, 'passed'); + assert.equal(record.tiers['session-driving'], undefined); +}); + +test('session-driving tier persists via recordTierGate when a real upstream ref is supplied', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-session-driving-ref', + readManifest: async () => { + const raw = rawAcmeManifest({ host: { capabilities: { canDriveSession: true, canRouteActivities: false } } }); + delete raw.execution; + // No baseDir exists for a mem:// source (nothing to fs.realpathSync), + // so the fixture's own relative lifecycle.detect.hook.command would be + // refused as unanchorable (F1) and fail admission — orthogonal to what + // this test isolates (session-driving semantics), so drop lifecycle too. + delete raw.lifecycle; + return raw; + }, + tiers: ['session-driving'], + grantsFile, + sessionDrivingUpstreamRef: 'ruvnet/ruflo#9001', + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'gated'); + assert.equal(tier.gatedBy, 'ruvnet/ruflo#9001'); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['session-driving'].status, 'gated'); + assert.equal(record.tiers['session-driving'].gatedBy, 'ruvnet/ruflo#9001'); +}); + +// ── primary-eligible / statusline: honest gated, ungranted or unbuilt ────── + +test('primary-eligible and statusline tiers are gated (never passed) against acme with no grant recorded', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible', 'statusline'], + grantsFile, + }); + for (const tier of report.tiers) { + assert.equal(tier.status, 'gated', `${tier.tier}: ${JSON.stringify(tier.checks)}`); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /ak-local/); + } + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +test('primary-eligible tier stays honestly gated even when GRANTED, because no lead/escalation runtime path is built yet', async () => { + const grantsFile = tempGrantsFile(); + // Seed a real grant the way the (future) promotion command would. + const hash = (await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + })).hash; + recordTierResult('acme', 'primary-eligible', { hash, evidence: 'seeded for test' }, { file: grantsFile }); + grantCapability('acme', 'canBePrimary', { hash }, { file: grantsFile }); + + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'gated'); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /granted/); + assert.match(tier.detail, /not built/); +}); + +test('primary-eligible tier PASSES when granted AND a real exercise path is injected (proves the injection seam, not a built-in fake)', async () => { + const grantsFile = tempGrantsFile(); + const hash = (await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + })).hash; + recordTierResult('acme', 'primary-eligible', { hash, evidence: 'seeded for test' }, { file: grantsFile }); + grantCapability('acme', 'canBePrimary', { hash }, { file: grantsFile }); + + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + exercisePrimaryEligible: async () => ({ ok: true, detail: 'led a run and received a simulated escalation' }), + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'passed'); + assert.equal(tier.evidence, 'led a run and received a simulated escalation'); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers['primary-eligible'].evidence, 'led a run and received a simulated escalation'); +}); + +test('statusline tier is skipped/gated (never passed) when the admission prerequisite tier is not part of this run and the manifest never admitted', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://does-not-admit', + readManifest: async () => { throw new Error('unreadable on purpose'); }, + tiers: ['statusline'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped'); + assert.notEqual(tier.status, 'passed'); +}); + +// ── full sequence + persistence summary ───────────────────────────────── + +test('running all five tiers together against acme yields exactly one passed-non-admission tier (activity-routing) plus admission, with nothing faked', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + grantsFile, + haveFn: async () => true, + }); + assert.equal(report.tiers.length, 5); + const byTier = Object.fromEntries(report.tiers.map((t) => [t.tier, t.status])); + assert.equal(byTier.admission, 'passed'); + assert.equal(byTier['session-driving'], 'skipped'); // acme declares canDriveSession:false + assert.equal(byTier['activity-routing'], 'passed'); + assert.equal(byTier['primary-eligible'], 'gated'); + assert.equal(byTier.statusline, 'gated'); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers.admission.status, 'passed'); + assert.equal(record.tiers['activity-routing'].status, 'passed'); + assert.equal(record.tiers['primary-eligible'], undefined, 'an ak-local gate with no upstream ref persists nothing'); + assert.equal(record.tiers.statusline, undefined); +}); + +// ── persist:false opt-out ─────────────────────────────────────────────── + +test('persist:false runs the full sequence without writing anything to the grant store', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['admission'], + grantsFile, + persist: false, + }); + assert.equal(report.tiers[0].status, 'passed'); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── self-contained + repeatable ───────────────────────────────────────── + +// ── F7 (Wave C security review): the lifecycle overlay must not leak ─────── +// checkAdmission's detect-hook check registers a real lifecycle adapter via +// registerAdmittedLifecycle. Before F7 the finally block only reset the host +// and execution overlays, leaving that registration live in +// LIFECYCLE_ADAPTERS after runTieredConformance returned — an +// already-bootstrapped-looking 'acme' lifecycle adapter surviving a +// conformance run that (in another test) may have refused it. + +test('does not leak an admitted lifecycle registration after returning (F7)', async () => { + const grantsFile = tempGrantsFile(); + assert.equal(lifecycleAdapterFor('acme'), null, 'sanity: nothing registered before this test runs'); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + }); + assert.equal(report.tiers[0].status, 'passed'); + assert.equal(lifecycleAdapterFor('acme'), null, 'the admitted lifecycle registration must not survive the run'); +}); + +test('is safe to call repeatedly and cleans up its own temp consent store when none is supplied', async () => { + for (let i = 0; i < 2; i += 1) { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + }); + assert.equal(report.tiers[0].status, 'passed'); + } +}); diff --git a/tests/kit/external-lifecycle.test.mjs b/tests/kit/external-lifecycle.test.mjs new file mode 100644 index 0000000..d4ab26f --- /dev/null +++ b/tests/kit/external-lifecycle.test.mjs @@ -0,0 +1,438 @@ +// ADR-0031 P3 — external lifecycle execution wired into setup/sync/uninstall. +// A synthetic ADMITTED host ('globex') with real, standalone node-script +// lifecycle hooks (apply/undo — no injected runHook, spawned through the +// REAL hook-runner, same black-box posture as adapter-conformance.test.mjs's +// acme fixture) proves the three command loops now iterate hostsWithLifecycle() +// safely: the hook actually runs and lifecycle-render.mjs's generic one-line +// summary prints, gated by lifecycleExecutionEnabled (cfg enablement AND the +// experimental flag — an admitted host is never exercised without both). +// +// setup.mjs and uninstall.mjs's admitted-host branch is reachable through a +// real command call (setup.run_machine / uninstall.run). sync.mjs's branch +// is additionally gated by `subsystems.has(hostId)`, sourced from +// status.mjs's collect() — which has no admitted-host awareness yet +// (HOST_DETAIL_RENDERERS is hardcoded to opencode; status.mjs is out of this +// wave's scope). That pre-existing gate means an admitted host's row never +// enters sync's plan today, so its lifecycle loop body is honestly +// unreachable through a real `ak sync` — pinned below as documented, current +// behavior (not a P3 regression: the gate and its rationale predate this +// wave, and generalizing status.mjs is a separate follow-up). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + sandboxHome, assertSandboxed, captureLog, rmrf, writeKitConfig, offlineKitConfig, fakeGlobalRoot, +} from './helpers/home-sandbox.mjs'; + +const HOME = sandboxHome('ak-external-lifecycle'); +const paths = await import('../../src/lib/paths.mjs'); +const setup = await import('../../src/commands/setup.mjs'); +const sync = await import('../../src/commands/sync.mjs'); +const uninstall = await import('../../src/commands/uninstall.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); +const { validateAdapterManifest } = await import('../../src/lib/adapters/manifest.mjs'); +const { applyAdmitted, resetAdmitted } = await import('../../src/lib/adapters/admitted.mjs'); +const { registerAdmittedLifecycle, lifecycleAdapterFor } = await import('../../src/lib/adapters/lifecycle-registry.mjs'); +assertSandboxed(paths, HOME); + +const PKG_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..'); +const FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; + +function globexHost(overrides = {}) { + return { + id: 'globex', + label: 'Globex', + install: { bin: 'globex-cli', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: false, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +/** Writes a real, standalone marker-writing hook script (ESM `.mjs`, no + * injected runHook — spawned through the REAL hook-runner) into `dir` under + * `filename`, and returns the RELATIVE `[process.execPath, filename]` + * command anchored to `dir` via baseDir. The marker path lives in FILE + * CONTENT, never a spawn argv element — a `node -e '