create-agent-rig 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,7 +9,145 @@ Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/pack
9
9
 
10
10
  Numbering is ordinary semver — **additive is a minor, a fix is a patch** — so
11
11
  that "I only take minors" remains a usable policy; 0.3.2 shipped additive
12
- content as a patch by the owner's call and stays recorded as one.
12
+ content as a patch by the owner's call and stays recorded as one. 0.8.0 is the
13
+ second recorded departure; its own entry states the direction and the reason,
14
+ and this paragraph deliberately does not restate them — a numbering rule with
15
+ two copies of its exceptions is the shape 0.8.0 exists to remove.
16
+
17
+ ## 0.8.0
18
+
19
+ **Three stale second copies, spread over three payload files a rig obeys,
20
+ described the mechanisms behind them wrongly — and each fix is a deletion.** A
21
+ count and a path list, both in `.claude/rules/autonomy.md`, and a paraphrase of
22
+ that same path list in `loop/SKILL.md`, which ships in both harnesses' copies.
23
+ Every one of them was a second writing of a fact the code owns, and every one
24
+ had drifted from its source while the suite stayed green.
25
+
26
+ **The `templates/agent-os/` payload gains no file and loses none**, measured
27
+ tarball to tarball — `npm pack create-agent-rig@0.7.1` unpacked, against this
28
+ release's own `npm pack`. That payload is the same 95 files either way, and
29
+ exactly three of them differ in content. ⚠ The tarball as a whole is **not**
30
+ unchanged: it grows by eleven files, and the section below names which and why.
31
+
32
+ **Numbered a minor by the owner's call, not by this file's rule.** The rule at
33
+ the top — additive is a minor, a fix is a patch — makes this delta a patch,
34
+ because nothing is added to what a project installs. It ships as `0.8.0`
35
+ because the owner's milestone of that name closes here and the number was fixed
36
+ before the delta was measured. Recorded rather than reconciled, the way `0.3.2`
37
+ is recorded above: a consumer on "I only take minors" receives three corrected
38
+ documents and no new capability.
39
+
40
+ ### Fixed
41
+
42
+ - **`.claude/rules/autonomy.md` stated a blind-spot count `guard-secret-file`
43
+ had outgrown.** The Never bullet said the guard's header "states the four
44
+ blind spots"; the header had been raised past four in `51402e99`, and the
45
+ stale copy shipped in 0.6.1, 0.6.2, 0.7.0 and 0.7.1 — a security rule
46
+ describing its own mechanism wrongly for four releases. The fix is not a
47
+ corrected number, which would only restart the same clock: the prose states
48
+ no count and points at the header, and `README.md` moved with it for the same
49
+ reason. Pinned in the generator's `test/template/guard-secret-file.test.ts`
50
+ (absent in a generated rig) › "no live rulebook document restates the guard’s
51
+ limit count" and › "the rule and the README still send the reader to the
52
+ guard’s own header".
53
+
54
+ - **The same file re-listed the paths `guard-rulebook` protects, and its list
55
+ was incomplete.** The enumeration of trees an unattended run may not edit
56
+ omitted `.claude/doctor-exemptions.json`, which the guard does refuse. It is
57
+ replaced by a pointer to `RULEBOOK_PREFIXES` in
58
+ `.claude/scripts/unattended-flag.mjs` — the set the guard judges an edit
59
+ against — so there is one spelling of it rather than two. One fact that
60
+ pointer cannot carry is stated beside it, because it does not follow from the
61
+ set: the checkout board selector is refused **even when an item's allow-list
62
+ names it**.
63
+
64
+ - **`loop/SKILL.md` paraphrased that set too, in both harnesses' copies.** The
65
+ step where a session composes an allow-list summarised the protected rulebook
66
+ in prose, so the one moment the set is read in anger was the moment it was
67
+ read from a summary that had already drifted. It now names
68
+ `RULEBOOK_PREFIXES` and says to open it. Both copies carry the change — the
69
+ Claude skill at `.claude/skills/loop/SKILL.md` and its Codex projection at
70
+ `.agents/skills/loop/SKILL.md`.
71
+
72
+ ### Inside the generator, and not inside a rig
73
+
74
+ `packages/cli/src/policy/` is new — a typed policy declaration, a registry, a
75
+ decision-record schema and one adapter per harness. Its compiled output adds
76
+ eleven files to the published tarball, 245 → 256. **No command imports it**, so nothing a
77
+ project scaffolded from this release does comes from it. What such a project
78
+ does get from this release is the three corrected documents above — and an
79
+ installed 0.7.1 rig still needs `create-agent-rig upgrade` to receive them —
80
+ which is a necessary condition and not a sufficient one, because a rig whose
81
+ owner has edited one of those three files is handed a conflict to merge rather
82
+ than an overwrite.
83
+ This module is named here only because a reader diffing the two tarballs sees
84
+ eleven new files and is owed the reason they are not part of that answer.
85
+
86
+ `templates/release-ledger.json` carries `0.7.1` at `52e879b6`, the commit it was
87
+ published from, read from `npm view create-agent-rig@0.7.1 gitHead` and verified
88
+ an ancestor of `master`. `templates/hash-history.json` is regenerated from it and
89
+ now covers eleven releases, `0.2.0` through `0.7.1`, so `upgrade` can tell an
90
+ untouched file from an edited one in a rig installed from 0.7.1.
91
+
92
+ ## 0.7.1
93
+
94
+ **The gate could not be run on work that has no queue item.** `pr-ship` names
95
+ owner-directed work and hotfixes with no item as a legitimate path — step 4
96
+ tells the fan-out to declare it and have the reviewer skip the item-contract
97
+ check openly. Step 1 then made that path unexecutable: it called
98
+ `revalidate.mjs` with an unconditional `--ticket`, and the script refused
99
+ without one. A newly scaffolded project inherited a rulebook that contradicted
100
+ itself at the one checkpoint before every PR, so the first hotfix in a fresh rig
101
+ had nothing it was allowed to do. Found downstream while integrating published
102
+ 0.7.0.
103
+
104
+ A patch: no file is added or removed, no new dependency, and the public CLI of
105
+ the generator is untouched. What changes is one flag on one internal script and
106
+ the skill step that calls it.
107
+
108
+ ### Fixed
109
+
110
+ - **`revalidate.mjs` BEFORE_PR now has two modes, and neither is inferred.**
111
+ `--ticket <key>` is unchanged, including the mandatory claim comparison.
112
+ `--owner-directed` runs the same default-branch drift comparison for work
113
+ with no item, reaching no tracker, no adapter and no claim record — so it
114
+ needs no tracker credentials. Passing both flags, or neither, is exit 1: a
115
+ mode chosen by absence is a mode nobody reviewed.
116
+
117
+ It is not a lighter checkpoint. A default-branch change under a path the
118
+ branch touches, or one a `check-premises` record cited, holds with the same
119
+ exit 2. What it drops is the claim comparison, because work with no item has
120
+ no claim to compare, and it records `ticket: null` rather than inventing an
121
+ id.
122
+
123
+ **Four refusals keep it from becoming a bypass** — exit 1, nothing
124
+ journalled: when the run carries an unresolved `revalidationHold`, when the
125
+ run declares a take-up, when the branch touches a tracked
126
+ `.rig/claims/*.json` in any direction (added, modified, removed or renamed),
127
+ and at `BEFORE_CLOSE`. The first is the one that makes re-running a held or
128
+ `UNVERIFIABLE` ticketed call in this mode a refusal rather than a way past
129
+ it; the run's stop inputs are read fail-closed, so an unreadable
130
+ `state.json` refuses instead of reading as an empty run.
131
+
132
+ An owner-directed HOLD is answered the same way a ticketed one is, with
133
+ `revalidate.mjs outcome` at the same point, passing `--owner-directed`
134
+ instead of `--ticket`. It addresses the detection by mode, since that
135
+ detection carries no ticket to name.
136
+
137
+ ⚠ Its stated limits, because a governance mode is trusted as far as it is
138
+ described. Nothing can prove an item does not exist. With no `RIG_RUN_DIR`
139
+ there is no run state, so the hold and take-up refusals cannot fire — the
140
+ command says so on stdout and in `evidence.runState` rather than reporting a
141
+ clean check. The claim refusal reads the branch diff, so a record already on
142
+ the default branch or not yet committed is not seen. And `--base` is the sole
143
+ authority for the verdict here, the claim comparison that would otherwise
144
+ survive a wrong base being absent.
145
+
146
+ - **`pr-ship` step 1 states both paths**, and step 4 now spells the words the
147
+ fan-out is launched with — `no item — owner-directed` — and says that this
148
+ skips the item-contract check and **nothing else**: the checks, the routing,
149
+ the security, code and prose/governance reviews, the coverage check and the
150
+ DoD all still run.
13
151
 
14
152
  ## 0.7.0
15
153
 
@@ -56,6 +194,31 @@ a patch is the case the rule exists to prevent.
56
194
  was allowed on the original surface too — and it is unchanged by this
57
195
  release.
58
196
 
197
+ - **A shell command the two shell guards cannot read is refused, where it used
198
+ to be allowed.** `guard-bash` and `block-no-verify` read the `command` a hook
199
+ hands them on a shell tool, and asked only whether it was a string. A command
200
+ that was **there** in some other container — an array of argv words, an
201
+ object — failed that test and returned _allow_ before the kill switch was ever
202
+ consulted, so restating a forbidden command in another shape stepped over an
203
+ armed brake. Those two guards now decide the three outcomes in one shared
204
+ place: an **absent** command still allows, because there is nothing to judge;
205
+ a **string** is inspected as before; a command that is present in a shape they
206
+ cannot read is **refused**, naming the shape they expected.
207
+
208
+ ⚠ The scope is those two guards, and it is narrow on purpose — it is **not** a
209
+ repository-wide ruling on the word `command`. `guard-secret-file` reads an
210
+ `apply_patch` `command` that is a _list of strings_, exactly the shape this
211
+ contract calls unreadable, and it is right to; routing a third guard through
212
+ the same place without checking what its tool actually sends would start
213
+ refusing input another guard exists to read.
214
+
215
+ ⚠ Two further limits. This changes only the unreadable case — the name-exact
216
+ bound described just above is untouched, and an absent field remains fail-open
217
+ on purpose, since a guard that blocked when handed nothing would be turned off
218
+ within the hour. And the **edit** surfaces are not part of this: what
219
+ `.claude/hooks/lib/edit-input.mjs` does with a `tool_input` it cannot read is
220
+ unchanged by this release.
221
+
59
222
  - **An adapter it cannot read is `UNVERIFIABLE`, not a stack trace.** A
60
223
  revalidation whose queue adapter could not be reached exited on a raw Node
61
224
  stack trace, which a caller could read as noise rather than as a hold. It now
package/README.md CHANGED
@@ -116,7 +116,7 @@ wired by both `.claude/settings.json` and `.codex/hooks.json`:
116
116
  carries a credential VALUE. Both arms read one vocabulary,
117
117
  `.claude/scripts/lib/secrets.mjs`, and a refusal names the pattern and the line
118
118
  and **never the matched value** — printing it would leak the secret in the act
119
- of refusing it. Its four blind spots are in its own header, each naming the
119
+ of refusing it. Its blind spots are in its own header, each naming the
120
120
  test that pins it or saying plainly that none does — and those tests live in
121
121
  this generator, not in the rig;
122
122
  - **`block-no-verify`** — refuses bypassing pre-commit checks (and knows the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The contract a harness adapter satisfies (RP-76).
3
+ *
4
+ * The core knows that a harness exists and has a hook-wiring surface; it does
5
+ * not know any harness's name, event vocabulary, tool names or paths. Those
6
+ * belong to the adapter, one module per harness under `../harness/`, and
7
+ * adding a harness means adding one such module and registering it — pinned in
8
+ * `test/template/policy-declaration.test.ts` under "adding a harness touches
9
+ * adapters only", whose two tests name exactly the files that may mention each
10
+ * harness. Their names carry the harness words this file may not, which is why
11
+ * the describe is cited here rather than either test.
12
+ *
13
+ * An adapter is a mapping, not a compiler: given a declaration it names the
14
+ * event, matcher and hook path the harness wires for it. The correspondence
15
+ * between that answer and the snapshot the rig actually ships is a test, in
16
+ * both directions, in the same file.
17
+ */
18
+ export {};
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The decision-record schema (RP-76): what one verdict of one policy on one
3
+ * harness must carry to be audited later, and the validator that refuses a
4
+ * record which would read as more than it is.
5
+ *
6
+ * Emitting these at runtime is a separate task; this file is the shape and the
7
+ * rules. The rules that go beyond "the field is in its vocabulary":
8
+ *
9
+ * - the policy must be registered and the version compatible with it, so the
10
+ * record names semantics a reader can look up;
11
+ * - the operation must be one the policy declares;
12
+ * - a qualifier (`UNVERIFIABLE`, `UNMEASURED`) must carry a reason;
13
+ * - a capability state of `UNSUPPORTED` or `INTEGRATION-FAILED` must qualify
14
+ * the verdict `UNVERIFIABLE` — an unenforceable policy never yields a silent
15
+ * pass (`./vocabulary.ts`, `CAPABILITY_STATES`);
16
+ * - every evidence kind the policy requires must be present;
17
+ * - a policy that redacts must not be recorded with unredacted diagnostics;
18
+ * - the timestamp is supplied by the caller and must be an ISO-8601 date-time
19
+ * with seconds and an explicit zone (`ISO_8601` below; a bare date is
20
+ * refused) — no clock here.
21
+ *
22
+ * Each rule is one test in `packages/cli/test/policy-declaration.test.ts`
23
+ * under "validating a decision record".
24
+ *
25
+ * ⚠ `diagnostics.redacted` is the emitter's claim, and this validator enforces
26
+ * the claim's presence, not the property: a record marked redacted whose
27
+ * `diagnostics.text`, `observedFacts[].value` or `evidence[].value` still
28
+ * carries a matched value is accepted here. Scanning content would pull the
29
+ * secret vocabulary into the core, which the dependency-direction test
30
+ * forbids — so the emitting task owns that scan, over those three fields,
31
+ * before it persists a record.
32
+ */
33
+ import { compatibilityOf, findPolicy } from './registry.js';
34
+ import { CAPABILITY_STATES, DECISION_OUTCOMES, EVIDENCE_KINDS, OPERATIONS, VERDICT_QUALIFIERS, } from './vocabulary.js';
35
+ import { isRecord, member, nonEmptyString, unknownKeys } from './validation.js';
36
+ export const DECISION_RECORD_SCHEMA_VERSION = 1;
37
+ const KEYS = [
38
+ 'schemaVersion',
39
+ 'policyId',
40
+ 'policyVersion',
41
+ 'harness',
42
+ 'operation',
43
+ 'capabilityState',
44
+ 'observedFacts',
45
+ 'verdict',
46
+ 'evidence',
47
+ 'artifactVersion',
48
+ 'diagnostics',
49
+ 'recordedAt',
50
+ ];
51
+ const NEVER_SILENT_PASS = ['UNSUPPORTED', 'INTEGRATION-FAILED'];
52
+ /** Date, `T`, time to the second (fractions allowed), and an explicit zone. */
53
+ const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
54
+ const namedPairs = (problems, field, value, keys) => {
55
+ if (!Array.isArray(value)) {
56
+ problems.push({ field, message: 'must be a list' });
57
+ return;
58
+ }
59
+ value.forEach((entry, index) => {
60
+ if (!isRecord(entry)) {
61
+ problems.push({ field: `${field}[${index}]`, message: 'must be an object' });
62
+ return;
63
+ }
64
+ unknownKeys(problems, entry, keys, `${field}[${index}]`);
65
+ for (const key of keys)
66
+ nonEmptyString(problems, `${field}[${index}].${key}`, entry[key]);
67
+ });
68
+ };
69
+ const checkVerdict = (problems, value, capabilityState) => {
70
+ if (!isRecord(value)) {
71
+ problems.push({ field: 'verdict', message: 'must be an object' });
72
+ return;
73
+ }
74
+ unknownKeys(problems, value, ['outcome', 'qualifier', 'reason'], 'verdict');
75
+ member(problems, 'verdict.outcome', value.outcome, DECISION_OUTCOMES);
76
+ const qualified = 'qualifier' in value;
77
+ if (qualified) {
78
+ member(problems, 'verdict.qualifier', value.qualifier, VERDICT_QUALIFIERS);
79
+ if (typeof value.reason !== 'string' || value.reason.trim() === '') {
80
+ problems.push({
81
+ field: 'verdict.reason',
82
+ message: `a ${String(value.qualifier)} verdict must say why`,
83
+ });
84
+ }
85
+ }
86
+ else if ('reason' in value && typeof value.reason !== 'string') {
87
+ problems.push({ field: 'verdict.reason', message: 'must be a string when present' });
88
+ }
89
+ if (capabilityState !== null && NEVER_SILENT_PASS.includes(capabilityState)) {
90
+ if (value.qualifier !== 'UNVERIFIABLE') {
91
+ problems.push({
92
+ field: 'verdict.qualifier',
93
+ message: `capabilityState ${capabilityState} never yields a silent pass: ` +
94
+ 'the verdict must be qualified UNVERIFIABLE with a reason',
95
+ });
96
+ }
97
+ }
98
+ };
99
+ const checkEvidence = (problems, value, required) => {
100
+ if (!Array.isArray(value)) {
101
+ problems.push({ field: 'evidence', message: 'must be a list' });
102
+ return;
103
+ }
104
+ const present = new Set();
105
+ value.forEach((entry, index) => {
106
+ if (!isRecord(entry)) {
107
+ problems.push({ field: `evidence[${index}]`, message: 'must be an object' });
108
+ return;
109
+ }
110
+ unknownKeys(problems, entry, ['kind', 'value'], `evidence[${index}]`);
111
+ if (member(problems, `evidence[${index}].kind`, entry.kind, EVIDENCE_KINDS)) {
112
+ present.add(entry.kind);
113
+ }
114
+ nonEmptyString(problems, `evidence[${index}].value`, entry.value);
115
+ });
116
+ for (const kind of required) {
117
+ if (!present.has(kind)) {
118
+ problems.push({ field: 'evidence', message: `the policy requires ${kind} evidence` });
119
+ }
120
+ }
121
+ };
122
+ const checkDiagnostics = (problems, value, mustRedact) => {
123
+ if (!isRecord(value)) {
124
+ problems.push({ field: 'diagnostics', message: 'must be an object' });
125
+ return;
126
+ }
127
+ unknownKeys(problems, value, ['redacted', 'text'], 'diagnostics');
128
+ if (typeof value.redacted !== 'boolean') {
129
+ problems.push({ field: 'diagnostics.redacted', message: 'must be a boolean' });
130
+ }
131
+ else if (mustRedact && !value.redacted) {
132
+ problems.push({
133
+ field: 'diagnostics.redacted',
134
+ message: 'the policy redacts matched values, so its diagnostics must be recorded redacted',
135
+ });
136
+ }
137
+ if (typeof value.text !== 'string') {
138
+ problems.push({ field: 'diagnostics.text', message: 'must be a string' });
139
+ }
140
+ };
141
+ /** Validate an unknown value as a decision record, reporting every problem at once. */
142
+ export function validateDecisionRecord(input) {
143
+ if (!isRecord(input)) {
144
+ return { ok: false, problems: [{ field: '', message: 'a decision record is an object' }] };
145
+ }
146
+ const problems = [];
147
+ unknownKeys(problems, input, KEYS);
148
+ if (input.schemaVersion !== DECISION_RECORD_SCHEMA_VERSION) {
149
+ problems.push({
150
+ field: 'schemaVersion',
151
+ message: `must be ${DECISION_RECORD_SCHEMA_VERSION}, got ${String(input.schemaVersion)}`,
152
+ });
153
+ }
154
+ const policy = typeof input.policyId === 'string' ? findPolicy(input.policyId) : null;
155
+ if (policy === null) {
156
+ problems.push({
157
+ field: 'policyId',
158
+ message: `${JSON.stringify(input.policyId)} is not a registered policy`,
159
+ });
160
+ }
161
+ else if (typeof input.policyVersion === 'string') {
162
+ const compatibility = compatibilityOf(policy.policyId, input.policyVersion);
163
+ if (compatibility !== 'compatible') {
164
+ problems.push({
165
+ field: 'policyVersion',
166
+ message: `${JSON.stringify(input.policyVersion)} is ${compatibility} with ${policy.policyId} ${policy.policyVersion}`,
167
+ });
168
+ }
169
+ }
170
+ else {
171
+ nonEmptyString(problems, 'policyVersion', input.policyVersion);
172
+ }
173
+ nonEmptyString(problems, 'harness', input.harness);
174
+ if (member(problems, 'operation', input.operation, OPERATIONS) && policy !== null) {
175
+ if (!policy.operations.includes(input.operation)) {
176
+ problems.push({
177
+ field: 'operation',
178
+ message: `${policy.policyId} does not apply to ${input.operation}`,
179
+ });
180
+ }
181
+ }
182
+ const capabilityState = member(problems, 'capabilityState', input.capabilityState, CAPABILITY_STATES)
183
+ ? input.capabilityState
184
+ : null;
185
+ namedPairs(problems, 'observedFacts', input.observedFacts, ['name', 'value']);
186
+ checkVerdict(problems, input.verdict, capabilityState);
187
+ checkEvidence(problems, input.evidence, policy?.requiredEvidence ?? []);
188
+ nonEmptyString(problems, 'artifactVersion', input.artifactVersion);
189
+ checkDiagnostics(problems, input.diagnostics, policy !== null && policy.redaction !== 'none');
190
+ if (typeof input.recordedAt !== 'string' ||
191
+ !ISO_8601.test(input.recordedAt) ||
192
+ Number.isNaN(Date.parse(input.recordedAt))) {
193
+ problems.push({
194
+ field: 'recordedAt',
195
+ message: `must be an ISO-8601 date-time with seconds and an explicit zone, got ${JSON.stringify(input.recordedAt)}`,
196
+ });
197
+ }
198
+ if (problems.length > 0)
199
+ return { ok: false, problems };
200
+ return { ok: true, value: input };
201
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The typed policy declaration (RP-76).
3
+ *
4
+ * A declaration is the semantic source of one policy: what the invariant is,
5
+ * where it applies, what the mechanism can answer, how it fails, what a
6
+ * verdict must carry as evidence, and which version of those semantics a
7
+ * decision record is talking about. It is data plus a validator — not a policy
8
+ * language, not an interpreter, not a compiler: a harness adapter reads a
9
+ * declaration and names its own native surface for it (`./adapter.ts`), and
10
+ * that is the whole of the transformation.
11
+ *
12
+ * The shape is closed: a field this interface does not name is refused, so a
13
+ * field added by mistake cannot travel unnoticed into a record somebody later
14
+ * audits. Every enumerated field draws from `./vocabulary.ts`.
15
+ */
16
+ import { AUTONOMY_TIERS, DECISION_OUTCOMES, ENFORCEMENT_TIMINGS, EVIDENCE_KINDS, FAILURE_SEMANTICS, HARNESS_CAPABILITIES, LIFECYCLE_STATES, OPERATIONS, REDACTION_RULES, } from './vocabulary.js';
17
+ import { isRecord, matching, member, members, nonEmptyString, unknownKeys } from './validation.js';
18
+ const KEYS = [
19
+ 'policyId',
20
+ 'policyVersion',
21
+ 'lifecycle',
22
+ 'invariant',
23
+ 'tier',
24
+ 'operations',
25
+ 'timing',
26
+ 'requiredCapability',
27
+ 'mechanism',
28
+ 'outcomes',
29
+ 'onInternalError',
30
+ 'onUnreadableInput',
31
+ 'requiredEvidence',
32
+ 'redaction',
33
+ 'statedIn',
34
+ ];
35
+ export const KEBAB_CASE = /^[a-z][a-z0-9-]*$/;
36
+ export const POLICY_VERSION = /^\d+\.\d+$/;
37
+ /** Validate an unknown value as a declaration, reporting every problem at once. */
38
+ export function validateDeclaration(input) {
39
+ if (!isRecord(input)) {
40
+ return { ok: false, problems: [{ field: '', message: 'a declaration is an object' }] };
41
+ }
42
+ const problems = [];
43
+ unknownKeys(problems, input, KEYS);
44
+ matching(problems, 'policyId', input.policyId, KEBAB_CASE, 'kebab-case');
45
+ matching(problems, 'policyVersion', input.policyVersion, POLICY_VERSION, 'MAJOR.MINOR');
46
+ member(problems, 'lifecycle', input.lifecycle, LIFECYCLE_STATES);
47
+ nonEmptyString(problems, 'invariant', input.invariant);
48
+ member(problems, 'tier', input.tier, AUTONOMY_TIERS);
49
+ members(problems, 'operations', input.operations, OPERATIONS, { nonEmpty: true });
50
+ member(problems, 'timing', input.timing, ENFORCEMENT_TIMINGS);
51
+ member(problems, 'requiredCapability', input.requiredCapability, HARNESS_CAPABILITIES);
52
+ matching(problems, 'mechanism', input.mechanism, KEBAB_CASE, 'kebab-case');
53
+ members(problems, 'outcomes', input.outcomes, DECISION_OUTCOMES, { nonEmpty: true });
54
+ member(problems, 'onInternalError', input.onInternalError, FAILURE_SEMANTICS);
55
+ member(problems, 'onUnreadableInput', input.onUnreadableInput, FAILURE_SEMANTICS);
56
+ members(problems, 'requiredEvidence', input.requiredEvidence, EVIDENCE_KINDS, {
57
+ nonEmpty: false,
58
+ });
59
+ member(problems, 'redaction', input.redaction, REDACTION_RULES);
60
+ nonEmptyString(problems, 'statedIn', input.statedIn);
61
+ if (problems.length > 0)
62
+ return { ok: false, problems };
63
+ // Every field above was checked against the shape, so the narrowing is earned
64
+ // rather than asserted: the cast is to the type the checks just established.
65
+ return { ok: true, value: input };
66
+ }
67
+ /**
68
+ * Define a policy: validate it and hand back a frozen copy. Throws an Error
69
+ * whose message lists every problem as `field: message`, so a registry that
70
+ * loads at import time fails with the whole list rather than one line at a
71
+ * time.
72
+ */
73
+ export function definePolicy(input) {
74
+ const result = validateDeclaration(input);
75
+ if (!result.ok) {
76
+ const lines = result.problems.map(({ field, message }) => `${field}: ${message}`);
77
+ throw new Error(`invalid policy declaration:\n ${lines.join('\n ')}`);
78
+ }
79
+ const policy = result.value;
80
+ return Object.freeze({
81
+ ...policy,
82
+ operations: Object.freeze([...policy.operations]),
83
+ outcomes: Object.freeze([...policy.outcomes]),
84
+ requiredEvidence: Object.freeze([...policy.requiredEvidence]),
85
+ });
86
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The policy registry (RP-76): the declarations of the policies this rig
3
+ * enforces, and the compatibility rule a decision record is read against.
4
+ *
5
+ * The three entries are the guards the rulebook already ships; declaring them
6
+ * changes nothing about how they run. Each field below was read off the
7
+ * guard's own header and its tests rather than inferred from its name, and the
8
+ * correspondence between a declaration and the hook wiring both harnesses
9
+ * carry is pinned in `test/template/policy-declaration.test.ts` › "every
10
+ * registered policy is wired in the %s snapshot under its event, matcher and
11
+ * hook path". Which hook file a mechanism name resolves to is the adapter's
12
+ * business (`../harness/`), not this file's.
13
+ */
14
+ import { definePolicy } from './declaration.js';
15
+ import { POLICY_VERSION } from './declaration.js';
16
+ const NEVER_TIER = 'rules/autonomy.md#never';
17
+ /**
18
+ * What the three share, stated once: each is a pre-operation hook of the
19
+ * Never tier that can allow, block, or refuse to inspect; each fails open on
20
+ * its own error and closed on input it can see but cannot read
21
+ * (`rules/invariants.md`, "Fail closed on a match, fail open on an error" and
22
+ * "Refusing to inspect is a third outcome"). The one evidence every outcome
23
+ * carries is the exit code; a refusal also prints a diagnostic line, an allow
24
+ * prints nothing, so `diagnostic-text` is not required of every record.
25
+ */
26
+ const guard = (declaration) => definePolicy({
27
+ policyVersion: '1.0',
28
+ lifecycle: 'active',
29
+ tier: 'never',
30
+ timing: 'before-operation',
31
+ requiredCapability: 'pre-operation-hook',
32
+ outcomes: ['allow', 'block', 'refuse-to-inspect'],
33
+ onInternalError: 'fail-open',
34
+ onUnreadableInput: 'fail-closed',
35
+ requiredEvidence: ['exit-code'],
36
+ statedIn: NEVER_TIER,
37
+ ...declaration,
38
+ });
39
+ /**
40
+ * Secret-write refusal. The guard refuses an edit that names a credential file
41
+ * or carries a credential value; the value arm never prints what it matched —
42
+ * `test/template/guard-secret-file.test.ts` › "never prints the credential it
43
+ * found, nor a fragment of it" — which is what `omit-matched-values` records.
44
+ */
45
+ const secretWriteRefusal = guard({
46
+ policyId: 'secret-write-refusal',
47
+ invariant: 'A credential never enters the repository through an edit.',
48
+ operations: ['file-edit'],
49
+ mechanism: 'guard-secret-file',
50
+ redaction: 'omit-matched-values',
51
+ });
52
+ /**
53
+ * No-verify refusal. The guard refuses a shell command that bypasses the
54
+ * pre-commit gate — `test/template/shell-tools.test.ts` › "refuses a
55
+ * pre-commit bypass through %s" pins the block on every shell tool. The
56
+ * refuse-to-inspect outcome and the fail-open on an unparseable payload are
57
+ * `test/template/hook-command-shape.test.ts` › "%s does not tell the caller to
58
+ * split and retry" and › "allows a malformed payload it cannot parse at all".
59
+ * ⚠ One limit of `onInternalError: 'fail-open'` for this guard: it has no
60
+ * try/catch, so an internal throw exits 1, which the harness reads as allow —
61
+ * a property of the harness that no test here pins.
62
+ */
63
+ const noVerifyRefusal = guard({
64
+ policyId: 'no-verify-refusal',
65
+ invariant: 'The pre-commit gate is never bypassed.',
66
+ operations: ['shell-command'],
67
+ mechanism: 'block-no-verify',
68
+ redaction: 'none',
69
+ });
70
+ /**
71
+ * Rulebook-mutation restriction. In an unattended run the guard refuses an
72
+ * edit under a rulebook prefix outside the current item's allow-list; in an
73
+ * attended session it does nothing — `test/template/guard-rulebook.test.ts` ›
74
+ * "blocks an edit to a rulebook path the allow-list does not name" and ›
75
+ * "allows a hook edit when no unattended flag exists".
76
+ */
77
+ const rulebookMutationRestriction = guard({
78
+ policyId: 'rulebook-mutation-restriction',
79
+ invariant: "In an unattended run, the rulebook is never edited outside the current item's allow-list.",
80
+ operations: ['file-edit'],
81
+ mechanism: 'guard-rulebook',
82
+ redaction: 'none',
83
+ });
84
+ /** Every declared policy, in declaration order. */
85
+ export const POLICIES = Object.freeze([
86
+ secretWriteRefusal,
87
+ noVerifyRefusal,
88
+ rulebookMutationRestriction,
89
+ ]);
90
+ export function findPolicy(policyId) {
91
+ return POLICIES.find((policy) => policy.policyId === policyId) ?? null;
92
+ }
93
+ export function policyIds() {
94
+ return POLICIES.map((policy) => policy.policyId);
95
+ }
96
+ /** The policies the registry still offers — everything not retired. */
97
+ export function activePolicies() {
98
+ return POLICIES.filter((policy) => policy.lifecycle !== 'retired');
99
+ }
100
+ const majorOf = (version) => Number(version.split('.')[0]);
101
+ /**
102
+ * Can a decision record naming `policyVersion` be read against the registered
103
+ * policy? Same MAJOR: yes, whatever the MINOR — a MINOR bump is additive.
104
+ * Different MAJOR: no, the semantics that produced the verdict are not these.
105
+ * A version that is not `MAJOR.MINOR` is refused as malformed rather than
106
+ * parsed for a MAJOR it might have meant.
107
+ */
108
+ export function compatibilityOf(policyId, policyVersion) {
109
+ const policy = findPolicy(policyId);
110
+ if (policy === null)
111
+ return 'unknown-policy';
112
+ if (!POLICY_VERSION.test(policyVersion))
113
+ return 'malformed-version';
114
+ return majorOf(policyVersion) === majorOf(policy.policyVersion) ? 'compatible' : 'incompatible';
115
+ }