@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.
@@ -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.31.0 — `delegation-advisory.sh` is INTENTIONALLY NOT in this list.
173
- // It is the brand-new PostToolUse delegation-nudge hook this release
174
- // ships; adding it to EXPECTED_HOOKS would make `checkHooksInstalled`
175
- // hard-`fail` on every pre-0.31.0 consumer install (and on this repo's
176
- // own dogfood) the instant they upgrade the rea binary but before they
177
- // run `rea upgrade` to lay down the new hook file — a regression that
178
- // turns a green doctor red purely from upgrade lag. This mirrors the
179
- // staged rollout `delegation-capture.sh` itself used: the file-presence
180
- // entry joins EXPECTED_HOOKS in the SAME future minor that promotes
181
- // `checkDelegationAdvisoryHookRegistered` from `warn` to `fail`, once
182
- // consumers have had upgrade-lag time. Until then, a missing
183
- // `delegation-advisory.sh` surfaces only through that `warn`-tier
184
- // registration check — proportionate, since the hook is advisory at
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
- const ADVISORY = 'warn';
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: ADVISORY,
1136
- detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\` (advisory in 0.31.0)`,
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: ADVISORY,
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: ADVISORY,
1163
+ status: REFUSE,
1156
1164
  detail: `no PostToolUse group with matcher "${MATCHER}" found in .claude/settings.json — ` +
1157
- 'run `rea upgrade` to install (advisory in 0.31.0; promoted to fail in a later minor). ' +
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: ADVISORY,
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
- // Round-2/3 P2: the registration string is present — now confirm the
1171
- // hook file it points at actually exists AND is executable. Because
1172
- // `delegation-advisory.sh` is intentionally out of `EXPECTED_HOOKS`
1173
- // for 0.31.0, `checkHooksInstalled` does NOT cover it; this is the
1174
- // only signal, so it owns both the presence and the `0o111` checks
1175
- // that `checkHooksInstalled` does for every other shipped hook.
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: ADVISORY,
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 (advisory in 0.31.0). ` +
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: ADVISORY,
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 (advisory in 0.31.0). ' +
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. Advisory (warn) for 0.31.0
1473
- // same upgrade-lag ratchet checkDelegationHookRegistered went
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}, 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;