@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
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}
|
|
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;
|
|
Binary file
|
|
@@ -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[];
|