@bookedsolid/rea 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/prepare-commit-msg +80 -6
- package/MIGRATING.md +24 -15
- package/dist/cli/hook.js +60 -22
- package/dist/hooks/_lib/halt-check.d.ts +78 -0
- package/dist/hooks/_lib/halt-check.js +106 -0
- package/dist/hooks/_lib/payload.d.ts +124 -0
- package/dist/hooks/_lib/payload.js +245 -0
- package/dist/hooks/_lib/segments.d.ts +125 -0
- package/dist/hooks/_lib/segments.js +766 -0
- package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
- package/dist/hooks/architecture-review-gate/index.js +250 -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/changeset-security-gate/index.d.ts +71 -0
- package/dist/hooks/changeset-security-gate/index.js +330 -0
- package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
- package/dist/hooks/dependency-audit-gate/index.js +294 -0
- package/dist/hooks/env-file-protection/index.d.ts +55 -0
- package/dist/hooks/env-file-protection/index.js +159 -0
- package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
- package/dist/hooks/pr-issue-link-gate/index.js +127 -0
- package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
- package/dist/hooks/security-disclosure-gate/index.js +502 -0
- package/hooks/_lib/protected-paths.sh +10 -3
- package/hooks/architecture-review-gate.sh +92 -77
- package/hooks/attribution-advisory.sh +139 -131
- package/hooks/changeset-security-gate.sh +114 -149
- package/hooks/dependency-audit-gate.sh +115 -156
- package/hooks/env-file-protection.sh +130 -97
- package/hooks/pr-issue-link-gate.sh +114 -45
- package/hooks/security-disclosure-gate.sh +148 -316
- package/hooks/settings-protection.sh +13 -9
- package/package.json +1 -1
- package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
- package/templates/attribution-advisory.dogfood-staged.sh +170 -0
- package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
- package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
- package/templates/env-file-protection.dogfood-staged.sh +157 -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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/architecture-review-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.33.0 Phase 1 port #4 — the SIMPLEST tier-1 port.
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse Write/Edit advisory. Reads `policy.architecture_review.
|
|
7
|
+
* patterns` and prints an advisory banner to stderr when the just-
|
|
8
|
+
* written file path begins with one of the configured prefixes.
|
|
9
|
+
* ALWAYS exits 0 — this is a nudge, not a gate.
|
|
10
|
+
*
|
|
11
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
12
|
+
*
|
|
13
|
+
* 1. HALT check → exit 2 with the shared banner. (Even though the
|
|
14
|
+
* gate is advisory, HALT short-circuits ALL hooks.)
|
|
15
|
+
* 2. `policy.architecture_advisory: false` short-circuit → exit 0
|
|
16
|
+
* silently. The bash hook reads the policy file with a grep
|
|
17
|
+
* `architecture_advisory: false`; we mirror via the canonical
|
|
18
|
+
* YAML loader.
|
|
19
|
+
* 3. Read stdin → `tool_input.file_path` (the bash hook uses
|
|
20
|
+
* `notebook_path` too via fall-through, but the original
|
|
21
|
+
* `jq -r '.tool_input.file_path // empty'` expression does NOT
|
|
22
|
+
* fall through to notebook_path. We preserve that exactly).
|
|
23
|
+
* 4. Empty file_path → exit 0.
|
|
24
|
+
* 5. Path normalization mirrors `_lib/path-normalize.sh::normalize_path`:
|
|
25
|
+
* - Convert backslashes to forward slashes (Windows / Git Bash).
|
|
26
|
+
* - URL-decode `%xx` sequences.
|
|
27
|
+
* - Strip a leading `<REA_ROOT>/` prefix if present so
|
|
28
|
+
* `policy.architecture_review.patterns` can use repo-relative
|
|
29
|
+
* patterns.
|
|
30
|
+
* 6. Read `policy.architecture_review.patterns`. Empty / unset →
|
|
31
|
+
* silent no-op (exit 0). The bst-internal profile pins rea-
|
|
32
|
+
* source patterns; consumer projects opt in by populating their
|
|
33
|
+
* own list.
|
|
34
|
+
* 7. First prefix match wins. Emit the advisory banner to stderr;
|
|
35
|
+
* exit 0.
|
|
36
|
+
*
|
|
37
|
+
* Distinct from the other 0.33.0 ports: this gate is POSTToolUse
|
|
38
|
+
* (fires AFTER the write, advisory only). The shim that invokes it
|
|
39
|
+
* should NOT fail-closed on missing CLI — the pre-0.33.0 bash hook
|
|
40
|
+
* was already a silent no-op when the policy was unset.
|
|
41
|
+
*/
|
|
42
|
+
import type { Buffer } from 'node:buffer';
|
|
43
|
+
export interface ArchitectureReviewGateOptions {
|
|
44
|
+
reaRoot?: string;
|
|
45
|
+
stdinOverride?: string | Buffer;
|
|
46
|
+
stderrWrite?: (s: string) => void;
|
|
47
|
+
}
|
|
48
|
+
export interface ArchitectureReviewGateResult {
|
|
49
|
+
exitCode: number;
|
|
50
|
+
stderr: string;
|
|
51
|
+
/** Test seam — the matched pattern (or `null`). */
|
|
52
|
+
matched: string | null;
|
|
53
|
+
}
|
|
54
|
+
export declare function runArchitectureReviewGate(options?: ArchitectureReviewGateOptions): Promise<ArchitectureReviewGateResult>;
|
|
55
|
+
/**
|
|
56
|
+
* CLI entry — `rea hook architecture-review-gate`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runHookArchitectureReviewGate(options?: ArchitectureReviewGateOptions): Promise<void>;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/architecture-review-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.33.0 Phase 1 port #4 — the SIMPLEST tier-1 port.
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse Write/Edit advisory. Reads `policy.architecture_review.
|
|
7
|
+
* patterns` and prints an advisory banner to stderr when the just-
|
|
8
|
+
* written file path begins with one of the configured prefixes.
|
|
9
|
+
* ALWAYS exits 0 — this is a nudge, not a gate.
|
|
10
|
+
*
|
|
11
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
12
|
+
*
|
|
13
|
+
* 1. HALT check → exit 2 with the shared banner. (Even though the
|
|
14
|
+
* gate is advisory, HALT short-circuits ALL hooks.)
|
|
15
|
+
* 2. `policy.architecture_advisory: false` short-circuit → exit 0
|
|
16
|
+
* silently. The bash hook reads the policy file with a grep
|
|
17
|
+
* `architecture_advisory: false`; we mirror via the canonical
|
|
18
|
+
* YAML loader.
|
|
19
|
+
* 3. Read stdin → `tool_input.file_path` (the bash hook uses
|
|
20
|
+
* `notebook_path` too via fall-through, but the original
|
|
21
|
+
* `jq -r '.tool_input.file_path // empty'` expression does NOT
|
|
22
|
+
* fall through to notebook_path. We preserve that exactly).
|
|
23
|
+
* 4. Empty file_path → exit 0.
|
|
24
|
+
* 5. Path normalization mirrors `_lib/path-normalize.sh::normalize_path`:
|
|
25
|
+
* - Convert backslashes to forward slashes (Windows / Git Bash).
|
|
26
|
+
* - URL-decode `%xx` sequences.
|
|
27
|
+
* - Strip a leading `<REA_ROOT>/` prefix if present so
|
|
28
|
+
* `policy.architecture_review.patterns` can use repo-relative
|
|
29
|
+
* patterns.
|
|
30
|
+
* 6. Read `policy.architecture_review.patterns`. Empty / unset →
|
|
31
|
+
* silent no-op (exit 0). The bst-internal profile pins rea-
|
|
32
|
+
* source patterns; consumer projects opt in by populating their
|
|
33
|
+
* own list.
|
|
34
|
+
* 7. First prefix match wins. Emit the advisory banner to stderr;
|
|
35
|
+
* exit 0.
|
|
36
|
+
*
|
|
37
|
+
* Distinct from the other 0.33.0 ports: this gate is POSTToolUse
|
|
38
|
+
* (fires AFTER the write, advisory only). The shim that invokes it
|
|
39
|
+
* should NOT fail-closed on missing CLI — the pre-0.33.0 bash hook
|
|
40
|
+
* was already a silent no-op when the policy was unset.
|
|
41
|
+
*/
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { parse as parseYaml } from 'yaml';
|
|
45
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
46
|
+
import { parseWriteHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
47
|
+
/**
|
|
48
|
+
* Normalize the incoming file path. Mirrors
|
|
49
|
+
* `hooks/_lib/path-normalize.sh::normalize_path`:
|
|
50
|
+
* - backslashes → forward slashes
|
|
51
|
+
* - URL-decoded
|
|
52
|
+
* - leading `<REA_ROOT>/` stripped (when applicable)
|
|
53
|
+
*
|
|
54
|
+
* Pre-0.16.0 the bash hook ONLY stripped the REA_ROOT prefix, which
|
|
55
|
+
* meant Windows / Git Bash backslash paths bypassed advisory.
|
|
56
|
+
*/
|
|
57
|
+
function normalizePath(rawPath, reaRoot) {
|
|
58
|
+
let p = rawPath.replace(/\\/g, '/');
|
|
59
|
+
try {
|
|
60
|
+
p = decodeURIComponent(p);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Malformed % escape — leave the string unchanged. The bash
|
|
64
|
+
// helper's `printf '%b'` behavior is similar (passes through).
|
|
65
|
+
}
|
|
66
|
+
// Strip leading <REA_ROOT>/. Compare normalized forms.
|
|
67
|
+
const normRoot = reaRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
68
|
+
if (normRoot.length > 0) {
|
|
69
|
+
if (p === normRoot)
|
|
70
|
+
return '';
|
|
71
|
+
const withSep = normRoot + '/';
|
|
72
|
+
if (p.startsWith(withSep)) {
|
|
73
|
+
p = p.slice(withSep.length);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 2026-05-15 codex round-1 P3 fix: strip chains of leading `./`
|
|
77
|
+
// segments. Mirrors `_lib/path-normalize.sh::path_canonical_form`.
|
|
78
|
+
// Pre-fix `./src/gateway/foo.ts` did NOT match the `src/gateway/`
|
|
79
|
+
// pattern because the leading `./` was preserved. Bash's
|
|
80
|
+
// path_canonical_form collapses `./` chains, so `./src/...`,
|
|
81
|
+
// `././src/...`, etc. all reduce to `src/...`.
|
|
82
|
+
while (p.startsWith('./')) {
|
|
83
|
+
p = p.slice(2);
|
|
84
|
+
}
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
function buildAdvisoryBanner(filePath, matched) {
|
|
88
|
+
return [
|
|
89
|
+
'ARCHITECTURE ADVISORY: Sensitive path modified\n',
|
|
90
|
+
'\n',
|
|
91
|
+
` File: ${filePath}\n`,
|
|
92
|
+
` Category: ${matched}\n`,
|
|
93
|
+
'\n',
|
|
94
|
+
' This file is in an architecture-sensitive directory.\n',
|
|
95
|
+
' Consider: Does this change maintain backward compatibility?\n',
|
|
96
|
+
' Consider: Should this be reviewed by the principal-engineer agent?\n',
|
|
97
|
+
].join('');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read `policy.architecture_review.patterns`. Returns `[]` on:
|
|
101
|
+
* - policy file missing
|
|
102
|
+
* - YAML unparseable
|
|
103
|
+
* - architecture_review unset
|
|
104
|
+
* - architecture_review.patterns unset/empty/non-list
|
|
105
|
+
*
|
|
106
|
+
* 2026-05-15 codex round-1 P3 fix: do NOT use `loadPolicy()` here.
|
|
107
|
+
* The strict zod schema throws on legacy keys / extra fields, which
|
|
108
|
+
* caused the catch to swallow patterns silently — a legacy policy.yaml
|
|
109
|
+
* with one unknown key would disable the advisory entirely, with no
|
|
110
|
+
* indication to the user.
|
|
111
|
+
*
|
|
112
|
+
* The bash original used `policy_list` (a non-strict reader). To match
|
|
113
|
+
* that behavior we read the YAML directly via the same permissive
|
|
114
|
+
* parser that `rea hook policy-get` uses (`yaml` package's `parse`),
|
|
115
|
+
* then pull `architecture_review.patterns` as a list of strings. Any
|
|
116
|
+
* non-string entry is filtered out. Unknown keys ELSEWHERE in the
|
|
117
|
+
* policy are tolerated — only the patterns subset matters for this
|
|
118
|
+
* advisory.
|
|
119
|
+
*/
|
|
120
|
+
function loadArchitecturePatterns(reaRoot, onWarning) {
|
|
121
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// File missing — bash hook treats this as "advisory disabled".
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = parseYaml(raw);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Unparseable YAML — log to stderr (NOT silent) and return [].
|
|
136
|
+
// The advisory still short-circuits to exit 0 since this is an
|
|
137
|
+
// advisory tier, but the user sees a one-line warning instead of
|
|
138
|
+
// mysterious silence.
|
|
139
|
+
onWarning('architecture-review-gate: policy.yaml is unparseable; advisory disabled\n');
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const ar = parsed['architecture_review'];
|
|
146
|
+
if (ar === undefined || ar === null || typeof ar !== 'object' || Array.isArray(ar)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const patterns = ar['patterns'];
|
|
150
|
+
if (!Array.isArray(patterns))
|
|
151
|
+
return [];
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const entry of patterns) {
|
|
154
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
155
|
+
out.push(entry);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Quick policy-disable probe. The bash hook reads
|
|
162
|
+
* `architecture_advisory: false` (legacy key — pre-0.20.1 toggle)
|
|
163
|
+
* directly from policy.yaml via grep. The canonical loader doesn't
|
|
164
|
+
* surface this key (it's not in the strict schema), so we re-read
|
|
165
|
+
* the raw YAML text. Returns true when the key is present and
|
|
166
|
+
* literally `false` (no other value disables the hook in the bash
|
|
167
|
+
* implementation).
|
|
168
|
+
*/
|
|
169
|
+
function isAdvisoryDisabled(reaRoot) {
|
|
170
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return /^architecture_advisory:\s*false\b/m.test(raw);
|
|
179
|
+
}
|
|
180
|
+
export async function runArchitectureReviewGate(options = {}) {
|
|
181
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
182
|
+
let stderr = '';
|
|
183
|
+
const writeStderr = (s) => {
|
|
184
|
+
stderr += s;
|
|
185
|
+
if (options.stderrWrite)
|
|
186
|
+
options.stderrWrite(s);
|
|
187
|
+
};
|
|
188
|
+
// 1. HALT.
|
|
189
|
+
const halt = checkHalt(reaRoot);
|
|
190
|
+
if (halt.halted) {
|
|
191
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
192
|
+
return { exitCode: 2, stderr, matched: null };
|
|
193
|
+
}
|
|
194
|
+
// 2. Disabled?
|
|
195
|
+
if (isAdvisoryDisabled(reaRoot)) {
|
|
196
|
+
return { exitCode: 0, stderr, matched: null };
|
|
197
|
+
}
|
|
198
|
+
// 3. Stdin.
|
|
199
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
200
|
+
? options.stdinOverride
|
|
201
|
+
: await readStdinWithTimeout(5_000);
|
|
202
|
+
let filePath = '';
|
|
203
|
+
try {
|
|
204
|
+
const payload = parseWriteHookPayload(stdinRaw);
|
|
205
|
+
filePath = payload.filePath;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
209
|
+
// Advisory tier: silently exit 0 on malformed payload. The bash
|
|
210
|
+
// hook used `jq -r '.tool_input.file_path // empty'` which
|
|
211
|
+
// coerces malformed JSON to empty stdout, then exits 0. Mirror
|
|
212
|
+
// that — never refuse on a parse error in the advisory path.
|
|
213
|
+
return { exitCode: 0, stderr, matched: null };
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
if (filePath.length === 0) {
|
|
218
|
+
return { exitCode: 0, stderr, matched: null };
|
|
219
|
+
}
|
|
220
|
+
const normalized = normalizePath(filePath, reaRoot);
|
|
221
|
+
if (normalized.length === 0) {
|
|
222
|
+
return { exitCode: 0, stderr, matched: null };
|
|
223
|
+
}
|
|
224
|
+
const patterns = loadArchitecturePatterns(reaRoot, writeStderr);
|
|
225
|
+
if (patterns.length === 0) {
|
|
226
|
+
return { exitCode: 0, stderr, matched: null };
|
|
227
|
+
}
|
|
228
|
+
let matched = null;
|
|
229
|
+
for (const pattern of patterns) {
|
|
230
|
+
if (normalized.startsWith(pattern)) {
|
|
231
|
+
matched = pattern;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (matched === null) {
|
|
236
|
+
return { exitCode: 0, stderr, matched: null };
|
|
237
|
+
}
|
|
238
|
+
writeStderr(buildAdvisoryBanner(normalized, matched));
|
|
239
|
+
return { exitCode: 0, stderr, matched };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* CLI entry — `rea hook architecture-review-gate`.
|
|
243
|
+
*/
|
|
244
|
+
export async function runHookArchitectureReviewGate(options = {}) {
|
|
245
|
+
const result = await runArchitectureReviewGate({
|
|
246
|
+
...options,
|
|
247
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
248
|
+
});
|
|
249
|
+
process.exit(result.exitCode);
|
|
250
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/attribution-advisory.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 Phase 1 Pilot #3 — opt-in policy-gated AI-attribution
|
|
5
|
+
* detector for `git commit` / `gh pr create|edit` commands.
|
|
6
|
+
*
|
|
7
|
+
* Why pilot #3 (and not #1): pilot #1 was the smallest port surface
|
|
8
|
+
* (no segments, no body-file resolution). Pilot #3 introduces the
|
|
9
|
+
* FULL `splitSegments` + `anySegmentStartsWith` + `anySegmentMatches`
|
|
10
|
+
* API surface. Pilot #2 (`security-disclosure-gate`) layers the
|
|
11
|
+
* file-IO body-file resolver on top of this same segment primitive.
|
|
12
|
+
*
|
|
13
|
+
* Behavioral contract — preserves bash hook byte-for-byte:
|
|
14
|
+
*
|
|
15
|
+
* 1. HALT check → exit 2 with shared banner. (Same as pilot 1.)
|
|
16
|
+
* 2. Read stdin payload. When `tool_input.command` is missing /
|
|
17
|
+
* empty, exit 0 silently.
|
|
18
|
+
* 3. Read `<reaRoot>/.rea/policy.yaml` and check for the line
|
|
19
|
+
* `block_ai_attribution: true`. The bash original used `grep -qE
|
|
20
|
+
* '^block_ai_attribution:[[:space:]]*true'` against the file
|
|
21
|
+
* directly; the Node port preserves the EXACT same regex against
|
|
22
|
+
* the file contents. NOT a YAML parse — the bash hook ran before
|
|
23
|
+
* we had a CLI-mediated `policy-get` read, and consumers may
|
|
24
|
+
* authored the line in either block or inline form. Matching the
|
|
25
|
+
* regex behavior preserves all the edge cases the bash hook
|
|
26
|
+
* shipped with.
|
|
27
|
+
* 4. Identify whether the command is RELEVANT — a `git commit` or
|
|
28
|
+
* `gh pr create|edit` invocation at the head of any segment.
|
|
29
|
+
* Uses `anySegmentStartsWith` (head-anchored, post-prefix-strip)
|
|
30
|
+
* so a quoted-body mention like `gh pr edit --body "ref: git
|
|
31
|
+
* commit earlier"` does NOT count as relevant.
|
|
32
|
+
* 5. Scan for FIVE attribution-marker classes, each via
|
|
33
|
+
* `anySegmentMatches` so the match has to live in the same
|
|
34
|
+
* segment as the relevant command head:
|
|
35
|
+
* a. `Co-Authored-By:` with an AI vendor noreply@ domain
|
|
36
|
+
* b. `Co-Authored-By:` with a known AI tool name
|
|
37
|
+
* c. `Generated|Created|Built|… with|by <AI Tool>`
|
|
38
|
+
* d. Markdown-linked tool name (`[Claude Code](`)
|
|
39
|
+
* e. Robot-emoji + Generated marker
|
|
40
|
+
* 6. Any match → exit 2 with the banner. No match → exit 0.
|
|
41
|
+
*
|
|
42
|
+
* Wider-net pattern choice: the bash hook used `[[:space:]]+` for
|
|
43
|
+
* `\s+` equivalents. JS regex uses `\s+` which is broader (includes
|
|
44
|
+
* vertical tab / form feed). For the ASCII payloads `gh` and `git`
|
|
45
|
+
* actually accept, the behavior is identical.
|
|
46
|
+
*/
|
|
47
|
+
import type { Buffer } from 'node:buffer';
|
|
48
|
+
export interface AttributionAdvisoryOptions {
|
|
49
|
+
reaRoot?: string;
|
|
50
|
+
stdinOverride?: string | Buffer;
|
|
51
|
+
stderrWrite?: (s: string) => void;
|
|
52
|
+
}
|
|
53
|
+
export interface AttributionAdvisoryResult {
|
|
54
|
+
exitCode: number;
|
|
55
|
+
stderr: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Pure executor — returns `{ exitCode, stderr }`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function runAttributionAdvisory(options?: AttributionAdvisoryOptions): Promise<AttributionAdvisoryResult>;
|
|
61
|
+
/**
|
|
62
|
+
* CLI entry — `rea hook attribution-advisory`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function runHookAttributionAdvisory(options?: AttributionAdvisoryOptions): Promise<void>;
|
|
65
|
+
export declare const __INTERNAL_BLOCK_BANNER_FOR_TESTS: string;
|
|
66
|
+
export declare const __INTERNAL_PATTERNS_FOR_TESTS: {
|
|
67
|
+
PATTERN_NOREPLY_AI: string;
|
|
68
|
+
PATTERN_COAUTH_AI_NAME: string;
|
|
69
|
+
PATTERN_GENERATED_WITH: string;
|
|
70
|
+
PATTERN_MD_LINK: string;
|
|
71
|
+
PATTERN_EMOJI: string;
|
|
72
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/attribution-advisory.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 Phase 1 Pilot #3 — opt-in policy-gated AI-attribution
|
|
5
|
+
* detector for `git commit` / `gh pr create|edit` commands.
|
|
6
|
+
*
|
|
7
|
+
* Why pilot #3 (and not #1): pilot #1 was the smallest port surface
|
|
8
|
+
* (no segments, no body-file resolution). Pilot #3 introduces the
|
|
9
|
+
* FULL `splitSegments` + `anySegmentStartsWith` + `anySegmentMatches`
|
|
10
|
+
* API surface. Pilot #2 (`security-disclosure-gate`) layers the
|
|
11
|
+
* file-IO body-file resolver on top of this same segment primitive.
|
|
12
|
+
*
|
|
13
|
+
* Behavioral contract — preserves bash hook byte-for-byte:
|
|
14
|
+
*
|
|
15
|
+
* 1. HALT check → exit 2 with shared banner. (Same as pilot 1.)
|
|
16
|
+
* 2. Read stdin payload. When `tool_input.command` is missing /
|
|
17
|
+
* empty, exit 0 silently.
|
|
18
|
+
* 3. Read `<reaRoot>/.rea/policy.yaml` and check for the line
|
|
19
|
+
* `block_ai_attribution: true`. The bash original used `grep -qE
|
|
20
|
+
* '^block_ai_attribution:[[:space:]]*true'` against the file
|
|
21
|
+
* directly; the Node port preserves the EXACT same regex against
|
|
22
|
+
* the file contents. NOT a YAML parse — the bash hook ran before
|
|
23
|
+
* we had a CLI-mediated `policy-get` read, and consumers may
|
|
24
|
+
* authored the line in either block or inline form. Matching the
|
|
25
|
+
* regex behavior preserves all the edge cases the bash hook
|
|
26
|
+
* shipped with.
|
|
27
|
+
* 4. Identify whether the command is RELEVANT — a `git commit` or
|
|
28
|
+
* `gh pr create|edit` invocation at the head of any segment.
|
|
29
|
+
* Uses `anySegmentStartsWith` (head-anchored, post-prefix-strip)
|
|
30
|
+
* so a quoted-body mention like `gh pr edit --body "ref: git
|
|
31
|
+
* commit earlier"` does NOT count as relevant.
|
|
32
|
+
* 5. Scan for FIVE attribution-marker classes, each via
|
|
33
|
+
* `anySegmentMatches` so the match has to live in the same
|
|
34
|
+
* segment as the relevant command head:
|
|
35
|
+
* a. `Co-Authored-By:` with an AI vendor noreply@ domain
|
|
36
|
+
* b. `Co-Authored-By:` with a known AI tool name
|
|
37
|
+
* c. `Generated|Created|Built|… with|by <AI Tool>`
|
|
38
|
+
* d. Markdown-linked tool name (`[Claude Code](`)
|
|
39
|
+
* e. Robot-emoji + Generated marker
|
|
40
|
+
* 6. Any match → exit 2 with the banner. No match → exit 0.
|
|
41
|
+
*
|
|
42
|
+
* Wider-net pattern choice: the bash hook used `[[:space:]]+` for
|
|
43
|
+
* `\s+` equivalents. JS regex uses `\s+` which is broader (includes
|
|
44
|
+
* vertical tab / form feed). For the ASCII payloads `gh` and `git`
|
|
45
|
+
* actually accept, the behavior is identical.
|
|
46
|
+
*/
|
|
47
|
+
import fs from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
50
|
+
import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
51
|
+
import { anySegmentStartsWith, anySegmentMatches } from '../_lib/segments.js';
|
|
52
|
+
const RELEVANT_GH = 'gh\\s+pr\\s+(create|edit)';
|
|
53
|
+
const RELEVANT_GIT_COMMIT = 'git\\s+commit';
|
|
54
|
+
/**
|
|
55
|
+
* `Co-Authored-By:` paired with a vendor `noreply@` domain that we
|
|
56
|
+
* recognize as AI tooling. GitHub's per-user `users.noreply.github.com`
|
|
57
|
+
* form is EXCLUDED — that's a legitimate human-collaborator credit.
|
|
58
|
+
*
|
|
59
|
+
* Mirrors the 0.18.0 helix-020 G4.B fix exactly: the catalog of
|
|
60
|
+
* recognized AI vendor noreply domains is enumerated here verbatim
|
|
61
|
+
* from the bash hook so adding/removing a vendor is a one-place edit.
|
|
62
|
+
*/
|
|
63
|
+
const NOREPLY_AI_DOMAINS = [
|
|
64
|
+
'anthropic\\.com',
|
|
65
|
+
'openai\\.com',
|
|
66
|
+
'github-copilot',
|
|
67
|
+
'github\\.com',
|
|
68
|
+
'claude\\.ai',
|
|
69
|
+
'chatgpt\\.com',
|
|
70
|
+
'googlemail\\.com',
|
|
71
|
+
'google\\.com',
|
|
72
|
+
'cursor\\.com',
|
|
73
|
+
'codeium\\.com',
|
|
74
|
+
'tabnine\\.com',
|
|
75
|
+
'amazon\\.com',
|
|
76
|
+
'amazonaws\\.com',
|
|
77
|
+
'amazon-q\\.amazonaws\\.com',
|
|
78
|
+
'cody\\.dev',
|
|
79
|
+
'sourcegraph\\.com',
|
|
80
|
+
'mistral\\.ai',
|
|
81
|
+
'xai-org',
|
|
82
|
+
'x\\.ai',
|
|
83
|
+
'inflection\\.ai',
|
|
84
|
+
'perplexity\\.ai',
|
|
85
|
+
'replit\\.com',
|
|
86
|
+
'jetbrains\\.com',
|
|
87
|
+
'bito\\.ai',
|
|
88
|
+
'pieces\\.app',
|
|
89
|
+
'phind\\.com',
|
|
90
|
+
'you\\.com',
|
|
91
|
+
];
|
|
92
|
+
const PATTERN_NOREPLY_AI = `Co-Authored-By:.*noreply@(${NOREPLY_AI_DOMAINS.join('|')})`;
|
|
93
|
+
const PATTERN_COAUTH_AI_NAME = 'Co-Authored-By:.*\\b(Claude|Sonnet|Opus|Haiku|Copilot|GPT|ChatGPT|Gemini|' +
|
|
94
|
+
'Cursor|Codeium|Tabnine|Amazon Q|CodeWhisperer|Devin|Windsurf|Cline|' +
|
|
95
|
+
'Aider|Anthropic|OpenAI|GitHub Copilot)\\b';
|
|
96
|
+
const PATTERN_GENERATED_WITH = '(Generated|Created|Built|Powered|Authored|Written|Produced)\\s+' +
|
|
97
|
+
'(with|by)\\s+' +
|
|
98
|
+
'(Claude|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|CodeWhisperer|' +
|
|
99
|
+
'Devin|Windsurf|Cline|Aider|AI|an? AI)\\b';
|
|
100
|
+
const PATTERN_MD_LINK = '\\[Claude Code\\]\\(|\\[GitHub Copilot\\]\\(|\\[ChatGPT\\]\\(|' +
|
|
101
|
+
'\\[Gemini\\]\\(|\\[Cursor\\]\\(';
|
|
102
|
+
const PATTERN_EMOJI = '🤖.*[Gg]enerated';
|
|
103
|
+
const BLOCK_BANNER = [
|
|
104
|
+
'\n',
|
|
105
|
+
'═══════════════════════════════════════════════════════════════════\n',
|
|
106
|
+
' BLOCKED: AI attribution detected in command\n',
|
|
107
|
+
'═══════════════════════════════════════════════════════════════════\n',
|
|
108
|
+
'\n',
|
|
109
|
+
' Your command contains structural AI attribution markers.\n',
|
|
110
|
+
'\n',
|
|
111
|
+
' What gets BLOCKED (structural attribution):\n',
|
|
112
|
+
' - Co-Authored-By with AI names or noreply@ emails\n',
|
|
113
|
+
' - "Generated with/by [AI Tool]" footer lines\n',
|
|
114
|
+
' - Markdown-linked tool names: [Claude Code](...)\n',
|
|
115
|
+
' - Emoji attribution: 🤖 Generated...\n',
|
|
116
|
+
'\n',
|
|
117
|
+
' What is ALLOWED (legitimate references):\n',
|
|
118
|
+
' - "Fix Claude API integration"\n',
|
|
119
|
+
' - "Update OpenAI SDK version"\n',
|
|
120
|
+
' - "Add Copilot config"\n',
|
|
121
|
+
'\n',
|
|
122
|
+
' Remove the attribution markers and rewrite the command.\n',
|
|
123
|
+
' To disable: set block_ai_attribution: false in .rea/policy.yaml\n',
|
|
124
|
+
'═══════════════════════════════════════════════════════════════════\n',
|
|
125
|
+
'\n',
|
|
126
|
+
].join('');
|
|
127
|
+
/**
|
|
128
|
+
* Check whether the policy file enables `block_ai_attribution`. Same
|
|
129
|
+
* grep posture as the bash hook (`grep -qE
|
|
130
|
+
* '^block_ai_attribution:[[:space:]]*true' "$POLICY_FILE"`).
|
|
131
|
+
*
|
|
132
|
+
* Missing file → not enabled. Read errors → not enabled (the policy
|
|
133
|
+
* itself becomes the gate's input; an unreadable policy can't say
|
|
134
|
+
* "block", so the safe posture is to no-op like the bash hook does).
|
|
135
|
+
*/
|
|
136
|
+
function isAttributionBlockingEnabled(reaRoot) {
|
|
137
|
+
const policyFile = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
138
|
+
if (!fs.existsSync(policyFile))
|
|
139
|
+
return false;
|
|
140
|
+
let content;
|
|
141
|
+
try {
|
|
142
|
+
content = fs.readFileSync(policyFile, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
// ERE: `^block_ai_attribution:[[:space:]]*true`. JS regex equiv:
|
|
148
|
+
// `^block_ai_attribution:\s*true` with multiline anchor.
|
|
149
|
+
return /^block_ai_attribution:\s*true/m.test(content);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Pure executor — returns `{ exitCode, stderr }`.
|
|
153
|
+
*/
|
|
154
|
+
export async function runAttributionAdvisory(options = {}) {
|
|
155
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
156
|
+
let stderr = '';
|
|
157
|
+
const writeStderr = (s) => {
|
|
158
|
+
stderr += s;
|
|
159
|
+
if (options.stderrWrite)
|
|
160
|
+
options.stderrWrite(s);
|
|
161
|
+
};
|
|
162
|
+
// 1. HALT check.
|
|
163
|
+
const halt = checkHalt(reaRoot);
|
|
164
|
+
if (halt.halted) {
|
|
165
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
166
|
+
return { exitCode: 2, stderr };
|
|
167
|
+
}
|
|
168
|
+
// 2. Read stdin.
|
|
169
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
170
|
+
? options.stdinOverride
|
|
171
|
+
: await readStdinWithTimeout(5_000);
|
|
172
|
+
let cmd = '';
|
|
173
|
+
try {
|
|
174
|
+
const payload = parseHookPayload(stdinRaw);
|
|
175
|
+
cmd = payload.command;
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
179
|
+
writeStderr(`attribution-advisory: ${err.message} — refusing on uncertainty.\n`);
|
|
180
|
+
return { exitCode: 2, stderr };
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
if (cmd.length === 0) {
|
|
185
|
+
return { exitCode: 0, stderr };
|
|
186
|
+
}
|
|
187
|
+
// 3. Policy gate.
|
|
188
|
+
if (!isAttributionBlockingEnabled(reaRoot)) {
|
|
189
|
+
return { exitCode: 0, stderr };
|
|
190
|
+
}
|
|
191
|
+
// 4. Relevance gate — only act on `git commit` / `gh pr create|edit`.
|
|
192
|
+
const isRelevant = anySegmentStartsWith(cmd, RELEVANT_GH) ||
|
|
193
|
+
anySegmentStartsWith(cmd, RELEVANT_GIT_COMMIT);
|
|
194
|
+
if (!isRelevant) {
|
|
195
|
+
return { exitCode: 0, stderr };
|
|
196
|
+
}
|
|
197
|
+
// 5. Attribution scan.
|
|
198
|
+
let found = false;
|
|
199
|
+
if (anySegmentMatches(cmd, PATTERN_NOREPLY_AI))
|
|
200
|
+
found = true;
|
|
201
|
+
if (!found && anySegmentMatches(cmd, PATTERN_COAUTH_AI_NAME))
|
|
202
|
+
found = true;
|
|
203
|
+
if (!found && anySegmentMatches(cmd, PATTERN_GENERATED_WITH))
|
|
204
|
+
found = true;
|
|
205
|
+
if (!found && anySegmentMatches(cmd, PATTERN_MD_LINK))
|
|
206
|
+
found = true;
|
|
207
|
+
if (!found && anySegmentMatches(cmd, PATTERN_EMOJI))
|
|
208
|
+
found = true;
|
|
209
|
+
if (found) {
|
|
210
|
+
writeStderr(BLOCK_BANNER);
|
|
211
|
+
return { exitCode: 2, stderr };
|
|
212
|
+
}
|
|
213
|
+
return { exitCode: 0, stderr };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* CLI entry — `rea hook attribution-advisory`.
|
|
217
|
+
*/
|
|
218
|
+
export async function runHookAttributionAdvisory(options = {}) {
|
|
219
|
+
const result = await runAttributionAdvisory({
|
|
220
|
+
...options,
|
|
221
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
222
|
+
});
|
|
223
|
+
process.exit(result.exitCode);
|
|
224
|
+
}
|
|
225
|
+
// Internal exports for tests.
|
|
226
|
+
export const __INTERNAL_BLOCK_BANNER_FOR_TESTS = BLOCK_BANNER;
|
|
227
|
+
export const __INTERNAL_PATTERNS_FOR_TESTS = {
|
|
228
|
+
PATTERN_NOREPLY_AI,
|
|
229
|
+
PATTERN_COAUTH_AI_NAME,
|
|
230
|
+
PATTERN_GENERATED_WITH,
|
|
231
|
+
PATTERN_MD_LINK,
|
|
232
|
+
PATTERN_EMOJI,
|
|
233
|
+
};
|
|
@@ -136,13 +136,25 @@ function isKillSwitchInvariant(p) {
|
|
|
136
136
|
/**
|
|
137
137
|
* Test whether a normalized lowercase project-relative path falls
|
|
138
138
|
* inside the documented husky extension surface
|
|
139
|
-
* (`.husky/{commit-msg,pre-push,pre-commit}.d/<fragment>`).
|
|
139
|
+
* (`.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/<fragment>`).
|
|
140
140
|
*
|
|
141
141
|
* The bare directory itself (`.husky/pre-push.d/`) and the dir node
|
|
142
142
|
* (`.husky/pre-push.d`) do NOT match — only fragments inside.
|
|
143
|
+
*
|
|
144
|
+
* 0.32.0 codex round 2 P1: `.husky/prepare-commit-msg.d/` joined the
|
|
145
|
+
* carve-out to match settings-protection.sh §5b and the bash-tier
|
|
146
|
+
* `rea_path_is_extension_surface` helper. Without this, the new
|
|
147
|
+
* prepare-commit-msg migration path documented in MIGRATING.md was
|
|
148
|
+
* still refused by the Node-binary protected-scan even though the
|
|
149
|
+
* Write/Edit allow-list permitted it.
|
|
143
150
|
*/
|
|
144
151
|
function isExtensionSurface(pathLc) {
|
|
145
|
-
const surfaces = [
|
|
152
|
+
const surfaces = [
|
|
153
|
+
'.husky/commit-msg.d/',
|
|
154
|
+
'.husky/pre-push.d/',
|
|
155
|
+
'.husky/pre-commit.d/',
|
|
156
|
+
'.husky/prepare-commit-msg.d/',
|
|
157
|
+
];
|
|
146
158
|
for (const s of surfaces) {
|
|
147
159
|
if (pathLc.startsWith(s) && pathLc.length > s.length) {
|
|
148
160
|
return true;
|