@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
package/dist/cli/hook.js CHANGED
@@ -43,6 +43,13 @@ import { runHookEnvFileProtection } from '../hooks/env-file-protection/index.js'
43
43
  import { runHookDependencyAuditGate } from '../hooks/dependency-audit-gate/index.js';
44
44
  import { runHookChangesetSecurityGate } from '../hooks/changeset-security-gate/index.js';
45
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';
49
+ import { runHookBlockedPathsBashGate } from '../hooks/blocked-paths-bash-gate/index.js';
50
+ import { runHookProtectedPathsBashGate } from '../hooks/protected-paths-bash-gate/index.js';
51
+ import { runHookBlockedPathsEnforcer } from '../hooks/blocked-paths-enforcer/index.js';
52
+ import { runHookSettingsProtection } from '../hooks/settings-protection/index.js';
46
53
  import { loadPolicy } from '../policy/loader.js';
47
54
  import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
48
55
  import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
@@ -1001,6 +1008,48 @@ export function registerHookCommand(program) {
1001
1008
  .action(async () => {
1002
1009
  await runHookArchitectureReviewGate();
1003
1010
  });
1011
+ hook
1012
+ .command('dangerous-bash-interceptor')
1013
+ .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.')
1014
+ .action(async () => {
1015
+ await runHookDangerousBashInterceptor();
1016
+ });
1017
+ hook
1018
+ .command('local-review-gate')
1019
+ .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.')
1020
+ .action(async () => {
1021
+ await runHookLocalReviewGate();
1022
+ });
1023
+ hook
1024
+ .command('secret-scanner')
1025
+ .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`.')
1026
+ .action(async () => {
1027
+ await runHookSecretScanner();
1028
+ });
1029
+ hook
1030
+ .command('blocked-paths-bash-gate')
1031
+ .description('Node-binary port of `hooks/blocked-paths-bash-gate.sh` (0.35.0). PreToolUse Bash gate refusing shell writes to `policy.blocked_paths` entries. Calls the AST-backed `runBlockedScan` directly (no shim→CLI→scanner subprocess hop). Permissive policy read — partial/migrating policy.yaml does NOT collapse the blocked_paths list. Empty list → no-op. Verdict `block` → exit 2; `allow` → exit 0.')
1032
+ .action(async () => {
1033
+ await runHookBlockedPathsBashGate();
1034
+ });
1035
+ hook
1036
+ .command('protected-paths-bash-gate')
1037
+ .description('Node-binary port of `hooks/protected-paths-bash-gate.sh` (0.35.0). PreToolUse Bash gate refusing shell-redirect/cp/mv/install/etc. to protected paths (.claude/settings.json, .claude/hooks/*, .husky/*, .rea/policy.yaml, .rea/HALT). Honors `policy.protected_writes` (full override) + `policy.protected_paths_relax` (subtractor). REA_HOOK_PATCH_SESSION relaxes .claude/hooks/ for the session.')
1038
+ .action(async () => {
1039
+ await runHookProtectedPathsBashGate();
1040
+ });
1041
+ hook
1042
+ .command('blocked-paths-enforcer')
1043
+ .description('Node-binary port of `hooks/blocked-paths-enforcer.sh` (0.35.0). PreToolUse Write/Edit/MultiEdit/NotebookEdit gate refusing writes to `policy.blocked_paths` entries. §5a path-traversal reject + §5a-bis interior `/./` reject + §H.2 intermediate-symlink resolution. Agent-writable allow-list (.rea/tasks.jsonl, .rea/audit/) short-circuits before policy match.')
1044
+ .action(async () => {
1045
+ await runHookBlockedPathsEnforcer();
1046
+ });
1047
+ hook
1048
+ .command('settings-protection')
1049
+ .description('Node-binary port of `hooks/settings-protection.sh` (0.35.0, the LARGEST hook in the repo at 582 LOC of bash). PreToolUse Write/Edit/MultiEdit/NotebookEdit gate protecting .claude/settings.json, .claude/hooks/*, .husky/*, .rea/policy.yaml, .rea/HALT, .rea/last-review.{json,cache.json}. Honors `protected_writes` (full override) + `protected_paths_relax` (subtractor, kill-switch invariants non-relaxable). §5b extension-surface allow-list for .husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/* with final-component and intermediate-directory symlink refusal. §6c intermediate-symlink resolution. §6b REA_HOOK_PATCH_SESSION unlock for .claude/hooks/ with hash-chained audit append (fail-closed on append failure).')
1050
+ .action(async () => {
1051
+ await runHookSettingsProtection();
1052
+ });
1004
1053
  hook
1005
1054
  .command('policy-get')
1006
1055
  .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.')
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared path-normalization primitives for the Node-binary hook tier.
3
+ *
4
+ * 0.35.0 — TypeScript port of `hooks/_lib/path-normalize.sh`. The bash
5
+ * helper is the single source of truth shared between settings-
6
+ * protection.sh and blocked-paths-enforcer.sh (and the Bash-tier
7
+ * gates' relevance pre-checks). The 4 hooks landing in 0.35.0 all
8
+ * need the same normalization to stay byte-parity with their bash
9
+ * counterparts.
10
+ *
11
+ * Functions:
12
+ * - `normalizePath(p, reaRoot)` — project-relative form. Strip
13
+ * reaRoot prefix → URL-decode `%2F`/`%2E`/`%20`/`%5C` → translate
14
+ * `\` → `/` → strip leading `./` segments.
15
+ * - `hasTraversalSegment(p)` — true if any `..` segment exists.
16
+ * - `hasInteriorDotSegment(p)` — true if any interior `/./` segment
17
+ * exists (0.29.0 helix-/./-class refusal).
18
+ * - `resolveParentRealpath(targetPath)` — pure-Node equivalent of
19
+ * the bash `resolve_parent_realpath`. Returns the realpath of the
20
+ * parent dir, walking up to the nearest existing ancestor if the
21
+ * parent doesn't exist yet, then appending the unresolved tail.
22
+ * - `resolveCanonRoot(reaRoot)` — `cd -P && pwd -P` equivalent of
23
+ * the project root, with macOS `/var` → `/private/var` collapse.
24
+ *
25
+ * All functions are pure (no logging, no exit) — the caller decides
26
+ * how to surface failures.
27
+ */
28
+ /**
29
+ * Project-relative form of a file path. Mirrors the bash helper
30
+ * byte-for-byte.
31
+ *
32
+ * Order of operations (lifted from `hooks/_lib/path-normalize.sh`):
33
+ * 1. Strip leading `<reaRoot>/` prefix.
34
+ * 2. URL-decode `%2F`, `%2E`, `%20`, `%5C` (case-insensitive). Other
35
+ * percent-encodings are left untouched — the bash helper only
36
+ * decodes this fixed set.
37
+ * 3. Translate backslash separators to forward slashes.
38
+ * 4. Strip leading `./` segments. Interior `./` is NOT stripped (that
39
+ * would corrupt `..` traversals — see §5a-bis).
40
+ */
41
+ export declare function normalizePath(input: string, reaRoot: string): string;
42
+ /**
43
+ * True if any `/../` segment is present (bracketing the input with `/`
44
+ * on each side so leading/trailing `..` segments still count). Mirrors
45
+ * the bash `case "/$path/" in *<slash>..<slash>*) traversal=1` shape
46
+ * (the literal asterisk-slash form is omitted to keep the JSDoc block
47
+ * from terminating early).
48
+ */
49
+ export declare function hasTraversalSegment(p: string): boolean;
50
+ /**
51
+ * True if any interior `/./` segment is present. The bash helper uses
52
+ * the equivalent `*<slash>.<slash>*` case-glob shape; leading `./` is
53
+ * stripped by normalizePath, so anything that survives is interior.
54
+ */
55
+ export declare function hasInteriorDotSegment(p: string): boolean;
56
+ /**
57
+ * Canonicalize a project root the same way bash `cd -P && pwd -P`
58
+ * would — follow every symlink in the path to the physical form. On
59
+ * macOS this collapses `/var/...` → `/private/var/...` because `/var`
60
+ * is itself a symlink. Used to make REA_ROOT prefix comparisons
61
+ * symmetric against realpath'd children.
62
+ *
63
+ * Returns the original `reaRoot` (unmodified) when realpath fails —
64
+ * the bash helper falls back the same way via `|| resolved=""`.
65
+ */
66
+ export declare function resolveCanonRoot(reaRoot: string): string;
67
+ /**
68
+ * Resolve the realpath of the parent directory of `targetPath`. Pure-
69
+ * Node mirror of `hooks/_lib/path-normalize.sh::resolve_parent_realpath`,
70
+ * including the 0.21.2 helix-022 #1 nearest-existing-ancestor walk.
71
+ *
72
+ * Returns:
73
+ * - The resolved realpath of the parent when it exists.
74
+ * - When the parent doesn't exist on disk: walk UP looking for the
75
+ * nearest existing ancestor, realpath that, then append the
76
+ * unresolved tail. This catches symlink walks where the terminal
77
+ * directory is created mid-segment (`mkdir -p linkroot/.husky/sub`).
78
+ * - Empty string when no existing ancestor inside REA_ROOT could be
79
+ * resolved.
80
+ */
81
+ export declare function resolveParentRealpath(targetPath: string): string;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Shared path-normalization primitives for the Node-binary hook tier.
3
+ *
4
+ * 0.35.0 — TypeScript port of `hooks/_lib/path-normalize.sh`. The bash
5
+ * helper is the single source of truth shared between settings-
6
+ * protection.sh and blocked-paths-enforcer.sh (and the Bash-tier
7
+ * gates' relevance pre-checks). The 4 hooks landing in 0.35.0 all
8
+ * need the same normalization to stay byte-parity with their bash
9
+ * counterparts.
10
+ *
11
+ * Functions:
12
+ * - `normalizePath(p, reaRoot)` — project-relative form. Strip
13
+ * reaRoot prefix → URL-decode `%2F`/`%2E`/`%20`/`%5C` → translate
14
+ * `\` → `/` → strip leading `./` segments.
15
+ * - `hasTraversalSegment(p)` — true if any `..` segment exists.
16
+ * - `hasInteriorDotSegment(p)` — true if any interior `/./` segment
17
+ * exists (0.29.0 helix-/./-class refusal).
18
+ * - `resolveParentRealpath(targetPath)` — pure-Node equivalent of
19
+ * the bash `resolve_parent_realpath`. Returns the realpath of the
20
+ * parent dir, walking up to the nearest existing ancestor if the
21
+ * parent doesn't exist yet, then appending the unresolved tail.
22
+ * - `resolveCanonRoot(reaRoot)` — `cd -P && pwd -P` equivalent of
23
+ * the project root, with macOS `/var` → `/private/var` collapse.
24
+ *
25
+ * All functions are pure (no logging, no exit) — the caller decides
26
+ * how to surface failures.
27
+ */
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ /**
31
+ * Project-relative form of a file path. Mirrors the bash helper
32
+ * byte-for-byte.
33
+ *
34
+ * Order of operations (lifted from `hooks/_lib/path-normalize.sh`):
35
+ * 1. Strip leading `<reaRoot>/` prefix.
36
+ * 2. URL-decode `%2F`, `%2E`, `%20`, `%5C` (case-insensitive). Other
37
+ * percent-encodings are left untouched — the bash helper only
38
+ * decodes this fixed set.
39
+ * 3. Translate backslash separators to forward slashes.
40
+ * 4. Strip leading `./` segments. Interior `./` is NOT stripped (that
41
+ * would corrupt `..` traversals — see §5a-bis).
42
+ */
43
+ export function normalizePath(input, reaRoot) {
44
+ let p = input;
45
+ // 1. Strip $REA_ROOT/ prefix.
46
+ const prefix = reaRoot.endsWith(path.sep) ? reaRoot : reaRoot + '/';
47
+ if (p === reaRoot || p.startsWith(prefix)) {
48
+ if (p === reaRoot) {
49
+ p = '';
50
+ }
51
+ else {
52
+ p = p.slice(prefix.length);
53
+ }
54
+ }
55
+ // 2. URL-decode the fixed set: %2F→/, %2E→., %20→' ', %5C→\.
56
+ p = p
57
+ .replace(/%2[Ff]/g, '/')
58
+ .replace(/%2[Ee]/g, '.')
59
+ .replace(/%20/g, ' ')
60
+ .replace(/%5[Cc]/g, '\\');
61
+ // 3. Translate backslash separators to forward slashes.
62
+ p = p.replace(/\\/g, '/');
63
+ // 4. Strip leading `./` segments only.
64
+ while (p.startsWith('./')) {
65
+ p = p.slice(2);
66
+ }
67
+ return p;
68
+ }
69
+ /**
70
+ * True if any `/../` segment is present (bracketing the input with `/`
71
+ * on each side so leading/trailing `..` segments still count). Mirrors
72
+ * the bash `case "/$path/" in *<slash>..<slash>*) traversal=1` shape
73
+ * (the literal asterisk-slash form is omitted to keep the JSDoc block
74
+ * from terminating early).
75
+ */
76
+ export function hasTraversalSegment(p) {
77
+ const bracketed = `/${p}/`;
78
+ return bracketed.includes('/../');
79
+ }
80
+ /**
81
+ * True if any interior `/./` segment is present. The bash helper uses
82
+ * the equivalent `*<slash>.<slash>*` case-glob shape; leading `./` is
83
+ * stripped by normalizePath, so anything that survives is interior.
84
+ */
85
+ export function hasInteriorDotSegment(p) {
86
+ const bracketed = `/${p}/`;
87
+ return bracketed.includes('/./');
88
+ }
89
+ /**
90
+ * Canonicalize a project root the same way bash `cd -P && pwd -P`
91
+ * would — follow every symlink in the path to the physical form. On
92
+ * macOS this collapses `/var/...` → `/private/var/...` because `/var`
93
+ * is itself a symlink. Used to make REA_ROOT prefix comparisons
94
+ * symmetric against realpath'd children.
95
+ *
96
+ * Returns the original `reaRoot` (unmodified) when realpath fails —
97
+ * the bash helper falls back the same way via `|| resolved=""`.
98
+ */
99
+ export function resolveCanonRoot(reaRoot) {
100
+ try {
101
+ return fs.realpathSync(reaRoot);
102
+ }
103
+ catch {
104
+ return reaRoot;
105
+ }
106
+ }
107
+ /**
108
+ * Resolve the realpath of the parent directory of `targetPath`. Pure-
109
+ * Node mirror of `hooks/_lib/path-normalize.sh::resolve_parent_realpath`,
110
+ * including the 0.21.2 helix-022 #1 nearest-existing-ancestor walk.
111
+ *
112
+ * Returns:
113
+ * - The resolved realpath of the parent when it exists.
114
+ * - When the parent doesn't exist on disk: walk UP looking for the
115
+ * nearest existing ancestor, realpath that, then append the
116
+ * unresolved tail. This catches symlink walks where the terminal
117
+ * directory is created mid-segment (`mkdir -p linkroot/.husky/sub`).
118
+ * - Empty string when no existing ancestor inside REA_ROOT could be
119
+ * resolved.
120
+ */
121
+ export function resolveParentRealpath(targetPath) {
122
+ const parentDir = path.dirname(targetPath);
123
+ // Fast path: parent exists. Resolve directly.
124
+ let parentStat;
125
+ try {
126
+ parentStat = fs.statSync(parentDir);
127
+ }
128
+ catch {
129
+ /* falls through to walk-up below */
130
+ }
131
+ if (parentStat?.isDirectory()) {
132
+ try {
133
+ return fs.realpathSync(parentDir);
134
+ }
135
+ catch {
136
+ return '';
137
+ }
138
+ }
139
+ // Walk up to the nearest existing ancestor; accumulate the tail.
140
+ let walk = parentDir;
141
+ let tail = '';
142
+ // Bound the walk to avoid pathological loops on relative paths.
143
+ for (let i = 0; i < 64; i++) {
144
+ if (!walk || walk === '/' || walk === '.')
145
+ break;
146
+ try {
147
+ const s = fs.statSync(walk);
148
+ if (s.isDirectory())
149
+ break;
150
+ }
151
+ catch {
152
+ /* keep walking */
153
+ }
154
+ const base = path.basename(walk);
155
+ tail = tail.length > 0 ? `${base}/${tail}` : base;
156
+ walk = path.dirname(walk);
157
+ }
158
+ if (!walk || walk === '/' || walk === '.') {
159
+ return '';
160
+ }
161
+ let resolvedWalk;
162
+ try {
163
+ resolvedWalk = fs.realpathSync(walk);
164
+ }
165
+ catch {
166
+ return '';
167
+ }
168
+ if (tail.length === 0)
169
+ return resolvedWalk;
170
+ return `${resolvedWalk}/${tail}`;
171
+ }
@@ -97,7 +97,7 @@ export function parseHookPayload(raw) {
97
97
  }
98
98
  const c = ti.command;
99
99
  if (c !== undefined && typeof c !== 'string') {
100
- throw new TypePayloadError(`hook payload tool_input.command is ${typeof c}, expected string`);
100
+ throw new TypePayloadError(`hook payload tool_input.command is non-string (got ${typeof c}); expected string`);
101
101
  }
102
102
  if (typeof c === 'string')
103
103
  command = c;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Shared protected-paths catalog for the Node-binary hook tier.
3
+ *
4
+ * 0.35.0 — TypeScript port of `hooks/_lib/protected-paths.sh`. The
5
+ * canonical hard-protected list shared between the Write/Edit tier
6
+ * (`settings-protection`) and the Bash tier (`protected-paths-bash-
7
+ * gate` → already in the bash-scanner module via `runProtectedScan`).
8
+ *
9
+ * # Why a TS port at all?
10
+ *
11
+ * The bash helper is sourced into both `settings-protection.sh` and
12
+ * the Bash-tier scanner caller. Now that settings-protection.sh is
13
+ * being moved to Node-binary in 0.35.0, the protected-list resolution
14
+ * needs to land in TypeScript too — otherwise the new `runSettingsProtection`
15
+ * would have to shell out to bash to read the list, which defeats the
16
+ * point of the Node-binary migration.
17
+ *
18
+ * # Kill-switch invariants (NON-RELAXABLE)
19
+ *
20
+ * These are ALWAYS protected, even when listed in `protected_paths_relax`:
21
+ *
22
+ * .rea/HALT — the kill switch itself
23
+ * .rea/policy.yaml — the policy that defines all enforcement
24
+ * .claude/settings.json — the hook registration that activates rea
25
+ * .rea/last-review.cache.json — verdict-cache security boundary
26
+ * .rea/last-review.json — operator forensic snapshot
27
+ *
28
+ * # Policy interaction
29
+ *
30
+ * - `protected_writes` (optional list): when set, FULLY REPLACES the
31
+ * hardcoded default. Kill-switch invariants are added back
32
+ * defensively. The override pattern set is tracked separately so
33
+ * `isProtected()` can prioritize override matches over the
34
+ * extension-surface allow-list (helix-020 G2 fix).
35
+ * - `protected_paths_relax` (list): SUBTRACTS from whatever the
36
+ * effective set is. Kill-switch invariants in this list are silently
37
+ * dropped + an advisory is emitted to stderr (caller's responsibility
38
+ * to surface).
39
+ */
40
+ export const KILL_SWITCH_INVARIANTS = [
41
+ '.claude/settings.json',
42
+ '.rea/policy.yaml',
43
+ '.rea/HALT',
44
+ '.rea/last-review.cache.json',
45
+ '.rea/last-review.json',
46
+ ];
47
+ /**
48
+ * Hardcoded historical default — the 7 patterns the bash helper ships
49
+ * (`REA_PROTECTED_PATTERNS_FULL`). Suffix `/` indicates prefix match;
50
+ * no suffix means case-insensitive exact match.
51
+ */
52
+ export const PROTECTED_PATTERNS_FULL = [
53
+ '.claude/settings.json',
54
+ '.claude/settings.local.json',
55
+ '.husky/',
56
+ '.rea/policy.yaml',
57
+ '.rea/HALT',
58
+ '.rea/last-review.cache.json',
59
+ '.rea/last-review.json',
60
+ ];
61
+ /**
62
+ * Patch-session patterns — protected from agents by default but
63
+ * unlockable by setting `REA_HOOK_PATCH_SESSION=<reason>`. Mirrors
64
+ * `PATCH_SESSION_PATTERNS` in settings-protection.sh §6b.
65
+ */
66
+ export const PATCH_SESSION_PATTERNS = ['.claude/hooks/'];
67
+ /**
68
+ * Documented husky extension surface — `.husky/{commit-msg,pre-push,
69
+ * pre-commit,prepare-commit-msg}.d/*`. Consumers write extension
70
+ * fragments here freely; the §6 prefix block on `.husky/` would
71
+ * otherwise catch them.
72
+ *
73
+ * The bare directory itself (e.g. `.husky/pre-push.d/`) is NOT
74
+ * considered extension-surface — only fragments INSIDE the surface.
75
+ */
76
+ export function isExtensionSurface(p) {
77
+ const lower = p.toLowerCase();
78
+ const surfaces = [
79
+ '.husky/commit-msg.d/',
80
+ '.husky/pre-push.d/',
81
+ '.husky/pre-commit.d/',
82
+ '.husky/prepare-commit-msg.d/',
83
+ ];
84
+ // Refuse the bare directory itself.
85
+ for (const s of surfaces) {
86
+ if (lower === s)
87
+ return false;
88
+ if (lower === s.slice(0, -1))
89
+ return false; // without trailing slash
90
+ }
91
+ for (const s of surfaces) {
92
+ if (lower.startsWith(s) && lower.length > s.length) {
93
+ return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
98
+ /**
99
+ * Resolve the effective hard-protected pattern set against policy.
100
+ * Pure function — no I/O, no stderr emission. Stderr advisories come
101
+ * back as strings so the caller can route them appropriately.
102
+ */
103
+ export function resolveProtectedPatterns(input = {}) {
104
+ const writes = input.protectedWrites;
105
+ const relax = input.protectedPathsRelax ?? [];
106
+ // 1. Compose the BASE list.
107
+ const baseList = [];
108
+ if (writes !== undefined) {
109
+ // protected_writes set — replaces the default.
110
+ for (const w of writes) {
111
+ if (typeof w === 'string' && w.length > 0)
112
+ baseList.push(w);
113
+ }
114
+ // Add kill-switch invariants if not already present (case-insensitive).
115
+ for (const inv of KILL_SWITCH_INVARIANTS) {
116
+ const invLc = inv.toLowerCase();
117
+ if (!baseList.some((b) => b.toLowerCase() === invLc)) {
118
+ baseList.push(inv);
119
+ }
120
+ }
121
+ }
122
+ else {
123
+ for (const pat of PROTECTED_PATTERNS_FULL)
124
+ baseList.push(pat);
125
+ }
126
+ // 2. Validate relax entries — kill-switch invariants are non-relaxable.
127
+ const advisories = [];
128
+ const relaxedSet = [];
129
+ for (const r of relax) {
130
+ if (typeof r !== 'string' || r.length === 0)
131
+ continue;
132
+ if (KILL_SWITCH_INVARIANTS.some((inv) => inv.toLowerCase() === r.toLowerCase())) {
133
+ advisories.push(`rea: protected_paths_relax: ${r} is a kill-switch invariant and cannot be relaxed; ignoring.\n`);
134
+ }
135
+ else {
136
+ relaxedSet.push(r);
137
+ }
138
+ }
139
+ // 3. Build the effective list — base entries NOT in relaxed set
140
+ // (case-insensitive comparison).
141
+ const patterns = [];
142
+ for (const pat of baseList) {
143
+ const patLc = pat.toLowerCase();
144
+ const relaxed = relaxedSet.some((r) => r.toLowerCase() === patLc);
145
+ if (!relaxed)
146
+ patterns.push(pat);
147
+ }
148
+ // 4. Build the OVERRIDE subset (only entries from `protected_writes`,
149
+ // NOT kill-switch invariants added back defensively). Mirrors
150
+ // REA_PROTECTED_OVERRIDE_PATTERNS in the bash helper.
151
+ const overridePatterns = [];
152
+ if (writes !== undefined) {
153
+ for (const w of writes) {
154
+ if (typeof w !== 'string' || w.length === 0)
155
+ continue;
156
+ const wLc = w.toLowerCase();
157
+ const relaxed = relaxedSet.some((r) => r.toLowerCase() === wLc);
158
+ if (!relaxed)
159
+ overridePatterns.push(w);
160
+ }
161
+ }
162
+ return { patterns, overridePatterns, advisories };
163
+ }
164
+ /**
165
+ * Match a project-relative path against a pattern list. Mirrors the
166
+ * shell exact-equal AND the trailing-slash prefix-glob shapes,
167
+ * case-INSENSITIVE.
168
+ *
169
+ * Returns the matched pattern (preserving its original case) or `null`.
170
+ */
171
+ export function matchAny(pathLc, patterns) {
172
+ for (const pattern of patterns) {
173
+ const patternLc = pattern.toLowerCase();
174
+ if (pathLc === patternLc)
175
+ return pattern;
176
+ if (patternLc.endsWith('/') && pathLc.startsWith(patternLc))
177
+ return pattern;
178
+ }
179
+ return null;
180
+ }
181
+ /**
182
+ * Full equivalent of `rea_path_is_protected` from the bash helper.
183
+ * Three-step decision:
184
+ *
185
+ * 1. Explicit `protected_writes` overrides win FIRST (helix-020 G2).
186
+ * 2. Extension-surface allow-list short-circuits "not protected"
187
+ * for `.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d`
188
+ * fragments.
189
+ * 3. Default hard-protected list (kill-switch invariants + the
190
+ * historical patterns from PROTECTED_PATTERNS_FULL).
191
+ */
192
+ export function isProtected(pathRel, resolution) {
193
+ const lower = pathRel.toLowerCase();
194
+ // 1. Explicit overrides win.
195
+ const overrideHit = matchAny(lower, resolution.overridePatterns);
196
+ if (overrideHit !== null) {
197
+ return { protected: true, matchedPattern: overrideHit };
198
+ }
199
+ // 2. Extension-surface short-circuit.
200
+ if (isExtensionSurface(pathRel)) {
201
+ return { protected: false, matchedPattern: null };
202
+ }
203
+ // 3. Default protected list.
204
+ const defaultHit = matchAny(lower, resolution.patterns);
205
+ if (defaultHit !== null) {
206
+ return { protected: true, matchedPattern: defaultHit };
207
+ }
208
+ return { protected: false, matchedPattern: null };
209
+ }
210
+ /**
211
+ * Strip C0/C1 control characters from a string before echoing it back to
212
+ * the operator. Mirrors `sanitize_for_stderr` in settings-protection.sh.
213
+ *
214
+ * Byte ranges stripped (after UTF-16→code-point):
215
+ * – — C0 controls (BEL, BS, HT, LF, CR, ESC, …)
216
+ *  — DEL
217
+ * €–Ÿ — C1 controls (CSI, OSC, …)
218
+ *
219
+ * String-level filter — does NOT operate on raw bytes. Sufficient for
220
+ * the bash helper's use case: file-name display in error messages.
221
+ */
222
+ export function sanitizeForStderr(s) {
223
+ let out = '';
224
+ for (const ch of s) {
225
+ const cp = ch.codePointAt(0);
226
+ if ((cp >= 0x00 && cp <= 0x1f) || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f)) {
227
+ continue;
228
+ }
229
+ out += ch;
230
+ }
231
+ return out;
232
+ }
@@ -123,3 +123,105 @@ export declare function anySegmentMatches(cmd: string, regexSource: string): boo
123
123
  * (any-utility OR any-env) were AND'd across segments.
124
124
  */
125
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[];