@bookedsolid/rea 0.33.0 → 0.35.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.
Files changed (37) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/path-normalize.d.ts +81 -0
  3. package/dist/hooks/_lib/path-normalize.js +171 -0
  4. package/dist/hooks/_lib/payload.js +1 -1
  5. package/dist/hooks/_lib/protected-paths.d.ts +0 -0
  6. package/dist/hooks/_lib/protected-paths.js +232 -0
  7. package/dist/hooks/_lib/segments.d.ts +102 -0
  8. package/dist/hooks/_lib/segments.js +290 -0
  9. package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
  10. package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
  11. package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
  12. package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
  13. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  14. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  15. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  16. package/dist/hooks/local-review-gate/index.js +374 -0
  17. package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
  18. package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
  19. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  20. package/dist/hooks/secret-scanner/index.js +404 -0
  21. package/dist/hooks/settings-protection/index.d.ts +74 -0
  22. package/dist/hooks/settings-protection/index.js +485 -0
  23. package/hooks/blocked-paths-bash-gate.sh +118 -116
  24. package/hooks/blocked-paths-enforcer.sh +152 -256
  25. package/hooks/dangerous-bash-interceptor.sh +168 -386
  26. package/hooks/local-review-gate.sh +523 -410
  27. package/hooks/protected-paths-bash-gate.sh +123 -210
  28. package/hooks/secret-scanner.sh +210 -200
  29. package/hooks/settings-protection.sh +171 -549
  30. package/package.json +1 -1
  31. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
  32. package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
  33. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  34. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  35. package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
  36. package/templates/secret-scanner.dogfood-staged.sh +240 -0
  37. package/templates/settings-protection.dogfood-staged.sh +204 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Node-binary port of `hooks/local-review-gate.sh`.
3
+ *
4
+ * 0.34.0 Phase 2 port #2 (tier-2 medium-complexity hooks with enforcer
5
+ * logic). This is the local-first guardrail — it refuses `git push`
6
+ * (and optionally `git commit`) until `rea review` has been run and a
7
+ * recent `rea.local_review` audit entry covers HEAD. CTO directive
8
+ * 2026-05-05 enforcement.
9
+ *
10
+ * Behavioral contract — preserves the bash hook byte-for-byte:
11
+ *
12
+ * 1. HALT check → exit 2 with the shared banner.
13
+ * 2. Read stdin, extract `tool_input.command`. Non-Bash payload OR
14
+ * empty command → exit 0.
15
+ * 3. Read `policy.review.local_review.mode`. `off` → exit 0
16
+ * immediately (silent no-op for codex-less teams). The mode
17
+ * short-circuit MUST happen before any further work.
18
+ * 4. Read `policy.review.local_review.refuse_at` (default `push`).
19
+ * Translate into REFUSE_PUSH / REFUSE_COMMIT booleans.
20
+ * 5. Sweep every segment whose head matches `git push` or `git commit`
21
+ * using BOTH `findAllSegmentsStartingWith` (stripped form) AND
22
+ * `findAllSegmentsRawMatches` (raw form with env-prefix shapes
23
+ * the stripper bails on). The raw fallback closes the helix-026
24
+ * round-25 P1-B laundering class.
25
+ * 6. If no trigger segments → exit 0.
26
+ * 7. Read the bypass env-var name from
27
+ * `policy.review.local_review.bypass_env_var` (default
28
+ * `REA_SKIP_LOCAL_REVIEW`). Check the process env first
29
+ * (operator-exported) — non-empty value covers ALL trigger
30
+ * segments uniformly. Otherwise inline-evaluate per-segment via
31
+ * `quoteMaskedCmd` + the segment-anchored bypass regex. EVERY
32
+ * trigger segment must independently authorize bypass for the
33
+ * gate to allow.
34
+ * 8. If all trigger segments are bypassed → exit 0.
35
+ * 9. Otherwise call `computePreflight({ strict: true })` in-process
36
+ * and use its exit code. On exit 0 → exit 0. On refuse → exit 2
37
+ * with the friendly "local-first review required" banner.
38
+ *
39
+ * Failure modes preserved:
40
+ * - Unknown `refuse_at` value → safest default (push).
41
+ * - Empty / unset bypass_env_var policy → default `REA_SKIP_LOCAL_REVIEW`.
42
+ * - Bypass var with shell metacharacters → skip inline detection
43
+ * (process-env path still works).
44
+ * - Empty bypass value (`VAR=""`) MUST NOT bypass.
45
+ * - Halted, mode-off, and short-circuit branches always WIN over
46
+ * CLI/sandbox concerns — same as 0.32.0 round-6 P2 fix for
47
+ * security-disclosure-gate (mode-off short-circuit before any CLI
48
+ * resolution).
49
+ */
50
+ import type { Buffer } from 'node:buffer';
51
+ import { type CommandSegment } from '../_lib/segments.js';
52
+ export interface LocalReviewGateOptions {
53
+ reaRoot?: string;
54
+ stdinOverride?: string | Buffer;
55
+ stderrWrite?: (s: string) => void;
56
+ /**
57
+ * Test seam — override the env-var lookup for the bypass var. When
58
+ * unset, the actual `process.env[BYPASS_VAR]` value is read. Useful
59
+ * for tests so they don't have to mutate global env state.
60
+ */
61
+ envOverride?: Record<string, string | undefined>;
62
+ /**
63
+ * Test seam — override the preflight runner. When set, the gate
64
+ * calls this instead of `computePreflight`. Production code never
65
+ * sets this; tests use it to assert refuse-behavior without spawning
66
+ * codex.
67
+ */
68
+ preflightImpl?: (reaRoot: string) => Promise<{
69
+ exitCode: 0 | 1 | 2;
70
+ reason: string;
71
+ }>;
72
+ }
73
+ export interface LocalReviewGateResult {
74
+ exitCode: number;
75
+ stderr: string;
76
+ /** Test seam — which trigger-detection branch fired (debug). */
77
+ decision: 'halt' | 'mode-off' | 'non-bash' | 'empty-cmd' | 'no-trigger' | 'bypass-process-env' | 'bypass-inline' | 'preflight-allow' | 'preflight-refuse' | 'malformed-payload';
78
+ }
79
+ interface LocalReviewPolicy {
80
+ mode: 'enforced' | 'off';
81
+ refuseAt: 'push' | 'commit' | 'both';
82
+ bypassEnvVar: string;
83
+ }
84
+ /**
85
+ * Resolve `policy.review.local_review.{mode, refuse_at, bypass_env_var}`.
86
+ * Returns defaults when policy is missing / unparseable / fields unset.
87
+ *
88
+ * Reads the YAML file directly rather than going through the strict
89
+ * `loadPolicy` validator — the bash counterpart only reads these three
90
+ * fields and tolerates malformed surrounding policy (missing
91
+ * `version`, `installed_by`, etc.). We mirror that posture so the
92
+ * gate behaves identically across consumer installs that may have
93
+ * partial / migrating policy files.
94
+ */
95
+ declare function loadLocalReviewPolicy(reaRoot: string): LocalReviewPolicy;
96
+ /**
97
+ * Build the inline-bypass head regex for a given bypass var name. The
98
+ * regex anchors at segment start (post-quote-mask) and accepts the
99
+ * three documented bypass value shapes: unquoted, double-quoted,
100
+ * single-quoted. The trailing `git` clause prevents quoted-mention
101
+ * false positives in commit-message bodies.
102
+ *
103
+ * Round-27 F1 / Round-30 F1 sibling sweep: the segment-start anchor
104
+ * additionally accepts zero-or-more LEADING env-var prefixes before
105
+ * the bypass var, so POSIX-legal shapes like
106
+ * `GIT_TRACE=1 REA_SKIP_LOCAL_REVIEW="reason" git push` are honored.
107
+ *
108
+ * Returns a fresh regex per call so callers don't trip over the `g`
109
+ * flag's stateful `lastIndex`.
110
+ */
111
+ declare function buildInlineBypassRegex(bypassVar: string): RegExp;
112
+ /**
113
+ * Evaluate the inline-bypass match for a single segment. Returns the
114
+ * non-empty bypass value if present, or `null` when no inline bypass
115
+ * was detected (segment must therefore be preflight-validated).
116
+ *
117
+ * Empty values (`VAR=""`) MUST NOT bypass — preserves the bash hook's
118
+ * `[[ -n "$val" ]]` guard.
119
+ */
120
+ declare function evaluateInlineBypass(segment: string, bypassVar: string): string | null;
121
+ /**
122
+ * Collect every trigger segment (deduplicated) across the stripped +
123
+ * raw sweeps. Mirrors the bash `_rea_append_triggers` + de-dupe loop.
124
+ */
125
+ declare function collectTriggerSegments(cmd: string, refusePush: boolean, refuseCommit: boolean): {
126
+ segments: CommandSegment[];
127
+ opLabel: 'git push' | 'git commit' | '';
128
+ };
129
+ /**
130
+ * Pure executor. Returns `{ exitCode, stderr, decision }`.
131
+ */
132
+ export declare function runLocalReviewGate(options?: LocalReviewGateOptions): Promise<LocalReviewGateResult>;
133
+ /**
134
+ * CLI entry point — `rea hook local-review-gate`.
135
+ */
136
+ export declare function runHookLocalReviewGate(options?: LocalReviewGateOptions): Promise<void>;
137
+ export declare const __INTERNAL_FOR_TESTS: {
138
+ buildInlineBypassRegex: typeof buildInlineBypassRegex;
139
+ evaluateInlineBypass: typeof evaluateInlineBypass;
140
+ collectTriggerSegments: typeof collectTriggerSegments;
141
+ loadLocalReviewPolicy: typeof loadLocalReviewPolicy;
142
+ RAW_INLINE_RE_PUSH: RegExp;
143
+ RAW_INLINE_RE_COMMIT: RegExp;
144
+ };
145
+ export {};
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Node-binary port of `hooks/local-review-gate.sh`.
3
+ *
4
+ * 0.34.0 Phase 2 port #2 (tier-2 medium-complexity hooks with enforcer
5
+ * logic). This is the local-first guardrail — it refuses `git push`
6
+ * (and optionally `git commit`) until `rea review` has been run and a
7
+ * recent `rea.local_review` audit entry covers HEAD. CTO directive
8
+ * 2026-05-05 enforcement.
9
+ *
10
+ * Behavioral contract — preserves the bash hook byte-for-byte:
11
+ *
12
+ * 1. HALT check → exit 2 with the shared banner.
13
+ * 2. Read stdin, extract `tool_input.command`. Non-Bash payload OR
14
+ * empty command → exit 0.
15
+ * 3. Read `policy.review.local_review.mode`. `off` → exit 0
16
+ * immediately (silent no-op for codex-less teams). The mode
17
+ * short-circuit MUST happen before any further work.
18
+ * 4. Read `policy.review.local_review.refuse_at` (default `push`).
19
+ * Translate into REFUSE_PUSH / REFUSE_COMMIT booleans.
20
+ * 5. Sweep every segment whose head matches `git push` or `git commit`
21
+ * using BOTH `findAllSegmentsStartingWith` (stripped form) AND
22
+ * `findAllSegmentsRawMatches` (raw form with env-prefix shapes
23
+ * the stripper bails on). The raw fallback closes the helix-026
24
+ * round-25 P1-B laundering class.
25
+ * 6. If no trigger segments → exit 0.
26
+ * 7. Read the bypass env-var name from
27
+ * `policy.review.local_review.bypass_env_var` (default
28
+ * `REA_SKIP_LOCAL_REVIEW`). Check the process env first
29
+ * (operator-exported) — non-empty value covers ALL trigger
30
+ * segments uniformly. Otherwise inline-evaluate per-segment via
31
+ * `quoteMaskedCmd` + the segment-anchored bypass regex. EVERY
32
+ * trigger segment must independently authorize bypass for the
33
+ * gate to allow.
34
+ * 8. If all trigger segments are bypassed → exit 0.
35
+ * 9. Otherwise call `computePreflight({ strict: true })` in-process
36
+ * and use its exit code. On exit 0 → exit 0. On refuse → exit 2
37
+ * with the friendly "local-first review required" banner.
38
+ *
39
+ * Failure modes preserved:
40
+ * - Unknown `refuse_at` value → safest default (push).
41
+ * - Empty / unset bypass_env_var policy → default `REA_SKIP_LOCAL_REVIEW`.
42
+ * - Bypass var with shell metacharacters → skip inline detection
43
+ * (process-env path still works).
44
+ * - Empty bypass value (`VAR=""`) MUST NOT bypass.
45
+ * - Halted, mode-off, and short-circuit branches always WIN over
46
+ * CLI/sandbox concerns — same as 0.32.0 round-6 P2 fix for
47
+ * security-disclosure-gate (mode-off short-circuit before any CLI
48
+ * resolution).
49
+ */
50
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
51
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
52
+ import { findAllSegmentsStartingWith, findAllSegmentsRawMatches, quoteMaskedCmd, } from '../_lib/segments.js';
53
+ import fs from 'node:fs';
54
+ import path from 'node:path';
55
+ import { parse as parseYaml } from 'yaml';
56
+ import { computePreflight } from '../../cli/preflight.js';
57
+ const DEFAULT_BYPASS_VAR = 'REA_SKIP_LOCAL_REVIEW';
58
+ /**
59
+ * Raw-form fallback regex matching `^(NAME=value...)+git push|commit` at
60
+ * segment start. Accepts unquoted, double-quoted, single-quoted, and
61
+ * ANSI-C-quoted (`$'…'`) value shapes. Mirrors
62
+ * `_REA_RAW_INLINE_RE_PUSH` / `_REA_RAW_INLINE_RE_COMMIT` in the bash
63
+ * counterpart.
64
+ */
65
+ const RAW_INLINE_RE_PUSH = /^([A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\$'[^']*'|[^\s]+)\s+)+git\s+push(\s|$)/;
66
+ const RAW_INLINE_RE_COMMIT = /^([A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\$'[^']*'|[^\s]+)\s+)+git\s+commit(\s|$)/;
67
+ /**
68
+ * Validates a bypass-var name as a POSIX identifier. Mirrors the bash
69
+ * `_BYPASS_VAR_VALID=0` check.
70
+ */
71
+ function isValidEnvVarName(name) {
72
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
73
+ }
74
+ /**
75
+ * Resolve `policy.review.local_review.{mode, refuse_at, bypass_env_var}`.
76
+ * Returns defaults when policy is missing / unparseable / fields unset.
77
+ *
78
+ * Reads the YAML file directly rather than going through the strict
79
+ * `loadPolicy` validator — the bash counterpart only reads these three
80
+ * fields and tolerates malformed surrounding policy (missing
81
+ * `version`, `installed_by`, etc.). We mirror that posture so the
82
+ * gate behaves identically across consumer installs that may have
83
+ * partial / migrating policy files.
84
+ */
85
+ function loadLocalReviewPolicy(reaRoot) {
86
+ const defaults = {
87
+ mode: 'enforced',
88
+ refuseAt: 'push',
89
+ bypassEnvVar: DEFAULT_BYPASS_VAR,
90
+ };
91
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
92
+ if (!fs.existsSync(policyPath))
93
+ return defaults;
94
+ let parsed;
95
+ try {
96
+ parsed = parseYaml(fs.readFileSync(policyPath, 'utf8'));
97
+ }
98
+ catch {
99
+ return defaults;
100
+ }
101
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
102
+ return defaults;
103
+ }
104
+ const review = parsed['review'];
105
+ if (review === null || typeof review !== 'object' || Array.isArray(review)) {
106
+ return defaults;
107
+ }
108
+ const lr = review['local_review'];
109
+ if (lr === null || typeof lr !== 'object' || Array.isArray(lr)) {
110
+ return defaults;
111
+ }
112
+ const lrObj = lr;
113
+ // mode: only `off` toggles silent no-op; anything else (including
114
+ // missing, unknown values) defaults to enforced. Strict-mode
115
+ // validation lives in `loadPolicy`; the gate is tolerant by design.
116
+ const modeRaw = lrObj['mode'];
117
+ const mode = modeRaw === 'off' ? 'off' : 'enforced';
118
+ let refuseAt = 'push';
119
+ const refuseRaw = lrObj['refuse_at'];
120
+ if (refuseRaw === 'commit')
121
+ refuseAt = 'commit';
122
+ else if (refuseRaw === 'both')
123
+ refuseAt = 'both';
124
+ // bypass_env_var must be a POSIX identifier; junk falls back to default.
125
+ const bypassRaw = lrObj['bypass_env_var'];
126
+ const bypassEnvVar = typeof bypassRaw === 'string' && isValidEnvVarName(bypassRaw)
127
+ ? bypassRaw
128
+ : DEFAULT_BYPASS_VAR;
129
+ return { mode, refuseAt, bypassEnvVar };
130
+ }
131
+ /**
132
+ * Build the inline-bypass head regex for a given bypass var name. The
133
+ * regex anchors at segment start (post-quote-mask) and accepts the
134
+ * three documented bypass value shapes: unquoted, double-quoted,
135
+ * single-quoted. The trailing `git` clause prevents quoted-mention
136
+ * false positives in commit-message bodies.
137
+ *
138
+ * Round-27 F1 / Round-30 F1 sibling sweep: the segment-start anchor
139
+ * additionally accepts zero-or-more LEADING env-var prefixes before
140
+ * the bypass var, so POSIX-legal shapes like
141
+ * `GIT_TRACE=1 REA_SKIP_LOCAL_REVIEW="reason" git push` are honored.
142
+ *
143
+ * Returns a fresh regex per call so callers don't trip over the `g`
144
+ * flag's stateful `lastIndex`.
145
+ */
146
+ function buildInlineBypassRegex(bypassVar) {
147
+ // Same value-shape alternation as RAW_INLINE_RE_* (quoted/unquoted/
148
+ // ANSI-C). The trailing `git` clause is preceded by zero-or-more
149
+ // env-var assignments so `REA_SKIP="…" GIT_TRACE=1 git push` is OK.
150
+ //
151
+ // Shape parity with the bash `_INLINE_LEAD_PREFIX_RE` +
152
+ // `_INLINE_TAIL_RE`: leading prefix accepts zero-or-more env-var
153
+ // assignments BEFORE the bypass var. Tail requires at least one
154
+ // whitespace between the bypass value and `git`, then optionally
155
+ // more env-prefixes before `git` itself.
156
+ //
157
+ // 0.34.0 round-5 P2 fix: the bypass-var VALUE capture pre-fix
158
+ // accepted only `"..."`, `'...'`, and bare tokens. ANSI-C shapes
159
+ // like `REA_SKIP_LOCAL_REVIEW=$'urgent fix' git push` (which the
160
+ // bash hook AND the raw trigger regex both accept) silently fell
161
+ // through to "no bypass detected" → preflight refused valid
162
+ // operator overrides. The fix adds `\\$'[^']*'` as the 4th
163
+ // alternation and the capture index handling in evaluateInlineBypass
164
+ // is updated accordingly.
165
+ const leadPrefix = `^\\s*(?:[A-Za-z_][A-Za-z0-9_]*=` +
166
+ `("[^"]*"|'[^']*'|\\$'[^']*'|[^\\s]+)\\s+)*`;
167
+ const tail = `\\s+(?:[A-Za-z_][A-Za-z0-9_]*=` +
168
+ `(?:[^\\s"']*|"[^"]*"|'[^']*'|\\$'[^']*')\\s+)*git(?:\\s|$)`;
169
+ // Value-shape alternation captures the bypass value as group(s).
170
+ // Order: double-quoted (m[2]), single-quoted (m[3]), ANSI-C (m[4]),
171
+ // unquoted (m[5]). evaluateInlineBypass uses the first non-empty.
172
+ const re = `${leadPrefix}${bypassVar}=` +
173
+ `(?:"([^"]*)"|'([^']*)'|\\$'([^']*)'|([^\\s"']+))` +
174
+ `${tail}`;
175
+ return new RegExp(re);
176
+ }
177
+ /**
178
+ * Evaluate the inline-bypass match for a single segment. Returns the
179
+ * non-empty bypass value if present, or `null` when no inline bypass
180
+ * was detected (segment must therefore be preflight-validated).
181
+ *
182
+ * Empty values (`VAR=""`) MUST NOT bypass — preserves the bash hook's
183
+ * `[[ -n "$val" ]]` guard.
184
+ */
185
+ function evaluateInlineBypass(segment, bypassVar) {
186
+ if (!isValidEnvVarName(bypassVar))
187
+ return null;
188
+ if (segment.length === 0)
189
+ return null;
190
+ const masked = quoteMaskedCmd(segment);
191
+ const re = buildInlineBypassRegex(bypassVar);
192
+ const m = re.exec(masked);
193
+ if (m === null)
194
+ return null;
195
+ // Value-capture groups (post-round-5-P2):
196
+ // m[1] — last lead-prefix env-var value (greedy * group)
197
+ // m[2] — double-quoted bypass value
198
+ // m[3] — single-quoted bypass value
199
+ // m[4] — ANSI-C-quoted bypass value (`$'...'`)
200
+ // m[5] — unquoted bypass value
201
+ // The first non-empty wins.
202
+ const candidate = m[2] ?? m[3] ?? m[4] ?? m[5] ?? '';
203
+ return candidate.length > 0 ? candidate : null;
204
+ }
205
+ /**
206
+ * Collect every trigger segment (deduplicated) across the stripped +
207
+ * raw sweeps. Mirrors the bash `_rea_append_triggers` + de-dupe loop.
208
+ */
209
+ function collectTriggerSegments(cmd, refusePush, refuseCommit) {
210
+ const map = new Map();
211
+ let opLabel = '';
212
+ const addAll = (segs, op) => {
213
+ for (const s of segs) {
214
+ if (!map.has(s.raw)) {
215
+ map.set(s.raw, s);
216
+ }
217
+ }
218
+ if (segs.length > 0 && opLabel === '') {
219
+ opLabel = op;
220
+ }
221
+ };
222
+ if (refusePush) {
223
+ addAll(findAllSegmentsStartingWith(cmd, 'git\\s+push(\\s|$)'), 'git push');
224
+ addAll(findAllSegmentsRawMatches(cmd, RAW_INLINE_RE_PUSH.source), 'git push');
225
+ }
226
+ if (refuseCommit) {
227
+ addAll(findAllSegmentsStartingWith(cmd, 'git\\s+commit(\\s|$)'), 'git commit');
228
+ addAll(findAllSegmentsRawMatches(cmd, RAW_INLINE_RE_COMMIT.source), 'git commit');
229
+ }
230
+ return { segments: [...map.values()], opLabel };
231
+ }
232
+ function buildRefuseBanner(opLabel, exitCode, bypassVar, reason) {
233
+ const lines = [];
234
+ lines.push(`BASH BLOCKED: ${opLabel} — local-first review required\n`);
235
+ lines.push('\n');
236
+ lines.push(` rea preflight refused (exit ${exitCode}). The local-first guardrail (CTO directive\n`);
237
+ lines.push(' 2026-05-05) requires a recent codex review of the working tree before any\n');
238
+ lines.push(' push or commit.\n');
239
+ if (reason.length > 0) {
240
+ lines.push(` Reason: ${reason}\n`);
241
+ }
242
+ lines.push('\n');
243
+ lines.push(' To unblock, do ONE of:\n');
244
+ lines.push(' 1. Run `rea review` first — writes the canonical audit entry.\n');
245
+ lines.push(` 2. Set ${bypassVar}="<reason>" — per-invocation override (audited).\n`);
246
+ lines.push(' 3. Edit .rea/policy.yaml — set:\n');
247
+ lines.push(' review:\n');
248
+ lines.push(' local_review:\n');
249
+ lines.push(' mode: off\n');
250
+ lines.push(' (use this if your team does not have codex/claude installed)\n');
251
+ return lines.join('');
252
+ }
253
+ /**
254
+ * Pure executor. Returns `{ exitCode, stderr, decision }`.
255
+ */
256
+ export async function runLocalReviewGate(options = {}) {
257
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
258
+ let stderr = '';
259
+ const writeStderr = (s) => {
260
+ stderr += s;
261
+ if (options.stderrWrite)
262
+ options.stderrWrite(s);
263
+ };
264
+ const envLookup = (name) => {
265
+ if (options.envOverride && name in options.envOverride) {
266
+ return options.envOverride[name];
267
+ }
268
+ return process.env[name];
269
+ };
270
+ // 1. HALT check — fail-closed.
271
+ const halt = checkHalt(reaRoot);
272
+ if (halt.halted) {
273
+ writeStderr(formatHaltBanner(halt.reason));
274
+ return { exitCode: 2, stderr, decision: 'halt' };
275
+ }
276
+ // 2. Read + parse stdin.
277
+ const stdinRaw = options.stdinOverride !== undefined
278
+ ? options.stdinOverride
279
+ : await readStdinWithTimeout(5_000);
280
+ let toolName = '';
281
+ let cmd = '';
282
+ try {
283
+ const payload = parseHookPayload(stdinRaw);
284
+ toolName = payload.toolName;
285
+ cmd = payload.command;
286
+ }
287
+ catch (err) {
288
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
289
+ writeStderr(`local-review-gate: ${err.message} — refusing on uncertainty.\n`);
290
+ return { exitCode: 2, stderr, decision: 'malformed-payload' };
291
+ }
292
+ throw err;
293
+ }
294
+ // 3. Read policy. mode=off → silent no-op BEFORE any other work
295
+ // (mirrors 0.32.0 round-6 P2 fix: short-circuit before CLI checks).
296
+ const policy = loadLocalReviewPolicy(reaRoot);
297
+ if (policy.mode === 'off') {
298
+ return { exitCode: 0, stderr, decision: 'mode-off' };
299
+ }
300
+ // 4. Non-Bash → allow.
301
+ if (toolName !== '' && toolName !== 'Bash') {
302
+ return { exitCode: 0, stderr, decision: 'non-bash' };
303
+ }
304
+ if (cmd.length === 0) {
305
+ return { exitCode: 0, stderr, decision: 'empty-cmd' };
306
+ }
307
+ // 5. Sweep trigger segments based on refuse_at.
308
+ const refusePush = policy.refuseAt === 'push' || policy.refuseAt === 'both';
309
+ const refuseCommit = policy.refuseAt === 'commit' || policy.refuseAt === 'both';
310
+ const { segments, opLabel } = collectTriggerSegments(cmd, refusePush, refuseCommit);
311
+ if (segments.length === 0 || opLabel === '') {
312
+ return { exitCode: 0, stderr, decision: 'no-trigger' };
313
+ }
314
+ // 6. Bypass — process env wins globally.
315
+ const processEnvBypass = envLookup(policy.bypassEnvVar) ?? '';
316
+ if (processEnvBypass.length > 0) {
317
+ return { exitCode: 0, stderr, decision: 'bypass-process-env' };
318
+ }
319
+ // 7. Per-segment inline bypass — every trigger must independently
320
+ // authorize. Mirrors helix-026 round-25 P1-B fix.
321
+ let allBypassed = true;
322
+ for (const seg of segments) {
323
+ const inline = evaluateInlineBypass(seg.raw, policy.bypassEnvVar);
324
+ if (inline === null) {
325
+ allBypassed = false;
326
+ break;
327
+ }
328
+ }
329
+ if (allBypassed) {
330
+ return { exitCode: 0, stderr, decision: 'bypass-inline' };
331
+ }
332
+ // 8. Run preflight in-process.
333
+ const preflightFn = options.preflightImpl ?? (async (root) => {
334
+ const result = await computePreflight(root, { strict: true });
335
+ return {
336
+ exitCode: result.outcome.exitCode,
337
+ reason: result.outcome.reason,
338
+ };
339
+ });
340
+ let preflight;
341
+ try {
342
+ preflight = await preflightFn(reaRoot);
343
+ }
344
+ catch (err) {
345
+ // Preflight throw is treated as refuse — same fail-closed posture as
346
+ // the bash shim. Emit a generic refusal banner.
347
+ writeStderr(buildRefuseBanner(opLabel, 2, policy.bypassEnvVar, err instanceof Error ? err.message : String(err)));
348
+ return { exitCode: 2, stderr, decision: 'preflight-refuse' };
349
+ }
350
+ if (preflight.exitCode === 0) {
351
+ return { exitCode: 0, stderr, decision: 'preflight-allow' };
352
+ }
353
+ writeStderr(buildRefuseBanner(opLabel, preflight.exitCode, policy.bypassEnvVar, preflight.reason));
354
+ return { exitCode: 2, stderr, decision: 'preflight-refuse' };
355
+ }
356
+ /**
357
+ * CLI entry point — `rea hook local-review-gate`.
358
+ */
359
+ export async function runHookLocalReviewGate(options = {}) {
360
+ const result = await runLocalReviewGate({
361
+ ...options,
362
+ stderrWrite: (s) => process.stderr.write(s),
363
+ });
364
+ process.exit(result.exitCode);
365
+ }
366
+ // Internal exports for byte-fidelity tests.
367
+ export const __INTERNAL_FOR_TESTS = {
368
+ buildInlineBypassRegex,
369
+ evaluateInlineBypass,
370
+ collectTriggerSegments,
371
+ loadLocalReviewPolicy,
372
+ RAW_INLINE_RE_PUSH,
373
+ RAW_INLINE_RE_COMMIT,
374
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Node-binary port of `hooks/protected-paths-bash-gate.sh`.
3
+ *
4
+ * 0.35.0 Phase 3 port (paired tier-1 scanner-shim). Like blocked-paths-
5
+ * bash-gate but uses `runProtectedScan` against the
6
+ * `policy.protected_writes` / `policy.protected_paths_relax` resolved
7
+ * set. The bash gate was already a thin shim over the parser-backed
8
+ * scanner; this port drops the shim → CLI → scanner subprocess hop.
9
+ *
10
+ * Behavioral contract — preserves the bash hook byte-for-byte:
11
+ *
12
+ * 1. HALT check → exit 2 with shared banner.
13
+ * 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0.
14
+ * 3. Non-Bash tool calls bypass.
15
+ * 4. REA_HOOK_PATCH_SESSION-class bypass: when the env var is set with
16
+ * a non-empty reason, the scanner's protected-set is RELAXED for
17
+ * .claude/hooks/ — the patch-session pattern. Implemented by
18
+ * appending `.claude/hooks/` to the relax list when the env var is
19
+ * live (this mirrors the bash gate's §6b semantics for the Bash
20
+ * tier).
21
+ * 5. Load policy permissively (same lesson as 0.34.0 round-2 P2).
22
+ * 6. Run `runProtectedScan` with the resolved policy context.
23
+ * 7. Verdict `block` → exit 2; `allow` → exit 0.
24
+ *
25
+ * Audit-log parity: emits a `rea.hook.protected-paths-bash-gate` entry.
26
+ */
27
+ import type { Buffer } from 'node:buffer';
28
+ import { type Verdict } from '../bash-scanner/index.js';
29
+ export interface ProtectedPathsBashGateOptions {
30
+ reaRoot?: string;
31
+ stdinOverride?: string | Buffer;
32
+ stderrWrite?: (s: string) => void;
33
+ /**
34
+ * Test seam — overrides `process.env.REA_HOOK_PATCH_SESSION`. The
35
+ * CLI wrapper omits, letting the real env var govern the bypass.
36
+ */
37
+ patchSessionOverride?: string;
38
+ }
39
+ export interface ProtectedPathsBashGateResult {
40
+ exitCode: number;
41
+ stderr: string;
42
+ /** Final verdict (test seam). Null when the gate short-circuited
43
+ * before scanning (HALT, non-Bash, empty cmd). */
44
+ verdict: Verdict | null;
45
+ }
46
+ export declare function runProtectedPathsBashGate(options?: ProtectedPathsBashGateOptions): Promise<ProtectedPathsBashGateResult>;
47
+ export declare function runHookProtectedPathsBashGate(options?: ProtectedPathsBashGateOptions): Promise<void>;