@bookedsolid/rea 0.32.0 → 0.34.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 (34) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/payload.d.ts +38 -0
  3. package/dist/hooks/_lib/payload.js +79 -0
  4. package/dist/hooks/_lib/segments.d.ts +127 -0
  5. package/dist/hooks/_lib/segments.js +628 -16
  6. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  7. package/dist/hooks/architecture-review-gate/index.js +250 -0
  8. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  9. package/dist/hooks/changeset-security-gate/index.js +330 -0
  10. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  11. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  12. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  13. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  14. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  15. package/dist/hooks/env-file-protection/index.js +159 -0
  16. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  17. package/dist/hooks/local-review-gate/index.js +374 -0
  18. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  19. package/dist/hooks/secret-scanner/index.js +404 -0
  20. package/hooks/architecture-review-gate.sh +92 -77
  21. package/hooks/changeset-security-gate.sh +114 -149
  22. package/hooks/dangerous-bash-interceptor.sh +168 -386
  23. package/hooks/dependency-audit-gate.sh +115 -156
  24. package/hooks/env-file-protection.sh +130 -97
  25. package/hooks/local-review-gate.sh +523 -410
  26. package/hooks/secret-scanner.sh +210 -200
  27. package/package.json +1 -1
  28. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  29. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  30. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  31. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  32. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  33. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  34. package/templates/secret-scanner.dogfood-staged.sh +240 -0
package/dist/cli/hook.js CHANGED
@@ -39,6 +39,13 @@ import { checkHalt, formatHaltBanner } from '../hooks/_lib/halt-check.js';
39
39
  import { runHookPrIssueLinkGate } from '../hooks/pr-issue-link-gate/index.js';
40
40
  import { runHookSecurityDisclosureGate } from '../hooks/security-disclosure-gate/index.js';
41
41
  import { runHookAttributionAdvisory } from '../hooks/attribution-advisory/index.js';
42
+ import { runHookEnvFileProtection } from '../hooks/env-file-protection/index.js';
43
+ import { runHookDependencyAuditGate } from '../hooks/dependency-audit-gate/index.js';
44
+ import { runHookChangesetSecurityGate } from '../hooks/changeset-security-gate/index.js';
45
+ import { runHookArchitectureReviewGate } from '../hooks/architecture-review-gate/index.js';
46
+ import { runHookDangerousBashInterceptor } from '../hooks/dangerous-bash-interceptor/index.js';
47
+ import { runHookLocalReviewGate } from '../hooks/local-review-gate/index.js';
48
+ import { runHookSecretScanner } from '../hooks/secret-scanner/index.js';
42
49
  import { loadPolicy } from '../policy/loader.js';
43
50
  import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
44
51
  import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
@@ -973,6 +980,48 @@ export function registerHookCommand(program) {
973
980
  .action(async () => {
974
981
  await runHookAttributionAdvisory();
975
982
  });
983
+ hook
984
+ .command('env-file-protection')
985
+ .description('Node-binary port of `hooks/env-file-protection.sh` (0.33.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command sources, cps, or reads a `.env*`/`.envrc` file via text-reading utilities (cat/head/tail/grep/sed/awk/etc.), exits 2 with banner. Same-segment co-occurrence required for the utility-vs-filename match so multi-segment commands do not false-positive.')
986
+ .action(async () => {
987
+ await runHookEnvFileProtection();
988
+ });
989
+ hook
990
+ .command('dependency-audit-gate')
991
+ .description('Node-binary port of `hooks/dependency-audit-gate.sh` (0.33.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `(npm|pnpm|yarn) (install|i|add) <pkg>`, verifies each named package exists on the npm registry via `npm view <pkg> name` (5s timeout, capped at 5 packages/command). Exit 2 with multi-line banner on any missing package, otherwise exit 0.')
992
+ .action(async () => {
993
+ await runHookDependencyAuditGate();
994
+ });
995
+ hook
996
+ .command('changeset-security-gate')
997
+ .description('Node-binary port of `hooks/changeset-security-gate.sh` (0.33.0). Reads a Claude Code PreToolUse Write/Edit/MultiEdit/NotebookEdit payload from stdin; for writes targeting `.changeset/*.md`, blocks GHSA/CVE pre-disclosure and validates frontmatter (`---`-delimited block with `<pkg>: (patch|minor|major)` + non-empty description). MultiEdit short-circuits frontmatter validation because fragments are not full files. Block emissions use the Claude Code JSON-on-stdout protocol.')
998
+ .action(async () => {
999
+ await runHookChangesetSecurityGate();
1000
+ });
1001
+ hook
1002
+ .command('architecture-review-gate')
1003
+ .description('Node-binary port of `hooks/architecture-review-gate.sh` (0.33.0). PostToolUse Write/Edit advisory. Reads `policy.architecture_review.patterns` and prints an advisory banner to stderr when the just-written file matches a configured prefix. ALWAYS exits 0 unless HALT (exit 2). Path normalization handles Windows backslashes + URL-encoding; empty/unset patterns short-circuit silently.')
1004
+ .action(async () => {
1005
+ await runHookArchitectureReviewGate();
1006
+ });
1007
+ hook
1008
+ .command('dangerous-bash-interceptor')
1009
+ .description('Node-binary port of `hooks/dangerous-bash-interceptor.sh` (0.34.0). PreToolUse Bash gate that blocks destructive commands. Catalog of 17 HIGH (H1-H17) + 1 MEDIUM (M1) rules: force-push, --no-verify, HUSKY=0, rm -rf broad targets, curl|sh pipe-RCE, REA_BYPASS, alias/function-with-bypass, psql DROP, context_protection delegate enforcement. Exit 2 on HIGH match, 0 on MEDIUM-only advisory or pass-through.')
1010
+ .action(async () => {
1011
+ await runHookDangerousBashInterceptor();
1012
+ });
1013
+ hook
1014
+ .command('local-review-gate')
1015
+ .description('Node-binary port of `hooks/local-review-gate.sh` (0.34.0). PreToolUse Bash gate refusing `git push` (and optionally `git commit`) until a recent `rea.local_review` audit entry covers HEAD. Honors `policy.review.local_review.{mode=off|enforced, refuse_at=push|commit|both, bypass_env_var}`. Mode=off short-circuits silently; bypass var (default REA_SKIP_LOCAL_REVIEW) accepts process-env (global) or per-segment inline `VAR="<reason>" git push` shapes. CTO directive 2026-05-05 enforcement.')
1016
+ .action(async () => {
1017
+ await runHookLocalReviewGate();
1018
+ });
1019
+ hook
1020
+ .command('secret-scanner')
1021
+ .description('Node-binary port of `hooks/secret-scanner.sh` (0.34.0). PreToolUse Write/Edit/MultiEdit/NotebookEdit pre-write credential gate. Catalog of 12 HIGH + 5 MEDIUM patterns (AWS, Anthropic, GitHub, Stripe live/test, Supabase JWT, generic SECRET=, private-key armor, DB connection strings). awk-style line filter strips shell comments and `process.env.VAR` RHS assignments; `is_placeholder` filter drops `<your_key>`/`test_token`/`aaaaaaa` shapes. HIGH match → exit 2; MEDIUM-only → exit 0 with advisory. Suffix-excludes `.env.example`/`.env.sample`.')
1022
+ .action(async () => {
1023
+ await runHookSecretScanner();
1024
+ });
976
1025
  hook
977
1026
  .command('policy-get')
978
1027
  .description('Read a value from `.rea/policy.yaml` via the canonical YAML parser. Used by bash-tier hooks (`hooks/_lib/policy-read.sh::policy_nested_scalar`) so inline AND block YAML forms agree at a single source of truth. Default scalar mode: prints raw value or empty. With `--json`: emits JSON (scalar or object/array; missing path → `null`). Unparseable YAML → empty / null, exit 1.')
@@ -68,6 +68,44 @@ export declare class TypePayloadError extends Error {
68
68
  * non-string type.
69
69
  */
70
70
  export declare function parseHookPayload(raw: string | Buffer): HookPayload;
71
+ /**
72
+ * Result of a write-tier hook payload extraction. Covers all four
73
+ * write-class tools (Write, Edit, MultiEdit, NotebookEdit).
74
+ */
75
+ export interface WriteHookPayload {
76
+ /** `tool_name` from the payload, or `''` when absent. */
77
+ toolName: string;
78
+ /**
79
+ * `tool_input.file_path` (Write/Edit/MultiEdit) OR
80
+ * `tool_input.notebook_path` (NotebookEdit), or `''` when absent.
81
+ */
82
+ filePath: string;
83
+ /**
84
+ * Concatenated content payload. Resolution order matches
85
+ * `hooks/_lib/payload-read.sh::extract_write_content`:
86
+ *
87
+ * 1. `tool_input.content` (Write)
88
+ * 2. `tool_input.new_string` (Edit)
89
+ * 3. `tool_input.edits[].new_string` joined (MultiEdit, `\n`)
90
+ * 4. `tool_input.new_source` (NotebookEdit cell)
91
+ *
92
+ * Returns `''` when none of these are present. Defensive coercion:
93
+ * a non-string `new_string`, non-array `edits`, or non-string
94
+ * fragments fail closed (treated as missing) rather than throwing —
95
+ * mirrors the bash hook's `.tool_input.new_string // ""` + the
96
+ * type-guard branches added in 0.16.0.
97
+ */
98
+ content: string;
99
+ }
100
+ /**
101
+ * Parse a Claude Code Write/Edit/MultiEdit/NotebookEdit stdin payload.
102
+ *
103
+ * Same fail-closed posture as `parseHookPayload`: malformed JSON →
104
+ * throws `MalformedPayloadError`; type-mismatched fields → throws
105
+ * `TypePayloadError`. Callers fail-closed on these for blocking-tier
106
+ * gates (changeset-security-gate refuses on uncertainty).
107
+ */
108
+ export declare function parseWriteHookPayload(raw: string | Buffer): WriteHookPayload;
71
109
  /**
72
110
  * Read all of stdin into a string with a soft byte cap and a hard
73
111
  * timeout. Mirrors the `readStdinWithTimeout` helper in
@@ -104,6 +104,85 @@ export function parseHookPayload(raw) {
104
104
  }
105
105
  return { toolName, command };
106
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
+ }
107
186
  /**
108
187
  * Read all of stdin into a string with a soft byte cap and a hard
109
188
  * timeout. Mirrors the `readStdinWithTimeout` helper in
@@ -69,6 +69,14 @@ export interface CommandSegment {
69
69
  * Split `cmd` into segments using the quote-aware masking → split →
70
70
  * unmask pipeline. Returns an array of `{ raw, head }` tuples in the
71
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.
72
80
  */
73
81
  export declare function splitSegments(cmd: string): CommandSegment[];
74
82
  /**
@@ -98,3 +106,122 @@ export declare function anySegmentStartsWith(cmd: string, regexSource: string):
98
106
  * Case-INSENSITIVE, extended regex. Same posture as the bash helper.
99
107
  */
100
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;
126
+ /**
127
+ * Returns true if any segment's RAW text (env-var prefixes intact, only
128
+ * leading whitespace trimmed) matches the regex source. Mirrors
129
+ * `any_segment_raw_matches` in the bash counterpart — used by checks
130
+ * where the env-prefix itself IS the signal (`HUSKY=0 git`, `REA_BYPASS=`,
131
+ * `alias … = HUSKY=0`).
132
+ *
133
+ * 0.34.0 port — dangerous-bash-interceptor (H10, H15, H16) and
134
+ * local-review-gate (env-prefix git push detection) call into this.
135
+ * Note: callers anchor with `^` in the regex source when they want
136
+ * "starts at segment head"; we do not prepend `^` here.
137
+ */
138
+ export declare function anySegmentRawMatches(cmd: string, regexSource: string): boolean;
139
+ /**
140
+ * Returns true if any segment's RAW text contains a match for the
141
+ * regex source. Mirrors `any_segment_matches` in the bash counterpart —
142
+ * used by content-scan style checks. The regex matches anywhere in the
143
+ * segment (not anchored). Useful for `(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|…)`
144
+ * style patterns that must match across the whole segment but only
145
+ * within a single segment (a heredoc body in segment N or commit
146
+ * message in segment 1 must NOT poison segment N+1).
147
+ *
148
+ * 0.34.0 port — dangerous-bash-interceptor H6 calls into this.
149
+ */
150
+ export declare function anySegmentContains(cmd: string, regexSource: string): boolean;
151
+ /**
152
+ * Iterate over every segment of `cmd` and invoke `callback(raw, head)`
153
+ * for each. Mirrors `for_each_segment` in the bash counterpart —
154
+ * dangerous-bash-interceptor H1 uses this to walk each push segment
155
+ * independently (since one segment may include `--force-with-lease`
156
+ * while another carries an unsafe `--force`).
157
+ *
158
+ * The callback receives the raw segment (env-prefix preserved) and the
159
+ * prefix-stripped head. Return value is ignored.
160
+ *
161
+ * 0.34.0 port.
162
+ */
163
+ export declare function forEachSegment(cmd: string, callback: (raw: string, head: string) => void): void;
164
+ /**
165
+ * Quote-aware mask of in-quote separators. Mirrors `quote_masked_cmd`
166
+ * in the bash counterpart — produces a string where in-quote `|` / `;`
167
+ * / `&` characters are replaced with multi-byte sentinels so a caller's
168
+ * regex can match real (unquoted) instances of those bytes without
169
+ * false-positiving on quoted commit-message bodies (`git commit -m
170
+ * "curl|sh later"`).
171
+ *
172
+ * 0.34.0 port — dangerous-bash-interceptor H12 (`curl|sh` detection)
173
+ * uses this to scan the WHOLE command (not split into segments)
174
+ * without quoted-mention false positives.
175
+ *
176
+ * Implementation uses the same sentinel-byte alphabet the bash helper
177
+ * uses. Sentinels are public so callers can `.test()` against the
178
+ * masked output without accidentally tripping on them.
179
+ */
180
+ export declare const INQUOTE_PIPE_SENTINEL = "__REA_INQUOTE_PIPE_a8f2c1__";
181
+ export declare const INQUOTE_SEMI_SENTINEL = "__REA_INQUOTE_SC_a8f2c1__";
182
+ export declare const INQUOTE_AMP_SENTINEL = "__REA_INQUOTE_AMP_a8f2c1__";
183
+ export declare function quoteMaskedCmd(cmd: string): string;
184
+ /**
185
+ * Walk the nested-shell unwrap chain and emit `cmd` PLUS each inner
186
+ * payload as a separate string. Mirrors `_rea_unwrap_nested_shells`
187
+ * in the bash counterpart.
188
+ *
189
+ * Used by dangerous-bash-interceptor H12 (`curl|sh` detection) so a
190
+ * payload like `zsh -c "curl https://x | sh"` is scanned for the pipe
191
+ * shape even though the literal `|` is inside quotes at the outer
192
+ * level. The H12 check then runs `quoteMaskedCmd` against each
193
+ * emitted line independently.
194
+ *
195
+ * Depth-bounded at MAX_NESTED_DEPTH (8) — same as `splitSegments`.
196
+ *
197
+ * 0.34.0 port.
198
+ */
199
+ export declare function unwrapNestedShells(cmd: string): string[];
200
+ /**
201
+ * Return every segment of `cmd` whose prefix-stripped head matches the
202
+ * head-anchored regex source. Mirrors `find_all_segments_starting_with`
203
+ * in the bash counterpart.
204
+ *
205
+ * Returns each match as `{ raw, head }` so callers (local-review-gate's
206
+ * round-25 P1-B sweep) can validate per-segment bypass against the
207
+ * raw (env-prefix-intact) form.
208
+ *
209
+ * Case-INSENSITIVE. Empty array on no matches.
210
+ *
211
+ * 0.34.0 port.
212
+ */
213
+ export declare function findAllSegmentsStartingWith(cmd: string, regexSource: string): CommandSegment[];
214
+ /**
215
+ * Return every segment of `cmd` whose RAW text (env-prefix intact,
216
+ * leading whitespace trimmed) matches the regex source. Mirrors
217
+ * `find_all_segments_raw_matches` in the bash counterpart.
218
+ *
219
+ * Companion to `findAllSegmentsStartingWith` for the env-prefix shapes
220
+ * the prefix-stripper bails on (quoted-value env-vars like
221
+ * `REA_SKIP="urgent fix"`).
222
+ *
223
+ * Case-INSENSITIVE. Empty array on no matches.
224
+ *
225
+ * 0.34.0 port.
226
+ */
227
+ export declare function findAllSegmentsRawMatches(cmd: string, regexSource: string): CommandSegment[];