create-agent-rig 0.7.1 → 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 +79 -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/.claude/rules/autonomy.md +17 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +7 -3
- package/templates/hash-history.json +8 -4
- package/templates/release-ledger.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,7 +9,85 @@ 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.
|
|
13
91
|
|
|
14
92
|
## 0.7.1
|
|
15
93
|
|
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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small set of checks the declaration and the decision record share.
|
|
3
|
+
*
|
|
4
|
+
* Every check appends to a problem list instead of throwing, so a caller sees
|
|
5
|
+
* every defect of a record at once — `validateDeclaration` › "reports every
|
|
6
|
+
* problem at once rather than stopping at the first" in
|
|
7
|
+
* `packages/cli/test/policy-declaration.test.ts`. Each message that refuses an
|
|
8
|
+
* enumerated value quotes the value, because a refusal that names the field
|
|
9
|
+
* and not the word leaves the caller guessing which of two spellings it sent.
|
|
10
|
+
*/
|
|
11
|
+
export const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
const quote = (value) => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.stringify(value) ?? String(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return String(value);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const list = (vocabulary) => vocabulary.map(quote).join(', ');
|
|
21
|
+
/**
|
|
22
|
+
* Refuse a key the shape does not declare — the shape is closed on purpose.
|
|
23
|
+
* A nested shape passes its own field name as `prefix`, so the problem names
|
|
24
|
+
* `verdict.severity` rather than a bare `severity` the caller cannot place.
|
|
25
|
+
*/
|
|
26
|
+
export const unknownKeys = (problems, input, known, prefix = '') => {
|
|
27
|
+
for (const key of Object.keys(input)) {
|
|
28
|
+
if (!known.includes(key)) {
|
|
29
|
+
problems.push({ field: prefix === '' ? key : `${prefix}.${key}`, message: 'unknown field' });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Refuse a string that is absent, not a string, or empty. */
|
|
34
|
+
export const nonEmptyString = (problems, field, value) => {
|
|
35
|
+
if (typeof value !== 'string' || value === '') {
|
|
36
|
+
problems.push({ field, message: `must be a non-empty string, got ${quote(value)}` });
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
/** Refuse a string outside a closed vocabulary, quoting the offending value. */
|
|
42
|
+
export const member = (problems, field, value, vocabulary) => {
|
|
43
|
+
if (typeof value === 'string' && vocabulary.includes(value))
|
|
44
|
+
return true;
|
|
45
|
+
problems.push({ field, message: `${quote(value)} is not one of ${list(vocabulary)}` });
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Refuse a list that is not an array, carries a value outside the vocabulary,
|
|
50
|
+
* repeats one, or — when `nonEmpty` — is empty.
|
|
51
|
+
*/
|
|
52
|
+
export const members = (problems, field, value, vocabulary, { nonEmpty }) => {
|
|
53
|
+
if (!Array.isArray(value)) {
|
|
54
|
+
problems.push({ field, message: `must be a list, got ${quote(value)}` });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
let clean = true;
|
|
58
|
+
if (nonEmpty && value.length === 0) {
|
|
59
|
+
problems.push({ field, message: 'must not be empty' });
|
|
60
|
+
clean = false;
|
|
61
|
+
}
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const entry of value) {
|
|
64
|
+
if (!member(problems, field, entry, vocabulary)) {
|
|
65
|
+
clean = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (seen.has(entry)) {
|
|
69
|
+
problems.push({ field, message: `${quote(entry)} is listed twice` });
|
|
70
|
+
clean = false;
|
|
71
|
+
}
|
|
72
|
+
seen.add(entry);
|
|
73
|
+
}
|
|
74
|
+
return clean;
|
|
75
|
+
};
|
|
76
|
+
/** Refuse a string that does not match the pattern, saying what shape was expected. */
|
|
77
|
+
export const matching = (problems, field, value, pattern, expected) => {
|
|
78
|
+
if (typeof value === 'string' && pattern.test(value))
|
|
79
|
+
return true;
|
|
80
|
+
problems.push({ field, message: `must be ${expected}, got ${quote(value)}` });
|
|
81
|
+
return false;
|
|
82
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed vocabularies of the policy declaration (RP-76).
|
|
3
|
+
*
|
|
4
|
+
* Every value a declaration or a decision record may carry in an enumerated
|
|
5
|
+
* field is listed here and nowhere else. Adding a value is a schema edit — a
|
|
6
|
+
* change to this file plus the test that pins the list — never a string a
|
|
7
|
+
* caller invents at runtime. That is what makes a record auditable: an unknown
|
|
8
|
+
* word is refused rather than read as something close to a known one.
|
|
9
|
+
*
|
|
10
|
+
* Harness-neutral by construction: nothing here names a harness, a vendor, a
|
|
11
|
+
* native tool or a native path. The per-harness spellings live in
|
|
12
|
+
* `../harness/`, and `test/template/policy-declaration.test.ts` › "no file
|
|
13
|
+
* under src/policy/core mentions a harness, a vendor, a native tool or a native
|
|
14
|
+
* path" is what keeps them out of here.
|
|
15
|
+
*/
|
|
16
|
+
const closed = (values) => Object.freeze(values);
|
|
17
|
+
/**
|
|
18
|
+
* Whether a declared policy can actually be enforced on a given harness
|
|
19
|
+
* surface. `UNSUPPORTED` and `INTEGRATION-FAILED` never yield a silent pass:
|
|
20
|
+
* a decision record carrying either must qualify its verdict `UNVERIFIABLE`
|
|
21
|
+
* (`./decision-record.ts`). The four states are defined here.
|
|
22
|
+
*/
|
|
23
|
+
export const CAPABILITY_STATES = closed([
|
|
24
|
+
'SUPPORTED',
|
|
25
|
+
'DEGRADED',
|
|
26
|
+
'UNSUPPORTED',
|
|
27
|
+
'INTEGRATION-FAILED',
|
|
28
|
+
]);
|
|
29
|
+
/** The autonomy tiers of `rules/autonomy.md`; `never` is the tier the guards enforce. */
|
|
30
|
+
export const AUTONOMY_TIERS = closed(['tier-0', 'tier-1', 'tier-2', 'never']);
|
|
31
|
+
/** The operations a policy can apply to, named by what the agent does, not by a tool. */
|
|
32
|
+
export const OPERATIONS = closed(['file-edit', 'shell-command']);
|
|
33
|
+
/** When the mechanism decides, relative to the operation it judges. */
|
|
34
|
+
export const ENFORCEMENT_TIMINGS = closed(['before-operation']);
|
|
35
|
+
/** What a harness must provide for the mechanism to run at all. */
|
|
36
|
+
export const HARNESS_CAPABILITIES = closed(['pre-operation-hook']);
|
|
37
|
+
/**
|
|
38
|
+
* The three outcomes a guard can reach: allow, block, or refuse to inspect —
|
|
39
|
+
* the third being neither a match nor an error (`rules/invariants.md`,
|
|
40
|
+
* "Refusing to inspect is a third outcome").
|
|
41
|
+
*/
|
|
42
|
+
export const DECISION_OUTCOMES = closed(['allow', 'block', 'refuse-to-inspect']);
|
|
43
|
+
/** What a mechanism does when it cannot decide: let the operation through, or stop it. */
|
|
44
|
+
export const FAILURE_SEMANTICS = closed(['fail-open', 'fail-closed']);
|
|
45
|
+
/** The kinds of evidence a decision record may carry, and a policy may require. */
|
|
46
|
+
export const EVIDENCE_KINDS = closed(['exit-code', 'diagnostic-text', 'test-pointer']);
|
|
47
|
+
/** How a mechanism treats what it matched when it reports: verbatim, or omitted. */
|
|
48
|
+
export const REDACTION_RULES = closed(['none', 'omit-matched-values']);
|
|
49
|
+
/** Where a policy is in its life; a `retired` policy is no longer offered by the registry. */
|
|
50
|
+
export const LIFECYCLE_STATES = closed(['active', 'deprecated', 'retired']);
|
|
51
|
+
/**
|
|
52
|
+
* The two ways a verdict can say "this word is weaker than it looks": the
|
|
53
|
+
* question could not be put (`UNVERIFIABLE`), or nothing backs the answer
|
|
54
|
+
* (`UNMEASURED`). Either one must carry a reason.
|
|
55
|
+
*/
|
|
56
|
+
export const VERDICT_QUALIFIERS = closed(['UNVERIFIABLE', 'UNMEASURED']);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code adapter: a declaration → the native hook surface Claude Code
|
|
3
|
+
* wires for it. The authoring surface of this rulebook is Claude-shaped
|
|
4
|
+
* (`CLAUDE.md`, "One operating system, two harnesses"), so the hook files
|
|
5
|
+
* themselves live in the historical directory `./shared-hooks.ts` names and are
|
|
6
|
+
* shared by every harness.
|
|
7
|
+
*
|
|
8
|
+
* What is native here and nowhere in the core: the `PreToolUse` event, the
|
|
9
|
+
* tool names in the matchers, and the snapshot path. The matcher strings are
|
|
10
|
+
* the ones `.claude/settings.json` carries, and the correspondence test holds
|
|
11
|
+
* the two to the SAME tool set, not a subset: a tool dropped here or gained
|
|
12
|
+
* there is reported for this adapter —
|
|
13
|
+
* `test/template/policy-declaration.test.ts` › "reports the no-verify policy
|
|
14
|
+
* on %s when the shell matcher loses PowerShell (mutation: matcher)", › "reports
|
|
15
|
+
* the no-verify policy on %s when the snapshot gains a tool the adapter does
|
|
16
|
+
* not name (mutation: widened snapshot)" and › "reports a policy on %s whose
|
|
17
|
+
* adapter matcher drops a tool the snapshot still wires (mutation: narrowed
|
|
18
|
+
* adapter)". The shell matcher's tool set is owned by `shell-tools.mjs` in the
|
|
19
|
+
* shipped scripts, and `test/template/shell-tools.test.ts` holds that
|
|
20
|
+
* correspondence.
|
|
21
|
+
*/
|
|
22
|
+
import { SHARED_HOOKS_DIR } from './shared-hooks.js';
|
|
23
|
+
const EVENT_OF = {
|
|
24
|
+
'before-operation': 'PreToolUse',
|
|
25
|
+
};
|
|
26
|
+
const MATCHER_OF = {
|
|
27
|
+
'file-edit': 'Write|Edit|MultiEdit|NotebookEdit|apply_patch',
|
|
28
|
+
'shell-command': 'Bash|PowerShell',
|
|
29
|
+
};
|
|
30
|
+
export const nativeSurfaceOf = (policy) => ({
|
|
31
|
+
event: EVENT_OF[policy.timing],
|
|
32
|
+
matcher: policy.operations.map((operation) => MATCHER_OF[operation]).join('|'),
|
|
33
|
+
hookPath: `${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`,
|
|
34
|
+
});
|
|
35
|
+
export const claudeAdapter = Object.freeze({
|
|
36
|
+
harness: 'claude',
|
|
37
|
+
surfaceFile: '.claude/settings.json',
|
|
38
|
+
nativeSurfaceOf,
|
|
39
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex adapter: a declaration → the native hook surface Codex wires for
|
|
3
|
+
* it. Codex's hook wiring (`.codex/hooks.json`) is DERIVED from the authoring
|
|
4
|
+
* harness's snapshot by `scripts/sync-codex-adapter.mjs`
|
|
5
|
+
* (`docs/decisions/codex-adapter.md`): it keeps the authoring harness's matcher
|
|
6
|
+
* spellings, adds the canonical edit tool `apply_patch`, and runs the same hook
|
|
7
|
+
* files from the shared hooks directory. So the strings below coincide with the
|
|
8
|
+
* other adapter's today — by derivation, not by accident — and each adapter
|
|
9
|
+
* still owns its own spelling. The correspondence test holds this adapter's
|
|
10
|
+
* matcher and the derived snapshot's to the SAME tool set, so a tool the
|
|
11
|
+
* snapshot gains or loses, or one this adapter drops, is reported for this
|
|
12
|
+
* adapter alone — `test/template/policy-declaration.test.ts` › "reports the
|
|
13
|
+
* no-verify policy on %s when the shell matcher loses PowerShell (mutation:
|
|
14
|
+
* matcher)", › "reports the no-verify policy on %s when the snapshot gains a
|
|
15
|
+
* tool the adapter does not name (mutation: widened snapshot)" and › "reports
|
|
16
|
+
* a policy on %s whose adapter matcher drops a tool the snapshot still wires
|
|
17
|
+
* (mutation: narrowed adapter)".
|
|
18
|
+
*
|
|
19
|
+
* The shared hooks directory is imported from `./shared-hooks.ts` rather than
|
|
20
|
+
* restated, for the same reason the snapshot is derived rather than
|
|
21
|
+
* hand-written: one spelling of one fact.
|
|
22
|
+
*/
|
|
23
|
+
import { SHARED_HOOKS_DIR } from './shared-hooks.js';
|
|
24
|
+
const EVENT_OF = {
|
|
25
|
+
'before-operation': 'PreToolUse',
|
|
26
|
+
};
|
|
27
|
+
const MATCHER_OF = {
|
|
28
|
+
'file-edit': 'Write|Edit|MultiEdit|NotebookEdit|apply_patch',
|
|
29
|
+
'shell-command': 'Bash|PowerShell',
|
|
30
|
+
};
|
|
31
|
+
export const nativeSurfaceOf = (policy) => ({
|
|
32
|
+
event: EVENT_OF[policy.timing],
|
|
33
|
+
matcher: policy.operations.map((operation) => MATCHER_OF[operation]).join('|'),
|
|
34
|
+
hookPath: `${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`,
|
|
35
|
+
});
|
|
36
|
+
export const codexAdapter = Object.freeze({
|
|
37
|
+
harness: 'codex',
|
|
38
|
+
surfaceFile: '.codex/hooks.json',
|
|
39
|
+
nativeSurfaceOf,
|
|
40
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness adapters this generator knows. Adding a harness is one new
|
|
3
|
+
* module beside these two and one entry in the list below — nothing in
|
|
4
|
+
* `../core/` changes, which `test/template/policy-declaration.test.ts` ›
|
|
5
|
+
* "codex is named only by its own adapter and the adapter index" pins by
|
|
6
|
+
* naming exactly the files that may mention each harness.
|
|
7
|
+
*/
|
|
8
|
+
import { claudeAdapter } from './claude.js';
|
|
9
|
+
import { codexAdapter } from './codex.js';
|
|
10
|
+
export { claudeAdapter, codexAdapter };
|
|
11
|
+
export { SHARED_HOOKS_DIR } from './shared-hooks.js';
|
|
12
|
+
export const HARNESS_ADAPTERS = Object.freeze([
|
|
13
|
+
claudeAdapter,
|
|
14
|
+
codexAdapter,
|
|
15
|
+
]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one directory every harness runs its hook files from. The rulebook's
|
|
3
|
+
* `.claude/` directory keeps its historical name but holds the shared rules,
|
|
4
|
+
* hooks, scripts and agent specifications for both harnesses (`CLAUDE.md`,
|
|
5
|
+
* "One operating system, two harnesses"), so a hook path is the same string
|
|
6
|
+
* whichever adapter names it. Stated once, here, and imported by each adapter
|
|
7
|
+
* — one spelling of one fact (`rules/invariants.md`, "One mechanism, one
|
|
8
|
+
* implementation").
|
|
9
|
+
*/
|
|
10
|
+
export const SHARED_HOOKS_DIR = '.claude/hooks';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy declaration, registry, decision-record schema and harness
|
|
3
|
+
* adapters (RP-76). Library surface only — nothing here is reached by the CLI
|
|
4
|
+
* commands yet; emitting decision records at runtime is a separate task.
|
|
5
|
+
*/
|
|
6
|
+
export * from './core/vocabulary.js';
|
|
7
|
+
export * from './core/declaration.js';
|
|
8
|
+
export * from './core/registry.js';
|
|
9
|
+
export * from './core/decision-record.js';
|
|
10
|
+
export * from './harness/index.js';
|
|
@@ -153,9 +153,13 @@ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run
|
|
|
153
153
|
|
|
154
154
|
`guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
|
|
155
155
|
on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
|
|
156
|
-
rulebook
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
rulebook is refused unless its
|
|
157
|
+
path starts with an allowed prefix. 🔴 **Which paths that covers is
|
|
158
|
+
`RULEBOOK_PREFIXES` in `.claude/scripts/unattended-flag.mjs`** — read it before
|
|
159
|
+
composing an allow-list, rather than working from a summary here. A summary is a
|
|
160
|
+
second copy, and the one that used to sit in this sentence had gone stale against
|
|
161
|
+
the set it described. One fact the set cannot carry, so it is stated: the board
|
|
162
|
+
selector is the one always-refused
|
|
159
163
|
exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
|
|
160
164
|
item that needs a rulebook path names it here — a decision made at claim
|
|
161
165
|
time, never a default — and the stop step below turns the flag off. Pinned in
|
|
@@ -99,7 +99,7 @@ own cost figures are read next to the lane they do not cover.
|
|
|
99
99
|
`MultiEdit`, `NotebookEdit`, or `apply_patch` that names a credential file or carries a credential value,
|
|
100
100
|
reading its vocabulary from `.claude/scripts/lib/secrets.mjs`. ⚠ **Only that
|
|
101
101
|
part.** The hook sees what an agent writes through those five tools and
|
|
102
|
-
nothing else — its own header states
|
|
102
|
+
nothing else — its own header states its blind spots — so whether a
|
|
103
103
|
credential typed by a human, or committed from disk, is also refused depends
|
|
104
104
|
on whether this project has a commit-time check. Look at `.husky/` and the CI
|
|
105
105
|
workflow; this file cannot tell you, and a
|
|
@@ -108,15 +108,25 @@ own cost figures are read next to the lane they do not cover.
|
|
|
108
108
|
at runtime instead of writing it out, or the check reports its own test data as
|
|
109
109
|
a leak.
|
|
110
110
|
- touch production data outside a reviewed migration
|
|
111
|
-
- edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
- edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it,
|
|
112
|
+
and the checkout board selector is refused **even when the item's allow-list
|
|
113
|
+
names it**; that one carve-out does not follow from the set below.
|
|
114
|
+
The rulebook is both harnesses' instruction, rule, agent, skill, script and
|
|
115
|
+
hook trees, plus the settings, queue, exemption and integrity files that decide
|
|
116
|
+
what a session may do. **Which paths exactly is not restated here**: the
|
|
117
|
+
mechanism reads them from `RULEBOOK_PREFIXES` in
|
|
118
|
+
`.claude/scripts/unattended-flag.mjs`, and so should you — the guard judges an
|
|
119
|
+
edit against it, and the flag writer refuses an allow entry that *widens* it.
|
|
120
|
+
Which entries those are is `isWidening`'s answer in that same module, not a
|
|
121
|
+
paraphrase here: pinned in the generator's `unattended-flag.test.ts` — absent
|
|
122
|
+
in a generated rig — › "explains exact protected-prefix refusal separately
|
|
123
|
+
from proper-prefix widening". An entry
|
|
124
|
+
outside the set — ordinary source — is not widening and is accepted. A second
|
|
125
|
+
copy in prose is a copy that goes stale, and this one did. Mechanical:
|
|
116
126
|
the hook refuses the edit while the unattended flag the `loop` skill writes
|
|
117
127
|
at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
|
|
118
128
|
nothing in an attended session. ⚠ It sees edit tool calls only — a
|
|
119
|
-
shell redirect into
|
|
129
|
+
shell redirect into a protected file is not one — and the flag, not
|
|
120
130
|
the run, is what arms it; its header states the rest of its limits.
|
|
121
131
|
|
|
122
132
|
## Stop rules — by work-state, not by feelings
|
|
@@ -153,9 +153,13 @@ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run
|
|
|
153
153
|
|
|
154
154
|
`guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
|
|
155
155
|
on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
|
|
156
|
-
rulebook
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
rulebook is refused unless its
|
|
157
|
+
path starts with an allowed prefix. 🔴 **Which paths that covers is
|
|
158
|
+
`RULEBOOK_PREFIXES` in `.claude/scripts/unattended-flag.mjs`** — read it before
|
|
159
|
+
composing an allow-list, rather than working from a summary here. A summary is a
|
|
160
|
+
second copy, and the one that used to sit in this sentence had gone stale against
|
|
161
|
+
the set it described. One fact the set cannot carry, so it is stated: the board
|
|
162
|
+
selector is the one always-refused
|
|
159
163
|
exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
|
|
160
164
|
item that needs a rulebook path names it here — a decision made at claim
|
|
161
165
|
time, never a default — and the stop step below turns the flag off. Pinned in
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"0.6.0",
|
|
10
10
|
"0.6.1",
|
|
11
11
|
"0.6.2",
|
|
12
|
-
"0.7.0"
|
|
12
|
+
"0.7.0",
|
|
13
|
+
"0.7.1"
|
|
13
14
|
],
|
|
14
15
|
"files": {
|
|
15
16
|
".agents/skills/check-premises/SKILL.md": {
|
|
@@ -59,7 +60,8 @@
|
|
|
59
60
|
"hashes": [
|
|
60
61
|
"a2fb0d03c65701af9d2d69cbcb2582ae573f097b1e1071eb2916e5e4f7832b37",
|
|
61
62
|
"9616e4767942b2021e34ab7bb76eec9a5c280cc608a0756abb02743741d5fc8a",
|
|
62
|
-
"a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8"
|
|
63
|
+
"a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8",
|
|
64
|
+
"dc1d9c02879e383448f2336ae6433f960f86e8f8efb4ab8ccf9a17a5f7f524c3"
|
|
63
65
|
]
|
|
64
66
|
},
|
|
65
67
|
".agents/skills/ro-debug/SKILL.md": {
|
|
@@ -438,7 +440,8 @@
|
|
|
438
440
|
"hashes": [
|
|
439
441
|
"32f847a2a3a5136cabcb42f6447da9f8cf98e2d20512212ee5201461128d7798",
|
|
440
442
|
"b62348a27bcaa1d62a0e4ddd53873fab6cb75f9af8e8c69d24f6ce81c592c4e3",
|
|
441
|
-
"b151045e8083c398e94571b35dc9568dfa0102abcb847cdf764f6f694914d53e"
|
|
443
|
+
"b151045e8083c398e94571b35dc9568dfa0102abcb847cdf764f6f694914d53e",
|
|
444
|
+
"f756b7ccc2a4459e58b2125408de1f91372cd6093bf6f4ed14e26dc2eb4190fc"
|
|
442
445
|
]
|
|
443
446
|
},
|
|
444
447
|
".claude/scripts/revalidation-report.mjs": {
|
|
@@ -550,7 +553,8 @@
|
|
|
550
553
|
"75adb84414a330db621ff4f1363b3926f537133199236a9e25bb9880b935e02e",
|
|
551
554
|
"a2fb0d03c65701af9d2d69cbcb2582ae573f097b1e1071eb2916e5e4f7832b37",
|
|
552
555
|
"9616e4767942b2021e34ab7bb76eec9a5c280cc608a0756abb02743741d5fc8a",
|
|
553
|
-
"a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8"
|
|
556
|
+
"a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8",
|
|
557
|
+
"dc1d9c02879e383448f2336ae6433f960f86e8f8efb4ab8ccf9a17a5f7f524c3"
|
|
554
558
|
]
|
|
555
559
|
},
|
|
556
560
|
".claude/skills/ro-debug/SKILL.md": {
|
|
@@ -9,5 +9,6 @@
|
|
|
9
9
|
"0.6.0": "29ca0543368e922349945e52541a92b46ddcc5bd",
|
|
10
10
|
"0.6.1": "f1d1e3dbd2161545d77ee5e7f90fa8e74b8a9f3e",
|
|
11
11
|
"0.6.2": "2a1fc8e10cd2b65deb5c95d937fd7de39f2c92a2",
|
|
12
|
-
"0.7.0": "6589db36e1daa63a99ec595191db1cccf7373196"
|
|
12
|
+
"0.7.0": "6589db36e1daa63a99ec595191db1cccf7373196",
|
|
13
|
+
"0.7.1": "52e879b6c103f6ba70493007b6a6466c57ea9824"
|
|
13
14
|
}
|