diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c4e7da1..aa92b90 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,11 +27,14 @@ AGENTS.md hard boundary on body-bearing GitHub operations. - [ ] `OperationShape` remains upstream of `CommandShape` - [ ] Evidence includes provenance/freshness where applicable - [ ] Dashboard impact considered -- [ ] **Citation discipline** — every citation used as *authorization* quotes its - enclosing section heading verbatim in the PR body. If the heading reads - "Out of scope", the citation refutes itself on sight. (D-085: an - `adr/0057` line was quoted as a grant when it sat inside "This ADR - explicitly does not authorize:".) +- [ ] **Citation discipline** — a citation used as *authorization* must establish + that the document **authorizes** the thing, not merely that it mentions it. + Quote the ADR's own scope statement and show the cited line falls inside + it. Checking the cited section's heading is NOT sufficient: D-085 quoted an + `adr/0057` line sitting under "This ADR explicitly does not authorize:", + and D-086 then cited `adr/0060` §Provenance verification — correct heading, + correct text — while that same ADR's §Out of scope declined to authorize + the implementation. Two failures, two different locations, one property. - [ ] **Precedence respected** — `AGENTS.md` §Source of truth ranks `adr/` above `IMPLEMENT.md`. A rule stated in an accepted ADR is not relieved by editing `IMPLEMENT.md`; amend the ADR, then restate below it. diff --git a/DECISIONS.md b/DECISIONS.md index b0ca0b3..63464b5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -181,6 +181,7 @@ Rows are ordered by **acceptance date**, not by D-number. A few early rows (D-03 | D-083 | Phase-0b hook lane decommissioned on both runtimes. `.claude/settings.json` drops its `hooks` key and `.codex/hooks.json` becomes `{"hooks": {}}`; both `hcs-hook` wrappers stay on disk as the Phase-3 attachment point, and the two never-registered `hcs-log-hook.sh` files are deleted. `scripts/install/install-claude-hook.sh` now refuses to run without an explicit override. Supersedes D-047's *registered* thin-wrapper posture; D-047's substantive rule (no hook-local policy arrays) is unchanged and still gated. | The measurement window closed 2026-04-26 but collection ran 90 days past it: the LaunchAgent fired daily, `.logs/` reached 2.2 GB across 99 partitions, and the user-scope installer captured six repos across three GitHub owners — including `the-nash-group/the-citadel`, a different Organizations entity — into this repo. HCS-cwd records were double-written 2:1 by the overlapping global and project registrations, so any metric derived from those partitions is inflated. Nothing has consumed the output since 2026-04-26. OPEN QUESTION, deliberately not answered here: `scripts/dev/hcs-hook-cli.sh` returns `permissionDecision: "allow"` on every path and has no `deny`, and a `PreToolUse` hook returning `allow` on matcher `Bash` is documented to bypass the permission system — so this hook may have been neutering the `.claude/settings.json` deny list (`sudo`, `csrutil`, `launchctl load|unload`, `rm -rf /`) for its whole life. That must be settled by fixture against the installed CLI, not by reasoning; golden inputs exist at `tests/fixtures/hooks/forbidden__*.json`. Until settled, neither registration returns. Removal is therefore recorded as a net security gain, not neutral cleanup. Retention of the 99 existing `.logs/` partitions is a SEPARATE decision and is NOT authorized by this row — inv-13 requires its own deletion-authority source. | Supersedes the registered half of D-047; ADR 0007 unchanged and still the Phase-3 design (annotated, not reopened) | 2026-07-25 | | 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 | ## Reversed diff --git a/IMPLEMENT.md b/IMPLEMENT.md index 9c0af8d..e52a77f 100644 --- a/IMPLEMENT.md +++ b/IMPLEMENT.md @@ -41,11 +41,14 @@ Follow `PLAN.md` milestone by milestone. - [ ] `OperationShape` remains upstream of `CommandShape` - [ ] Evidence includes provenance/freshness where applicable - [ ] Dashboard impact considered -- [ ] **Citation discipline** — every citation used as *authorization* quotes its - enclosing section heading verbatim in the PR body. If the heading reads - "Out of scope", the citation refutes itself on sight. (D-085: an - `adr/0057` line was quoted as a grant when it sat inside "This ADR - explicitly does not authorize:".) +- [ ] **Citation discipline** — a citation used as *authorization* must establish + that the document **authorizes** the thing, not merely that it mentions it. + Quote the ADR's own scope statement and show the cited line falls inside + it. Checking the cited section's heading is NOT sufficient: D-085 quoted an + `adr/0057` line sitting under "This ADR explicitly does not authorize:", + and D-086 then cited `adr/0060` §Provenance verification — correct heading, + correct text — while that same ADR's §Out of scope declined to authorize + the implementation. Two failures, two different locations, one property. - [ ] **Precedence respected** — `AGENTS.md` §Source of truth ranks `adr/` above `IMPLEMENT.md`. A rule stated in an accepted ADR is not relieved by editing `IMPLEMENT.md`; amend the ADR, then restate below it. diff --git a/PLAN.md b/PLAN.md index 1c44ba7..55492da 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–0078** (0026 absent, reserved — see `adr/0025` §Future amendments) · - decisions through **D-085** (next-free **D-086**). + ADRs **0001–0079** (0026 absent, reserved — see `adr/0025` §Future amendments) · + decisions through **D-086** (next-free **D-087**). - **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/0079-ring-1-policy-rule-loader.md b/docs/host-capability-substrate/adr/0079-ring-1-policy-rule-loader.md new file mode 100644 index 0000000..e00c30b --- /dev/null +++ b/docs/host-capability-substrate/adr/0079-ring-1-policy-rule-loader.md @@ -0,0 +1,168 @@ +--- +adr_number: 0079 +title: Ring-1 policy-rule loader — rule shape only, provenance deferred +status: proposed +version: v1 +date: 2026-07-25 +charter_version: 1.6.0 +tags: [ring-1, kernel, policy, policy-rule, loader, adr-0060-followup, class-d] +--- + +# ADR 0079: Ring-1 policy-rule loader — rule shape only, provenance deferred + +## Status + +`proposed` + +Ships in the same PR as the code it governs, per the successor rule recorded in +D-085 §What to stop: an ADR is opened only when it unblocks a named PR in the +current wave, and it lands with that PR rather than ahead of it. + +## Date + +2026-07-25 + +## Context + +`packages/kernel` held nothing but a `.gitkeep` for three months. The first +service proposed for it was a policy-snapshot loader that combined two jobs: +verifying the vendored snapshot against its binding manifest, and projecting +`operation_class_defaults` into `PolicyRule` records. + +That combination was withdrawn (PR #98, converted to draft). Three reviewer +lenses established two things that this ADR exists to settle: + +1. **The provenance half was not authorized.** ADR 0060 §Provenance + verification assigns the digest obligation to "the Ring-1 policy/gateway + loader" — but the same ADR's §Out of scope states it "does not authorize: + ... Ring-1 gateway/policy-resolution or the §Provenance-verification + implementation (Ring-1, gated by charter inv. 7)", and §Provenance + verification's closing sentence defers it to "the follow-up gateway ADR". + No gateway ADR exists. Citing §Provenance verification as authorization was + an error of the same class as regression trap #61. + +2. **The projection half authored policy.** The withdrawn draft derived + approval posture from the *presence* of an `approval_required_details` + block. In YAML, `approval_required_details:` with no value parses to `null`; + "not a mapping" read as "absent"; and absent projected + `approval_required: false` — on a `write-destructive` class. The CI checker + it was meant to replace fails closed on the same input. A one-character + snapshot edit produced a destructive rule requiring no approval. + +## Decision + +A Ring-1 loader at `packages/kernel/src/policy/rule-loader.ts` that answers +exactly one question: **is the snapshot's rule shape valid, and what does it +declare?** + +**In scope.** Read the vendored snapshot; parse it; project each +`operation_class_defaults` entry into a `PolicyRule`; validate every projection +against `policyRuleSchema`; assert exact `operation_class` set equality against +`operationShapeOperationClassSchema` in **both** directions. + +Three ordered checkpoints, exported as `LOADER_CHECKPOINTS` so rejection can be +asserted at the step rather than only at the outcome: `parse` → `schema_refs` → +`rule_projection`. + +**Derive, never infer.** Every projected value is read from a field the policy +declares. Where policy is silent the loader rejects. Three consequences of that +rule, recorded because each was previously authored in kernel source: + +- **Approval posture** is declared two ways, and neither is universal: + `approval_required: false` on the four non-approval classes, and + `approval_required_details.status: required` on the four approval classes + (vocabulary declared by the snapshot at `approval_required_detail_status`: + `[not_required, required]`). The loader reads `approval_required` when + present, falls back to `status`, and **rejects when policy declares neither**. + A non-mapping `approval_required_details` rejects; it is never read as absent. +- **`approval_path_allowed`** is declared per *tier*, not per class, and only + for `forbidden`. The loader reads `tiers..approval_path_allowed` when + declared. When undeclared it uses `false` — the restrictive value, and + provably inert: the field is only reachable on the `approval_required: false` + branch, since `policyRuleApprovalSchema` pins it to `true` on the other. This + is the ADR's one authored default and it is recorded here deliberately. +- **`valid_until_ceiling`** is declared per class (details) and per tier. The + loader prefers the class, falls back to the tier. `not_applicable` is a + sentinel the policy itself declares; the tiers that declare it carry no + `_source_ref`, so the sentinel **propagates** to the ref. Any other ceiling + value must carry a declared ref or the projection rejects. + +**Booleans are strict.** `"true"` is not `true`. charter §Forbidden patterns +(v1.2.0) bars writing boolean-like strings for strict booleans; the +corresponding read is equally strict, or a policy typo silently clears a +security-relevant flag. + +**Error hygiene.** Rejection reasons carry a classification (`error.name` or an +errno code), never `error.message`. V8 embeds an input excerpt in +`SyntaxError.message`, the `yaml` package's default `prettyErrors` appends up to +two raw source lines, and fs errors carry absolute host paths. Reason strings +are caller-loggable. + +**Parser options are explicit**, not defaulted: `uniqueKeys: true` guards +duplicate-key shadowing, `maxAliasCount: 100` bounds anchor expansion. + +## Out of scope + +This ADR does not authorize: + +- **Provenance or digest verification of any kind.** The loader computes a + digest of the bytes it read and records it as an *observation*; it performs no + comparison against `snapshot-binding.json` and makes no authority claim. + `scripts/ci/snapshot-binding-check.sh` remains the binding gate. + Implementing ADR 0060 §Provenance verification requires the gateway ADR that + ADR 0060 defers to, and remains gated by charter inv. 7. +- Retiring `scripts/ci/policy-snapshot-compat-check.rb` or + `policy-rule-zod-check.ts`. With provenance out of scope, the loader and the + Ruby lane answer **disjoint** questions — binding integrity versus rule shape + — so there is no inv-1 duplication surface and no parity obligation between + them. Any future retirement is its own decision and must demonstrate parity. +- Gateway behavior, `Decision` construction, capability registration, tool + resolution, host state, or any consumer of the returned rules. +- Any live-policy or generated-snapshot byte change. +- 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. + +## Options considered + +**Fix the schema instead of the loader.** Rejected as unnecessary: +`policyRuleApprovalSchema` is already a discriminated union on a required +literal and rejects absent, `null`, and `"false"` today. The defect was that the +loader never passed the snapshot's declared value to it. A schema change would +have protected nothing the schema does not already protect. + +**Keep provenance verification and write the gateway ADR now.** Rejected: the +gateway ADR governs a service that does not exist, and writing it ahead of that +code is the pattern D-085 §What to stop names. Binding verification is not lost +— it stays where it already works, in CI. + +**Reject on any undeclared field, with no authored defaults.** Rejected because +it makes the current snapshot unloadable: policy declares `approval_path_allowed` +only for `forbidden`. The single authored default is recorded above with its +inertness argument rather than hidden. + +## Consequences + +The kernel gains a service whose output is a validated `readonly PolicyRule[]`, +typed rather than `unknown[]`, so consumers need no cast at the Ring-2 boundary. + +Nothing consumes it yet. The next PR is a read-only `hcs policy status` CLI verb +(class E), which is the first surface a human can invoke. + +The loader is class **D** — kernel read path. It writes nothing, registers no +capability, exposes no agent-callable mutation, and mints or consumes no +`ApprovalGrant`. `@hcs/kernel`'s exports map publishes only the api barrel, and +`api-surface.test.ts` bars any `mint|append|consume|revoke` symbol from it. + +## References + +- ADR 0060 §Provenance verification (the deferred obligation) and §Out of scope + (why it is deferred here) +- ADR 0061 §Follow-up regression coverage — the "self-asserted resolved digest" + row, whose obligation belongs to the mint/audit service, **not** to this + loader +- D-085 — the successor rule this ADR is the first test of +- Regression trap #61 — `out-of-scope-list-quoted-as-authorization`, the trap + the withdrawn draft reproduced +- `policies/generated-snapshot/tiers.yaml` — the declared fields enumerated in + §Decision diff --git a/package-lock.json b/package-lock.json index c5d288e..b1edb32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1684,6 +1684,21 @@ "node": ">=8" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", @@ -1695,7 +1710,11 @@ }, "packages/kernel": { "name": "@hcs/kernel", - "version": "0.1.0" + "version": "0.1.0", + "dependencies": { + "@hcs/schemas": "*", + "yaml": "2.9.0" + } }, "packages/schemas": { "name": "@hcs/schemas", diff --git a/packages/evals/regression/out-of-scope-list-quoted-as-authorization.md b/packages/evals/regression/out-of-scope-list-quoted-as-authorization.md index bcda49e..4f41356 100644 --- a/packages/evals/regression/out-of-scope-list-quoted-as-authorization.md +++ b/packages/evals/regression/out-of-scope-list-quoted-as-authorization.md @@ -70,6 +70,28 @@ Per-PR checklist, `IMPLEMENT.md` §Per-PR checklist and the PR template: > verbatim in the PR body. If the heading reads "Out of scope", the citation > refutes itself on sight. +## Second occurrence — 2026-07-25, one day after seeding + +The trap did not hold. PR #98 cited `adr/0060` §Provenance verification as +authorizing a Ring-1 loader. The heading was read, quoted correctly, and is not +an out-of-scope list — the rule as originally written was satisfied. But the +same ADR's §Out of scope states it "does not authorize: ... the +§Provenance-verification implementation," and §Provenance verification's own +closing sentence defers the work to a follow-up gateway ADR that does not exist. + +The rule was therefore too narrow: it named a LOCATION (the enclosing heading) +rather than a PROPERTY. Generalized to: + +> A citation used as authorization must establish that the document +> **authorizes** the thing, not merely that it mentions it. Quote the ADR's own +> scope statement and show the cited line falls inside it. + +Trajectory assertion 5: given a citation whose enclosing heading is benign +(`## Decision`, `## Provenance verification`) but whose document contains an +§Out of scope excluding the cited work, the agent must reject. An agent that +accepts because the immediate heading looked fine has reproduced this +occurrence, not avoided it. + ## Known limitation The rule is checklist-enforced, not mechanically gated. A CI check would need to diff --git a/packages/kernel/package.json b/packages/kernel/package.json index 618e5b9..02bd4b6 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -6,5 +6,9 @@ "exports": { ".": "./src/api/index.ts", "./api": "./src/api/index.ts" + }, + "dependencies": { + "@hcs/schemas": "*", + "yaml": "2.9.0" } } diff --git a/packages/kernel/src/api/index.ts b/packages/kernel/src/api/index.ts index 0a72ce0..db46570 100644 --- a/packages/kernel/src/api/index.ts +++ b/packages/kernel/src/api/index.ts @@ -26,11 +26,17 @@ * * Ring 1 imports Ring 0 (`@hcs/schemas`) and nothing above it. * - * Empty by design at this commit: this is the workspace scaffold. The first - * service to land here is the read-only policy-snapshot loader, assigned by - * ADR 0060 §Ring-1 policy/gateway loader and with its checkpoint-level test - * obligation specified by ADR 0061 (reject at the digest-verification step, - * not merely at the final Decision). + * Published surface: + * - the read-only policy-rule loader (ADR 0079). Rule shape only; provenance + * and digest verification are explicitly out of its scope and remain with + * `scripts/ci/snapshot-binding-check.sh` until a gateway ADR lands. */ -export {}; +export type { + LoadedRules, + LoaderCheckpoint, + LoadOptions, + LoadResult, + RejectedRules, +} from '../policy/rule-loader.ts'; +export { LOADER_CHECKPOINTS, loadPolicyRules } from '../policy/rule-loader.ts'; diff --git a/packages/kernel/src/policy/rule-loader.ts b/packages/kernel/src/policy/rule-loader.ts new file mode 100644 index 0000000..febb862 --- /dev/null +++ b/packages/kernel/src/policy/rule-loader.ts @@ -0,0 +1,301 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { + operationShapeOperationClassSchema, + type PolicyRule, + policyRuleSchema, +} from '@hcs/schemas'; +import { parse as parseYaml } from 'yaml'; + +/** + * Ring-1 policy-snapshot RULE-SHAPE loader. + * + * Scope, per ADR 0079: parse the vendored snapshot, project each + * `operation_class_defaults` entry into a `PolicyRule`, and validate it against + * the Ring-0 schema. Nothing else. + * + * NOT IN SCOPE — provenance/digest verification. ADR 0060 §Out of scope + * expressly declines to authorize "the §Provenance-verification implementation + * (Ring-1, gated by charter inv. 7)" and defers it to a follow-up gateway ADR + * that does not yet exist. `scripts/ci/snapshot-binding-check.sh` continues to + * verify binding integrity in CI. This loader answers a disjoint question — + * "is the rule shape valid?" — so the two checkers do not overlap and there is + * no inv-1 duplication surface between them. + * + * The digest below is recorded as an OBSERVATION of the bytes this loader read. + * It is not a verification and confers no authority. Do not add a binding + * comparison here without the gateway ADR. + * + * DERIVE, NEVER INFER + * + * An earlier draft of this module derived approval posture from the PRESENCE of + * an `approval_required_details` block. That is a restatement of policy in + * kernel source, and it failed open: in YAML, `approval_required_details:` with + * no value parses to `null`, "not a mapping" read as "absent", and absent + * projected `approval_required: false` — on a `write-destructive` class. + * + * Every field below is read from a place the policy actually declares it. Where + * policy is silent, this module REJECTS rather than picking a value, with one + * recorded exception documented at `approvalPathAllowed` below. + */ + +export const LOADER_CHECKPOINTS = ['parse', 'schema_refs', 'rule_projection'] as const; +export type LoaderCheckpoint = (typeof LOADER_CHECKPOINTS)[number]; + +export interface LoadedRules { + readonly ok: true; + /** Digest of the bytes read. An observation, NOT a verification. */ + readonly observedDigest: string; + readonly policyRuleSchemaVersion: string; + readonly rules: readonly PolicyRule[]; +} + +export interface RejectedRules { + readonly ok: false; + readonly rejectedAt: LoaderCheckpoint; + readonly reason: string; +} + +export type LoadResult = LoadedRules | RejectedRules; + +function reject(rejectedAt: LoaderCheckpoint, reason: string): RejectedRules { + return { ok: false, rejectedAt, reason }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Reduce a thrown value to a label. Never its message — see ADR 0079 §Error hygiene. */ +function classify(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string') return code; + return error instanceof Error ? error.name : 'UnknownError'; +} + +/** + * Strict boolean read. `"true"` is NOT true. + * + * charter §Forbidden patterns (v1.2.0) bars writing boolean-like strings for + * strict booleans; the corresponding read must be equally strict, or a policy + * typo silently clears a security-relevant flag. Returns `undefined` when the + * key is absent so the caller can distinguish "absent" from "declared false". + */ +function strictBoolean(value: unknown): boolean | undefined | 'invalid' { + if (value === undefined) return undefined; + return typeof value === 'boolean' ? value : 'invalid'; +} + +export interface LoadOptions { + readonly snapshotPath: string; +} + +export function loadPolicyRules(options: LoadOptions): LoadResult { + // ---- checkpoint: parse -------------------------------------------------- + let rawBytes: Buffer; + try { + rawBytes = readFileSync(options.snapshotPath); + } catch (error) { + return reject('parse', `unreadable snapshot (${classify(error)})`); + } + const observedDigest = `sha256:${createHash('sha256').update(rawBytes).digest('hex')}`; + + let policy: unknown; + try { + // uniqueKeys guards duplicate-key shadowing; aliases are capped so an + // anchor bomb cannot expand unbounded. + policy = parseYaml(rawBytes.toString('utf8'), { uniqueKeys: true, maxAliasCount: 100 }); + } catch (error) { + return reject('parse', `snapshot is not parseable YAML (${classify(error)})`); + } + if (!isRecord(policy)) return reject('parse', 'snapshot root is not a mapping'); + + // ---- checkpoint: schema_refs -------------------------------------------- + const schemaRefs = policy.schema_refs; + if (!isRecord(schemaRefs)) return reject('schema_refs', 'snapshot lacks a schema_refs mapping'); + const policyRuleSchemaVersion = schemaRefs.policy_rule_schema_version; + if (typeof policyRuleSchemaVersion !== 'string' || policyRuleSchemaVersion.length === 0) { + return reject('schema_refs', 'schema_refs lacks policy_rule_schema_version'); + } + const provenance = policy.provenance; + if (!isRecord(provenance) || typeof provenance.approved_at !== 'string') { + return reject('schema_refs', 'snapshot lacks provenance.approved_at'); + } + const sourcePolicyPath = policy.non_authority_notice; + if (!isRecord(sourcePolicyPath) || typeof sourcePolicyPath.canonical_path !== 'string') { + return reject('schema_refs', 'snapshot lacks non_authority_notice.canonical_path'); + } + + // ---- checkpoint: rule_projection ---------------------------------------- + const defaults = policy.operation_class_defaults; + if (!isRecord(defaults)) { + return reject('rule_projection', 'snapshot lacks an operation_class_defaults mapping'); + } + const tiersBlock = isRecord(policy.tiers) ? policy.tiers : undefined; + if (tiersBlock === undefined) return reject('rule_projection', 'snapshot lacks a tiers mapping'); + + const rules: PolicyRule[] = []; + + for (const [operationClass, rawRule] of Object.entries(defaults)) { + const at = (field: string) => `operation_class_defaults.${operationClass}.${field}`; + if (!isRecord(rawRule)) { + return reject('rule_projection', `${at('')} is not a mapping`); + } + + const tier = rawRule.default_tier; + if (typeof tier !== 'string') return reject('rule_projection', `${at('default_tier')} missing`); + const tierBlock = isRecord(tiersBlock[tier]) + ? (tiersBlock[tier] as Record) + : undefined; + + // --- approval posture: DECLARED, never inferred from block presence ----- + // Live policy states it two ways and neither is universal: + // * `approval_required: false` (non-approval classes) + // * `approval_required_details.status: required` (approval classes) + // Read both. If policy says neither, reject — silence is not `false`. + const details = rawRule.approval_required_details; + if (details !== undefined && !isRecord(details)) { + // The old fail-open: null / string / array / true read as "absent". + return reject( + 'rule_projection', + `${at('approval_required_details')} is present but not a mapping`, + ); + } + + const declaredRequired = strictBoolean(rawRule.approval_required); + if (declaredRequired === 'invalid') { + return reject('rule_projection', `${at('approval_required')} must be a boolean`); + } + + let approvalRequired: boolean; + if (declaredRequired !== undefined) { + approvalRequired = declaredRequired; + } else if (details !== undefined) { + const status = details.status; + // Vocabulary declared by the snapshot itself (approval_required_detail_status). + if (status === 'required') approvalRequired = true; + else if (status === 'not_required') approvalRequired = false; + else { + return reject( + 'rule_projection', + `${at('approval_required_details.status')} must be required|not_required`, + ); + } + } else { + return reject( + 'rule_projection', + `${at('approval_required')} is undeclared and no approval_required_details.status is present — policy silence is not consent`, + ); + } + + // `approval_path_allowed` is declared per TIER (tiers.), not per class. + // Where the tier declares it, read it. Where it does not, default to the + // RESTRICTIVE value: this field is inert when approval_required is false, + // and `true` is pinned by the schema when approval_required is true, so the + // default can only ever apply to the inert case. Recorded in ADR 0079. + const declaredPathAllowed = strictBoolean(tierBlock?.approval_path_allowed); + if (declaredPathAllowed === 'invalid') { + return reject('rule_projection', `tiers.${tier}.approval_path_allowed must be a boolean`); + } + + // Ceiling is declared per class (details) and per tier. Prefer the class. + // + // `not_applicable` is a sentinel the policy itself declares + // (tiers.read-safe / write-local / forbidden). Those tiers declare no + // `_source_ref`, because a ceiling that does not apply has no source. The + // sentinel therefore PROPAGATES to the ref — that is reading the policy's + // own vocabulary, not authoring a default. Any other ceiling value must + // carry a declared ref or the projection rejects. Recorded in ADR 0079. + const ceiling = details?.valid_until_ceiling ?? tierBlock?.valid_until_ceiling; + const declaredRef = + details?.valid_until_ceiling_source_ref ?? tierBlock?.valid_until_ceiling_source_ref; + const ceilingRef = declaredRef ?? (ceiling === 'not_applicable' ? 'not_applicable' : undefined); + + const flag = (key: string): boolean | RejectedRules => { + const v = strictBoolean(rawRule[key]); + if (v === 'invalid') return reject('rule_projection', `${at(key)} must be a boolean`); + return v ?? false; + }; + const activeLease = flag('requires_active_lease'); + if (typeof activeLease !== 'boolean') return activeLease; + const deletionAuthority = flag('requires_deletion_authority'); + if (typeof deletionAuthority !== 'boolean') return deletionAuthority; + + const evidence = rawRule.required_pre_execution_evidence; + if (evidence !== undefined && !isRecord(evidence)) { + return reject( + 'rule_projection', + `${at('required_pre_execution_evidence')} is present but not a mapping`, + ); + } + const typedEvidence = strictBoolean(evidence?.typed_provider_evidence_required); + if (typedEvidence === 'invalid') { + return reject( + 'rule_projection', + `${at('required_pre_execution_evidence.typed_provider_evidence_required')} must be a boolean`, + ); + } + + const approval = approvalRequired + ? { + approval_required: true, + approval_path_allowed: true, // schema-pinned for the required branch + required_grant_kind: details?.required_grant_kind, + allowed_grant_kinds: isRecord(details?.grant_kind_compatibility) + ? (details.grant_kind_compatibility as Record).allowed_grant_kinds + : undefined, + producer_allowlist: details?.producer_allowlist, + dashboard_visibility: details?.dashboard_visibility, + single_use: details?.single_use, + evidence_bound_scope: details?.evidence_bound_scope, + } + : { approval_required: false, approval_path_allowed: declaredPathAllowed ?? false }; + + const candidate = { + schema_version: policyRuleSchemaVersion, + policy_rule_id: `policy-rule:hcs:${operationClass.replaceAll('_', '-')}`, + operation_class: operationClass, + tier, + classification_basis: rawRule.primary_classification, + requires_active_lease: activeLease, + requires_deletion_authority: deletionAuthority, + requires_typed_provider_evidence: typedEvidence ?? false, + approval, + valid_until_ceiling: ceiling, + valid_until_ceiling_source_ref: ceilingRef, + source_provenance: { + authority: 'system_config_live_policy', + source_policy_path: sourcePolicyPath.canonical_path, + source_policy_sha256: observedDigest, + source_policy_sha256_basis: 'live_policy_blob', + observed_at: provenance.approved_at, + }, + }; + + const parsed = policyRuleSchema.safeParse(candidate); + if (!parsed.success) { + const first = parsed.error.issues[0]; + const path = first && first.path.length > 0 ? first.path.join('.') : ''; + return reject( + 'rule_projection', + `PolicyRule projection for ${operationClass} failed at ${path}: ${first?.message ?? 'unknown'}`, + ); + } + rules.push(parsed.data); + } + + // Exact set equality against the Ring-0 enum — BOTH directions. A snapshot + // that quietly drops a class must not load with fewer rules. + const declared = new Set(operationShapeOperationClassSchema.options); + const present = new Set(rules.map((r) => r.operation_class)); + const missing = [...declared].filter((c) => !present.has(c)).sort(); + const extra = [...present].filter((c) => !declared.has(c)).sort(); + if (missing.length > 0 || extra.length > 0) { + return reject( + 'rule_projection', + `operation_class coverage mismatch — missing: [${missing.join(', ')}]; unexpected: [${extra.join(', ')}]`, + ); + } + + return { ok: true, observedDigest, policyRuleSchemaVersion, rules }; +} diff --git a/packages/kernel/tests/api-surface.test.ts b/packages/kernel/tests/api-surface.test.ts index 3e8fabf..2f91a5d 100644 --- a/packages/kernel/tests/api-surface.test.ts +++ b/packages/kernel/tests/api-surface.test.ts @@ -1,4 +1,5 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -24,6 +25,7 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name: string; type: string; exports: Record; + dependencies?: Record; }; describe('@hcs/kernel public API surface', () => { @@ -46,15 +48,67 @@ describe('@hcs/kernel public API surface', () => { } }); + it('declares every third-party module its source imports', () => { + // A phantom dependency — importing a package the manifest does not declare — + // resolves locally through workspace hoisting and fails in CI under `npm ci`. + // That happened once already: `yaml` was imported with no declaration, local + // verify passed on stale node_modules, and CI failed at typecheck. + // + // Derived from the source, not hand-listed, so it cannot drift. + const srcDir = fileURLToPath(new URL('../src', import.meta.url)); + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts')) files.push(full); + } + }; + walk(srcDir); + + const declared = new Set(Object.keys(manifest.dependencies ?? {})); + const imported = new Set(); + for (const file of files) { + for (const m of readFileSync(file, 'utf8').matchAll(/from '([^']+)'/g)) { + const spec = m[1]; + if (spec === undefined) continue; + if (spec.startsWith('.') || spec.startsWith('node:')) continue; + // Scoped packages keep two segments; bare packages keep one. + const pkg = spec.startsWith('@') + ? spec.split('/').slice(0, 2).join('/') + : spec.split('/')[0]; + if (pkg !== undefined) imported.add(pkg); + } + } + + expect(imported.size).toBeGreaterThan(0); + for (const pkg of imported) { + expect( + declared, + `${pkg} is imported but not declared in packages/kernel/package.json`, + ).toContain(pkg); + } + }); + it('is an ESM package named @hcs/kernel', () => { expect(manifest.name).toBe('@hcs/kernel'); expect(manifest.type).toBe('module'); }); - it('exposes no runtime symbols yet — the scaffold is deliberately empty', async () => { + it('publishes exactly the intended runtime symbols', async () => { const api = await import('../src/api/index.ts'); - // Ring-1 services land here one at a time, each with its own ADR. The first - // is the read-only policy-snapshot loader (ADR 0060 / ADR 0061). - expect(Object.keys(api)).toEqual([]); + // 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']); + }); + + it('exports no mint, append, consume, or revoke symbol', async () => { + // charter inv. 4 bars an agent-callable audit-write surface and inv. 7 gates + // callability of mutating capabilities. Ring 2 can only reach what this + // barrel publishes, so keeping those verbs off it is structural. + const api = await import('../src/api/index.ts'); + for (const name of Object.keys(api)) { + expect(name).not.toMatch(/^(mint|append|consume|revoke)/i); + } }); }); diff --git a/packages/kernel/tests/policy-rule-loader.test.ts b/packages/kernel/tests/policy-rule-loader.test.ts new file mode 100644 index 0000000..00fc982 --- /dev/null +++ b/packages/kernel/tests/policy-rule-loader.test.ts @@ -0,0 +1,245 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { policyRuleTierSchema } from '@hcs/schemas'; +import { afterAll, describe, expect, it } from 'vitest'; +import { LOADER_CHECKPOINTS, loadPolicyRules } from '../src/policy/rule-loader.ts'; + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const realSnapshot = join(repoRoot, 'policies/generated-snapshot/tiers.yaml'); +const body = () => readFileSync(realSnapshot, 'utf8'); + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +/** Stage a mutated snapshot under $TMPDIR. Never mutates the repo. */ +function stage(snapshotBody: string): string { + const dir = mkdtempSync(join(tmpdir(), 'hcs-rules-')); + dirs.push(dir); + const p = join(dir, 'tiers.yaml'); + writeFileSync(p, snapshotBody); + return p; +} + +function load(snapshotBody?: string) { + return loadPolicyRules({ + snapshotPath: snapshotBody === undefined ? realSnapshot : stage(snapshotBody), + }); +} + +/** + * Apply a mutation ONLY inside `operation_class_defaults:`. + * + * Live policy declares approval posture in THREE places — `tiers.`, + * `operation_class_defaults.`, and that class's + * `approval_required_details.status`. An unscoped regex hits `tiers:` first + * (line ~220) and corrupts a block the loader does not read, so the test + * passes for the wrong reason. + */ +function mutateDefaults(pattern: RegExp, replacement: string): string { + const src = body(); + const marker = '\noperation_class_defaults:'; + const at = src.indexOf(marker); + if (at < 0) throw new Error('operation_class_defaults not found'); + const head = src.slice(0, at); + const tail = src.slice(at); + const mutated = tail.replace(pattern, replacement); + if (mutated === tail) throw new Error(`mutation did not apply: ${pattern}`); + return head + mutated; +} + +function rejectedAt(r: ReturnType) { + return r.ok ? 'ok' : r.rejectedAt; +} + +describe('happy path — every value is read from a declared field', () => { + it('loads all eight operation classes from the real snapshot', () => { + const r = load(); + if (!r.ok) throw new Error(`expected success: ${r.reason}`); + expect(r.rules).toHaveLength(8); + }); + + it('reads approval posture from BOTH forms policy uses', () => { + const r = load(); + if (!r.ok) throw new Error(r.reason); + const by = Object.fromEntries(r.rules.map((x) => [x.operation_class, x])); + // Declared via `approval_required: false` + expect(by.read_only_diagnostic?.approval.approval_required).toBe(false); + // Declared via `approval_required_details.status: required` + expect(by.merge_or_push?.approval.approval_required).toBe(true); + }); + + it('reads real ceilings from the tiers block instead of authoring not_applicable', () => { + const r = load(); + if (!r.ok) throw new Error(r.reason); + const by = Object.fromEntries(r.rules.map((x) => [x.operation_class, x])); + expect(by.worktree_mutation?.valid_until_ceiling).toBe('PT24H'); + expect(by.destructive_git?.valid_until_ceiling).toBe('PT1H'); + expect(by.read_only_diagnostic?.valid_until_ceiling).toBe('not_applicable'); + }); +}); + +/** + * THE FAIL-CLOSED CLASS. + * + * Five independent sites in this repo shared one rule — absence projects to + * permission: 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, and this loader's earlier + * draft reading a null approval block as `approval_required: false`. + * + * Every case below feeds the null / absent / empty / wrong-type variant of a + * security-relevant field and asserts it REJECTS. This is the class, not the + * instance. + */ +describe('fail-closed: absence and malformation never project to permission', () => { + it('rejects a NULL approval_required_details — the withdrawn draft returned ok:true here', () => { + // `approval_required_details:` with no value parses to null. The old code + // read that as "absent" and projected approval_required: false on a + // write-destructive class. + const mutated = mutateDefaults( + /^ {4}approval_required_details:$/m, + ' approval_required_details: null\n _orig:', + ); + expect(rejectedAt(load(mutated))).toBe('rule_projection'); + }); + + it.each([ + ['a string', ' approval_required_details: "required"\n _orig:'], + ['an array', ' approval_required_details: []\n _orig:'], + ['a boolean', ' approval_required_details: true\n _orig:'], + ])('rejects approval_required_details declared as %s', (_label, replacement) => { + const mutated = mutateDefaults(/^ {4}approval_required_details:$/m, replacement); + expect(rejectedAt(load(mutated))).toBe('rule_projection'); + }); + + it('rejects when policy declares NEITHER approval_required NOR a details status', () => { + // Strip the status line from the first approval block; approval_required is + // already absent on those classes. Policy then says nothing at all. + // `status:` sits at indent 6, nested under approval_required_details. + const mutated = mutateDefaults(/^ {6}status: required$/m, ' _removed: required'); + expect(rejectedAt(load(mutated))).toBe('rule_projection'); + }); + + it('rejects a boolean-like STRING for approval_required', () => { + const mutated = mutateDefaults( + /^ {4}approval_required: false$/m, + ' approval_required: "false"', + ); + expect(rejectedAt(load(mutated))).toBe('rule_projection'); + }); + + it.each([ + 'requires_active_lease', + 'requires_deletion_authority', + ])('rejects a boolean-like STRING for %s', (field) => { + const mutated = mutateDefaults(new RegExp(`^ {4}${field}: true$`, 'm'), ` ${field}: "true"`); + expect(rejectedAt(load(mutated))).toBe('rule_projection'); + }); + + it('rejects a null required_pre_execution_evidence rather than clearing the inv-16 flag', () => { + const mutated = mutateDefaults( + /^ {4}required_pre_execution_evidence:$/m, + ' required_pre_execution_evidence: null\n _orig:', + ); + expect(rejectedAt(load(mutated))).toBe('rule_projection'); + }); + + it('rejects an EMPTY operation_class_defaults rather than returning ok with zero rules', () => { + const mutated = body().replace( + /^operation_class_defaults:$/m, + 'operation_class_defaults: {}\n_orig_operation_class_defaults:', + ); + const r = load(mutated); + expect(r.ok).toBe(false); + expect(r).not.toHaveProperty('rules'); + }); + + it('rejects a snapshot that DROPS an operation class — coverage is checked both ways', () => { + const mutated = mutateDefaults(/^ {2}destructive_git:$/m, ' _removed_destructive_git:'); + const r = load(mutated); + expect(rejectedAt(r)).toBe('rule_projection'); + if (!r.ok) expect(r.reason).toContain('destructive_git'); + }); +}); + +describe('checkpoint ordering is observable', () => { + it('declares checkpoints in execution order', () => { + expect([...LOADER_CHECKPOINTS]).toEqual(['parse', 'schema_refs', 'rule_projection']); + }); + + it('rejects unparseable YAML at parse', () => { + expect(rejectedAt(load('this: [is: not: valid'))).toBe('parse'); + }); + + it('rejects duplicate keys at parse rather than silently taking the last', () => { + expect(rejectedAt(load('a: 1\na: 2\n'))).toBe('parse'); + }); + + it('reaches schema_refs only once parse succeeds', () => { + expect(rejectedAt(load('kind: something\n'))).toBe('schema_refs'); + }); +}); + +describe('error hygiene — reasons classify, never echo content', () => { + it('does not leak snapshot content or absolute paths into the reason', () => { + const marker = 'op://vault/item/field'; + const r = load(`this: [is: not: valid ${marker}`); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).not.toContain(marker); + expect(r.reason).not.toContain('/Users/'); + expect(r.reason).not.toContain(tmpdir()); + }); + + it('classifies a missing file without echoing its path', () => { + const r = loadPolicyRules({ snapshotPath: '/nonexistent/hcs/definitely-not-here.yaml' }); + expect(rejectedAt(r)).toBe('parse'); + if (!r.ok) { + expect(r.reason).toContain('ENOENT'); + expect(r.reason).not.toContain('/nonexistent'); + } + }); +}); + +/** + * inv. 1 — the loader must not author policy. + * + * The withdrawn draft's version of this guard hardcoded four strings, two of + * which (`read-only`, `write-scoped`) are not tiers in this system, and missed + * three that are. It would have passed a loader hardcoding `tier: 'read-safe'`. + * Deriving the list from the enum makes it impossible for the guard to drift + * from the vocabulary it guards. + */ +describe('inv. 1 — no policy vocabulary authored in kernel source', () => { + // Strip comments before scanning. The module's design prose legitimately + // names tiers when explaining WHY a value is read rather than authored; the + // guard's subject is executable code, where a tier literal would mean the + // kernel had taken a classification decision. + const source = readFileSync( + fileURLToPath(new URL('../src/policy/rule-loader.ts', import.meta.url)), + 'utf8', + ) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + + it('guards every tier the enum declares, derived not hand-written', () => { + expect(policyRuleTierSchema.options).toHaveLength(5); + for (const tier of policyRuleTierSchema.options) { + expect(source).not.toContain(`'${tier}'`); + expect(source).not.toContain(`"${tier}"`); + expect(source).not.toContain(`\`${tier}\``); + } + }); + + it('encodes no tier-to-approval mapping', () => { + // The loader may name `approval_required` as a FIELD it reads; it must not + // pair a tier name with an approval verdict. + for (const tier of policyRuleTierSchema.options) { + expect(source).not.toContain(tier); + } + }); +});