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 +164 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/cli/dist/policy/core/adapter.js +18 -0
- package/packages/cli/dist/policy/core/decision-record.js +201 -0
- package/packages/cli/dist/policy/core/declaration.js +86 -0
- package/packages/cli/dist/policy/core/registry.js +115 -0
- package/packages/cli/dist/policy/core/validation.js +82 -0
- package/packages/cli/dist/policy/core/vocabulary.js +56 -0
- package/packages/cli/dist/policy/harness/claude.js +39 -0
- package/packages/cli/dist/policy/harness/codex.js +40 -0
- package/packages/cli/dist/policy/harness/index.js +15 -0
- package/packages/cli/dist/policy/harness/shared-hooks.js +10 -0
- package/packages/cli/dist/policy/index.js +10 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +7 -3
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +70 -9
- package/templates/agent-os/universal/.claude/rules/autonomy.md +17 -7
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +380 -19
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +7 -3
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +70 -9
- package/templates/hash-history.json +82 -24
- package/templates/release-ledger.json +3 -1
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
|
|
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
|
@@ -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
|
+
}
|