@bookedsolid/rea 0.33.0 → 0.35.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 (37) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/path-normalize.d.ts +81 -0
  3. package/dist/hooks/_lib/path-normalize.js +171 -0
  4. package/dist/hooks/_lib/payload.js +1 -1
  5. package/dist/hooks/_lib/protected-paths.d.ts +0 -0
  6. package/dist/hooks/_lib/protected-paths.js +232 -0
  7. package/dist/hooks/_lib/segments.d.ts +102 -0
  8. package/dist/hooks/_lib/segments.js +290 -0
  9. package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
  10. package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
  11. package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
  12. package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
  13. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  14. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  15. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  16. package/dist/hooks/local-review-gate/index.js +374 -0
  17. package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
  18. package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
  19. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  20. package/dist/hooks/secret-scanner/index.js +404 -0
  21. package/dist/hooks/settings-protection/index.d.ts +74 -0
  22. package/dist/hooks/settings-protection/index.js +485 -0
  23. package/hooks/blocked-paths-bash-gate.sh +118 -116
  24. package/hooks/blocked-paths-enforcer.sh +152 -256
  25. package/hooks/dangerous-bash-interceptor.sh +168 -386
  26. package/hooks/local-review-gate.sh +523 -410
  27. package/hooks/protected-paths-bash-gate.sh +123 -210
  28. package/hooks/secret-scanner.sh +210 -200
  29. package/hooks/settings-protection.sh +171 -549
  30. package/package.json +1 -1
  31. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
  32. package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
  33. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  34. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  35. package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
  36. package/templates/secret-scanner.dogfood-staged.sh +240 -0
  37. package/templates/settings-protection.dogfood-staged.sh +204 -0
@@ -764,3 +764,293 @@ export function anySegmentMatchesBoth(cmd, regexA, regexB) {
764
764
  }
765
765
  return false;
766
766
  }
767
+ /**
768
+ * Returns true if any segment's RAW text (env-var prefixes intact, only
769
+ * leading whitespace trimmed) matches the regex source. Mirrors
770
+ * `any_segment_raw_matches` in the bash counterpart — used by checks
771
+ * where the env-prefix itself IS the signal (`HUSKY=0 git`, `REA_BYPASS=`,
772
+ * `alias … = HUSKY=0`).
773
+ *
774
+ * 0.34.0 port — dangerous-bash-interceptor (H10, H15, H16) and
775
+ * local-review-gate (env-prefix git push detection) call into this.
776
+ * Note: callers anchor with `^` in the regex source when they want
777
+ * "starts at segment head"; we do not prepend `^` here.
778
+ */
779
+ export function anySegmentRawMatches(cmd, regexSource) {
780
+ const re = new RegExp(regexSource, 'i');
781
+ for (const seg of splitSegments(cmd)) {
782
+ const trimmed = seg.raw.replace(/^\s+/, '');
783
+ if (re.test(trimmed))
784
+ return true;
785
+ }
786
+ return false;
787
+ }
788
+ /**
789
+ * Returns true if any segment's RAW text contains a match for the
790
+ * regex source. Mirrors `any_segment_matches` in the bash counterpart —
791
+ * used by content-scan style checks. The regex matches anywhere in the
792
+ * segment (not anchored). Useful for `(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|…)`
793
+ * style patterns that must match across the whole segment but only
794
+ * within a single segment (a heredoc body in segment N or commit
795
+ * message in segment 1 must NOT poison segment N+1).
796
+ *
797
+ * 0.34.0 port — dangerous-bash-interceptor H6 calls into this.
798
+ */
799
+ export function anySegmentContains(cmd, regexSource) {
800
+ const re = new RegExp(regexSource, 'i');
801
+ for (const seg of splitSegments(cmd)) {
802
+ if (re.test(seg.head))
803
+ return true;
804
+ }
805
+ return false;
806
+ }
807
+ /**
808
+ * Iterate over every segment of `cmd` and invoke `callback(raw, head)`
809
+ * for each. Mirrors `for_each_segment` in the bash counterpart —
810
+ * dangerous-bash-interceptor H1 uses this to walk each push segment
811
+ * independently (since one segment may include `--force-with-lease`
812
+ * while another carries an unsafe `--force`).
813
+ *
814
+ * The callback receives the raw segment (env-prefix preserved) and the
815
+ * prefix-stripped head. Return value is ignored.
816
+ *
817
+ * 0.34.0 port.
818
+ */
819
+ export function forEachSegment(cmd, callback) {
820
+ for (const seg of splitSegments(cmd)) {
821
+ callback(seg.raw, seg.head);
822
+ }
823
+ }
824
+ /**
825
+ * Quote-aware mask of in-quote separators. Mirrors `quote_masked_cmd`
826
+ * in the bash counterpart — produces a string where in-quote `|` / `;`
827
+ * / `&` characters are replaced with multi-byte sentinels so a caller's
828
+ * regex can match real (unquoted) instances of those bytes without
829
+ * false-positiving on quoted commit-message bodies (`git commit -m
830
+ * "curl|sh later"`).
831
+ *
832
+ * 0.34.0 port — dangerous-bash-interceptor H12 (`curl|sh` detection)
833
+ * uses this to scan the WHOLE command (not split into segments)
834
+ * without quoted-mention false positives.
835
+ *
836
+ * Implementation uses the same sentinel-byte alphabet the bash helper
837
+ * uses. Sentinels are public so callers can `.test()` against the
838
+ * masked output without accidentally tripping on them.
839
+ */
840
+ export const INQUOTE_PIPE_SENTINEL = '__REA_INQUOTE_PIPE_a8f2c1__';
841
+ export const INQUOTE_SEMI_SENTINEL = '__REA_INQUOTE_SC_a8f2c1__';
842
+ export const INQUOTE_AMP_SENTINEL = '__REA_INQUOTE_AMP_a8f2c1__';
843
+ export function quoteMaskedCmd(cmd) {
844
+ // 4-state walker mirroring the bash awk:
845
+ // 0 = plain
846
+ // 1 = inside "…" (backslash escapes next char)
847
+ // 2 = inside '…' (no escapes)
848
+ // 3 = inside $'…' (ANSI-C; backslash escapes next char)
849
+ // In modes 1/2/3, in-quote `|`/`;`/`&` are replaced with sentinels.
850
+ // The opening `$'` is preserved verbatim (caller code that detects
851
+ // ANSI-C envelopes still sees them).
852
+ let out = '';
853
+ let i = 0;
854
+ const n = cmd.length;
855
+ let mode = 0;
856
+ while (i < n) {
857
+ const ch = cmd[i];
858
+ if (mode === 0) {
859
+ if (ch === '$' && i + 1 < n && cmd[i + 1] === "'") {
860
+ mode = 3;
861
+ out += "$'";
862
+ i += 2;
863
+ continue;
864
+ }
865
+ if (ch === '"') {
866
+ mode = 1;
867
+ out += ch;
868
+ i += 1;
869
+ continue;
870
+ }
871
+ if (ch === "'") {
872
+ mode = 2;
873
+ out += ch;
874
+ i += 1;
875
+ continue;
876
+ }
877
+ if (ch === '\\' && i + 1 < n) {
878
+ out += ch + cmd[i + 1];
879
+ i += 2;
880
+ continue;
881
+ }
882
+ out += ch;
883
+ i += 1;
884
+ continue;
885
+ }
886
+ if (mode === 3) {
887
+ if (ch === '\\' && i + 1 < n) {
888
+ out += ch + cmd[i + 1];
889
+ i += 2;
890
+ continue;
891
+ }
892
+ if (ch === "'") {
893
+ mode = 0;
894
+ out += ch;
895
+ i += 1;
896
+ continue;
897
+ }
898
+ if (ch === '|') {
899
+ out += INQUOTE_PIPE_SENTINEL;
900
+ i += 1;
901
+ continue;
902
+ }
903
+ if (ch === ';') {
904
+ out += INQUOTE_SEMI_SENTINEL;
905
+ i += 1;
906
+ continue;
907
+ }
908
+ if (ch === '&') {
909
+ out += INQUOTE_AMP_SENTINEL;
910
+ i += 1;
911
+ continue;
912
+ }
913
+ out += ch;
914
+ i += 1;
915
+ continue;
916
+ }
917
+ if (mode === 2) {
918
+ if (ch === "'") {
919
+ mode = 0;
920
+ out += ch;
921
+ i += 1;
922
+ continue;
923
+ }
924
+ if (ch === '|') {
925
+ out += INQUOTE_PIPE_SENTINEL;
926
+ i += 1;
927
+ continue;
928
+ }
929
+ if (ch === ';') {
930
+ out += INQUOTE_SEMI_SENTINEL;
931
+ i += 1;
932
+ continue;
933
+ }
934
+ if (ch === '&') {
935
+ out += INQUOTE_AMP_SENTINEL;
936
+ i += 1;
937
+ continue;
938
+ }
939
+ out += ch;
940
+ i += 1;
941
+ continue;
942
+ }
943
+ // mode === 1
944
+ if (ch === '\\' && i + 1 < n) {
945
+ out += ch + cmd[i + 1];
946
+ i += 2;
947
+ continue;
948
+ }
949
+ if (ch === '"') {
950
+ mode = 0;
951
+ out += ch;
952
+ i += 1;
953
+ continue;
954
+ }
955
+ if (ch === '|') {
956
+ out += INQUOTE_PIPE_SENTINEL;
957
+ i += 1;
958
+ continue;
959
+ }
960
+ if (ch === ';') {
961
+ out += INQUOTE_SEMI_SENTINEL;
962
+ i += 1;
963
+ continue;
964
+ }
965
+ if (ch === '&') {
966
+ out += INQUOTE_AMP_SENTINEL;
967
+ i += 1;
968
+ continue;
969
+ }
970
+ out += ch;
971
+ i += 1;
972
+ }
973
+ return out;
974
+ }
975
+ /**
976
+ * Walk the nested-shell unwrap chain and emit `cmd` PLUS each inner
977
+ * payload as a separate string. Mirrors `_rea_unwrap_nested_shells`
978
+ * in the bash counterpart.
979
+ *
980
+ * Used by dangerous-bash-interceptor H12 (`curl|sh` detection) so a
981
+ * payload like `zsh -c "curl https://x | sh"` is scanned for the pipe
982
+ * shape even though the literal `|` is inside quotes at the outer
983
+ * level. The H12 check then runs `quoteMaskedCmd` against each
984
+ * emitted line independently.
985
+ *
986
+ * Depth-bounded at MAX_NESTED_DEPTH (8) — same as `splitSegments`.
987
+ *
988
+ * 0.34.0 port.
989
+ */
990
+ export function unwrapNestedShells(cmd) {
991
+ const out = [cmd];
992
+ unwrapNestedShellsRecursive(cmd, 0, out);
993
+ return out;
994
+ }
995
+ function unwrapNestedShellsRecursive(cmd, depth, acc) {
996
+ if (depth >= MAX_NESTED_DEPTH)
997
+ return;
998
+ // Walk segments so a heredoc-style or multi-line command gets each
999
+ // segment's inner payload extracted independently.
1000
+ const masked = maskQuotedSeparators(cmd);
1001
+ const rawSegs = splitOnUnquotedSeparators(masked);
1002
+ for (const raw of rawSegs) {
1003
+ const unmaskedRaw = unmask(raw);
1004
+ const head = stripSegmentPrefix(unmaskedRaw);
1005
+ const inner = extractNestedShellPayload(head);
1006
+ if (inner !== null) {
1007
+ acc.push(inner);
1008
+ unwrapNestedShellsRecursive(inner, depth + 1, acc);
1009
+ }
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Return every segment of `cmd` whose prefix-stripped head matches the
1014
+ * head-anchored regex source. Mirrors `find_all_segments_starting_with`
1015
+ * in the bash counterpart.
1016
+ *
1017
+ * Returns each match as `{ raw, head }` so callers (local-review-gate's
1018
+ * round-25 P1-B sweep) can validate per-segment bypass against the
1019
+ * raw (env-prefix-intact) form.
1020
+ *
1021
+ * Case-INSENSITIVE. Empty array on no matches.
1022
+ *
1023
+ * 0.34.0 port.
1024
+ */
1025
+ export function findAllSegmentsStartingWith(cmd, regexSource) {
1026
+ const re = new RegExp(`^${regexSource}`, 'i');
1027
+ const out = [];
1028
+ for (const seg of splitSegments(cmd)) {
1029
+ if (re.test(seg.head))
1030
+ out.push(seg);
1031
+ }
1032
+ return out;
1033
+ }
1034
+ /**
1035
+ * Return every segment of `cmd` whose RAW text (env-prefix intact,
1036
+ * leading whitespace trimmed) matches the regex source. Mirrors
1037
+ * `find_all_segments_raw_matches` in the bash counterpart.
1038
+ *
1039
+ * Companion to `findAllSegmentsStartingWith` for the env-prefix shapes
1040
+ * the prefix-stripper bails on (quoted-value env-vars like
1041
+ * `REA_SKIP="urgent fix"`).
1042
+ *
1043
+ * Case-INSENSITIVE. Empty array on no matches.
1044
+ *
1045
+ * 0.34.0 port.
1046
+ */
1047
+ export function findAllSegmentsRawMatches(cmd, regexSource) {
1048
+ const re = new RegExp(regexSource, 'i');
1049
+ const out = [];
1050
+ for (const seg of splitSegments(cmd)) {
1051
+ const trimmed = seg.raw.replace(/^\s+/, '');
1052
+ if (re.test(trimmed))
1053
+ out.push(seg);
1054
+ }
1055
+ return out;
1056
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Node-binary port of `hooks/blocked-paths-bash-gate.sh`.
3
+ *
4
+ * 0.35.0 Phase 3 port (paired tier-1 scanner-shim). This was a thin
5
+ * bash shim over `rea hook scan-bash --mode blocked` — the heavy
6
+ * lifting (the parser-backed AST walker that closes 9 bypass classes
7
+ * from helix-023 + discord-ops Round 13) lives in `src/hooks/bash-
8
+ * scanner/`.
9
+ *
10
+ * The Node-binary port preserves the same byte-for-byte verdict shape
11
+ * and exit-code contract but eliminates the bash-shim → node-CLI →
12
+ * scanner-module subprocess hop. The caller is now `rea hook blocked-
13
+ * paths-bash-gate`, which calls `runBlockedScan` directly.
14
+ *
15
+ * Behavioral contract — preserves the bash hook byte-for-byte:
16
+ *
17
+ * 1. HALT check → exit 2 with shared banner.
18
+ * 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0
19
+ * (the bash gate's `[[ -z "$payload" ]] && exit 0` guard).
20
+ * 3. Non-Bash tool calls bypass — Claude Code's hook matcher already
21
+ * filters to Bash but defense-in-depth.
22
+ * 4. Load policy permissively (a partial/migrating policy.yaml with
23
+ * unknown keys must NOT collapse the `blocked_paths` list — same
24
+ * lesson from 0.33.0 round-1 P3 + 0.34.0 round-2 P2).
25
+ * 5. Empty `blocked_paths` → allow (no-op). Mirrors
26
+ * `runBlockedScan({ blockedPaths: [] }, cmd)` short-circuit.
27
+ * 6. Run `runBlockedScan` against the command.
28
+ * 7. Verdict `block` → exit 2 with the scanner's reason. Verdict
29
+ * `allow` → exit 0.
30
+ *
31
+ * Audit-log parity: emits a `rea.hook.blocked-paths-bash-gate` entry
32
+ * (best-effort, never blocks the verdict on audit failure).
33
+ */
34
+ import type { Buffer } from 'node:buffer';
35
+ import { type Verdict } from '../bash-scanner/index.js';
36
+ export interface BlockedPathsBashGateOptions {
37
+ reaRoot?: string;
38
+ stdinOverride?: string | Buffer;
39
+ stderrWrite?: (s: string) => void;
40
+ }
41
+ export interface BlockedPathsBashGateResult {
42
+ exitCode: number;
43
+ stderr: string;
44
+ /** Final verdict from the scanner (test seam). */
45
+ verdict: Verdict | null;
46
+ }
47
+ /**
48
+ * Pure executor. Returns `{ exitCode, stderr, verdict }`; the CLI
49
+ * wrapper translates them into `process.stderr.write` + `process.exit`.
50
+ */
51
+ export declare function runBlockedPathsBashGate(options?: BlockedPathsBashGateOptions): Promise<BlockedPathsBashGateResult>;
52
+ /**
53
+ * CLI entry point — `rea hook blocked-paths-bash-gate`.
54
+ */
55
+ export declare function runHookBlockedPathsBashGate(options?: BlockedPathsBashGateOptions): Promise<void>;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Node-binary port of `hooks/blocked-paths-bash-gate.sh`.
3
+ *
4
+ * 0.35.0 Phase 3 port (paired tier-1 scanner-shim). This was a thin
5
+ * bash shim over `rea hook scan-bash --mode blocked` — the heavy
6
+ * lifting (the parser-backed AST walker that closes 9 bypass classes
7
+ * from helix-023 + discord-ops Round 13) lives in `src/hooks/bash-
8
+ * scanner/`.
9
+ *
10
+ * The Node-binary port preserves the same byte-for-byte verdict shape
11
+ * and exit-code contract but eliminates the bash-shim → node-CLI →
12
+ * scanner-module subprocess hop. The caller is now `rea hook blocked-
13
+ * paths-bash-gate`, which calls `runBlockedScan` directly.
14
+ *
15
+ * Behavioral contract — preserves the bash hook byte-for-byte:
16
+ *
17
+ * 1. HALT check → exit 2 with shared banner.
18
+ * 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0
19
+ * (the bash gate's `[[ -z "$payload" ]] && exit 0` guard).
20
+ * 3. Non-Bash tool calls bypass — Claude Code's hook matcher already
21
+ * filters to Bash but defense-in-depth.
22
+ * 4. Load policy permissively (a partial/migrating policy.yaml with
23
+ * unknown keys must NOT collapse the `blocked_paths` list — same
24
+ * lesson from 0.33.0 round-1 P3 + 0.34.0 round-2 P2).
25
+ * 5. Empty `blocked_paths` → allow (no-op). Mirrors
26
+ * `runBlockedScan({ blockedPaths: [] }, cmd)` short-circuit.
27
+ * 6. Run `runBlockedScan` against the command.
28
+ * 7. Verdict `block` → exit 2 with the scanner's reason. Verdict
29
+ * `allow` → exit 0.
30
+ *
31
+ * Audit-log parity: emits a `rea.hook.blocked-paths-bash-gate` entry
32
+ * (best-effort, never blocks the verdict on audit failure).
33
+ */
34
+ import path from 'node:path';
35
+ import fs from 'node:fs';
36
+ import { parse as parseYaml } from 'yaml';
37
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
38
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
39
+ import { runBlockedScan } from '../bash-scanner/index.js';
40
+ import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
41
+ /**
42
+ * Load `blocked_paths` from `<reaRoot>/.rea/policy.yaml` permissively.
43
+ *
44
+ * Why not `loadPolicy`? The strict zod loader refuses partial / unknown
45
+ * keys (it's strict-mode by design). A consumer running a migrating
46
+ * policy.yaml or holding legacy keys would have their `blocked_paths`
47
+ * effectively wiped — silently. The bash gate's pre-0.35.0 yaml grep
48
+ * scanned for the key directly with no schema validation; we mirror
49
+ * that permissive posture by reading `blocked_paths` from the parsed
50
+ * YAML directly without validation.
51
+ *
52
+ * Returns `[]` on any failure (missing file, bad YAML, missing key,
53
+ * unexpected type). Empty list is the "no enforcement" no-op state.
54
+ */
55
+ function loadBlockedPathsPermissive(reaRoot) {
56
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
57
+ if (!fs.existsSync(policyPath))
58
+ return [];
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(policyPath, 'utf8');
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = parseYaml(raw);
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
74
+ return [];
75
+ }
76
+ const obj = parsed;
77
+ const bp = obj['blocked_paths'];
78
+ if (!Array.isArray(bp))
79
+ return [];
80
+ const out = [];
81
+ for (const entry of bp) {
82
+ if (typeof entry === 'string' && entry.length > 0) {
83
+ out.push(entry);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Pure executor. Returns `{ exitCode, stderr, verdict }`; the CLI
90
+ * wrapper translates them into `process.stderr.write` + `process.exit`.
91
+ */
92
+ export async function runBlockedPathsBashGate(options = {}) {
93
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
94
+ let stderr = '';
95
+ const writeStderr = (s) => {
96
+ stderr += s;
97
+ if (options.stderrWrite)
98
+ options.stderrWrite(s);
99
+ };
100
+ // 1. HALT check.
101
+ const halt = checkHalt(reaRoot);
102
+ if (halt.halted) {
103
+ writeStderr(formatHaltBanner(halt.reason));
104
+ return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: 'rea HALT active' } };
105
+ }
106
+ // 2. Read + parse stdin.
107
+ const stdinRaw = options.stdinOverride !== undefined
108
+ ? options.stdinOverride
109
+ : await readStdinWithTimeout(5_000);
110
+ let toolName = '';
111
+ let cmd = '';
112
+ try {
113
+ const payload = parseHookPayload(stdinRaw);
114
+ toolName = payload.toolName;
115
+ cmd = payload.command;
116
+ }
117
+ catch (err) {
118
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
119
+ writeStderr(`blocked-paths-bash-gate: ${err.message} — refusing on uncertainty.\n`);
120
+ return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: err.message } };
121
+ }
122
+ throw err;
123
+ }
124
+ // 3. Non-Bash tool calls bypass.
125
+ if (toolName !== '' && toolName !== 'Bash') {
126
+ return { exitCode: 0, stderr, verdict: null };
127
+ }
128
+ // 4. Empty command → allow.
129
+ if (cmd.length === 0) {
130
+ return { exitCode: 0, stderr, verdict: null };
131
+ }
132
+ // 5. Load policy permissively.
133
+ const blockedPaths = loadBlockedPathsPermissive(reaRoot);
134
+ // 6. Empty list → allow.
135
+ if (blockedPaths.length === 0) {
136
+ return { exitCode: 0, stderr, verdict: { verdict: 'allow' } };
137
+ }
138
+ // 7. Scan.
139
+ const verdict = runBlockedScan({ reaRoot, blockedPaths }, cmd);
140
+ // 8. Audit — best-effort, never changes verdict.
141
+ try {
142
+ await appendAuditRecord(reaRoot, {
143
+ tool_name: 'rea.hook.blocked-paths-bash-gate',
144
+ server_name: 'rea',
145
+ tier: Tier.Read,
146
+ status: verdict.verdict === 'allow' ? InvocationStatus.Allowed : InvocationStatus.Denied,
147
+ metadata: {
148
+ verdict: verdict.verdict,
149
+ ...(verdict.detected_form !== undefined ? { detected_form: verdict.detected_form } : {}),
150
+ ...(verdict.hit_pattern !== undefined ? { hit_pattern: verdict.hit_pattern } : {}),
151
+ command_preview: cmd.slice(0, 256),
152
+ },
153
+ });
154
+ }
155
+ catch {
156
+ /* best-effort */
157
+ }
158
+ if (verdict.verdict === 'block') {
159
+ if (typeof verdict.reason === 'string' && verdict.reason.length > 0) {
160
+ writeStderr(verdict.reason + '\n');
161
+ }
162
+ return { exitCode: 2, stderr, verdict };
163
+ }
164
+ return { exitCode: 0, stderr, verdict };
165
+ }
166
+ /**
167
+ * CLI entry point — `rea hook blocked-paths-bash-gate`.
168
+ */
169
+ export async function runHookBlockedPathsBashGate(options = {}) {
170
+ const result = await runBlockedPathsBashGate({
171
+ ...options,
172
+ stderrWrite: (s) => process.stderr.write(s),
173
+ });
174
+ process.exit(result.exitCode);
175
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Node-binary port of `hooks/blocked-paths-enforcer.sh`.
3
+ *
4
+ * 0.35.0 Phase 4 port (paired Write/Edit tier). Enforces
5
+ * `policy.blocked_paths` against Write/Edit/MultiEdit/NotebookEdit
6
+ * tool calls. Sibling of `blocked-paths-bash-gate` (Bash-tier) — same
7
+ * policy data, different surface.
8
+ *
9
+ * Behavioral contract — preserves the bash hook byte-for-byte:
10
+ *
11
+ * 1. HALT check → exit 2 with shared banner.
12
+ * 2. Read stdin, extract `tool_input.file_path` (or `notebook_path`).
13
+ * Missing/empty → exit 0.
14
+ * 3. Load policy permissively (a partial / migrating policy.yaml
15
+ * must NOT collapse the blocked_paths list).
16
+ * 4. Empty `blocked_paths` → exit 0.
17
+ * 5. §5a path-traversal rejection. Refuses any path with a `..`
18
+ * segment in EITHER the raw form OR the normalized form. Also
19
+ * catches URL-encoded traversal (`%2E%2E/`, `..%2F`, etc.)
20
+ * against the raw input.
21
+ * 6. §5a-bis interior `/./` segment rejection (0.29.0 helix-/./-class).
22
+ * NORMALIZED form only — `normalize_path` already strips leading
23
+ * `./` segments, so anything remaining is interior by construction.
24
+ * 7. Agent-writable allow-list short-circuit (`.rea/tasks.jsonl`,
25
+ * `.rea/audit/`) — even if blocked_paths includes `.rea/` as a
26
+ * prefix block, these are PM-data writeables.
27
+ * 8. Match the normalized path against each blocked entry:
28
+ * - directory prefix (entry ends with `/`)
29
+ * - glob (entry contains `*`)
30
+ * - exact (lower-case, case-INSENSITIVE)
31
+ * Match → exit 2 with reason.
32
+ * 9. §H.2 intermediate-symlink resolution. If the parent dir exists,
33
+ * resolve its realpath. If the resolved target falls inside a
34
+ * blocked entry, refuse.
35
+ *
36
+ * Audit-log parity: emits a `rea.hook.blocked-paths-enforcer` entry.
37
+ */
38
+ import type { Buffer } from 'node:buffer';
39
+ export interface BlockedPathsEnforcerOptions {
40
+ reaRoot?: string;
41
+ stdinOverride?: string | Buffer;
42
+ stderrWrite?: (s: string) => void;
43
+ }
44
+ export interface BlockedPathsEnforcerResult {
45
+ exitCode: number;
46
+ stderr: string;
47
+ /** Test seam — when the gate blocks, the matched blocked-paths entry. */
48
+ matched: string | null;
49
+ }
50
+ export declare function runBlockedPathsEnforcer(options?: BlockedPathsEnforcerOptions): Promise<BlockedPathsEnforcerResult>;
51
+ export declare function runHookBlockedPathsEnforcer(options?: BlockedPathsEnforcerOptions): Promise<void>;