@bookedsolid/rea 0.30.1 → 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/audit-specialists.d.ts +106 -24
- package/dist/cli/audit-specialists.js +239 -64
- package/dist/cli/delegation-advisory.d.ts +161 -0
- package/dist/cli/delegation-advisory.js +433 -0
- package/dist/cli/doctor.d.ts +110 -39
- package/dist/cli/doctor.js +302 -90
- package/dist/cli/hook.d.ts +6 -0
- package/dist/cli/hook.js +45 -22
- package/dist/cli/index.js +1 -1
- package/dist/cli/install/settings-merge.js +25 -0
- package/dist/cli/roster.d.ts +119 -0
- package/dist/cli/roster.js +141 -0
- 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/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +46 -0
- package/dist/policy/profiles.d.ts +23 -0
- package/dist/policy/profiles.js +16 -0
- package/dist/policy/types.d.ts +61 -0
- package/hooks/_lib/protected-paths.sh +10 -3
- package/hooks/attribution-advisory.sh +139 -131
- package/hooks/delegation-advisory.sh +162 -0
- 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/profiles/bst-internal-no-codex.yaml +12 -0
- package/profiles/bst-internal.yaml +13 -0
- package/profiles/client-engagement.yaml +11 -0
- package/profiles/lit-wc.yaml +10 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +11 -0
- package/profiles/open-source.yaml +11 -0
- 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
package/dist/policy/types.d.ts
CHANGED
|
@@ -367,6 +367,59 @@ export interface AttributionCoAuthorPolicy {
|
|
|
367
367
|
email?: string;
|
|
368
368
|
skip_merge?: boolean;
|
|
369
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Delegation-advisory nudge policy (0.31.0+).
|
|
372
|
+
*
|
|
373
|
+
* 0.29.0 shipped the delegation-telemetry *observability* layer (the
|
|
374
|
+
* `Agent|Skill` PreToolUse capture hook + `rea audit specialists`
|
|
375
|
+
* reader). 0.31.0 closes the loop with the *nudge*: the
|
|
376
|
+
* `delegation-advisory.sh` PostToolUse hook (matcher
|
|
377
|
+
* `Bash|Edit|Write|MultiEdit|NotebookEdit`) counts the current
|
|
378
|
+
* session's write-class tool calls and, when that count crosses
|
|
379
|
+
* `threshold` WITHOUT a `rea.delegation_signal` record landing in the
|
|
380
|
+
* session, prints a one-time stderr advisory: "this session has done a
|
|
381
|
+
* lot of work without delegating to a specialist".
|
|
382
|
+
*
|
|
383
|
+
* The advisory is purely informational — the hook always exits 0
|
|
384
|
+
* (except under HALT, which exits 2 to keep the kill-switch contract
|
|
385
|
+
* uniform). It NEVER blocks a tool call.
|
|
386
|
+
*
|
|
387
|
+
* Profile defaults: `enabled: true` for the `bst-internal*` profiles
|
|
388
|
+
* (BST's own delegation discipline is load-bearing); `enabled: false`
|
|
389
|
+
* for every external profile (`open-source*`, `minimal`,
|
|
390
|
+
* `client-engagement`, `lit-wc`) — OSS consumers opt in per-repo via
|
|
391
|
+
* `.rea/policy.yaml`, since "you should delegate more" is an opinion
|
|
392
|
+
* not every team shares.
|
|
393
|
+
*/
|
|
394
|
+
export interface DelegationAdvisoryPolicy {
|
|
395
|
+
/**
|
|
396
|
+
* Master switch. When `false` (or the whole block is omitted) the
|
|
397
|
+
* `delegation-advisory.sh` hook is a silent no-op. Default `false` at
|
|
398
|
+
* the schema layer; `bst-internal*` profiles pin `true`.
|
|
399
|
+
*/
|
|
400
|
+
enabled?: boolean;
|
|
401
|
+
/**
|
|
402
|
+
* Write-class tool-call count at which the advisory fires. The
|
|
403
|
+
* `delegation-advisory.sh` hook maintains a per-session counter file
|
|
404
|
+
* and emits the nudge the first time the counter reaches this value
|
|
405
|
+
* with zero delegation signals recorded for the session. Default
|
|
406
|
+
* `25` — a session that has run 25 Bash/Edit/Write/MultiEdit/
|
|
407
|
+
* NotebookEdit calls without once dispatching a specialist is doing
|
|
408
|
+
* meaningful work solo. Must be a positive integer.
|
|
409
|
+
*/
|
|
410
|
+
threshold?: number;
|
|
411
|
+
/**
|
|
412
|
+
* Subagent / skill names that do NOT count as "real delegation" for
|
|
413
|
+
* the purpose of suppressing the advisory. A session that only ever
|
|
414
|
+
* delegated to `general-purpose` / `Explore` / `Plan` (the built-in
|
|
415
|
+
* Claude Code helpers) has not actually routed work to a curated
|
|
416
|
+
* specialist, so those signals don't reset the nudge. Default:
|
|
417
|
+
* `["general-purpose", "Explore", "Plan", "output-style-setup",
|
|
418
|
+
* "statusline-setup"]`. A delegation signal whose `subagent_type` is
|
|
419
|
+
* in this list is ignored when deciding whether to fire.
|
|
420
|
+
*/
|
|
421
|
+
exempt_subagents?: string[];
|
|
422
|
+
}
|
|
370
423
|
/**
|
|
371
424
|
* G9 — injection tier escalation knobs. The classifier bucketed matches into
|
|
372
425
|
* `clean` / `suspicious` / `likely_injection`; this block governs what happens
|
|
@@ -472,4 +525,12 @@ export interface Policy {
|
|
|
472
525
|
* trailer are no-ops. See `AttributionPolicy` for the full contract.
|
|
473
526
|
*/
|
|
474
527
|
attribution?: AttributionPolicy;
|
|
528
|
+
/**
|
|
529
|
+
* Delegation-advisory nudge (0.31.0+). When `enabled: true`, the
|
|
530
|
+
* `delegation-advisory.sh` PostToolUse hook emits a one-time stderr
|
|
531
|
+
* advisory when a session crosses `threshold` write-class tool calls
|
|
532
|
+
* without dispatching a curated specialist. Advisory only — never
|
|
533
|
+
* blocks. See `DelegationAdvisoryPolicy` for the full contract.
|
|
534
|
+
*/
|
|
535
|
+
delegation_advisory?: DelegationAdvisoryPolicy;
|
|
475
536
|
}
|
|
@@ -242,7 +242,7 @@ _rea_load_protected_patterns() {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
# Test whether a project-relative path is in the documented husky
|
|
245
|
-
# extension surface (`.husky/commit-msg
|
|
245
|
+
# extension surface (`.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/*`).
|
|
246
246
|
# Returns 0 on match, 1 on no match. Case-insensitive.
|
|
247
247
|
#
|
|
248
248
|
# 0.16.4 helix-018 Option B: settings-protection.sh §5b has carved
|
|
@@ -253,17 +253,24 @@ _rea_load_protected_patterns() {
|
|
|
253
253
|
# redirect was refused by the bash-gate even though the equivalent
|
|
254
254
|
# Write-tool call would succeed. This helper bakes the carve-out
|
|
255
255
|
# into the shared lib so every caller inherits it uniformly.
|
|
256
|
+
#
|
|
257
|
+
# 0.32.0 codex round 2 P1: `.husky/prepare-commit-msg.d/*` joins the
|
|
258
|
+
# carve-out to match settings-protection.sh §5b — the Write-tier
|
|
259
|
+
# allow-list shipped earlier in 0.32.0 was incomplete without the
|
|
260
|
+
# Bash-tier parity. Without this update, the migration path in
|
|
261
|
+
# MIGRATING.md (`cat > .husky/prepare-commit-msg.d/...`) is refused
|
|
262
|
+
# by `protected-paths-bash-gate.sh` even though Write/Edit succeeds.
|
|
256
263
|
rea_path_is_extension_surface() {
|
|
257
264
|
local p_lc
|
|
258
265
|
p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
|
259
266
|
case "$p_lc" in
|
|
260
|
-
.husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*)
|
|
267
|
+
.husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*|.husky/prepare-commit-msg.d/*)
|
|
261
268
|
# Refuse the bare directory itself — only fragments INSIDE
|
|
262
269
|
# the surface count. `.husky/pre-push.d/` (trailing slash, no
|
|
263
270
|
# fragment) and `.husky/pre-push.d` (the dir node) both fall
|
|
264
271
|
# through to the protection check via the parent prefix.
|
|
265
272
|
case "$p_lc" in
|
|
266
|
-
.husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/) return 1 ;;
|
|
273
|
+
.husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/|.husky/prepare-commit-msg.d/) return 1 ;;
|
|
267
274
|
esac
|
|
268
275
|
return 0
|
|
269
276
|
;;
|
|
@@ -1,162 +1,170 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: attribution-advisory.sh
|
|
3
|
-
#
|
|
3
|
+
# 0.32.0+ — Node-binary shim for `rea hook attribution-advisory`.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Pre-0.32.0 the gate's full body lived here as bash (162 LOC,
|
|
6
|
+
# including the AI-attribution pattern catalog and segment-relevance
|
|
7
|
+
# gating). The migration to the parser-backed Node binary moves all
|
|
8
|
+
# of that into `src/hooks/attribution-advisory/index.ts`. This shim
|
|
9
|
+
# is the Claude Code dispatcher's view of the hook — it forwards
|
|
10
|
+
# stdin to the CLI and exits with whatever the CLI returns.
|
|
7
11
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
12
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on
|
|
13
|
+
# disabled-policy / non-relevant / clean-command, exit 2 on HALT /
|
|
14
|
+
# attribution detected / malformed payload (fail-closed).
|
|
11
15
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# # CLI-resolution trust boundary
|
|
17
|
+
#
|
|
18
|
+
# Codex round 1 P1 (2026-05-15): realpath sandbox check + version
|
|
19
|
+
# probe. Mirrors delegation-advisory.sh §3. Defends against
|
|
20
|
+
# symlink-out + tarball-replacement attacks on the resolved CLI AND
|
|
21
|
+
# stale-node_modules version skew that would otherwise turn every
|
|
22
|
+
# Bash dispatch into a hard failure.
|
|
15
23
|
|
|
16
24
|
set -uo pipefail
|
|
17
25
|
|
|
18
|
-
#
|
|
19
|
-
INPUT=$(cat)
|
|
20
|
-
|
|
21
|
-
# ── 2. Dependency check ───────────────────────────────────────────────────────
|
|
22
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
23
|
-
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
24
|
-
printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
|
|
25
|
-
exit 2
|
|
26
|
-
fi
|
|
27
|
-
|
|
28
|
-
# ── 3. HALT check ─────────────────────────────────────────────────────────────
|
|
29
|
-
# 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
|
|
26
|
+
# 1. HALT check.
|
|
30
27
|
# shellcheck source=_lib/halt-check.sh
|
|
31
28
|
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
32
29
|
check_halt
|
|
33
30
|
REA_ROOT=$(rea_root)
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
37
|
-
if [ ! -f "$POLICY_FILE" ]; then
|
|
38
|
-
exit 0
|
|
39
|
-
fi
|
|
40
|
-
if ! grep -qE '^block_ai_attribution:[[:space:]]*true' "$POLICY_FILE" 2>/dev/null; then
|
|
41
|
-
exit 0
|
|
42
|
-
fi
|
|
43
|
-
|
|
44
|
-
# ── 5. Parse tool_input.command from the hook payload ─────────────────────────
|
|
45
|
-
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
46
|
-
|
|
47
|
-
if [[ -z "$CMD" ]]; then
|
|
48
|
-
exit 0
|
|
49
|
-
fi
|
|
32
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
50
33
|
|
|
51
|
-
# 0.
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
|
|
76
|
-
fi
|
|
77
|
-
|
|
78
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+commit'; then
|
|
79
|
-
IS_RELEVANT=1
|
|
34
|
+
# 2. Relevance pre-gate (0.32.0 round-5 P1, round-6 fix). PreToolUse
|
|
35
|
+
# Bash matchers fire on EVERY shell command, but this hook only
|
|
36
|
+
# enforces against `git commit` / `gh pr create|edit`. Capture
|
|
37
|
+
# stdin + check relevance FIRST so unrelated commands (ls,
|
|
38
|
+
# pnpm test, …) exit 0 even when the CLI is missing/stale/
|
|
39
|
+
# sandboxed-out.
|
|
40
|
+
#
|
|
41
|
+
# Match the pattern ANYWHERE in the command string (after the
|
|
42
|
+
# opening quote, then `[^"]*` for any leading shell prefix —
|
|
43
|
+
# `sudo`, `time`, env assignments like `FOO=x git commit …`).
|
|
44
|
+
# Round-6 P1: prior round-5 pattern anchored at the start of the
|
|
45
|
+
# JSON value and missed all prefixed forms.
|
|
46
|
+
INPUT=$(cat)
|
|
47
|
+
# Substring scan (NOT JSON-aware). Round-7 P2: any JSON-aware regex
|
|
48
|
+
# anchored on `"command":"...` gets tripped by escaped quotes in
|
|
49
|
+
# quoted env prefixes (`FOO="two words" git commit …` → the payload
|
|
50
|
+
# carries `\"two words\"` and `[^"]*` stops at the escaped quote).
|
|
51
|
+
# Plain substring match has no such edge: it over-triggers only on
|
|
52
|
+
# the rare case where the pattern appears inside a quoted argument
|
|
53
|
+
# (`echo "gh pr create"`), and the Node body handles that correctly.
|
|
54
|
+
# This hook only fires on `tool_name=Bash`, so we don't risk matching
|
|
55
|
+
# unrelated payload shapes.
|
|
56
|
+
RELEVANT=0
|
|
57
|
+
if printf '%s' "$INPUT" | grep -qE '(git[[:space:]]+commit|gh[[:space:]]+pr[[:space:]]+(create|edit))'; then
|
|
58
|
+
RELEVANT=1
|
|
80
59
|
fi
|
|
81
|
-
|
|
82
|
-
|
|
60
|
+
if [ "$RELEVANT" -eq 0 ]; then
|
|
61
|
+
# Irrelevant Bash call — nothing the pre-0.32.0 body would have
|
|
62
|
+
# processed. Always exit 0 regardless of CLI state.
|
|
83
63
|
exit 0
|
|
84
64
|
fi
|
|
85
65
|
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
# either end-of-line, whitespace, `>`, or a domain that does NOT begin
|
|
100
|
-
# with `users.noreply.github.com`. Posix ERE has no lookarounds, so we
|
|
101
|
-
# enumerate the allowed-prefix shapes explicitly. The "AI names" branch
|
|
102
|
-
# below catches Co-Authored-By with named tools regardless of the email
|
|
103
|
-
# domain, so dropping `users.noreply.github.com` from the noreply
|
|
104
|
-
# pattern only relaxes the check for human collaborators — never for AI.
|
|
105
|
-
if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)'; then
|
|
106
|
-
FOUND=1
|
|
66
|
+
# 2b. Policy short-circuit (round-6 P2). The pre-0.32.0 bash body
|
|
67
|
+
# no-op'd when `block_ai_attribution` was absent or false. Without
|
|
68
|
+
# this check, an unbuilt/stale install would refuse `git commit`
|
|
69
|
+
# even on repos that DELIBERATELY disable the attribution gate.
|
|
70
|
+
# Read the policy via a simple grep — the canonical loader
|
|
71
|
+
# handles inline forms but we only need block form here, and a
|
|
72
|
+
# conservative "true-and-only-true counts" rule matches the
|
|
73
|
+
# intent (false / absent / inline-only all → no enforcement).
|
|
74
|
+
POLICY_FILE="$REA_ROOT/.rea/policy.yaml"
|
|
75
|
+
if [ ! -f "$POLICY_FILE" ] || ! grep -qE '^block_ai_attribution:[[:space:]]*true([[:space:]]|$)' "$POLICY_FILE"; then
|
|
76
|
+
# Attribution blocking disabled — pre-0.32.0 bash body would have
|
|
77
|
+
# exited 0 here. Don't refuse on stale-install grounds.
|
|
78
|
+
exit 0
|
|
107
79
|
fi
|
|
108
80
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
81
|
+
# 3. Resolve the rea CLI.
|
|
82
|
+
REA_ARGV=()
|
|
83
|
+
RESOLVED_CLI_PATH=""
|
|
84
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
85
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
86
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
87
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
88
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
89
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
112
90
|
fi
|
|
113
91
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
92
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
93
|
+
# 0.32.0 round-4 P2: when `block_ai_attribution: true`, this hook is
|
|
94
|
+
# blocking-tier — the pre-0.32.0 bash body enforced the policy
|
|
95
|
+
# without a compiled CLI. Falling through to exit 0 would silently
|
|
96
|
+
# let AI-attribution patterns through every git commit / gh pr
|
|
97
|
+
# create-or-edit until the operator rebuilds. Fail closed and tell
|
|
98
|
+
# the operator how to restore protection.
|
|
99
|
+
printf 'rea: attribution-advisory cannot run — the rea CLI is not built.\n' >&2
|
|
100
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
101
|
+
printf 'This shim fails closed because the pre-0.32.0 bash body enforced attribution policy without a CLI.\n' >&2
|
|
102
|
+
exit 2
|
|
117
103
|
fi
|
|
118
104
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if any_segment_matches "$CMD" '\[Claude Code\]\(|\[GitHub Copilot\]\(|\[ChatGPT\]\(|\[Gemini\]\(|\[Cursor\]\('; then
|
|
125
|
-
FOUND=1
|
|
105
|
+
# 3. Realpath sandbox check.
|
|
106
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
107
|
+
printf 'rea: attribution-advisory cannot run — `node` is not on PATH.\n' >&2
|
|
108
|
+
printf 'Install Node 22+ (engines.node) to restore enforcement.\n' >&2
|
|
109
|
+
exit 2
|
|
126
110
|
fi
|
|
127
111
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
112
|
+
sandbox_check=$(node -e '
|
|
113
|
+
const fs = require("fs");
|
|
114
|
+
const path = require("path");
|
|
115
|
+
const cli = process.argv[1];
|
|
116
|
+
const projDir = process.argv[2];
|
|
117
|
+
let real, realProj;
|
|
118
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
119
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
122
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
const sep = path.sep;
|
|
125
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
126
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
127
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
130
|
+
let found = false;
|
|
131
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
132
|
+
const pj = path.join(cur, "package.json");
|
|
133
|
+
if (fs.existsSync(pj)) {
|
|
134
|
+
try {
|
|
135
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
136
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
137
|
+
} catch (e) { /* keep walking */ }
|
|
138
|
+
}
|
|
139
|
+
cur = path.dirname(cur);
|
|
140
|
+
}
|
|
141
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
142
|
+
process.stdout.write("ok");
|
|
143
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
144
|
+
|
|
145
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
146
|
+
# 0.32.0 round-4 P2: fail closed (blocking-tier when policy enables —
|
|
147
|
+
# see top-of-file rationale). Sandbox failure means the CLI cannot
|
|
148
|
+
# be authenticated; refuse rather than silently bypass.
|
|
149
|
+
printf 'rea: attribution-advisory FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
150
|
+
exit 2
|
|
131
151
|
fi
|
|
132
152
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
printf ' - Markdown-linked tool names: [Claude Code](...)\n'
|
|
146
|
-
printf ' - Emoji attribution: 🤖 Generated...\n'
|
|
147
|
-
printf '\n'
|
|
148
|
-
printf ' What is ALLOWED (legitimate references):\n'
|
|
149
|
-
printf ' - "Fix Claude API integration"\n'
|
|
150
|
-
printf ' - "Update OpenAI SDK version"\n'
|
|
151
|
-
printf ' - "Add Copilot config"\n'
|
|
152
|
-
printf '\n'
|
|
153
|
-
printf ' Remove the attribution markers and rewrite the command.\n'
|
|
154
|
-
printf ' To disable: set block_ai_attribution: false in .rea/policy.yaml\n'
|
|
155
|
-
printf '═══════════════════════════════════════════════════════════════════\n'
|
|
156
|
-
printf '\n'
|
|
157
|
-
} >&2
|
|
153
|
+
# 4. Version-probe: confirm the resolved CLI implements
|
|
154
|
+
# `hook attribution-advisory`. Codex round 1 P1.
|
|
155
|
+
probe_out=$("${REA_ARGV[@]}" hook attribution-advisory --help 2>&1)
|
|
156
|
+
probe_status=$?
|
|
157
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'attribution-advisory'; then
|
|
158
|
+
# 0.32.0 round-4 P2: stale/older CLI without the new subcommand is
|
|
159
|
+
# NOT advisory-tier fall-through — the bash body it replaces
|
|
160
|
+
# enforced when policy enabled. Fail closed and tell the operator
|
|
161
|
+
# exactly how to fix.
|
|
162
|
+
printf 'rea: this shim requires the `rea hook attribution-advisory` subcommand (introduced in 0.32.0).\n' >&2
|
|
163
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
164
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
158
165
|
exit 2
|
|
159
166
|
fi
|
|
160
167
|
|
|
161
|
-
#
|
|
162
|
-
|
|
168
|
+
# 5. Forward stdin (already captured up-front for the relevance gate).
|
|
169
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook attribution-advisory
|
|
170
|
+
exit $?
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse hook: delegation-advisory.sh
|
|
3
|
+
# 0.31.0+ — delegation-telemetry completion (the *nudge*).
|
|
4
|
+
#
|
|
5
|
+
# Fires AFTER every write-class tool call. The settings.json matcher is
|
|
6
|
+
# `Bash|Edit|Write|MultiEdit|NotebookEdit`. Reads the Claude Code hook
|
|
7
|
+
# payload from stdin, pipes it to `rea hook delegation-advisory`, and
|
|
8
|
+
# exits 0.
|
|
9
|
+
#
|
|
10
|
+
# 0.29.0 shipped the delegation-telemetry *observability* layer
|
|
11
|
+
# (`delegation-capture.sh` + `rea audit specialists`). 0.31.0 closes the
|
|
12
|
+
# loop with the *nudge*: `rea hook delegation-advisory` maintains a
|
|
13
|
+
# per-session write-class counter and, the FIRST time that counter
|
|
14
|
+
# crosses `policy.delegation_advisory.threshold` while the session has
|
|
15
|
+
# recorded zero real delegation signals, prints a one-time stderr
|
|
16
|
+
# advisory ("this session has done a lot of work without delegating to
|
|
17
|
+
# a specialist").
|
|
18
|
+
#
|
|
19
|
+
# # Advisory, never gating
|
|
20
|
+
#
|
|
21
|
+
# This hook ALWAYS exits 0 (under normal operation). The advisory is a
|
|
22
|
+
# nudge — it never blocks a tool call. The ONLY non-zero exit is 2
|
|
23
|
+
# under HALT, to keep the kill-switch contract uniform with the rest of
|
|
24
|
+
# the hook tree.
|
|
25
|
+
#
|
|
26
|
+
# # Synchronous, NOT detached
|
|
27
|
+
#
|
|
28
|
+
# Unlike `delegation-capture.sh` (which backgrounds `rea hook
|
|
29
|
+
# delegation-signal` with `& disown` because the audit write must not
|
|
30
|
+
# block tool dispatch), this hook runs the CLI SYNCHRONOUSLY. The
|
|
31
|
+
# advisory text must reach the operator's stderr before the hook
|
|
32
|
+
# returns — backgrounding it would race the hook's own exit and the
|
|
33
|
+
# message could be lost or interleaved with the next tool call's
|
|
34
|
+
# output. The CLI is cheap on the hot path: below the threshold it
|
|
35
|
+
# only bumps an integer counter file and exits, no audit scan, no
|
|
36
|
+
# roster discovery.
|
|
37
|
+
#
|
|
38
|
+
# # CLI-resolution trust boundary
|
|
39
|
+
#
|
|
40
|
+
# Same 2-tier sandboxed resolution `delegation-capture.sh`,
|
|
41
|
+
# `protected-paths-bash-gate.sh`, and `blocked-paths-bash-gate.sh` use:
|
|
42
|
+
# 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
|
|
43
|
+
# published artifact)
|
|
44
|
+
# 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
|
|
45
|
+
# dogfood install)
|
|
46
|
+
# PATH lookup is INTENTIONALLY OMITTED — agent-controlled $PATH would
|
|
47
|
+
# let a forged `rea` binary intercept this hook on every write-class
|
|
48
|
+
# tool call. A realpath sandbox check ensures the resolved CLI lives
|
|
49
|
+
# INSIDE realpath(CLAUDE_PROJECT_DIR) with an ancestor package.json
|
|
50
|
+
# declaring `@bookedsolid/rea`.
|
|
51
|
+
#
|
|
52
|
+
# Exit codes:
|
|
53
|
+
# 0 — always (under normal operation). Disabled-by-policy,
|
|
54
|
+
# below-threshold, already-fired, just-fired — all exit 0.
|
|
55
|
+
# 2 — HALT active.
|
|
56
|
+
|
|
57
|
+
set -uo pipefail
|
|
58
|
+
|
|
59
|
+
# 1. HALT check. Even though this hook is advisory, refusing to run
|
|
60
|
+
# while frozen matches the rest of the hook tree and keeps the
|
|
61
|
+
# kill-switch contract uniform.
|
|
62
|
+
# shellcheck source=_lib/halt-check.sh
|
|
63
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
64
|
+
check_halt
|
|
65
|
+
REA_ROOT=$(rea_root)
|
|
66
|
+
|
|
67
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
68
|
+
|
|
69
|
+
# 2. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
70
|
+
# PATH lookup is omitted on purpose (see header). Other install
|
|
71
|
+
# shapes silently drop the advisory — matching the bash-gate
|
|
72
|
+
# posture; the nudge is a convenience, not a security claim.
|
|
73
|
+
REA_ARGV=()
|
|
74
|
+
RESOLVED_CLI_PATH=""
|
|
75
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
76
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
77
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
78
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
79
|
+
# rea repo dogfood: the project IS @bookedsolid/rea.
|
|
80
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
81
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
85
|
+
# No rea CLI in scope — drop the advisory silently. This is the
|
|
86
|
+
# expected state during bootstrap (consumer ran `rea init` but
|
|
87
|
+
# hasn't installed the npm package yet) or in non-rea repos. A
|
|
88
|
+
# noisy stderr warning here would fire on every write-class tool
|
|
89
|
+
# call and drown legitimate output.
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# 3. Realpath sandbox check — mirrors delegation-capture.sh §3 and
|
|
94
|
+
# protected-paths-bash-gate.sh §6. The resolved CLI MUST live inside
|
|
95
|
+
# realpath(CLAUDE_PROJECT_DIR) AND have an ancestor package.json
|
|
96
|
+
# declaring `@bookedsolid/rea` as its `name`. Catches symlink-out
|
|
97
|
+
# attacks where an attacker writes
|
|
98
|
+
# node_modules/@bookedsolid/rea → /tmp/forged-tree.
|
|
99
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
100
|
+
# Node not on PATH — we can't verify the CLI shape. Fail safe by
|
|
101
|
+
# dropping the advisory (it is not a security claim; the rest of
|
|
102
|
+
# the Bash gate suite refuses on this path).
|
|
103
|
+
exit 0
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
sandbox_check=$(node -e '
|
|
107
|
+
const fs = require("fs");
|
|
108
|
+
const path = require("path");
|
|
109
|
+
const cli = process.argv[1];
|
|
110
|
+
const projDir = process.argv[2];
|
|
111
|
+
let real, realProj;
|
|
112
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
113
|
+
process.stdout.write("bad:realpath");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
117
|
+
process.stdout.write("bad:realpath-proj");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
const sep = path.sep;
|
|
121
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
122
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
123
|
+
process.stdout.write("bad:cli-escapes-project");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// Walk up looking for package.json with the protected name.
|
|
127
|
+
let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
|
|
128
|
+
let found = false;
|
|
129
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
130
|
+
const pj = path.join(cur, "package.json");
|
|
131
|
+
if (fs.existsSync(pj)) {
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
134
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
135
|
+
} catch (e) { /* keep walking */ }
|
|
136
|
+
}
|
|
137
|
+
cur = path.dirname(cur);
|
|
138
|
+
}
|
|
139
|
+
if (!found) {
|
|
140
|
+
process.stdout.write("bad:no-rea-pkg-json");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write("ok");
|
|
144
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
145
|
+
|
|
146
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
147
|
+
# CLI failed the sandbox check — silent drop. The forensic
|
|
148
|
+
# breadcrumb in stderr is intentional but trimmed so this doesn't
|
|
149
|
+
# become spammy on every tool call.
|
|
150
|
+
printf 'rea: delegation-advisory skipped (sandbox check: %s)\n' "$sandbox_check" >&2
|
|
151
|
+
exit 0
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# 4. Read stdin and pipe to the CLI SYNCHRONOUSLY. The advisory must
|
|
155
|
+
# print before this hook returns — see the "Synchronous" note in
|
|
156
|
+
# the header. We pass CLAUDE_PROJECT_DIR through explicitly so the
|
|
157
|
+
# CLI resolves the same REA_ROOT this shim did. The CLI's own exit
|
|
158
|
+
# code is the hook's exit code: 0 normally, 2 under HALT (the CLI
|
|
159
|
+
# re-checks HALT itself for defense-in-depth).
|
|
160
|
+
INPUT=$(cat)
|
|
161
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-advisory
|
|
162
|
+
exit $?
|