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); +});