@bookedsolid/rea 0.31.0 → 0.33.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 (43) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/hook.js +60 -22
  4. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  5. package/dist/hooks/_lib/halt-check.js +106 -0
  6. package/dist/hooks/_lib/payload.d.ts +124 -0
  7. package/dist/hooks/_lib/payload.js +245 -0
  8. package/dist/hooks/_lib/segments.d.ts +125 -0
  9. package/dist/hooks/_lib/segments.js +766 -0
  10. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  11. package/dist/hooks/architecture-review-gate/index.js +250 -0
  12. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  13. package/dist/hooks/attribution-advisory/index.js +233 -0
  14. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  15. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  16. package/dist/hooks/changeset-security-gate/index.js +330 -0
  17. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  18. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  19. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  20. package/dist/hooks/env-file-protection/index.js +159 -0
  21. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  22. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  23. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  24. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  25. package/hooks/_lib/protected-paths.sh +10 -3
  26. package/hooks/architecture-review-gate.sh +92 -77
  27. package/hooks/attribution-advisory.sh +139 -131
  28. package/hooks/changeset-security-gate.sh +114 -149
  29. package/hooks/dependency-audit-gate.sh +115 -156
  30. package/hooks/env-file-protection.sh +130 -97
  31. package/hooks/pr-issue-link-gate.sh +114 -45
  32. package/hooks/security-disclosure-gate.sh +148 -316
  33. package/hooks/settings-protection.sh +13 -9
  34. package/package.json +1 -1
  35. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  36. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  37. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  38. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  39. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  40. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  41. package/templates/prepare-commit-msg.husky.sh +80 -6
  42. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  43. package/templates/settings-protection.dogfood.patch +58 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Node-binary port of `hooks/dependency-audit-gate.sh`.
3
+ *
4
+ * 0.33.0 Phase 1 port #2.
5
+ *
6
+ * Detects npm/pnpm/yarn `install|i|add` invocations and verifies that
7
+ * every named package exists on the npm registry before allowing the
8
+ * install. The original bash hook is the LARGEST member of the 0.33.0
9
+ * tier-1 batch (179 LOC) and the only one in this tier that makes a
10
+ * NETWORK call (spawning `npm view <pkg> name`).
11
+ *
12
+ * Behavioral contract preserves the bash hook byte-for-byte:
13
+ *
14
+ * 1. HALT check → exit 2 with shared banner.
15
+ * 2. Read stdin → `tool_input.command`. Non-Bash tool → exit 0.
16
+ * 3. Empty command → exit 0.
17
+ * 4. Use the shared quote-aware segmenter to split on shell command
18
+ * separators (`;`, `&&`, `||`, `|`, `&`, newline). For each
19
+ * segment whose prefix-stripped head matches the install pattern
20
+ * (`(npm install|i|add) | (pnpm add|install|i) | (yarn add)`),
21
+ * extract the package-name tokens after the install command.
22
+ * Skip tokens that:
23
+ * - start with `-` (flags)
24
+ * - start with `./`, `/`, `../` (path installs)
25
+ * - contain shell metacharacters (`=`, `>`, `<`, `&`, `|`, `;`,
26
+ * `$`, backtick, quotes)
27
+ * - use workspace/link/file/git+ prefixes
28
+ * Strip trailing `@version` so `lodash@^4.0` → `lodash`.
29
+ * 5. For each extracted package (capped at 5 per command — same
30
+ * cap as the bash hook), spawn `npm view <pkg> name` with a 5s
31
+ * timeout (when GNU `timeout` is available; falls back to a
32
+ * JS-side timeout otherwise). Failed lookups accumulate.
33
+ * 6. If any failures, emit the same multi-line banner to stderr
34
+ * and exit 2. Otherwise exit 0.
35
+ *
36
+ * Key fidelity choices:
37
+ * - Segment-anchored: heredoc bodies / commit-message text that
38
+ * happens to contain `pnpm install` does NOT trigger; the bash
39
+ * hook's 0.15.0 fix is reproduced here via `splitSegments` +
40
+ * anchor-on-segment-head.
41
+ * - Env-prefix strip: `CI=1 pnpm add foo` → `pnpm add foo` for
42
+ * matching purposes. The segments helper strips leading
43
+ * `VAR=value` env-var assignments and shell prefixes (`sudo`,
44
+ * `exec`, `time`).
45
+ * - Network failure is a registry-not-found verdict, same as the
46
+ * bash hook's `npm view` exit≠0 → "package missing" — we don't
47
+ * distinguish ECONNREFUSED from "package not found", matching the
48
+ * bash hook's fail-closed posture.
49
+ */
50
+ import { spawn } from 'node:child_process';
51
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
52
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
53
+ import { splitSegments } from '../_lib/segments.js';
54
+ /**
55
+ * Cap on packages verified per command. Mirrors the bash hook's
56
+ * `if [[ $CHECKED -gt 5 ]]; then break; fi`. Designed to keep the
57
+ * hook latency bounded — `npm view` against a slow registry can
58
+ * take seconds per call.
59
+ */
60
+ const MAX_PACKAGES_PER_COMMAND = 5;
61
+ /**
62
+ * Per-package `npm view` timeout. Mirrors the bash hook's
63
+ * `timeout 5 npm view ...` (or fall-through to no-timeout when
64
+ * `timeout` is unavailable). We always enforce in JS-space so the
65
+ * Node port doesn't depend on a coreutils binary.
66
+ */
67
+ const NPM_VIEW_TIMEOUT_MS = 5_000;
68
+ /**
69
+ * Regex matching the install-command pattern at the head of a
70
+ * prefix-stripped segment. Case-insensitive (bash `grep -qiE`).
71
+ *
72
+ * Note: segments.ts's `stripSegmentPrefix` already removes leading
73
+ * `sudo`, `exec`, `time` and `VAR=value` env-vars, so we don't need
74
+ * to repeat them here.
75
+ */
76
+ const INSTALL_PATTERN = /^(npm\s+(install|i|add)|pnpm\s+(add|install|i)|yarn\s+add)\s+/i;
77
+ /**
78
+ * Tokens that look like flags or paths — never npm registry packages.
79
+ */
80
+ function looksLikeFlagOrPath(token) {
81
+ if (token.startsWith('-'))
82
+ return true;
83
+ if (token.startsWith('./'))
84
+ return true;
85
+ if (token.startsWith('/'))
86
+ return true;
87
+ if (token.startsWith('../'))
88
+ return true;
89
+ return false;
90
+ }
91
+ /**
92
+ * Tokens that contain shell metacharacters — never valid npm package
93
+ * names. The bash hook lists this set explicitly in a single conditional;
94
+ * we mirror it character-for-character.
95
+ */
96
+ function hasShellMeta(token) {
97
+ return (token.includes('=') ||
98
+ token.includes('>') ||
99
+ token.includes('<') ||
100
+ token.includes('&') ||
101
+ token.includes('|') ||
102
+ token.includes(';') ||
103
+ token.includes('$') ||
104
+ token.includes('`') ||
105
+ token.includes('"') ||
106
+ token.includes("'"));
107
+ }
108
+ /**
109
+ * Tokens that use a workspace / link / file / git+ protocol — never
110
+ * resolvable via the npm registry.
111
+ */
112
+ function isWorkspaceProtocol(token) {
113
+ return (token.startsWith('workspace:') ||
114
+ token.startsWith('link:') ||
115
+ token.startsWith('file:') ||
116
+ token.startsWith('git+'));
117
+ }
118
+ /**
119
+ * Strip a trailing `@version` from a package spec.
120
+ *
121
+ * lodash → lodash
122
+ * lodash@4.17.21 → lodash
123
+ * @scope/pkg → @scope/pkg (leading-@ preserved)
124
+ * @scope/pkg@1 → @scope/pkg
125
+ *
126
+ * Mirrors the bash hook's `sed -E 's/@[^@/]+$//'`. The pattern strips
127
+ * a trailing `@<chars-without-/-or-@>` only — leading-scope `@` is
128
+ * untouched because it has either a `/` or another `@` to the right.
129
+ */
130
+ function stripVersion(token) {
131
+ const stripped = token.replace(/@[^@/]+$/, '');
132
+ return stripped.length === 0 ? token : stripped;
133
+ }
134
+ /**
135
+ * Extract package-name tokens from a single segment that has already
136
+ * been matched against the install pattern. The bash hook's logic is
137
+ * reproduced verbatim.
138
+ */
139
+ function extractFromSegmentHead(head) {
140
+ // After the install pattern, the remainder is the argv-style token
141
+ // list (still as a string). Whitespace-separated tokens, no shell
142
+ // parsing required at this point because segments are already
143
+ // unquoted by `splitSegments`.
144
+ const afterCmd = head.replace(INSTALL_PATTERN, '');
145
+ const tokens = afterCmd.split(/\s+/).filter((t) => t.length > 0);
146
+ const out = [];
147
+ for (const token of tokens) {
148
+ if (looksLikeFlagOrPath(token))
149
+ continue;
150
+ if (hasShellMeta(token))
151
+ continue;
152
+ if (isWorkspaceProtocol(token))
153
+ continue;
154
+ out.push(stripVersion(token));
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Walk every segment of the command. For each segment whose stripped
160
+ * head matches the install pattern, contribute its package tokens.
161
+ */
162
+ export function extractPackages(cmd) {
163
+ const out = [];
164
+ for (const seg of splitSegments(cmd)) {
165
+ if (!INSTALL_PATTERN.test(seg.head))
166
+ continue;
167
+ out.push(...extractFromSegmentHead(seg.head));
168
+ }
169
+ return out;
170
+ }
171
+ /**
172
+ * Real verifier — spawns `npm view <pkg> name`. Resolves `true` when
173
+ * the registry confirms the package exists; `false` on timeout,
174
+ * non-zero exit, or spawn failure. The bash hook treats all three
175
+ * the same (`npm view` exit ≠ 0 → fail).
176
+ */
177
+ export function verifyPackageReal(pkg) {
178
+ return new Promise((resolve) => {
179
+ let resolved = false;
180
+ const settle = (ok) => {
181
+ if (resolved)
182
+ return;
183
+ resolved = true;
184
+ clearTimeout(timer);
185
+ resolve(ok);
186
+ };
187
+ const child = spawn('npm', ['view', pkg, 'name'], {
188
+ stdio: ['ignore', 'ignore', 'ignore'],
189
+ });
190
+ const timer = setTimeout(() => {
191
+ try {
192
+ child.kill('SIGTERM');
193
+ }
194
+ catch {
195
+ /* best effort */
196
+ }
197
+ settle(false);
198
+ }, NPM_VIEW_TIMEOUT_MS);
199
+ timer.unref?.();
200
+ child.on('exit', (code) => {
201
+ settle(code === 0);
202
+ });
203
+ child.on('error', () => {
204
+ settle(false);
205
+ });
206
+ });
207
+ }
208
+ function buildFailureBanner(failed) {
209
+ const lines = [
210
+ 'DEPENDENCY AUDIT: Package not found on npm registry\n',
211
+ '\n',
212
+ ' The following packages could not be verified:\n',
213
+ ];
214
+ for (const pkg of failed) {
215
+ lines.push(` - ${pkg}\n`);
216
+ }
217
+ lines.push('\n');
218
+ lines.push(' Rule: All packages must exist on the npm registry before installation.\n');
219
+ lines.push(' Check: Is the package name spelled correctly? Does it exist on npmjs.com?\n');
220
+ return lines.join('');
221
+ }
222
+ export async function runDependencyAuditGate(options = {}) {
223
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
224
+ let stderr = '';
225
+ const writeStderr = (s) => {
226
+ stderr += s;
227
+ if (options.stderrWrite)
228
+ options.stderrWrite(s);
229
+ };
230
+ const checkedPackages = [];
231
+ const failedPackages = [];
232
+ // 1. HALT.
233
+ const halt = checkHalt(reaRoot);
234
+ if (halt.halted) {
235
+ writeStderr(formatHaltBanner(halt.reason));
236
+ return { exitCode: 2, stderr, checkedPackages, failedPackages };
237
+ }
238
+ // 2. Stdin.
239
+ const stdinRaw = options.stdinOverride !== undefined
240
+ ? options.stdinOverride
241
+ : await readStdinWithTimeout(5_000);
242
+ let toolName = '';
243
+ let cmd = '';
244
+ try {
245
+ const payload = parseHookPayload(stdinRaw);
246
+ toolName = payload.toolName;
247
+ cmd = payload.command;
248
+ }
249
+ catch (err) {
250
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
251
+ writeStderr(`dependency-audit-gate: ${err.message} — refusing on uncertainty.\n`);
252
+ return { exitCode: 2, stderr, checkedPackages, failedPackages };
253
+ }
254
+ throw err;
255
+ }
256
+ if (toolName !== '' && toolName !== 'Bash') {
257
+ return { exitCode: 0, stderr, checkedPackages, failedPackages };
258
+ }
259
+ if (cmd.length === 0) {
260
+ return { exitCode: 0, stderr, checkedPackages, failedPackages };
261
+ }
262
+ const packages = extractPackages(cmd);
263
+ if (packages.length === 0) {
264
+ return { exitCode: 0, stderr, checkedPackages, failedPackages };
265
+ }
266
+ const verify = options.verifyPackage ?? verifyPackageReal;
267
+ for (const pkg of packages) {
268
+ if (checkedPackages.length >= MAX_PACKAGES_PER_COMMAND)
269
+ break;
270
+ if (pkg.length === 0)
271
+ continue;
272
+ checkedPackages.push(pkg);
273
+ const ok = await verify(pkg);
274
+ if (!ok)
275
+ failedPackages.push(pkg);
276
+ }
277
+ if (failedPackages.length > 0) {
278
+ writeStderr(buildFailureBanner(failedPackages));
279
+ return { exitCode: 2, stderr, checkedPackages, failedPackages };
280
+ }
281
+ return { exitCode: 0, stderr, checkedPackages, failedPackages };
282
+ }
283
+ /**
284
+ * CLI entry — `rea hook dependency-audit-gate`.
285
+ */
286
+ export async function runHookDependencyAuditGate(options = {}) {
287
+ const result = await runDependencyAuditGate({
288
+ ...options,
289
+ stderrWrite: (s) => process.stderr.write(s),
290
+ });
291
+ process.exit(result.exitCode);
292
+ }
293
+ export const __INTERNAL_INSTALL_PATTERN_FOR_TESTS = INSTALL_PATTERN;
294
+ export const __INTERNAL_MAX_PACKAGES_FOR_TESTS = MAX_PACKAGES_PER_COMMAND;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Node-binary port of `hooks/env-file-protection.sh`.
3
+ *
4
+ * 0.33.0 Phase 1 port #1 (tier-1 advisory/single-purpose hooks).
5
+ *
6
+ * Behavioral contract — preserves the bash hook byte-for-byte:
7
+ *
8
+ * 1. HALT check → exit 2 with the shared banner.
9
+ * 2. Read stdin, extract `tool_input.command`.
10
+ * 3. Only Bash tool calls (matches the bash hook's PreToolUse Bash
11
+ * matcher — non-Bash payloads bypass).
12
+ * 4. Empty command → exit 0.
13
+ * 5. Three independent block patterns:
14
+ * - segment-anchored `source ... .env` / `. ... .env`
15
+ * - segment-anchored `cp ... .env`
16
+ * - any-segment co-occurrence of a text-reading utility
17
+ * (cat/head/tail/less/more/grep/sed/awk/bat/strings/printf/
18
+ * xargs/tee/jq/python -c/ruby -e) AND a `.env*`/`.envrc`
19
+ * filename WITHIN THE SAME segment. The co-occurrence
20
+ * property is critical: multi-segment commands like
21
+ * `echo "fix: don't cat .env" ; touch foo.env` would
22
+ * false-positive under two independent any-segment booleans.
23
+ * 6. Match → exit 2 with the matching advisory banner; otherwise
24
+ * exit 0.
25
+ *
26
+ * Pattern parity with the bash hook is by-design verbatim. The bash
27
+ * regex bodies are reused literally so a future addition to the
28
+ * utility list lands in one place. Case-insensitive (`grep -qiE` in
29
+ * bash; `new RegExp(..., 'i')` here) — same posture.
30
+ */
31
+ import type { Buffer } from 'node:buffer';
32
+ export interface EnvFileProtectionOptions {
33
+ reaRoot?: string;
34
+ stdinOverride?: string | Buffer;
35
+ stderrWrite?: (s: string) => void;
36
+ }
37
+ export interface EnvFileProtectionResult {
38
+ exitCode: number;
39
+ stderr: string;
40
+ }
41
+ /**
42
+ * Pure executor. Returns `{ exitCode, stderr }`; the CLI wrapper
43
+ * translates them into `process.stderr.write` + `process.exit`.
44
+ */
45
+ export declare function runEnvFileProtection(options?: EnvFileProtectionOptions): Promise<EnvFileProtectionResult>;
46
+ /**
47
+ * CLI entry point — `rea hook env-file-protection`.
48
+ */
49
+ export declare function runHookEnvFileProtection(options?: EnvFileProtectionOptions): Promise<void>;
50
+ export declare const __INTERNAL_PATTERNS_FOR_TESTS: {
51
+ PATTERN_UTILITY: string;
52
+ PATTERN_SOURCE: string;
53
+ PATTERN_CP_ENV: string;
54
+ PATTERN_ENV_FILE: string;
55
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Node-binary port of `hooks/env-file-protection.sh`.
3
+ *
4
+ * 0.33.0 Phase 1 port #1 (tier-1 advisory/single-purpose hooks).
5
+ *
6
+ * Behavioral contract — preserves the bash hook byte-for-byte:
7
+ *
8
+ * 1. HALT check → exit 2 with the shared banner.
9
+ * 2. Read stdin, extract `tool_input.command`.
10
+ * 3. Only Bash tool calls (matches the bash hook's PreToolUse Bash
11
+ * matcher — non-Bash payloads bypass).
12
+ * 4. Empty command → exit 0.
13
+ * 5. Three independent block patterns:
14
+ * - segment-anchored `source ... .env` / `. ... .env`
15
+ * - segment-anchored `cp ... .env`
16
+ * - any-segment co-occurrence of a text-reading utility
17
+ * (cat/head/tail/less/more/grep/sed/awk/bat/strings/printf/
18
+ * xargs/tee/jq/python -c/ruby -e) AND a `.env*`/`.envrc`
19
+ * filename WITHIN THE SAME segment. The co-occurrence
20
+ * property is critical: multi-segment commands like
21
+ * `echo "fix: don't cat .env" ; touch foo.env` would
22
+ * false-positive under two independent any-segment booleans.
23
+ * 6. Match → exit 2 with the matching advisory banner; otherwise
24
+ * exit 0.
25
+ *
26
+ * Pattern parity with the bash hook is by-design verbatim. The bash
27
+ * regex bodies are reused literally so a future addition to the
28
+ * utility list lands in one place. Case-insensitive (`grep -qiE` in
29
+ * bash; `new RegExp(..., 'i')` here) — same posture.
30
+ */
31
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
32
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
33
+ import { anySegmentStartsWith, anySegmentMatchesBoth, } from '../_lib/segments.js';
34
+ /**
35
+ * Patterns mirroring the bash hook's PATTERN_* shell vars.
36
+ *
37
+ * `PATTERN_UTILITY` — text-reading utilities. Bash uses
38
+ * `(cat|head|tail|less|more|grep|sed|awk|bat|strings|printf|xargs|
39
+ * tee|jq|python3?[[:space:]]+-c|ruby[[:space:]]+-e)[[:space:]]`.
40
+ * We carry the same body; bash POSIX char classes are translated to
41
+ * their JavaScript regex equivalents (`[[:space:]]` → `\s`).
42
+ *
43
+ * `PATTERN_SOURCE` / `PATTERN_CP_ENV` — anchored at segment start. The
44
+ * `any_segment_starts_with` walker strips leading prefixes (sudo,
45
+ * env-var assignments) before applying the regex, so we DO NOT
46
+ * re-anchor with `^` here.
47
+ *
48
+ * `PATTERN_ENV_FILE` — matches `.env*`, `.env.local`, `.envrc`, etc.
49
+ * The trailing `(\s|"|'|$)` boundary in the bash hook keeps it from
50
+ * matching `foo.environment` style identifiers; we preserve that.
51
+ */
52
+ const PATTERN_UTILITY = '(cat|head|tail|less|more|grep|sed|awk|bat|strings|printf|xargs|tee|jq|python3?\\s+-c|ruby\\s+-e)\\s';
53
+ const PATTERN_SOURCE = "(source|\\.)\\s+[^;|&]*\\.env";
54
+ const PATTERN_CP_ENV = "cp\\s+[^;|&]*\\.env";
55
+ const PATTERN_ENV_FILE = '(\\.env[a-zA-Z0-9._-]*|\\.envrc)(\\s|"|\'|$)';
56
+ const MAX_DISPLAY_CMD_LEN = 100;
57
+ function truncate(cmd) {
58
+ if (cmd.length <= MAX_DISPLAY_CMD_LEN)
59
+ return cmd;
60
+ return cmd.slice(0, MAX_DISPLAY_CMD_LEN) + '...';
61
+ }
62
+ function buildSourceBanner(cmd) {
63
+ return [
64
+ 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n',
65
+ '\n',
66
+ ` Command: ${truncate(cmd)}\n`,
67
+ '\n',
68
+ ' Rule: Load credentials in code only — never via shell source or cp.\n',
69
+ ' Use: process.env.VAR_NAME, os.environ["VAR_NAME"], etc.\n',
70
+ ].join('');
71
+ }
72
+ function buildReadBanner(cmd) {
73
+ return [
74
+ 'ENV FILE PROTECTION: Reading .env files via Bash is blocked.\n',
75
+ '\n',
76
+ ` Command: ${truncate(cmd)}\n`,
77
+ '\n',
78
+ ' Rule: Load credentials in code only, never via shell.\n',
79
+ ' Use: process.env.VAR_NAME, os.environ["VAR_NAME"], etc.\n',
80
+ ' .env files must not be read via shell utilities in agent sessions.\n',
81
+ ].join('');
82
+ }
83
+ /**
84
+ * Pure executor. Returns `{ exitCode, stderr }`; the CLI wrapper
85
+ * translates them into `process.stderr.write` + `process.exit`.
86
+ */
87
+ export async function runEnvFileProtection(options = {}) {
88
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
89
+ let stderr = '';
90
+ const writeStderr = (s) => {
91
+ stderr += s;
92
+ if (options.stderrWrite)
93
+ options.stderrWrite(s);
94
+ };
95
+ // 1. HALT check — fail-closed (exit 2).
96
+ const halt = checkHalt(reaRoot);
97
+ if (halt.halted) {
98
+ writeStderr(formatHaltBanner(halt.reason));
99
+ return { exitCode: 2, stderr };
100
+ }
101
+ // 2. Read stdin.
102
+ const stdinRaw = options.stdinOverride !== undefined
103
+ ? options.stdinOverride
104
+ : await readStdinWithTimeout(5_000);
105
+ let toolName = '';
106
+ let cmd = '';
107
+ try {
108
+ const payload = parseHookPayload(stdinRaw);
109
+ toolName = payload.toolName;
110
+ cmd = payload.command;
111
+ }
112
+ catch (err) {
113
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
114
+ writeStderr(`env-file-protection: ${err.message} — refusing on uncertainty.\n`);
115
+ return { exitCode: 2, stderr };
116
+ }
117
+ throw err;
118
+ }
119
+ // 3. Only Bash tool calls. The bash hook's PreToolUse Bash matcher
120
+ // only fires for Bash dispatches, but the shim's relevance pre-
121
+ // gate is a substring scan that can over-trigger; the CLI
122
+ // re-checks for safety.
123
+ if (toolName !== '' && toolName !== 'Bash') {
124
+ return { exitCode: 0, stderr };
125
+ }
126
+ // 4. Empty command → allow.
127
+ if (cmd.length === 0) {
128
+ return { exitCode: 0, stderr };
129
+ }
130
+ // 5a. Direct source/cp of .env — segment-anchored.
131
+ if (anySegmentStartsWith(cmd, PATTERN_SOURCE) ||
132
+ anySegmentStartsWith(cmd, PATTERN_CP_ENV)) {
133
+ writeStderr(buildSourceBanner(cmd));
134
+ return { exitCode: 2, stderr };
135
+ }
136
+ // 5b. Utility + .env co-occurrence within the same segment.
137
+ if (anySegmentMatchesBoth(cmd, PATTERN_UTILITY, PATTERN_ENV_FILE)) {
138
+ writeStderr(buildReadBanner(cmd));
139
+ return { exitCode: 2, stderr };
140
+ }
141
+ return { exitCode: 0, stderr };
142
+ }
143
+ /**
144
+ * CLI entry point — `rea hook env-file-protection`.
145
+ */
146
+ export async function runHookEnvFileProtection(options = {}) {
147
+ const result = await runEnvFileProtection({
148
+ ...options,
149
+ stderrWrite: (s) => process.stderr.write(s),
150
+ });
151
+ process.exit(result.exitCode);
152
+ }
153
+ // Internal exports for byte-fidelity / banner-drift tests.
154
+ export const __INTERNAL_PATTERNS_FOR_TESTS = {
155
+ PATTERN_UTILITY,
156
+ PATTERN_SOURCE,
157
+ PATTERN_CP_ENV,
158
+ PATTERN_ENV_FILE,
159
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Node-binary port of `hooks/pr-issue-link-gate.sh`.
3
+ *
4
+ * 0.32.0 Phase 1 Pilot #1 — selected first because the bash original
5
+ * has the smallest dependency surface in the hook tree:
6
+ *
7
+ * - No segment splitter (just substring match on `gh pr create`)
8
+ * - No `--body-file` resolution
9
+ * - No multi-pattern catalog
10
+ * - Advisory only (always exits 0)
11
+ *
12
+ * That makes it the safest place to validate the playbook end-to-end:
13
+ * archive bash → write TS module → wire `rea hook pr-issue-link-gate`
14
+ * subcommand → replace .sh with a 15-line shim → mirror to
15
+ * `.claude/hooks/pr-issue-link-gate.sh` (PROTECTED — staged for git
16
+ * apply) → byte-fidelity test → consumer migration via `rea upgrade`
17
+ * picks up the new shim on next install.
18
+ *
19
+ * Behavioral contract — preserves bash hook byte-for-byte:
20
+ *
21
+ * 1. HALT check — exits 2 with banner when `.rea/HALT` is present.
22
+ * Bash original called `check_halt` from `_lib/halt-check.sh`;
23
+ * Node port calls the shared `checkHalt` primitive in
24
+ * `src/hooks/_lib/halt-check.ts`. Same fail-closed posture.
25
+ * 2. Reads stdin payload, extracts `tool_input.command`. When the
26
+ * tool isn't `Bash`, exits 0 silently (matches bash original
27
+ * `[[ "$TOOL_NAME" != "Bash" ]] && exit 0`).
28
+ * 3. When command does NOT contain `gh\s+pr\s+create`, exits 0.
29
+ * 4. When command DOES contain a closing keyword paired with `#N`
30
+ * (case-insensitive `closes`/`fixes`/`resolves` + whitespace +
31
+ * `#` + digits), exits 0 — the agent has already linked an issue.
32
+ * 5. Otherwise, prints the same advisory banner to stderr and exits
33
+ * 0 (advisory only — never blocks).
34
+ *
35
+ * Wider-net pattern choice: the bash original used `grep -qiE
36
+ * 'gh\s+pr\s+create'` (free `\s` shorthand). The Node port uses the
37
+ * equivalent JavaScript regex `/gh\s+pr\s+create/i` — same byte
38
+ * outcomes for ASCII inputs, which is the only shape `gh` accepts.
39
+ */
40
+ import type { Buffer } from 'node:buffer';
41
+ export interface PrIssueLinkGateOptions {
42
+ /**
43
+ * Override REA_ROOT. Production caller relies on
44
+ * `$CLAUDE_PROJECT_DIR` → `process.cwd()`. Tests set this.
45
+ */
46
+ reaRoot?: string;
47
+ /**
48
+ * Pre-supplied stdin bytes. When set, skip the stdin read and feed
49
+ * this string into `parseHookPayload`. Tests use this to avoid
50
+ * touching `process.stdin`.
51
+ */
52
+ stdinOverride?: string | Buffer;
53
+ /**
54
+ * Test seam — receives every stderr write the gate produces. Default
55
+ * is `(s) => process.stderr.write(s)`.
56
+ */
57
+ stderrWrite?: (s: string) => void;
58
+ }
59
+ /**
60
+ * Result tuple — `{ exitCode, stderr }`. The CLI wrapper translates
61
+ * `exitCode` into `process.exit`; tests inspect `stderr` for the
62
+ * advisory banner shape.
63
+ *
64
+ * `exitCode` follows the bash hook's contract:
65
+ * 0 — allow / advisory only
66
+ * 2 — HALT active OR malformed payload (fail-closed)
67
+ *
68
+ * The bash hook itself never exits non-zero except via `check_halt`;
69
+ * the Node port adds the malformed-JSON fail-closed exit mirroring
70
+ * `runHookScanBash`'s posture (an attacker who can craft a payload
71
+ * shouldn't get a free allow).
72
+ */
73
+ export interface PrIssueLinkGateResult {
74
+ exitCode: number;
75
+ /** Full stderr concatenated for test inspection. */
76
+ stderr: string;
77
+ }
78
+ /**
79
+ * Pure executor — no `process.exit`, no stdin read (when
80
+ * `stdinOverride` is set), no HALT-check side effects beyond reading
81
+ * the file. Returns the exit code + full stderr; the CLI wrapper
82
+ * applies them to the actual process.
83
+ */
84
+ export declare function runPrIssueLinkGate(options?: PrIssueLinkGateOptions): Promise<PrIssueLinkGateResult>;
85
+ /**
86
+ * CLI entry — `rea hook pr-issue-link-gate`. Wires the pure executor
87
+ * to `process.stderr.write` + `process.exit`. Mirrors the wiring
88
+ * pattern in `runHookScanBash` / `runHookCodexReview`.
89
+ */
90
+ export declare function runHookPrIssueLinkGate(options?: PrIssueLinkGateOptions): Promise<void>;
91
+ export declare const __INTERNAL_ADVISORY_BANNER_FOR_TESTS: string;