@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.
- package/dist/cli/hook.js +49 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
- package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
- package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
- package/dist/hooks/local-review-gate/index.d.ts +145 -0
- package/dist/hooks/local-review-gate/index.js +374 -0
- package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/secret-scanner.sh +210 -200
- package/hooks/settings-protection.sh +171 -549
- package/package.json +1 -1
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
- 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>;
|