@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.
@@ -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
- REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
53
-
54
- # HALT kill switch refuse to mutate anything while frozen.
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/*` and `.husky/commit-msg.d/*` are the
84
- **upgrade-safe** place to layer your own gates. Files in those
85
- directories must be executable; rea sources them in lex order AFTER
86
- its own governance work succeeds. A non-zero exit from any fragment
87
- fails the hook (matches husky's normal chaining).
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 rea's chained body. As of 0.30.0
162
- rea's prepare-commit-msg body does NOT support `.husky/prepare-commit-msg.d/*`
163
- fragments yet (it's on the 0.31.0 roadmap). For now, port the logic
164
- into a wrapper invoked by `commit-msg.d` instead:
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
- # Branch-prefix logic moved from prepare-commit-msg to commit-msg.d
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
- const haltPath = path.join(reaRoot, '.rea', 'HALT');
133
- if (fs.existsSync(haltPath)) {
134
- let reason = 'Reason unknown';
135
- try {
136
- const content = fs.readFileSync(haltPath, 'utf8');
137
- reason = content.slice(0, 1024).trim() || reason;
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
- const haltPath = path.join(baseDir, '.rea', 'HALT');
336
- if (fs.existsSync(haltPath)) {
337
- let reason = 'Reason unknown';
338
- try {
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>;