@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
|
@@ -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;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/pr-issue-link-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 Phase 1 Pilot #1 — selected first because the bash original
|
|
5
|
+
* has the smallest dependency surface in the hook tree:
|
|
6
|
+
*
|
|
7
|
+
* - No segment splitter (just substring match on `gh pr create`)
|
|
8
|
+
* - No `--body-file` resolution
|
|
9
|
+
* - No multi-pattern catalog
|
|
10
|
+
* - Advisory only (always exits 0)
|
|
11
|
+
*
|
|
12
|
+
* That makes it the safest place to validate the playbook end-to-end:
|
|
13
|
+
* archive bash → write TS module → wire `rea hook pr-issue-link-gate`
|
|
14
|
+
* subcommand → replace .sh with a 15-line shim → mirror to
|
|
15
|
+
* `.claude/hooks/pr-issue-link-gate.sh` (PROTECTED — staged for git
|
|
16
|
+
* apply) → byte-fidelity test → consumer migration via `rea upgrade`
|
|
17
|
+
* picks up the new shim on next install.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral contract — preserves bash hook byte-for-byte:
|
|
20
|
+
*
|
|
21
|
+
* 1. HALT check — exits 2 with banner when `.rea/HALT` is present.
|
|
22
|
+
* Bash original called `check_halt` from `_lib/halt-check.sh`;
|
|
23
|
+
* Node port calls the shared `checkHalt` primitive in
|
|
24
|
+
* `src/hooks/_lib/halt-check.ts`. Same fail-closed posture.
|
|
25
|
+
* 2. Reads stdin payload, extracts `tool_input.command`. When the
|
|
26
|
+
* tool isn't `Bash`, exits 0 silently (matches bash original
|
|
27
|
+
* `[[ "$TOOL_NAME" != "Bash" ]] && exit 0`).
|
|
28
|
+
* 3. When command does NOT contain `gh\s+pr\s+create`, exits 0.
|
|
29
|
+
* 4. When command DOES contain a closing keyword paired with `#N`
|
|
30
|
+
* (case-insensitive `closes`/`fixes`/`resolves` + whitespace +
|
|
31
|
+
* `#` + digits), exits 0 — the agent has already linked an issue.
|
|
32
|
+
* 5. Otherwise, prints the same advisory banner to stderr and exits
|
|
33
|
+
* 0 (advisory only — never blocks).
|
|
34
|
+
*
|
|
35
|
+
* Wider-net pattern choice: the bash original used `grep -qiE
|
|
36
|
+
* 'gh\s+pr\s+create'` (free `\s` shorthand). The Node port uses the
|
|
37
|
+
* equivalent JavaScript regex `/gh\s+pr\s+create/i` — same byte
|
|
38
|
+
* outcomes for ASCII inputs, which is the only shape `gh` accepts.
|
|
39
|
+
*/
|
|
40
|
+
import type { Buffer } from 'node:buffer';
|
|
41
|
+
export interface PrIssueLinkGateOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Override REA_ROOT. Production caller relies on
|
|
44
|
+
* `$CLAUDE_PROJECT_DIR` → `process.cwd()`. Tests set this.
|
|
45
|
+
*/
|
|
46
|
+
reaRoot?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Pre-supplied stdin bytes. When set, skip the stdin read and feed
|
|
49
|
+
* this string into `parseHookPayload`. Tests use this to avoid
|
|
50
|
+
* touching `process.stdin`.
|
|
51
|
+
*/
|
|
52
|
+
stdinOverride?: string | Buffer;
|
|
53
|
+
/**
|
|
54
|
+
* Test seam — receives every stderr write the gate produces. Default
|
|
55
|
+
* is `(s) => process.stderr.write(s)`.
|
|
56
|
+
*/
|
|
57
|
+
stderrWrite?: (s: string) => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Result tuple — `{ exitCode, stderr }`. The CLI wrapper translates
|
|
61
|
+
* `exitCode` into `process.exit`; tests inspect `stderr` for the
|
|
62
|
+
* advisory banner shape.
|
|
63
|
+
*
|
|
64
|
+
* `exitCode` follows the bash hook's contract:
|
|
65
|
+
* 0 — allow / advisory only
|
|
66
|
+
* 2 — HALT active OR malformed payload (fail-closed)
|
|
67
|
+
*
|
|
68
|
+
* The bash hook itself never exits non-zero except via `check_halt`;
|
|
69
|
+
* the Node port adds the malformed-JSON fail-closed exit mirroring
|
|
70
|
+
* `runHookScanBash`'s posture (an attacker who can craft a payload
|
|
71
|
+
* shouldn't get a free allow).
|
|
72
|
+
*/
|
|
73
|
+
export interface PrIssueLinkGateResult {
|
|
74
|
+
exitCode: number;
|
|
75
|
+
/** Full stderr concatenated for test inspection. */
|
|
76
|
+
stderr: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Pure executor — no `process.exit`, no stdin read (when
|
|
80
|
+
* `stdinOverride` is set), no HALT-check side effects beyond reading
|
|
81
|
+
* the file. Returns the exit code + full stderr; the CLI wrapper
|
|
82
|
+
* applies them to the actual process.
|
|
83
|
+
*/
|
|
84
|
+
export declare function runPrIssueLinkGate(options?: PrIssueLinkGateOptions): Promise<PrIssueLinkGateResult>;
|
|
85
|
+
/**
|
|
86
|
+
* CLI entry — `rea hook pr-issue-link-gate`. Wires the pure executor
|
|
87
|
+
* to `process.stderr.write` + `process.exit`. Mirrors the wiring
|
|
88
|
+
* pattern in `runHookScanBash` / `runHookCodexReview`.
|
|
89
|
+
*/
|
|
90
|
+
export declare function runHookPrIssueLinkGate(options?: PrIssueLinkGateOptions): Promise<void>;
|
|
91
|
+
export declare const __INTERNAL_ADVISORY_BANNER_FOR_TESTS: string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/pr-issue-link-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 Phase 1 Pilot #1 — selected first because the bash original
|
|
5
|
+
* has the smallest dependency surface in the hook tree:
|
|
6
|
+
*
|
|
7
|
+
* - No segment splitter (just substring match on `gh pr create`)
|
|
8
|
+
* - No `--body-file` resolution
|
|
9
|
+
* - No multi-pattern catalog
|
|
10
|
+
* - Advisory only (always exits 0)
|
|
11
|
+
*
|
|
12
|
+
* That makes it the safest place to validate the playbook end-to-end:
|
|
13
|
+
* archive bash → write TS module → wire `rea hook pr-issue-link-gate`
|
|
14
|
+
* subcommand → replace .sh with a 15-line shim → mirror to
|
|
15
|
+
* `.claude/hooks/pr-issue-link-gate.sh` (PROTECTED — staged for git
|
|
16
|
+
* apply) → byte-fidelity test → consumer migration via `rea upgrade`
|
|
17
|
+
* picks up the new shim on next install.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral contract — preserves bash hook byte-for-byte:
|
|
20
|
+
*
|
|
21
|
+
* 1. HALT check — exits 2 with banner when `.rea/HALT` is present.
|
|
22
|
+
* Bash original called `check_halt` from `_lib/halt-check.sh`;
|
|
23
|
+
* Node port calls the shared `checkHalt` primitive in
|
|
24
|
+
* `src/hooks/_lib/halt-check.ts`. Same fail-closed posture.
|
|
25
|
+
* 2. Reads stdin payload, extracts `tool_input.command`. When the
|
|
26
|
+
* tool isn't `Bash`, exits 0 silently (matches bash original
|
|
27
|
+
* `[[ "$TOOL_NAME" != "Bash" ]] && exit 0`).
|
|
28
|
+
* 3. When command does NOT contain `gh\s+pr\s+create`, exits 0.
|
|
29
|
+
* 4. When command DOES contain a closing keyword paired with `#N`
|
|
30
|
+
* (case-insensitive `closes`/`fixes`/`resolves` + whitespace +
|
|
31
|
+
* `#` + digits), exits 0 — the agent has already linked an issue.
|
|
32
|
+
* 5. Otherwise, prints the same advisory banner to stderr and exits
|
|
33
|
+
* 0 (advisory only — never blocks).
|
|
34
|
+
*
|
|
35
|
+
* Wider-net pattern choice: the bash original used `grep -qiE
|
|
36
|
+
* 'gh\s+pr\s+create'` (free `\s` shorthand). The Node port uses the
|
|
37
|
+
* equivalent JavaScript regex `/gh\s+pr\s+create/i` — same byte
|
|
38
|
+
* outcomes for ASCII inputs, which is the only shape `gh` accepts.
|
|
39
|
+
*/
|
|
40
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
41
|
+
import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
42
|
+
const ADVISORY_BANNER = [
|
|
43
|
+
'PR ISSUE LINK ADVISORY: This PR does not reference a GitHub issue.\n',
|
|
44
|
+
'\n',
|
|
45
|
+
'When a PR body includes a closing reference, GitHub automatically:\n',
|
|
46
|
+
' - Closes the issue when the PR merges to the default branch\n',
|
|
47
|
+
' - Creates a cross-reference in the issue timeline\n',
|
|
48
|
+
' - Links the PR in the CHANGELOG context\n',
|
|
49
|
+
'\n',
|
|
50
|
+
'Add to the --body:\n',
|
|
51
|
+
' closes #N closes one issue\n',
|
|
52
|
+
' fixes #N same effect\n',
|
|
53
|
+
' resolves #N same effect\n',
|
|
54
|
+
' closes #N, closes #M closes multiple issues\n',
|
|
55
|
+
'\n',
|
|
56
|
+
'If this is a chore, release, or hotfix PR with no upstream issue, you may proceed.\n',
|
|
57
|
+
].join('');
|
|
58
|
+
/**
|
|
59
|
+
* Pure executor — no `process.exit`, no stdin read (when
|
|
60
|
+
* `stdinOverride` is set), no HALT-check side effects beyond reading
|
|
61
|
+
* the file. Returns the exit code + full stderr; the CLI wrapper
|
|
62
|
+
* applies them to the actual process.
|
|
63
|
+
*/
|
|
64
|
+
export async function runPrIssueLinkGate(options = {}) {
|
|
65
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
66
|
+
let stderr = '';
|
|
67
|
+
const writeStderr = (s) => {
|
|
68
|
+
stderr += s;
|
|
69
|
+
if (options.stderrWrite)
|
|
70
|
+
options.stderrWrite(s);
|
|
71
|
+
};
|
|
72
|
+
// 1. HALT check — fail-closed (exit 2).
|
|
73
|
+
const halt = checkHalt(reaRoot);
|
|
74
|
+
if (halt.halted) {
|
|
75
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
76
|
+
return { exitCode: 2, stderr };
|
|
77
|
+
}
|
|
78
|
+
// 2. Read stdin.
|
|
79
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
80
|
+
? options.stdinOverride
|
|
81
|
+
: await readStdinWithTimeout(5_000);
|
|
82
|
+
let toolName = '';
|
|
83
|
+
let cmd = '';
|
|
84
|
+
try {
|
|
85
|
+
const payload = parseHookPayload(stdinRaw);
|
|
86
|
+
toolName = payload.toolName;
|
|
87
|
+
cmd = payload.command;
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
91
|
+
writeStderr(`pr-issue-link-gate: ${err.message} — refusing on uncertainty.\n`);
|
|
92
|
+
return { exitCode: 2, stderr };
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
// 3. Only Bash tool calls.
|
|
97
|
+
if (toolName !== '' && toolName !== 'Bash') {
|
|
98
|
+
return { exitCode: 0, stderr };
|
|
99
|
+
}
|
|
100
|
+
// 4. Only `gh pr create`.
|
|
101
|
+
if (!/gh\s+pr\s+create/i.test(cmd)) {
|
|
102
|
+
return { exitCode: 0, stderr };
|
|
103
|
+
}
|
|
104
|
+
// 5. Closing keyword paired with `#N` → satisfied, no advisory.
|
|
105
|
+
if (/(closes|fixes|resolves)\s+#[0-9]+/i.test(cmd)) {
|
|
106
|
+
return { exitCode: 0, stderr };
|
|
107
|
+
}
|
|
108
|
+
// 6. Advisory.
|
|
109
|
+
writeStderr(ADVISORY_BANNER);
|
|
110
|
+
return { exitCode: 0, stderr };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* CLI entry — `rea hook pr-issue-link-gate`. Wires the pure executor
|
|
114
|
+
* to `process.stderr.write` + `process.exit`. Mirrors the wiring
|
|
115
|
+
* pattern in `runHookScanBash` / `runHookCodexReview`.
|
|
116
|
+
*/
|
|
117
|
+
export async function runHookPrIssueLinkGate(options = {}) {
|
|
118
|
+
const result = await runPrIssueLinkGate({
|
|
119
|
+
...options,
|
|
120
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
121
|
+
});
|
|
122
|
+
process.exit(result.exitCode);
|
|
123
|
+
}
|
|
124
|
+
// Internal export — used by the byte-fidelity test to assert the
|
|
125
|
+
// advisory banner string hasn't drifted vs. the bash hook's
|
|
126
|
+
// `printf` lines.
|
|
127
|
+
export const __INTERNAL_ADVISORY_BANNER_FOR_TESTS = ADVISORY_BANNER;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/security-disclosure-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 Phase 1 Pilot #2 — env-var-policy + body-file-resolver +
|
|
5
|
+
* mode-aware redirect router for `gh issue create` commands that
|
|
6
|
+
* mention vulnerability-class keywords.
|
|
7
|
+
*
|
|
8
|
+
* Why pilot #2 (and not #3): pilot #2 is the LARGEST of the three
|
|
9
|
+
* (339 LOC bash) and exercises every primitive landed in Phase 0:
|
|
10
|
+
* - `checkHalt` (Phase 0)
|
|
11
|
+
* - `parseHookPayload` (Phase 0)
|
|
12
|
+
* - `splitSegments` / `anySegmentStartsWith` (Phase 0, used by
|
|
13
|
+
* pilot #3 first but in scope here for `gh issue create`)
|
|
14
|
+
* - File-IO resolver for `--body-file` / `-F` paths with `..`
|
|
15
|
+
* traversal refusal, ABSOLUTE-vs-relative resolution, 64 KiB cap.
|
|
16
|
+
* - Read of `REA_DISCLOSURE_MODE` env var with three-state semantics
|
|
17
|
+
* (`advisory` / `issues` / `disabled`).
|
|
18
|
+
*
|
|
19
|
+
* Behavioral contract — preserves bash hook byte-for-byte:
|
|
20
|
+
*
|
|
21
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
22
|
+
* 2. Read `REA_DISCLOSURE_MODE` env var. `disabled` → exit 0
|
|
23
|
+
* immediately (no scan at all).
|
|
24
|
+
* 3. Read stdin. If `tool_name` isn't `Bash`, exit 0.
|
|
25
|
+
* 4. Identify `gh issue create` segments via `anySegmentStartsWith`.
|
|
26
|
+
* Substring fallback when the segment splitter is unreachable is
|
|
27
|
+
* moot in Node — `splitSegments` is always in scope. (The bash
|
|
28
|
+
* hook had a fallback only because `cmd-segments.sh` might be
|
|
29
|
+
* absent in foreign installs.)
|
|
30
|
+
* 5. Resolve `--body-file PATH` and `-F PATH` arguments. The
|
|
31
|
+
* resolver MUST match the bash quote-aware awk tokenizer for the
|
|
32
|
+
* shape `--body-file "path with spaces.md"` — we run our own
|
|
33
|
+
* quote-aware walker that yields each `--body-file` / `-F`
|
|
34
|
+
* value. Stdin form (`-`) is skipped. Paths whose CANONICAL form
|
|
35
|
+
* (after resolving `..` segments) escape REA_ROOT are REFUSED
|
|
36
|
+
* with exit 2 + advisory banner (matches the 0.17.0 helix-019 #1
|
|
37
|
+
* fix). Readable files contribute the first 64 KiB to the scan
|
|
38
|
+
* buffer; unreadable files print a warning and continue.
|
|
39
|
+
* 6. Build `FULL_TEXT` = body-file contents + command text (both
|
|
40
|
+
* lowercased) and scan for SECURITY_PATTERNS (an ordered list of
|
|
41
|
+
* ERE patterns mirroring the bash array). First match wins;
|
|
42
|
+
* `MATCHED_PATTERN` becomes the body-banner placeholder.
|
|
43
|
+
* 7. Route on mode:
|
|
44
|
+
* - `issues` → block banner pointing to `gh issue create
|
|
45
|
+
* --label 'security,internal' …` private form
|
|
46
|
+
* - `advisory` → block banner pointing to `gh api
|
|
47
|
+
* repos/.../security-advisories` private form
|
|
48
|
+
* Both return exit 2.
|
|
49
|
+
*
|
|
50
|
+
* Out-of-scope vs. the bash hook (intentional simplifications):
|
|
51
|
+
*
|
|
52
|
+
* - The bash hook emits `json_output "block" "..."` via
|
|
53
|
+
* `_lib/common.sh`. The JSON format is a Claude Code-specific
|
|
54
|
+
* wrapper that lets the hook present a structured block reason
|
|
55
|
+
* to the agent. In the Node tier, the canonical surface is `{
|
|
56
|
+
* hookSpecificOutput: { hookEventName: 'PreToolUse', ... } }`
|
|
57
|
+
* emitted on STDOUT with exit code 0; the legacy bash hook emits
|
|
58
|
+
* it on stdout. We preserve that exact shape via `emitJsonBlock`.
|
|
59
|
+
* - The bash hook's `require_jq` check is moot — Node parses JSON
|
|
60
|
+
* natively.
|
|
61
|
+
*/
|
|
62
|
+
import { Buffer } from 'node:buffer';
|
|
63
|
+
export type DisclosureMode = 'advisory' | 'issues' | 'disabled';
|
|
64
|
+
export interface SecurityDisclosureGateOptions {
|
|
65
|
+
reaRoot?: string;
|
|
66
|
+
stdinOverride?: string | Buffer;
|
|
67
|
+
stderrWrite?: (s: string) => void;
|
|
68
|
+
stdoutWrite?: (s: string) => void;
|
|
69
|
+
/** Override `REA_DISCLOSURE_MODE`. Production reads `process.env`. */
|
|
70
|
+
disclosureModeOverride?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Override `cwd()` for relative `--body-file` path resolution. The
|
|
73
|
+
* bash hook uses `pwd` (the shell's cwd at hook-execution time).
|
|
74
|
+
* Tests inject this so they don't have to `process.chdir`.
|
|
75
|
+
*/
|
|
76
|
+
cwdOverride?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface SecurityDisclosureGateResult {
|
|
79
|
+
exitCode: number;
|
|
80
|
+
stderr: string;
|
|
81
|
+
stdout: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Pure executor.
|
|
85
|
+
*/
|
|
86
|
+
export declare function runSecurityDisclosureGate(options?: SecurityDisclosureGateOptions): Promise<SecurityDisclosureGateResult>;
|
|
87
|
+
/**
|
|
88
|
+
* CLI entry — `rea hook security-disclosure-gate`.
|
|
89
|
+
*/
|
|
90
|
+
export declare function runHookSecurityDisclosureGate(options?: SecurityDisclosureGateOptions): Promise<void>;
|
|
91
|
+
export declare const __INTERNAL_SECURITY_PATTERNS_FOR_TESTS: readonly string[];
|