diff --git a/DECISIONS.md b/DECISIONS.md index 63464b5..9c4bc0c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -182,6 +182,7 @@ Rows are ordered by **acceptance date**, not by D-number. A few early rows (D-03 | D-084 | ADR-to-ledger bidirectional integrity repair. (a) Markdown render: twelve blank lines inside the Accepted table (former lines 138-158 and 186) split it into thirteen fragments, so every row from D-042 onward — the whole M1/M2 arc, all tool-baseline re-baselines, and the ADR 0073-0078 acceptances — rendered as literal pipe text on github.com. Blanks removed; a note above the table records that rows are ordered by acceptance date, not by D-number. (b) Status flips: ADRs **0002** (runtime), **0003** (transport topology) and **0008** (dashboard auth) each read `status: proposed` while this ledger listed them Accepted at D-002, D-009 and D-015 respectively, all dated 2026-04-22. All three flipped to `accepted`; each decision is accepted **as written**, with no change to its recommendation. (c) Backfilled references: ADRs **0009** (ontology versioning), **0013** (forbidden tier split), **0014** (InterventionRecord) and **0039** (charter v1.4.0 invariants 18+19) are `status: accepted` but appear nowhere in this ledger; they are recorded here rather than given four retroactive rows, because inventing dated acceptance rows for decisions whose real acceptance dates are unrecoverable would fabricate provenance. ADR 0039 is the sharpest case — the charter change log credits invariants 18 and 19 to it, and both are live charter text. The `Decision ledger:` lines in ADRs 0013 and 0014 cited unrelated rows (D-025/D-026/D-032) and now point here. | The ledger is the human-readable decision record and the repo is a public portfolio surface; a table that does not render defeats both. The three flips close a contradiction where the ADR and the ledger disagreed about the same decision — a reader following either one alone got a different answer. Phase 1 Thread B, the accept trigger both 0002 and 0003 named, is retired as a decision gate rather than run: for 0002 the installed runtime settles the axis Thread B was scoped to measure (observed 2026-07-25 on this host, charter inv. 14: `node --version` = v24.18.0; `node:sqlite` exports DatabaseSync/StatementSync/Session/constants/backup; `process.versions.sqlite` = 3.53.1 — Node ships SQLite in-core with the synchronous single-writer API ADR 0077 needs), and for 0003 no finding could argue against localhost-only, which is the conservative option and already the operative posture. ADR 0008 is recorded as a decision, NOT as construction: no dashboard exists, Ring 3 is `.gitkeep`-only, and accepting it authorizes no implementation and starts no milestone. Rows are NOT reordered — D-037..D-044 were accepted out of numeric order and the ledger is append-only; rewriting recorded history for a cosmetic property is a worse defect than the one it fixes. | ADR 0002 / 0003 / 0008 status flips; ADR 0009 / 0013 / 0014 / 0039 reference backfill | 2026-07-25 | | D-085 | `PLAN.md` collapses 1,579 -> ~290 lines: the ~1,190-line superseded prior-focus stack is removed (preserved at `3063d9f`; `git show 3063d9f:PLAN.md`) and §Current Focus is capped at 20 lines. Milestones 0-6 are **retained** with their acceptance criteria; two demonstrably false statements inside them are corrected — the M3 hash bullet, which specified naive concatenation that `adr/0064:453-456` forbids, and eight scoped `just test` invocations naming targets the justfile rejects. Three checklist rules and regression trap #61 are added, earned by the withdrawn attempt described below. | The prior-focus prose is narration, not obligation: its decisions are in this ledger, its designs in `adr/`, its status in the dated readouts, so a third copy is pure maintenance cost. The milestone blocks were NOT deleted because the ground for doing so did not survive checking — `adr/0010:19` enumerates M4's five tools in accepted text, and `adr/0004` §References anchors M3's `facts`/`fact_observations` to the external research plan, so "no matching ADR" was false for both; correcting the two provably wrong statements removes the implementer trap without a claim that fails scrutiny. **Withdrawn from this change:** an earlier commit (`3e9b314`, dropped, never pushed) also redefined change class I by callability. Five reviewer lenses found it unmergeable — it quoted `adr/0057:422-423` as authorizing an ApprovalGrant mint when that line sits inside "This ADR explicitly does not authorize:" whose final bullet is "Ring 1 service implementation code"; it restated `charter:54`'s "is live" as "exist", weakening a binding invariant; it claimed "supersedes no ADR" while contradicting `adr/0077`'s nine class-I self-classifications; and it placed write paths in a class labelled "kernel read path". It is relocated, not rejected: the audit-append relief belongs in the PR that implements the audit append, where code exercises the wording. Nothing is blocked — the policy-snapshot loader stands on `adr/0060:325-331` and `adr/0061:371`. | Trap #61; `adr/0064:452-456`; `adr/0010:19`; `adr/0004` §References; no ADR superseded | 2026-07-25 | | D-086 | Ring-1 policy-rule loader lands at `packages/kernel/src/policy/rule-loader.ts` under **ADR 0079**, scoped to rule shape only: parse, project `operation_class_defaults` into `PolicyRule`, validate against `policyRuleSchema`, assert exact `operation_class` set equality both ways. Provenance/digest verification is **explicitly out of scope** and stays with `scripts/ci/snapshot-binding-check.sh`. Every projected value is read from a field policy declares; where policy is silent the loader rejects, with one recorded authored default (`approval_path_allowed` when the tier does not declare it — provably inert, since the schema pins it to `true` on the only branch where it is consulted). | Supersedes a withdrawn attempt (`3e9b314`-lineage, PR #98, converted to draft, never merged) that combined provenance verification with projection. Five reviewer lenses found it unmergeable on two counts. **First, it was not authorized:** it cited ADR 0060 §Provenance verification, while that ADR's §Out of scope declines to authorize "the §Provenance-verification implementation" and defers it to a follow-up gateway ADR that does not exist — the same failure class as trap #61, one day after that trap was seeded, which proved the rule too narrow (it named a location, not a property; now generalized in `IMPLEMENT.md` §Per-PR checklist). **Second, it failed open:** it derived approval posture from the PRESENCE of an `approval_required_details` block, and in YAML `approval_required_details:` with no value parses to `null`, so "not a mapping" read as "absent" and absent projected `approval_required: false` on a `write-destructive` class — while the CI checker it proposed to replace fails closed on the same bytes. That is the fifth instance of one repo-wide pattern (boundary-check rc=2 read as pass; `no-live-secrets` malformed pattern read as clean; the justfile's missing toolchain read as exit 0; the Phase-0b hook's missing deny path read as allow), so the test suite now carries a **fail-closed class** that feeds the null / absent / wrong-type variant of every security-relevant field and asserts rejection. Investigating it established that live policy declares approval posture in **three** places (`tiers.`, `operation_class_defaults.`, and that class's `approval_required_details.status`) and that `valid_until_ceiling` is declared per tier and per class — values the withdrawn draft authored as `'not_applicable'` rather than read. A schema change was considered and rejected as unnecessary: `policyRuleApprovalSchema` already rejects absent, `null`, and `"false"`; the defect was that the loader never passed policy's declared value to it. Retiring the Ruby lane is NOT part of this decision — with provenance out of scope the two checkers answer disjoint questions, so no inv-1 duplication surface exists. | ADR 0079 (ships with this code per D-085's successor rule); ADR 0060 §Out of scope (why provenance is deferred); trap #61 second occurrence | 2026-07-25 | +| D-087 | Read-only CLI adapter surface lands under **ADR 0080**: `packages/adapters/cli/` with one substantive verb, `hcs policy status`, invoked as `just cli policy status`. Discharges ADR 0079's precondition by adding a kernel-resolved `loadBoundPolicyRules()` that takes no path; the parameterized form stays for tests. Two gate defects were found and fixed because this is the first Ring-2 code to exist: `boundary-check.sh` rule 2 rejected the charter-legal `@hcs/kernel/api` import (its subtraction required a trailing slash, matching only deeper paths) and then flagged its own documentation (stage 1 matched any occurrence of the token, including backticked prose); the justfile's scoped-test discovery missed nested packages, so `just test cli` could not find `packages/adapters/cli/tests`. | No ADR authorized a CLI surface — ADR 0003 covers stdio/Streamable HTTP transports, and ADR 0079 §Out of scope excludes "any consumer of the returned rules," which a verb rendering those rules is. Citing either would have been the third occurrence of trap #61; applying the generalized citation rule prospectively caught it before a false citation was written, which is the first time that rule has prevented rather than diagnosed. The adapter decides nothing (inv. 1): it formats the loader's result, contains no tier literal, and a guard derived from `policyRuleTierSchema.options` asserts so over adapter source. Dispatch is an exhaustive match over a closed verb list — unknown verb exits 2, rejected snapshot exits 1 — rather than a lookup with a default handler. `run(argv)` returns `{exitCode, lines}` so the verb is testable without spawning a process. `resolveBoundSnapshotPath()` reads `HCS_ROOT` and falls back to the kernel module's own location, because charter inv. 15 warns GUI apps, launchd jobs, and IDE extensions do not inherit shell env. Rule 2 is now load-bearing for the first time: it is `[ -d packages/adapters ]`-guarded, so until this package existed it had no subject — and its first real subject exposed two false-positive bugs in the repair that landed in #93. | ADR 0080 (ships with this code per D-085's successor rule); ADR 0079 §Out of scope (the precondition discharged); charter inv. 1 / 7 / 15 | 2026-07-25 | ## Reversed diff --git a/PLAN.md b/PLAN.md index 55492da..47e7c31 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,8 +13,8 @@ readouts; decisions belong in `DECISIONS.md`. This section states status only. - **M1 COMPLETE** — 22/22 canonical Ring-0 entities, plus post-M1 `Model` (ADR 0076 / D-077) and the ADR 0078 / D-081 `model_ref` slice. - **Versions:** ontology **v1.34.0** · registry **v0.4.37** · charter **v1.6.0** · - ADRs **0001–0079** (0026 absent, reserved — see `adr/0025` §Future amendments) · - decisions through **D-086** (next-free **D-087**). + ADRs **0001–0080** (0026 absent, reserved — see `adr/0025` §Future amendments) · + decisions through **D-087** (next-free **D-088**). - **M2 residue:** the ADR 0034 boundary-evidence consumption matrix (`Decision`/`ApprovalGrant` × {stale, missing, contradictory}) is the one acceptance bullet still open. PolicyRule schema ref (D-078), freshness windows diff --git a/docs/host-capability-substrate/adr/0080-read-only-cli-adapter-surface.md b/docs/host-capability-substrate/adr/0080-read-only-cli-adapter-surface.md new file mode 100644 index 0000000..2ffd90d --- /dev/null +++ b/docs/host-capability-substrate/adr/0080-read-only-cli-adapter-surface.md @@ -0,0 +1,135 @@ +--- +adr_number: 0080 +title: Read-only CLI adapter surface +status: proposed +version: v1 +date: 2026-07-25 +charter_version: 1.6.0 +tags: [ring-2, adapter, cli, read-only, class-e, adr-0079-followup] +--- + +# ADR 0080: Read-only CLI adapter surface + +## Status + +`proposed` + +Ships in the same PR as the code it governs, per D-085's successor rule. + +## Date + +2026-07-25 + +## Context + +`packages/adapters/cli/` has held a `.gitkeep` since the repo was scaffolded. +No ADR authorizes a CLI surface: ADR 0003 covers transport topology (stdio + +Streamable HTTP), and ADR 0079 §Out of scope explicitly excludes "any consumer +of the returned rules" — which a CLI verb is. Citing either as authorization +would repeat regression trap #61 for a third time. + +ADR 0079 also left a precondition on this PR: "A no-argument public form. +`LoadOptions` takes a caller-supplied path, which is acceptable while the only +callers are tests; before an adapter forwards a path argument, the public form +must become kernel-resolved." + +The substrate has produced no runnable surface in its history. Everything built +so far is verified by tests and CI. That is not the same as an operator being +able to ask the substrate a question and read the answer. + +## Decision + +A read-only `hcs` CLI adapter at `packages/adapters/cli/`, with exactly one +substantive verb in this ADR's scope: **`hcs policy status`**. + +**Read-only by construction, not by convention.** The adapter registers no +`Capability`, accepts and emits no `OperationShape`, mints and consumes no +`ApprovalGrant`, spawns no process, and writes nothing. Adding a verb that +mutates host state makes this package class I and gates it behind charter +invariant 7. + +**The adapter decides nothing** (charter inv. 1). `policy-status.ts` formats the +loader's result object and does nothing else. It contains no tier name, no +classification branch, and no verdict of its own. A guard test asserts no tier +literal appears in adapter source, derived from `policyRuleTierSchema.options` +rather than hand-written. + +**It imports only `@hcs/kernel/api`.** The kernel's exports map publishes that +path and nothing deeper, so a kernel internal is unreachable from Ring 2 at +module resolution — `ERR_PACKAGE_PATH_NOT_EXPORTED` — independently of whether +`boundary-check.sh` rule 2 is working. + +**Dispatch is an exhaustive match over a closed verb list**, not a lookup with a +default handler. An unrecognised verb exits `2`. A rejected snapshot exits `1`: +a snapshot the kernel refuses is an operator-visible failure, not an +informational note. + +**`run(argv)` is exported and pure** — it returns `{exitCode, lines}` rather +than writing to stdout — so the verb is testable without spawning a process or +capturing streams. + +**Kernel-resolved snapshot path.** This ADR discharges ADR 0079's precondition: +`@hcs/kernel/api` now exports `loadBoundPolicyRules()`, which takes no path. +`resolveBoundSnapshotPath()` reads `HCS_ROOT` when set and falls back to the +kernel module's own location — charter inv. 15 warns that GUI apps, launchd +jobs, and IDE extensions do not inherit shell env, so a resolver that only read +the variable would fail in exactly those contexts. The parameterized +`loadPolicyRules` remains for tests, which must stage mutated snapshots. + +## Out of scope + +This ADR does not authorize: + +- Any mutating verb, any verb that spawns a process, or any verb that writes to + disk or network. +- MCP tools, HTTP endpoints, or the dashboard. ADR 0003 governs transports; + those surfaces need their own ADRs. +- `hcs audit verify` or any audit-adjacent verb. The audit store does not exist, + and ADR 0077 §5 makes its chain-walk a kernel write path — a "read-only" verb + that induces a kernel write is not read-only. +- Gateway re-derivation, `Decision` construction, capability registration, tool + resolution, or host state. ADR 0057 §Out of scope defers all of these to ADRs + that do not exist. +- Installing `hcs` onto `PATH`. The verb is invoked through `just cli` in this + ADR's scope; a PATH install is a host-surface change with its own posture + questions. +- Machine-readable output. The current renderer is for a human reading a + terminal. A `--json` form is a contract, and contracts get ADRs. + +## Options considered + +**Fold the verb into ADR 0079.** Rejected: 0079's §Out of scope excludes +consumers of its own output, and amending an ADR to authorize the thing it just +declined is the shortcut D-085 recorded against. + +**Skip the ADR and cite 0079 §Consequences**, which mentions this verb as +next work. Rejected on the generalized citation rule: a document mentioning +something is not the same as authorizing it. That rule exists because this +failure has now occurred twice. + +**Ship a `--json` mode immediately.** Deferred. The first consumer is a human; +a machine format invites a parser, and a parser is a contract that outlives the +convenience. + +## Consequences + +An operator can run `just cli policy status` and read what the substrate knows +about the bound policy snapshot — the first invocable surface this project has +produced. + +It also makes `boundary-check.sh` rule 2 load-bearing for the first time: that +rule is `[ -d packages/adapters ]`-guarded and scans adapters only, so until +this package existed it had no subject. The rule was repaired in PR #93 after +three months of never executing; this is the first code it actually polices. + +Class **E** — adapter read path. + +## References + +- ADR 0079 §Out of scope — the kernel-resolved-path precondition this discharges +- ADR 0003 — transport topology; does not cover a CLI surface +- ADR 0057 §Out of scope — why gateway/capability/tool-resolution verbs are absent +- ADR 0077 §5 — why `hcs audit verify` is out of scope +- charter inv. 1 (adapters do not classify), inv. 7 (callability), inv. 15 + (GUI/launchd do not inherit shell env) +- D-085 — the successor rule under which this ADR ships with its code diff --git a/justfile b/justfile index bbb5019..d1e3f97 100644 --- a/justfile +++ b/justfile @@ -107,17 +107,26 @@ typecheck: (_require-node-tool "tsc") test target="": (_require-node-tool "vitest") @echo "→ unit tests" @if [ -n "{{target}}" ]; then \ - if [ ! -d "packages/{{target}}/tests" ]; then \ - echo "error: no test directory at packages/{{target}}/tests" >&2; \ + dir=""; \ + for d in packages/*/tests packages/*/*/tests; do \ + [ -d "$d" ] || continue; \ + [ "$(basename $(dirname $d))" = "{{target}}" ] && dir="$d"; \ + done; \ + if [ -z "$dir" ]; then \ + echo "error: no test directory for target '{{target}}'" >&2; \ echo " available scoped targets:" >&2; \ - for d in packages/*/tests; do [ -d "$d" ] && echo " - $(basename $(dirname $d))" >&2; done; \ + for d in packages/*/tests packages/*/*/tests; do [ -d "$d" ] && echo " - $(basename $(dirname $d))" >&2; done; \ exit 1; \ fi; \ - node_modules/.bin/vitest run "packages/{{target}}/tests"; \ + node_modules/.bin/vitest run "$dir"; \ else \ node_modules/.bin/vitest run; \ fi +# Run the read-only CLI adapter. `just cli policy status` +cli *args: + @node --experimental-strip-types packages/adapters/cli/src/main.ts {{args}} + # Schema drift check generate-schemas-check: @bash scripts/ci/schema-drift.sh diff --git a/package-lock.json b/package-lock.json index b1edb32..8c11381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -200,6 +200,10 @@ "node": ">=14.21.3" } }, + "node_modules/@hcs/cli": { + "resolved": "packages/adapters/cli", + "link": true + }, "node_modules/@hcs/kernel": { "resolved": "packages/kernel", "link": true @@ -1708,6 +1712,14 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/adapters/cli": { + "name": "@hcs/cli", + "version": "0.1.0", + "dependencies": { + "@hcs/kernel": "*", + "@hcs/schemas": "*" + } + }, "packages/kernel": { "name": "@hcs/kernel", "version": "0.1.0", diff --git a/packages/adapters/cli/.gitkeep b/packages/adapters/cli/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/adapters/cli/package.json b/packages/adapters/cli/package.json new file mode 100644 index 0000000..49b7b5f --- /dev/null +++ b/packages/adapters/cli/package.json @@ -0,0 +1,13 @@ +{ + "name": "@hcs/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/main.ts" + }, + "dependencies": { + "@hcs/kernel": "*", + "@hcs/schemas": "*" + } +} diff --git a/packages/adapters/cli/src/commands/policy-status.ts b/packages/adapters/cli/src/commands/policy-status.ts new file mode 100644 index 0000000..e5fc3e1 --- /dev/null +++ b/packages/adapters/cli/src/commands/policy-status.ts @@ -0,0 +1,61 @@ +import { loadBoundPolicyRules, resolveBoundSnapshotPath } from '@hcs/kernel/api'; + +/** + * `hcs policy status` — read-only. + * + * Renders what the kernel loader reports about the bound policy snapshot. + * + * THIS ADAPTER DECIDES NOTHING (charter inv. 1). It performs no classification, + * applies no tier logic, and reaches no verdict of its own. Every value printed + * below comes from the loader's result object; the adapter's entire job is + * formatting. If you find yourself adding an `if` on a tier name here, that + * logic belongs in Ring 1 and this file is the wrong place for it. + * + * It imports only `@hcs/kernel/api`. The kernel's exports map publishes that + * path and nothing deeper, so a kernel internal is not reachable from here even + * by accident (ERR_PACKAGE_PATH_NOT_EXPORTED). + */ + +export interface CommandResult { + readonly exitCode: number; + readonly lines: readonly string[]; +} + +function pad(value: string, width: number): string { + return value.length >= width ? value : value + ' '.repeat(width - value.length); +} + +export function policyStatus(): CommandResult { + const result = loadBoundPolicyRules(); + const lines: string[] = []; + + lines.push(`snapshot ${resolveBoundSnapshotPath()}`); + + if (!result.ok) { + lines.push(`status REJECTED at ${result.rejectedAt}`); + lines.push(`reason ${result.reason}`); + // Non-zero: a snapshot the kernel refuses is an operator-visible failure, + // not an informational note. + return { exitCode: 1, lines }; + } + + lines.push(`status loaded`); + lines.push(`digest ${result.observedDigest} (observed, not verified — see ADR 0079)`); + lines.push(`schema policy_rule_schema_version ${result.policyRuleSchemaVersion}`); + lines.push(`rules ${result.rules.length}`); + lines.push(''); + lines.push( + ` ${pad('OPERATION CLASS', 32)}${pad('TIER', 19)}${pad('APPROVAL', 10)}${pad('PATH', 7)}CEILING`, + ); + + for (const rule of result.rules) { + lines.push( + ` ${pad(rule.operation_class, 32)}${pad(rule.tier, 19)}` + + `${pad(String(rule.approval.approval_required), 10)}` + + `${pad(String(rule.approval.approval_path_allowed), 7)}` + + `${rule.valid_until_ceiling}`, + ); + } + + return { exitCode: 0, lines }; +} diff --git a/packages/adapters/cli/src/main.ts b/packages/adapters/cli/src/main.ts new file mode 100644 index 0000000..b68a731 --- /dev/null +++ b/packages/adapters/cli/src/main.ts @@ -0,0 +1,40 @@ +import { policyStatus } from './commands/policy-status.ts'; + +/** + * `hcs` — read-only CLI adapter (Ring 2). + * + * Per ADR 0080: this surface is read-only by construction. It registers no + * capability, accepts and emits no `OperationShape`, mints and consumes no + * `ApprovalGrant`, and executes nothing. Adding a verb that mutates host state + * makes this file class I and gates it behind charter invariant 7. + * + * Dispatch is an exhaustive match over a closed verb list — not a lookup that + * falls through to a default handler — so an unrecognised verb exits non-zero + * rather than doing something approximate. + */ + +const VERBS = ['policy status'] as const; + +function usage(): string[] { + return ['usage: hcs ', '', 'verbs:', ...VERBS.map((v) => ` ${v}`)]; +} + +export function run(argv: readonly string[]): { exitCode: number; lines: readonly string[] } { + const verb = argv.join(' ').trim(); + + switch (verb) { + case 'policy status': + return policyStatus(); + case '': + case 'help': + case '--help': + case '-h': + return { exitCode: 0, lines: usage() }; + default: + return { exitCode: 2, lines: [`unknown verb: ${verb}`, '', ...usage()] }; + } +} + +const { exitCode, lines } = run(process.argv.slice(2)); +for (const line of lines) process.stdout.write(`${line}\n`); +process.exitCode = exitCode; diff --git a/packages/adapters/cli/tests/policy-status.test.ts b/packages/adapters/cli/tests/policy-status.test.ts new file mode 100644 index 0000000..08d0641 --- /dev/null +++ b/packages/adapters/cli/tests/policy-status.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { policyRuleTierSchema } from '@hcs/schemas'; +import { describe, expect, it } from 'vitest'; +import { policyStatus } from '../src/commands/policy-status.ts'; +import { run } from '../src/main.ts'; + +describe('hcs policy status', () => { + it('loads the bound snapshot and exits 0', () => { + const result = policyStatus(); + expect(result.exitCode).toBe(0); + }); + + it('renders every rule the kernel returned', () => { + const { lines } = policyStatus(); + const body = lines.join('\n'); + expect(body).toContain('status loaded'); + expect(body).toContain('rules 8'); + for (const cls of ['read_only_diagnostic', 'destructive_git', 'merge_or_push']) { + expect(body).toContain(cls); + } + }); + + it('labels the digest as observed, not verified', () => { + // ADR 0079 cut provenance verification from the loader's scope. The verb + // must not imply a guarantee the kernel does not make. + const body = policyStatus().lines.join('\n'); + expect(body).toContain('observed, not verified'); + }); +}); + +describe('dispatch is a closed match, not a permissive lookup', () => { + it('exits 2 on an unknown verb rather than doing something approximate', () => { + const result = run(['policy', 'bogus']); + expect(result.exitCode).toBe(2); + expect(result.lines.join('\n')).toContain('unknown verb'); + }); + + it('exits 0 with usage on no verb', () => { + expect(run([]).exitCode).toBe(0); + expect(run([]).lines.join('\n')).toContain('usage: hcs'); + }); + + it('does not treat a prefix of a known verb as that verb', () => { + // `policy` alone is not `policy status`. + expect(run(['policy']).exitCode).toBe(2); + }); +}); + +/** + * charter inv. 1 — adapters translate, they do not classify. + * + * Same guard shape as the kernel's, derived from the enum so it cannot drift + * from the vocabulary it guards. If a tier name ever appears in adapter source, + * the adapter has started making a policy decision and belongs in Ring 1. + */ +describe('inv. 1 — the adapter authors no policy vocabulary', () => { + const sources = ['../src/commands/policy-status.ts', '../src/main.ts'].map((rel) => + readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''), + ); + + it('contains no tier literal in executable code', () => { + expect(policyRuleTierSchema.options).toHaveLength(5); + for (const source of sources) { + for (const tier of policyRuleTierSchema.options) { + expect(source).not.toContain(tier); + } + } + }); + + it('imports the kernel only through its public api path', () => { + for (const source of sources) { + for (const match of source.matchAll(/from '(@hcs\/kernel[^']*)'/g)) { + expect(match[1]).toBe('@hcs/kernel/api'); + } + } + }); +}); diff --git a/packages/kernel/src/api/index.ts b/packages/kernel/src/api/index.ts index db46570..484d49a 100644 --- a/packages/kernel/src/api/index.ts +++ b/packages/kernel/src/api/index.ts @@ -39,4 +39,9 @@ export type { LoadResult, RejectedRules, } from '../policy/rule-loader.ts'; -export { LOADER_CHECKPOINTS, loadPolicyRules } from '../policy/rule-loader.ts'; +export { + LOADER_CHECKPOINTS, + loadBoundPolicyRules, + loadPolicyRules, + resolveBoundSnapshotPath, +} from '../policy/rule-loader.ts'; diff --git a/packages/kernel/src/policy/rule-loader.ts b/packages/kernel/src/policy/rule-loader.ts index febb862..4315abf 100644 --- a/packages/kernel/src/policy/rule-loader.ts +++ b/packages/kernel/src/policy/rule-loader.ts @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { operationShapeOperationClassSchema, type PolicyRule, @@ -90,6 +92,40 @@ export interface LoadOptions { readonly snapshotPath: string; } +/** + * The snapshot location, resolved by the kernel — never supplied by a caller. + * + * ADR 0079 §Out of scope made this a precondition on the first non-test caller: + * "before an adapter forwards a path argument, the public form must become + * kernel-resolved." `loadPolicyRules` keeps its parameterized form for tests, + * which need to stage mutated snapshots; adapters get this one, which cannot be + * pointed at an arbitrary file and therefore cannot be turned into a file-read + * oracle by a caller that forwards user input. + * + * `HCS_ROOT` is set by .mise.toml to the repo root. When it is absent — a GUI + * app, a launchd job, any context charter inv. 15 warns does not inherit shell + * env — resolution falls back to this module's own location, which is stable + * because the kernel package always sits at packages/kernel/src/policy/. + */ +export function resolveBoundSnapshotPath(): string { + const root = process.env.HCS_ROOT; + if (root !== undefined && root.length > 0) { + return join(root, SNAPSHOT_RELATIVE_PATH); + } + return fileURLToPath(new URL(`../../../../${SNAPSHOT_RELATIVE_PATH}`, import.meta.url)); +} + +const SNAPSHOT_RELATIVE_PATH = 'policies/generated-snapshot/tiers.yaml'; + +/** + * Load the bound snapshot from its kernel-resolved location. + * + * This is the form adapters use. There is deliberately no path parameter. + */ +export function loadBoundPolicyRules(): LoadResult { + return loadPolicyRules({ snapshotPath: resolveBoundSnapshotPath() }); +} + export function loadPolicyRules(options: LoadOptions): LoadResult { // ---- checkpoint: parse -------------------------------------------------- let rawBytes: Buffer; diff --git a/packages/kernel/tests/api-surface.test.ts b/packages/kernel/tests/api-surface.test.ts index 2f91a5d..960244b 100644 --- a/packages/kernel/tests/api-surface.test.ts +++ b/packages/kernel/tests/api-surface.test.ts @@ -99,7 +99,12 @@ describe('@hcs/kernel public API surface', () => { const api = await import('../src/api/index.ts'); // Surface lock: adding an export without updating this turns the suite red, // so the public API cannot widen by accident. - expect(Object.keys(api).sort()).toEqual(['LOADER_CHECKPOINTS', 'loadPolicyRules']); + expect(Object.keys(api).sort()).toEqual([ + 'LOADER_CHECKPOINTS', + 'loadBoundPolicyRules', + 'loadPolicyRules', + 'resolveBoundSnapshotPath', + ]); }); it('exports no mint, append, consume, or revoke symbol', async () => { diff --git a/scripts/ci/boundary-check.sh b/scripts/ci/boundary-check.sh index b9cf91b..89004d0 100755 --- a/scripts/ci/boundary-check.sh +++ b/scripts/ci/boundary-check.sh @@ -66,7 +66,11 @@ ring2_importers=() if [ ${#ring2_importers[@]} -gt 0 ]; then rule2_rc=0 - rule2_hits="$(grep_gate grep -rE "(@hcs/kernel(/src)?/|[.][.]?/[^\"']*kernel/src/)" "${ring2_importers[@]}")" || rule2_rc=$? + # The specifier must be QUOTED. Every import form quotes it — `from "x"`, + # `import "x"`, `require("x")`, `await import("x")` — while prose that names + # the path in backticks does not. Without this anchor the rule flags its own + # documentation, which it did the first time it had a real subject. + rule2_hits="$(grep_gate grep -rE "['\"](@hcs/kernel(/src)?/|[.][.]?/[^'\"]*kernel/src/)" "${ring2_importers[@]}")" || rule2_rc=$? if [ "$rule2_rc" -ge 2 ]; then err "rule 2 could not execute (grep rc=$rule2_rc) — treating as FAILED" @@ -75,7 +79,12 @@ if [ ${#ring2_importers[@]} -gt 0 ]; then # the legal path, so both @hcs/kernel/api/ and @hcs/kernel/src/api/ are # permitted — subtracting only the former would flag the charter's own # declared public path as a violation. - rule2_private="$(printf '%s\n' "$rule2_hits" | gate_filter "(@hcs/kernel(/src)?/api/|kernel/src/api/)")" + # The trailing-slash form alone is WRONG: charter:84 declares + # `@hcs/kernel/api` legal, and `/api/` only matches deeper paths like + # `@hcs/kernel/api/policy`. Anchoring on a quote, slash, or end-of-token + # accepts both the barrel and a deep public path while still rejecting + # `@hcs/kernel/apifoo`. Found the first time this rule had a real subject. + rule2_private="$(printf '%s\n' "$rule2_hits" | gate_filter "(@hcs/kernel(/src)?/api([\"'/]|\$)|kernel/src/api/)")" if [ -n "$rule2_private" ]; then printf '%s\n' "$rule2_private" >&2 err "Ring 2 importing kernel private internals (use @hcs/kernel/api instead)"