@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.
- package/.husky/prepare-commit-msg +80 -6
- package/MIGRATING.md +24 -15
- package/dist/cli/hook.js +60 -22
- package/dist/hooks/_lib/halt-check.d.ts +78 -0
- package/dist/hooks/_lib/halt-check.js +106 -0
- package/dist/hooks/_lib/payload.d.ts +124 -0
- package/dist/hooks/_lib/payload.js +245 -0
- package/dist/hooks/_lib/segments.d.ts +125 -0
- package/dist/hooks/_lib/segments.js +766 -0
- package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
- package/dist/hooks/architecture-review-gate/index.js +250 -0
- package/dist/hooks/attribution-advisory/index.d.ts +72 -0
- package/dist/hooks/attribution-advisory/index.js +233 -0
- package/dist/hooks/bash-scanner/protected-scan.js +14 -2
- package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
- package/dist/hooks/changeset-security-gate/index.js +330 -0
- package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
- package/dist/hooks/dependency-audit-gate/index.js +294 -0
- package/dist/hooks/env-file-protection/index.d.ts +55 -0
- package/dist/hooks/env-file-protection/index.js +159 -0
- package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
- package/dist/hooks/pr-issue-link-gate/index.js +127 -0
- package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
- package/dist/hooks/security-disclosure-gate/index.js +502 -0
- package/hooks/_lib/protected-paths.sh +10 -3
- package/hooks/architecture-review-gate.sh +92 -77
- package/hooks/attribution-advisory.sh +139 -131
- package/hooks/changeset-security-gate.sh +114 -149
- package/hooks/dependency-audit-gate.sh +115 -156
- package/hooks/env-file-protection.sh +130 -97
- package/hooks/pr-issue-link-gate.sh +114 -45
- package/hooks/security-disclosure-gate.sh +148 -316
- package/hooks/settings-protection.sh +13 -9
- package/package.json +1 -1
- package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
- package/templates/attribution-advisory.dogfood-staged.sh +170 -0
- package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
- package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
- package/templates/env-file-protection.dogfood-staged.sh +157 -0
- package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
- package/templates/prepare-commit-msg.husky.sh +80 -6
- package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
- 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;
|