@bookedsolid/rea 0.32.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.
@@ -0,0 +1,91 @@
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 type { Buffer } from 'node:buffer';
51
+ export interface DependencyAuditGateOptions {
52
+ reaRoot?: string;
53
+ stdinOverride?: string | Buffer;
54
+ stderrWrite?: (s: string) => void;
55
+ /**
56
+ * Test seam — replaces the live `npm view` spawn. Returns `true`
57
+ * when the package is verified to exist, `false` otherwise. The
58
+ * production caller binds this to the real `npm view <pkg> name`
59
+ * spawn with a 5s timeout.
60
+ */
61
+ verifyPackage?: (pkg: string) => Promise<boolean>;
62
+ }
63
+ export interface DependencyAuditGateResult {
64
+ exitCode: number;
65
+ stderr: string;
66
+ /**
67
+ * Test seam — packages this run attempted to verify, in order.
68
+ * Useful for assertion-driven tests without grepping stderr.
69
+ */
70
+ checkedPackages: string[];
71
+ failedPackages: string[];
72
+ }
73
+ /**
74
+ * Walk every segment of the command. For each segment whose stripped
75
+ * head matches the install pattern, contribute its package tokens.
76
+ */
77
+ export declare function extractPackages(cmd: string): string[];
78
+ /**
79
+ * Real verifier — spawns `npm view <pkg> name`. Resolves `true` when
80
+ * the registry confirms the package exists; `false` on timeout,
81
+ * non-zero exit, or spawn failure. The bash hook treats all three
82
+ * the same (`npm view` exit ≠ 0 → fail).
83
+ */
84
+ export declare function verifyPackageReal(pkg: string): Promise<boolean>;
85
+ export declare function runDependencyAuditGate(options?: DependencyAuditGateOptions): Promise<DependencyAuditGateResult>;
86
+ /**
87
+ * CLI entry — `rea hook dependency-audit-gate`.
88
+ */
89
+ export declare function runHookDependencyAuditGate(options?: DependencyAuditGateOptions): Promise<void>;
90
+ export declare const __INTERNAL_INSTALL_PATTERN_FOR_TESTS: RegExp;
91
+ export declare const __INTERNAL_MAX_PACKAGES_FOR_TESTS = 5;
@@ -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
+ };