create-agent-rig 0.8.0 → 0.9.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 +105 -1
- package/README.md +92 -3
- package/package.json +4 -3
- package/packages/cli/dist/commands/memory.js +123 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/index.js +107 -3
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +130 -44
- package/packages/cli/dist/policy/core/declaration.js +58 -17
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/validation.js +194 -1
- package/packages/cli/dist/policy/core/vocabulary.js +70 -3
- package/packages/cli/dist/policy/harness/claude.js +9 -1
- package/packages/cli/dist/policy/harness/codex.js +48 -1
- package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
- package/packages/cli/dist/policy/index.js +9 -2
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +8 -4
- package/templates/release-ledger.json +2 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability evidence matrix (RP-36, absorbing the discovery component of
|
|
3
|
+
* the item it supersedes): one row per concrete harness surface, saying what
|
|
4
|
+
* was observed, on exactly which version, when, and where the proof is.
|
|
5
|
+
*
|
|
6
|
+
* The rule that gives the matrix its value is that its validator refuses an
|
|
7
|
+
* incomplete row: a row without an exact version or an observed
|
|
8
|
+
* date-time reads like evidence and is not one, because neither "which build
|
|
9
|
+
* was this" nor "was this before or after the change" can be answered from it.
|
|
10
|
+
* This module owns validation only; a caller that persists rows owns storage.
|
|
11
|
+
* Both halves are checked — `packages/cli/test/policy-coverage.test.ts` ›
|
|
12
|
+
* "refuses the harness version %j, because it names a range or a moving target
|
|
13
|
+
* rather than a build" and › "refuses an observedAt that is not an ISO-8601
|
|
14
|
+
* date-time with a zone (%j)". Documentation claims are not rows; a row records
|
|
15
|
+
* live behaviour that was seen.
|
|
16
|
+
*
|
|
17
|
+
* A row whose status is anything but `SUPPORTED` must say why, and a
|
|
18
|
+
* `SUPPORTED` row must not — the shape is closed in both directions, per ›
|
|
19
|
+
* "refuses a %s row that does not say why it is not supported".
|
|
20
|
+
*
|
|
21
|
+
* Every field of the shape is checked and every problem is reported at once,
|
|
22
|
+
* the way `./declaration.ts` does it, so a caller fixes a row in one pass: ›
|
|
23
|
+
* "reports every problem at once rather than stopping at the first".
|
|
24
|
+
*
|
|
25
|
+
* Why the row is shaped this way, and what it deliberately does not check, is
|
|
26
|
+
* `docs/decisions/capability-coverage.md`.
|
|
27
|
+
*/
|
|
28
|
+
import { CAPABILITY_STATES } from './vocabulary.js';
|
|
29
|
+
import { carriesField, exactVersion, ISO_8601, isRecord, matching, member, nonBlankString, ownField, unknownKeys, } from './validation.js';
|
|
30
|
+
const KEYS = [
|
|
31
|
+
'harness',
|
|
32
|
+
'surface',
|
|
33
|
+
'harnessVersion',
|
|
34
|
+
'os',
|
|
35
|
+
'observedAt',
|
|
36
|
+
'mechanism',
|
|
37
|
+
'observableSignal',
|
|
38
|
+
'status',
|
|
39
|
+
'downgradeReason',
|
|
40
|
+
'evidencePointer',
|
|
41
|
+
];
|
|
42
|
+
const REQUIRED_TEXT = [
|
|
43
|
+
'harness',
|
|
44
|
+
'surface',
|
|
45
|
+
'harnessVersion',
|
|
46
|
+
'os',
|
|
47
|
+
'mechanism',
|
|
48
|
+
'observableSignal',
|
|
49
|
+
'evidencePointer',
|
|
50
|
+
];
|
|
51
|
+
/** Validate an unknown value as one matrix row, reporting every problem at once. */
|
|
52
|
+
export function validateEvidenceRow(input) {
|
|
53
|
+
if (!isRecord(input)) {
|
|
54
|
+
return { ok: false, problems: [{ field: '', message: 'an evidence row is an object' }] };
|
|
55
|
+
}
|
|
56
|
+
const problems = [];
|
|
57
|
+
unknownKeys(problems, input, KEYS);
|
|
58
|
+
// EVERY field is read through `ownField`, not off the record directly. A row
|
|
59
|
+
// is evidence only insofar as it CARRIES what it was validated on: a value
|
|
60
|
+
// reached through the prototype chain, or held in a non-enumerable own
|
|
61
|
+
// property, validates and then serialises to nothing — "a row that reads like
|
|
62
|
+
// evidence and is not one", which is the shape this module exists to refuse.
|
|
63
|
+
// `unknownKeys` already judges the row by `Object.keys`, and `ownField` is
|
|
64
|
+
// that same notion, so the closed-shape check and the field reads cannot
|
|
65
|
+
// disagree about what the row contains.
|
|
66
|
+
for (const field of REQUIRED_TEXT)
|
|
67
|
+
nonBlankString(problems, field, ownField(input, field));
|
|
68
|
+
exactVersion(problems, 'harnessVersion', ownField(input, 'harnessVersion'));
|
|
69
|
+
matching(problems, 'observedAt', ownField(input, 'observedAt'), ISO_8601, 'an ISO-8601 date-time with a zone');
|
|
70
|
+
const status = ownField(input, 'status');
|
|
71
|
+
const known = member(problems, 'status', status, CAPABILITY_STATES);
|
|
72
|
+
const carriesReason = carriesField(input, 'downgradeReason');
|
|
73
|
+
if (known && status !== 'SUPPORTED') {
|
|
74
|
+
if (carriesReason)
|
|
75
|
+
nonBlankString(problems, 'downgradeReason', ownField(input, 'downgradeReason'));
|
|
76
|
+
else
|
|
77
|
+
problems.push({ field: 'downgradeReason', message: 'must be a non-blank string' });
|
|
78
|
+
}
|
|
79
|
+
// The shape is closed in both directions: a supported row has no reason to
|
|
80
|
+
// give, so a `downgradeReason` on one is refused rather than ignored. Left
|
|
81
|
+
// unchecked, the field went unvalidated on that branch and the narrowing
|
|
82
|
+
// below handed back a value typed `string` that was not one.
|
|
83
|
+
if (known && status === 'SUPPORTED' && carriesReason) {
|
|
84
|
+
problems.push({
|
|
85
|
+
field: 'downgradeReason',
|
|
86
|
+
message: 'a SUPPORTED row has nothing to explain, so it carries no downgrade reason',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (problems.length > 0)
|
|
90
|
+
return { ok: false, problems };
|
|
91
|
+
// Every field above was checked against the shape, so the narrowing is the
|
|
92
|
+
// type the checks just established rather than an assertion over them.
|
|
93
|
+
return { ok: true, value: input };
|
|
94
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability probe (RP-36): given a declaration, a harness adapter and
|
|
3
|
+
* that surface's hook-wiring snapshot, what can this surface actually enforce?
|
|
4
|
+
*
|
|
5
|
+
* ONE active read of a snapshot the caller supplies. This module cannot fetch
|
|
6
|
+
* one, which bounds what IT does — it does not, on its own, stop a caller
|
|
7
|
+
* looping it, and the header of an earlier draft claimed otherwise. What
|
|
8
|
+
* records the occasion of a probe is `./coverage.ts`, whose `coverageFromProbe`
|
|
9
|
+
* requires a `ProbeTrigger` and refuses a word outside that vocabulary:
|
|
10
|
+
* `policy-coverage.test.ts` › "refuses the trigger %j, because the coverage
|
|
11
|
+
* contract accepts only a declared surface-change trigger".
|
|
12
|
+
*
|
|
13
|
+
* The four answers, each pinned in `packages/cli/test/policy-coverage.test.ts`:
|
|
14
|
+
*
|
|
15
|
+
* | input | answer | pinned by |
|
|
16
|
+
* | --- | --- | --- |
|
|
17
|
+
* | a group naming exactly the declared tools and RUNNING the hook | `SUPPORTED` | › "reads the real %s command as running the hook, because it is what the rig really ships" |
|
|
18
|
+
* | the hook run under that event, but by no group naming exactly those tools | `DEGRADED` | › "reads a wiring that drops a declared tool as DEGRADED, naming the tool that is missing" |
|
|
19
|
+
* | a readable snapshot that runs the hook nowhere under that event | `UNSUPPORTED` | › "reads a hook wired nowhere under the policy event as UNSUPPORTED, naming the hook path" |
|
|
20
|
+
* | a snapshot, or any level of wiring under it, in a shape this module cannot read | `INTEGRATION-FAILED` | › "reports INTEGRATION-FAILED when %s, naming the event it could not read" |
|
|
21
|
+
*
|
|
22
|
+
* 🔴 The last two rows are a distinction, not a duplicate, and collapsing them
|
|
23
|
+
* costs something in each direction. `rules/invariants.md` states the rule
|
|
24
|
+
* under "Refusing to inspect is a third outcome": a field that is simply
|
|
25
|
+
* ABSENT leaves nothing to judge, while a field PRESENT in an unreadable shape
|
|
26
|
+
* is exactly the case worth reporting. Read as one, either an honest absence
|
|
27
|
+
* becomes a false alarm or an unreadable surface becomes a quiet, uncounted
|
|
28
|
+
* zero — the second being the shape a past guard here actually shipped. The
|
|
29
|
+
* costly case is an element BESIDE a valid group whose STRUCTURE cannot be
|
|
30
|
+
* read, held by › "does not report SUPPORTED when an unreadable element sits
|
|
31
|
+
* beside a valid group, because that is a partial read". ⚠ That is the
|
|
32
|
+
* structural level only. A readable command that merely names the hook in an
|
|
33
|
+
* unverified spelling does NOT outrank a group carrying the generated command:
|
|
34
|
+
* a hook list is conjunctive, so an entry the probe could not verify cannot
|
|
35
|
+
* un-run one it did verify, and the surface reads `SUPPORTED`.
|
|
36
|
+
*
|
|
37
|
+
* Neither weak answer passes silently on the path this module's own callers
|
|
38
|
+
* take: `./coverage.ts` › `qualifierFor` maps both to `UNVERIFIABLE`, and
|
|
39
|
+
* `./decision-record.ts` refuses a record carrying either state with an
|
|
40
|
+
* unqualified verdict — › "refuses the silent pass an unwired surface would
|
|
41
|
+
* otherwise produce, and accepts it once qualifierFor speaks".
|
|
42
|
+
*
|
|
43
|
+
* ⚠ That used to be a claim about records built the ordinary way, narrower than
|
|
44
|
+
* "by construction", because `./decision-record.ts` decided whether a verdict
|
|
45
|
+
* carries a qualifier with `in` — a verdict object INHERITING one satisfied the
|
|
46
|
+
* check and then serialised without it. RP-153 brought that reader, and
|
|
47
|
+
* `./declaration.ts`, onto the same own-and-enumerable rule this module and
|
|
48
|
+
* `./evidence-matrix.ts` already used, so the gap this paragraph named is
|
|
49
|
+
* closed. `docs/decisions/capability-coverage.md` records the limits that
|
|
50
|
+
* remain — an accessor read twice (RP-157), and an array hole that serialises
|
|
51
|
+
* as `null` and is read by nothing (RP-161). A contract that claims cover it
|
|
52
|
+
* does not have is worse than one that names the gap, which is why this
|
|
53
|
+
* paragraph is corrected rather than deleted.
|
|
54
|
+
*
|
|
55
|
+
* The rationale, including what this deliberately does not do, is
|
|
56
|
+
* `docs/decisions/capability-coverage.md`.
|
|
57
|
+
*/
|
|
58
|
+
import { carriesField, isRecord, ownField, quote } from './validation.js';
|
|
59
|
+
/**
|
|
60
|
+
* The longest hook command this module will parse.
|
|
61
|
+
*
|
|
62
|
+
* A command comes off a file on disk, and every line of work done on untrusted
|
|
63
|
+
* input in a module whose callers fail open is a line that can be made
|
|
64
|
+
* expensive (`rules/invariants.md`, "A guard that fails open must do provably
|
|
65
|
+
* bounded work"). Past the cap the command is not read at all, and the refusal
|
|
66
|
+
* reads `INTEGRATION-FAILED` — not `UNSUPPORTED`, because a command nobody
|
|
67
|
+
* looked at is not evidence that the mechanism is absent. An earlier version of
|
|
68
|
+
* this sentence said the opposite of the code, which is the sentence a
|
|
69
|
+
* maintainer would read before "simplifying" the branch back into the silent
|
|
70
|
+
* absence this design removed: › "refuses a command one byte over the cap
|
|
71
|
+
* rather than reading it, even though it names no hook".
|
|
72
|
+
*
|
|
73
|
+
* The margin, measured rather than asserted over every wiring snapshot this
|
|
74
|
+
* rig ships: the longest command in any of them is 131 bytes, so 4096 is ~31x
|
|
75
|
+
* the longest. An earlier draft of
|
|
76
|
+
* this comment claimed "two orders of magnitude", which was wrong by 3x and
|
|
77
|
+
* which nothing checked — the cited test pins that the cap ADMITS the shipped
|
|
78
|
+
* commands, not any ratio: › "states its command-length cap as a whole number,
|
|
79
|
+
* and one that admits the commands the rig ships".
|
|
80
|
+
*/
|
|
81
|
+
export const MAX_HOOK_COMMAND_LENGTH = 4096;
|
|
82
|
+
/**
|
|
83
|
+
* How many tool names a reason may list before it says "and N more".
|
|
84
|
+
*
|
|
85
|
+
* The names come out of the snapshot's own matcher, so an unbounded join is
|
|
86
|
+
* both unbounded work and an unbounded string in an operator's terminal: ›
|
|
87
|
+
* "keeps the reason short when a thousand tools are added, and says how many it
|
|
88
|
+
* did not name".
|
|
89
|
+
*/
|
|
90
|
+
export const MAX_NAMED_TOOLS_IN_REASON = 5;
|
|
91
|
+
/**
|
|
92
|
+
* How much STRUCTURE this module will read before it refuses to read any.
|
|
93
|
+
*
|
|
94
|
+
* 🔴 Capping each command's length bounded the wrong axis alone. A snapshot is
|
|
95
|
+
* outside data, and its cardinalities were the caller's to choose: a hundred
|
|
96
|
+
* thousand groups, each carrying a hundred thousand hooks, is a snapshot whose
|
|
97
|
+
* every command is inside the length cap and whose total work is bounded by
|
|
98
|
+
* nothing. `rules/invariants.md` ("A guard that fails open must do provably
|
|
99
|
+
* bounded work") makes that the whole test — not "is it fast on realistic
|
|
100
|
+
* input" but "can any input make it do unbounded work at all" — and a consumer
|
|
101
|
+
* of this answer reads an absent verdict as no finding, so exhausting this
|
|
102
|
+
* module is a way to be reported on by nobody.
|
|
103
|
+
*
|
|
104
|
+
* Each cap is checked BEFORE the level it bounds is walked, and each refusal is
|
|
105
|
+
* `INTEGRATION-FAILED` naming the number crossed — never `UNSUPPORTED`, because
|
|
106
|
+
* a level nobody walked is not evidence that the mechanism is absent. That is
|
|
107
|
+
* the rule the over-long command already follows.
|
|
108
|
+
*
|
|
109
|
+
* A cap must admit the wiring this rig really ships, and that is asserted where
|
|
110
|
+
* the shipped file is actually opened rather than described here. Two tests, and
|
|
111
|
+
* the difference between them is the point: `test/template/policy-coverage.test.ts`
|
|
112
|
+
* (absent in a generated rig) › "keeps every level of the %s wiring this rig
|
|
113
|
+
* ships inside the caps the probe will read" reads each adapter's own surface
|
|
114
|
+
* file and measures its group, hook and matcher sizes against these three
|
|
115
|
+
* numbers, so a shipped surface file that grows past one of them goes red;
|
|
116
|
+
* `packages/cli/test/policy-coverage.test.ts` › "admits every level of the %s
|
|
117
|
+
* wiring this rig really ships, so no cap refuses honest work" makes the same
|
|
118
|
+
* assertion over the REGISTRY-DERIVED fixture, which is narrower and cannot
|
|
119
|
+
* stand in for the file. An earlier version of this paragraph quoted the three
|
|
120
|
+
* measured sizes and cited only the second test — a figure nothing regenerates,
|
|
121
|
+
* pointing at a test that never opens the thing it described.
|
|
122
|
+
*
|
|
123
|
+
* This module cannot name a surface file itself, and
|
|
124
|
+
* `test/template/policy-declaration.test.ts` › "no file under src/policy/core
|
|
125
|
+
* mentions a harness, a vendor, a native tool or a native path" is what stops it
|
|
126
|
+
* trying. The refusal direction is held in the unit file,
|
|
127
|
+
* `packages/cli/test/policy-coverage.test.ts` › "refuses a snapshot carrying
|
|
128
|
+
* one group more than it will read, naming the limit it crossed".
|
|
129
|
+
*
|
|
130
|
+
* ⚠ `MAX_MATCHER_LENGTH` is NOT set from the longest matcher this rig ships,
|
|
131
|
+
* which is far shorter. A cap is a promise about what
|
|
132
|
+
* this module still reads, and the contract had already made a wider one: ›
|
|
133
|
+
* "keeps the reason short when a thousand tools are added, and says how many it
|
|
134
|
+
* did not name" hands the probe a 4,935-character matcher and requires a
|
|
135
|
+
* `DEGRADED` answer with a bounded reason. Setting the cap below that would
|
|
136
|
+
* have turned an answer the suite already demands into a refusal — a cap
|
|
137
|
+
* chosen from the typical size rather than from the promise, which is how a
|
|
138
|
+
* bound quietly becomes a behaviour change. 8192 is the first power of two
|
|
139
|
+
* above what is already read.
|
|
140
|
+
*
|
|
141
|
+
* The work that admits is still bounded, and the product is what matters rather
|
|
142
|
+
* than any one factor: at most `MAX_HOOK_GROUPS` × `MAX_HOOKS_PER_GROUP` hook
|
|
143
|
+
* entries are classified, each against a fixed number of adapter fields capped
|
|
144
|
+
* at `MAX_HOOK_COMMAND_LENGTH`, and each group's matcher yields at most
|
|
145
|
+
* `MAX_MATCHER_LENGTH / 2` tools compared against a declaration of a handful.
|
|
146
|
+
*/
|
|
147
|
+
export const MAX_HOOK_GROUPS = 64;
|
|
148
|
+
export const MAX_HOOKS_PER_GROUP = 64;
|
|
149
|
+
export const MAX_MATCHER_LENGTH = 8192;
|
|
150
|
+
/** One horizontal tab, named so no source line carries an invisible one. */
|
|
151
|
+
const TAB = String.fromCharCode(9);
|
|
152
|
+
/**
|
|
153
|
+
* The command as a shell would word it, for comparison only.
|
|
154
|
+
*
|
|
155
|
+
* Runs of spaces and tabs collapse to one space, and a single leading or
|
|
156
|
+
* trailing space is then dropped. Those two are the whole tolerance — stated
|
|
157
|
+
* together because an omitted one is as misleading as an invented one, and the
|
|
158
|
+
* trim was missing from the first version of this paragraph. Space and tab are
|
|
159
|
+
* the only default IFS
|
|
160
|
+
* characters that separate WORDS without separating COMMANDS. A newline does
|
|
161
|
+
* separate commands — `node` on one line and the hook path on the next is two
|
|
162
|
+
* commands, the second executing the hook file directly — so collapsing it
|
|
163
|
+
* would manufacture a match against the generated string and hand back the one
|
|
164
|
+
* answer this module must never give without evidence. The same holds for
|
|
165
|
+
* carriage return, vertical tab, form feed and a non-breaking space, none of
|
|
166
|
+
* which a shell splits words on at all.
|
|
167
|
+
*
|
|
168
|
+
* Pinned in `packages/cli/test/policy-coverage.test.ts` (absent in a generated
|
|
169
|
+
* rig) › "still reads a %s wiring with %s as SUPPORTED, because a shell
|
|
170
|
+
* separates words on both" and, in the other direction, › "refuses a %s wiring
|
|
171
|
+
* whose spaces became %s, because a shell does not separate words on it".
|
|
172
|
+
*/
|
|
173
|
+
const normalise = (command) =>
|
|
174
|
+
// The tab is split out rather than matched: a literal tab inside a character
|
|
175
|
+
// class is a control character to a regular expression, which `no-control-regex`
|
|
176
|
+
// refuses - and rightly, because it is invisible to a reader.
|
|
177
|
+
command.split(TAB).join(' ').replace(/ +/g, ' ').replace(/^ | $/g, '');
|
|
178
|
+
/**
|
|
179
|
+
* Classify one hook entry by comparing every command the harness generates for
|
|
180
|
+
* this hook against the field it belongs to — never by parsing any of them.
|
|
181
|
+
*
|
|
182
|
+
* 🔴 This replaced a partial shell parser, and the reason is worth keeping
|
|
183
|
+
* because it was expensive to learn. Three gate rounds tried to decide "does
|
|
184
|
+
* this command execute the hook?" by reading shell syntax. Each round closed a
|
|
185
|
+
* class of false `SUPPORTED` and opened a new one: a `.bak` neighbour, then a
|
|
186
|
+
* conditional `&&` segment, then a quoted mention the splitter cut through,
|
|
187
|
+
* then an unterminated quote, a mismatched brace, a backgrounding `&` on a
|
|
188
|
+
* later segment, and finally an assignment whose command substitution can fail
|
|
189
|
+
* — which cannot be refused, because it is the shape this rig's own derived
|
|
190
|
+
* command uses. The input surface was the whole shell grammar and the error
|
|
191
|
+
* was asymmetric, so a partial parser could not win.
|
|
192
|
+
*
|
|
193
|
+
* The rig GENERATES its own wiring, so the probe compares against what it
|
|
194
|
+
* would generate. There is no grammar left to lose to.
|
|
195
|
+
*
|
|
196
|
+
* EVERY declared field must match, because a surface can run a different
|
|
197
|
+
* spelling per platform: reading only the first left a guard that had been
|
|
198
|
+
* replaced on one platform reading as enforced on all of them.
|
|
199
|
+
*
|
|
200
|
+
* The substring test survives, and its FAILURE DIRECTION is what makes that
|
|
201
|
+
* safe: it now only chooses between `INTEGRATION-FAILED` and `UNSUPPORTED` —
|
|
202
|
+
* two non-passing answers — so a false positive can no longer reach
|
|
203
|
+
* `SUPPORTED`. That inversion is the design: › "never reaches SUPPORTED from a
|
|
204
|
+
* mere mention of the hook path, whichever spelling the mention takes".
|
|
205
|
+
*
|
|
206
|
+
* ⚠ What it costs, stated because it is a real loss: a hand-written wiring
|
|
207
|
+
* that genuinely runs the hook — a bare `node <hookPath>`, a flag before the
|
|
208
|
+
* path, a different spelling of the same variable — is `INTEGRATION-FAILED`
|
|
209
|
+
* rather than `SUPPORTED`. That is "I cannot verify this" in place of a
|
|
210
|
+
* confident answer, which is the direction this contract is required to err
|
|
211
|
+
* in, but a rig wiring its hooks by hand will read as unverifiable.
|
|
212
|
+
*
|
|
213
|
+
* Over the length cap a command is not read at all: refusing to inspect is a
|
|
214
|
+
* third outcome, and a guard that did not look may not report that it found
|
|
215
|
+
* nothing (`rules/invariants.md`, "Refusing to inspect is a third outcome, not
|
|
216
|
+
* a match and not an error"). An adapter that generates NO command is the same
|
|
217
|
+
* outcome for the same reason — there is nothing to compare against, so there
|
|
218
|
+
* is nothing verified — but it carries its OWN cause and its own sentence,
|
|
219
|
+
* because the fault is in the adapter rather than in the surface being read: ›
|
|
220
|
+
* "refuses %s under a matcher that matches the declaration exactly, because
|
|
221
|
+
* there was nothing to compare it against", with the two refusals held apart
|
|
222
|
+
* by › "does not borrow the sentence of a wiring that really did name the
|
|
223
|
+
* hook, which still gets it".
|
|
224
|
+
*/
|
|
225
|
+
const classify = (entry, surface) => {
|
|
226
|
+
const fields = Object.keys(surface.commands);
|
|
227
|
+
// An adapter that generates no command names nothing to compare against, and
|
|
228
|
+
// `matched` starts true — so without this, EVERY hook entry, including an
|
|
229
|
+
// empty one, ran the loop zero times and came back as running the hook. A
|
|
230
|
+
// surface whose adapter says nothing about what it would generate is one this
|
|
231
|
+
// module can verify nothing about, which is `INTEGRATION-FAILED`, never a
|
|
232
|
+
// pass. The two shipped adapters both declare commands, so nothing today
|
|
233
|
+
// takes this branch; the type admits `{}` and adding an adapter is a
|
|
234
|
+
// documented extension point, which is how a new adapter would have been
|
|
235
|
+
// handed "every policy enforced" for free.
|
|
236
|
+
//
|
|
237
|
+
// 🔴 Its own cause, not a borrowed one. The first version returned
|
|
238
|
+
// `'spelling'`, which made the refusal say "something under <event> names
|
|
239
|
+
// <hookPath>" for a hook entry of `{}` — nothing named anything, and
|
|
240
|
+
// `mentions` had not even been computed. That is the failure this union
|
|
241
|
+
// exists to prevent, stated above and again at the ladder below, and both
|
|
242
|
+
// reviewers found it in the same round. The defect is also in the ADAPTER
|
|
243
|
+
// rather than the surface, and the sentence has to say so, or an operator
|
|
244
|
+
// goes looking through a wiring file that is fine.
|
|
245
|
+
if (fields.length === 0)
|
|
246
|
+
return { kind: 'unreadable', cause: 'nothing-generated' };
|
|
247
|
+
let matched = true;
|
|
248
|
+
let mentions = false;
|
|
249
|
+
for (const field of fields) {
|
|
250
|
+
const wired = entry[field] ?? '';
|
|
251
|
+
if (wired.length > MAX_HOOK_COMMAND_LENGTH)
|
|
252
|
+
return { kind: 'unreadable', cause: 'oversize' };
|
|
253
|
+
const generated = surface.commands[field] ?? [];
|
|
254
|
+
if (!generated.some((spelling) => normalise(spelling) === normalise(wired)))
|
|
255
|
+
matched = false;
|
|
256
|
+
if (wired.includes(surface.hookPath))
|
|
257
|
+
mentions = true;
|
|
258
|
+
}
|
|
259
|
+
if (matched)
|
|
260
|
+
return { kind: 'runs' };
|
|
261
|
+
return mentions ? { kind: 'unreadable', cause: 'spelling' } : { kind: 'unrelated' };
|
|
262
|
+
};
|
|
263
|
+
const toolsOf = (matcher) => matcher.split('|').filter((tool) => tool !== '');
|
|
264
|
+
const sameTools = (declared, wired) => declared.length === wired.length && declared.every((tool) => wired.includes(tool));
|
|
265
|
+
/** What the declaration names and the wiring does not, and the reverse. */
|
|
266
|
+
const differenceOf = (declared, wired) => ({
|
|
267
|
+
missing: declared.filter((tool) => !wired.includes(tool)),
|
|
268
|
+
extra: wired.filter((tool) => !declared.includes(tool)),
|
|
269
|
+
});
|
|
270
|
+
/**
|
|
271
|
+
* Name a bounded number of tools, each escaped.
|
|
272
|
+
*
|
|
273
|
+
* Both properties are required of a string assembled from a matcher that came
|
|
274
|
+
* off disk: bounded, or one probe produces a megabyte of diagnostic; escaped,
|
|
275
|
+
* or a segment carrying a newline and an ANSI sequence forges a line of the
|
|
276
|
+
* report it appears in. Held by › "escapes a tool name carrying a newline and
|
|
277
|
+
* an ANSI sequence, so a matcher cannot forge a line of the report".
|
|
278
|
+
*/
|
|
279
|
+
const names = (tools) => {
|
|
280
|
+
const shown = tools.slice(0, MAX_NAMED_TOOLS_IN_REASON).map(quote).join(', ');
|
|
281
|
+
const unnamed = tools.length - MAX_NAMED_TOOLS_IN_REASON;
|
|
282
|
+
return unnamed > 0 ? `${shown}, and ${String(unnamed)} more` : shown;
|
|
283
|
+
};
|
|
284
|
+
const describeDifference = (missing, extra) => {
|
|
285
|
+
const parts = [];
|
|
286
|
+
if (missing.length > 0)
|
|
287
|
+
parts.push(`does not cover ${names(missing)}`);
|
|
288
|
+
if (extra.length > 0)
|
|
289
|
+
parts.push(`also covers ${names(extra)}, which the policy does not`);
|
|
290
|
+
return parts.join('; ');
|
|
291
|
+
};
|
|
292
|
+
const unreadable = (what) => ({
|
|
293
|
+
state: 'INTEGRATION-FAILED',
|
|
294
|
+
reason: `the surface snapshot could not be read: ${what}`,
|
|
295
|
+
});
|
|
296
|
+
/**
|
|
297
|
+
* Probe one policy against one surface's snapshot. Pure: the snapshot is the
|
|
298
|
+
* caller's to obtain, and `snapshot` is `unknown` on purpose — the shape is
|
|
299
|
+
* checked at every level rather than trusted, because a surface file is
|
|
300
|
+
* outside data and a level silently skipped is a partial read reported as a
|
|
301
|
+
* whole one.
|
|
302
|
+
*/
|
|
303
|
+
export function probePolicy(policy, adapter, snapshot) {
|
|
304
|
+
if (!isRecord(snapshot)) {
|
|
305
|
+
return unreadable('it is not an object carrying a hooks field');
|
|
306
|
+
}
|
|
307
|
+
// Every level below is read through `carriesField`/`ownField` rather than
|
|
308
|
+
// with `in` and a bracket read. A snapshot is outside data, and the question
|
|
309
|
+
// this module answers is what the SURFACE wires — so a value the surface's
|
|
310
|
+
// own serialisation would not carry is not wiring, whether it sits on a
|
|
311
|
+
// prototype or in a non-enumerable own property. Reading one as wiring
|
|
312
|
+
// reported a hook that serialises as `{}` as running the generated command,
|
|
313
|
+
// which is the false SUPPORTED this contract exists to make impossible.
|
|
314
|
+
if (carriesField(snapshot, 'hooks') && !isRecord(ownField(snapshot, 'hooks'))) {
|
|
315
|
+
return unreadable('its hooks field is not an object of event names');
|
|
316
|
+
}
|
|
317
|
+
const surface = adapter.nativeSurfaceOf(policy);
|
|
318
|
+
const hooksField = ownField(snapshot, 'hooks');
|
|
319
|
+
const wiring = isRecord(hooksField) ? hooksField : {};
|
|
320
|
+
// An event that is ABSENT contributes no groups and is not a finding; an
|
|
321
|
+
// event PRESENT in a shape this cannot read is the finding. An event key the
|
|
322
|
+
// record does not itself carry is the first of those, not the second.
|
|
323
|
+
const groups = [];
|
|
324
|
+
if (carriesField(wiring, surface.event)) {
|
|
325
|
+
const under = ownField(wiring, surface.event);
|
|
326
|
+
if (!Array.isArray(under)) {
|
|
327
|
+
return unreadable(`the value under ${surface.event} is not a list of groups`);
|
|
328
|
+
}
|
|
329
|
+
if (under.length > MAX_HOOK_GROUPS) {
|
|
330
|
+
return unreadable(`${surface.event} carries more than ${String(MAX_HOOK_GROUPS)} groups, so none of them was read`);
|
|
331
|
+
}
|
|
332
|
+
for (const group of under) {
|
|
333
|
+
if (!isRecord(group)) {
|
|
334
|
+
return unreadable(`a group under ${surface.event} is not an object`);
|
|
335
|
+
}
|
|
336
|
+
const groupHooks = ownField(group, 'hooks');
|
|
337
|
+
if (!Array.isArray(groupHooks)) {
|
|
338
|
+
return unreadable(`the hooks of a group under ${surface.event} are not a list`);
|
|
339
|
+
}
|
|
340
|
+
if (groupHooks.length > MAX_HOOKS_PER_GROUP) {
|
|
341
|
+
return unreadable(`a group under ${surface.event} carries more than ${String(MAX_HOOKS_PER_GROUP)} hooks, so none of them was read`);
|
|
342
|
+
}
|
|
343
|
+
const hooks = [];
|
|
344
|
+
for (const hook of groupHooks) {
|
|
345
|
+
if (!isRecord(hook)) {
|
|
346
|
+
return unreadable(`a hook under ${surface.event} is not an object`);
|
|
347
|
+
}
|
|
348
|
+
// Every field this harness generates a command for has to be there and
|
|
349
|
+
// be a string. An absent one is not an honest absence: the harness
|
|
350
|
+
// writes them all, so a hook missing one is a wiring this module
|
|
351
|
+
// cannot vouch for on the platform that field serves.
|
|
352
|
+
const entry = {};
|
|
353
|
+
for (const field of Object.keys(surface.commands)) {
|
|
354
|
+
const wired = ownField(hook, field);
|
|
355
|
+
if (typeof wired !== 'string') {
|
|
356
|
+
return unreadable(`a hook under ${surface.event} has no readable ${field}`);
|
|
357
|
+
}
|
|
358
|
+
entry[field] = wired;
|
|
359
|
+
}
|
|
360
|
+
hooks.push(entry);
|
|
361
|
+
}
|
|
362
|
+
// The matcher is a level like any other, and it is the level the
|
|
363
|
+
// SUPPORTED/DEGRADED decision is read from. Coercing a present-but-
|
|
364
|
+
// unreadable one to `undefined` made it the EMPTY matcher, so an
|
|
365
|
+
// unreadable group answered DEGRADED — a level skipped, reported as a
|
|
366
|
+
// whole read. Absent stays absent; present-and-unreadable is a finding.
|
|
367
|
+
const carriedMatcher = ownField(group, 'matcher');
|
|
368
|
+
if (carriesField(group, 'matcher') && typeof carriedMatcher !== 'string') {
|
|
369
|
+
return unreadable(`the matcher of a group under ${surface.event} is not a string`);
|
|
370
|
+
}
|
|
371
|
+
if (typeof carriedMatcher === 'string' && carriedMatcher.length > MAX_MATCHER_LENGTH) {
|
|
372
|
+
return unreadable(`the matcher of a group under ${surface.event} is longer than ${String(MAX_MATCHER_LENGTH)} characters and was not read`);
|
|
373
|
+
}
|
|
374
|
+
groups.push({
|
|
375
|
+
matcher: typeof carriedMatcher === 'string' ? carriedMatcher : undefined,
|
|
376
|
+
hooks,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const declared = toolsOf(surface.matcher);
|
|
381
|
+
// One pass, three kinds. Whether anything merely NAMED the hook is carried
|
|
382
|
+
// across groups, because that is what separates "nothing here wires this"
|
|
383
|
+
// from "something here names it and I cannot verify that it runs" — the
|
|
384
|
+
// ABSENT/UNREADABLE pair this module keeps at every other level.
|
|
385
|
+
const running = [];
|
|
386
|
+
const refusals = new Set();
|
|
387
|
+
for (const group of groups) {
|
|
388
|
+
let runsHere = false;
|
|
389
|
+
for (const hook of group.hooks) {
|
|
390
|
+
const kind = classify(hook, surface);
|
|
391
|
+
if (kind.kind === 'runs')
|
|
392
|
+
runsHere = true;
|
|
393
|
+
else if (kind.kind === 'unreadable')
|
|
394
|
+
refusals.add(kind.cause);
|
|
395
|
+
}
|
|
396
|
+
if (runsHere)
|
|
397
|
+
running.push(group);
|
|
398
|
+
}
|
|
399
|
+
if (running.length === 0) {
|
|
400
|
+
// A cause that really happened, chosen from what was observed. Three of
|
|
401
|
+
// them now, and each has its own sentence for the same reason: a refusal
|
|
402
|
+
// that borrows another's cause sends an operator to the wrong file.
|
|
403
|
+
// `nothing-generated` is answered first because it is a fact about the
|
|
404
|
+
// ADAPTER — every entry under the event was classified by it, so no
|
|
405
|
+
// per-entry cause it might sit beside is informative.
|
|
406
|
+
if (refusals.has('nothing-generated')) {
|
|
407
|
+
return {
|
|
408
|
+
state: 'INTEGRATION-FAILED',
|
|
409
|
+
reason: `the ${adapter.harness} adapter generates no command for this policy, so nothing under ${surface.event} could be compared against one and whether ${surface.hookPath} runs cannot be verified`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (refusals.has('spelling')) {
|
|
413
|
+
return {
|
|
414
|
+
state: 'INTEGRATION-FAILED',
|
|
415
|
+
reason: `something under ${surface.event} names ${surface.hookPath}, but in no command this harness generates, so whether the hook runs cannot be verified from this surface`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (refusals.has('oversize')) {
|
|
419
|
+
return {
|
|
420
|
+
state: 'INTEGRATION-FAILED',
|
|
421
|
+
reason: `a command under ${surface.event} is longer than ${String(MAX_HOOK_COMMAND_LENGTH)} characters and was not read, so whether ${surface.hookPath} runs cannot be verified from this surface`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
state: 'UNSUPPORTED',
|
|
426
|
+
reason: `no group under ${surface.event} runs ${surface.hookPath}, so the mechanism is absent on this surface`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (running.some((group) => sameTools(declared, toolsOf(group.matcher ?? '')))) {
|
|
430
|
+
return { state: 'SUPPORTED' };
|
|
431
|
+
}
|
|
432
|
+
// Every group running the hook differs from the declaration; report the
|
|
433
|
+
// closest one, so the reason names a real discrepancy rather than a union of
|
|
434
|
+
// several. "Closest" is the fewest tools out of place.
|
|
435
|
+
const differences = running.map((group) => differenceOf(declared, toolsOf(group.matcher ?? '')));
|
|
436
|
+
const sizeOf = (d) => d.missing.length + d.extra.length;
|
|
437
|
+
const closest = differences.reduce((best, d) => (sizeOf(d) < sizeOf(best) ? d : best));
|
|
438
|
+
return {
|
|
439
|
+
state: 'DEGRADED',
|
|
440
|
+
reason: `${surface.hookPath} runs under ${surface.event}, but the matcher ${describeDifference(closest.missing, closest.extra)}`,
|
|
441
|
+
};
|
|
442
|
+
}
|