@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,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "helix-023",
|
|
3
|
+
"title": "0.23.0 structural rewrite — bash hooks → Node-binary parser-backed scanner via mvdan-sh AST",
|
|
4
|
+
"introduced_in": "<=0.22.0",
|
|
5
|
+
"closed_in": "0.23.0",
|
|
6
|
+
"summary": "13 codex adversarial rounds + 12,875-fixture corpus across 23 classes A-W. Closure ladder 34 → 14 → 9 → 8 → 5 → 2 → 1 → 1 → 3 → 2 → 7 → 9 → 9 (round-13 deferred to 0.24.0 as denylist-asymptotic per codex). PoCs sample one fixture from each closure-ladder round to verify the parser-backed scanner still rejects them.",
|
|
7
|
+
"pocs": [
|
|
8
|
+
{
|
|
9
|
+
"id": "round1.alias-then-invoke",
|
|
10
|
+
"type": "scan-bash",
|
|
11
|
+
"input": "alias evil='echo x > .rea/HALT'; evil",
|
|
12
|
+
"mode": "protected",
|
|
13
|
+
"expected_verdict": "block"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "round1.heredoc-into-shell",
|
|
17
|
+
"type": "scan-bash",
|
|
18
|
+
"input": "bash <<'EOF'\necho x > .rea/HALT\nEOF",
|
|
19
|
+
"mode": "protected",
|
|
20
|
+
"expected_verdict": "block"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "round1.process-substitution",
|
|
24
|
+
"type": "scan-bash",
|
|
25
|
+
"input": "diff <(echo a) <(rm -rf .rea)",
|
|
26
|
+
"mode": "protected",
|
|
27
|
+
"expected_verdict": "block"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "round2.tee-redirect",
|
|
31
|
+
"type": "scan-bash",
|
|
32
|
+
"input": "echo x | tee .rea/HALT",
|
|
33
|
+
"mode": "protected",
|
|
34
|
+
"expected_verdict": "block"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "round3.pipe-into-bash",
|
|
38
|
+
"type": "scan-bash",
|
|
39
|
+
"input": "echo 'echo x > .rea/HALT' | bash",
|
|
40
|
+
"mode": "protected",
|
|
41
|
+
"expected_verdict": "block"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "helix-024",
|
|
3
|
+
"title": "Round-24 kill-switch bypass classes (cd-cwd, double-eval, ln-symlink) — 9 PoCs",
|
|
4
|
+
"introduced_in": "0.23.0",
|
|
5
|
+
"closed_in": "0.23.1",
|
|
6
|
+
"summary": "Three P1 bypass classes against 0.23.0 closed via cd-target tracking + bash-eval payload re-parse + ln-symlink target sandbox. 5 codex local pre-push rounds caught every refinement-introduced over-block. PR #131 landed green-first-try.",
|
|
7
|
+
"pocs": [
|
|
8
|
+
{
|
|
9
|
+
"id": "F1.1.cd-and-write-literal",
|
|
10
|
+
"type": "scan-bash",
|
|
11
|
+
"input": "cd .rea && echo x > HALT",
|
|
12
|
+
"mode": "protected",
|
|
13
|
+
"expected_verdict": "block"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "F1.2.cd-with-quoted-target",
|
|
17
|
+
"type": "scan-bash",
|
|
18
|
+
"input": "cd '.rea' && echo x > HALT",
|
|
19
|
+
"mode": "protected",
|
|
20
|
+
"expected_verdict": "block"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "F1.3.cd-then-write-via-redirect",
|
|
24
|
+
"type": "scan-bash",
|
|
25
|
+
"input": "cd .husky; echo x > pre-push",
|
|
26
|
+
"mode": "protected",
|
|
27
|
+
"expected_verdict": "block"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "F1.4.pushd-then-write",
|
|
31
|
+
"type": "scan-bash",
|
|
32
|
+
"input": "pushd .rea && cat /tmp/payload > HALT",
|
|
33
|
+
"mode": "protected",
|
|
34
|
+
"expected_verdict": "block"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "F1.5.cd-with-binary-and-write",
|
|
38
|
+
"type": "scan-bash",
|
|
39
|
+
"input": "cd .claude && echo x > settings.json",
|
|
40
|
+
"mode": "protected",
|
|
41
|
+
"expected_verdict": "block"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "F2.1.double-eval-string",
|
|
45
|
+
"type": "scan-bash",
|
|
46
|
+
"input": "eval 'echo x > .rea/HALT'",
|
|
47
|
+
"mode": "protected",
|
|
48
|
+
"expected_verdict": "block"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "F2.2.eval-bash-c-string",
|
|
52
|
+
"type": "scan-bash",
|
|
53
|
+
"input": "eval \"bash -c 'echo x > .rea/HALT'\"",
|
|
54
|
+
"mode": "protected",
|
|
55
|
+
"expected_verdict": "block"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "F3.1.ln-symlink-into-protected",
|
|
59
|
+
"type": "scan-bash",
|
|
60
|
+
"input": "ln -s /tmp/x .rea/HALT",
|
|
61
|
+
"mode": "protected",
|
|
62
|
+
"expected_verdict": "block"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "F3.2.ln-symlink-into-husky",
|
|
66
|
+
"type": "scan-bash",
|
|
67
|
+
"input": "ln -sf /tmp/payload .husky/pre-push",
|
|
68
|
+
"mode": "protected",
|
|
69
|
+
"expected_verdict": "block"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "helix-028",
|
|
3
|
+
"title": "0.23.0 cmd-segments multiline awk + ANSI-C bypass classes",
|
|
4
|
+
"introduced_in": "0.23.0",
|
|
5
|
+
"closed_in": "0.26.1",
|
|
6
|
+
"summary": "Multiline awk inside command-substitutions and ANSI-C $'...' string literals were silently dropped from segment scanning. Two P1 bypass classes closed in 0.26.1: awk multiline body now flagged when destination is protected; ANSI-C strings normalize to their decoded form before scanning.",
|
|
7
|
+
"pocs": [
|
|
8
|
+
{
|
|
9
|
+
"id": "multiline-awk-redirect",
|
|
10
|
+
"type": "scan-bash",
|
|
11
|
+
"input": "awk 'BEGIN { print \"x\" > \".rea/HALT\" }'",
|
|
12
|
+
"mode": "protected",
|
|
13
|
+
"expected_verdict": "block"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "ansi-c-string-redirect",
|
|
17
|
+
"type": "scan-bash",
|
|
18
|
+
"input": "echo $'\\x78' > .rea/HALT",
|
|
19
|
+
"mode": "protected",
|
|
20
|
+
"expected_verdict": "block"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "helix-031",
|
|
3
|
+
"title": "shellcheck SC1078 false positive on cmd-segments.sh closed by removing legacy bash module (0.27.0)",
|
|
4
|
+
"introduced_in": "0.26.1",
|
|
5
|
+
"closed_in": "0.27.0",
|
|
6
|
+
"summary": "0.26.1 added an ANSI-C handling path inside hooks/_lib/cmd-segments.sh that confused shellcheck SC1078 (mismatched single-quote in unclosed-string). 0.27.0 removed the legacy bash cmd-segments path entirely (the parser-backed Node scanner has owned the segmentation work since 0.23.0). This claim verifies that shellcheck runs clean on every shipped hook.",
|
|
7
|
+
"pocs": [
|
|
8
|
+
{
|
|
9
|
+
"id": "SC1078-clean-protected-paths-bash-gate",
|
|
10
|
+
"type": "shellcheck",
|
|
11
|
+
"target": "hooks/protected-paths-bash-gate.sh",
|
|
12
|
+
"expected_verdict": "clean"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "SC1078-clean-blocked-paths-bash-gate",
|
|
16
|
+
"type": "shellcheck",
|
|
17
|
+
"target": "hooks/blocked-paths-bash-gate.sh",
|
|
18
|
+
"expected_verdict": "clean"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "SC1078-clean-local-review-gate",
|
|
22
|
+
"type": "shellcheck",
|
|
23
|
+
"target": "hooks/local-review-gate.sh",
|
|
24
|
+
"expected_verdict": "clean"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* This matches the protective default established in 0.10.x.
|
|
30
30
|
*/
|
|
31
31
|
import type { Command } from 'commander';
|
|
32
|
+
import { runCodexReview } from '../hooks/push-gate/codex-runner.js';
|
|
32
33
|
export interface HookPushGateOptions {
|
|
33
34
|
base?: string;
|
|
34
35
|
/**
|
|
@@ -125,9 +126,82 @@ export interface HookPolicyGetOptions {
|
|
|
125
126
|
}
|
|
126
127
|
export declare function runHookPolicyGet(options: HookPolicyGetOptions): Promise<void>;
|
|
127
128
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
129
|
+
* `rea hook codex-review` — the canonical Bash-direct codex invocation
|
|
130
|
+
* for marathon-mode review cycles (0.27.0+).
|
|
131
|
+
*
|
|
132
|
+
* The user directive is "codex should be invoked this way always to
|
|
133
|
+
* minimize claude consumption of all the output. we just need the log
|
|
134
|
+
* at the end." This command wraps `codex exec review --json --ephemeral`
|
|
135
|
+
* with the same iron-gate model defaults the push-gate uses, tees the
|
|
136
|
+
* raw JSONL stream to a tempfile so the caller can read the
|
|
137
|
+
* un-summarized output directly, parses out the verdict + finding
|
|
138
|
+
* count, writes a `codex.review` audit entry, and prints a single terse
|
|
139
|
+
* status line to stderr. Stdout stays clean — when `--json` is set the
|
|
140
|
+
* canonical JSON summary lands there for jq-style chaining.
|
|
141
|
+
*
|
|
142
|
+
* Distinct from `rea review`:
|
|
143
|
+
* - `rea review` writes a `rea.local_review` entry the local-review
|
|
144
|
+
* gate consults and prints human-readable output. Treated as the
|
|
145
|
+
* primary CLI surface for the local-first workflow.
|
|
146
|
+
* - `rea hook codex-review` writes a `codex.review` entry (the legacy
|
|
147
|
+
* gateway shape), keeps the raw JSONL on disk, and is intentionally
|
|
148
|
+
* terse. Designed for thin-shim invocation from agents and slash
|
|
149
|
+
* commands that DON'T need a Claude-paraphrased summary — the raw
|
|
150
|
+
* JSON IS the review.
|
|
151
|
+
*
|
|
152
|
+
* Exit-code contract (mirrors push-gate convention):
|
|
153
|
+
*
|
|
154
|
+
* 0 — pass verdict
|
|
155
|
+
* 1 — concerns verdict
|
|
156
|
+
* 2 — blocking verdict, codex error, or HALT active
|
|
157
|
+
*/
|
|
158
|
+
export interface HookCodexReviewOptions {
|
|
159
|
+
base?: string;
|
|
160
|
+
/**
|
|
161
|
+
* Mirror of `--last-n-commits` on push-gate. When set, diff against
|
|
162
|
+
* `HEAD~N` instead of running the upstream-resolution ladder. `--base`
|
|
163
|
+
* always wins when both are set. Validated as a positive integer at
|
|
164
|
+
* the commander layer.
|
|
165
|
+
*/
|
|
166
|
+
lastNCommits?: number;
|
|
167
|
+
/**
|
|
168
|
+
* Emit a single JSON line on stdout instead of a stderr-only status
|
|
169
|
+
* line. The JSON shape carries `verdict`, `finding_count`, `head_sha`,
|
|
170
|
+
* `target`, `audit_hash`, `raw_path`, and `exit_code`.
|
|
171
|
+
*/
|
|
172
|
+
json?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* Override REA_ROOT. Tests set this; the production caller relies on
|
|
175
|
+
* `process.cwd()`.
|
|
176
|
+
*/
|
|
177
|
+
reaRoot?: string;
|
|
178
|
+
/**
|
|
179
|
+
* Test seam — replaces the spawn of `codex exec review`. Same
|
|
180
|
+
* contract as `runCodexReview`'s `spawnImpl`. When set, the codex-
|
|
181
|
+
* availability probe is skipped (matches `runCodexReview` behavior).
|
|
182
|
+
*/
|
|
183
|
+
spawnImpl?: Parameters<typeof runCodexReview>[0]['spawnImpl'];
|
|
184
|
+
/**
|
|
185
|
+
* Test seam — override the directory raw stdout is teed into. Default
|
|
186
|
+
* is `os.tmpdir()`. Tests set this so they can read the file back.
|
|
187
|
+
*/
|
|
188
|
+
rawStdoutDir?: string;
|
|
189
|
+
}
|
|
190
|
+
export declare function runHookCodexReview(options: HookCodexReviewOptions): Promise<void>;
|
|
191
|
+
/**
|
|
192
|
+
* Attach the `rea hook` subcommand tree to a commander Program.
|
|
193
|
+
*
|
|
194
|
+
* Subcommands:
|
|
195
|
+
* - `push-gate` — stateless pre-push Codex review (called by husky).
|
|
196
|
+
* - `scan-bash` — parser-backed bash-tier scanner (called by Claude
|
|
197
|
+
* Code shim hooks).
|
|
198
|
+
* - `policy-get` — single-source-of-truth policy reader for bash hooks.
|
|
199
|
+
* - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
|
|
200
|
+
* marathon-mode review cycles. The canonical
|
|
201
|
+
* invocation that all agents and slash commands
|
|
202
|
+
* route through.
|
|
203
|
+
*
|
|
204
|
+
* New hooks should land here rather than as top-level commands so the
|
|
205
|
+
* CLI surface stays navigable.
|
|
132
206
|
*/
|
|
133
207
|
export declare function registerHookCommand(program: Command): void;
|
package/dist/cli/hook.js
CHANGED
|
@@ -29,12 +29,19 @@
|
|
|
29
29
|
* This matches the protective default established in 0.10.x.
|
|
30
30
|
*/
|
|
31
31
|
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
32
33
|
import path from 'node:path';
|
|
34
|
+
import crypto from 'node:crypto';
|
|
33
35
|
import { parse as parseYaml } from 'yaml';
|
|
34
36
|
import { parsePrePushStdin, runPushGate } from '../hooks/push-gate/index.js';
|
|
35
37
|
import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js';
|
|
36
38
|
import { loadPolicy } from '../policy/loader.js';
|
|
37
39
|
import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
|
|
40
|
+
import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
|
|
41
|
+
import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, createRealGitExecutor, runCodexReview, } from '../hooks/push-gate/codex-runner.js';
|
|
42
|
+
import { resolveBaseRef } from '../hooks/push-gate/base.js';
|
|
43
|
+
import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
|
|
44
|
+
import { summarizeReview } from '../hooks/push-gate/findings.js';
|
|
38
45
|
import { err } from './utils.js';
|
|
39
46
|
/**
|
|
40
47
|
* Public runner, exposed so integration tests and the commander binding can
|
|
@@ -319,11 +326,272 @@ export async function runHookPolicyGet(options) {
|
|
|
319
326
|
// Object/Array → no output (caller treats as unset).
|
|
320
327
|
process.exit(0);
|
|
321
328
|
}
|
|
329
|
+
export async function runHookCodexReview(options) {
|
|
330
|
+
const baseDir = options.reaRoot ?? process.cwd();
|
|
331
|
+
// HALT check — uniform with the rest of the hook tree.
|
|
332
|
+
const haltPath = path.join(baseDir, '.rea', 'HALT');
|
|
333
|
+
if (fs.existsSync(haltPath)) {
|
|
334
|
+
let reason = 'Reason unknown';
|
|
335
|
+
try {
|
|
336
|
+
const content = fs.readFileSync(haltPath, 'utf8');
|
|
337
|
+
reason = content.slice(0, 1024).trim() || reason;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* leave default */
|
|
341
|
+
}
|
|
342
|
+
process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
|
|
343
|
+
process.exit(2);
|
|
344
|
+
}
|
|
345
|
+
// Resolve git context + base ref using the same primitives the push-
|
|
346
|
+
// gate uses. Missing HEAD short-circuits with an explicit error rather
|
|
347
|
+
// than silently coercing — `rea hook codex-review` is intended for
|
|
348
|
+
// explicit invocation, not for the unborn-HEAD bootstrap path that
|
|
349
|
+
// `rea review` handles.
|
|
350
|
+
const git = createRealGitExecutor(baseDir);
|
|
351
|
+
const headSha = git.headSha();
|
|
352
|
+
if (headSha.length === 0) {
|
|
353
|
+
process.stderr.write('rea hook codex-review: could not resolve HEAD sha — is this a valid git repo with at least one commit?\n');
|
|
354
|
+
process.exit(2);
|
|
355
|
+
}
|
|
356
|
+
const resolved = await resolvePushGatePolicy(baseDir);
|
|
357
|
+
const explicit = options.base !== undefined && options.base.length > 0 ? options.base : undefined;
|
|
358
|
+
const lastN = options.lastNCommits;
|
|
359
|
+
// Delegate base resolution to the shared resolver so shallow-clone /
|
|
360
|
+
// short-history clamping matches `rea hook push-gate` behavior. The
|
|
361
|
+
// resolver returns a fully-resolved SHA + source tag; on a branch
|
|
362
|
+
// shorter than `lastN`, it clamps to the deepest ancestor (or the
|
|
363
|
+
// empty-tree sentinel for orphan/single-commit history) instead of
|
|
364
|
+
// refusing the review.
|
|
365
|
+
const resolvedBase = resolveBaseRef(git, {
|
|
366
|
+
...(explicit !== undefined ? { explicit } : {}),
|
|
367
|
+
...(lastN !== undefined && lastN > 0 ? { lastNCommits: lastN } : {}),
|
|
368
|
+
});
|
|
369
|
+
const baseRef = resolvedBase.ref;
|
|
370
|
+
const target = resolvedBase.ref;
|
|
371
|
+
// Allocate the raw-stdout sink. We write to `${tmp}/rea-codex-<sha>.json`
|
|
372
|
+
// where <sha> is a short hex token derived from headSha + a random
|
|
373
|
+
// nonce so concurrent invocations on the same HEAD don't clobber each
|
|
374
|
+
// other (rare in practice — agents queue serially — but cheap to
|
|
375
|
+
// make safe).
|
|
376
|
+
const tmpRoot = options.rawStdoutDir ?? os.tmpdir();
|
|
377
|
+
const nonce = crypto.randomBytes(4).toString('hex');
|
|
378
|
+
const rawPath = path.join(tmpRoot, `rea-codex-${headSha.slice(0, 12)}-${nonce}.json`);
|
|
379
|
+
let rawStream;
|
|
380
|
+
try {
|
|
381
|
+
// mode 0o600: review JSONL contains the unfiltered codex output for
|
|
382
|
+
// the repo being scanned (file paths, code excerpts, finding text).
|
|
383
|
+
// On shared workstations / CI runners other local users could read
|
|
384
|
+
// a default-mode 0644 file. Owner-only is the right floor.
|
|
385
|
+
rawStream = fs.createWriteStream(rawPath, { flags: 'w', mode: 0o600 });
|
|
386
|
+
// createWriteStream() does not throw ENOENT/EACCES/ENOSPC
|
|
387
|
+
// synchronously — it emits an `error` event later. Without a
|
|
388
|
+
// listener, the unhandled stream error terminates the process. Fall
|
|
389
|
+
// back to "no raw tee" instead so a logging failure can never crash
|
|
390
|
+
// the review itself.
|
|
391
|
+
rawStream.once('error', (err) => {
|
|
392
|
+
process.stderr.write(`rea hook codex-review: raw-stdout sink at ${rawPath} failed: ${err.message}\n`);
|
|
393
|
+
rawStream = null;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
// Synchronous failures (rare — usually invalid path shape) fall
|
|
398
|
+
// through the same way: the audit entry still gets written, we
|
|
399
|
+
// just lose the raw JSON tee.
|
|
400
|
+
process.stderr.write(`rea hook codex-review: could not open raw-stdout sink at ${rawPath}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
401
|
+
rawStream = null;
|
|
402
|
+
}
|
|
403
|
+
// Run codex. The runner enforces iron-gate defaults internally —
|
|
404
|
+
// gpt-5.4 + high reasoning unless policy overrides — so we pass
|
|
405
|
+
// policy-resolved values straight through. spawnImpl is forwarded to
|
|
406
|
+
// the test seam.
|
|
407
|
+
let reviewText = '';
|
|
408
|
+
let durationSeconds = 0;
|
|
409
|
+
let codexError;
|
|
410
|
+
try {
|
|
411
|
+
const result = await runCodexReview({
|
|
412
|
+
baseRef,
|
|
413
|
+
cwd: baseDir,
|
|
414
|
+
timeoutMs: resolved.timeout_ms,
|
|
415
|
+
env: process.env,
|
|
416
|
+
...(resolved.codex_model !== undefined ? { model: resolved.codex_model } : {}),
|
|
417
|
+
...(resolved.codex_reasoning_effort !== undefined
|
|
418
|
+
? { reasoningEffort: resolved.codex_reasoning_effort }
|
|
419
|
+
: {}),
|
|
420
|
+
...(options.spawnImpl !== undefined ? { spawnImpl: options.spawnImpl } : {}),
|
|
421
|
+
...(rawStream !== null
|
|
422
|
+
? {
|
|
423
|
+
rawStdoutSink: (chunk) => {
|
|
424
|
+
// Defensive: swallow any write error (closed/destroyed
|
|
425
|
+
// stream, EBADF, ENOSPC). The codex-runner already
|
|
426
|
+
// wraps sink calls in try/catch so a sink failure must
|
|
427
|
+
// never change the verdict — but throwing inside the
|
|
428
|
+
// 'data' handler also triggers an uncaughtException via
|
|
429
|
+
// the readable stream. Catch it here so it stays local.
|
|
430
|
+
try {
|
|
431
|
+
if (!rawStream.writableEnded && !rawStream.destroyed) {
|
|
432
|
+
rawStream.write(chunk);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
/* sink failure is non-fatal */
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
: {}),
|
|
441
|
+
});
|
|
442
|
+
reviewText = result.reviewText;
|
|
443
|
+
durationSeconds = result.durationSeconds;
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
codexError = e;
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
if (rawStream !== null) {
|
|
450
|
+
// End the stream — best-effort. The file is on disk either way,
|
|
451
|
+
// and the OS flushes pending writes when the FD closes.
|
|
452
|
+
try {
|
|
453
|
+
await new Promise((resolve) => {
|
|
454
|
+
rawStream.end(() => resolve());
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
/* swallow */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// Translate the codex error (if any) into a verdict + audit-error
|
|
463
|
+
// shape. This mirrors `rea review`'s classifyCodexError + the push-
|
|
464
|
+
// gate's translation, but stays inline so this CLI is self-contained.
|
|
465
|
+
if (codexError !== undefined) {
|
|
466
|
+
const msg = codexError instanceof Error ? codexError.message : String(codexError);
|
|
467
|
+
const kind = codexError instanceof CodexNotInstalledError
|
|
468
|
+
? 'not-installed'
|
|
469
|
+
: codexError instanceof CodexTimeoutError
|
|
470
|
+
? 'timeout'
|
|
471
|
+
: codexError instanceof CodexProtocolError
|
|
472
|
+
? 'protocol'
|
|
473
|
+
: codexError instanceof CodexSubprocessError
|
|
474
|
+
? 'subprocess'
|
|
475
|
+
: 'unknown';
|
|
476
|
+
let auditHash = '';
|
|
477
|
+
try {
|
|
478
|
+
const record = await appendAuditRecord(baseDir, {
|
|
479
|
+
tool_name: CODEX_REVIEW_TOOL_NAME,
|
|
480
|
+
server_name: CODEX_REVIEW_SERVER_NAME,
|
|
481
|
+
status: InvocationStatus.Error,
|
|
482
|
+
tier: Tier.Read,
|
|
483
|
+
metadata: {
|
|
484
|
+
head_sha: headSha,
|
|
485
|
+
target,
|
|
486
|
+
finding_count: 0,
|
|
487
|
+
verdict: 'error',
|
|
488
|
+
summary: `codex error (${kind}): ${msg}`,
|
|
489
|
+
model: resolved.codex_model ?? IRON_GATE_DEFAULT_MODEL,
|
|
490
|
+
reasoning_effort: resolved.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
|
|
491
|
+
raw_path: rawPath,
|
|
492
|
+
duration_seconds: durationSeconds,
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
auditHash = record.hash;
|
|
496
|
+
}
|
|
497
|
+
catch (auditErr) {
|
|
498
|
+
// Audit failure must NOT change the exit code, but we surface it.
|
|
499
|
+
process.stderr.write(`rea hook codex-review: audit append failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}\n`);
|
|
500
|
+
}
|
|
501
|
+
process.stderr.write(`[codex-review] verdict=error kind=${kind} findings=0 audit=${auditHash.slice(0, 16)} raw=${rawPath}\n`);
|
|
502
|
+
process.stderr.write(`[codex-review] error: ${msg}\n`);
|
|
503
|
+
if (options.json === true) {
|
|
504
|
+
process.stdout.write(JSON.stringify({
|
|
505
|
+
verdict: 'error',
|
|
506
|
+
kind,
|
|
507
|
+
finding_count: 0,
|
|
508
|
+
head_sha: headSha,
|
|
509
|
+
target,
|
|
510
|
+
audit_hash: auditHash,
|
|
511
|
+
raw_path: rawPath,
|
|
512
|
+
exit_code: 2,
|
|
513
|
+
message: msg,
|
|
514
|
+
}) + '\n');
|
|
515
|
+
}
|
|
516
|
+
process.exit(2);
|
|
517
|
+
}
|
|
518
|
+
// Codex exited cleanly — parse the review prose and translate to a
|
|
519
|
+
// verdict + finding count.
|
|
520
|
+
const summary = summarizeReview(reviewText);
|
|
521
|
+
const verdict = summary.verdict;
|
|
522
|
+
const findingCount = summary.findings.length;
|
|
523
|
+
// First non-empty paragraph of the review text becomes the audit
|
|
524
|
+
// summary line. Truncated to 240 chars so the audit log doesn't blow
|
|
525
|
+
// up on multi-paragraph review prose.
|
|
526
|
+
const summaryLine = (() => {
|
|
527
|
+
const firstPara = reviewText
|
|
528
|
+
.split(/\n{2,}/)
|
|
529
|
+
.map((p) => p.trim())
|
|
530
|
+
.find((p) => p.length > 0);
|
|
531
|
+
if (firstPara === undefined)
|
|
532
|
+
return '';
|
|
533
|
+
const oneLine = firstPara.replace(/\s+/g, ' ');
|
|
534
|
+
return oneLine.length > 240 ? oneLine.slice(0, 237) + '...' : oneLine;
|
|
535
|
+
})();
|
|
536
|
+
let auditHash = '';
|
|
537
|
+
try {
|
|
538
|
+
const record = await appendAuditRecord(baseDir, {
|
|
539
|
+
tool_name: CODEX_REVIEW_TOOL_NAME,
|
|
540
|
+
server_name: CODEX_REVIEW_SERVER_NAME,
|
|
541
|
+
status: verdict === 'blocking' ? InvocationStatus.Denied : InvocationStatus.Allowed,
|
|
542
|
+
tier: Tier.Read,
|
|
543
|
+
metadata: {
|
|
544
|
+
head_sha: headSha,
|
|
545
|
+
target,
|
|
546
|
+
finding_count: findingCount,
|
|
547
|
+
verdict,
|
|
548
|
+
...(summaryLine.length > 0 ? { summary: summaryLine } : {}),
|
|
549
|
+
model: resolved.codex_model ?? IRON_GATE_DEFAULT_MODEL,
|
|
550
|
+
reasoning_effort: resolved.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
|
|
551
|
+
raw_path: rawPath,
|
|
552
|
+
duration_seconds: durationSeconds,
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
auditHash = record.hash;
|
|
556
|
+
}
|
|
557
|
+
catch (auditErr) {
|
|
558
|
+
process.stderr.write(`rea hook codex-review: audit append failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}\n`);
|
|
559
|
+
}
|
|
560
|
+
// Map verdict → exit code. Same as the push-gate's contract.
|
|
561
|
+
const exitCode = verdict === 'blocking' ? 2 : verdict === 'concerns' ? 1 : 0;
|
|
562
|
+
// Terse status line on stderr. The directive is "the codex JSON IS
|
|
563
|
+
// the review" — agents read raw_path to act on findings, not this
|
|
564
|
+
// line. The line exists so a human running this from a shell sees
|
|
565
|
+
// the verdict at a glance.
|
|
566
|
+
process.stderr.write(`[codex-review] verdict=${verdict} findings=${String(findingCount)} audit=${auditHash.slice(0, 16)} raw=${rawPath}\n`);
|
|
567
|
+
if (options.json === true) {
|
|
568
|
+
process.stdout.write(JSON.stringify({
|
|
569
|
+
verdict,
|
|
570
|
+
finding_count: findingCount,
|
|
571
|
+
head_sha: headSha,
|
|
572
|
+
target,
|
|
573
|
+
audit_hash: auditHash,
|
|
574
|
+
raw_path: rawPath,
|
|
575
|
+
exit_code: exitCode,
|
|
576
|
+
}) + '\n');
|
|
577
|
+
}
|
|
578
|
+
process.exit(exitCode);
|
|
579
|
+
}
|
|
322
580
|
/**
|
|
323
|
-
* Attach the `rea hook` subcommand tree to a commander Program.
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
581
|
+
* Attach the `rea hook` subcommand tree to a commander Program.
|
|
582
|
+
*
|
|
583
|
+
* Subcommands:
|
|
584
|
+
* - `push-gate` — stateless pre-push Codex review (called by husky).
|
|
585
|
+
* - `scan-bash` — parser-backed bash-tier scanner (called by Claude
|
|
586
|
+
* Code shim hooks).
|
|
587
|
+
* - `policy-get` — single-source-of-truth policy reader for bash hooks.
|
|
588
|
+
* - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
|
|
589
|
+
* marathon-mode review cycles. The canonical
|
|
590
|
+
* invocation that all agents and slash commands
|
|
591
|
+
* route through.
|
|
592
|
+
*
|
|
593
|
+
* New hooks should land here rather than as top-level commands so the
|
|
594
|
+
* CLI surface stays navigable.
|
|
327
595
|
*/
|
|
328
596
|
export function registerHookCommand(program) {
|
|
329
597
|
const hook = program
|
|
@@ -366,6 +634,25 @@ export function registerHookCommand(program) {
|
|
|
366
634
|
...(opts.lastNCommits !== undefined ? { lastNCommits: opts.lastNCommits } : {}),
|
|
367
635
|
});
|
|
368
636
|
});
|
|
637
|
+
hook
|
|
638
|
+
.command('codex-review')
|
|
639
|
+
.description('Run `codex exec review --json --ephemeral` directly against the working tree, tee raw JSONL to a tempfile, write a `codex.review` audit entry, and emit a terse status line on stderr. Exits 0/1/2: pass/concerns/blocking. The canonical Bash-direct codex invocation (0.27.0+) — minimizes Claude consumption of codex output by NOT paraphrasing findings into prose.')
|
|
640
|
+
.option('--base <ref>', 'explicit base ref to diff against (default: @{upstream} → origin/HEAD → main/master)')
|
|
641
|
+
.option('--last-n-commits <n>', 'narrow review to the last N commits (diff against HEAD~N). Loses to --base when both are set.', (raw) => {
|
|
642
|
+
const n = Number(raw);
|
|
643
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
644
|
+
throw new Error(`--last-n-commits must be a positive integer, got ${JSON.stringify(raw)}`);
|
|
645
|
+
}
|
|
646
|
+
return n;
|
|
647
|
+
})
|
|
648
|
+
.option('--json', 'emit a single-line JSON result on stdout (in addition to the stderr status line)')
|
|
649
|
+
.action(async (opts) => {
|
|
650
|
+
await runHookCodexReview({
|
|
651
|
+
...(opts.base !== undefined ? { base: opts.base } : {}),
|
|
652
|
+
...(opts.lastNCommits !== undefined ? { lastNCommits: opts.lastNCommits } : {}),
|
|
653
|
+
...(opts.json === true ? { json: true } : {}),
|
|
654
|
+
});
|
|
655
|
+
});
|
|
369
656
|
hook
|
|
370
657
|
.command('policy-get')
|
|
371
658
|
.description('Read a value from `.rea/policy.yaml` via the canonical YAML parser. Used by bash-tier hooks (`hooks/_lib/policy-read.sh::policy_nested_scalar`) so inline AND block YAML forms agree at a single source of truth. Default scalar mode: prints raw value or empty. With `--json`: emits JSON (scalar or object/array; missing path → `null`). Unparseable YAML → empty / null, exit 1.')
|
package/dist/cli/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { runServe } from './serve.js';
|
|
|
12
12
|
import { runStatus } from './status.js';
|
|
13
13
|
import { runTofuAccept, runTofuList } from './tofu.js';
|
|
14
14
|
import { runUpgrade } from './upgrade.js';
|
|
15
|
+
import { registerVerifyClaimCommand } from './verify-claim.js';
|
|
15
16
|
import { err, getPkgVersion } from './utils.js';
|
|
16
17
|
async function main() {
|
|
17
18
|
const program = new Command();
|
|
@@ -115,6 +116,11 @@ async function main() {
|
|
|
115
116
|
// `local-review-gate.sh` hook both delegate to `rea preflight --strict`.
|
|
116
117
|
registerReviewCommand(program);
|
|
117
118
|
registerPreflightCommand(program);
|
|
119
|
+
// 0.28.0 — `rea verify-claim <claim-id>` replays recorded
|
|
120
|
+
// security-claim PoC batteries against the CLI under test. The
|
|
121
|
+
// centerpiece of 0.28.0 (4th structural pivot — claims as
|
|
122
|
+
// machine-verifiable artifacts).
|
|
123
|
+
registerVerifyClaimCommand(program);
|
|
118
124
|
const tofu = program
|
|
119
125
|
.command('tofu')
|
|
120
126
|
.description('TOFU fingerprint operations (G7) — inspect and rebase `.rea/fingerprints.json` when a legitimate registry edit has triggered drift fail-close. Emits audit records.');
|
package/dist/cli/preflight.d.ts
CHANGED
|
@@ -111,6 +111,18 @@ export interface LocalReviewLookupResult {
|
|
|
111
111
|
* (back-compat / fallback).
|
|
112
112
|
*/
|
|
113
113
|
match_kind?: 'content_token' | 'head_sha';
|
|
114
|
+
/**
|
|
115
|
+
* 0.28.0 round-29 P3 — set when the most recent path-matching audit
|
|
116
|
+
* entry for this HEAD had verdict `blocking` (or `error`) and was
|
|
117
|
+
* therefore skipped as "not coverage". Surfacing this lets the
|
|
118
|
+
* preflight caller render a clearer message than "no recent local-
|
|
119
|
+
* review audit entry covers HEAD" — the operator hasn't forgotten
|
|
120
|
+
* to review, they've already done one and it told them to fix
|
|
121
|
+
* findings.
|
|
122
|
+
*/
|
|
123
|
+
last_blocking_verdict?: 'blocking' | 'error';
|
|
124
|
+
/** ISO timestamp of the last blocking entry, when present. */
|
|
125
|
+
last_blocking_timestamp?: string;
|
|
114
126
|
}
|
|
115
127
|
export declare function findRecentLocalReview(baseDir: string, headSha: string, maxAgeSeconds: number, now?: Date, contentToken?: string): LocalReviewLookupResult;
|
|
116
128
|
/**
|