@bookedsolid/rea 0.28.0 → 0.28.2
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.
|
@@ -114,6 +114,24 @@ Consumer projects may extend the roster via `.rea/agents/` and profile YAMLs, bu
|
|
|
114
114
|
4. Delegate with full context — include file paths, constraints from policy.yaml, acceptance criteria, and the commit-discipline note above
|
|
115
115
|
5. Verify outputs before reporting completion — do not trust agent summaries at face value. Read the files, check git status, confirm the build.
|
|
116
116
|
|
|
117
|
+
## Self-review when the orchestrator implements directly (0.29.0+)
|
|
118
|
+
|
|
119
|
+
There are sessions where the orchestrator must implement work itself instead of dispatching:
|
|
120
|
+
|
|
121
|
+
- Subagent dispatch is unavailable (no Task tool in the current harness, exempt-subagent scenario).
|
|
122
|
+
- The task is narrowly scoped to a single small surface where the dispatch overhead exceeds the implementation cost.
|
|
123
|
+
- A codex round between specialist hand-offs is being used as the de facto specialist tier (the "Option C" iteration pattern from the 0.29.0 marathon).
|
|
124
|
+
|
|
125
|
+
In every such case, you MUST still apply the specialist discipline that delegation would have enforced. This is not optional — the structural risk of "one Opus turn implements five surfaces" is exactly the failure mode that principal-engineer review caught in the 0.28.0 cycle (manifest glob-injection P1 + cache-staleness P2, both pre-commit). Reach the same closure shape by:
|
|
126
|
+
|
|
127
|
+
1. **Name the specialists you are channeling.** Before each surface, state which specialist's discipline applies (e.g. "shell-scripting-specialist + adversarial-test-specialist for the bash gate corpus; typescript-specialist for the CLI; platform-architect for the workflow"). State it out loud so the user can spot a mis-cast role.
|
|
128
|
+
2. **Codex round between surfaces, not just at the end.** A single end-of-build codex round across 5 surfaces buries P1s in noise. One round per surface keeps the signal sharp. The 0.27.0 direct-Bash codex CLI is cheap enough at one Opus turn per round to make this routine.
|
|
129
|
+
3. **Explicit threat-model framing for security-tier changes.** When patching a hook, name the bypass class, the conservative-vs-narrow reading, and the sibling shapes the class implies. Refuse to commit until the corpus enumerates every shape the class includes.
|
|
130
|
+
4. **Single-commit-per-PR discipline still applies.** Squash local work before push. The pre-push gate's stateless codex review runs once against the squashed diff; granular commits multiply the review burden without surfacing new findings.
|
|
131
|
+
5. **Defer ruthlessly.** Trimmed-scope greenlights from the user are a maximum, not a minimum. The marathon's 0.28.0 lesson was "principal-engineer trimmed the 11-item plate to 6 with crisp deferral reasons." Apply the same lens during direct-implementation: if surface 6 needs structural rework, defer it to the next minor with the reason in the changeset rather than ship a half-baked closure.
|
|
132
|
+
|
|
133
|
+
A self-review checkpoint after each surface (read the diff back, run the targeted tests, fire codex against the working tree) IS the specialist tier when no subagent is in the path. Skip the checkpoint and the structural lesson resets.
|
|
134
|
+
|
|
117
135
|
## The Plan / Build / Review Loop (default workflow)
|
|
118
136
|
|
|
119
137
|
REA's default engineering workflow is three-legged, with Review performed by a different model than Build:
|
package/dist/cli/review.d.ts
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* the push-gate's review.
|
|
32
32
|
*/
|
|
33
33
|
import type { Command } from 'commander';
|
|
34
|
+
import { type LocalReviewVerdict } from '../audit/local-review-event.js';
|
|
35
|
+
import { type Finding } from '../hooks/push-gate/findings.js';
|
|
34
36
|
export interface RunReviewOptions {
|
|
35
37
|
/** Optional explicit base ref. Defaults to upstream-ladder resolution. */
|
|
36
38
|
base?: string;
|
|
@@ -42,13 +44,65 @@ export interface RunReviewOptions {
|
|
|
42
44
|
strictFailOn?: 'concerns' | 'blocking';
|
|
43
45
|
/** Emit a single JSON line on stdout instead of pretty output. */
|
|
44
46
|
json?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* 0.28.1 defect-V: when true, after the human-readable summary line
|
|
49
|
+
* (or alongside the JSON payload), emit the finding bodies grouped by
|
|
50
|
+
* severity. Default off — preserves backward-compatible single-line
|
|
51
|
+
* stdout for existing CI consumers.
|
|
52
|
+
*/
|
|
53
|
+
withFindings?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Exported so tests can construct fake outcomes for the seam in
|
|
57
|
+
* `runReview`. Production callers don't reference this directly.
|
|
58
|
+
*/
|
|
59
|
+
export interface ReviewOutcome {
|
|
60
|
+
verdict: LocalReviewVerdict;
|
|
61
|
+
findingCount: number;
|
|
62
|
+
baseRef: string;
|
|
63
|
+
headSha: string;
|
|
64
|
+
/**
|
|
65
|
+
* 0.26.0 helix-026 finding-1: tree SHA of HEAD at review time. The
|
|
66
|
+
* deterministic content fingerprint `rea preflight` matches coverage
|
|
67
|
+
* on. Empty string when not resolvable (no HEAD, no git repo) — the
|
|
68
|
+
* audit writer omits `content_token` from metadata in that case.
|
|
69
|
+
*/
|
|
70
|
+
contentToken: string;
|
|
71
|
+
durationSeconds: number;
|
|
72
|
+
model: string;
|
|
73
|
+
reasoningEffort: string;
|
|
74
|
+
/**
|
|
75
|
+
* 0.28.1 defect-V: structured findings produced by the review. Pre-fix
|
|
76
|
+
* the CLI threw these away after counting; agents could not remediate
|
|
77
|
+
* blocking verdicts because the bodies were unreadable through any
|
|
78
|
+
* documented surface.
|
|
79
|
+
*/
|
|
80
|
+
findings: Finding[];
|
|
81
|
+
/**
|
|
82
|
+
* 0.28.1 defect-V: full agent-prose review text. Persisted to
|
|
83
|
+
* `.rea/last-review.json` (post-redaction) so consumers have a
|
|
84
|
+
* machine-readable transcript for parser-miss debugging.
|
|
85
|
+
*/
|
|
86
|
+
reviewText: string;
|
|
87
|
+
/** Count of raw JSONL events from codex — recorded in last-review.json. */
|
|
88
|
+
eventCount: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 0.28.1 defect-V — narrow test seam. Production callers never set this;
|
|
92
|
+
* tests inject a fake to drive `runReview` deterministically without
|
|
93
|
+
* spawning codex. The seam matches `executeCodexReview`'s signature so
|
|
94
|
+
* the production path and the test path go through the same downstream
|
|
95
|
+
* wiring (audit append, last-review.json, exit code, output).
|
|
96
|
+
*/
|
|
97
|
+
export interface RunReviewDeps {
|
|
98
|
+
executeCodexReview?: (baseDir: string, options: RunReviewOptions) => Promise<ReviewOutcome>;
|
|
45
99
|
}
|
|
46
100
|
/**
|
|
47
101
|
* Public runner — exposed so tests can drive the function in-process and
|
|
48
102
|
* the commander binding can stay thin. Throws via `process.exit` (CLI
|
|
49
103
|
* convention across `src/cli/`).
|
|
50
104
|
*/
|
|
51
|
-
export declare function runReview(options: RunReviewOptions): Promise<void>;
|
|
105
|
+
export declare function runReview(options: RunReviewOptions, deps?: RunReviewDeps): Promise<void>;
|
|
52
106
|
/**
|
|
53
107
|
* Attach `rea review` to a commander Program.
|
|
54
108
|
*/
|
package/dist/cli/review.js
CHANGED
|
@@ -39,9 +39,34 @@ import { loadPolicyAsync } from '../policy/loader.js';
|
|
|
39
39
|
import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, createRealGitExecutor, runCodexReview, } from '../hooks/push-gate/codex-runner.js';
|
|
40
40
|
import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
|
|
41
41
|
import { resolveBaseRef } from '../hooks/push-gate/base.js';
|
|
42
|
-
import { summarizeReview } from '../hooks/push-gate/findings.js';
|
|
42
|
+
import { summarizeReview, } from '../hooks/push-gate/findings.js';
|
|
43
|
+
import { writeLastReview } from '../hooks/push-gate/report.js';
|
|
43
44
|
import { computeTreeToken, EMPTY_TREE_SHA } from '../audit/content-token.js';
|
|
45
|
+
import { compileDefaultSecretPatterns, redactSecrets, } from '../gateway/middleware/redact.js';
|
|
44
46
|
import { err, log } from './utils.js';
|
|
47
|
+
/** Relative path to the last-review snapshot, surfaced in JSON output. */
|
|
48
|
+
const LAST_REVIEW_RELATIVE = '.rea/last-review.json';
|
|
49
|
+
/**
|
|
50
|
+
* 0.28.1 defect-V round-1 P2-1: shared redactor for the
|
|
51
|
+
* `writeLastReview` failure path. The canonical writer redacts findings
|
|
52
|
+
* before serialization; if it threw we still need to redact the
|
|
53
|
+
* in-memory findings before they reach `--with-findings` stdout or
|
|
54
|
+
* `--json --with-findings`. Without this, a writer failure (read-only
|
|
55
|
+
* .rea/, ENOSPC, race) would let unredacted Codex prose — which can
|
|
56
|
+
* quote secrets from the diff — escape via the new surfaces, defeating
|
|
57
|
+
* the redaction guarantee the writer provides.
|
|
58
|
+
*/
|
|
59
|
+
function redactFindingsInMemory(findings) {
|
|
60
|
+
const patterns = compileDefaultSecretPatterns({ source: 'default' });
|
|
61
|
+
const redactStr = (s) => redactSecrets(s, patterns).output;
|
|
62
|
+
return findings.map((f) => ({
|
|
63
|
+
severity: f.severity,
|
|
64
|
+
title: redactStr(f.title),
|
|
65
|
+
body: redactStr(f.body),
|
|
66
|
+
...(f.file !== undefined ? { file: f.file } : {}),
|
|
67
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
45
70
|
const PROVIDER_CODEX = 'codex';
|
|
46
71
|
/**
|
|
47
72
|
* Probe `codex --version` synchronously. Same shape as the push-gate's
|
|
@@ -84,7 +109,7 @@ async function resolveLocalReviewMode(baseDir) {
|
|
|
84
109
|
* the commander binding can stay thin. Throws via `process.exit` (CLI
|
|
85
110
|
* convention across `src/cli/`).
|
|
86
111
|
*/
|
|
87
|
-
export async function runReview(options) {
|
|
112
|
+
export async function runReview(options, deps = {}) {
|
|
88
113
|
const baseDir = process.cwd();
|
|
89
114
|
const strictFailOn = options.strictFailOn ?? 'blocking';
|
|
90
115
|
const { mode, policy } = await resolveLocalReviewMode(baseDir);
|
|
@@ -131,7 +156,8 @@ export async function runReview(options) {
|
|
|
131
156
|
// Codex available — run the review.
|
|
132
157
|
let outcome;
|
|
133
158
|
try {
|
|
134
|
-
|
|
159
|
+
const exec = deps.executeCodexReview ?? executeCodexReview;
|
|
160
|
+
outcome = await exec(baseDir, options);
|
|
135
161
|
}
|
|
136
162
|
catch (e) {
|
|
137
163
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -168,6 +194,49 @@ export async function runReview(options) {
|
|
|
168
194
|
if (probe.version !== undefined)
|
|
169
195
|
metadata.provider_version = probe.version;
|
|
170
196
|
await safeAudit(baseDir, LOCAL_REVIEW_TOOL_NAME, outcome.verdict === 'blocking' ? InvocationStatus.Denied : InvocationStatus.Allowed, metadata, policy);
|
|
197
|
+
// 0.28.1 defect-V: persist `.rea/last-review.json` on EVERY successful
|
|
198
|
+
// codex run (pass / concerns / blocking) BEFORE the exit so agents can
|
|
199
|
+
// read structured findings to remediate. Pre-fix only the push-gate
|
|
200
|
+
// wrote this file; `rea review` discarded the bodies after counting,
|
|
201
|
+
// so consumers saw stale snapshots from days-old push-gate runs (Ava
|
|
202
|
+
// reported a 2026-05-08 file surviving across new 2026-05-09 runs).
|
|
203
|
+
//
|
|
204
|
+
// Reuses the push-gate's writer — the canonical atomic-write path with
|
|
205
|
+
// redaction. We do NOT inline a second implementation: any divergence
|
|
206
|
+
// between the two writers would silently desynchronize the schema for
|
|
207
|
+
// `rea preflight` and any tooling that reads last-review.json.
|
|
208
|
+
//
|
|
209
|
+
// Skipped/error paths (codex unavailable, codex error) do NOT call this
|
|
210
|
+
// — there are no findings to serialize.
|
|
211
|
+
let lastReviewWritten;
|
|
212
|
+
try {
|
|
213
|
+
// `LocalReviewVerdict` permits `'error'` for the audit-record schema
|
|
214
|
+
// (transport / subprocess failures) but the codex success path can
|
|
215
|
+
// only produce pass | concerns | blocking — we caught throw above.
|
|
216
|
+
// Narrow here so the report writer's stricter `Verdict` type accepts
|
|
217
|
+
// it without losing the audit shape elsewhere in this file.
|
|
218
|
+
const verdict = outcome.verdict;
|
|
219
|
+
lastReviewWritten = writeLastReview({
|
|
220
|
+
baseDir,
|
|
221
|
+
summary: {
|
|
222
|
+
verdict,
|
|
223
|
+
findings: outcome.findings,
|
|
224
|
+
reviewText: outcome.reviewText,
|
|
225
|
+
},
|
|
226
|
+
baseRef: outcome.baseRef,
|
|
227
|
+
headSha: outcome.headSha,
|
|
228
|
+
eventCount: outcome.eventCount,
|
|
229
|
+
durationSeconds: outcome.durationSeconds,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
// last-review.json is a remediation surface, not a gate. A write
|
|
234
|
+
// failure (read-only fs, ENOSPC, race with another run) must not
|
|
235
|
+
// change the verdict-driven exit code. Surface the error to stderr
|
|
236
|
+
// so operators can correlate, then continue.
|
|
237
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
238
|
+
process.stderr.write(`rea: last-review.json write failed: ${msg}\n`);
|
|
239
|
+
}
|
|
171
240
|
// Decide exit code based on strictFailOn.
|
|
172
241
|
let exitCode;
|
|
173
242
|
if (outcome.verdict === 'blocking') {
|
|
@@ -179,8 +248,17 @@ export async function runReview(options) {
|
|
|
179
248
|
else {
|
|
180
249
|
exitCode = 0;
|
|
181
250
|
}
|
|
251
|
+
// 0.28.1 defect-V: redacted findings come from the writer when it
|
|
252
|
+
// succeeded (so `--with-findings` shows the same bodies that landed on
|
|
253
|
+
// disk). When the write FAILED we re-redact the in-memory findings
|
|
254
|
+
// inline (round-1 P2-1) — without this fallback, secrets that codex
|
|
255
|
+
// copied from the diff into a finding body would escape via stdout/
|
|
256
|
+
// JSON in the exact failure mode where the on-disk surface is gone.
|
|
257
|
+
const findingsForOutput = lastReviewWritten !== undefined
|
|
258
|
+
? lastReviewWritten.findings
|
|
259
|
+
: redactFindingsInMemory(outcome.findings);
|
|
182
260
|
if (options.json === true) {
|
|
183
|
-
|
|
261
|
+
const payload = {
|
|
184
262
|
status: outcome.verdict,
|
|
185
263
|
finding_count: outcome.findingCount,
|
|
186
264
|
head_sha: outcome.headSha,
|
|
@@ -190,14 +268,89 @@ export async function runReview(options) {
|
|
|
190
268
|
reasoning_effort: outcome.reasoningEffort,
|
|
191
269
|
duration_seconds: outcome.durationSeconds,
|
|
192
270
|
exit_code: exitCode,
|
|
193
|
-
|
|
271
|
+
// 0.28.1 defect-V round-1 P2-2: only advertise `last_review_path`
|
|
272
|
+
// when the writer actually produced a current snapshot. If the
|
|
273
|
+
// write threw, the file on disk is either missing or a stale
|
|
274
|
+
// snapshot from an older run — pointing JSON consumers at it
|
|
275
|
+
// would let agents remediate against the wrong findings while
|
|
276
|
+
// the current run still exits successfully. Emit `null` and an
|
|
277
|
+
// explicit `last_review_error` so consumers can branch
|
|
278
|
+
// deterministically.
|
|
279
|
+
last_review_path: lastReviewWritten !== undefined ? LAST_REVIEW_RELATIVE : null,
|
|
280
|
+
};
|
|
281
|
+
if (lastReviewWritten === undefined) {
|
|
282
|
+
payload.last_review_error = 'write_failed';
|
|
283
|
+
}
|
|
284
|
+
if (options.withFindings === true) {
|
|
285
|
+
// Mirror last-review.json's Finding shape so JSON consumers see one
|
|
286
|
+
// schema. Findings are pre-redacted (writer-redacted on success,
|
|
287
|
+
// re-redacted inline on writer failure — see findingsForOutput).
|
|
288
|
+
payload.findings = findingsForOutput;
|
|
289
|
+
}
|
|
290
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
194
291
|
}
|
|
195
292
|
else {
|
|
196
293
|
log(`local review: ${outcome.verdict} (${outcome.findingCount} finding(s)) — head=${outcome.headSha.slice(0, 12)} base=${outcome.baseRef}`);
|
|
197
294
|
log(`audit entry written: tool_name=${LOCAL_REVIEW_TOOL_NAME}`);
|
|
295
|
+
if (options.withFindings === true) {
|
|
296
|
+
printFindingsBySeverity(findingsForOutput, lastReviewWritten !== undefined);
|
|
297
|
+
}
|
|
198
298
|
}
|
|
199
299
|
process.exit(exitCode);
|
|
200
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* 0.28.1 defect-V — group findings by severity (P1 → P2 → P3) and print
|
|
303
|
+
* to stdout via `log()`. Each finding renders as
|
|
304
|
+
*
|
|
305
|
+
* - [P1] <title> — <file>:<line>
|
|
306
|
+
*
|
|
307
|
+
* mirroring the codex-banner shape produced by the push-gate, so muscle
|
|
308
|
+
* memory transfers between the two surfaces. The full body is intentionally
|
|
309
|
+
* NOT printed here — the body can be very long, and the canonical place to
|
|
310
|
+
* read full bodies is `.rea/last-review.json`. We print enough to identify
|
|
311
|
+
* each finding and drive the agent to the file.
|
|
312
|
+
*
|
|
313
|
+
* Round-2 P2 fix: only point at last-review.json when the writer
|
|
314
|
+
* actually produced a current snapshot. Mirrors the JSON-path guard on
|
|
315
|
+
* `last_review_path`. If the write failed, the on-disk file is missing
|
|
316
|
+
* or stale; pointing a human there would let them remediate against the
|
|
317
|
+
* wrong findings. Falls back to a self-contained banner that names the
|
|
318
|
+
* failure mode.
|
|
319
|
+
*/
|
|
320
|
+
function printFindingsBySeverity(findings, lastReviewWritten) {
|
|
321
|
+
if (findings.length === 0)
|
|
322
|
+
return;
|
|
323
|
+
const order = ['P1', 'P2', 'P3'];
|
|
324
|
+
log('');
|
|
325
|
+
if (lastReviewWritten) {
|
|
326
|
+
log(`findings (see ${LAST_REVIEW_RELATIVE} for full bodies):`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
log('findings (last-review.json write FAILED — bodies shown inline below; stale file may exist on disk and should be ignored):');
|
|
330
|
+
}
|
|
331
|
+
for (const sev of order) {
|
|
332
|
+
const group = findings.filter((f) => f.severity === sev);
|
|
333
|
+
if (group.length === 0)
|
|
334
|
+
continue;
|
|
335
|
+
for (const f of group) {
|
|
336
|
+
const loc = f.file !== undefined ? ` — ${f.file}${f.line !== undefined ? `:${f.line}` : ''}` : '';
|
|
337
|
+
log(` - [${sev}] ${f.title}${loc}`);
|
|
338
|
+
// Round-3 P2 fix: when the writer failed, the on-disk surface is
|
|
339
|
+
// gone — agents and humans have no other place to read the body.
|
|
340
|
+
// Render the body inline (already redacted upstream) so the
|
|
341
|
+
// banner's "bodies shown inline below" promise is truthful and
|
|
342
|
+
// remediation can still happen. On the success path, bodies stay
|
|
343
|
+
// in last-review.json so the stdout surface stays scannable.
|
|
344
|
+
if (!lastReviewWritten && f.body.length > 0) {
|
|
345
|
+
for (const bodyLine of f.body.split(/\r?\n/)) {
|
|
346
|
+
if (bodyLine.length === 0)
|
|
347
|
+
continue;
|
|
348
|
+
log(` ${bodyLine}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
201
354
|
/**
|
|
202
355
|
* Execute the codex review subprocess and translate the output to a
|
|
203
356
|
* verdict. Reuses the push-gate's resolved policy so `codex_model` /
|
|
@@ -260,6 +413,13 @@ async function executeCodexReview(baseDir, options) {
|
|
|
260
413
|
durationSeconds: codexResult.durationSeconds,
|
|
261
414
|
model: resolved.codex_model ?? IRON_GATE_DEFAULT_MODEL,
|
|
262
415
|
reasoningEffort: resolved.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
|
|
416
|
+
// 0.28.1 defect-V: thread the structured findings + reviewText + event
|
|
417
|
+
// count through to the caller so `runReview` can persist last-review.json
|
|
418
|
+
// and (optionally) print bodies. Pre-fix these were dropped on the floor
|
|
419
|
+
// after `summary.findings.length` was computed.
|
|
420
|
+
findings: summary.findings,
|
|
421
|
+
reviewText: codexResult.reviewText,
|
|
422
|
+
eventCount: codexResult.eventCount,
|
|
263
423
|
};
|
|
264
424
|
}
|
|
265
425
|
function classifyCodexError(e) {
|
|
@@ -313,11 +473,13 @@ export function registerReviewCommand(program) {
|
|
|
313
473
|
return raw;
|
|
314
474
|
})
|
|
315
475
|
.option('--json', 'emit a single-line JSON result instead of human-readable output')
|
|
476
|
+
.option('--with-findings', 'after the summary, print findings grouped by severity (P1/P2/P3); when combined with --json, the JSON payload gains a `findings` array')
|
|
316
477
|
.action(async (opts) => {
|
|
317
478
|
await runReview({
|
|
318
479
|
...(opts.base !== undefined ? { base: opts.base } : {}),
|
|
319
480
|
...(opts.strictFailOn !== undefined ? { strictFailOn: opts.strictFailOn } : {}),
|
|
320
481
|
...(opts.json === true ? { json: true } : {}),
|
|
482
|
+
...(opts.withFindings === true ? { withFindings: true } : {}),
|
|
321
483
|
});
|
|
322
484
|
});
|
|
323
485
|
}
|
|
@@ -145,6 +145,45 @@ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
|
|
|
145
145
|
exit 2
|
|
146
146
|
fi
|
|
147
147
|
|
|
148
|
+
# ── 5a-bis. Reject interior single-dot segments (0.29.0 helix-/./-class) ─────
|
|
149
|
+
# Parallel to the `..` guard above. `normalize_path` does NOT collapse
|
|
150
|
+
# interior `./` segments — that would corrupt `..` traversals — which leaves
|
|
151
|
+
# a bypass class. A blocked entry of `.env` does not match `foo/./.env`
|
|
152
|
+
# (the literal-comparison loop is byte-for-byte), so an attacker who can
|
|
153
|
+
# influence the file_path string can dodge the policy entry.
|
|
154
|
+
#
|
|
155
|
+
# The conservative closure (per Jake 2026-05-12): treat any interior `/./`
|
|
156
|
+
# segment exactly like `..`. The NORMALIZED form is the safe surface for
|
|
157
|
+
# the check — `normalize_path` already stripped leading `./` segments, so
|
|
158
|
+
# any `/./` that survives is interior by construction. A raw-form check
|
|
159
|
+
# would false-positive on benign `./foo` paths (codex round 1 P2: a path
|
|
160
|
+
# like `%2E%2Fsrc/foo.ts` decodes to `./src/foo.ts` which is the same
|
|
161
|
+
# leading-`./` allowed shape the comment at the top of `normalize_path`
|
|
162
|
+
# documents — guarding against it on the raw form would block legit
|
|
163
|
+
# writes under `src/` and friends).
|
|
164
|
+
#
|
|
165
|
+
# URL-encoded companion: `.%2F` / `%2E/` / `%2E%2F` decode to `./` via
|
|
166
|
+
# `normalize_path` (which knows `%2E` → `.` and `%2F` → `/`). After
|
|
167
|
+
# URL-decode + leading-`./` strip, any encoded INTERIOR form hits the
|
|
168
|
+
# normalized `*/./* ` check. No raw-form encoded guard is needed — the
|
|
169
|
+
# normalize_path path already covers every encoded shape the helper
|
|
170
|
+
# decodes, and shapes it doesn't decode wouldn't resolve to an interior
|
|
171
|
+
# `./` segment on disk either.
|
|
172
|
+
norm_has_dot_segment=0
|
|
173
|
+
case "/$NORMALIZED/" in
|
|
174
|
+
*/./*) norm_has_dot_segment=1 ;;
|
|
175
|
+
esac
|
|
176
|
+
if [[ "$norm_has_dot_segment" -eq 1 ]]; then
|
|
177
|
+
{
|
|
178
|
+
printf 'BLOCKED PATH: interior dot-segment rejected\n'
|
|
179
|
+
printf '\n'
|
|
180
|
+
printf ' File: %s\n' "$FILE_PATH"
|
|
181
|
+
printf " Rule: path contains an interior '/./' segment; rewrite to a\n"
|
|
182
|
+
printf ' canonical project-relative path without dot segments.\n'
|
|
183
|
+
} >&2
|
|
184
|
+
exit 2
|
|
185
|
+
fi
|
|
186
|
+
|
|
148
187
|
for writable in "${AGENT_WRITABLE[@]}"; do
|
|
149
188
|
if [[ "$NORMALIZED" == "$writable" ]] || [[ "$NORMALIZED" == "$writable"* && "$writable" == */ ]]; then
|
|
150
189
|
exit 0
|
|
@@ -128,6 +128,45 @@ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
|
|
|
128
128
|
exit 2
|
|
129
129
|
fi
|
|
130
130
|
|
|
131
|
+
# ── 5a-bis. Reject interior single-dot segments (0.29.0 helix-/./-class) ─────
|
|
132
|
+
# Companion to the `..` guard above. The `normalize_path` helper deliberately
|
|
133
|
+
# does NOT collapse interior `./` segments because doing so would corrupt
|
|
134
|
+
# `..` traversals — but that leaves a parallel bypass class. A path like
|
|
135
|
+
# `.husky/./pre-push` resolves on disk to `.husky/pre-push`, yet the literal/
|
|
136
|
+
# prefix matchers in §6 compare against the un-collapsed `.husky/./pre-push`
|
|
137
|
+
# string and miss the match.
|
|
138
|
+
#
|
|
139
|
+
# Conservative reading (per Jake 2026-05-12): treat any interior `./`
|
|
140
|
+
# segment exactly like a `..` segment — refuse outright, force the caller
|
|
141
|
+
# to send a canonical path. The corpus design pairs shell-scripting-specialist
|
|
142
|
+
# with adversarial-test-specialist; the canonical attack shapes are:
|
|
143
|
+
#
|
|
144
|
+
# .husky/./pre-push — single segment
|
|
145
|
+
# .husky/././pre-push — repeated segments
|
|
146
|
+
# .husky/.//pre-push — `./` immediately followed by another `/`
|
|
147
|
+
# .claude/hooks/./_lib/halt-check.sh — inside a protected directory
|
|
148
|
+
# %2E%2F — percent-encoded `./`, caught after URL-decode
|
|
149
|
+
# .\.\pre-push — backslash variant, normalize_path → `./`
|
|
150
|
+
#
|
|
151
|
+
# Only the NORMALIZED form is checked (not the raw form) because raw `./foo`
|
|
152
|
+
# at start-of-string is a legitimate relative path; `normalize_path` already
|
|
153
|
+
# strips leading `./` segments, so anything that survives into the normalized
|
|
154
|
+
# form's `/./` shape is INTERIOR by construction.
|
|
155
|
+
norm_has_dot_segment=0
|
|
156
|
+
case "/$NORMALIZED/" in
|
|
157
|
+
*/./*) norm_has_dot_segment=1 ;;
|
|
158
|
+
esac
|
|
159
|
+
if [[ "$norm_has_dot_segment" -eq 1 ]]; then
|
|
160
|
+
{
|
|
161
|
+
printf 'SETTINGS PROTECTION: interior dot-segment rejected\n'
|
|
162
|
+
printf '\n'
|
|
163
|
+
printf ' File: %s\n' "$SAFE_FILE_PATH"
|
|
164
|
+
printf " Rule: path contains an interior '/./' segment; rewrite to a\n"
|
|
165
|
+
printf ' canonical project-relative path without dot segments.\n'
|
|
166
|
+
} >&2
|
|
167
|
+
exit 2
|
|
168
|
+
fi
|
|
169
|
+
|
|
131
170
|
# Compute lower-cased path early so the §5b allow-list (and §6/§6b matchers
|
|
132
171
|
# below) all reference a single normalized variable.
|
|
133
172
|
LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -167,8 +167,52 @@ trap 'rm -rf -- "$WORK"' EXIT HUP INT TERM
|
|
|
167
167
|
# a new tarball was published. The release.yml rebuild+verify step
|
|
168
168
|
# remains the catching net at publish time, so skipping here does not
|
|
169
169
|
# re-open the BUG-013 attack surface for the merge-to-main path.
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
#
|
|
171
|
+
# 0.29.0: bounded retry loop for npm CDN propagation lag. The memory
|
|
172
|
+
# entries for 0.9.0, 0.12.0, 0.13.0, 0.28.0, and 0.28.1 all note
|
|
173
|
+
# "release verify flaked on npm CDN lag" — `npm view` returns the
|
|
174
|
+
# version metadata but `npm pack` against the same version times out
|
|
175
|
+
# or 404s because the tarball blob has not propagated to all CDN edges
|
|
176
|
+
# yet. The CI-side workflow already has a 12×10s retry (release.yml
|
|
177
|
+
# phase 2); this script runs locally / in PR CI where the failure
|
|
178
|
+
# window is shorter but still occurs.
|
|
179
|
+
#
|
|
180
|
+
# Shape: initial attempt + three retries with sleeps 2s / 8s / 30s.
|
|
181
|
+
# Total worst-case wait = 2 + 8 + 30 = 40s, all on the failure path.
|
|
182
|
+
# That covers the empirically observed CDN propagation window (cf.
|
|
183
|
+
# release.yml phase 2 retry loops) while bounding the local-/ PR-side
|
|
184
|
+
# blocking time to under a minute on a genuine outage.
|
|
185
|
+
NPM_PACK_OK=0
|
|
186
|
+
NPM_PACK_DELAYS=(2 8 30)
|
|
187
|
+
NPM_PACK_ATTEMPTS=$((${#NPM_PACK_DELAYS[@]} + 1))
|
|
188
|
+
# Codex round 1 P2-2: use bash arithmetic for-loop instead of `$(seq 1 N)`.
|
|
189
|
+
# `seq` is not in the preflight tool list (line 104: npm jq git shasum tar)
|
|
190
|
+
# and `set -e` at the top of the script would exit 127 inside the loop body
|
|
191
|
+
# on minimal images that lack it (Alpine, some BusyBox shells). Bash's
|
|
192
|
+
# arithmetic for-loop is a builtin and works on every supported version.
|
|
193
|
+
for ((attempt = 1; attempt <= NPM_PACK_ATTEMPTS; attempt++)); do
|
|
194
|
+
if ( cd "$WORK" && npm pack "${PKG_NAME}@${PREV_VERSION}" --silent >/dev/null 2>&1 ); then
|
|
195
|
+
if [ "$attempt" -gt 1 ]; then
|
|
196
|
+
log "npm pack succeeded after ${attempt} attempt(s) (CDN propagation lag)"
|
|
197
|
+
fi
|
|
198
|
+
NPM_PACK_OK=1
|
|
199
|
+
break
|
|
200
|
+
fi
|
|
201
|
+
# Clean up any partial artifact npm pack may have left in $WORK
|
|
202
|
+
# before retrying so the next attempt has a clean slate.
|
|
203
|
+
find "$WORK" -maxdepth 1 -type f -name '*.tgz' -delete 2>/dev/null || true
|
|
204
|
+
if [ "$attempt" -lt "$NPM_PACK_ATTEMPTS" ]; then
|
|
205
|
+
# Bash array is 0-indexed; $attempt is 1-indexed; index into
|
|
206
|
+
# NPM_PACK_DELAYS at $attempt-1 to read the delay AFTER this
|
|
207
|
+
# failed attempt (before the next try).
|
|
208
|
+
idx=$((attempt - 1))
|
|
209
|
+
delay="${NPM_PACK_DELAYS[$idx]}"
|
|
210
|
+
log "npm pack attempt ${attempt}/${NPM_PACK_ATTEMPTS} failed; sleeping ${delay}s for CDN propagation"
|
|
211
|
+
sleep "$delay"
|
|
212
|
+
fi
|
|
213
|
+
done
|
|
214
|
+
if [ "$NPM_PACK_OK" -ne 1 ]; then
|
|
215
|
+
log "skip — npm pack ${PKG_NAME}@${PREV_VERSION} failed after ${NPM_PACK_ATTEMPTS} attempts (network issue, registry outage, or persistent CDN lag)"
|
|
172
216
|
exit 0
|
|
173
217
|
fi
|
|
174
218
|
|