@bookedsolid/rea 0.31.0 → 0.32.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/.husky/prepare-commit-msg +80 -6
- package/MIGRATING.md +24 -15
- package/dist/cli/hook.js +32 -22
- package/dist/hooks/_lib/halt-check.d.ts +78 -0
- package/dist/hooks/_lib/halt-check.js +106 -0
- package/dist/hooks/_lib/payload.d.ts +86 -0
- package/dist/hooks/_lib/payload.js +166 -0
- package/dist/hooks/_lib/segments.d.ts +100 -0
- package/dist/hooks/_lib/segments.js +444 -0
- package/dist/hooks/attribution-advisory/index.d.ts +72 -0
- package/dist/hooks/attribution-advisory/index.js +233 -0
- package/dist/hooks/bash-scanner/protected-scan.js +14 -2
- package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
- package/dist/hooks/pr-issue-link-gate/index.js +127 -0
- package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
- package/dist/hooks/security-disclosure-gate/index.js +502 -0
- package/hooks/_lib/protected-paths.sh +10 -3
- package/hooks/attribution-advisory.sh +139 -131
- package/hooks/pr-issue-link-gate.sh +114 -45
- package/hooks/security-disclosure-gate.sh +148 -316
- package/hooks/settings-protection.sh +13 -9
- package/package.json +1 -1
- package/templates/attribution-advisory.dogfood-staged.sh +170 -0
- package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
- package/templates/prepare-commit-msg.husky.sh +80 -6
- package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
- package/templates/settings-protection.dogfood.patch +58 -0
|
@@ -36,28 +36,65 @@ set -u
|
|
|
36
36
|
COMMIT_MSG_FILE="${1:-}"
|
|
37
37
|
COMMIT_SOURCE="${2:-}"
|
|
38
38
|
|
|
39
|
+
REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
40
|
+
|
|
41
|
+
# Forward declaration — the extension-chain runner is defined further
|
|
42
|
+
# down (after $REA_ROOT is set so the dir lookup is anchored). We call
|
|
43
|
+
# it from every "augmenter skipped" exit point so consumer fragments
|
|
44
|
+
# under .husky/prepare-commit-msg.d/* run regardless of whether rea's
|
|
45
|
+
# own augmenter ran. The function fires fragments in lex order,
|
|
46
|
+
# logs-and-continues on non-zero exits, and is a no-op if the dir is
|
|
47
|
+
# absent or empty.
|
|
48
|
+
#
|
|
49
|
+
# 0.32.0 Phase 3: the pre-0.32.0 layout exited early at every
|
|
50
|
+
# precondition gate, which made the extension surface unreachable
|
|
51
|
+
# when (a) attribution was disabled, (b) HALT was active, or (c)
|
|
52
|
+
# REA_SKIP_ATTRIBUTION was set. The new layout runs the chain at the
|
|
53
|
+
# end of every exit path EXCEPT when the message file itself is
|
|
54
|
+
# missing/unparseable (no point running fragments against a path that
|
|
55
|
+
# doesn't exist).
|
|
56
|
+
run_extension_chain() {
|
|
57
|
+
ext_dir="${REA_ROOT}/.husky/prepare-commit-msg.d"
|
|
58
|
+
if [ -d "$ext_dir" ]; then
|
|
59
|
+
for frag in "$ext_dir"/*; do
|
|
60
|
+
[ -e "$frag" ] || continue
|
|
61
|
+
[ -f "$frag" ] || continue
|
|
62
|
+
[ -x "$frag" ] || continue
|
|
63
|
+
if ! "$frag" "$COMMIT_MSG_FILE" "$COMMIT_SOURCE"; then
|
|
64
|
+
printf 'rea: prepare-commit-msg.d fragment exited non-zero: %s (continuing)\n' \
|
|
65
|
+
"$(basename "$frag")" >&2
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
fi
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
# Skip conditions: any missing precondition exits 0 silently. The hook
|
|
40
72
|
# is purely additive; refusing here would break commits with no upside.
|
|
41
73
|
|
|
42
|
-
# Missing message file → nothing to augment
|
|
74
|
+
# Missing message file → nothing to augment AND nothing for fragments
|
|
75
|
+
# to act on either. Exit immediately without running the chain.
|
|
43
76
|
if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
|
|
44
77
|
exit 0
|
|
45
78
|
fi
|
|
46
79
|
|
|
47
|
-
# Per-invocation override
|
|
80
|
+
# Per-invocation override — skip the augmenter, but still run consumer
|
|
81
|
+
# fragments. The flag is named REA_SKIP_ATTRIBUTION, not REA_SKIP_HOOK,
|
|
82
|
+
# precisely so the rest of the chain runs.
|
|
48
83
|
if [ -n "${REA_SKIP_ATTRIBUTION:-}" ]; then
|
|
84
|
+
run_extension_chain
|
|
49
85
|
exit 0
|
|
50
86
|
fi
|
|
51
87
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
#
|
|
88
|
+
# HALT kill switch — refuse to mutate anything while frozen. The
|
|
89
|
+
# extension chain is also skipped under HALT: a frozen system means
|
|
90
|
+
# "no agent-side actions" and consumer fragments are agent-side too.
|
|
55
91
|
if [ -f "${REA_ROOT}/.rea/HALT" ]; then
|
|
56
92
|
exit 0
|
|
57
93
|
fi
|
|
58
94
|
|
|
59
95
|
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
60
96
|
if [ ! -f "$POLICY_FILE" ]; then
|
|
97
|
+
run_extension_chain
|
|
61
98
|
exit 0
|
|
62
99
|
fi
|
|
63
100
|
|
|
@@ -172,6 +209,7 @@ print(enabled); print(name); print(email); print(skip_merge)
|
|
|
172
209
|
PY
|
|
173
210
|
)
|
|
174
211
|
if [ -z "$CO_AUTHOR_PARSE" ]; then
|
|
212
|
+
run_extension_chain
|
|
175
213
|
exit 0
|
|
176
214
|
fi
|
|
177
215
|
ENABLED=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '1p')
|
|
@@ -179,11 +217,15 @@ PY
|
|
|
179
217
|
CO_EMAIL=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '3p')
|
|
180
218
|
SKIP_MERGE=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '4p')
|
|
181
219
|
else
|
|
182
|
-
# Neither rea CLI nor python3 reachable — silent no-op
|
|
220
|
+
# Neither rea CLI nor python3 reachable — silent no-op for the
|
|
221
|
+
# augmenter, but still run consumer fragments. The chain doesn't
|
|
222
|
+
# need policy values; it just runs `.husky/prepare-commit-msg.d/*`.
|
|
223
|
+
run_extension_chain
|
|
183
224
|
exit 0
|
|
184
225
|
fi
|
|
185
226
|
|
|
186
227
|
if [ "$ENABLED" != "true" ]; then
|
|
228
|
+
run_extension_chain
|
|
187
229
|
exit 0
|
|
188
230
|
fi
|
|
189
231
|
|
|
@@ -204,11 +246,13 @@ if [ -z "$CO_NAME" ] || [ -z "$CO_EMAIL" ]; then
|
|
|
204
246
|
"$([ -z "$CO_NAME" ] && [ -z "$CO_EMAIL" ] && printf '+')" \
|
|
205
247
|
"$([ -z "$CO_EMAIL" ] && printf email)" >&2
|
|
206
248
|
printf 'rea: edit .rea/policy.yaml — set name + email, OR set enabled: false.\n' >&2
|
|
249
|
+
run_extension_chain
|
|
207
250
|
exit 0
|
|
208
251
|
fi
|
|
209
252
|
|
|
210
253
|
# skip_merge: true → skip when commit source is 'merge'.
|
|
211
254
|
if [ "$SKIP_MERGE" = "true" ] && [ "$COMMIT_SOURCE" = "merge" ]; then
|
|
255
|
+
run_extension_chain
|
|
212
256
|
exit 0
|
|
213
257
|
fi
|
|
214
258
|
|
|
@@ -226,6 +270,7 @@ LOWER_EMAIL=$(printf '%s' "$CO_EMAIL" | tr '[:upper:]' '[:lower:]')
|
|
|
226
270
|
ESCAPED_EMAIL=$(printf '%s' "$LOWER_EMAIL" | sed 's/[.[\*^$(){}+?|]/\\&/g')
|
|
227
271
|
if grep -iE "^co-authored-by:[[:space:]]*[^<]*<${ESCAPED_EMAIL}>[[:space:]]*$" \
|
|
228
272
|
"$COMMIT_MSG_FILE" >/dev/null 2>&1; then
|
|
273
|
+
run_extension_chain
|
|
229
274
|
exit 0
|
|
230
275
|
fi
|
|
231
276
|
|
|
@@ -311,4 +356,33 @@ awk '
|
|
|
311
356
|
} > "${COMMIT_MSG_FILE}.rea-tmp" && mv "${COMMIT_MSG_FILE}.rea-tmp" "$COMMIT_MSG_FILE"
|
|
312
357
|
|
|
313
358
|
rm -f "$TMP_BODY_TRIMMED"
|
|
359
|
+
|
|
360
|
+
# ── Extension-hook chaining ───────────────────────────────────────────────────
|
|
361
|
+
# 0.32.0 — `.husky/prepare-commit-msg.d/*` extension surface mirrors
|
|
362
|
+
# the `.husky/commit-msg.d/*` and `.husky/pre-push.d/*` patterns from
|
|
363
|
+
# 0.13.0. Source every executable file under
|
|
364
|
+
# `.husky/prepare-commit-msg.d/` in lexical order. Missing directory
|
|
365
|
+
# is a no-op (backward compatible). Each fragment receives the same
|
|
366
|
+
# `$1` (commit message file path) and `$2` (commit source) that git
|
|
367
|
+
# delivered to this hook so consumers can layer on their own
|
|
368
|
+
# augmenters (lint-staged --on-prepare, branch-name-injection,
|
|
369
|
+
# ticket-reference-prepend, …) without losing rea coverage.
|
|
370
|
+
#
|
|
371
|
+
# Fragments run AFTER rea's attribution augmenter so the
|
|
372
|
+
# `Co-Authored-By` trailer is already in the file before any consumer
|
|
373
|
+
# fragment reads it; that lets a fragment reorder trailers, dedupe,
|
|
374
|
+
# or run its own template substitution against the augmented body.
|
|
375
|
+
#
|
|
376
|
+
# A non-zero exit from a fragment does NOT fail the commit — this
|
|
377
|
+
# hook is purely additive (its bash counterpart `commit-msg` is the
|
|
378
|
+
# blocking gate). We log the failure to stderr and continue so a
|
|
379
|
+
# broken consumer fragment can't take down `git commit`.
|
|
380
|
+
#
|
|
381
|
+
# The actual chain body lives in `run_extension_chain` (defined near
|
|
382
|
+
# the top of the file). The reason for the early definition: several
|
|
383
|
+
# augmenter-skip exit paths (enabled: false, missing identity, idempo-
|
|
384
|
+
# tency hit, skip_merge match) need to run the chain too, so consumer
|
|
385
|
+
# fragments fire regardless of whether rea's own augmenter activated.
|
|
386
|
+
run_extension_chain
|
|
387
|
+
|
|
314
388
|
exit 0
|
package/MIGRATING.md
CHANGED
|
@@ -78,13 +78,16 @@ are on the vanilla-git path — install husky first.
|
|
|
78
78
|
The only files rea touches are explicitly enumerated above. Everything
|
|
79
79
|
else is the consumer's surface.
|
|
80
80
|
|
|
81
|
-
## Extension surface (added in 0.13.0)
|
|
81
|
+
## Extension surface (added in 0.13.0; expanded in 0.32.0)
|
|
82
82
|
|
|
83
|
-
`.husky/pre-push.d
|
|
84
|
-
**upgrade-safe** place to
|
|
85
|
-
|
|
86
|
-
its own governance work succeeds.
|
|
87
|
-
fails the hook (matches husky's
|
|
83
|
+
`.husky/pre-push.d/*`, `.husky/commit-msg.d/*`, and (as of 0.32.0)
|
|
84
|
+
`.husky/prepare-commit-msg.d/*` are the **upgrade-safe** place to
|
|
85
|
+
layer your own gates. Files in those directories must be executable;
|
|
86
|
+
rea sources them in lex order AFTER its own governance work succeeds.
|
|
87
|
+
A non-zero exit from any fragment fails the hook (matches husky's
|
|
88
|
+
normal chaining) — EXCEPT for the `prepare-commit-msg.d/*` lane,
|
|
89
|
+
which logs and continues so a broken fragment can't take down `git
|
|
90
|
+
commit`.
|
|
88
91
|
|
|
89
92
|
- Fragment receives positional args from git (`<remote-name> <remote-url>`
|
|
90
93
|
for pre-push, `<commit-msg-file>` for commit-msg).
|
|
@@ -158,26 +161,32 @@ Two paths, depending on whether you intend to use the rea augmenter.
|
|
|
158
161
|
|
|
159
162
|
**Path A — you want the augmenter (Co-Authored-By trailer)**
|
|
160
163
|
|
|
161
|
-
Move your branch-prefix logic into
|
|
162
|
-
rea's prepare-commit-msg body
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
Move your branch-prefix logic into a `.husky/prepare-commit-msg.d/*`
|
|
165
|
+
fragment. As of **0.32.0** rea's prepare-commit-msg body sources every
|
|
166
|
+
executable file in `.husky/prepare-commit-msg.d/` in lexical order
|
|
167
|
+
AFTER its own attribution augmenter runs (mirrors the
|
|
168
|
+
`commit-msg.d/*` and `pre-push.d/*` extension surfaces from 0.13.0).
|
|
169
|
+
Each fragment receives the same `$1` (commit-message file path) and
|
|
170
|
+
`$2` (commit source) git delivered to the hook:
|
|
165
171
|
|
|
166
172
|
```bash
|
|
167
|
-
mkdir -p .husky/commit-msg.d
|
|
168
|
-
cat > .husky/commit-msg.d/00-branch-prefix <<'EOF'
|
|
173
|
+
mkdir -p .husky/prepare-commit-msg.d
|
|
174
|
+
cat > .husky/prepare-commit-msg.d/00-branch-prefix <<'EOF'
|
|
169
175
|
#!/bin/sh
|
|
170
|
-
#
|
|
171
|
-
# (runs AFTER rea's augmenter, before the commit is finalized).
|
|
176
|
+
# Runs AFTER rea's Co-Authored-By augmenter. $1 = commit-msg file.
|
|
172
177
|
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
|
|
173
178
|
case $(head -1 "$1") in
|
|
174
179
|
"[$BRANCH]"*) ;; # already prefixed
|
|
175
180
|
*) printf '[%s] %s' "$BRANCH" "$(cat "$1")" > "$1" ;;
|
|
176
181
|
esac
|
|
177
182
|
EOF
|
|
178
|
-
chmod +x .husky/commit-msg.d/00-branch-prefix
|
|
183
|
+
chmod +x .husky/prepare-commit-msg.d/00-branch-prefix
|
|
179
184
|
```
|
|
180
185
|
|
|
186
|
+
A non-zero exit from a fragment does NOT fail the commit (the augmenter
|
|
187
|
+
hook is purely additive; the blocking gate is `commit-msg`). Broken
|
|
188
|
+
fragments log to stderr and the hook continues.
|
|
189
|
+
|
|
181
190
|
Then remove the old `.husky/prepare-commit-msg`:
|
|
182
191
|
|
|
183
192
|
```bash
|
package/dist/cli/hook.js
CHANGED
|
@@ -35,6 +35,10 @@ import crypto from 'node:crypto';
|
|
|
35
35
|
import { parse as parseYaml } from 'yaml';
|
|
36
36
|
import { parsePrePushStdin, runPushGate } from '../hooks/push-gate/index.js';
|
|
37
37
|
import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js';
|
|
38
|
+
import { checkHalt, formatHaltBanner } from '../hooks/_lib/halt-check.js';
|
|
39
|
+
import { runHookPrIssueLinkGate } from '../hooks/pr-issue-link-gate/index.js';
|
|
40
|
+
import { runHookSecurityDisclosureGate } from '../hooks/security-disclosure-gate/index.js';
|
|
41
|
+
import { runHookAttributionAdvisory } from '../hooks/attribution-advisory/index.js';
|
|
38
42
|
import { loadPolicy } from '../policy/loader.js';
|
|
39
43
|
import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
|
|
40
44
|
import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
|
|
@@ -129,17 +133,12 @@ export async function runHookScanBash(options) {
|
|
|
129
133
|
// HALT check — uniform with the bash hooks. We exit 2 (block) so
|
|
130
134
|
// the shim refuses the command in the same way settings-protection
|
|
131
135
|
// and the bash gates do.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
/* leave default */
|
|
141
|
-
}
|
|
142
|
-
process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
|
|
136
|
+
// 0.32.0: shared via `src/hooks/_lib/halt-check.ts` so the Phase 1
|
|
137
|
+
// pilots and the codex-review hook below all emit the same banner
|
|
138
|
+
// byte-for-byte and apply the same fail-closed read posture.
|
|
139
|
+
const halt = checkHalt(reaRoot);
|
|
140
|
+
if (halt.halted) {
|
|
141
|
+
process.stderr.write(formatHaltBanner(halt.reason));
|
|
143
142
|
const haltVerdict = {
|
|
144
143
|
verdict: 'block',
|
|
145
144
|
reason: 'rea HALT active',
|
|
@@ -332,17 +331,10 @@ export async function runHookPolicyGet(options) {
|
|
|
332
331
|
export async function runHookCodexReview(options) {
|
|
333
332
|
const baseDir = options.reaRoot ?? process.cwd();
|
|
334
333
|
// HALT check — uniform with the rest of the hook tree.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const content = fs.readFileSync(haltPath, 'utf8');
|
|
340
|
-
reason = content.slice(0, 1024).trim() || reason;
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
343
|
-
/* leave default */
|
|
344
|
-
}
|
|
345
|
-
process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
|
|
334
|
+
// 0.32.0: shared via `src/hooks/_lib/halt-check.ts`.
|
|
335
|
+
const halt = checkHalt(baseDir);
|
|
336
|
+
if (halt.halted) {
|
|
337
|
+
process.stderr.write(formatHaltBanner(halt.reason));
|
|
346
338
|
process.exit(2);
|
|
347
339
|
}
|
|
348
340
|
// Resolve git context + base ref using the same primitives the push-
|
|
@@ -963,6 +955,24 @@ export function registerHookCommand(program) {
|
|
|
963
955
|
.action(async () => {
|
|
964
956
|
await runHookDelegationAdvisory();
|
|
965
957
|
});
|
|
958
|
+
hook
|
|
959
|
+
.command('pr-issue-link-gate')
|
|
960
|
+
.description('Node-binary port of `hooks/pr-issue-link-gate.sh` (0.32.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `gh pr create` without a `closes/fixes/resolves #N` reference, prints an advisory banner to stderr. ALWAYS exits 0 except HALT (exit 2) or malformed payload (exit 2, fail-closed). The bash shim at `hooks/pr-issue-link-gate.sh` invokes this.')
|
|
961
|
+
.action(async () => {
|
|
962
|
+
await runHookPrIssueLinkGate();
|
|
963
|
+
});
|
|
964
|
+
hook
|
|
965
|
+
.command('security-disclosure-gate')
|
|
966
|
+
.description('Node-binary port of `hooks/security-disclosure-gate.sh` (0.32.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `gh issue create` AND title/body/body-file contents match a SECURITY_PATTERNS keyword, emits a deny JSON on stdout and exits 2. Routing depends on REA_DISCLOSURE_MODE: advisory (default, redirect to GHSA), issues (private repo, redirect to labeled issue), disabled (pass through).')
|
|
967
|
+
.action(async () => {
|
|
968
|
+
await runHookSecurityDisclosureGate();
|
|
969
|
+
});
|
|
970
|
+
hook
|
|
971
|
+
.command('attribution-advisory')
|
|
972
|
+
.description('Node-binary port of `hooks/attribution-advisory.sh` (0.32.0). Opt-in via policy.yaml `block_ai_attribution: true`. Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `git commit` or `gh pr create|edit` AND contains structural AI attribution markers (Co-Authored-By with vendor noreply, AI tool names, "Generated with [X]", markdown-linked tools, 🤖 Generated), exits 2 with banner. Otherwise exits 0.')
|
|
973
|
+
.action(async () => {
|
|
974
|
+
await runHookAttributionAdvisory();
|
|
975
|
+
});
|
|
966
976
|
hook
|
|
967
977
|
.command('policy-get')
|
|
968
978
|
.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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HALT kill-switch reader for the Node-binary hook tier.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 — extracted from `src/cli/hook.ts`. Pre-extraction the same
|
|
5
|
+
* `.rea/HALT` reader inlined twice (`runHookScanBash` lines 204-222 and
|
|
6
|
+
* `runHookCodexReview` lines 518-531) and `src/hooks/push-gate/halt.ts`
|
|
7
|
+
* carried a third copy with slightly different error semantics (the
|
|
8
|
+
* push-gate variant returns `{ halted: true, reason: 'unknown (HALT
|
|
9
|
+
* file unreadable)' }` on filesystem errors instead of falling through
|
|
10
|
+
* to allow). The Node-binary hook ports landing in 0.32.0 need the
|
|
11
|
+
* same primitive, so consolidate here before more copies accumulate.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
*
|
|
15
|
+
* - Returns `{ halted: false }` when `<reaRoot>/.rea/HALT` is absent.
|
|
16
|
+
* - Returns `{ halted: true, reason }` when the file exists. `reason`
|
|
17
|
+
* is the first non-empty line trimmed and capped at 1024 bytes;
|
|
18
|
+
* missing/blank content collapses to `"Reason unknown"`.
|
|
19
|
+
* - Filesystem errors during the read collapse to a halted sentinel
|
|
20
|
+
* `"unknown (HALT file unreadable)"` — fail-CLOSED. The historical
|
|
21
|
+
* `runHookScanBash` inline copy fell through to allow on read
|
|
22
|
+
* failure; that is the wrong posture for a kill switch (an
|
|
23
|
+
* attacker who can prevent the read should not get a free allow).
|
|
24
|
+
* The push-gate's halt.ts already takes this stance; we converge.
|
|
25
|
+
* - NEVER throws.
|
|
26
|
+
*
|
|
27
|
+
* Used by:
|
|
28
|
+
* - `runHookScanBash`, `runHookCodexReview` (existing — migrated to
|
|
29
|
+
* this primitive in 0.32.0)
|
|
30
|
+
* - `runHookPrIssueLinkGate`, `runHookSecurityDisclosureGate`,
|
|
31
|
+
* `runHookAttributionAdvisory` (Phase 1 pilots, 0.32.0)
|
|
32
|
+
*
|
|
33
|
+
* Distinct from `src/hooks/push-gate/halt.ts`:
|
|
34
|
+
* - The push-gate's `readHalt` is part of the dependency-injected
|
|
35
|
+
* test seam (`PushGateDeps.readHalt`) and cannot be replaced
|
|
36
|
+
* wholesale without breaking the gate's existing contract.
|
|
37
|
+
* - Future-work item: thread `checkHalt` THROUGH the push-gate's
|
|
38
|
+
* `readHalt` default so a single primitive backs every consumer.
|
|
39
|
+
* Out of scope for 0.32.0 — the push-gate ships green and rotating
|
|
40
|
+
* it now would expand the diff without carrying its own bug fix.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Result of a HALT probe.
|
|
44
|
+
*
|
|
45
|
+
* Discriminated union so callers cannot accidentally read `reason` from
|
|
46
|
+
* the not-halted case. The `halted: true` arm always carries a non-
|
|
47
|
+
* empty `reason` — the reader manufactures a placeholder rather than
|
|
48
|
+
* leaving the field undefined (the operator-facing stderr message
|
|
49
|
+
* `REA HALT: <reason>` would render `undefined` otherwise).
|
|
50
|
+
*/
|
|
51
|
+
export type HaltState = {
|
|
52
|
+
halted: true;
|
|
53
|
+
reason: string;
|
|
54
|
+
} | {
|
|
55
|
+
halted: false;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Probe `<reaRoot>/.rea/HALT`. Pure function — does not write, log, or
|
|
59
|
+
* mutate process state. Caller is responsible for the operator-facing
|
|
60
|
+
* stderr emission and the exit code.
|
|
61
|
+
*
|
|
62
|
+
* @param reaRoot Absolute path to the project root that owns `.rea/`.
|
|
63
|
+
* Hooks resolve this from `$CLAUDE_PROJECT_DIR` or
|
|
64
|
+
* `process.cwd()` — callers should pre-resolve before
|
|
65
|
+
* invoking this primitive.
|
|
66
|
+
* @returns `{ halted: false }` when the kill switch is clear, or
|
|
67
|
+
* `{ halted: true, reason }` with a non-empty reason string.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkHalt(reaRoot: string): HaltState;
|
|
70
|
+
/**
|
|
71
|
+
* Render the canonical operator-facing HALT banner. Pulled into a
|
|
72
|
+
* helper so the 5 hook callers (`runHookScanBash`,
|
|
73
|
+
* `runHookCodexReview`, and the 3 Phase 1 pilots) emit the same
|
|
74
|
+
* stderr text byte-for-byte. Matches the historical inline string
|
|
75
|
+
* exactly so existing consumer-side log parsers (if any) continue to
|
|
76
|
+
* work.
|
|
77
|
+
*/
|
|
78
|
+
export declare function formatHaltBanner(reason: string): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HALT kill-switch reader for the Node-binary hook tier.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 — extracted from `src/cli/hook.ts`. Pre-extraction the same
|
|
5
|
+
* `.rea/HALT` reader inlined twice (`runHookScanBash` lines 204-222 and
|
|
6
|
+
* `runHookCodexReview` lines 518-531) and `src/hooks/push-gate/halt.ts`
|
|
7
|
+
* carried a third copy with slightly different error semantics (the
|
|
8
|
+
* push-gate variant returns `{ halted: true, reason: 'unknown (HALT
|
|
9
|
+
* file unreadable)' }` on filesystem errors instead of falling through
|
|
10
|
+
* to allow). The Node-binary hook ports landing in 0.32.0 need the
|
|
11
|
+
* same primitive, so consolidate here before more copies accumulate.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
*
|
|
15
|
+
* - Returns `{ halted: false }` when `<reaRoot>/.rea/HALT` is absent.
|
|
16
|
+
* - Returns `{ halted: true, reason }` when the file exists. `reason`
|
|
17
|
+
* is the first non-empty line trimmed and capped at 1024 bytes;
|
|
18
|
+
* missing/blank content collapses to `"Reason unknown"`.
|
|
19
|
+
* - Filesystem errors during the read collapse to a halted sentinel
|
|
20
|
+
* `"unknown (HALT file unreadable)"` — fail-CLOSED. The historical
|
|
21
|
+
* `runHookScanBash` inline copy fell through to allow on read
|
|
22
|
+
* failure; that is the wrong posture for a kill switch (an
|
|
23
|
+
* attacker who can prevent the read should not get a free allow).
|
|
24
|
+
* The push-gate's halt.ts already takes this stance; we converge.
|
|
25
|
+
* - NEVER throws.
|
|
26
|
+
*
|
|
27
|
+
* Used by:
|
|
28
|
+
* - `runHookScanBash`, `runHookCodexReview` (existing — migrated to
|
|
29
|
+
* this primitive in 0.32.0)
|
|
30
|
+
* - `runHookPrIssueLinkGate`, `runHookSecurityDisclosureGate`,
|
|
31
|
+
* `runHookAttributionAdvisory` (Phase 1 pilots, 0.32.0)
|
|
32
|
+
*
|
|
33
|
+
* Distinct from `src/hooks/push-gate/halt.ts`:
|
|
34
|
+
* - The push-gate's `readHalt` is part of the dependency-injected
|
|
35
|
+
* test seam (`PushGateDeps.readHalt`) and cannot be replaced
|
|
36
|
+
* wholesale without breaking the gate's existing contract.
|
|
37
|
+
* - Future-work item: thread `checkHalt` THROUGH the push-gate's
|
|
38
|
+
* `readHalt` default so a single primitive backs every consumer.
|
|
39
|
+
* Out of scope for 0.32.0 — the push-gate ships green and rotating
|
|
40
|
+
* it now would expand the diff without carrying its own bug fix.
|
|
41
|
+
*/
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
/**
|
|
45
|
+
* Maximum bytes of the HALT file we consider when assembling the
|
|
46
|
+
* `reason` line. Defends against a runaway-write scenario where
|
|
47
|
+
* `.rea/HALT` is megabytes large — we always emit the reason on
|
|
48
|
+
* stderr, and a multi-MB stderr blob can overwhelm a TTY before the
|
|
49
|
+
* user sees the actual exit. 1 KiB is more than enough for a human-
|
|
50
|
+
* authored kill-switch reason.
|
|
51
|
+
*/
|
|
52
|
+
const HALT_REASON_MAX_BYTES = 1024;
|
|
53
|
+
/**
|
|
54
|
+
* Probe `<reaRoot>/.rea/HALT`. Pure function — does not write, log, or
|
|
55
|
+
* mutate process state. Caller is responsible for the operator-facing
|
|
56
|
+
* stderr emission and the exit code.
|
|
57
|
+
*
|
|
58
|
+
* @param reaRoot Absolute path to the project root that owns `.rea/`.
|
|
59
|
+
* Hooks resolve this from `$CLAUDE_PROJECT_DIR` or
|
|
60
|
+
* `process.cwd()` — callers should pre-resolve before
|
|
61
|
+
* invoking this primitive.
|
|
62
|
+
* @returns `{ halted: false }` when the kill switch is clear, or
|
|
63
|
+
* `{ halted: true, reason }` with a non-empty reason string.
|
|
64
|
+
*/
|
|
65
|
+
export function checkHalt(reaRoot) {
|
|
66
|
+
const haltPath = path.join(reaRoot, '.rea', 'HALT');
|
|
67
|
+
if (!fs.existsSync(haltPath)) {
|
|
68
|
+
return { halted: false };
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(haltPath, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Fail-closed: the file exists (existsSync passed) but we cannot
|
|
76
|
+
// read it. The operator intended to halt; a permissions glitch or
|
|
77
|
+
// race that prevents the read should NOT translate into a free
|
|
78
|
+
// allow. Surface a generic reason so the operator knows the file
|
|
79
|
+
// was present even when its content was unreadable.
|
|
80
|
+
return { halted: true, reason: 'unknown (HALT file unreadable)' };
|
|
81
|
+
}
|
|
82
|
+
// Cap at HALT_REASON_MAX_BYTES BEFORE splitting to bound the work.
|
|
83
|
+
// The pre-0.32.0 inline copies sliced the entire file content first
|
|
84
|
+
// and then trimmed; that is identical behavior for any reasonable
|
|
85
|
+
// file size but differs unboundedly for pathological inputs.
|
|
86
|
+
const slice = raw.length > HALT_REASON_MAX_BYTES ? raw.slice(0, HALT_REASON_MAX_BYTES) : raw;
|
|
87
|
+
const firstNonEmpty = slice
|
|
88
|
+
.split(/\r?\n/)
|
|
89
|
+
.map((l) => l.trim())
|
|
90
|
+
.find((l) => l.length > 0);
|
|
91
|
+
return {
|
|
92
|
+
halted: true,
|
|
93
|
+
reason: firstNonEmpty !== undefined && firstNonEmpty.length > 0 ? firstNonEmpty : 'Reason unknown',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Render the canonical operator-facing HALT banner. Pulled into a
|
|
98
|
+
* helper so the 5 hook callers (`runHookScanBash`,
|
|
99
|
+
* `runHookCodexReview`, and the 3 Phase 1 pilots) emit the same
|
|
100
|
+
* stderr text byte-for-byte. Matches the historical inline string
|
|
101
|
+
* exactly so existing consumer-side log parsers (if any) continue to
|
|
102
|
+
* work.
|
|
103
|
+
*/
|
|
104
|
+
export function formatHaltBanner(reason) {
|
|
105
|
+
return `REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stdin payload primitive for the Node-binary hook tier.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 — extracts the `INPUT=$(cat) ; jq -r '.tool_input.command'`
|
|
5
|
+
* pattern that every bash hook in `hooks/` repeats. The Node-binary
|
|
6
|
+
* scan-bash already does this work in `runHookScanBash` (lines 225-258
|
|
7
|
+
* of `src/cli/hook.ts`); the Phase 1 pilots landing in 0.32.0 need
|
|
8
|
+
* the same primitive without copy-pasting the parsing + type-guard +
|
|
9
|
+
* fail-closed-on-malformed-JSON dance into each new hook.
|
|
10
|
+
*
|
|
11
|
+
* The shape mirrors the bash hooks' contract verbatim:
|
|
12
|
+
*
|
|
13
|
+
* - `tool_input.command` is the only field we read; bash hooks only
|
|
14
|
+
* ever ran `jq -r '.tool_input.command // ""'` against this payload.
|
|
15
|
+
* - `tool_name` is also surfaced because two bash hooks
|
|
16
|
+
* (`pr-issue-link-gate.sh` and `security-disclosure-gate.sh`)
|
|
17
|
+
* short-circuit when the tool isn't `Bash`.
|
|
18
|
+
*
|
|
19
|
+
* Failure modes:
|
|
20
|
+
*
|
|
21
|
+
* - Empty stdin → `{ command: '', toolName: '' }`. The bash hooks
|
|
22
|
+
* allow on empty command (`[[ -z "$CMD" ]] && exit 0`); the Node
|
|
23
|
+
* port preserves this by returning empty strings rather than
|
|
24
|
+
* throwing.
|
|
25
|
+
* - Malformed JSON → throws `MalformedPayloadError`. The caller
|
|
26
|
+
* decides whether to fail-closed (block) or fail-open (allow);
|
|
27
|
+
* `runHookScanBash` chose fail-closed (block) and the Phase 1
|
|
28
|
+
* pilots match that posture for consistency.
|
|
29
|
+
* - `tool_input.command` is non-string → throws `TypePayloadError`.
|
|
30
|
+
* A crafted payload like `{"tool_input":{"command":["rm","-rf"]}}`
|
|
31
|
+
* would silently coerce to `''` if we used `String(c)`; that
|
|
32
|
+
* would translate into a free allow. Refuse instead.
|
|
33
|
+
*/
|
|
34
|
+
import { Buffer } from 'node:buffer';
|
|
35
|
+
/**
|
|
36
|
+
* Result of parsing a Claude Code hook PreToolUse stdin payload.
|
|
37
|
+
*/
|
|
38
|
+
export interface HookPayload {
|
|
39
|
+
/** `tool_name` from the payload, or `''` when absent. */
|
|
40
|
+
toolName: string;
|
|
41
|
+
/** `tool_input.command` from the payload, or `''` when absent. */
|
|
42
|
+
command: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when stdin contains content that is not valid JSON.
|
|
46
|
+
*
|
|
47
|
+
* Distinct error class so callers can `instanceof` discriminate without
|
|
48
|
+
* leaning on string matching of the message.
|
|
49
|
+
*/
|
|
50
|
+
export declare class MalformedPayloadError extends Error {
|
|
51
|
+
constructor(message: string);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Thrown when the JSON parses but `tool_input.command` is present and
|
|
55
|
+
* has the wrong type (anything other than `string` / `undefined`).
|
|
56
|
+
*/
|
|
57
|
+
export declare class TypePayloadError extends Error {
|
|
58
|
+
constructor(message: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Parse a Claude Code PreToolUse stdin payload. Pure function — no I/O.
|
|
62
|
+
*
|
|
63
|
+
* @param raw Bytes / string read from the hook's stdin (the `INPUT=$(cat)`
|
|
64
|
+
* equivalent).
|
|
65
|
+
* @returns A normalized `HookPayload` with both fields always defined.
|
|
66
|
+
* @throws MalformedPayloadError if the input is not parseable JSON.
|
|
67
|
+
* @throws TypePayloadError if `tool_input.command` is present with a
|
|
68
|
+
* non-string type.
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseHookPayload(raw: string | Buffer): HookPayload;
|
|
71
|
+
/**
|
|
72
|
+
* Read all of stdin into a string with a soft byte cap and a hard
|
|
73
|
+
* timeout. Mirrors the `readStdinWithTimeout` helper in
|
|
74
|
+
* `src/cli/hook.ts` (which scans a fixed timeout but no byte cap).
|
|
75
|
+
*
|
|
76
|
+
* The cap (default 1 MiB) defends against a misbehaving caller piping
|
|
77
|
+
* an unbounded payload — we'd otherwise sit in the read loop forever
|
|
78
|
+
* even if the caller eventually closed stdin.
|
|
79
|
+
*
|
|
80
|
+
* @param timeoutMs How long to wait for stdin to close before resolving
|
|
81
|
+
* with whatever we have. Default 5_000 ms.
|
|
82
|
+
* @param maxBytes Soft cap on total bytes accepted. Default 1 MiB.
|
|
83
|
+
* Once reached, additional chunks are dropped silently
|
|
84
|
+
* (the caller still gets a parseable string back).
|
|
85
|
+
*/
|
|
86
|
+
export declare function readStdinWithTimeout(timeoutMs?: number, maxBytes?: number): Promise<string>;
|