@bookedsolid/rea 0.13.0 → 0.13.2

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/.husky/pre-push CHANGED
@@ -37,40 +37,47 @@ fi
37
37
  # splitting; `/Users/jane/My Projects/repo` produced four argv tokens
38
38
  # instead of two). The `set --` form below preserves spaces verbatim.
39
39
  #
40
- # The pre-push stdin carries one line per refspec; `exec` inherits stdin
41
- # unchanged. $@ on entry carries git's <remote-name> <remote-url>; we
42
- # preserve those by appending "$@" inside each `set --` arm.
40
+ # 0.13.2 fix: the dispatch + invocation runs inside a SUBSHELL so the
41
+ # `set --` rewrite does NOT bleed into the parent's $@. Before 0.13.2,
42
+ # the parent's $@ ended up as the rea-CLI argv (`node /path/dist/cli/index.js
43
+ # hook push-gate <remote-name> <remote-url>`) and the extension-fragment
44
+ # loop below passed that mangled argv to fragments instead of git's
45
+ # original `<remote-name> <remote-url>`. The subshell scopes the rewrite
46
+ # so fragments see git's argv unchanged.
47
+ #
48
+ # The pre-push stdin carries one line per refspec; the subshell inherits
49
+ # stdin unchanged. $@ on entry carries git's <remote-name> <remote-url>;
50
+ # the subshell sees those as its initial $@, appends them inside each
51
+ # `set --` arm, and the parent's $@ is preserved.
43
52
 
44
- if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
45
- set -- "${REA_ROOT}/node_modules/.bin/rea" hook push-gate "$@"
46
- elif [ -f "${REA_ROOT}/dist/cli/index.js" ] && [ -f "${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
47
- # rea's own repo (dogfood) the package is not installed under
48
- # node_modules here because we ARE the package. The built CLI
49
- # entry point lives at dist/cli/index.js; node runs it directly.
50
- # Gate this branch on `package.json` declaring `@bookedsolid/rea` so a
51
- # consumer repo that happens to ship its own `dist/cli/index.js` does
52
- # not get this hook executing the consumer's unrelated build.
53
- set -- node "${REA_ROOT}/dist/cli/index.js" hook push-gate "$@"
54
- elif command -v rea >/dev/null 2>&1; then
55
- set -- rea hook push-gate "$@"
56
- elif command -v npx >/dev/null 2>&1; then
57
- # Last resort: npx will resolve the package from npm or the cache.
58
- # Pass `--no-install` so a rare cache-cold machine surfaces a clear
59
- # error instead of silently downloading at push time.
60
- set -- npx --no-install @bookedsolid/rea hook push-gate "$@"
53
+ if (
54
+ if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
55
+ set -- "${REA_ROOT}/node_modules/.bin/rea" hook push-gate "$@"
56
+ elif [ -f "${REA_ROOT}/dist/cli/index.js" ] && [ -f "${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
57
+ # rea's own repo (dogfood) the package is not installed under
58
+ # node_modules here because we ARE the package. The built CLI
59
+ # entry point lives at dist/cli/index.js; node runs it directly.
60
+ # Gate this branch on `package.json` declaring `@bookedsolid/rea` so a
61
+ # consumer repo that happens to ship its own `dist/cli/index.js` does
62
+ # not get this hook executing the consumer's unrelated build.
63
+ set -- node "${REA_ROOT}/dist/cli/index.js" hook push-gate "$@"
64
+ elif command -v rea >/dev/null 2>&1; then
65
+ set -- rea hook push-gate "$@"
66
+ elif command -v npx >/dev/null 2>&1; then
67
+ # Last resort: npx will resolve the package from npm or the cache.
68
+ # Pass `--no-install` so a rare cache-cold machine surfaces a clear
69
+ # error instead of silently downloading at push time.
70
+ set -- npx --no-install @bookedsolid/rea hook push-gate "$@"
71
+ else
72
+ printf 'rea: cannot locate the rea CLI. Install locally (`pnpm add -D @bookedsolid/rea`) or globally (`npm i -g @bookedsolid/rea`).\n' >&2
73
+ exit 2
74
+ fi
75
+ exec "$@"
76
+ ); then
77
+ rea_status=0
61
78
  else
62
- printf 'rea: cannot locate the rea CLI. Install locally (`pnpm add -D @bookedsolid/rea`) or globally (`npm i -g @bookedsolid/rea`).\n' >&2
63
- exit 2
79
+ rea_status=$?
64
80
  fi
65
-
66
- # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
67
- # instead of `exec`-ing — extension fragments must only run after rea's own
68
- # governance work succeeds. The fragments are user code; surfacing them
69
- # AFTER rea's body (HALT check, Codex review, audit write) preserves the
70
- # governance contract while letting consumers chain their own checks (e.g.
71
- # commitlint, branch-policy linters) without losing rea coverage.
72
- "$@"
73
- rea_status=$?
74
81
  if [ "$rea_status" -ne 0 ]; then
75
82
  exit "$rea_status"
76
83
  fi
@@ -374,14 +374,30 @@ function checkPrePushHook(state) {
374
374
  if (state.activeForeign) {
375
375
  // Executable file exists at the active path but neither carries a rea
376
376
  // marker nor invokes `rea hook push-gate` — the push-gate is silently
377
- // bypassed. Always a hard fail.
377
+ // bypassed. Always a hard fail. When the foreign hook references a
378
+ // recognizable prior tool (commitlint, lint-staged, gitleaks, act-CI,
379
+ // …), surface the .d/ migration path explicitly so consumers know
380
+ // exactly how to keep their existing chain without losing rea coverage
381
+ // or having `rea upgrade` clobber them again.
382
+ const hints = state.activePath !== null
383
+ ? detectPriorToolHints(state.activePath)
384
+ : [];
385
+ let detail = `active pre-push at ${state.activePath} is present and executable but does NOT ` +
386
+ 'invoke `rea hook push-gate` — the 0.11.0 push-gate is silently bypassed. ' +
387
+ 'Either add `exec rea hook push-gate "$@"` to the existing hook, or ' +
388
+ 'remove it and re-run `rea init` to install the fallback.';
389
+ if (hints.length > 0) {
390
+ detail +=
391
+ `\n Detected prior tooling in the foreign hook: ${hints.join(', ')}. ` +
392
+ 'Recommended migration (rea 0.13.0+): move each chained command to ' +
393
+ '`.husky/pre-push.d/<NN>-<name>` as a separate executable file; rea then ' +
394
+ 'runs them in lex order AFTER the push-gate, surviving `rea upgrade` ' +
395
+ 'unchanged. See `MIGRATING.md` for a worked example.';
396
+ }
378
397
  return {
379
398
  label: 'pre-push hook installed',
380
399
  status: 'fail',
381
- detail: `active pre-push at ${state.activePath} is present and executable but does NOT ` +
382
- 'invoke `rea hook push-gate` — the 0.11.0 push-gate is silently bypassed. ' +
383
- 'Either add `exec rea hook push-gate "$@"` to the existing hook, or ' +
384
- 'remove it and re-run `rea init` to install the fallback.',
400
+ detail,
385
401
  };
386
402
  }
387
403
  const present = state.candidates
@@ -403,6 +419,47 @@ function checkPrePushHook(state) {
403
419
  'Run `rea init` to install the fallback.',
404
420
  };
405
421
  }
422
+ /**
423
+ * Best-effort scan of a foreign hook body for references to recognizable
424
+ * consumer tooling. Each match returns a short label so doctor can render
425
+ * a precise migration recommendation without dumping the raw hook body.
426
+ *
427
+ * Patterns are intentionally narrow: a substring match in a non-comment line
428
+ * referencing a tool's CLI binary or a well-known wrapper. False positives
429
+ * here are cosmetic (extra hint) — the hard-fail decision still drives the
430
+ * doctor verdict.
431
+ *
432
+ * Read errors are swallowed (return []). Doctor's foreign-hook message is
433
+ * still useful without hints.
434
+ */
435
+ function detectPriorToolHints(hookPath) {
436
+ let body;
437
+ try {
438
+ body = fs.readFileSync(hookPath, 'utf8');
439
+ }
440
+ catch {
441
+ return [];
442
+ }
443
+ const lines = body.split(/\r?\n/);
444
+ const found = new Set();
445
+ for (const raw of lines) {
446
+ if (/^\s*#/.test(raw))
447
+ continue; // skip comments
448
+ if (/\bcommitlint\b/.test(raw))
449
+ found.add('commitlint');
450
+ if (/\blint-staged\b/.test(raw))
451
+ found.add('lint-staged');
452
+ if (/\bgitleaks\b/.test(raw))
453
+ found.add('gitleaks');
454
+ if (/\bact[-_]ci\b/i.test(raw) || /\bact-CI\b/.test(raw))
455
+ found.add('act-CI');
456
+ if (/\bhusky\.sh\b/.test(raw))
457
+ found.add('legacy husky 4-8 wrapper');
458
+ if (/\bnpx\s+--no-install\s+commitlint/.test(raw))
459
+ found.add('commitlint');
460
+ }
461
+ return Array.from(found).sort();
462
+ }
406
463
  /**
407
464
  * Detect and list extension-hook fragments under `.husky/commit-msg.d/` and
408
465
  * `.husky/pre-push.d/`. Informational only — fragments are an opt-in feature
@@ -147,6 +147,39 @@ export declare function isLegacyReaManagedHuskyGate(content: string): boolean;
147
147
  * — we don't overwrite consumer-authored hooks that respect the gate.
148
148
  */
149
149
  export declare function referencesReviewGate(content: string): boolean;
150
+ /**
151
+ * True when `content` is a husky 9 auto-generated stub that delegates to
152
+ * the canonical hook body via `.husky/_/h`.
153
+ *
154
+ * Husky 9 layout when `core.hooksPath=.husky/_`:
155
+ * .husky/<hookname> — user/rea-authored body (committed, source of truth)
156
+ * .husky/_/<hookname> — auto-generated stub git actually fires
157
+ * .husky/_/h — runner that exec's `.husky/<hookname>`
158
+ *
159
+ * The stub body is a single non-comment line, e.g.
160
+ * . "${0%/*}/h"
161
+ * . "$(dirname -- "$0")/h"
162
+ *
163
+ * Without this detection `rea doctor` would classify `.husky/_/pre-push`
164
+ * as foreign (no rea marker, no `rea hook push-gate` reference) even
165
+ * though the hook git fires sources the canonical body that DOES carry
166
+ * governance. The stub is the husky 9 indirection contract — treating it
167
+ * as the active hook and following the source chain to the parent hook
168
+ * resolves the false positive.
169
+ *
170
+ * Detection is conservative: any non-comment, non-blank, non-source line
171
+ * disqualifies. We anchor on `$0` in the dirname expansion to confirm the
172
+ * stub references self, and on the trailing `/h` segment to confirm it
173
+ * sources the husky 9 runner. A user-authored shell script that happens
174
+ * to dot-source a file does NOT match.
175
+ */
176
+ export declare function isHusky9Stub(content: string): boolean;
177
+ /**
178
+ * Resolve a husky 9 stub at `<dir>/<hookname>` to the canonical hook
179
+ * body at `<parent-of-dir>/<hookname>`. Returns null when the stub
180
+ * lives at the filesystem root (no parent to walk to).
181
+ */
182
+ export declare function resolveHusky9StubTarget(stubPath: string): string | null;
150
183
  export declare function resolveHooksDir(targetDir: string): Promise<{
151
184
  dir: string | null;
152
185
  configured: boolean;
@@ -167,7 +200,9 @@ export type ClassifyExistingHook = {
167
200
  kind: 'foreign';
168
201
  reason: string;
169
202
  };
170
- export declare function classifyExistingHook(hookPath: string): Promise<ClassifyExistingHook>;
203
+ export declare function classifyExistingHook(hookPath: string, options?: {
204
+ followHusky9Stub?: boolean;
205
+ }): Promise<ClassifyExistingHook>;
171
206
  export type InstallDecision = {
172
207
  action: 'skip';
173
208
  reason: 'active-pre-push-present';
@@ -148,40 +148,47 @@ fi
148
148
  # splitting; \`/Users/jane/My Projects/repo\` produced four argv tokens
149
149
  # instead of two). The \`set --\` form below preserves spaces verbatim.
150
150
  #
151
- # The pre-push stdin carries one line per refspec; \`exec\` inherits stdin
152
- # unchanged. \$@ on entry carries git's <remote-name> <remote-url>; we
153
- # preserve those by appending "\$@" inside each \`set --\` arm.
151
+ # 0.13.2 fix: the dispatch + invocation runs inside a SUBSHELL so the
152
+ # \`set --\` rewrite does NOT bleed into the parent's \$@. Before 0.13.2,
153
+ # the parent's \$@ ended up as the rea-CLI argv (\`node /path/dist/cli/index.js
154
+ # hook push-gate <remote-name> <remote-url>\`) and the extension-fragment
155
+ # loop below passed that mangled argv to fragments instead of git's
156
+ # original \`<remote-name> <remote-url>\`. The subshell scopes the rewrite
157
+ # so fragments see git's argv unchanged.
158
+ #
159
+ # The pre-push stdin carries one line per refspec; the subshell inherits
160
+ # stdin unchanged. \$@ on entry carries git's <remote-name> <remote-url>;
161
+ # the subshell sees those as its initial \$@, appends them inside each
162
+ # \`set --\` arm, and the parent's \$@ is preserved.
154
163
 
155
- if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
156
- set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
157
- elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
158
- # rea's own repo (dogfood) the package is not installed under
159
- # node_modules here because we ARE the package. The built CLI
160
- # entry point lives at dist/cli/index.js; node runs it directly.
161
- # Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
162
- # consumer repo that happens to ship its own \`dist/cli/index.js\` does
163
- # not get this hook executing the consumer's unrelated build.
164
- set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
165
- elif command -v rea >/dev/null 2>&1; then
166
- set -- rea hook push-gate "\$@"
167
- elif command -v npx >/dev/null 2>&1; then
168
- # Last resort: npx will resolve the package from npm or the cache.
169
- # Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
170
- # error instead of silently downloading at push time.
171
- set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
164
+ if (
165
+ if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
166
+ set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
167
+ elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
168
+ # rea's own repo (dogfood) the package is not installed under
169
+ # node_modules here because we ARE the package. The built CLI
170
+ # entry point lives at dist/cli/index.js; node runs it directly.
171
+ # Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
172
+ # consumer repo that happens to ship its own \`dist/cli/index.js\` does
173
+ # not get this hook executing the consumer's unrelated build.
174
+ set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
175
+ elif command -v rea >/dev/null 2>&1; then
176
+ set -- rea hook push-gate "\$@"
177
+ elif command -v npx >/dev/null 2>&1; then
178
+ # Last resort: npx will resolve the package from npm or the cache.
179
+ # Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
180
+ # error instead of silently downloading at push time.
181
+ set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
182
+ else
183
+ printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
184
+ exit 2
185
+ fi
186
+ exec "\$@"
187
+ ); then
188
+ rea_status=0
172
189
  else
173
- printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
174
- exit 2
190
+ rea_status=\$?
175
191
  fi
176
-
177
- # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
178
- # instead of \`exec\`-ing — extension fragments must only run after rea's own
179
- # governance work succeeds. The fragments are user code; surfacing them
180
- # AFTER rea's body (HALT check, Codex review, audit write) preserves the
181
- # governance contract while letting consumers chain their own checks (e.g.
182
- # commitlint, branch-policy linters) without losing rea coverage.
183
- "\$@"
184
- rea_status=\$?
185
192
  if [ "\$rea_status" -ne 0 ]; then
186
193
  exit "\$rea_status"
187
194
  fi
@@ -349,6 +356,71 @@ export function referencesReviewGate(content) {
349
356
  }
350
357
  return false;
351
358
  }
359
+ /**
360
+ * True when `content` is a husky 9 auto-generated stub that delegates to
361
+ * the canonical hook body via `.husky/_/h`.
362
+ *
363
+ * Husky 9 layout when `core.hooksPath=.husky/_`:
364
+ * .husky/<hookname> — user/rea-authored body (committed, source of truth)
365
+ * .husky/_/<hookname> — auto-generated stub git actually fires
366
+ * .husky/_/h — runner that exec's `.husky/<hookname>`
367
+ *
368
+ * The stub body is a single non-comment line, e.g.
369
+ * . "${0%/*}/h"
370
+ * . "$(dirname -- "$0")/h"
371
+ *
372
+ * Without this detection `rea doctor` would classify `.husky/_/pre-push`
373
+ * as foreign (no rea marker, no `rea hook push-gate` reference) even
374
+ * though the hook git fires sources the canonical body that DOES carry
375
+ * governance. The stub is the husky 9 indirection contract — treating it
376
+ * as the active hook and following the source chain to the parent hook
377
+ * resolves the false positive.
378
+ *
379
+ * Detection is conservative: any non-comment, non-blank, non-source line
380
+ * disqualifies. We anchor on `$0` in the dirname expansion to confirm the
381
+ * stub references self, and on the trailing `/h` segment to confirm it
382
+ * sources the husky 9 runner. A user-authored shell script that happens
383
+ * to dot-source a file does NOT match.
384
+ */
385
+ export function isHusky9Stub(content) {
386
+ const lines = content.split(/\r?\n/);
387
+ // Match a `.` (source) command whose argument is the husky 9 runner `h`
388
+ // resolved relative to the stub's own location. The two shapes husky 9
389
+ // generates:
390
+ // . "${0%/*}/h" — POSIX param expansion (current default)
391
+ // . "$(dirname -- "$0")/h" — older variant still in the wild
392
+ // We do NOT accept arbitrary `. <path>/h` because that would false-match
393
+ // a user-authored hook that happens to source a file named `h`.
394
+ const sourceLine = /^\.\s+(?:"\$\{0%\/\*\}\/h"|"\$\(dirname[^)]*\$0[^)]*\)\/h")\s*$/;
395
+ let sawSource = false;
396
+ for (const rawLine of lines) {
397
+ const line = rawLine.trim();
398
+ if (line === '')
399
+ continue;
400
+ if (line.startsWith('#'))
401
+ continue; // shebang, comment
402
+ if (sawSource)
403
+ return false; // any line after the source line disqualifies
404
+ if (sourceLine.test(line)) {
405
+ sawSource = true;
406
+ continue;
407
+ }
408
+ return false;
409
+ }
410
+ return sawSource;
411
+ }
412
+ /**
413
+ * Resolve a husky 9 stub at `<dir>/<hookname>` to the canonical hook
414
+ * body at `<parent-of-dir>/<hookname>`. Returns null when the stub
415
+ * lives at the filesystem root (no parent to walk to).
416
+ */
417
+ export function resolveHusky9StubTarget(stubPath) {
418
+ const dir = path.dirname(stubPath);
419
+ const parent = path.dirname(dir);
420
+ if (parent === dir)
421
+ return null;
422
+ return path.join(parent, path.basename(stubPath));
423
+ }
352
424
  // ---------------------------------------------------------------------------
353
425
  // Hook resolution
354
426
  // ---------------------------------------------------------------------------
@@ -408,7 +480,8 @@ async function resolveTargetHookPath(targetDir) {
408
480
  hooksPathConfigured: false,
409
481
  };
410
482
  }
411
- export async function classifyExistingHook(hookPath) {
483
+ export async function classifyExistingHook(hookPath, options = {}) {
484
+ const followStub = options.followHusky9Stub ?? true;
412
485
  let stat;
413
486
  try {
414
487
  stat = await fsPromises.lstat(hookPath);
@@ -442,6 +515,17 @@ export async function classifyExistingHook(hookPath) {
442
515
  return { kind: 'rea-managed-legacy-v1' };
443
516
  if (referencesReviewGate(content))
444
517
  return { kind: 'gate-delegating' };
518
+ // Husky 9 indirection: when git fires `.husky/_/<hookname>` (auto-generated
519
+ // stub) the body just sources `.husky/_/h` which exec's the canonical
520
+ // `.husky/<hookname>`. Without this branch, doctor false-positives the stub
521
+ // as foreign even though governance is intact via the source chain.
522
+ // One level of follow only — never recurse stubs-of-stubs.
523
+ if (followStub && isHusky9Stub(content)) {
524
+ const target = resolveHusky9StubTarget(hookPath);
525
+ if (target !== null) {
526
+ return classifyExistingHook(target, { followHusky9Stub: false });
527
+ }
528
+ }
445
529
  return { kind: 'foreign', reason: 'no-marker' };
446
530
  }
447
531
  export async function classifyPrePushInstall(targetDir) {
@@ -130,6 +130,59 @@ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
130
130
  exit 2
131
131
  fi
132
132
 
133
+ # Compute lower-cased path early so the §5b allow-list (and §6/§6b matchers
134
+ # below) all reference a single normalized variable.
135
+ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
136
+
137
+ # ── 5b. Extension-surface allow-list ──────────────────────────────────────────
138
+ # `.husky/commit-msg.d/*` and `.husky/pre-push.d/*` are the documented
139
+ # consumer extension surface (Fix H / 0.13.0). Consumers — and the agents
140
+ # that govern those consumers — are expected to write here freely so they
141
+ # can layer commitlint, lint-staged, branch-policy, act-CI, etc. without
142
+ # losing rea coverage on `rea upgrade`.
143
+ #
144
+ # The §6 PROTECTED_PATTERNS list below has `.husky/` as a prefix block,
145
+ # which (correctly) keeps `.husky/pre-push`, `.husky/commit-msg`, and
146
+ # the `.husky/_/*` runtime stubs out of agent reach. But the same prefix
147
+ # also caught `.husky/pre-push.d/00-act-ci` and `.husky/commit-msg.d/*`
148
+ # until 0.13.2 — the very directories advertised as the extension
149
+ # surface. This early allow-list closes that contract gap.
150
+ #
151
+ # Anchored on the literal `.d/` segment (not `.d`) so `.husky/pre-push.d.bak/`
152
+ # or `.husky/pre-push.dump` still hit the prefix block. Nested fragments
153
+ # (e.g. `pre-push.d/sub/file`) are allowed so the surface composes naturally.
154
+ #
155
+ # SECURITY: runs AFTER §5a (path-traversal reject), so a clever
156
+ # `.husky/pre-push.d/../pre-push` cannot bypass §6's protection of the
157
+ # package-managed body — §5a kills it before this matcher runs.
158
+ #
159
+ # SECURITY (defense-in-depth): symlinks INSIDE the .d/ surface are
160
+ # refused. A fragment is a short shell script authored in place;
161
+ # consumers do not need symlinks here. Without this check, a sequence
162
+ # like `ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil`
163
+ # would be allowed by §5b's path-string match and the downstream
164
+ # Write/Edit tool would follow the symlink, overwriting the
165
+ # package-managed `.husky/pre-push` body that §6 is meant to protect.
166
+ # Costs near-zero (no legitimate use case for symlinked fragments);
167
+ # closes the path-string→symlink bypass completely.
168
+ case "$LOWER_NORM" in
169
+ .husky/commit-msg.d/*|.husky/pre-push.d/*)
170
+ if [ -L "$FILE_PATH" ]; then
171
+ {
172
+ printf 'SETTINGS PROTECTION: symlink in extension surface refused\n'
173
+ printf '\n'
174
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
175
+ printf ' Rule: .husky/commit-msg.d/* and .husky/pre-push.d/* must be\n'
176
+ printf ' regular files (a symlink could resolve to a protected\n'
177
+ printf ' package-managed body and bypass §6 protection).\n'
178
+ } >&2
179
+ exit 2
180
+ fi
181
+ # Documented extension surface — agents can write here freely.
182
+ exit 0
183
+ ;;
184
+ esac
185
+
133
186
  # ── 6. Protected path patterns ────────────────────────────────────────────────
134
187
  # §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
135
188
  # reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
@@ -149,7 +202,7 @@ PATCH_SESSION_PATTERNS=(
149
202
  '.claude/hooks/'
150
203
  )
151
204
 
152
- LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
205
+ # LOWER_NORM was computed in §5b above and is reused here.
153
206
 
154
207
  # Match $NORMALIZED against PROTECTED_PATTERNS (exact or prefix for patterns
155
208
  # ending in '/'). Sets $PROTECTED_MATCH to the matched pattern; exit 0 on hit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",