@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,245 @@
1
+ /**
2
+ * Shared stdin payload primitive for the Node-binary hook tier.
3
+ *
4
+ * 0.32.0 — extracts the `INPUT=$(cat) ; jq -r '.tool_input.command'`
5
+ * pattern that every bash hook in `hooks/` repeats. The Node-binary
6
+ * scan-bash already does this work in `runHookScanBash` (lines 225-258
7
+ * of `src/cli/hook.ts`); the Phase 1 pilots landing in 0.32.0 need
8
+ * the same primitive without copy-pasting the parsing + type-guard +
9
+ * fail-closed-on-malformed-JSON dance into each new hook.
10
+ *
11
+ * The shape mirrors the bash hooks' contract verbatim:
12
+ *
13
+ * - `tool_input.command` is the only field we read; bash hooks only
14
+ * ever ran `jq -r '.tool_input.command // ""'` against this payload.
15
+ * - `tool_name` is also surfaced because two bash hooks
16
+ * (`pr-issue-link-gate.sh` and `security-disclosure-gate.sh`)
17
+ * short-circuit when the tool isn't `Bash`.
18
+ *
19
+ * Failure modes:
20
+ *
21
+ * - Empty stdin → `{ command: '', toolName: '' }`. The bash hooks
22
+ * allow on empty command (`[[ -z "$CMD" ]] && exit 0`); the Node
23
+ * port preserves this by returning empty strings rather than
24
+ * throwing.
25
+ * - Malformed JSON → throws `MalformedPayloadError`. The caller
26
+ * decides whether to fail-closed (block) or fail-open (allow);
27
+ * `runHookScanBash` chose fail-closed (block) and the Phase 1
28
+ * pilots match that posture for consistency.
29
+ * - `tool_input.command` is non-string → throws `TypePayloadError`.
30
+ * A crafted payload like `{"tool_input":{"command":["rm","-rf"]}}`
31
+ * would silently coerce to `''` if we used `String(c)`; that
32
+ * would translate into a free allow. Refuse instead.
33
+ */
34
+ import { Buffer } from 'node:buffer';
35
+ /**
36
+ * Thrown when stdin contains content that is not valid JSON.
37
+ *
38
+ * Distinct error class so callers can `instanceof` discriminate without
39
+ * leaning on string matching of the message.
40
+ */
41
+ export class MalformedPayloadError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'MalformedPayloadError';
45
+ }
46
+ }
47
+ /**
48
+ * Thrown when the JSON parses but `tool_input.command` is present and
49
+ * has the wrong type (anything other than `string` / `undefined`).
50
+ */
51
+ export class TypePayloadError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'TypePayloadError';
55
+ }
56
+ }
57
+ /**
58
+ * Parse a Claude Code PreToolUse stdin payload. Pure function — no I/O.
59
+ *
60
+ * @param raw Bytes / string read from the hook's stdin (the `INPUT=$(cat)`
61
+ * equivalent).
62
+ * @returns A normalized `HookPayload` with both fields always defined.
63
+ * @throws MalformedPayloadError if the input is not parseable JSON.
64
+ * @throws TypePayloadError if `tool_input.command` is present with a
65
+ * non-string type.
66
+ */
67
+ export function parseHookPayload(raw) {
68
+ const text = typeof raw === 'string' ? raw : raw.toString('utf8');
69
+ if (text.trim().length === 0) {
70
+ return { toolName: '', command: '' };
71
+ }
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(text);
75
+ }
76
+ catch (err) {
77
+ const detail = err instanceof Error ? err.message : String(err);
78
+ throw new MalformedPayloadError(`hook payload is not valid JSON: ${detail}`);
79
+ }
80
+ if (parsed === null) {
81
+ // Top-level `null` mirrors `jq -r '.tool_name // ""'` returning ``
82
+ // — the bash hooks treated this as "no tool, allow on empty cmd".
83
+ return { toolName: '', command: '' };
84
+ }
85
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
86
+ // Top-level primitives (number, string, boolean) and arrays are
87
+ // unambiguously malformed — Claude Code never emits these shapes.
88
+ // Fail-closed so a crafted payload can't sneak past as a no-op.
89
+ throw new MalformedPayloadError(`hook payload top-level is ${Array.isArray(parsed) ? 'array' : typeof parsed}, expected object`);
90
+ }
91
+ const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : '';
92
+ const ti = parsed.tool_input;
93
+ let command = '';
94
+ if (ti !== undefined && ti !== null) {
95
+ if (typeof ti !== 'object') {
96
+ throw new TypePayloadError(`hook payload tool_input is ${typeof ti}, expected object`);
97
+ }
98
+ const c = ti.command;
99
+ if (c !== undefined && typeof c !== 'string') {
100
+ throw new TypePayloadError(`hook payload tool_input.command is ${typeof c}, expected string`);
101
+ }
102
+ if (typeof c === 'string')
103
+ command = c;
104
+ }
105
+ return { toolName, command };
106
+ }
107
+ /**
108
+ * Parse a Claude Code Write/Edit/MultiEdit/NotebookEdit stdin payload.
109
+ *
110
+ * Same fail-closed posture as `parseHookPayload`: malformed JSON →
111
+ * throws `MalformedPayloadError`; type-mismatched fields → throws
112
+ * `TypePayloadError`. Callers fail-closed on these for blocking-tier
113
+ * gates (changeset-security-gate refuses on uncertainty).
114
+ */
115
+ export function parseWriteHookPayload(raw) {
116
+ const text = typeof raw === 'string' ? raw : raw.toString('utf8');
117
+ if (text.trim().length === 0) {
118
+ return { toolName: '', filePath: '', content: '' };
119
+ }
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(text);
123
+ }
124
+ catch (err) {
125
+ const detail = err instanceof Error ? err.message : String(err);
126
+ throw new MalformedPayloadError(`hook payload is not valid JSON: ${detail}`);
127
+ }
128
+ if (parsed === null) {
129
+ return { toolName: '', filePath: '', content: '' };
130
+ }
131
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
132
+ throw new MalformedPayloadError(`hook payload top-level is ${Array.isArray(parsed) ? 'array' : typeof parsed}, expected object`);
133
+ }
134
+ const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : '';
135
+ const ti = parsed.tool_input;
136
+ let filePath = '';
137
+ let content = '';
138
+ if (ti !== undefined && ti !== null) {
139
+ if (typeof ti !== 'object') {
140
+ throw new TypePayloadError(`hook payload tool_input is ${typeof ti}, expected object`);
141
+ }
142
+ // file_path or notebook_path. Both are optional; either string or absent.
143
+ if (ti.file_path !== undefined) {
144
+ if (typeof ti.file_path !== 'string') {
145
+ throw new TypePayloadError(`hook payload tool_input.file_path is ${typeof ti.file_path}, expected string`);
146
+ }
147
+ filePath = ti.file_path;
148
+ }
149
+ else if (ti.notebook_path !== undefined) {
150
+ if (typeof ti.notebook_path !== 'string') {
151
+ throw new TypePayloadError(`hook payload tool_input.notebook_path is ${typeof ti.notebook_path}, expected string`);
152
+ }
153
+ filePath = ti.notebook_path;
154
+ }
155
+ // Content extraction — same priority order as the bash hook.
156
+ if (typeof ti.content === 'string' && ti.content.length > 0) {
157
+ content = ti.content;
158
+ }
159
+ else if (typeof ti.new_string === 'string' && ti.new_string.length > 0) {
160
+ content = ti.new_string;
161
+ }
162
+ else if (Array.isArray(ti.edits) && ti.edits.length > 0) {
163
+ // Defensive: non-string `new_string` fragments collapse to ''
164
+ // (matches the bash helper's `// ""` + `tostring`). The
165
+ // concatenation order is bash hook's `join("\n")`.
166
+ const parts = [];
167
+ for (const edit of ti.edits) {
168
+ if (edit === null || typeof edit !== 'object')
169
+ continue;
170
+ const e = edit;
171
+ if (typeof e.new_string === 'string') {
172
+ parts.push(e.new_string);
173
+ }
174
+ else {
175
+ parts.push('');
176
+ }
177
+ }
178
+ content = parts.join('\n');
179
+ }
180
+ else if (typeof ti.new_source === 'string' && ti.new_source.length > 0) {
181
+ content = ti.new_source;
182
+ }
183
+ }
184
+ return { toolName, filePath, content };
185
+ }
186
+ /**
187
+ * Read all of stdin into a string with a soft byte cap and a hard
188
+ * timeout. Mirrors the `readStdinWithTimeout` helper in
189
+ * `src/cli/hook.ts` (which scans a fixed timeout but no byte cap).
190
+ *
191
+ * The cap (default 1 MiB) defends against a misbehaving caller piping
192
+ * an unbounded payload — we'd otherwise sit in the read loop forever
193
+ * even if the caller eventually closed stdin.
194
+ *
195
+ * @param timeoutMs How long to wait for stdin to close before resolving
196
+ * with whatever we have. Default 5_000 ms.
197
+ * @param maxBytes Soft cap on total bytes accepted. Default 1 MiB.
198
+ * Once reached, additional chunks are dropped silently
199
+ * (the caller still gets a parseable string back).
200
+ */
201
+ export function readStdinWithTimeout(timeoutMs = 5_000, maxBytes = 1024 * 1024) {
202
+ return new Promise((resolve) => {
203
+ if (process.stdin.isTTY) {
204
+ resolve('');
205
+ return;
206
+ }
207
+ let buf = '';
208
+ let bytesRead = 0;
209
+ let resolved = false;
210
+ const finish = () => {
211
+ if (resolved)
212
+ return;
213
+ resolved = true;
214
+ clearTimeout(timer);
215
+ try {
216
+ process.stdin.removeAllListeners('data');
217
+ process.stdin.removeAllListeners('end');
218
+ process.stdin.removeAllListeners('error');
219
+ }
220
+ catch {
221
+ /* best effort */
222
+ }
223
+ resolve(buf);
224
+ };
225
+ const timer = setTimeout(finish, timeoutMs);
226
+ process.stdin.setEncoding('utf8');
227
+ process.stdin.on('data', (chunk) => {
228
+ const chunkBytes = Buffer.byteLength(chunk, 'utf8');
229
+ if (bytesRead + chunkBytes > maxBytes) {
230
+ // Truncate to the cap; further chunks are dropped silently.
231
+ const remaining = Math.max(0, maxBytes - bytesRead);
232
+ if (remaining > 0) {
233
+ buf += chunk.slice(0, remaining);
234
+ bytesRead = maxBytes;
235
+ }
236
+ finish();
237
+ return;
238
+ }
239
+ buf += chunk;
240
+ bytesRead += chunkBytes;
241
+ });
242
+ process.stdin.on('end', finish);
243
+ process.stdin.on('error', finish);
244
+ });
245
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Quote-aware shell-segment splitter for the Node-binary hook tier.
3
+ *
4
+ * 0.32.0 — port of the relevant primitives in
5
+ * `hooks/_lib/cmd-segments.sh`. The bash helper is 1002 LOC of
6
+ * defense-in-depth (heredoc unwrapping, nested-shell recursion,
7
+ * env-var-assignment stripping, etc.) — most of those branches exist
8
+ * to defend against bypass attempts in WRITE-tier gates (`dangerous-
9
+ * bash-interceptor`, `dependency-audit-gate`). The Phase 1 pilots
10
+ * landing in 0.32.0 (`security-disclosure-gate`,
11
+ * `attribution-advisory`) only need the SUBSET of segment behavior
12
+ * those two hooks actually exercise:
13
+ *
14
+ * 1. Split the input on shell command separators (`;`, `&&`, `||`,
15
+ * `|`, `&`, newline) while masking separators that appear inside
16
+ * matched `"..."` and `'...'` quote spans.
17
+ * 2. For each segment, strip leading `sudo`, `exec`, `time`, `then`,
18
+ * `do`, `else`, `fi`, and `VAR=value` env-prefixes so the
19
+ * caller's regex can anchor at the segment's actual command head.
20
+ * 3. Expose two query primitives:
21
+ * - `anySegmentStartsWith(cmd, regexHead)`
22
+ * true if any segment's prefix-stripped head matches the
23
+ * head-anchored regex.
24
+ * - `anySegmentMatches(cmd, regex)`
25
+ * true if any segment's raw (non-stripped) text contains a
26
+ * match for the regex (used for content scans like
27
+ * `Co-Authored-By:` markers inside `git commit -m "..."`).
28
+ *
29
+ * Out-of-scope vs. the bash helper:
30
+ *
31
+ * - No heredoc body extraction. The pilots match on the command
32
+ * line, not on heredoc contents. (Body-file resolution in
33
+ * `security-disclosure-gate` is done separately by reading the
34
+ * file path off the command.)
35
+ * - No nested-shell unwrapping (`bash -c 'PAYLOAD'`). The
36
+ * bash-scanner walker already handles that for the WRITE gates;
37
+ * the Phase 1 pilots inherit the SECURITY guarantee that any
38
+ * hostile nested shell would have been refused by the bash-scanner
39
+ * tier BEFORE this advisory tier ran.
40
+ * - No backtick/command-substitution recursion.
41
+ *
42
+ * If a future pilot needs those branches, port them here in a
43
+ * subsequent release. The CURRENT pilots' bash counterparts call only
44
+ * `any_segment_starts_with` and `any_segment_matches` against
45
+ * direct-stdin commands.
46
+ *
47
+ * Quote-handling parity with cmd-segments.sh:
48
+ *
49
+ * - Double-quoted spans (`"..."`): `\"` and `\\` are literal escapes;
50
+ * all other characters are literal.
51
+ * - Single-quoted spans (`'...'`): no escape semantics; every
52
+ * character is literal until the next `'`.
53
+ * - Unterminated quote spans extend to end-of-input (caller's bug —
54
+ * we still emit a single segment for it rather than throwing).
55
+ * - Backslash outside quotes escapes the following character (so
56
+ * `git commit \&\& foo` parses as a single segment, matching
57
+ * bash's behavior).
58
+ */
59
+ /**
60
+ * A single emitted segment. `raw` preserves the original (post-
61
+ * unmasking) text; `head` is the prefix-stripped form used for
62
+ * head-anchored matchers.
63
+ */
64
+ export interface CommandSegment {
65
+ raw: string;
66
+ head: string;
67
+ }
68
+ /**
69
+ * Split `cmd` into segments using the quote-aware masking → split →
70
+ * unmask pipeline. Returns an array of `{ raw, head }` tuples in the
71
+ * order they appeared in the original command.
72
+ *
73
+ * 0.33.0 — nested-shell unwrapping was added on top of the original
74
+ * 0.32.0 splitter. When a segment's head is `bash -c|-lc|--c PAYLOAD`
75
+ * or `sh -c|-lc|--c PAYLOAD` (any combination of `-l` and `-c` flags),
76
+ * the PAYLOAD inside the quoted arg becomes additional segments
77
+ * appended after the wrapper segment. Mirrors the bash counterpart's
78
+ * `_rea_unwrap_nested_shells` (helix-017 #3 fix). Recurses up to
79
+ * `MAX_NESTED_DEPTH` levels.
80
+ */
81
+ export declare function splitSegments(cmd: string): CommandSegment[];
82
+ /**
83
+ * Returns true if any segment's prefix-stripped head matches the
84
+ * head-anchored regex. The regex must NOT include a `^` anchor —
85
+ * we anchor by testing against the head of the segment via
86
+ * `regex.test(head.slice(0, match.length))` simulation. In practice
87
+ * we just run the regex against the head with the regex already
88
+ * head-anchored by virtue of `head` containing only the prefix-
89
+ * stripped form.
90
+ *
91
+ * The bash counterpart uses `grep -qiE PATTERN <<<"$head"` so we
92
+ * match the same posture: case-INSENSITIVE, extended regex.
93
+ *
94
+ * @param regexSource ERE source. We compile with case-insensitive
95
+ * flag. Caller passes the same string they would
96
+ * have passed to `any_segment_starts_with` in bash.
97
+ * The regex is internally anchored with `^`.
98
+ */
99
+ export declare function anySegmentStartsWith(cmd: string, regexSource: string): boolean;
100
+ /**
101
+ * Returns true if any segment's RAW text contains a match for the
102
+ * regex (no head anchoring). Mirrors `any_segment_matches` — used for
103
+ * content-scan patterns like `Co-Authored-By:` markers inside
104
+ * quoted `git commit -m "..."` arguments.
105
+ *
106
+ * Case-INSENSITIVE, extended regex. Same posture as the bash helper.
107
+ */
108
+ export declare function anySegmentMatches(cmd: string, regexSource: string): boolean;
109
+ /**
110
+ * Returns true if any single segment's RAW text contains matches for
111
+ * BOTH `regexA` AND `regexB`. Mirrors `any_segment_matches_both` from
112
+ * the bash counterpart — used by `env-file-protection` to require that
113
+ * a text-reading utility AND an `.env*` filename co-occur within the
114
+ * same shell segment (a multi-segment construction like
115
+ * `echo "log: cat .env stuff" ; touch foo.env` must NOT fire because
116
+ * the utility and filename live in different segments).
117
+ *
118
+ * Case-INSENSITIVE, extended regex on both patterns. Same posture as
119
+ * the bash helper.
120
+ *
121
+ * 0.33.0 port. The bash helper was introduced in 0.16.2 to fix the
122
+ * helix-017 P2 false-positive class where two independent booleans
123
+ * (any-utility OR any-env) were AND'd across segments.
124
+ */
125
+ export declare function anySegmentMatchesBoth(cmd: string, regexA: string, regexB: string): boolean;