@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.
- package/dist/cli/hook.js +28 -0
- package/dist/hooks/_lib/payload.d.ts +38 -0
- package/dist/hooks/_lib/payload.js +79 -0
- package/dist/hooks/_lib/segments.d.ts +25 -0
- package/dist/hooks/_lib/segments.js +338 -16
- package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
- package/dist/hooks/architecture-review-gate/index.js +250 -0
- 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/hooks/architecture-review-gate.sh +92 -77
- 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/package.json +1 -1
- package/templates/architecture-review-gate.dogfood-staged.sh +116 -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
|
@@ -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
|
+
};
|