@bookedsolid/rea 0.33.0 → 0.34.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/dist/cli/hook.js +21 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
- package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
- package/dist/hooks/local-review-gate/index.d.ts +145 -0
- package/dist/hooks/local-review-gate/index.js +374 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/secret-scanner.sh +210 -200
- package/package.json +1 -1
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/secret-scanner.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.34.0 Phase 2 port #3 (tier-2 medium-complexity hooks with enforcer
|
|
5
|
+
* logic).
|
|
6
|
+
*
|
|
7
|
+
* Detects credential patterns in content about to be written via the
|
|
8
|
+
* Write/Edit/MultiEdit/NotebookEdit Claude Code tools and blocks (exit
|
|
9
|
+
* 2) when a HIGH-severity pattern matches a non-placeholder substring.
|
|
10
|
+
* Last-resort pre-write guard — gitleaks (pre-commit) is the primary
|
|
11
|
+
* gate; this hook stops the obvious credential-in-source-file shapes
|
|
12
|
+
* before they ever touch disk.
|
|
13
|
+
*
|
|
14
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
15
|
+
*
|
|
16
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
17
|
+
* 2. Read stdin via `parseWriteHookPayload`. Extracts `file_path` /
|
|
18
|
+
* `notebook_path` and the canonical content priority:
|
|
19
|
+
* content > new_string > edits[].new_string joined > new_source.
|
|
20
|
+
* Empty content → exit 0.
|
|
21
|
+
* 3. Suffix-based file_path exclusion: `*.env.example` / `*.env.sample`
|
|
22
|
+
* pass through silently. Test files are NOT excluded — the
|
|
23
|
+
* placeholder filter handles legitimate test fixtures.
|
|
24
|
+
* 4. Apply the bash hook's awk line filter:
|
|
25
|
+
* - Strip lines whose trimmed form starts with `#` (shell comment).
|
|
26
|
+
* - Strip lines where `process.env.VAR` is the RHS of an
|
|
27
|
+
* assignment (`= process.env.SOMETHING`).
|
|
28
|
+
* - Strip lines mentioning `os.environ[`.
|
|
29
|
+
* Anything left is the corpus the patterns run against.
|
|
30
|
+
* 5. Run each of the 17 patterns (12 HIGH + 5 MEDIUM) against the
|
|
31
|
+
* filtered corpus. For each match:
|
|
32
|
+
* - Apply `isPlaceholder()` filter (matches the bash hook's
|
|
33
|
+
* `is_placeholder` shell function — placeholder forms like
|
|
34
|
+
* `<your_key>`, `your_api_key`, `example_token`,
|
|
35
|
+
* `aaaaaaa...`, etc. are dropped).
|
|
36
|
+
* - Truncate the matching substring at 60 chars for display.
|
|
37
|
+
* - Cap collected matches at 5 per pattern.
|
|
38
|
+
* 6. If ANY HIGH match remains → exit 2 with the "SECRET DETECTED"
|
|
39
|
+
* banner. Else if MEDIUM matches → emit advisory + exit 0. No
|
|
40
|
+
* matches → exit 0.
|
|
41
|
+
*
|
|
42
|
+
* MultiEdit handling: `parseWriteHookPayload` joins every `edits[i].
|
|
43
|
+
* new_string` with `\n`. This intentionally folds the fragments into
|
|
44
|
+
* one corpus for scanning; the joined newline boundary preserves
|
|
45
|
+
* line-anchored patterns. The bash counterpart used the same join
|
|
46
|
+
* shape via `extract_write_content` in `_lib/payload-read.sh`.
|
|
47
|
+
*
|
|
48
|
+
* 0.14.0 hardening — type-guard against malformed payloads (non-string
|
|
49
|
+
* `new_string`, non-array `edits`, etc.) lives in the shared
|
|
50
|
+
* `parseWriteHookPayload`. Defensive coercion means a crafted
|
|
51
|
+
* `{"edits":42}` payload doesn't throw at the boundary; it's treated as
|
|
52
|
+
* missing.
|
|
53
|
+
*/
|
|
54
|
+
import type { Buffer } from 'node:buffer';
|
|
55
|
+
export interface SecretScannerOptions {
|
|
56
|
+
reaRoot?: string;
|
|
57
|
+
stdinOverride?: string | Buffer;
|
|
58
|
+
stderrWrite?: (s: string) => void;
|
|
59
|
+
}
|
|
60
|
+
export interface SecretScannerResult {
|
|
61
|
+
exitCode: number;
|
|
62
|
+
stderr: string;
|
|
63
|
+
/**
|
|
64
|
+
* Test seam — surfaces the matches the scanner accepted (post-
|
|
65
|
+
* placeholder filter, post-truncation). Ordered HIGH first, then
|
|
66
|
+
* MEDIUM. Useful for assertion-driven tests without grepping stderr.
|
|
67
|
+
*/
|
|
68
|
+
matches: ScannerMatch[];
|
|
69
|
+
}
|
|
70
|
+
export interface ScannerMatch {
|
|
71
|
+
severity: 'HIGH' | 'MEDIUM';
|
|
72
|
+
label: string;
|
|
73
|
+
snippet: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pattern descriptors. The bash hook used ERE strings via `grep -oE`;
|
|
77
|
+
* the JS port uses native RegExp. Each pattern carries:
|
|
78
|
+
* - severity (HIGH = blocking; MEDIUM = advisory)
|
|
79
|
+
* - label (banner display string)
|
|
80
|
+
* - regex (compiled global; the `g` flag is required for matchAll)
|
|
81
|
+
*
|
|
82
|
+
* Pattern parity with the bash hook is line-by-line. Where the bash
|
|
83
|
+
* hook used POSIX character classes (`[[:space:]]`) we use `\s`; where
|
|
84
|
+
* it used `[A-Za-z0-9]` we keep that literal. Case-insensitive flags
|
|
85
|
+
* are applied per-pattern to match the bash hook's `grep -oE` posture
|
|
86
|
+
* — note that the bash `grep -oE` was case-SENSITIVE by default, so
|
|
87
|
+
* unless a pattern explicitly used `[Aa][Ww][Ss]_…` style alternation
|
|
88
|
+
* we keep the JS form case-sensitive too.
|
|
89
|
+
*/
|
|
90
|
+
export interface SecretPatternDescriptor {
|
|
91
|
+
severity: 'HIGH' | 'MEDIUM';
|
|
92
|
+
label: string;
|
|
93
|
+
regex: RegExp;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Filter content lines the same way the bash hook's awk preprocessor
|
|
97
|
+
* does:
|
|
98
|
+
* - Strip lines whose leading-whitespace-stripped form starts with `#`.
|
|
99
|
+
* - Strip lines where `process.env.VAR` is the RHS of an assignment.
|
|
100
|
+
* The bash hook used two regexes (trailing-non-letter and
|
|
101
|
+
* `;,)` punctuation forms) — we cover both.
|
|
102
|
+
* - Strip lines mentioning `os.environ[`.
|
|
103
|
+
*
|
|
104
|
+
* Newline-preserving so multiline regex anchors (`^…$`) still work on
|
|
105
|
+
* the filtered corpus.
|
|
106
|
+
*/
|
|
107
|
+
export declare function filterContent(content: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Bash `is_placeholder` parity. Returns true when the match is a known
|
|
110
|
+
* placeholder shape and should NOT be counted as a real secret.
|
|
111
|
+
*
|
|
112
|
+
* Lowercased once at the top; all sub-checks operate on the lower form.
|
|
113
|
+
*/
|
|
114
|
+
export declare function isPlaceholder(match: string): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Scan filtered content against every pattern in the catalog. Returns
|
|
117
|
+
* the accepted matches in catalog order.
|
|
118
|
+
*/
|
|
119
|
+
export declare function scanContent(filtered: string): ScannerMatch[];
|
|
120
|
+
/**
|
|
121
|
+
* Suffix-based file_path exclusion. `*.env.example` and `*.env.sample`
|
|
122
|
+
* skip the scan entirely — those are documentation files that
|
|
123
|
+
* intentionally carry placeholder credential shapes.
|
|
124
|
+
*
|
|
125
|
+
* Test files are NOT excluded. Real credentials in test fixtures must
|
|
126
|
+
* still be caught; the placeholder filter handles legitimate dummy
|
|
127
|
+
* keys.
|
|
128
|
+
*/
|
|
129
|
+
export declare function isExcludedSuffix(filePath: string): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Pure executor. Returns `{ exitCode, stderr, matches }`; the CLI
|
|
132
|
+
* wrapper translates them into `process.stderr.write` + `process.exit`.
|
|
133
|
+
*/
|
|
134
|
+
export declare function runSecretScanner(options?: SecretScannerOptions): Promise<SecretScannerResult>;
|
|
135
|
+
/**
|
|
136
|
+
* CLI entry point — `rea hook secret-scanner`.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runHookSecretScanner(options?: SecretScannerOptions): Promise<void>;
|
|
139
|
+
export declare const __INTERNAL_FOR_TESTS: {
|
|
140
|
+
SECRET_PATTERNS: readonly SecretPatternDescriptor[];
|
|
141
|
+
MAX_SNIPPET_LEN: number;
|
|
142
|
+
MAX_MATCHES_PER_PATTERN: number;
|
|
143
|
+
};
|