@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.
- package/dist/cli/hook.js +49 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -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/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/secret-scanner.sh +210 -200
- package/hooks/settings-protection.sh +171 -549
- package/package.json +1 -1
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
- 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>;
|