@bookedsolid/rea 0.28.2 → 0.30.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 +295 -0
- package/MIGRATING.md +75 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/delegation-event.d.ts +215 -0
- package/dist/audit/delegation-event.js +113 -0
- package/dist/cli/audit-specialists.d.ts +113 -0
- package/dist/cli/audit-specialists.js +220 -0
- package/dist/cli/doctor.d.ts +114 -1
- package/dist/cli/doctor.js +523 -5
- package/dist/cli/hook.d.ts +40 -8
- package/dist/cli/hook.js +305 -8
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +120 -0
- package/dist/cli/install/manifest-schema.d.ts +6 -6
- package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
- package/dist/cli/install/prepare-commit-msg.js +208 -0
- package/dist/cli/install/settings-merge.js +20 -0
- package/dist/cli/upgrade.js +34 -0
- package/dist/config/settings-schema.d.ts +2087 -0
- package/dist/config/settings-schema.js +294 -0
- package/dist/config/tier-map.js +22 -1
- package/dist/policy/loader.d.ts +58 -0
- package/dist/policy/loader.js +68 -0
- package/dist/policy/profiles.d.ts +48 -0
- package/dist/policy/profiles.js +25 -0
- package/dist/policy/types.d.ts +51 -0
- package/dist/registry/loader.d.ts +12 -12
- package/hooks/delegation-capture.sh +158 -0
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +15 -0
- package/profiles/bst-internal.yaml +16 -0
- package/profiles/client-engagement.yaml +14 -0
- package/profiles/lit-wc.yaml +14 -0
- package/profiles/minimal.yaml +16 -0
- package/profiles/open-source-no-codex.yaml +13 -0
- package/profiles/open-source.yaml +13 -0
- package/templates/prepare-commit-msg.husky.sh +295 -0
|
@@ -18,20 +18,20 @@ declare const RegistryServerSchema: z.ZodObject<{
|
|
|
18
18
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
19
19
|
}, "strict", z.ZodTypeAny, {
|
|
20
20
|
name: string;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
env: Record<string, string>;
|
|
21
23
|
command: string;
|
|
22
24
|
args: string[];
|
|
23
|
-
env: Record<string, string>;
|
|
24
|
-
enabled: boolean;
|
|
25
25
|
env_passthrough?: string[] | undefined;
|
|
26
26
|
tier_overrides?: Record<string, Tier> | undefined;
|
|
27
27
|
}, {
|
|
28
28
|
name: string;
|
|
29
29
|
command: string;
|
|
30
|
-
|
|
30
|
+
enabled?: boolean | undefined;
|
|
31
31
|
env?: Record<string, string> | undefined;
|
|
32
|
+
args?: string[] | undefined;
|
|
32
33
|
env_passthrough?: string[] | undefined;
|
|
33
34
|
tier_overrides?: Record<string, Tier> | undefined;
|
|
34
|
-
enabled?: boolean | undefined;
|
|
35
35
|
}>;
|
|
36
36
|
declare const RegistrySchema: z.ZodObject<{
|
|
37
37
|
version: z.ZodLiteral<"1">;
|
|
@@ -45,30 +45,30 @@ declare const RegistrySchema: z.ZodObject<{
|
|
|
45
45
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
46
46
|
}, "strict", z.ZodTypeAny, {
|
|
47
47
|
name: string;
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
env: Record<string, string>;
|
|
48
50
|
command: string;
|
|
49
51
|
args: string[];
|
|
50
|
-
env: Record<string, string>;
|
|
51
|
-
enabled: boolean;
|
|
52
52
|
env_passthrough?: string[] | undefined;
|
|
53
53
|
tier_overrides?: Record<string, Tier> | undefined;
|
|
54
54
|
}, {
|
|
55
55
|
name: string;
|
|
56
56
|
command: string;
|
|
57
|
-
|
|
57
|
+
enabled?: boolean | undefined;
|
|
58
58
|
env?: Record<string, string> | undefined;
|
|
59
|
+
args?: string[] | undefined;
|
|
59
60
|
env_passthrough?: string[] | undefined;
|
|
60
61
|
tier_overrides?: Record<string, Tier> | undefined;
|
|
61
|
-
enabled?: boolean | undefined;
|
|
62
62
|
}>, "many">>;
|
|
63
63
|
reviewer: z.ZodOptional<z.ZodEnum<["codex", "claude-self"]>>;
|
|
64
64
|
}, "strict", z.ZodTypeAny, {
|
|
65
65
|
version: "1";
|
|
66
66
|
servers: {
|
|
67
67
|
name: string;
|
|
68
|
+
enabled: boolean;
|
|
69
|
+
env: Record<string, string>;
|
|
68
70
|
command: string;
|
|
69
71
|
args: string[];
|
|
70
|
-
env: Record<string, string>;
|
|
71
|
-
enabled: boolean;
|
|
72
72
|
env_passthrough?: string[] | undefined;
|
|
73
73
|
tier_overrides?: Record<string, Tier> | undefined;
|
|
74
74
|
}[];
|
|
@@ -78,11 +78,11 @@ declare const RegistrySchema: z.ZodObject<{
|
|
|
78
78
|
servers?: {
|
|
79
79
|
name: string;
|
|
80
80
|
command: string;
|
|
81
|
-
|
|
81
|
+
enabled?: boolean | undefined;
|
|
82
82
|
env?: Record<string, string> | undefined;
|
|
83
|
+
args?: string[] | undefined;
|
|
83
84
|
env_passthrough?: string[] | undefined;
|
|
84
85
|
tier_overrides?: Record<string, Tier> | undefined;
|
|
85
|
-
enabled?: boolean | undefined;
|
|
86
86
|
}[] | undefined;
|
|
87
87
|
reviewer?: "codex" | "claude-self" | undefined;
|
|
88
88
|
}>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: delegation-capture.sh
|
|
3
|
+
# 0.29.0+ — delegation-telemetry MVP.
|
|
4
|
+
#
|
|
5
|
+
# Fires BEFORE every `Agent` or `Skill` tool call. Reads the Claude
|
|
6
|
+
# Code hook payload from stdin, pipes it to
|
|
7
|
+
# `rea hook delegation-signal --detach`, and exits 0 immediately.
|
|
8
|
+
#
|
|
9
|
+
# The signal is OBSERVATIONAL — never gates tool dispatch. Worst-case
|
|
10
|
+
# latency budget is ~50ms even when the audit chain is under
|
|
11
|
+
# cross-process contention, because the audit append runs in the
|
|
12
|
+
# background (via `&`) and the CLI subcommand itself only validates
|
|
13
|
+
# the payload before forking the writer.
|
|
14
|
+
#
|
|
15
|
+
# Matcher: `Agent|Skill` (NOT `Task|Skill` — `TaskCreate`/`TaskList`
|
|
16
|
+
# are the unrelated todo-list tools and MUST NOT match).
|
|
17
|
+
#
|
|
18
|
+
# # CLI-resolution trust boundary
|
|
19
|
+
#
|
|
20
|
+
# Codex round 3 P1 (2026-05-12): pre-fix this hook resolved the rea
|
|
21
|
+
# binary via `$REA_ROOT/node_modules/.bin/rea` then PATH-walked
|
|
22
|
+
# `command -v rea`. Either path was attacker-influenced in a consumer
|
|
23
|
+
# repo with a forged `node_modules/.bin/rea` symlink or a
|
|
24
|
+
# PATH-prepended fake `rea` binary — giving attacker-controlled code
|
|
25
|
+
# execution on every Agent/Skill dispatch.
|
|
26
|
+
#
|
|
27
|
+
# Fix: this hook now uses the same 2-tier sandboxed resolution that
|
|
28
|
+
# protected-paths-bash-gate.sh + blocked-paths-bash-gate.sh use:
|
|
29
|
+
# 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
|
|
30
|
+
# published artifact)
|
|
31
|
+
# 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
|
|
32
|
+
# dogfood install)
|
|
33
|
+
#
|
|
34
|
+
# A realpath sandbox check ensures the resolved CLI lives INSIDE
|
|
35
|
+
# realpath(CLAUDE_PROJECT_DIR) — catches symlink-out attacks.
|
|
36
|
+
#
|
|
37
|
+
# Exit codes:
|
|
38
|
+
# 0 — always (under normal operation). Failure to write the audit
|
|
39
|
+
# signal must NEVER block Claude Code's tool dispatch. Stderr
|
|
40
|
+
# breadcrumbs surface diagnostic info to the operator. HALT
|
|
41
|
+
# still exits 2 because the kill-switch contract must hold.
|
|
42
|
+
# 2 — HALT active.
|
|
43
|
+
|
|
44
|
+
set -uo pipefail
|
|
45
|
+
|
|
46
|
+
# 1. HALT check. Even though this hook is observational, refusing to
|
|
47
|
+
# emit signals while frozen matches the rest of the hook tree and
|
|
48
|
+
# keeps the kill-switch contract uniform.
|
|
49
|
+
# shellcheck source=_lib/halt-check.sh
|
|
50
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
51
|
+
check_halt
|
|
52
|
+
REA_ROOT=$(rea_root)
|
|
53
|
+
|
|
54
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
55
|
+
|
|
56
|
+
# 2. Resolve the rea CLI through the same fixed 2-tier sandboxed order
|
|
57
|
+
# the protected-paths / blocked-paths bash gates use. PATH lookup
|
|
58
|
+
# is INTENTIONALLY OMITTED — agent-controlled $PATH would let a
|
|
59
|
+
# forged `rea` binary on a consumer machine intercept the
|
|
60
|
+
# delegation signal on every Agent/Skill dispatch. The trade-off:
|
|
61
|
+
# consumers MUST have `@bookedsolid/rea` installed under
|
|
62
|
+
# `node_modules` (the common case after `pnpm i`) OR be running
|
|
63
|
+
# against the rea repo's own dogfood (where dist/cli/index.js
|
|
64
|
+
# holds the canonical CLI). Other install shapes silently drop the
|
|
65
|
+
# signal — matching the bash-gate posture.
|
|
66
|
+
REA_ARGV=()
|
|
67
|
+
RESOLVED_CLI_PATH=""
|
|
68
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
69
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
70
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
71
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
72
|
+
# rea repo dogfood: the project IS @bookedsolid/rea.
|
|
73
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
74
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
78
|
+
# No rea CLI in scope — drop the signal silently. This is the
|
|
79
|
+
# expected state during bootstrap (consumer ran `rea init` but
|
|
80
|
+
# hasn't installed the npm package yet) or in non-rea repos. A
|
|
81
|
+
# noisy stderr warning here would fire on every Agent/Skill
|
|
82
|
+
# dispatch and drown legitimate signals.
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
# 3. Realpath sandbox check — mirrors protected-paths-bash-gate.sh §6.
|
|
87
|
+
# The resolved CLI MUST live inside realpath(CLAUDE_PROJECT_DIR)
|
|
88
|
+
# AND have an ancestor package.json declaring `@bookedsolid/rea`
|
|
89
|
+
# as its `name`. Catches symlink-out attacks where an attacker
|
|
90
|
+
# writes node_modules/@bookedsolid/rea → /tmp/forged-tree.
|
|
91
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
92
|
+
# Node not on PATH — we can't verify the CLI shape. Fail safe by
|
|
93
|
+
# dropping the signal (observability is not a security claim; the
|
|
94
|
+
# rest of the Bash gate suite refuses on this path).
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
sandbox_check=$(node -e '
|
|
99
|
+
const fs = require("fs");
|
|
100
|
+
const path = require("path");
|
|
101
|
+
const cli = process.argv[1];
|
|
102
|
+
const projDir = process.argv[2];
|
|
103
|
+
let real, realProj;
|
|
104
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
105
|
+
process.stdout.write("bad:realpath");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
109
|
+
process.stdout.write("bad:realpath-proj");
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
const sep = path.sep;
|
|
113
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
114
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
115
|
+
process.stdout.write("bad:cli-escapes-project");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
// Walk up looking for package.json with the protected name.
|
|
119
|
+
let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
|
|
120
|
+
let found = false;
|
|
121
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
122
|
+
const pj = path.join(cur, "package.json");
|
|
123
|
+
if (fs.existsSync(pj)) {
|
|
124
|
+
try {
|
|
125
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
126
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
127
|
+
} catch (e) { /* keep walking */ }
|
|
128
|
+
}
|
|
129
|
+
cur = path.dirname(cur);
|
|
130
|
+
}
|
|
131
|
+
if (!found) {
|
|
132
|
+
process.stdout.write("bad:no-rea-pkg-json");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write("ok");
|
|
136
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
137
|
+
|
|
138
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
139
|
+
# CLI failed the sandbox check — silent drop. The forensic
|
|
140
|
+
# breadcrumb in stderr is intentional but trimmed so this doesn't
|
|
141
|
+
# become spammy on every dispatch.
|
|
142
|
+
printf 'rea: delegation-capture skipped (sandbox check: %s)\n' "$sandbox_check" >&2
|
|
143
|
+
exit 0
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
# 4. Read stdin and pipe to the CLI. `--detach` tells the CLI to
|
|
147
|
+
# suppress stderr output (no parent shell is listening); we ALSO
|
|
148
|
+
# background the whole pipeline with `&` and `disown` so the
|
|
149
|
+
# shell hook returns instantly even if the CLI's own startup
|
|
150
|
+
# takes a few ms.
|
|
151
|
+
INPUT=$(cat)
|
|
152
|
+
{
|
|
153
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-signal --detach \
|
|
154
|
+
>/dev/null 2>&1 &
|
|
155
|
+
disown 2>/dev/null || true
|
|
156
|
+
} 2>/dev/null
|
|
157
|
+
|
|
158
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -43,3 +43,18 @@ context_protection:
|
|
|
43
43
|
- pnpm run test
|
|
44
44
|
- pnpm run lint
|
|
45
45
|
max_bash_output_lines: 100
|
|
46
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
47
|
+
# Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
|
|
48
|
+
# every commit when enabled. Even the bst-internal profile ships
|
|
49
|
+
# `enabled: false` so other BST contributors using the profile don't
|
|
50
|
+
# silently route their commits onto the profile author's GitHub
|
|
51
|
+
# heatmap; opt-in lives in repo-local edits to .rea/policy.yaml.
|
|
52
|
+
# attribution:
|
|
53
|
+
# co_author:
|
|
54
|
+
# enabled: true
|
|
55
|
+
# name: 'Your Name'
|
|
56
|
+
# email: 'you@example.com'
|
|
57
|
+
# skip_merge: false
|
|
58
|
+
attribution:
|
|
59
|
+
co_author:
|
|
60
|
+
enabled: false
|
|
@@ -51,3 +51,19 @@ architecture_review:
|
|
|
51
51
|
- hooks/_lib/
|
|
52
52
|
- templates/
|
|
53
53
|
- profiles/
|
|
54
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
55
|
+
# Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
|
|
56
|
+
# every commit when enabled. Even the bst-internal profile ships
|
|
57
|
+
# `enabled: false` so other BST contributors using the profile don't
|
|
58
|
+
# silently route their commits onto the profile author's GitHub
|
|
59
|
+
# heatmap; opt-in lives in repo-local edits to .rea/policy.yaml
|
|
60
|
+
# because the identity to roll commits onto is per-developer.
|
|
61
|
+
# attribution:
|
|
62
|
+
# co_author:
|
|
63
|
+
# enabled: true
|
|
64
|
+
# name: 'Your Name'
|
|
65
|
+
# email: 'you@example.com'
|
|
66
|
+
# skip_merge: false
|
|
67
|
+
attribution:
|
|
68
|
+
co_author:
|
|
69
|
+
enabled: false
|
|
@@ -21,3 +21,17 @@ context_protection:
|
|
|
21
21
|
- pnpm run build
|
|
22
22
|
- pnpm run test
|
|
23
23
|
max_bash_output_lines: 100
|
|
24
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
25
|
+
# Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
|
|
26
|
+
# every commit when enabled. client-engagement profile ships
|
|
27
|
+
# `enabled: false`; opt-in lives in repo-local edits to .rea/policy.yaml
|
|
28
|
+
# because the identity to roll commits onto is per-developer.
|
|
29
|
+
# attribution:
|
|
30
|
+
# co_author:
|
|
31
|
+
# enabled: true
|
|
32
|
+
# name: 'Your Name'
|
|
33
|
+
# email: 'you@example.com'
|
|
34
|
+
# skip_merge: false
|
|
35
|
+
attribution:
|
|
36
|
+
co_author:
|
|
37
|
+
enabled: false
|
package/profiles/lit-wc.yaml
CHANGED
|
@@ -15,3 +15,17 @@ blocked_paths:
|
|
|
15
15
|
- .github/workflows/publish.yml
|
|
16
16
|
- tokens/
|
|
17
17
|
notification_channel: ''
|
|
18
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
19
|
+
# Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
|
|
20
|
+
# every commit when enabled. lit-wc profile ships `enabled: false`;
|
|
21
|
+
# opt-in lives in repo-local edits to .rea/policy.yaml because the
|
|
22
|
+
# identity to roll commits onto is per-developer.
|
|
23
|
+
# attribution:
|
|
24
|
+
# co_author:
|
|
25
|
+
# enabled: true
|
|
26
|
+
# name: 'Your Name'
|
|
27
|
+
# email: 'you@example.com'
|
|
28
|
+
# skip_merge: false
|
|
29
|
+
attribution:
|
|
30
|
+
co_author:
|
|
31
|
+
enabled: false
|
package/profiles/minimal.yaml
CHANGED
|
@@ -9,3 +9,19 @@ blocked_paths:
|
|
|
9
9
|
- .env
|
|
10
10
|
- .env.*
|
|
11
11
|
notification_channel: ''
|
|
12
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
13
|
+
# When enabled: true, the husky prepare-commit-msg hook appends a
|
|
14
|
+
# Co-Authored-By trailer to every commit (or every non-merge commit
|
|
15
|
+
# when skip_merge: true). Idempotent on email match (case-insensitive).
|
|
16
|
+
# Profile defaults are off; opt in per-repo via .rea/policy.yaml —
|
|
17
|
+
# the identity to roll commits onto is per-developer.
|
|
18
|
+
#
|
|
19
|
+
# attribution:
|
|
20
|
+
# co_author:
|
|
21
|
+
# enabled: true
|
|
22
|
+
# name: 'Your Name'
|
|
23
|
+
# email: 'you@example.com'
|
|
24
|
+
# skip_merge: false
|
|
25
|
+
attribution:
|
|
26
|
+
co_author:
|
|
27
|
+
enabled: false
|
|
@@ -31,3 +31,16 @@ blocked_paths:
|
|
|
31
31
|
- .github/workflows/release.yml
|
|
32
32
|
- .github/workflows/publish.yml
|
|
33
33
|
notification_channel: ''
|
|
34
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
35
|
+
# Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
|
|
36
|
+
# every commit when enabled. Profile defaults are off — opt in
|
|
37
|
+
# per-repo via .rea/policy.yaml because the identity is per-developer.
|
|
38
|
+
# attribution:
|
|
39
|
+
# co_author:
|
|
40
|
+
# enabled: true
|
|
41
|
+
# name: 'Your Name'
|
|
42
|
+
# email: 'you@example.com'
|
|
43
|
+
# skip_merge: false
|
|
44
|
+
attribution:
|
|
45
|
+
co_author:
|
|
46
|
+
enabled: false
|
|
@@ -16,3 +16,16 @@ blocked_paths:
|
|
|
16
16
|
- .github/workflows/release.yml
|
|
17
17
|
- .github/workflows/publish.yml
|
|
18
18
|
notification_channel: ''
|
|
19
|
+
# 0.30.0 attribution augmenter — opt-in.
|
|
20
|
+
# Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
|
|
21
|
+
# every commit when enabled. Profile defaults are off — opt in
|
|
22
|
+
# per-repo via .rea/policy.yaml because the identity is per-developer.
|
|
23
|
+
# attribution:
|
|
24
|
+
# co_author:
|
|
25
|
+
# enabled: true
|
|
26
|
+
# name: 'Your Name'
|
|
27
|
+
# email: 'you@example.com'
|
|
28
|
+
# skip_merge: false
|
|
29
|
+
attribution:
|
|
30
|
+
co_author:
|
|
31
|
+
enabled: false
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# rea:prepare-commit-msg v1
|
|
3
|
+
# rea:augment-body-v1
|
|
4
|
+
#
|
|
5
|
+
# Husky prepare-commit-msg hook installed by `rea init` / `rea upgrade`.
|
|
6
|
+
# Do NOT edit by hand — the file is refreshed on every rea upgrade.
|
|
7
|
+
#
|
|
8
|
+
# Governance contract: when policy.attribution.co_author.enabled is
|
|
9
|
+
# `true`, append a `Co-Authored-By: <name> <email>` trailer to the
|
|
10
|
+
# commit message file. Idempotent on email match (case-insensitive,
|
|
11
|
+
# line-anchored). Skips merge commits when policy.attribution.co_author
|
|
12
|
+
# .skip_merge is true.
|
|
13
|
+
#
|
|
14
|
+
# Triggers under all five commit sources git delivers:
|
|
15
|
+
# - $2 unset / empty (`git commit` with no body provided)
|
|
16
|
+
# - $2 = 'message' (`git commit -m "..."`)
|
|
17
|
+
# - $2 = 'template' (commit.template configured)
|
|
18
|
+
# - $2 = 'merge' (merge commit; honored by skip_merge: true)
|
|
19
|
+
# - $2 = 'squash' (squash merge / rebase)
|
|
20
|
+
# - $2 = 'commit' (`git commit --amend`)
|
|
21
|
+
#
|
|
22
|
+
# Skip conditions:
|
|
23
|
+
# - REA_SKIP_ATTRIBUTION=1 in env (per-invocation override)
|
|
24
|
+
# - .rea/HALT present (kill switch active)
|
|
25
|
+
# - $1 (message file path) missing or not a file
|
|
26
|
+
# - policy.attribution.co_author.enabled !== true
|
|
27
|
+
#
|
|
28
|
+
# Coexistence: this hook does NOT block on anything. The companion
|
|
29
|
+
# `commit-msg` hook (which runs AFTER prepare-commit-msg in git's
|
|
30
|
+
# lifecycle) still enforces `block_ai_attribution`. A human trailer
|
|
31
|
+
# `Co-Authored-By: Real Name <real@email.tld>` is NOT AI attribution
|
|
32
|
+
# (no AI noreply domain, no AI name keyword) and is not blocked.
|
|
33
|
+
|
|
34
|
+
set -u
|
|
35
|
+
|
|
36
|
+
COMMIT_MSG_FILE="${1:-}"
|
|
37
|
+
COMMIT_SOURCE="${2:-}"
|
|
38
|
+
|
|
39
|
+
# Skip conditions: any missing precondition exits 0 silently. The hook
|
|
40
|
+
# is purely additive; refusing here would break commits with no upside.
|
|
41
|
+
|
|
42
|
+
# Missing message file → nothing to augment.
|
|
43
|
+
if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# Per-invocation override.
|
|
48
|
+
if [ -n "${REA_SKIP_ATTRIBUTION:-}" ]; then
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
53
|
+
|
|
54
|
+
# HALT kill switch — refuse to mutate anything while frozen.
|
|
55
|
+
if [ -f "${REA_ROOT}/.rea/HALT" ]; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
60
|
+
if [ ! -f "$POLICY_FILE" ]; then
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Delegate policy reads to the canonical rea CLI when available so we
|
|
65
|
+
# get the zod-validated document regardless of whether the operator
|
|
66
|
+
# wrote block-form (`attribution:\n co_author:\n enabled: true`)
|
|
67
|
+
# or inline-form (`attribution: { co_author: { enabled: true } }`)
|
|
68
|
+
# YAML. Codex round 1 P2: the prior Python inline parser only handled
|
|
69
|
+
# block form. When the CLI is unreachable (fresh consumer install
|
|
70
|
+
# pre-`pnpm i`, foreign dev environment, …) we fall back to the
|
|
71
|
+
# embedded Python state machine — it correctly handles block-form
|
|
72
|
+
# YAML, which is what `rea init` writes.
|
|
73
|
+
#
|
|
74
|
+
# Locator priority mirrors `.husky/pre-push`: project node_modules →
|
|
75
|
+
# dogfood dist → PATH.
|
|
76
|
+
rea_invoke() {
|
|
77
|
+
if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
|
|
78
|
+
"${REA_ROOT}/node_modules/.bin/rea" "$@"
|
|
79
|
+
elif [ -f "${REA_ROOT}/dist/cli/index.js" ] && [ -f "${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
|
|
80
|
+
node "${REA_ROOT}/dist/cli/index.js" "$@"
|
|
81
|
+
elif command -v rea >/dev/null 2>&1; then
|
|
82
|
+
rea "$@"
|
|
83
|
+
else
|
|
84
|
+
return 127
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ENABLED=$(rea_invoke hook policy-get attribution.co_author.enabled 2>/dev/null)
|
|
89
|
+
REA_RC=$?
|
|
90
|
+
|
|
91
|
+
if [ "$REA_RC" = "0" ]; then
|
|
92
|
+
CO_NAME=$(rea_invoke hook policy-get attribution.co_author.name 2>/dev/null || printf '')
|
|
93
|
+
CO_EMAIL=$(rea_invoke hook policy-get attribution.co_author.email 2>/dev/null || printf '')
|
|
94
|
+
SKIP_MERGE=$(rea_invoke hook policy-get attribution.co_author.skip_merge 2>/dev/null || printf 'false')
|
|
95
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
96
|
+
# rea CLI unreachable — fall back to Python block-form parser.
|
|
97
|
+
CO_AUTHOR_PARSE=$(python3 - "$POLICY_FILE" <<'PY' 2>/dev/null
|
|
98
|
+
import re
|
|
99
|
+
import sys
|
|
100
|
+
|
|
101
|
+
path = sys.argv[1]
|
|
102
|
+
try:
|
|
103
|
+
with open(path, 'r', encoding='utf-8') as fh:
|
|
104
|
+
lines = fh.readlines()
|
|
105
|
+
except OSError:
|
|
106
|
+
print('false'); print(''); print(''); print('false'); sys.exit(0)
|
|
107
|
+
|
|
108
|
+
in_attr = False
|
|
109
|
+
in_co = False
|
|
110
|
+
enabled = 'false'
|
|
111
|
+
name = ''
|
|
112
|
+
email = ''
|
|
113
|
+
skip_merge = 'false'
|
|
114
|
+
|
|
115
|
+
def strip_value(raw):
|
|
116
|
+
raw = raw.rstrip('\n').rstrip()
|
|
117
|
+
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
|
|
118
|
+
return raw[1:-1]
|
|
119
|
+
if '#' in raw:
|
|
120
|
+
raw = raw.split('#', 1)[0].rstrip()
|
|
121
|
+
return raw
|
|
122
|
+
|
|
123
|
+
for line in lines:
|
|
124
|
+
stripped_line = line.rstrip('\n')
|
|
125
|
+
if re.match(r'^\s*#', stripped_line):
|
|
126
|
+
continue
|
|
127
|
+
if re.match(r'^attribution:\s*(#.*)?$', stripped_line):
|
|
128
|
+
in_attr = True; in_co = False; continue
|
|
129
|
+
if in_attr and re.match(r'^\S', stripped_line):
|
|
130
|
+
in_attr = False; in_co = False
|
|
131
|
+
if in_attr and re.match(r'^\s+co_author:\s*(#.*)?$', stripped_line):
|
|
132
|
+
in_co = True; continue
|
|
133
|
+
if in_co:
|
|
134
|
+
m = re.match(r'^(\s*)\S', stripped_line)
|
|
135
|
+
if m and len(m.group(1)) <= 2:
|
|
136
|
+
in_co = False; continue
|
|
137
|
+
if re.search(r'enabled:\s*true(\s|$)', stripped_line):
|
|
138
|
+
enabled = 'true'
|
|
139
|
+
elif re.search(r'enabled:\s*false(\s|$)', stripped_line):
|
|
140
|
+
enabled = 'false'
|
|
141
|
+
if re.search(r'skip_merge:\s*true(\s|$)', stripped_line):
|
|
142
|
+
skip_merge = 'true'
|
|
143
|
+
elif re.search(r'skip_merge:\s*false(\s|$)', stripped_line):
|
|
144
|
+
skip_merge = 'false'
|
|
145
|
+
m = re.search(r'name:\s*(.*)$', stripped_line)
|
|
146
|
+
if m:
|
|
147
|
+
name = strip_value(m.group(1))
|
|
148
|
+
m = re.search(r'email:\s*(.*)$', stripped_line)
|
|
149
|
+
if m:
|
|
150
|
+
email = strip_value(m.group(1))
|
|
151
|
+
|
|
152
|
+
print(enabled); print(name); print(email); print(skip_merge)
|
|
153
|
+
PY
|
|
154
|
+
)
|
|
155
|
+
if [ -z "$CO_AUTHOR_PARSE" ]; then
|
|
156
|
+
exit 0
|
|
157
|
+
fi
|
|
158
|
+
ENABLED=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '1p')
|
|
159
|
+
CO_NAME=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '2p')
|
|
160
|
+
CO_EMAIL=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '3p')
|
|
161
|
+
SKIP_MERGE=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '4p')
|
|
162
|
+
else
|
|
163
|
+
# Neither rea CLI nor python3 reachable — silent no-op.
|
|
164
|
+
exit 0
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
if [ "$ENABLED" != "true" ]; then
|
|
168
|
+
exit 0
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
# Defense-in-depth: if we got here with enabled=true but no identity,
|
|
172
|
+
# the policy loader's cross-field refinement was bypassed (or someone
|
|
173
|
+
# edited the YAML around the load path). Bail without augmenting and
|
|
174
|
+
# emit a stderr advisory so the operator sees the misconfig at commit
|
|
175
|
+
# time. We deliberately do NOT exit non-zero — refusing the commit
|
|
176
|
+
# would be more disruptive than the silent no-op (the loader + doctor
|
|
177
|
+
# already surface the misconfig at policy load and at `rea doctor`).
|
|
178
|
+
#
|
|
179
|
+
# When `rea audit record <topic>` lands in a future release this
|
|
180
|
+
# branch should emit a `rea.attribution_augmented_invalid_config`
|
|
181
|
+
# record instead of stderr. Tracked as a 0.31.0+ item.
|
|
182
|
+
if [ -z "$CO_NAME" ] || [ -z "$CO_EMAIL" ]; then
|
|
183
|
+
printf 'rea: attribution.co_author.enabled=true but %s%s%s is empty — augmenter no-op.\n' \
|
|
184
|
+
"$([ -z "$CO_NAME" ] && printf name)" \
|
|
185
|
+
"$([ -z "$CO_NAME" ] && [ -z "$CO_EMAIL" ] && printf '+')" \
|
|
186
|
+
"$([ -z "$CO_EMAIL" ] && printf email)" >&2
|
|
187
|
+
printf 'rea: edit .rea/policy.yaml — set name + email, OR set enabled: false.\n' >&2
|
|
188
|
+
exit 0
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
# skip_merge: true → skip when commit source is 'merge'.
|
|
192
|
+
if [ "$SKIP_MERGE" = "true" ] && [ "$COMMIT_SOURCE" = "merge" ]; then
|
|
193
|
+
exit 0
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# Idempotency: scan the current message file for a Co-Authored-By line
|
|
197
|
+
# that names the same email (case-insensitive). Line-anchored — body
|
|
198
|
+
# prose mentioning the email in passing does NOT count.
|
|
199
|
+
LOWER_EMAIL=$(printf '%s' "$CO_EMAIL" | tr '[:upper:]' '[:lower:]')
|
|
200
|
+
# grep -E with case-insensitive flag; portable across BSD + GNU grep.
|
|
201
|
+
# The pattern: ^co-authored-by: <anything> <EMAIL>[ws]*$
|
|
202
|
+
# Email is regex-escaped via the conservative approach: assume the
|
|
203
|
+
# email passed policy validation (only safe chars per loader regex
|
|
204
|
+
# /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/), so the only metachars present
|
|
205
|
+
# are `.` and possibly `+` / `-`. We escape `.` and rely on the
|
|
206
|
+
# permissive char set.
|
|
207
|
+
ESCAPED_EMAIL=$(printf '%s' "$LOWER_EMAIL" | sed 's/[.[\*^$(){}+?|]/\\&/g')
|
|
208
|
+
if grep -iE "^co-authored-by:[[:space:]]*[^<]*<${ESCAPED_EMAIL}>[[:space:]]*$" \
|
|
209
|
+
"$COMMIT_MSG_FILE" >/dev/null 2>&1; then
|
|
210
|
+
exit 0
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# Build the trailer line. Idempotency above already lower-cased the
|
|
214
|
+
# email for comparison; we ship the trailer with the policy-supplied
|
|
215
|
+
# casing so the user's preferred display name + email render verbatim.
|
|
216
|
+
TRAILER="Co-Authored-By: ${CO_NAME} <${CO_EMAIL}>"
|
|
217
|
+
|
|
218
|
+
# Find the insert point: at the bottom of the message, after stripping
|
|
219
|
+
# trailing blank/comment lines (git's scissors line `# -- >8 --` and
|
|
220
|
+
# everything below is appended verbatim to preserve git's own view).
|
|
221
|
+
TMP_BODY=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || exit 0
|
|
222
|
+
TMP_TAIL=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || { rm -f "$TMP_BODY"; exit 0; }
|
|
223
|
+
trap 'rm -f "$TMP_BODY" "$TMP_TAIL"' EXIT INT TERM
|
|
224
|
+
|
|
225
|
+
# Split the file: body (above the scissors marker) vs. tail (scissors
|
|
226
|
+
# and everything below). Codex round 2 P1: previously used python3
|
|
227
|
+
# unconditionally — on environments where rea CLI is reachable but
|
|
228
|
+
# python3 is missing, the split silently failed and the user's commit
|
|
229
|
+
# body got dropped. awk is universally available on POSIX systems and
|
|
230
|
+
# does the same work.
|
|
231
|
+
SCISSORS='# ------------------------ >8 ------------------------'
|
|
232
|
+
awk -v scissors="$SCISSORS" -v body_dst="$TMP_BODY" -v tail_dst="$TMP_TAIL" '
|
|
233
|
+
BEGIN { found = 0 }
|
|
234
|
+
{
|
|
235
|
+
if (!found && $0 == scissors) found = 1
|
|
236
|
+
if (found) print > tail_dst
|
|
237
|
+
else print > body_dst
|
|
238
|
+
}
|
|
239
|
+
' "$COMMIT_MSG_FILE"
|
|
240
|
+
|
|
241
|
+
# Determine whether the body's last non-blank/non-comment line is a
|
|
242
|
+
# real git trailer (`Key: value` where Key matches `[A-Za-z][-A-Za-z0-9]*`)
|
|
243
|
+
# AND part of a multi-line trailer block (not the subject of a single-line
|
|
244
|
+
# conventional commit). Codex round 3 P1: the round-2 fix correctly
|
|
245
|
+
# rejected commit-prose `: ` patterns but still matched the conventional
|
|
246
|
+
# commit subject form `feat: add x` because that line is ALSO
|
|
247
|
+
# `[A-Za-z][-A-Za-z0-9]*: <value>`. The right distinguisher: a real
|
|
248
|
+
# trailer block has at least one preceding non-blank body line; a bare
|
|
249
|
+
# `feat: x` commit is just a subject and always needs a separator.
|
|
250
|
+
LAST_BODY_LINE=$(awk '
|
|
251
|
+
/^[[:space:]]*#/ { next }
|
|
252
|
+
/^[[:space:]]*$/ { next }
|
|
253
|
+
{ lastline = $0 }
|
|
254
|
+
END { if (lastline != "") print lastline }
|
|
255
|
+
' "$TMP_BODY")
|
|
256
|
+
BODY_LINE_COUNT=$(awk '
|
|
257
|
+
/^[[:space:]]*#/ { next }
|
|
258
|
+
/^[[:space:]]*$/ { next }
|
|
259
|
+
{ count++ }
|
|
260
|
+
END { print count + 0 }
|
|
261
|
+
' "$TMP_BODY")
|
|
262
|
+
|
|
263
|
+
SEPARATOR_NEEDED=1
|
|
264
|
+
if [ -z "$LAST_BODY_LINE" ]; then
|
|
265
|
+
SEPARATOR_NEEDED=0
|
|
266
|
+
elif [ "$BODY_LINE_COUNT" -gt 1 ] && printf '%s' "$LAST_BODY_LINE" | grep -qE '^[A-Za-z][-A-Za-z0-9]*: '; then
|
|
267
|
+
SEPARATOR_NEEDED=0
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
# Trim trailing blank lines from the body so the trailer lands cleanly
|
|
271
|
+
# (without leaving a triple-newline before it).
|
|
272
|
+
TMP_BODY_TRIMMED=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || exit 0
|
|
273
|
+
awk '
|
|
274
|
+
{ lines[NR] = $0; total = NR }
|
|
275
|
+
END {
|
|
276
|
+
end = total
|
|
277
|
+
while (end > 0 && lines[end] ~ /^[[:space:]]*$/) { end-- }
|
|
278
|
+
for (i = 1; i <= end; i++) print lines[i]
|
|
279
|
+
}
|
|
280
|
+
' "$TMP_BODY" > "$TMP_BODY_TRIMMED"
|
|
281
|
+
|
|
282
|
+
# Compose the new file: trimmed body + (optional blank) + trailer + tail.
|
|
283
|
+
{
|
|
284
|
+
cat "$TMP_BODY_TRIMMED"
|
|
285
|
+
if [ "$SEPARATOR_NEEDED" -eq 1 ]; then
|
|
286
|
+
printf '\n'
|
|
287
|
+
fi
|
|
288
|
+
printf '%s\n' "$TRAILER"
|
|
289
|
+
if [ -s "$TMP_TAIL" ]; then
|
|
290
|
+
cat "$TMP_TAIL"
|
|
291
|
+
fi
|
|
292
|
+
} > "${COMMIT_MSG_FILE}.rea-tmp" && mv "${COMMIT_MSG_FILE}.rea-tmp" "$COMMIT_MSG_FILE"
|
|
293
|
+
|
|
294
|
+
rm -f "$TMP_BODY_TRIMMED"
|
|
295
|
+
exit 0
|