@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 +38 -31
- package/dist/cli/doctor.js +62 -5
- package/dist/cli/install/pre-push.d.ts +36 -1
- package/dist/cli/install/pre-push.js +116 -32
- package/hooks/settings-protection.sh +54 -1
- package/package.json +1 -1
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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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)",
|