@bookedsolid/rea 0.34.0 → 0.36.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/doctor.js +45 -36
- package/dist/cli/hook.js +28 -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.js +67 -7
- 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/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.js +64 -2
- 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/protected-paths-bash-gate.sh +123 -210
- package/hooks/settings-protection.sh +171 -549
- package/package.json +3 -2
- package/scripts/lint-awk-shim-quotes.mjs +386 -0
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/settings-protection.dogfood-staged.sh +204 -0
package/dist/cli/doctor.js
CHANGED
|
@@ -169,22 +169,19 @@ export const EXPECTED_HOOKS = [
|
|
|
169
169
|
'blocked-paths-enforcer.sh',
|
|
170
170
|
'changeset-security-gate.sh',
|
|
171
171
|
'dangerous-bash-interceptor.sh',
|
|
172
|
-
// 0.
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// hard-`fail`
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
// runtime and never blocks a tool call. See `git blame` on the 0.29.0
|
|
186
|
-
// round-2 P2 comment on `checkDelegationHookRegistered` for the prior
|
|
187
|
-
// application of this exact discipline.
|
|
172
|
+
// 0.36.0 — `delegation-advisory.sh` PROMOTED to EXPECTED_HOOKS (charter
|
|
173
|
+
// follow-through from 0.31.0). Originally held out in 0.31.0 to give
|
|
174
|
+
// consumers an upgrade-lag window: adding a brand-new hook to
|
|
175
|
+
// EXPECTED_HOOKS would have hard-`fail`ed `checkHooksInstalled` on
|
|
176
|
+
// every pre-0.31.0 install the instant they bumped the rea binary, a
|
|
177
|
+
// regression that turns a green doctor red purely from upgrade lag.
|
|
178
|
+
// After 4 releases of propagation (0.32, 0.33, 0.34, 0.35), the lag
|
|
179
|
+
// window has closed — consumers running `rea upgrade` since 0.31.0
|
|
180
|
+
// have laid the hook down. Same ratchet `delegation-capture.sh` went
|
|
181
|
+
// through 0.29.0 → 0.30.0. Promotion happens in lockstep with
|
|
182
|
+
// `checkDelegationAdvisoryHookRegistered` flipping `warn` → `fail`
|
|
183
|
+
// (see that function for the matching commentary).
|
|
184
|
+
'delegation-advisory.sh',
|
|
188
185
|
// 0.29.0 — delegation-telemetry MVP. The PreToolUse hook on
|
|
189
186
|
// matcher `Agent|Skill` emits a `rea.delegation_signal` audit record
|
|
190
187
|
// on every subagent / skill dispatch. Observational only — fails
|
|
@@ -1126,14 +1123,25 @@ export function checkDelegationHookRegistered(baseDir) {
|
|
|
1126
1123
|
*/
|
|
1127
1124
|
export function checkDelegationAdvisoryHookRegistered(baseDir) {
|
|
1128
1125
|
const label = 'delegation-advisory hook registered';
|
|
1129
|
-
|
|
1126
|
+
// 0.36.0 — promoted from `warn` (advisory in 0.31.0) to `fail` (hard)
|
|
1127
|
+
// per the staged-rollout ratchet. After 4 releases of upgrade-lag
|
|
1128
|
+
// propagation (0.32, 0.33, 0.34, 0.35), consumer installs that have
|
|
1129
|
+
// run `rea upgrade` since 0.31.0 already carry the PostToolUse
|
|
1130
|
+
// `Bash|Edit|Write|MultiEdit|NotebookEdit` group. Any install that
|
|
1131
|
+
// still lacks it after that window is genuinely missing the nudge
|
|
1132
|
+
// and `fail` is the proportionate signal. Companion change:
|
|
1133
|
+
// `delegation-advisory.sh` joined `EXPECTED_HOOKS` in the same
|
|
1134
|
+
// commit, so `checkHooksInstalled` also covers the file-presence +
|
|
1135
|
+
// executability checks now (this function still does both directly
|
|
1136
|
+
// as defense-in-depth, mirroring `checkDelegationHookRegistered`).
|
|
1137
|
+
const REFUSE = 'fail';
|
|
1130
1138
|
const MATCHER = 'Bash|Edit|Write|MultiEdit|NotebookEdit';
|
|
1131
1139
|
const settingsPath = path.join(baseDir, '.claude', 'settings.json');
|
|
1132
1140
|
if (!fs.existsSync(settingsPath)) {
|
|
1133
1141
|
return {
|
|
1134
1142
|
label,
|
|
1135
|
-
status:
|
|
1136
|
-
detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init
|
|
1143
|
+
status: REFUSE,
|
|
1144
|
+
detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\``,
|
|
1137
1145
|
};
|
|
1138
1146
|
}
|
|
1139
1147
|
let parsed;
|
|
@@ -1143,7 +1151,7 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
|
|
|
1143
1151
|
catch (e) {
|
|
1144
1152
|
return {
|
|
1145
1153
|
label,
|
|
1146
|
-
status:
|
|
1154
|
+
status: REFUSE,
|
|
1147
1155
|
detail: e instanceof Error ? e.message : String(e),
|
|
1148
1156
|
};
|
|
1149
1157
|
}
|
|
@@ -1152,9 +1160,9 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
|
|
|
1152
1160
|
if (group === undefined) {
|
|
1153
1161
|
return {
|
|
1154
1162
|
label,
|
|
1155
|
-
status:
|
|
1163
|
+
status: REFUSE,
|
|
1156
1164
|
detail: `no PostToolUse group with matcher "${MATCHER}" found in .claude/settings.json — ` +
|
|
1157
|
-
'run `rea upgrade` to install
|
|
1165
|
+
'run `rea upgrade` to install. ' +
|
|
1158
1166
|
'NOTE: the matcher INCLUDES Bash — the delegation nudge counts every write-class ' +
|
|
1159
1167
|
'tool call, not just file edits.',
|
|
1160
1168
|
};
|
|
@@ -1163,16 +1171,16 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
|
|
|
1163
1171
|
if (!cmds.some((c) => c.includes('delegation-advisory.sh'))) {
|
|
1164
1172
|
return {
|
|
1165
1173
|
label,
|
|
1166
|
-
status:
|
|
1174
|
+
status: REFUSE,
|
|
1167
1175
|
detail: `${MATCHER} matcher exists but no delegation-advisory.sh command found in its hooks list`,
|
|
1168
1176
|
};
|
|
1169
1177
|
}
|
|
1170
|
-
//
|
|
1171
|
-
// hook file it points at actually exists AND is
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1178
|
+
// 0.31.0 round-2/3 P2: the registration string is present — now
|
|
1179
|
+
// confirm the hook file it points at actually exists AND is
|
|
1180
|
+
// executable. Kept after the 0.36.0 EXPECTED_HOOKS promotion as
|
|
1181
|
+
// defense-in-depth (the same `0o111` check `checkHooksInstalled`
|
|
1182
|
+
// does, scoped to this hook so the failure message can name the
|
|
1183
|
+
// exact remediation rather than a generic "missing X" enumeration).
|
|
1176
1184
|
const hookFile = path.join(baseDir, '.claude', 'hooks', 'delegation-advisory.sh');
|
|
1177
1185
|
let hookStat;
|
|
1178
1186
|
try {
|
|
@@ -1181,19 +1189,19 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
|
|
|
1181
1189
|
catch {
|
|
1182
1190
|
return {
|
|
1183
1191
|
label,
|
|
1184
|
-
status:
|
|
1192
|
+
status: REFUSE,
|
|
1185
1193
|
detail: `${MATCHER} matcher references delegation-advisory.sh but the hook file is missing: ` +
|
|
1186
|
-
`${hookFile} — run \`rea upgrade\` to lay it down
|
|
1194
|
+
`${hookFile} — run \`rea upgrade\` to lay it down. ` +
|
|
1187
1195
|
'Without the file every matching PostToolUse dispatch shells out to a nonexistent path.',
|
|
1188
1196
|
};
|
|
1189
1197
|
}
|
|
1190
1198
|
if ((hookStat.mode & 0o111) === 0) {
|
|
1191
1199
|
return {
|
|
1192
1200
|
label,
|
|
1193
|
-
status:
|
|
1201
|
+
status: REFUSE,
|
|
1194
1202
|
detail: `${MATCHER} matcher references delegation-advisory.sh but the hook file is not executable ` +
|
|
1195
1203
|
`(mode=${(hookStat.mode & 0o777).toString(8)}): ${hookFile} — ` +
|
|
1196
|
-
'run `rea upgrade` or `chmod +x` it
|
|
1204
|
+
'run `rea upgrade` or `chmod +x` it. ' +
|
|
1197
1205
|
'A non-executable hook cannot be launched by Claude Code from settings.json.',
|
|
1198
1206
|
};
|
|
1199
1207
|
}
|
|
@@ -1469,9 +1477,10 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
|
|
|
1469
1477
|
checkDelegationHookRegistered(baseDir),
|
|
1470
1478
|
// 0.31.0 — delegation-telemetry completion. The PostToolUse
|
|
1471
1479
|
// `Bash|Edit|Write|MultiEdit|NotebookEdit` matcher group drives
|
|
1472
|
-
// the delegation-advisory nudge hook.
|
|
1473
|
-
//
|
|
1474
|
-
// through in 0.29.0.
|
|
1480
|
+
// the delegation-advisory nudge hook. 0.36.0 — promoted warn →
|
|
1481
|
+
// fail (same upgrade-lag ratchet checkDelegationHookRegistered
|
|
1482
|
+
// went through in 0.29.0 → 0.30.0, after 4 release cycles of
|
|
1483
|
+
// propagation).
|
|
1475
1484
|
checkDelegationAdvisoryHookRegistered(baseDir),
|
|
1476
1485
|
];
|
|
1477
1486
|
// Non-git escape hatch: when `.git/` is absent, both git-hook checks are
|
package/dist/cli/hook.js
CHANGED
|
@@ -46,6 +46,10 @@ import { runHookArchitectureReviewGate } from '../hooks/architecture-review-gate
|
|
|
46
46
|
import { runHookDangerousBashInterceptor } from '../hooks/dangerous-bash-interceptor/index.js';
|
|
47
47
|
import { runHookLocalReviewGate } from '../hooks/local-review-gate/index.js';
|
|
48
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';
|
|
49
53
|
import { loadPolicy } from '../policy/loader.js';
|
|
50
54
|
import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
|
|
51
55
|
import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
|
|
@@ -1022,6 +1026,30 @@ export function registerHookCommand(program) {
|
|
|
1022
1026
|
.action(async () => {
|
|
1023
1027
|
await runHookSecretScanner();
|
|
1024
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
|
+
});
|
|
1025
1053
|
hook
|
|
1026
1054
|
.command('policy-get')
|
|
1027
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
|