@bookedsolid/rea 0.19.0 → 0.21.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/.husky/commit-msg +2 -2
- package/dist/cli/init.js +10 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +17 -0
- package/dist/hooks/push-gate/codex-runner.js +24 -2
- package/dist/hooks/push-gate/index.js +63 -27
- package/dist/hooks/push-gate/verdict-cache.d.ts +46 -8
- package/dist/hooks/push-gate/verdict-cache.js +193 -70
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +17 -1
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +6 -0
- package/dist/policy/types.d.ts +12 -0
- package/hooks/_lib/cmd-segments.sh +8 -1
- package/hooks/_lib/path-normalize.sh +75 -0
- package/hooks/_lib/protected-paths.sh +11 -0
- package/hooks/architecture-review-gate.sh +23 -9
- package/hooks/attribution-advisory.sh +1 -1
- package/hooks/blocked-paths-bash-gate.sh +19 -0
- package/hooks/protected-paths-bash-gate.sh +54 -9
- package/hooks/settings-protection.sh +8 -1
- package/package.json +2 -1
- package/profiles/bst-internal.yaml +15 -0
- package/scripts/postinstall.mjs +7 -0
package/.husky/commit-msg
CHANGED
|
@@ -86,9 +86,9 @@ MATCHES=""
|
|
|
86
86
|
# Pattern 2 below catches Co-Authored-By with named tools regardless of
|
|
87
87
|
# email, so dropping users.noreply.github.com from this branch only
|
|
88
88
|
# relaxes the check for human collaborators — never for AI.
|
|
89
|
-
if grep -qiE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com)' "$COMMIT_MSG_FILE" 2>/dev/null; then
|
|
89
|
+
if grep -qiE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)' "$COMMIT_MSG_FILE" 2>/dev/null; then
|
|
90
90
|
BLOCKED=1
|
|
91
|
-
MATCHES="${MATCHES}$(grep -niE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com)' "$COMMIT_MSG_FILE" 2>/dev/null)
|
|
91
|
+
MATCHES="${MATCHES}$(grep -niE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)' "$COMMIT_MSG_FILE" 2>/dev/null)
|
|
92
92
|
"
|
|
93
93
|
fi
|
|
94
94
|
|
package/dist/cli/init.js
CHANGED
|
@@ -297,6 +297,16 @@ function writePolicyYaml(targetDir, config, layered) {
|
|
|
297
297
|
lines.push(` max_bash_output_lines: ${cp.max_bash_output_lines}`);
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
+
// 0.20.1+ helix-round-N P2: emit architecture_review.patterns when
|
|
301
|
+
// the layered profile declared them. Consumers without patterns see
|
|
302
|
+
// a silent no-op from architecture-review-gate.sh.
|
|
303
|
+
if (layered.architecture_review?.patterns !== undefined) {
|
|
304
|
+
lines.push(`architecture_review:`);
|
|
305
|
+
lines.push(` patterns:`);
|
|
306
|
+
for (const p of layered.architecture_review.patterns) {
|
|
307
|
+
lines.push(` - ${JSON.stringify(p)}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
300
310
|
// 0.18.1+ helixir #9: emit audit.rotation when the layered profile
|
|
301
311
|
// declared it. Empty `rotation: {}` opts in to documented defaults
|
|
302
312
|
// (50 MiB / 30 days); explicit values override.
|
|
@@ -19,6 +19,23 @@
|
|
|
19
19
|
* and so the one git dependency surface is in one place.
|
|
20
20
|
*/
|
|
21
21
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
22
|
+
/**
|
|
23
|
+
* Default codex model when policy doesn't pin one. Always passed via
|
|
24
|
+
* `-c model="<name>"` so codex's own default (`codex-auto-review` at
|
|
25
|
+
* medium reasoning) is unreachable through the rea push-gate.
|
|
26
|
+
*
|
|
27
|
+
* 0.19.0 code-reviewer P3-4: exported as a single source of truth.
|
|
28
|
+
* `src/hooks/push-gate/index.ts` imports this for the verdict-cache
|
|
29
|
+
* write so the cached `model` field reflects the same constant the
|
|
30
|
+
* runner actually used. Bump here to bump everywhere.
|
|
31
|
+
*/
|
|
32
|
+
export declare const IRON_GATE_DEFAULT_MODEL = "gpt-5.4";
|
|
33
|
+
/**
|
|
34
|
+
* Default reasoning effort when policy doesn't pin one. `high` for
|
|
35
|
+
* verdict stability — the helixir 2026-04-26 thrashing came from the
|
|
36
|
+
* lower-reasoning default.
|
|
37
|
+
*/
|
|
38
|
+
export declare const IRON_GATE_DEFAULT_REASONING: 'low' | 'medium' | 'high';
|
|
22
39
|
export declare class CodexNotInstalledError extends Error {
|
|
23
40
|
readonly kind: "not-installed";
|
|
24
41
|
constructor();
|
|
@@ -20,6 +20,26 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { spawn, spawnSync } from 'node:child_process';
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
|
+
// Iron-gate runtime defaults (0.18.0+)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Default codex model when policy doesn't pin one. Always passed via
|
|
27
|
+
* `-c model="<name>"` so codex's own default (`codex-auto-review` at
|
|
28
|
+
* medium reasoning) is unreachable through the rea push-gate.
|
|
29
|
+
*
|
|
30
|
+
* 0.19.0 code-reviewer P3-4: exported as a single source of truth.
|
|
31
|
+
* `src/hooks/push-gate/index.ts` imports this for the verdict-cache
|
|
32
|
+
* write so the cached `model` field reflects the same constant the
|
|
33
|
+
* runner actually used. Bump here to bump everywhere.
|
|
34
|
+
*/
|
|
35
|
+
export const IRON_GATE_DEFAULT_MODEL = 'gpt-5.4';
|
|
36
|
+
/**
|
|
37
|
+
* Default reasoning effort when policy doesn't pin one. `high` for
|
|
38
|
+
* verdict stability — the helixir 2026-04-26 thrashing came from the
|
|
39
|
+
* lower-reasoning default.
|
|
40
|
+
*/
|
|
41
|
+
export const IRON_GATE_DEFAULT_REASONING = 'high';
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
23
43
|
// Errors
|
|
24
44
|
// ---------------------------------------------------------------------------
|
|
25
45
|
export class CodexNotInstalledError extends Error {
|
|
@@ -151,8 +171,10 @@ export async function runCodexReview(options) {
|
|
|
151
171
|
// Codex's TOML parser interprets the value, so we wrap strings in TOML
|
|
152
172
|
// quotes — `-c model="gpt-5.4"` not `-c model=gpt-5.4` — to ensure the
|
|
153
173
|
// value lands as a string regardless of upstream parsing changes.
|
|
154
|
-
const effectiveModel = options.model !== undefined && options.model.length > 0
|
|
155
|
-
|
|
174
|
+
const effectiveModel = options.model !== undefined && options.model.length > 0
|
|
175
|
+
? options.model
|
|
176
|
+
: IRON_GATE_DEFAULT_MODEL;
|
|
177
|
+
const effectiveReasoning = options.reasoningEffort ?? IRON_GATE_DEFAULT_REASONING;
|
|
156
178
|
const overrideArgs = [
|
|
157
179
|
'-c',
|
|
158
180
|
`model="${escapeTomlString(effectiveModel)}"`,
|
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { appendAuditRecord } from '../../audit/append.js';
|
|
26
|
+
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
26
27
|
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
27
28
|
import { resolvePushGatePolicy, PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK, } from './policy.js';
|
|
28
29
|
import { readHalt } from './halt.js';
|
|
29
30
|
import { resolveBaseRef } from './base.js';
|
|
30
|
-
import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
|
|
31
|
+
import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, } from './codex-runner.js';
|
|
31
32
|
import { summarizeReview } from './findings.js';
|
|
32
33
|
import { renderBanner, writeLastReview } from './report.js';
|
|
33
34
|
import { isFlip, lookupVerdict, writeVerdict, } from './verdict-cache.js';
|
|
@@ -87,13 +88,27 @@ export async function runPushGate(deps) {
|
|
|
87
88
|
const runCodexFn = deps.runCodex ?? runCodexReview;
|
|
88
89
|
const appendAuditFn = deps.appendAudit ?? appendAuditRecord;
|
|
89
90
|
const git = deps.git ?? createRealGitExecutor(deps.baseDir);
|
|
91
|
+
// 0.19.0 backend-engineer review P1-1: load the full Policy once and
|
|
92
|
+
// thread it to every safeAppend so audit rotation actually fires.
|
|
93
|
+
// Pre-fix the rotator short-circuited because policy was never passed
|
|
94
|
+
// through, silently disabling the `audit.rotation: {}` opt-in shipped
|
|
95
|
+
// in 0.18.1 for the bst-internal profile. A failure to load policy
|
|
96
|
+
// here is non-fatal — the gate continues; audit rotation just stays
|
|
97
|
+
// disabled for this run (back-compat).
|
|
98
|
+
let fullPolicy;
|
|
99
|
+
try {
|
|
100
|
+
fullPolicy = await loadPolicyAsync(deps.baseDir);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
fullPolicy = undefined;
|
|
104
|
+
}
|
|
90
105
|
// 1. HALT wins over everything, including `review.codex_required: false`.
|
|
91
106
|
// Reading it before policy also means a corrupted policy.yaml doesn't
|
|
92
107
|
// prevent the kill-switch from firing.
|
|
93
108
|
const halt = readHaltFn(deps.baseDir);
|
|
94
109
|
if (halt.halted) {
|
|
95
110
|
stderr(`REA HALT: ${halt.reason ?? 'unknown'}\nAll push operations suspended. Run: rea unfreeze\n`);
|
|
96
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, {
|
|
111
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, fullPolicy, {
|
|
97
112
|
reason: halt.reason ?? 'unknown',
|
|
98
113
|
});
|
|
99
114
|
return {
|
|
@@ -111,14 +126,14 @@ export async function runPushGate(deps) {
|
|
|
111
126
|
catch (e) {
|
|
112
127
|
const msg = e instanceof Error ? e.message : String(e);
|
|
113
128
|
stderr(`PUSH BLOCKED: failed to load .rea/policy.yaml — ${msg}\n`);
|
|
114
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, {
|
|
129
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, fullPolicy, {
|
|
115
130
|
kind: 'policy-load',
|
|
116
131
|
error: msg,
|
|
117
132
|
});
|
|
118
133
|
return { status: 'error', exitCode: 2, summary: `policy-load error: ${msg}` };
|
|
119
134
|
}
|
|
120
135
|
if (!policy.codex_required) {
|
|
121
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, {
|
|
136
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, fullPolicy, {
|
|
122
137
|
policy_missing: policy.policyMissing,
|
|
123
138
|
});
|
|
124
139
|
return {
|
|
@@ -156,7 +171,7 @@ export async function runPushGate(deps) {
|
|
|
156
171
|
const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
|
|
157
172
|
const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
|
|
158
173
|
stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
|
|
159
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
|
|
174
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, fullPolicy, {
|
|
160
175
|
reason: skipReason,
|
|
161
176
|
skip_var: skipVar,
|
|
162
177
|
});
|
|
@@ -251,7 +266,7 @@ export async function runPushGate(deps) {
|
|
|
251
266
|
}
|
|
252
267
|
if (headSha.length === 0) {
|
|
253
268
|
stderr('PUSH BLOCKED: could not resolve HEAD SHA. Is this a valid git repo?\n');
|
|
254
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
|
|
269
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, fullPolicy, { kind: 'head-sha-missing' });
|
|
255
270
|
return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
|
|
256
271
|
}
|
|
257
272
|
// 4b. Auto-narrow probe (J / 0.13.0). When the resolved base is far
|
|
@@ -321,7 +336,7 @@ export async function runPushGate(deps) {
|
|
|
321
336
|
// no-op relative to base.
|
|
322
337
|
const diff = git.diffNames(base.ref, headSha);
|
|
323
338
|
if (diff.length === 0) {
|
|
324
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, {
|
|
339
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, fullPolicy, {
|
|
325
340
|
base_ref: base.ref,
|
|
326
341
|
base_source: base.source,
|
|
327
342
|
head_sha: headSha,
|
|
@@ -348,7 +363,12 @@ export async function runPushGate(deps) {
|
|
|
348
363
|
const cached = cacheLookup.entry;
|
|
349
364
|
const cachedBlocked = cached.verdict === 'blocking'
|
|
350
365
|
|| (cached.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
|
|
351
|
-
|
|
366
|
+
// 0.19.1 P3-3 (code-reviewer): emit EVT_CACHE_HIT (forensic detail
|
|
367
|
+
// for the cache layer specifically) AND EVT_REVIEWED (the canonical
|
|
368
|
+
// verdict event with `cache_hit: true` metadata). Operators
|
|
369
|
+
// grepping `rea.push_gate.reviewed` for verdict-stability dashboards
|
|
370
|
+
// see every push, including cached ones.
|
|
371
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_CACHE_HIT, fullPolicy, {
|
|
352
372
|
verdict: cached.verdict,
|
|
353
373
|
finding_count: cached.finding_count,
|
|
354
374
|
base_ref: base.ref,
|
|
@@ -359,16 +379,23 @@ export async function runPushGate(deps) {
|
|
|
359
379
|
cached_reasoning_effort: cached.reasoning_effort,
|
|
360
380
|
blocked: cachedBlocked,
|
|
361
381
|
});
|
|
382
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
|
|
383
|
+
verdict: cached.verdict,
|
|
384
|
+
finding_count: cached.finding_count,
|
|
385
|
+
base_ref: base.ref,
|
|
386
|
+
base_source: base.source,
|
|
387
|
+
head_sha: headSha,
|
|
388
|
+
blocked: cachedBlocked,
|
|
389
|
+
cache_hit: true,
|
|
390
|
+
cached_reviewed_at: cached.reviewed_at,
|
|
391
|
+
cached_model: cached.model,
|
|
392
|
+
cached_reasoning_effort: cached.reasoning_effort,
|
|
393
|
+
});
|
|
394
|
+
// 0.19.1 P3-1 (backend): simplified return shape. Verdict maps
|
|
395
|
+
// 1:1 to status; cachedBlocked maps 1:1 to exitCode. The prior
|
|
396
|
+
// nested ternary recomputed the same mapping in both arms.
|
|
362
397
|
return {
|
|
363
|
-
status:
|
|
364
|
-
? cached.verdict === 'blocking'
|
|
365
|
-
? 'blocking'
|
|
366
|
-
: 'concerns'
|
|
367
|
-
: cached.verdict === 'blocking'
|
|
368
|
-
? 'blocking'
|
|
369
|
-
: cached.verdict === 'concerns'
|
|
370
|
-
? 'concerns'
|
|
371
|
-
: 'pass',
|
|
398
|
+
status: cached.verdict,
|
|
372
399
|
exitCode: cachedBlocked ? 2 : 0,
|
|
373
400
|
summary: `${cached.verdict}: ${cached.finding_count} finding(s) (cached)`,
|
|
374
401
|
verdict: cached.verdict,
|
|
@@ -423,7 +450,7 @@ export async function runPushGate(deps) {
|
|
|
423
450
|
if (policy.cache_ttl_ms > 0) {
|
|
424
451
|
const flipped = isFlip(cacheLookup.entry, summary.verdict);
|
|
425
452
|
if (flipped && cacheLookup.entry !== undefined) {
|
|
426
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, {
|
|
453
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, fullPolicy, {
|
|
427
454
|
head_sha: headSha,
|
|
428
455
|
prior_verdict: cacheLookup.entry.verdict,
|
|
429
456
|
fresh_verdict: summary.verdict,
|
|
@@ -435,20 +462,22 @@ export async function runPushGate(deps) {
|
|
|
435
462
|
verdict: summary.verdict,
|
|
436
463
|
finding_count: summary.findings.length,
|
|
437
464
|
reviewed_at: deps.now !== undefined ? deps.now().toISOString() : new Date().toISOString(),
|
|
438
|
-
model: policy.codex_model ??
|
|
439
|
-
reasoning_effort: policy.codex_reasoning_effort ??
|
|
465
|
+
model: policy.codex_model ?? IRON_GATE_DEFAULT_MODEL,
|
|
466
|
+
reasoning_effort: policy.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
|
|
440
467
|
ttl_ms: policy.cache_ttl_ms,
|
|
441
468
|
};
|
|
442
469
|
try {
|
|
443
|
-
writeVerdict(deps.baseDir, headSha, entry);
|
|
470
|
+
await writeVerdict(deps.baseDir, headSha, entry);
|
|
444
471
|
}
|
|
445
472
|
catch {
|
|
446
473
|
// Cache writes are best-effort. A failure here must NOT
|
|
447
474
|
// affect the verdict — log to stderr (already done by the
|
|
448
|
-
// caller via banner) and proceed.
|
|
475
|
+
// caller via banner) and proceed. Foreign-schema (v3+ cache
|
|
476
|
+
// from a future rea version) lands here and is correctly
|
|
477
|
+
// declined — overwriting would lose forward-compat data.
|
|
449
478
|
}
|
|
450
479
|
}
|
|
451
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
|
|
480
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
|
|
452
481
|
verdict: summary.verdict,
|
|
453
482
|
finding_count: summary.findings.length,
|
|
454
483
|
base_ref: base.ref,
|
|
@@ -492,7 +521,7 @@ export async function runPushGate(deps) {
|
|
|
492
521
|
};
|
|
493
522
|
}
|
|
494
523
|
catch (e) {
|
|
495
|
-
return handleCodexError(e, deps, base, headSha, appendAuditFn);
|
|
524
|
+
return handleCodexError(e, deps, base, headSha, appendAuditFn, fullPolicy);
|
|
496
525
|
}
|
|
497
526
|
}
|
|
498
527
|
function isConcernsOverrideSet(env) {
|
|
@@ -502,7 +531,7 @@ function isConcernsOverrideSet(env) {
|
|
|
502
531
|
const normalized = raw.trim().toLowerCase();
|
|
503
532
|
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
504
533
|
}
|
|
505
|
-
async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
|
|
534
|
+
async function handleCodexError(e, deps, base, headSha, appendAuditFn, policy) {
|
|
506
535
|
const stderr = deps.stderr;
|
|
507
536
|
const runError = classifyCodexError(e);
|
|
508
537
|
const metadata = {
|
|
@@ -514,7 +543,7 @@ async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
|
|
|
514
543
|
if (runError.message.length > 0)
|
|
515
544
|
metadata.error = runError.message;
|
|
516
545
|
stderr(`PUSH BLOCKED: ${runError.message}\n`);
|
|
517
|
-
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, metadata);
|
|
546
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, policy, metadata);
|
|
518
547
|
return {
|
|
519
548
|
status: 'error',
|
|
520
549
|
exitCode: 2,
|
|
@@ -542,7 +571,7 @@ function classifyCodexError(e) {
|
|
|
542
571
|
* its primary result. The hash chain remains intact if this succeeds; on
|
|
543
572
|
* failure we've already made the gate decision based on the actual review.
|
|
544
573
|
*/
|
|
545
|
-
async function safeAppend(appendFn, baseDir, toolName, metadata) {
|
|
574
|
+
async function safeAppend(appendFn, baseDir, toolName, policy, metadata) {
|
|
546
575
|
try {
|
|
547
576
|
// Prune undefined values — the audit record schema's `metadata` is an
|
|
548
577
|
// arbitrary map, but `undefined` values cause JSON.stringify to emit
|
|
@@ -552,12 +581,19 @@ async function safeAppend(appendFn, baseDir, toolName, metadata) {
|
|
|
552
581
|
if (v !== undefined)
|
|
553
582
|
cleanMeta[k] = v;
|
|
554
583
|
}
|
|
584
|
+
// 0.19.0 P1-1 fix (backend-engineer review): pass the loaded Policy
|
|
585
|
+
// through so `appendAuditRecord` → `maybeRotate` actually fires.
|
|
586
|
+
// Pre-fix the policy was never threaded; rotation short-circuited
|
|
587
|
+
// to `{ rotated: false }` on the entire push-gate audit-emission
|
|
588
|
+
// path, silently disabling the `audit.rotation: {}` opt-in shipped
|
|
589
|
+
// in 0.18.1 for the bst-internal profile.
|
|
555
590
|
await appendFn(baseDir, {
|
|
556
591
|
tool_name: toolName,
|
|
557
592
|
server_name: AUDIT_SERVER_NAME,
|
|
558
593
|
tier: Tier.Read,
|
|
559
594
|
status: InvocationStatus.Allowed,
|
|
560
595
|
...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
|
|
596
|
+
...(policy !== undefined ? { policy } : {}),
|
|
561
597
|
});
|
|
562
598
|
}
|
|
563
599
|
catch (e) {
|
|
@@ -37,6 +37,21 @@
|
|
|
37
37
|
* audit log alone (helixir #8).
|
|
38
38
|
* - REA_SKIP_CODEX_REVIEW short-circuits BEFORE cache lookup (unchanged).
|
|
39
39
|
*
|
|
40
|
+
* 0.19.0 review fixes:
|
|
41
|
+
* - Concurrent writes are now serialized via `withAuditLock` on the
|
|
42
|
+
* `.rea/` directory (backend-engineer P1-2; security M3). Two
|
|
43
|
+
* concurrent push-gate runs no longer race read-modify-write.
|
|
44
|
+
* - Tmp filenames carry a high-entropy suffix (PID + millis + random)
|
|
45
|
+
* and are unlinked in finally so a crash mid-write doesn't leave
|
|
46
|
+
* stale state (backend-engineer P1-3; code-reviewer P2-1).
|
|
47
|
+
* - All three writers (writeVerdict, clearVerdict, pruneOlderThan,
|
|
48
|
+
* clearAll) route through one `_atomicWrite` helper — no asymmetry
|
|
49
|
+
* between paths (code-reviewer P2-2).
|
|
50
|
+
* - On unrecognized schema_version, reads return undefined AND
|
|
51
|
+
* writes refuse to overwrite — the v3 cache stays intact for a
|
|
52
|
+
* future rea version that knows how to read it (code-reviewer P3-5;
|
|
53
|
+
* backend-engineer P2-2).
|
|
54
|
+
*
|
|
40
55
|
* The cache is OPTIONAL by design: existing callers that don't pass a
|
|
41
56
|
* `cacheImpl` get the legacy stateless path. Tests inject a fake.
|
|
42
57
|
*/
|
|
@@ -44,6 +59,16 @@ import type { Verdict as ReviewVerdict } from './findings.js';
|
|
|
44
59
|
export declare const VERDICT_CACHE_FILE = "last-review.cache.json";
|
|
45
60
|
export declare const VERDICT_CACHE_SCHEMA_VERSION: 2;
|
|
46
61
|
export declare const DEFAULT_CACHE_TTL_MS: number;
|
|
62
|
+
/**
|
|
63
|
+
* Soft cap on cached entry count before `writeVerdict` opportunistically
|
|
64
|
+
* prunes expired entries (0.19.1 backend-engineer P2-3). Keeps the
|
|
65
|
+
* cache file from growing unbounded over months of pushes against many
|
|
66
|
+
* SHAs — at 500 entries × ~200 bytes/entry ≈ 100 KB, we proactively
|
|
67
|
+
* drop expired entries on the next write. The prune is best-effort:
|
|
68
|
+
* if every entry is unexpired we accept the larger file rather than
|
|
69
|
+
* dropping fresh durable verdicts.
|
|
70
|
+
*/
|
|
71
|
+
export declare const VERDICT_CACHE_PRUNE_THRESHOLD = 500;
|
|
47
72
|
export interface VerdictCacheEntry {
|
|
48
73
|
verdict: ReviewVerdict;
|
|
49
74
|
finding_count: number;
|
|
@@ -66,33 +91,46 @@ export interface VerdictCacheLookupResult {
|
|
|
66
91
|
* miss with `entry: undefined` — the caller proceeds to codex.
|
|
67
92
|
*/
|
|
68
93
|
export declare function lookupVerdict(baseDir: string, headSha: string, now?: Date): VerdictCacheLookupResult;
|
|
69
|
-
/**
|
|
70
|
-
* Write a fresh verdict entry. Atomic via tmp-file + rename. Unrecognized
|
|
71
|
-
* pre-existing entries are preserved (forward-compat for v3+).
|
|
72
|
-
*/
|
|
73
|
-
export declare function writeVerdict(baseDir: string, headSha: string, entry: VerdictCacheEntry): void;
|
|
74
94
|
/**
|
|
75
95
|
* Detect whether a new verdict contradicts a previously-cached verdict
|
|
76
96
|
* on the same SHA. Used by `runPushGate` to set the flip-flag on
|
|
77
97
|
* last-review.json and emit the `verdict_flip` audit event.
|
|
78
98
|
*/
|
|
79
99
|
export declare function isFlip(prior: VerdictCacheEntry | undefined, fresh: ReviewVerdict): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Write a fresh verdict entry. Atomic via tmp-file + rename, serialized
|
|
102
|
+
* via `withAuditLock` on `.rea/`. Refuses to overwrite when the existing
|
|
103
|
+
* cache has an unrecognized schema_version (forward-compat — a v3 cache
|
|
104
|
+
* from a future rea version stays intact for that version to read).
|
|
105
|
+
*/
|
|
106
|
+
export declare function writeVerdict(baseDir: string, headSha: string, entry: VerdictCacheEntry): Promise<void>;
|
|
80
107
|
/**
|
|
81
108
|
* Remove a single SHA from the cache. Returns true if the entry existed.
|
|
82
109
|
*/
|
|
83
|
-
export declare function clearVerdict(baseDir: string, headSha: string): boolean
|
|
110
|
+
export declare function clearVerdict(baseDir: string, headSha: string): Promise<boolean>;
|
|
84
111
|
/**
|
|
85
112
|
* Remove ALL entries from the cache. Returns the count of removed entries.
|
|
86
113
|
*/
|
|
87
|
-
export declare function clearAll(baseDir: string): number
|
|
114
|
+
export declare function clearAll(baseDir: string): Promise<number>;
|
|
88
115
|
/**
|
|
89
116
|
* Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
|
|
90
117
|
* Returns the count of removed entries.
|
|
91
118
|
*/
|
|
92
|
-
export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): number
|
|
119
|
+
export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): Promise<number>;
|
|
93
120
|
/**
|
|
94
121
|
* Read all entries (used by `rea cache stats` / `rea cache show`).
|
|
95
122
|
* Returns empty object on any read error (missing file, malformed JSON,
|
|
96
123
|
* unsupported schema_version).
|
|
97
124
|
*/
|
|
98
125
|
export declare function listEntries(baseDir: string): Record<string, VerdictCacheEntry>;
|
|
126
|
+
/**
|
|
127
|
+
* Thrown by writeVerdict when the existing cache file has an
|
|
128
|
+
* unrecognized schema_version. The caller (push-gate) catches this
|
|
129
|
+
* and treats the write as best-effort failure (log to stderr,
|
|
130
|
+
* continue) rather than overwriting forward-compat data.
|
|
131
|
+
*/
|
|
132
|
+
export declare class VerdictCacheForeignSchemaError extends Error {
|
|
133
|
+
readonly cachePath: string;
|
|
134
|
+
readonly kind: "foreign-schema";
|
|
135
|
+
constructor(cachePath: string);
|
|
136
|
+
}
|
|
@@ -37,14 +37,41 @@
|
|
|
37
37
|
* audit log alone (helixir #8).
|
|
38
38
|
* - REA_SKIP_CODEX_REVIEW short-circuits BEFORE cache lookup (unchanged).
|
|
39
39
|
*
|
|
40
|
+
* 0.19.0 review fixes:
|
|
41
|
+
* - Concurrent writes are now serialized via `withAuditLock` on the
|
|
42
|
+
* `.rea/` directory (backend-engineer P1-2; security M3). Two
|
|
43
|
+
* concurrent push-gate runs no longer race read-modify-write.
|
|
44
|
+
* - Tmp filenames carry a high-entropy suffix (PID + millis + random)
|
|
45
|
+
* and are unlinked in finally so a crash mid-write doesn't leave
|
|
46
|
+
* stale state (backend-engineer P1-3; code-reviewer P2-1).
|
|
47
|
+
* - All three writers (writeVerdict, clearVerdict, pruneOlderThan,
|
|
48
|
+
* clearAll) route through one `_atomicWrite` helper — no asymmetry
|
|
49
|
+
* between paths (code-reviewer P2-2).
|
|
50
|
+
* - On unrecognized schema_version, reads return undefined AND
|
|
51
|
+
* writes refuse to overwrite — the v3 cache stays intact for a
|
|
52
|
+
* future rea version that knows how to read it (code-reviewer P3-5;
|
|
53
|
+
* backend-engineer P2-2).
|
|
54
|
+
*
|
|
40
55
|
* The cache is OPTIONAL by design: existing callers that don't pass a
|
|
41
56
|
* `cacheImpl` get the legacy stateless path. Tests inject a fake.
|
|
42
57
|
*/
|
|
58
|
+
import crypto from 'node:crypto';
|
|
43
59
|
import fs from 'node:fs';
|
|
44
60
|
import path from 'node:path';
|
|
61
|
+
import { withAuditLock } from '../../audit/fs.js';
|
|
45
62
|
export const VERDICT_CACHE_FILE = 'last-review.cache.json';
|
|
46
63
|
export const VERDICT_CACHE_SCHEMA_VERSION = 2;
|
|
47
64
|
export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24h
|
|
65
|
+
/**
|
|
66
|
+
* Soft cap on cached entry count before `writeVerdict` opportunistically
|
|
67
|
+
* prunes expired entries (0.19.1 backend-engineer P2-3). Keeps the
|
|
68
|
+
* cache file from growing unbounded over months of pushes against many
|
|
69
|
+
* SHAs — at 500 entries × ~200 bytes/entry ≈ 100 KB, we proactively
|
|
70
|
+
* drop expired entries on the next write. The prune is best-effort:
|
|
71
|
+
* if every entry is unexpired we accept the larger file rather than
|
|
72
|
+
* dropping fresh durable verdicts.
|
|
73
|
+
*/
|
|
74
|
+
export const VERDICT_CACHE_PRUNE_THRESHOLD = 500;
|
|
48
75
|
/**
|
|
49
76
|
* Read the cache file and look up `head_sha`. Missing file, malformed
|
|
50
77
|
* JSON, missing entry, and unsupported schema_version all resolve to a
|
|
@@ -66,25 +93,6 @@ export function lookupVerdict(baseDir, headSha, now = new Date()) {
|
|
|
66
93
|
}
|
|
67
94
|
return { hit: true, entry };
|
|
68
95
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Write a fresh verdict entry. Atomic via tmp-file + rename. Unrecognized
|
|
71
|
-
* pre-existing entries are preserved (forward-compat for v3+).
|
|
72
|
-
*/
|
|
73
|
-
export function writeVerdict(baseDir, headSha, entry) {
|
|
74
|
-
const reaDir = path.join(baseDir, '.rea');
|
|
75
|
-
if (!fs.existsSync(reaDir)) {
|
|
76
|
-
fs.mkdirSync(reaDir, { recursive: true });
|
|
77
|
-
}
|
|
78
|
-
const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
|
|
79
|
-
const existing = readCacheFile(baseDir);
|
|
80
|
-
const next = {
|
|
81
|
-
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
82
|
-
entries: { ...(existing?.entries ?? {}), [headSha]: entry },
|
|
83
|
-
};
|
|
84
|
-
const tmp = `${cachePath}.tmp.${process.pid}`;
|
|
85
|
-
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
86
|
-
fs.renameSync(tmp, cachePath);
|
|
87
|
-
}
|
|
88
96
|
/**
|
|
89
97
|
* Detect whether a new verdict contradicts a previously-cached verdict
|
|
90
98
|
* on the same SHA. Used by `runPushGate` to set the flip-flag on
|
|
@@ -95,68 +103,126 @@ export function isFlip(prior, fresh) {
|
|
|
95
103
|
return false;
|
|
96
104
|
return prior.verdict !== fresh;
|
|
97
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Write a fresh verdict entry. Atomic via tmp-file + rename, serialized
|
|
108
|
+
* via `withAuditLock` on `.rea/`. Refuses to overwrite when the existing
|
|
109
|
+
* cache has an unrecognized schema_version (forward-compat — a v3 cache
|
|
110
|
+
* from a future rea version stays intact for that version to read).
|
|
111
|
+
*/
|
|
112
|
+
export async function writeVerdict(baseDir, headSha, entry) {
|
|
113
|
+
const reaDir = path.join(baseDir, '.rea');
|
|
114
|
+
if (!fs.existsSync(reaDir)) {
|
|
115
|
+
fs.mkdirSync(reaDir, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
|
|
118
|
+
await withAuditLock(cachePath, async () => {
|
|
119
|
+
if (foreignSchemaPresent(baseDir)) {
|
|
120
|
+
throw new VerdictCacheForeignSchemaError(cachePath);
|
|
121
|
+
}
|
|
122
|
+
const existing = readCacheFile(baseDir);
|
|
123
|
+
const merged = {
|
|
124
|
+
...(existing?.entries ?? {}),
|
|
125
|
+
[headSha]: entry,
|
|
126
|
+
};
|
|
127
|
+
// 0.19.1 P2-3 (backend-engineer): opportunistic prune.
|
|
128
|
+
// When the cache crosses the soft threshold, drop entries whose
|
|
129
|
+
// own ttl_ms has expired before writing. Cheap walk; bounded to
|
|
130
|
+
// O(n) at write time only when n > threshold. Prevents the cache
|
|
131
|
+
// file from growing unbounded over months of pushes.
|
|
132
|
+
const pruned = Object.keys(merged).length > VERDICT_CACHE_PRUNE_THRESHOLD
|
|
133
|
+
? _pruneExpired(merged, new Date())
|
|
134
|
+
: merged;
|
|
135
|
+
const next = {
|
|
136
|
+
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
137
|
+
entries: pruned,
|
|
138
|
+
};
|
|
139
|
+
_atomicWriteJson(cachePath, next);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function _pruneExpired(entries, now) {
|
|
143
|
+
const surviving = {};
|
|
144
|
+
const nowMs = now.getTime();
|
|
145
|
+
for (const [sha, entry] of Object.entries(entries)) {
|
|
146
|
+
const reviewedAtMs = Date.parse(entry.reviewed_at);
|
|
147
|
+
if (Number.isNaN(reviewedAtMs)) {
|
|
148
|
+
surviving[sha] = entry;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (nowMs - reviewedAtMs < entry.ttl_ms) {
|
|
152
|
+
surviving[sha] = entry;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return surviving;
|
|
156
|
+
}
|
|
98
157
|
/**
|
|
99
158
|
* Remove a single SHA from the cache. Returns true if the entry existed.
|
|
100
159
|
*/
|
|
101
|
-
export function clearVerdict(baseDir, headSha) {
|
|
102
|
-
const file = readCacheFile(baseDir);
|
|
103
|
-
if (file === undefined || file.entries[headSha] === undefined)
|
|
104
|
-
return false;
|
|
105
|
-
const next = {
|
|
106
|
-
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
107
|
-
entries: { ...file.entries },
|
|
108
|
-
};
|
|
109
|
-
delete next.entries[headSha];
|
|
160
|
+
export async function clearVerdict(baseDir, headSha) {
|
|
110
161
|
const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
|
|
111
|
-
|
|
112
|
-
|
|
162
|
+
return withAuditLock(cachePath, async () => {
|
|
163
|
+
const file = readCacheFile(baseDir);
|
|
164
|
+
if (file === undefined || file.entries[headSha] === undefined)
|
|
165
|
+
return false;
|
|
166
|
+
const next = {
|
|
167
|
+
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
168
|
+
entries: { ...file.entries },
|
|
169
|
+
};
|
|
170
|
+
delete next.entries[headSha];
|
|
171
|
+
_atomicWriteJson(cachePath, next);
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
113
174
|
}
|
|
114
175
|
/**
|
|
115
176
|
* Remove ALL entries from the cache. Returns the count of removed entries.
|
|
116
177
|
*/
|
|
117
|
-
export function clearAll(baseDir) {
|
|
118
|
-
const
|
|
119
|
-
const cachePath = path.join(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
123
|
-
entries: {},
|
|
124
|
-
};
|
|
125
|
-
if (!fs.existsSync(path.dirname(cachePath))) {
|
|
126
|
-
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
178
|
+
export async function clearAll(baseDir) {
|
|
179
|
+
const reaDir = path.join(baseDir, '.rea');
|
|
180
|
+
const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
|
|
181
|
+
if (!fs.existsSync(reaDir)) {
|
|
182
|
+
fs.mkdirSync(reaDir, { recursive: true });
|
|
127
183
|
}
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
return withAuditLock(cachePath, async () => {
|
|
185
|
+
const file = readCacheFile(baseDir);
|
|
186
|
+
const count = file === undefined ? 0 : Object.keys(file.entries).length;
|
|
187
|
+
const empty = {
|
|
188
|
+
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
189
|
+
entries: {},
|
|
190
|
+
};
|
|
191
|
+
_atomicWriteJson(cachePath, empty);
|
|
192
|
+
return count;
|
|
193
|
+
});
|
|
130
194
|
}
|
|
131
195
|
/**
|
|
132
196
|
* Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
|
|
133
197
|
* Returns the count of removed entries.
|
|
134
198
|
*/
|
|
135
|
-
export function pruneOlderThan(baseDir, olderThanMs, now = new Date()) {
|
|
136
|
-
const file = readCacheFile(baseDir);
|
|
137
|
-
if (file === undefined)
|
|
138
|
-
return 0;
|
|
139
|
-
const cutoff = now.getTime() - olderThanMs;
|
|
140
|
-
const surviving = {};
|
|
141
|
-
let removed = 0;
|
|
142
|
-
for (const [sha, entry] of Object.entries(file.entries)) {
|
|
143
|
-
const reviewedAtMs = Date.parse(entry.reviewed_at);
|
|
144
|
-
if (Number.isNaN(reviewedAtMs) || reviewedAtMs >= cutoff) {
|
|
145
|
-
surviving[sha] = entry;
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
removed += 1;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (removed === 0)
|
|
152
|
-
return 0;
|
|
153
|
-
const next = {
|
|
154
|
-
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
155
|
-
entries: surviving,
|
|
156
|
-
};
|
|
199
|
+
export async function pruneOlderThan(baseDir, olderThanMs, now = new Date()) {
|
|
157
200
|
const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
|
|
158
|
-
|
|
159
|
-
|
|
201
|
+
return withAuditLock(cachePath, async () => {
|
|
202
|
+
const file = readCacheFile(baseDir);
|
|
203
|
+
if (file === undefined)
|
|
204
|
+
return 0;
|
|
205
|
+
const cutoff = now.getTime() - olderThanMs;
|
|
206
|
+
const surviving = {};
|
|
207
|
+
let removed = 0;
|
|
208
|
+
for (const [sha, entry] of Object.entries(file.entries)) {
|
|
209
|
+
const reviewedAtMs = Date.parse(entry.reviewed_at);
|
|
210
|
+
if (Number.isNaN(reviewedAtMs) || reviewedAtMs >= cutoff) {
|
|
211
|
+
surviving[sha] = entry;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
removed += 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (removed === 0)
|
|
218
|
+
return 0;
|
|
219
|
+
const next = {
|
|
220
|
+
schema_version: VERDICT_CACHE_SCHEMA_VERSION,
|
|
221
|
+
entries: surviving,
|
|
222
|
+
};
|
|
223
|
+
_atomicWriteJson(cachePath, next);
|
|
224
|
+
return removed;
|
|
225
|
+
});
|
|
160
226
|
}
|
|
161
227
|
/**
|
|
162
228
|
* Read all entries (used by `rea cache stats` / `rea cache show`).
|
|
@@ -167,18 +233,43 @@ export function listEntries(baseDir) {
|
|
|
167
233
|
const file = readCacheFile(baseDir);
|
|
168
234
|
return file?.entries ?? {};
|
|
169
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Thrown by writeVerdict when the existing cache file has an
|
|
238
|
+
* unrecognized schema_version. The caller (push-gate) catches this
|
|
239
|
+
* and treats the write as best-effort failure (log to stderr,
|
|
240
|
+
* continue) rather than overwriting forward-compat data.
|
|
241
|
+
*/
|
|
242
|
+
export class VerdictCacheForeignSchemaError extends Error {
|
|
243
|
+
cachePath;
|
|
244
|
+
kind = 'foreign-schema';
|
|
245
|
+
constructor(cachePath) {
|
|
246
|
+
super(`Refused to overwrite ${cachePath}: existing cache has unrecognized schema_version. ` +
|
|
247
|
+
`Either delete the file or run with a newer rea that supports it.`);
|
|
248
|
+
this.cachePath = cachePath;
|
|
249
|
+
this.name = 'VerdictCacheForeignSchemaError';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
170
252
|
function readCacheFile(baseDir) {
|
|
253
|
+
const parsed = readForeignCacheFile(baseDir);
|
|
254
|
+
if (parsed === undefined)
|
|
255
|
+
return undefined;
|
|
256
|
+
if (parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION)
|
|
257
|
+
return undefined;
|
|
258
|
+
// We checked schema_version exactly; entries shape is the v2 contract.
|
|
259
|
+
return parsed;
|
|
260
|
+
}
|
|
261
|
+
function readForeignCacheFile(baseDir) {
|
|
171
262
|
const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
|
|
172
263
|
if (!fs.existsSync(cachePath))
|
|
173
264
|
return undefined;
|
|
174
265
|
try {
|
|
175
266
|
const raw = fs.readFileSync(cachePath, 'utf8');
|
|
176
267
|
const parsed = JSON.parse(raw);
|
|
177
|
-
if (typeof parsed !== 'object' ||
|
|
178
|
-
|
|
179
|
-
|
|
268
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
269
|
+
return undefined;
|
|
270
|
+
const sv = parsed.schema_version;
|
|
271
|
+
if (typeof sv !== 'number')
|
|
180
272
|
return undefined;
|
|
181
|
-
}
|
|
182
273
|
const entries = parsed.entries;
|
|
183
274
|
if (typeof entries !== 'object' || entries === null)
|
|
184
275
|
return undefined;
|
|
@@ -188,3 +279,35 @@ function readCacheFile(baseDir) {
|
|
|
188
279
|
return undefined;
|
|
189
280
|
}
|
|
190
281
|
}
|
|
282
|
+
function foreignSchemaPresent(baseDir) {
|
|
283
|
+
const parsed = readForeignCacheFile(baseDir);
|
|
284
|
+
if (parsed === undefined)
|
|
285
|
+
return false;
|
|
286
|
+
return parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Atomic JSON write: stringify → write tmp → fsync → rename.
|
|
290
|
+
*
|
|
291
|
+
* Tmp filename: `${target}.tmp.${pid}.${ms}.${random8}` — collision-
|
|
292
|
+
* resistant under concurrent writes, PID reuse, and same-process
|
|
293
|
+
* parallel calls. On any failure, the tmp file is unlinked so a crash
|
|
294
|
+
* mid-write doesn't leave stale state.
|
|
295
|
+
*/
|
|
296
|
+
function _atomicWriteJson(targetPath, payload) {
|
|
297
|
+
const tmp = `${targetPath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
|
298
|
+
try {
|
|
299
|
+
fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
300
|
+
fs.renameSync(tmp, targetPath);
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
try {
|
|
304
|
+
if (fs.existsSync(tmp))
|
|
305
|
+
fs.unlinkSync(tmp);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Tmp already gone or unlink failed — caller's error is the
|
|
309
|
+
// important signal.
|
|
310
|
+
}
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -178,6 +178,13 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
178
178
|
expose_diagnostics?: boolean | undefined;
|
|
179
179
|
} | undefined;
|
|
180
180
|
}>>;
|
|
181
|
+
architecture_review: z.ZodOptional<z.ZodObject<{
|
|
182
|
+
patterns: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
183
|
+
}, "strip", z.ZodTypeAny, {
|
|
184
|
+
patterns?: string[] | undefined;
|
|
185
|
+
}, {
|
|
186
|
+
patterns?: string[] | undefined;
|
|
187
|
+
}>>;
|
|
181
188
|
}, "strict", z.ZodTypeAny, {
|
|
182
189
|
version: string;
|
|
183
190
|
profile: string;
|
|
@@ -228,6 +235,9 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
228
235
|
expose_diagnostics?: boolean | undefined;
|
|
229
236
|
} | undefined;
|
|
230
237
|
} | undefined;
|
|
238
|
+
architecture_review?: {
|
|
239
|
+
patterns?: string[] | undefined;
|
|
240
|
+
} | undefined;
|
|
231
241
|
}, {
|
|
232
242
|
version: string;
|
|
233
243
|
profile: string;
|
|
@@ -278,6 +288,9 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
278
288
|
expose_diagnostics?: boolean | undefined;
|
|
279
289
|
} | undefined;
|
|
280
290
|
} | undefined;
|
|
291
|
+
architecture_review?: {
|
|
292
|
+
patterns?: string[] | undefined;
|
|
293
|
+
} | undefined;
|
|
281
294
|
}>;
|
|
282
295
|
/**
|
|
283
296
|
* Async policy loader with TTL cache and mtime-based invalidation.
|
package/dist/policy/loader.js
CHANGED
|
@@ -58,7 +58,12 @@ const ReviewPolicySchema = z
|
|
|
58
58
|
* NOT want to lock consumers to a hardcoded enum that drifts behind
|
|
59
59
|
* upstream. Codex itself validates the model name at exec time.
|
|
60
60
|
*/
|
|
61
|
-
|
|
61
|
+
// 0.19.0 security review M4: restrict to a safe character class so
|
|
62
|
+
// a typo or malicious value can't smuggle TOML control characters
|
|
63
|
+
// (NUL, NL, CR, escape sequences) through the `-c model="<value>"`
|
|
64
|
+
// injection point. Accepts published codex model names; rejects
|
|
65
|
+
// re-quote / TOML-escape edge cases.
|
|
66
|
+
codex_model: z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/).optional(),
|
|
62
67
|
/**
|
|
63
68
|
* Codex reasoning effort knob (0.13.4+). Pinned via
|
|
64
69
|
* `-c model_reasoning_effort="<level>"` on every invocation. Only
|
|
@@ -195,6 +200,17 @@ const PolicySchema = z
|
|
|
195
200
|
redact: RedactPolicySchema.optional(),
|
|
196
201
|
audit: AuditPolicySchema.optional(),
|
|
197
202
|
gateway: GatewayPolicySchema.optional(),
|
|
203
|
+
// 0.20.1 helix-round-N P2: architecture-review-gate.sh patterns
|
|
204
|
+
// are now policy-driven. Pre-fix the hook hardcoded rea-internal
|
|
205
|
+
// source-tree patterns (`src/gateway/`, `hooks/_lib/`, etc.) which
|
|
206
|
+
// produced irrelevant advisory output in consumer projects.
|
|
207
|
+
// Empty (or unset) → silent no-op. bst-internal profile pins the
|
|
208
|
+
// rea-source patterns so dogfood behaves as before.
|
|
209
|
+
architecture_review: z
|
|
210
|
+
.object({
|
|
211
|
+
patterns: z.array(z.string()).optional(),
|
|
212
|
+
})
|
|
213
|
+
.optional(),
|
|
198
214
|
})
|
|
199
215
|
.strict();
|
|
200
216
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
|
@@ -69,6 +69,13 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
69
69
|
max_age_days?: number | undefined;
|
|
70
70
|
} | undefined;
|
|
71
71
|
}>>;
|
|
72
|
+
architecture_review: z.ZodOptional<z.ZodObject<{
|
|
73
|
+
patterns: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
74
|
+
}, "strip", z.ZodTypeAny, {
|
|
75
|
+
patterns?: string[] | undefined;
|
|
76
|
+
}, {
|
|
77
|
+
patterns?: string[] | undefined;
|
|
78
|
+
}>>;
|
|
72
79
|
}, "strict", z.ZodTypeAny, {
|
|
73
80
|
autonomy_level?: AutonomyLevel | undefined;
|
|
74
81
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -92,6 +99,9 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
92
99
|
max_age_days?: number | undefined;
|
|
93
100
|
} | undefined;
|
|
94
101
|
} | undefined;
|
|
102
|
+
architecture_review?: {
|
|
103
|
+
patterns?: string[] | undefined;
|
|
104
|
+
} | undefined;
|
|
95
105
|
}, {
|
|
96
106
|
autonomy_level?: AutonomyLevel | undefined;
|
|
97
107
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -115,6 +125,9 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
115
125
|
max_age_days?: number | undefined;
|
|
116
126
|
} | undefined;
|
|
117
127
|
} | undefined;
|
|
128
|
+
architecture_review?: {
|
|
129
|
+
patterns?: string[] | undefined;
|
|
130
|
+
} | undefined;
|
|
118
131
|
}>;
|
|
119
132
|
export type Profile = z.infer<typeof ProfileSchema>;
|
|
120
133
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -69,6 +69,12 @@ export const ProfileSchema = z
|
|
|
69
69
|
.optional(),
|
|
70
70
|
})
|
|
71
71
|
.optional(),
|
|
72
|
+
// 0.20.1+ profiles can declare architecture-sensitive paths.
|
|
73
|
+
architecture_review: z
|
|
74
|
+
.object({
|
|
75
|
+
patterns: z.array(z.string()).optional(),
|
|
76
|
+
})
|
|
77
|
+
.optional(),
|
|
72
78
|
})
|
|
73
79
|
.strict();
|
|
74
80
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -289,4 +289,16 @@ export interface Policy {
|
|
|
289
289
|
redact?: RedactPolicy;
|
|
290
290
|
audit?: AuditPolicy;
|
|
291
291
|
gateway?: GatewayPolicy;
|
|
292
|
+
/**
|
|
293
|
+
* Architecture-review patterns (0.20.1+). When set, the
|
|
294
|
+
* `architecture-review-gate.sh` hook fires an advisory when a
|
|
295
|
+
* Write/Edit/MultiEdit/NotebookEdit lands on a path matching one
|
|
296
|
+
* of the patterns. When unset or empty, the hook is a silent no-op
|
|
297
|
+
* — consumers without architecture-sensitive paths see zero noise.
|
|
298
|
+
* bst-internal profile pins rea's own source-tree patterns
|
|
299
|
+
* (`src/gateway/`, `hooks/_lib/`, etc.).
|
|
300
|
+
*/
|
|
301
|
+
architecture_review?: {
|
|
302
|
+
patterns?: string[];
|
|
303
|
+
};
|
|
292
304
|
}
|
|
@@ -181,7 +181,14 @@ _rea_unwrap_nested_shells() {
|
|
|
181
181
|
# alternation `(^|[[:space:]&|;])` therefore cannot anchor on a
|
|
182
182
|
# masked separator, and the shell-name token itself can no longer
|
|
183
183
|
# appear adjacent to a masked quote-introducer.
|
|
184
|
-
|
|
184
|
+
# 0.19.0 security review M1: extend the shell-name set to cover
|
|
185
|
+
# every commonly-installed POSIX-style shell. mksh / oksh / yash /
|
|
186
|
+
# posh ship on minimal containers, csh/tcsh on legacy macOS,
|
|
187
|
+
# fish on dev workstations. Each accepts -c with a quoted body.
|
|
188
|
+
# NOTE: pwsh (PowerShell) uses -Command / -EncodedCommand and is
|
|
189
|
+
# NOT covered here. Adding pwsh requires a separate code path
|
|
190
|
+
# because EncodedCommand base64-decodes at runtime.
|
|
191
|
+
WRAP = "(^|[[:space:]&|;])(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+-(c|lc|lic|ic|cl|cli|li|il)[[:space:]]+"
|
|
185
192
|
# Track the cursor in BOTH raw and masked. Because the mask is
|
|
186
193
|
# byte-for-byte width-preserving, the same RSTART/RLENGTH applies
|
|
187
194
|
# to both — but each iteration of the loop must SLICE both strings
|
|
@@ -70,3 +70,78 @@ resolve_parent_realpath() {
|
|
|
70
70
|
resolved=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved=""
|
|
71
71
|
printf '%s' "$resolved"
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
# 0.20.1 helix-021 fixes: shared helper for the Bash-tier symlink
|
|
75
|
+
# resolution that the Write-tier `blocked-paths-enforcer.sh` has had
|
|
76
|
+
# since 0.10.x. Given a project-relative LOGICAL_PATH (already
|
|
77
|
+
# normalized via normalize_path) and the original raw token (whose
|
|
78
|
+
# parent dir may exist on disk), return the resolved-symlink
|
|
79
|
+
# project-relative form on stdout.
|
|
80
|
+
#
|
|
81
|
+
# Returns:
|
|
82
|
+
# - The empty string if the parent doesn't exist (caller can't
|
|
83
|
+
# resolve, falls back to LOGICAL_PATH only).
|
|
84
|
+
# - A literal `__rea_outside_root__:<resolved>` sentinel when the
|
|
85
|
+
# parent's realpath escapes REA_ROOT. Caller refuses with the
|
|
86
|
+
# same shape as the existing outside-REA_ROOT check.
|
|
87
|
+
# - The project-relative resolved form (lowercased to match
|
|
88
|
+
# case-insensitive comparisons elsewhere) when resolution
|
|
89
|
+
# succeeds.
|
|
90
|
+
#
|
|
91
|
+
# Reference:
|
|
92
|
+
# `blocked-paths-enforcer.sh` lines ~205-238 for the Write-tier
|
|
93
|
+
# reference implementation that this helper backports to Bash-tier.
|
|
94
|
+
rea_resolved_relative_form() {
|
|
95
|
+
local raw_token="$1"
|
|
96
|
+
# Skip absolute paths whose logical form is already outside REA_ROOT
|
|
97
|
+
# — `/tmp/log`, `/var/log/x`, etc. The caller's logical-path check
|
|
98
|
+
# has already decided whether to allow or refuse based on the
|
|
99
|
+
# logical form. Re-running symlink resolution on these would
|
|
100
|
+
# produce a false "symlink resolves outside project root" refusal
|
|
101
|
+
# (because `/tmp` resolves to `/private/tmp` on macOS, which is
|
|
102
|
+
# technically outside REA_ROOT). The threat model for THIS helper
|
|
103
|
+
# is intra-project symlink walks: a path the caller thinks is
|
|
104
|
+
# under REA_ROOT but resolves elsewhere via an intermediate
|
|
105
|
+
# symlink. Pure external paths are out of scope.
|
|
106
|
+
if [[ "$raw_token" == /* ]]; then
|
|
107
|
+
# Canonicalize REA_ROOT for the comparison.
|
|
108
|
+
local rea_root_canon_for_skip
|
|
109
|
+
rea_root_canon_for_skip=$(cd -P -- "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null) || rea_root_canon_for_skip="$REA_ROOT"
|
|
110
|
+
if [[ "$raw_token" != "$rea_root_canon_for_skip"/* && "$raw_token" != "$REA_ROOT"/* ]]; then
|
|
111
|
+
printf ''
|
|
112
|
+
return 0
|
|
113
|
+
fi
|
|
114
|
+
fi
|
|
115
|
+
local resolved_parent
|
|
116
|
+
resolved_parent=$(resolve_parent_realpath "$raw_token")
|
|
117
|
+
if [[ -z "$resolved_parent" ]]; then
|
|
118
|
+
printf ''
|
|
119
|
+
return 0
|
|
120
|
+
fi
|
|
121
|
+
# Canonicalize REA_ROOT the same way `pwd -P` canonicalized
|
|
122
|
+
# `resolved_parent`. macOS resolves `/var/folders/...` to
|
|
123
|
+
# `/private/var/folders/...` because `/var` is a symlink to
|
|
124
|
+
# `/private/var`; without this normalization the prefix-equality
|
|
125
|
+
# below produces a false outside-REA_ROOT sentinel for every path
|
|
126
|
+
# under a tmpdir that started life as `/var/...`. Memo-friendly:
|
|
127
|
+
# `cd -P` runs once per hook invocation; the cost is bounded.
|
|
128
|
+
local rea_root_canon
|
|
129
|
+
rea_root_canon=$(cd -P -- "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null) || rea_root_canon="$REA_ROOT"
|
|
130
|
+
# Outside-REA_ROOT guard. The resolve may walk a symlink that exits
|
|
131
|
+
# the project tree entirely; emit the sentinel so the caller
|
|
132
|
+
# refuses with the same wording as the logical-path traversal
|
|
133
|
+
# check.
|
|
134
|
+
if [[ "$resolved_parent" != "$rea_root_canon" && "$resolved_parent" != "$rea_root_canon"/* ]]; then
|
|
135
|
+
printf '__rea_outside_root__:%s/%s' "$resolved_parent" "$(basename -- "$raw_token")"
|
|
136
|
+
return 0
|
|
137
|
+
fi
|
|
138
|
+
# Strip canonical REA_ROOT prefix, append basename, lowercase to
|
|
139
|
+
# match rea_path_is_protected's case-insensitive comparison.
|
|
140
|
+
local rel
|
|
141
|
+
if [[ "$resolved_parent" == "$rea_root_canon" ]]; then
|
|
142
|
+
rel="$(basename -- "$raw_token")"
|
|
143
|
+
else
|
|
144
|
+
rel="${resolved_parent#"$rea_root_canon"/}/$(basename -- "$raw_token")"
|
|
145
|
+
fi
|
|
146
|
+
printf '%s' "$rel" | tr '[:upper:]' '[:lower:]'
|
|
147
|
+
}
|
|
@@ -45,6 +45,15 @@ REA_PROTECTED_PATTERNS_FULL=(
|
|
|
45
45
|
'.husky/'
|
|
46
46
|
'.rea/policy.yaml'
|
|
47
47
|
'.rea/HALT'
|
|
48
|
+
# 0.19.0 security review C1: the verdict cache is a security boundary
|
|
49
|
+
# since 0.18.1. A forged entry would skip codex on next push of that
|
|
50
|
+
# SHA. Protect it like the kill-switch.
|
|
51
|
+
'.rea/last-review.cache.json'
|
|
52
|
+
# 0.20.1 round-N P1: last-review.json is the operator's only forensic
|
|
53
|
+
# snapshot of the most recent codex review. A forged entry presents
|
|
54
|
+
# a fake "PASS" verdict to operators reading the file directly, and
|
|
55
|
+
# to any future tooling that consults it. Protect alongside the cache.
|
|
56
|
+
'.rea/last-review.json'
|
|
48
57
|
)
|
|
49
58
|
|
|
50
59
|
# Kill-switch invariants — never relaxable. Subset of FULL.
|
|
@@ -52,6 +61,8 @@ REA_KILL_SWITCH_INVARIANTS=(
|
|
|
52
61
|
'.claude/settings.json'
|
|
53
62
|
'.rea/policy.yaml'
|
|
54
63
|
'.rea/HALT'
|
|
64
|
+
'.rea/last-review.cache.json'
|
|
65
|
+
'.rea/last-review.json'
|
|
55
66
|
)
|
|
56
67
|
|
|
57
68
|
# Effective patterns after applying the relax list. Computed lazily on
|
|
@@ -50,15 +50,29 @@ source "$(dirname "$0")/_lib/path-normalize.sh"
|
|
|
50
50
|
FILE_PATH=$(normalize_path "$FILE_PATH")
|
|
51
51
|
|
|
52
52
|
# ── 6. Check architecture-sensitive paths ─────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
# 0.20.1 helix-round-N P2: read patterns from policy. Pre-fix the
|
|
54
|
+
# rea-internal source-tree patterns (`src/gateway/`, `hooks/_lib/`,
|
|
55
|
+
# `profiles/`, etc.) shipped as hardcoded defaults — irrelevant noise
|
|
56
|
+
# in consumer projects whose architecture-sensitive paths are
|
|
57
|
+
# different. Consumers with their own architecture surfaces declare
|
|
58
|
+
# them in `.rea/policy.yaml::architecture_review.patterns`. The
|
|
59
|
+
# bst-internal profile pins the rea-source patterns so the dogfood
|
|
60
|
+
# install behaves the same as before; consumers without a pattern
|
|
61
|
+
# set get a silent no-op.
|
|
62
|
+
# shellcheck source=_lib/policy-read.sh
|
|
63
|
+
source "$(dirname "$0")/_lib/policy-read.sh"
|
|
64
|
+
|
|
65
|
+
ARCH_PATTERNS=()
|
|
66
|
+
while IFS= read -r entry; do
|
|
67
|
+
[[ -z "$entry" ]] && continue
|
|
68
|
+
ARCH_PATTERNS+=("$entry")
|
|
69
|
+
done < <(policy_list "architecture_review.patterns" 2>/dev/null || true)
|
|
70
|
+
|
|
71
|
+
if [[ ${#ARCH_PATTERNS[@]} -eq 0 ]]; then
|
|
72
|
+
# Empty/unset policy → silent no-op. Consumers who haven't declared
|
|
73
|
+
# architecture-sensitive paths see zero advisory output.
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
62
76
|
|
|
63
77
|
MATCHED=""
|
|
64
78
|
for pattern in "${ARCH_PATTERNS[@]}"; do
|
|
@@ -102,7 +102,7 @@ FOUND=0
|
|
|
102
102
|
# below catches Co-Authored-By with named tools regardless of the email
|
|
103
103
|
# domain, so dropping `users.noreply.github.com` from the noreply
|
|
104
104
|
# pattern only relaxes the check for human collaborators — never for AI.
|
|
105
|
-
if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com)'; then
|
|
105
|
+
if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)'; then
|
|
106
106
|
FOUND=1
|
|
107
107
|
fi
|
|
108
108
|
|
|
@@ -161,6 +161,13 @@ _refuse() {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
# Check a single resolved-target token. Refuses on hit.
|
|
164
|
+
#
|
|
165
|
+
# 0.20.1 helix-021 #2: in addition to the logical post-_normalize_target
|
|
166
|
+
# form, also check the symlink-resolved form. Pre-fix `ln -s . linkroot;
|
|
167
|
+
# printf x > linkroot/.env` had a logical form of `linkroot/.env`
|
|
168
|
+
# (no match against blocked_paths) but a resolved form of `.env`
|
|
169
|
+
# (which DOES match). Refuse on either match. Write-tier
|
|
170
|
+
# `blocked-paths-enforcer.sh` already has this resolution since 0.10.x.
|
|
164
171
|
_check_token() {
|
|
165
172
|
local token="$1" segment="$2"
|
|
166
173
|
[[ -z "$token" ]] && return 0
|
|
@@ -172,9 +179,21 @@ _check_token() {
|
|
|
172
179
|
# outside-root rejection on the protected list itself.
|
|
173
180
|
return 0
|
|
174
181
|
fi
|
|
182
|
+
# Symlink-resolved form via shared helper. Returns empty when the
|
|
183
|
+
# parent doesn't exist (legitimate "creating the parent" case);
|
|
184
|
+
# outside-REA_ROOT sentinel when the symlink walks out of the
|
|
185
|
+
# project (silently allow — same as the logical-path branch above).
|
|
186
|
+
local resolved_symlink
|
|
187
|
+
resolved_symlink=$(rea_resolved_relative_form "$token")
|
|
188
|
+
if [[ "$resolved_symlink" == __rea_outside_root__:* ]]; then
|
|
189
|
+
resolved_symlink=""
|
|
190
|
+
fi
|
|
175
191
|
if _match_blocked "$resolved"; then
|
|
176
192
|
_refuse "$MATCHED" "$resolved" "$segment"
|
|
177
193
|
fi
|
|
194
|
+
if [[ -n "$resolved_symlink" ]] && _match_blocked "$resolved_symlink"; then
|
|
195
|
+
_refuse "$MATCHED" "$resolved_symlink" "$segment"
|
|
196
|
+
fi
|
|
178
197
|
return 0
|
|
179
198
|
}
|
|
180
199
|
|
|
@@ -27,6 +27,8 @@ set -uo pipefail
|
|
|
27
27
|
|
|
28
28
|
# shellcheck source=_lib/protected-paths.sh
|
|
29
29
|
source "$(dirname "$0")/_lib/protected-paths.sh"
|
|
30
|
+
# shellcheck source=_lib/path-normalize.sh
|
|
31
|
+
source "$(dirname "$0")/_lib/path-normalize.sh"
|
|
30
32
|
# shellcheck source=_lib/cmd-segments.sh
|
|
31
33
|
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
32
34
|
|
|
@@ -235,7 +237,7 @@ _check_segment() {
|
|
|
235
237
|
# walking — there may be more positional args.
|
|
236
238
|
local _t
|
|
237
239
|
_t=$(_normalize_target "$target_token")
|
|
238
|
-
# 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling.
|
|
240
|
+
# 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling (logical).
|
|
239
241
|
if [[ "$_t" == __rea_outside_root__:* ]]; then
|
|
240
242
|
local resolved="${_t#__rea_outside_root__:}"
|
|
241
243
|
{
|
|
@@ -244,15 +246,37 @@ _check_segment() {
|
|
|
244
246
|
} >&2
|
|
245
247
|
exit 2
|
|
246
248
|
fi
|
|
247
|
-
|
|
249
|
+
# 0.20.1 helix-021 #1: resolve intermediate symlinks via
|
|
250
|
+
# `cd -P / pwd -P` parent-canonicalization (Write-tier parity).
|
|
251
|
+
# `ln -s ../ .husky/pre-push.d/linkdir; printf x > .husky/pre-push.d/linkdir/pre-push`
|
|
252
|
+
# had a logical form of `.husky/pre-push.d/linkdir/pre-push`
|
|
253
|
+
# that didn't match any protected pattern; the resolved form
|
|
254
|
+
# is `.husky/pre-push` which DOES match. Refuse on either.
|
|
255
|
+
local _t_resolved
|
|
256
|
+
_t_resolved=$(rea_resolved_relative_form "$target_token")
|
|
257
|
+
if [[ "$_t_resolved" == __rea_outside_root__:* ]]; then
|
|
258
|
+
local resolved="${_t_resolved#__rea_outside_root__:}"
|
|
259
|
+
{
|
|
260
|
+
printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
|
|
261
|
+
printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
|
|
262
|
+
} >&2
|
|
263
|
+
exit 2
|
|
264
|
+
fi
|
|
265
|
+
if rea_path_is_protected "$_t" \
|
|
266
|
+
|| ([[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved"); then
|
|
248
267
|
local matched=""
|
|
249
268
|
local pattern_lc
|
|
269
|
+
local hit_form="$_t"
|
|
270
|
+
if [[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved" \
|
|
271
|
+
&& ! rea_path_is_protected "$_t"; then
|
|
272
|
+
hit_form="$_t_resolved"
|
|
273
|
+
fi
|
|
250
274
|
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
251
275
|
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
252
|
-
if [[ "$
|
|
253
|
-
if [[ "$pattern_lc" == */ && "$
|
|
276
|
+
if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
|
|
277
|
+
if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
|
|
254
278
|
done
|
|
255
|
-
_refuse "$matched" "$
|
|
279
|
+
_refuse "$matched" "$hit_form" "$segment"
|
|
256
280
|
fi
|
|
257
281
|
# Reset target_token so the post-loop check doesn't double-check.
|
|
258
282
|
target_token=""
|
|
@@ -283,17 +307,38 @@ _check_segment() {
|
|
|
283
307
|
} >&2
|
|
284
308
|
exit 2
|
|
285
309
|
fi
|
|
286
|
-
|
|
310
|
+
# 0.20.1 helix-021 #1: resolve intermediate symlinks. See parallel
|
|
311
|
+
# block in the multi-target loop above for the rationale.
|
|
312
|
+
local target_resolved
|
|
313
|
+
target_resolved=$(rea_resolved_relative_form "$target_token")
|
|
314
|
+
if [[ "$target_resolved" == __rea_outside_root__:* ]]; then
|
|
315
|
+
local resolved="${target_resolved#__rea_outside_root__:}"
|
|
316
|
+
{
|
|
317
|
+
printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
|
|
318
|
+
printf '\n'
|
|
319
|
+
printf ' Logical: %s\n' "$target_token"
|
|
320
|
+
printf ' Resolved: %s\n' "$resolved"
|
|
321
|
+
printf ' Segment: %s\n' "$segment"
|
|
322
|
+
} >&2
|
|
323
|
+
exit 2
|
|
324
|
+
fi
|
|
325
|
+
if rea_path_is_protected "$target" \
|
|
326
|
+
|| ([[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved"); then
|
|
287
327
|
# Find the matching pattern for the error message. Both `target`
|
|
288
328
|
# and `pattern` lowercased to match `_normalize_target`'s case-
|
|
289
329
|
# insensitive output (helix-015 P1 fix).
|
|
290
330
|
local matched="" pattern_lc
|
|
331
|
+
local hit_form="$target"
|
|
332
|
+
if [[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved" \
|
|
333
|
+
&& ! rea_path_is_protected "$target"; then
|
|
334
|
+
hit_form="$target_resolved"
|
|
335
|
+
fi
|
|
291
336
|
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
292
337
|
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
293
|
-
if [[ "$
|
|
294
|
-
if [[ "$pattern_lc" == */ && "$
|
|
338
|
+
if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
|
|
339
|
+
if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
|
|
295
340
|
done
|
|
296
|
-
_refuse "$matched" "$
|
|
341
|
+
_refuse "$matched" "$hit_form" "$segment"
|
|
297
342
|
fi
|
|
298
343
|
return 0
|
|
299
344
|
}
|
|
@@ -199,8 +199,15 @@ case "$LOWER_NORM" in
|
|
|
199
199
|
if [ -d "$parent_dir" ]; then
|
|
200
200
|
resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
|
|
201
201
|
if [ -n "$resolved_parent" ]; then
|
|
202
|
+
# 0.20.1 helix-021 #3: directory-boundary on the case glob.
|
|
203
|
+
# Pre-fix `*"/.husky/commit-msg.d"*` matched `.husky/commit-msg.d.bak/`
|
|
204
|
+
# too (substring without trailing-slash anchor). A symlink
|
|
205
|
+
# `.husky/pre-push.d/linkdir -> ../pre-push.d.bak` then resolved
|
|
206
|
+
# to `.husky/pre-push.d.bak/...` and slipped through.
|
|
207
|
+
# The trailing `/` on each pattern (and the explicit
|
|
208
|
+
# exact-match arm) requires a real directory boundary.
|
|
202
209
|
case "$resolved_parent" in
|
|
203
|
-
|
|
210
|
+
*/.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*) : ;;
|
|
204
211
|
*)
|
|
205
212
|
{
|
|
206
213
|
printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
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)",
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
85
85
|
"@typescript-eslint/parser": "^8.0.0",
|
|
86
86
|
"@vitest/coverage-v8": "^3.2.4",
|
|
87
|
+
"ajv": "^8.17.1",
|
|
87
88
|
"eslint": "^10.2.0",
|
|
88
89
|
"prettier": "^3.8.1",
|
|
89
90
|
"typescript": "^5.8.0",
|
|
@@ -36,3 +36,18 @@ context_protection:
|
|
|
36
36
|
# hash chain across the boundary.
|
|
37
37
|
audit:
|
|
38
38
|
rotation: {}
|
|
39
|
+
# 0.20.1 helix-round-N P2: rea's own architecture-sensitive paths.
|
|
40
|
+
# Hardcoded into the hook before this release; now policy-driven so
|
|
41
|
+
# consumer projects don't get rea-source patterns advisory-warning on
|
|
42
|
+
# their own `src/types/` directories. Empty (or unset) on other
|
|
43
|
+
# profiles = silent no-op. Operators add their own
|
|
44
|
+
# architecture-sensitive paths here on a per-project basis.
|
|
45
|
+
architecture_review:
|
|
46
|
+
patterns:
|
|
47
|
+
- src/types/
|
|
48
|
+
- src/gateway/
|
|
49
|
+
- src/config/
|
|
50
|
+
- src/cli/commands/init/
|
|
51
|
+
- hooks/_lib/
|
|
52
|
+
- templates/
|
|
53
|
+
- profiles/
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -135,10 +135,17 @@ try {
|
|
|
135
135
|
const reaCli = path.join(consumerRoot, 'node_modules', '.bin', 'rea');
|
|
136
136
|
if (fs.existsSync(reaCli)) {
|
|
137
137
|
const { spawnSync } = await import('node:child_process');
|
|
138
|
+
// 0.19.0 backend-engineer P2-1: 5-min wall-clock cap so a hung
|
|
139
|
+
// upgrade falls through to print-only instead of hanging the
|
|
140
|
+
// consumer's `npm install`. 0.19.0 code-reviewer P3-6:
|
|
141
|
+
// Windows shim (.bin/rea.cmd) requires `shell: true` —
|
|
142
|
+
// detect via process.platform.
|
|
138
143
|
const res = spawnSync(reaCli, ['upgrade', '--yes'], {
|
|
139
144
|
cwd: consumerRoot,
|
|
140
145
|
stdio: 'inherit',
|
|
141
146
|
env: process.env,
|
|
147
|
+
timeout: 5 * 60 * 1000,
|
|
148
|
+
shell: process.platform === 'win32',
|
|
142
149
|
});
|
|
143
150
|
if (res.status === 0) {
|
|
144
151
|
NOTE([
|