@bookedsolid/rea 0.26.1 → 0.28.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/README.md +16 -3
- package/agents/adversarial-test-specialist.md +113 -0
- package/agents/ast-parser-specialist.md +92 -0
- package/agents/codex-adversarial.md +50 -97
- package/agents/figma-dx-specialist.md +112 -0
- package/agents/mcp-protocol-specialist.md +94 -0
- package/agents/observability-specialist.md +103 -0
- package/agents/rea-orchestrator.md +25 -5
- package/agents/shell-scripting-specialist.md +101 -0
- package/commands/codex-review.md +62 -59
- package/data/claims/helix-022.json +51 -0
- package/data/claims/helix-023.json +44 -0
- package/data/claims/helix-024.json +72 -0
- package/data/claims/helix-028.json +23 -0
- package/data/claims/helix-031.json +27 -0
- package/dist/cli/hook.d.ts +78 -4
- package/dist/cli/hook.js +291 -4
- package/dist/cli/index.js +6 -0
- package/dist/cli/preflight.d.ts +12 -0
- package/dist/cli/preflight.js +65 -4
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.js +7 -0
- package/dist/cli/verify-claim.d.ts +149 -0
- package/dist/cli/verify-claim.js +386 -0
- package/dist/gateway/downstream-pool.d.ts +17 -0
- package/dist/gateway/downstream-pool.js +1 -0
- package/dist/gateway/downstream.d.ts +25 -0
- package/dist/gateway/downstream.js +40 -0
- package/dist/gateway/live-state.d.ts +12 -0
- package/dist/gateway/live-state.js +1 -0
- package/dist/hooks/bash-scanner/walker.js +196 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +9 -0
- package/dist/hooks/push-gate/codex-runner.js +14 -1
- package/dist/hooks/push-gate/findings.d.ts +27 -0
- package/dist/hooks/push-gate/findings.js +87 -0
- package/dist/hooks/push-gate/index.js +58 -4
- package/dist/hooks/push-gate/policy.d.ts +15 -0
- package/dist/hooks/push-gate/policy.js +82 -0
- package/dist/policy/loader.d.ts +20 -0
- package/dist/policy/loader.js +12 -0
- package/dist/policy/types.d.ts +31 -0
- package/hooks/_lib/cmd-segments.sh +10 -0
- package/hooks/blocked-paths-bash-gate.sh +12 -0
- package/hooks/protected-paths-bash-gate.sh +21 -0
- package/package.json +2 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea verify-claim <claim-id>` — replay a recorded security-claim PoC
|
|
3
|
+
* battery against the currently-installed (or in-tree dogfood) rea CLI.
|
|
4
|
+
*
|
|
5
|
+
* The centerpiece of 0.28.0 (4th structural pivot — claims as
|
|
6
|
+
* machine-verifiable artifacts rather than prose-only release notes).
|
|
7
|
+
*
|
|
8
|
+
* Each claim lives at `data/claims/<id>.json` and lists 1..N PoCs.
|
|
9
|
+
* Every PoC has a `type` that names the executor:
|
|
10
|
+
*
|
|
11
|
+
* - `scan-bash` (primary): pipes `input` into
|
|
12
|
+
* `dist/cli/index.js hook scan-bash --mode <protected|blocked>` and
|
|
13
|
+
* compares the resulting verdict to `expected_verdict`.
|
|
14
|
+
* - `shellcheck` (helix-031 case): runs shellcheck on `target` and
|
|
15
|
+
* asserts the run is clean (no SC<code> warnings).
|
|
16
|
+
*
|
|
17
|
+
* Resolution order for the rea CLI under test:
|
|
18
|
+
*
|
|
19
|
+
* - `--installed` → resolves to `<cwd>/node_modules/@bookedsolid/rea/dist/cli/index.js`.
|
|
20
|
+
* This is the canonical "verify against MY pinned rea" mode for
|
|
21
|
+
* consumers — tells them whether the version they actually have
|
|
22
|
+
* installed still rejects the PoCs the claim targets.
|
|
23
|
+
* - default → uses the same `dist/cli/index.js` that ships with the
|
|
24
|
+
* CLI itself (i.e. the rea repo's own dogfood). Resolved relative
|
|
25
|
+
* to the running script.
|
|
26
|
+
*
|
|
27
|
+
* Exit codes:
|
|
28
|
+
*
|
|
29
|
+
* - 0 — every PoC matched the recorded `expected_verdict`.
|
|
30
|
+
* - 1 — at least one PoC mismatched (regression — investigate).
|
|
31
|
+
* - 2 — claim id is unknown / no JSON file at `data/claims/<id>.json`.
|
|
32
|
+
*/
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { err } from './utils.js';
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Loader
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the directory holding the bundled claim JSON files. Walks up
|
|
43
|
+
* from the running script (or from this file at dev time) looking for
|
|
44
|
+
* a `data/claims/` sibling. Returns null when the directory cannot be
|
|
45
|
+
* located — the caller falls back to whatever `claimsDir` override was
|
|
46
|
+
* passed.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveDefaultClaimsDir() {
|
|
49
|
+
// The compiled CLI runs from `dist/cli/index.js` — walk up to the
|
|
50
|
+
// package root, then look for `data/claims/`.
|
|
51
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
let cur = here;
|
|
53
|
+
for (let i = 0; i < 8 && cur && cur !== path.dirname(cur); i += 1) {
|
|
54
|
+
const cand = path.join(cur, 'data', 'claims');
|
|
55
|
+
if (fs.existsSync(cand))
|
|
56
|
+
return cand;
|
|
57
|
+
cur = path.dirname(cur);
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Load and validate a claim file. Throws on malformed JSON or shape
|
|
63
|
+
* mismatch — `runVerifyClaim` translates the throw into exit-code 2 +
|
|
64
|
+
* a stderr message.
|
|
65
|
+
*/
|
|
66
|
+
export function loadClaim(claimsDir, claimId) {
|
|
67
|
+
// Defensive: claim ids are constrained to a kebab-case shape so a
|
|
68
|
+
// crafted argv can't escape the directory ('../../etc/passwd'). The
|
|
69
|
+
// CLI argument is also passed verbatim to fs.readFileSync, so a
|
|
70
|
+
// non-conforming id should hard-fail before any disk access.
|
|
71
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(claimId)) {
|
|
72
|
+
throw new Error(`verify-claim: invalid claim id ${JSON.stringify(claimId)} ` +
|
|
73
|
+
`(allowed: kebab-case [a-z0-9][a-z0-9._-]*)`);
|
|
74
|
+
}
|
|
75
|
+
const file = path.join(claimsDir, `${claimId}.json`);
|
|
76
|
+
if (!fs.existsSync(file)) {
|
|
77
|
+
throw new Error(`verify-claim: unknown claim id ${JSON.stringify(claimId)} (expected ${file})`);
|
|
78
|
+
}
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
throw new Error(`verify-claim: could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
85
|
+
}
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
throw new Error(`verify-claim: ${file} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
92
|
+
}
|
|
93
|
+
return validateClaim(parsed, file);
|
|
94
|
+
}
|
|
95
|
+
function validateClaim(parsed, source) {
|
|
96
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
97
|
+
throw new Error(`verify-claim: ${source} top-level must be an object`);
|
|
98
|
+
}
|
|
99
|
+
const obj = parsed;
|
|
100
|
+
const id = obj.id;
|
|
101
|
+
const title = obj.title;
|
|
102
|
+
const introducedIn = obj.introduced_in;
|
|
103
|
+
const closedIn = obj.closed_in;
|
|
104
|
+
const summary = obj.summary;
|
|
105
|
+
const pocs = obj.pocs;
|
|
106
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
107
|
+
throw new Error(`verify-claim: ${source} requires a non-empty string \`id\``);
|
|
108
|
+
}
|
|
109
|
+
if (typeof title !== 'string' || title.length === 0) {
|
|
110
|
+
throw new Error(`verify-claim: ${source} requires a non-empty string \`title\``);
|
|
111
|
+
}
|
|
112
|
+
if (typeof introducedIn !== 'string' || introducedIn.length === 0) {
|
|
113
|
+
throw new Error(`verify-claim: ${source} requires a non-empty string \`introduced_in\``);
|
|
114
|
+
}
|
|
115
|
+
if (typeof closedIn !== 'string' || closedIn.length === 0) {
|
|
116
|
+
throw new Error(`verify-claim: ${source} requires a non-empty string \`closed_in\``);
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(pocs) || pocs.length === 0) {
|
|
119
|
+
throw new Error(`verify-claim: ${source} requires a non-empty \`pocs\` array`);
|
|
120
|
+
}
|
|
121
|
+
const validatedPocs = pocs.map((p, idx) => validatePoC(p, source, idx));
|
|
122
|
+
const claim = {
|
|
123
|
+
id,
|
|
124
|
+
title,
|
|
125
|
+
introduced_in: introducedIn,
|
|
126
|
+
closed_in: closedIn,
|
|
127
|
+
pocs: validatedPocs,
|
|
128
|
+
};
|
|
129
|
+
if (typeof summary === 'string' && summary.length > 0) {
|
|
130
|
+
claim.summary = summary;
|
|
131
|
+
}
|
|
132
|
+
return claim;
|
|
133
|
+
}
|
|
134
|
+
function validatePoC(parsed, source, index) {
|
|
135
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
136
|
+
throw new Error(`verify-claim: ${source} pocs[${index}] must be an object`);
|
|
137
|
+
}
|
|
138
|
+
const obj = parsed;
|
|
139
|
+
const id = obj.id;
|
|
140
|
+
const type = obj.type;
|
|
141
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
142
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].id must be a non-empty string`);
|
|
143
|
+
}
|
|
144
|
+
if (type === 'scan-bash') {
|
|
145
|
+
const input = obj.input;
|
|
146
|
+
const mode = obj.mode;
|
|
147
|
+
const expected = obj.expected_verdict;
|
|
148
|
+
if (typeof input !== 'string') {
|
|
149
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].input must be a string`);
|
|
150
|
+
}
|
|
151
|
+
if (mode !== 'protected' && mode !== 'blocked') {
|
|
152
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].mode must be 'protected' | 'blocked'`);
|
|
153
|
+
}
|
|
154
|
+
if (expected !== 'allow' && expected !== 'block') {
|
|
155
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].expected_verdict must be 'allow' | 'block'`);
|
|
156
|
+
}
|
|
157
|
+
return { id, type: 'scan-bash', input, mode, expected_verdict: expected };
|
|
158
|
+
}
|
|
159
|
+
if (type === 'shellcheck') {
|
|
160
|
+
const target = obj.target;
|
|
161
|
+
const expected = obj.expected_verdict;
|
|
162
|
+
if (typeof target !== 'string' || target.length === 0) {
|
|
163
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].target must be a non-empty string`);
|
|
164
|
+
}
|
|
165
|
+
if (expected !== 'clean') {
|
|
166
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].expected_verdict must be 'clean'`);
|
|
167
|
+
}
|
|
168
|
+
return { id, type: 'shellcheck', target, expected_verdict: expected };
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`verify-claim: ${source} pocs[${index}].type must be 'scan-bash' | 'shellcheck' (got ${JSON.stringify(type)})`);
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Executors
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
/**
|
|
176
|
+
* Resolve the rea CLI to invoke for `scan-bash` PoCs.
|
|
177
|
+
*
|
|
178
|
+
* Precedence: cliOverride > --installed > sibling dogfood dist/cli/index.js.
|
|
179
|
+
*
|
|
180
|
+
* Returns a pair `[command, args]` so the caller can do
|
|
181
|
+
* `spawnSync(cmd, [...args, 'hook', 'scan-bash', ...])`. The shape
|
|
182
|
+
* keeps node-vs-direct-binary differences localized to this resolver.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveCli(opts) {
|
|
185
|
+
if (opts.cliOverride !== undefined && opts.cliOverride.length > 0) {
|
|
186
|
+
const abs = path.resolve(opts.cliOverride);
|
|
187
|
+
return { cmd: process.execPath, args: [abs], path: abs };
|
|
188
|
+
}
|
|
189
|
+
if (opts.installed === true) {
|
|
190
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
191
|
+
const installed = path.join(cwd, 'node_modules', '@bookedsolid', 'rea', 'dist', 'cli', 'index.js');
|
|
192
|
+
if (!fs.existsSync(installed)) {
|
|
193
|
+
throw new Error(`verify-claim --installed: not found at ${installed}. ` +
|
|
194
|
+
`Install @bookedsolid/rea in the current project.`);
|
|
195
|
+
}
|
|
196
|
+
return { cmd: process.execPath, args: [installed], path: installed };
|
|
197
|
+
}
|
|
198
|
+
// Default: walk up from this file to find the dogfood dist/cli/index.js.
|
|
199
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
200
|
+
let cur = here;
|
|
201
|
+
for (let i = 0; i < 8 && cur && cur !== path.dirname(cur); i += 1) {
|
|
202
|
+
const cand = path.join(cur, 'dist', 'cli', 'index.js');
|
|
203
|
+
if (fs.existsSync(cand)) {
|
|
204
|
+
return { cmd: process.execPath, args: [cand], path: cand };
|
|
205
|
+
}
|
|
206
|
+
cur = path.dirname(cur);
|
|
207
|
+
}
|
|
208
|
+
throw new Error('verify-claim: could not locate dist/cli/index.js. Run `pnpm build` or pass --installed.');
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Run a single PoC against the resolved CLI. Pure function — no global
|
|
212
|
+
* state, all dependencies threaded through `cliCmd` / `cliArgs` / `spawn`.
|
|
213
|
+
* Tests substitute `spawn` with a fake.
|
|
214
|
+
*/
|
|
215
|
+
export function runPoC(poc, cliCmd, cliArgs, spawn = spawnSync, cwd = process.cwd()) {
|
|
216
|
+
if (poc.type === 'scan-bash') {
|
|
217
|
+
const args = [...cliArgs, 'hook', 'scan-bash', '--mode', poc.mode];
|
|
218
|
+
const result = spawn(cliCmd, args, { input: poc.input, encoding: 'utf8', timeout: 30_000 });
|
|
219
|
+
let actual = 'error';
|
|
220
|
+
let detail = '';
|
|
221
|
+
if (result.error) {
|
|
222
|
+
detail = `spawn error: ${result.error.message}`;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// CLI contract: exit 0 = allow, 2 = block, 1 = error. Stdout
|
|
226
|
+
// carries the verdict JSON. Prefer the JSON shape (richer, but
|
|
227
|
+
// exit code is the floor).
|
|
228
|
+
const stdout = result.stdout ?? '';
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(stdout.trim());
|
|
231
|
+
if (parsed.verdict === 'allow' || parsed.verdict === 'block') {
|
|
232
|
+
actual = parsed.verdict;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
detail = `verdict JSON missing valid \`verdict\` field; stdout=${stdout.slice(0, 200)}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
if (result.status === 0) {
|
|
240
|
+
actual = 'allow';
|
|
241
|
+
}
|
|
242
|
+
else if (result.status === 2) {
|
|
243
|
+
actual = 'block';
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
detail = `unparseable stdout; exit=${result.status} stdout=${stdout.slice(0, 200)} stderr=${(result.stderr ?? '').slice(0, 200)}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const match = actual === poc.expected_verdict;
|
|
251
|
+
return {
|
|
252
|
+
poc_id: poc.id,
|
|
253
|
+
type: 'scan-bash',
|
|
254
|
+
expected: poc.expected_verdict,
|
|
255
|
+
actual,
|
|
256
|
+
match,
|
|
257
|
+
detail: match ? '' : detail.length > 0 ? detail : `expected ${poc.expected_verdict}, got ${actual}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// shellcheck
|
|
261
|
+
const target = path.isAbsolute(poc.target) ? poc.target : path.join(cwd, poc.target);
|
|
262
|
+
// -S error excludes warnings/info; but the claim contract is "no SC<code>
|
|
263
|
+
// warnings" — keep severity at the default (warning) so SC1078 surfaces.
|
|
264
|
+
// We allow stderr to be non-empty (shellcheck prints debug noise on
|
|
265
|
+
// some versions) — only the exit code + stdout-line count matters.
|
|
266
|
+
const result = spawn('shellcheck', [target], { encoding: 'utf8', timeout: 30_000 });
|
|
267
|
+
if (result.error !== undefined) {
|
|
268
|
+
// shellcheck not installed or otherwise broken. Treat as
|
|
269
|
+
// "indeterminate" — we can't refute the claim without the tool, so
|
|
270
|
+
// the safer posture is to FAIL the verification so a missing
|
|
271
|
+
// shellcheck doesn't silently bless every claim.
|
|
272
|
+
return {
|
|
273
|
+
poc_id: poc.id,
|
|
274
|
+
type: 'shellcheck',
|
|
275
|
+
expected: 'clean',
|
|
276
|
+
actual: 'error',
|
|
277
|
+
match: false,
|
|
278
|
+
detail: `shellcheck unavailable: ${result.error.message}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const clean = result.status === 0 && (result.stdout ?? '').trim().length === 0;
|
|
282
|
+
return {
|
|
283
|
+
poc_id: poc.id,
|
|
284
|
+
type: 'shellcheck',
|
|
285
|
+
expected: 'clean',
|
|
286
|
+
actual: clean ? 'clean' : 'warnings',
|
|
287
|
+
match: clean,
|
|
288
|
+
detail: clean
|
|
289
|
+
? ''
|
|
290
|
+
: `shellcheck exit=${result.status}; output=${(result.stdout ?? '').slice(0, 400)}`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Run all PoCs in a claim. Pure — exposed so tests can drive without
|
|
295
|
+
* spawning processes if they substitute `spawn`.
|
|
296
|
+
*/
|
|
297
|
+
export function runVerifyClaimSync(claim, cliCmd, cliArgs, cliPath, spawn = spawnSync, cwd = process.cwd()) {
|
|
298
|
+
const results = [];
|
|
299
|
+
let matched = 0;
|
|
300
|
+
let mismatched = 0;
|
|
301
|
+
for (const poc of claim.pocs) {
|
|
302
|
+
const r = runPoC(poc, cliCmd, cliArgs, spawn, cwd);
|
|
303
|
+
results.push(r);
|
|
304
|
+
if (r.match)
|
|
305
|
+
matched += 1;
|
|
306
|
+
else
|
|
307
|
+
mismatched += 1;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
claim_id: claim.id,
|
|
311
|
+
cli: cliPath,
|
|
312
|
+
total: claim.pocs.length,
|
|
313
|
+
matched,
|
|
314
|
+
mismatched,
|
|
315
|
+
results,
|
|
316
|
+
exit_code: mismatched > 0 ? 1 : 0,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// CLI entry
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
export async function runVerifyClaim(claimId, opts) {
|
|
323
|
+
const claimsDir = opts.claimsDir ?? resolveDefaultClaimsDir();
|
|
324
|
+
if (claimsDir === null) {
|
|
325
|
+
err('verify-claim: could not locate data/claims/ directory. ' +
|
|
326
|
+
'This is a bug in the install or a stripped tarball.');
|
|
327
|
+
process.exit(2);
|
|
328
|
+
}
|
|
329
|
+
let claim;
|
|
330
|
+
try {
|
|
331
|
+
claim = loadClaim(claimsDir, claimId);
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
err(e instanceof Error ? e.message : String(e));
|
|
335
|
+
process.exit(2);
|
|
336
|
+
}
|
|
337
|
+
let resolved;
|
|
338
|
+
try {
|
|
339
|
+
resolved = resolveCli(opts);
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
err(e instanceof Error ? e.message : String(e));
|
|
343
|
+
process.exit(2);
|
|
344
|
+
}
|
|
345
|
+
const result = runVerifyClaimSync(claim, resolved.cmd, resolved.args, resolved.path, spawnSync, opts.cwd ?? process.cwd());
|
|
346
|
+
if (opts.json === true) {
|
|
347
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// Human-readable summary on stderr — keeps stdout clean for jq pipes
|
|
351
|
+
// (consistent with `rea status`, `rea hook codex-review`).
|
|
352
|
+
process.stderr.write(`[verify-claim] ${claim.id} — ${claim.title}\n`);
|
|
353
|
+
process.stderr.write(`[verify-claim] cli=${resolved.path}\n`);
|
|
354
|
+
process.stderr.write(`[verify-claim] introduced_in=${claim.introduced_in} closed_in=${claim.closed_in}\n`);
|
|
355
|
+
for (const r of result.results) {
|
|
356
|
+
const tag = r.match ? 'PASS' : 'FAIL';
|
|
357
|
+
process.stderr.write(`[verify-claim] ${tag} ${r.poc_id} expected=${r.expected} actual=${r.actual}` +
|
|
358
|
+
(r.detail.length > 0 ? ` (${r.detail})` : '') +
|
|
359
|
+
'\n');
|
|
360
|
+
}
|
|
361
|
+
process.stderr.write(`[verify-claim] ${result.matched}/${result.total} PoCs matched (mismatched=${result.mismatched})\n`);
|
|
362
|
+
}
|
|
363
|
+
process.exit(result.exit_code);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Attach `rea verify-claim <claim-id>` to the commander program.
|
|
367
|
+
*/
|
|
368
|
+
export function registerVerifyClaimCommand(program) {
|
|
369
|
+
program
|
|
370
|
+
.command('verify-claim')
|
|
371
|
+
.description('Replay a recorded security-claim PoC battery against the rea CLI under test. ' +
|
|
372
|
+
'Each claim at `data/claims/<id>.json` lists 1..N PoCs (scan-bash inputs or ' +
|
|
373
|
+
'shellcheck targets) with expected verdicts. Exit 0 = all matched, 1 = mismatch, ' +
|
|
374
|
+
'2 = unknown claim id.')
|
|
375
|
+
.argument('<claim-id>', 'claim identifier (kebab-case; corresponds to data/claims/<id>.json)')
|
|
376
|
+
.option('--installed', 'verify against `node_modules/@bookedsolid/rea/dist/cli/index.js` ' +
|
|
377
|
+
'in the current working directory (consumer-pinned version) ' +
|
|
378
|
+
'instead of the dogfood build')
|
|
379
|
+
.option('--json', 'emit a single-line JSON result on stdout')
|
|
380
|
+
.action(async (claimId, opts) => {
|
|
381
|
+
await runVerifyClaim(claimId, {
|
|
382
|
+
...(opts.installed === true ? { installed: true } : {}),
|
|
383
|
+
...(opts.json === true ? { json: true } : {}),
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
@@ -29,6 +29,23 @@ export interface DownstreamHealth {
|
|
|
29
29
|
healthy: boolean;
|
|
30
30
|
/** Last error observed, or null if the connection is clean or never errored. */
|
|
31
31
|
last_error: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* 0.28.0 helix-025 F1 — explicit tri-state for the connection lifecycle.
|
|
34
|
+
*
|
|
35
|
+
* - `'never'` — the downstream has not yet been attempted (gateway
|
|
36
|
+
* boot before the first `connectAll` lands, or connection refused
|
|
37
|
+
* so early no error message reached the supervisor)
|
|
38
|
+
* - `'ok'` — most recent connect / call succeeded
|
|
39
|
+
* - `'errored'` — currently in an error state; `last_error` is set
|
|
40
|
+
*
|
|
41
|
+
* Pre-fix the helix consumer saw `connected: false, healthy: false,
|
|
42
|
+
* last_error: null` after a downstream's child failed to spawn — the
|
|
43
|
+
* agent had no way to tell whether the connection had been attempted
|
|
44
|
+
* at all. The tri-state distinguishes "never tried" from "tried and
|
|
45
|
+
* failed" even when the underlying error never produced a renderable
|
|
46
|
+
* string (e.g. ECONNREFUSED before the supervisor wired its hooks).
|
|
47
|
+
*/
|
|
48
|
+
connection_state: 'never' | 'ok' | 'errored';
|
|
32
49
|
/**
|
|
33
50
|
* Number of tools advertised by the downstream on the most recent
|
|
34
51
|
* successful `tools/list`, or null when never listed / listing failed.
|
|
@@ -188,6 +188,15 @@ export declare class DownstreamConnection {
|
|
|
188
188
|
* instead of watching the child die again.
|
|
189
189
|
*/
|
|
190
190
|
private unexpectedDeathAt;
|
|
191
|
+
/**
|
|
192
|
+
* 0.28.0 helix-025 F1 — flips to true the first time `connect()` is
|
|
193
|
+
* invoked (regardless of outcome). Drives the `'never'` arm of the
|
|
194
|
+
* tri-state surfaced via `connectionState`. Any path that touches
|
|
195
|
+
* `this.client`, `this.#lastErrorMessage`, or `this.health` runs
|
|
196
|
+
* AFTER `connect()` has set this — so a single boolean is sufficient
|
|
197
|
+
* to tell "supervisor has tried at least once" from "never attempted".
|
|
198
|
+
*/
|
|
199
|
+
private everAttemptedConnect;
|
|
191
200
|
private health;
|
|
192
201
|
/**
|
|
193
202
|
* Optional supervisor-event listener. Set via
|
|
@@ -278,6 +287,22 @@ export declare class DownstreamConnection {
|
|
|
278
287
|
* backing field instead of going through the setter).
|
|
279
288
|
*/
|
|
280
289
|
get lastError(): string | null;
|
|
290
|
+
/**
|
|
291
|
+
* 0.28.0 helix-025 F1 — explicit tri-state for the lifecycle:
|
|
292
|
+
*
|
|
293
|
+
* `'never'` — connect() has not yet been called (the connection
|
|
294
|
+
* was constructed but the gateway hasn't gotten to
|
|
295
|
+
* the connectAll loop, or the entire pool hasn't
|
|
296
|
+
* booted)
|
|
297
|
+
* `'ok'` — the most recent connect/call cleared lastError;
|
|
298
|
+
* the supervisor considers the link live
|
|
299
|
+
* `'errored'` — there is a current error or the connection is
|
|
300
|
+
* unhealthy after at least one attempt
|
|
301
|
+
*
|
|
302
|
+
* The tri-state is derived — no separate state machine — so it
|
|
303
|
+
* cannot drift from the underlying connect/error flow.
|
|
304
|
+
*/
|
|
305
|
+
get connectionState(): 'never' | 'ok' | 'errored';
|
|
281
306
|
connect(): Promise<void>;
|
|
282
307
|
listTools(): Promise<DownstreamToolInfo[]>;
|
|
283
308
|
/**
|
|
@@ -174,6 +174,15 @@ export class DownstreamConnection {
|
|
|
174
174
|
* instead of watching the child die again.
|
|
175
175
|
*/
|
|
176
176
|
unexpectedDeathAt = 0;
|
|
177
|
+
/**
|
|
178
|
+
* 0.28.0 helix-025 F1 — flips to true the first time `connect()` is
|
|
179
|
+
* invoked (regardless of outcome). Drives the `'never'` arm of the
|
|
180
|
+
* tri-state surfaced via `connectionState`. Any path that touches
|
|
181
|
+
* `this.client`, `this.#lastErrorMessage`, or `this.health` runs
|
|
182
|
+
* AFTER `connect()` has set this — so a single boolean is sufficient
|
|
183
|
+
* to tell "supervisor has tried at least once" from "never attempted".
|
|
184
|
+
*/
|
|
185
|
+
everAttemptedConnect = false;
|
|
177
186
|
health = 'healthy';
|
|
178
187
|
/**
|
|
179
188
|
* Optional supervisor-event listener. Set via
|
|
@@ -378,9 +387,40 @@ export class DownstreamConnection {
|
|
|
378
387
|
return null;
|
|
379
388
|
return boundedDiagnosticString(raw);
|
|
380
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* 0.28.0 helix-025 F1 — explicit tri-state for the lifecycle:
|
|
392
|
+
*
|
|
393
|
+
* `'never'` — connect() has not yet been called (the connection
|
|
394
|
+
* was constructed but the gateway hasn't gotten to
|
|
395
|
+
* the connectAll loop, or the entire pool hasn't
|
|
396
|
+
* booted)
|
|
397
|
+
* `'ok'` — the most recent connect/call cleared lastError;
|
|
398
|
+
* the supervisor considers the link live
|
|
399
|
+
* `'errored'` — there is a current error or the connection is
|
|
400
|
+
* unhealthy after at least one attempt
|
|
401
|
+
*
|
|
402
|
+
* The tri-state is derived — no separate state machine — so it
|
|
403
|
+
* cannot drift from the underlying connect/error flow.
|
|
404
|
+
*/
|
|
405
|
+
get connectionState() {
|
|
406
|
+
if (!this.everAttemptedConnect)
|
|
407
|
+
return 'never';
|
|
408
|
+
if (this.health === 'unhealthy')
|
|
409
|
+
return 'errored';
|
|
410
|
+
if (this.#lastErrorMessage !== null)
|
|
411
|
+
return 'errored';
|
|
412
|
+
return 'ok';
|
|
413
|
+
}
|
|
381
414
|
async connect() {
|
|
382
415
|
if (this.client !== null)
|
|
383
416
|
return;
|
|
417
|
+
// 0.28.0 helix-025 F1: stamp the "ever-attempted" flag BEFORE any
|
|
418
|
+
// failure paths fire — a connect() that throws on env-resolution
|
|
419
|
+
// still counts as "we tried", so the tri-state moves out of
|
|
420
|
+
// `'never'` even when no error string is renderable. The flag
|
|
421
|
+
// never resets; once attempted, the connection is in 'ok' or
|
|
422
|
+
// 'errored' for the rest of its life.
|
|
423
|
+
this.everAttemptedConnect = true;
|
|
384
424
|
// Resolve env BEFORE spawning. If any `${VAR}` reference in the registry's
|
|
385
425
|
// explicit env: map is unset at startup, refuse to spawn this server:
|
|
386
426
|
// - log a clear, secret-safe error (only the var name appears; the
|
|
@@ -85,6 +85,18 @@ export interface LiveDownstreamState {
|
|
|
85
85
|
/** ISO timestamp when the circuit is expected to move to half-open. Only present when `open`. */
|
|
86
86
|
retry_at: string | null;
|
|
87
87
|
last_error: string | null;
|
|
88
|
+
/**
|
|
89
|
+
* 0.28.0 helix-025 F1 — explicit tri-state for the connection
|
|
90
|
+
* lifecycle. `'never'` means the supervisor has not attempted to
|
|
91
|
+
* connect yet; `'ok'` means the most recent attempt cleared
|
|
92
|
+
* lastError; `'errored'` means a connect or call failed and the
|
|
93
|
+
* downstream is currently considered unhealthy. Mirrors
|
|
94
|
+
* `DownstreamHealth.connection_state` in `downstream-pool.ts`.
|
|
95
|
+
* Optional in the type to keep older state-file readers
|
|
96
|
+
* compatible — pre-0.28.0 snapshots that lack the field surface
|
|
97
|
+
* as `null` in `rea status` rather than crashing the parse.
|
|
98
|
+
*/
|
|
99
|
+
connection_state?: 'never' | 'ok' | 'errored';
|
|
88
100
|
tools_count: number | null;
|
|
89
101
|
/** Cumulative circuit-open transitions counted toward SESSION_BLOCKER. */
|
|
90
102
|
open_transitions: number;
|
|
@@ -496,6 +496,7 @@ export class LiveStatePublisher {
|
|
|
496
496
|
circuit_state: circuitState,
|
|
497
497
|
retry_at: retryAt,
|
|
498
498
|
last_error: lastError,
|
|
499
|
+
connection_state: h.connection_state,
|
|
499
500
|
tools_count: h.tools_count,
|
|
500
501
|
open_transitions: blocker?.open_transitions ?? 0,
|
|
501
502
|
session_blocker_emitted: blocker?.emitted ?? false,
|