@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,502 @@
|
|
|
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
|
+
import fs from 'node:fs';
|
|
64
|
+
import path from 'node:path';
|
|
65
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
66
|
+
import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
67
|
+
import { anySegmentStartsWith } from '../_lib/segments.js';
|
|
68
|
+
/**
|
|
69
|
+
* Ordered list of ERE patterns that indicate a security finding when
|
|
70
|
+
* present in a public issue. Order mirrors the bash array
|
|
71
|
+
* `SECURITY_PATTERNS=(...)` so the `MATCHED_PATTERN` placeholder
|
|
72
|
+
* picks the same first-match string the bash hook would have.
|
|
73
|
+
*/
|
|
74
|
+
const SECURITY_PATTERNS = [
|
|
75
|
+
// Vulnerability classes
|
|
76
|
+
'bypass',
|
|
77
|
+
'exploit',
|
|
78
|
+
'injection',
|
|
79
|
+
'traversal',
|
|
80
|
+
'exfiltrat',
|
|
81
|
+
'escalat',
|
|
82
|
+
'privilege',
|
|
83
|
+
'rce',
|
|
84
|
+
'remote.code.exec',
|
|
85
|
+
'arbitrary.code',
|
|
86
|
+
'code.execution',
|
|
87
|
+
'zero.day',
|
|
88
|
+
'0day',
|
|
89
|
+
'CVE-',
|
|
90
|
+
'CVSS',
|
|
91
|
+
'GHSA-',
|
|
92
|
+
// Reagent-specific sensitive terms
|
|
93
|
+
'hook.bypass',
|
|
94
|
+
'HALT.bypass',
|
|
95
|
+
'redaction.bypass',
|
|
96
|
+
'policy.bypass',
|
|
97
|
+
'middleware.bypass',
|
|
98
|
+
'skip.*gate',
|
|
99
|
+
'evad',
|
|
100
|
+
// Credential/secret exposure
|
|
101
|
+
'secret.*leak',
|
|
102
|
+
'credential.*leak',
|
|
103
|
+
'token.*leak',
|
|
104
|
+
'key.*expos',
|
|
105
|
+
'expos.*secret',
|
|
106
|
+
// Prompt injection
|
|
107
|
+
'prompt.inject',
|
|
108
|
+
'jailbreak',
|
|
109
|
+
'jail.break',
|
|
110
|
+
];
|
|
111
|
+
const BODY_FILE_BYTE_CAP = 64 * 1024;
|
|
112
|
+
/**
|
|
113
|
+
* Quote-aware tokenizer that yields each `--body-file <PATH>` and
|
|
114
|
+
* `-F <PATH>` argument from the raw command string. Mirrors the awk
|
|
115
|
+
* walker in security-disclosure-gate.sh#_extract_body_file_paths,
|
|
116
|
+
* including the 0.18.0 helix-020 G3.B `\<space>` plain-mode escape
|
|
117
|
+
* fix.
|
|
118
|
+
*/
|
|
119
|
+
function extractBodyFilePaths(cmd) {
|
|
120
|
+
// First, tokenize the command string with quote/escape awareness
|
|
121
|
+
// and yield tokens. Then walk tokens looking for `--body-file` /
|
|
122
|
+
// `-F` (consume next), or `--body-file=PATH` / `-F=PATH` (use the
|
|
123
|
+
// inline value).
|
|
124
|
+
const tokens = [];
|
|
125
|
+
let i = 0;
|
|
126
|
+
const n = cmd.length;
|
|
127
|
+
let tok = '';
|
|
128
|
+
let mode = 'plain';
|
|
129
|
+
const flush = () => {
|
|
130
|
+
if (tok.length > 0) {
|
|
131
|
+
tokens.push(tok);
|
|
132
|
+
tok = '';
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
while (i < n) {
|
|
136
|
+
const ch = cmd[i];
|
|
137
|
+
if (mode === 'plain') {
|
|
138
|
+
if (ch === '\\' && i + 1 < n) {
|
|
139
|
+
// Plain-mode `\X` → literal X. helix-020 G3.B fix.
|
|
140
|
+
tok += cmd[i + 1];
|
|
141
|
+
i += 2;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === ' ' || ch === '\t' || ch === '\n') {
|
|
145
|
+
flush();
|
|
146
|
+
i += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ch === '"') {
|
|
150
|
+
mode = 'dquote';
|
|
151
|
+
tok += ch;
|
|
152
|
+
i += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ch === "'") {
|
|
156
|
+
mode = 'squote';
|
|
157
|
+
tok += ch;
|
|
158
|
+
i += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
tok += ch;
|
|
162
|
+
i += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (mode === 'dquote') {
|
|
166
|
+
if (ch === '\\' && i + 1 < n) {
|
|
167
|
+
// Preserve `\"` / `\\` literally inside the token; bash's
|
|
168
|
+
// `awk` walker emits the escape sequence verbatim, and
|
|
169
|
+
// strip_outer_quotes handles the outer pair.
|
|
170
|
+
tok += ch + cmd[i + 1];
|
|
171
|
+
i += 2;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === '"') {
|
|
175
|
+
mode = 'plain';
|
|
176
|
+
tok += ch;
|
|
177
|
+
i += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
tok += ch;
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// mode === 'squote'
|
|
185
|
+
if (ch === "'") {
|
|
186
|
+
mode = 'plain';
|
|
187
|
+
tok += ch;
|
|
188
|
+
i += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
tok += ch;
|
|
192
|
+
i += 1;
|
|
193
|
+
}
|
|
194
|
+
flush();
|
|
195
|
+
/**
|
|
196
|
+
* Strip a single outer pair of matching `"..."` or `'...'`.
|
|
197
|
+
* Mirrors awk strip_outer_quotes.
|
|
198
|
+
*/
|
|
199
|
+
const stripOuterQuotes = (s) => {
|
|
200
|
+
if (s.length < 2)
|
|
201
|
+
return s;
|
|
202
|
+
const first = s[0];
|
|
203
|
+
const last = s[s.length - 1];
|
|
204
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
205
|
+
return s.slice(1, -1);
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
};
|
|
209
|
+
const out = [];
|
|
210
|
+
let skipNext = false;
|
|
211
|
+
for (const t of tokens) {
|
|
212
|
+
if (skipNext) {
|
|
213
|
+
skipNext = false;
|
|
214
|
+
if (t === '-' || t === '')
|
|
215
|
+
continue;
|
|
216
|
+
const stripped = stripOuterQuotes(t);
|
|
217
|
+
out.push({ raw: stripped, isStdinForm: false });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (t === '--body-file' || t === '-F') {
|
|
221
|
+
skipNext = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (t.startsWith('--body-file=')) {
|
|
225
|
+
const v = stripOuterQuotes(t.slice('--body-file='.length));
|
|
226
|
+
if (v !== '' && v !== '-')
|
|
227
|
+
out.push({ raw: v, isStdinForm: false });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (t.startsWith('-F=')) {
|
|
231
|
+
const v = stripOuterQuotes(t.slice('-F='.length));
|
|
232
|
+
if (v !== '' && v !== '-')
|
|
233
|
+
out.push({ raw: v, isStdinForm: false });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Canonicalize a path by walking `..` segments. Mirrors the bash
|
|
241
|
+
* resolver — pure-string, NO `fs.realpath` (we explicitly do NOT want
|
|
242
|
+
* to follow symlinks here; the protected-paths gates do that
|
|
243
|
+
* separately).
|
|
244
|
+
*/
|
|
245
|
+
function canonicalizePath(abs) {
|
|
246
|
+
const parts = abs.split('/');
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const p of parts) {
|
|
249
|
+
if (p === '' || p === '.')
|
|
250
|
+
continue;
|
|
251
|
+
if (p === '..') {
|
|
252
|
+
if (out.length > 0)
|
|
253
|
+
out.pop();
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
out.push(p);
|
|
257
|
+
}
|
|
258
|
+
return '/' + out.join('/');
|
|
259
|
+
}
|
|
260
|
+
function resolveBodyFile(bodyPath, reaRoot, cwd) {
|
|
261
|
+
const isAbsolute = bodyPath.startsWith('/');
|
|
262
|
+
const abs = isAbsolute ? bodyPath : path.join(cwd, bodyPath);
|
|
263
|
+
// Detect traversal in the RAW path (matches the bash check `case
|
|
264
|
+
// "/$raw_path/" in */../*) had_traversal=1 ;; esac`).
|
|
265
|
+
const hadTraversal = `/${bodyPath}/`.includes('/../');
|
|
266
|
+
let resolved = abs;
|
|
267
|
+
if (hadTraversal) {
|
|
268
|
+
resolved = canonicalizePath(abs);
|
|
269
|
+
// Hard refusal if resolved escapes REA_ROOT.
|
|
270
|
+
const reaRootCanonical = canonicalizePath(reaRoot);
|
|
271
|
+
if (resolved !== reaRootCanonical &&
|
|
272
|
+
!resolved.startsWith(reaRootCanonical + '/')) {
|
|
273
|
+
return { kind: 'traversal', resolved, raw: bodyPath };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Check readability.
|
|
277
|
+
try {
|
|
278
|
+
fs.accessSync(resolved, fs.constants.R_OK);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { kind: 'unreadable', raw: bodyPath };
|
|
282
|
+
}
|
|
283
|
+
// Final check — make sure it's a regular file (not a directory).
|
|
284
|
+
try {
|
|
285
|
+
const st = fs.statSync(resolved);
|
|
286
|
+
if (!st.isFile())
|
|
287
|
+
return { kind: 'unreadable', raw: bodyPath };
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return { kind: 'unreadable', raw: bodyPath };
|
|
291
|
+
}
|
|
292
|
+
return { kind: 'ok', resolved };
|
|
293
|
+
}
|
|
294
|
+
function readBodyFileChunk(p) {
|
|
295
|
+
// Read up to BODY_FILE_BYTE_CAP bytes. Lowercase to match the
|
|
296
|
+
// bash hook's `tr '[:upper:]' '[:lower:]'`.
|
|
297
|
+
try {
|
|
298
|
+
const fd = fs.openSync(p, 'r');
|
|
299
|
+
try {
|
|
300
|
+
const buf = Buffer.alloc(BODY_FILE_BYTE_CAP);
|
|
301
|
+
const bytesRead = fs.readSync(fd, buf, 0, BODY_FILE_BYTE_CAP, 0);
|
|
302
|
+
return buf.slice(0, bytesRead).toString('utf8').toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
fs.closeSync(fd);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return '';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function normalizeDisclosureMode(raw) {
|
|
313
|
+
if (raw === 'issues')
|
|
314
|
+
return 'issues';
|
|
315
|
+
if (raw === 'disabled')
|
|
316
|
+
return 'disabled';
|
|
317
|
+
// Default and unrecognized → 'advisory'. Mirrors the bash hook's
|
|
318
|
+
// default and silent-default-on-bogus posture.
|
|
319
|
+
return 'advisory';
|
|
320
|
+
}
|
|
321
|
+
function emitTraversalRefusal(rawPath, resolved) {
|
|
322
|
+
return [
|
|
323
|
+
'SECURITY DISCLOSURE GATE: --body-file path traversal escapes project root\n',
|
|
324
|
+
'\n',
|
|
325
|
+
` Path: ${rawPath}\n`,
|
|
326
|
+
` Resolved: ${resolved}\n`,
|
|
327
|
+
'\n',
|
|
328
|
+
' Rule: --body-file paths whose canonical form uses `..` segments to\n',
|
|
329
|
+
' escape REA_ROOT are refused. Move the file inside the project\n',
|
|
330
|
+
' tree, or paste the body inline via --body.\n',
|
|
331
|
+
].join('');
|
|
332
|
+
}
|
|
333
|
+
function emitBlockJsonAndStderr(reason) {
|
|
334
|
+
// Claude Code PreToolUse hook block format. Mirrors `json_output
|
|
335
|
+
// "block" "..."` in _lib/common.sh — which printed `message` to
|
|
336
|
+
// stderr before exiting 2. 0.32.0 codex round 2 P2: restore the
|
|
337
|
+
// stderr banner so hook runners that only surface stderr (the
|
|
338
|
+
// pre-0.32.0 bash hook contract, plus any non-Claude-Code wrapper
|
|
339
|
+
// that ignores the JSON-on-stdout protocol) still get the
|
|
340
|
+
// remediation text. Newline terminator matches `printf '%s\n'`.
|
|
341
|
+
const obj = {
|
|
342
|
+
hookSpecificOutput: {
|
|
343
|
+
hookEventName: 'PreToolUse',
|
|
344
|
+
permissionDecision: 'deny',
|
|
345
|
+
permissionDecisionReason: reason,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
return { json: JSON.stringify(obj) + '\n', stderr: reason + '\n' };
|
|
349
|
+
}
|
|
350
|
+
function buildIssuesModeReason(matched) {
|
|
351
|
+
return `SECURITY DISCLOSURE GATE: This issue appears to describe a security finding (matched: '${matched}').
|
|
352
|
+
|
|
353
|
+
This project is configured for PRIVATE disclosure (REA_DISCLOSURE_MODE=issues).
|
|
354
|
+
|
|
355
|
+
CORRECT PATH for security findings in this private repo:
|
|
356
|
+
Use: gh issue create --label 'security,internal' --title '...' --body '...'
|
|
357
|
+
|
|
358
|
+
The 'security' and 'internal' labels keep this off public project boards and
|
|
359
|
+
mark it for maintainer-only triage. Do NOT use the public issue queue without
|
|
360
|
+
these labels for security findings.
|
|
361
|
+
|
|
362
|
+
If this is NOT a security finding, rephrase the title/body to avoid triggering
|
|
363
|
+
security patterns, then retry.`;
|
|
364
|
+
}
|
|
365
|
+
function buildAdvisoryModeReason(matched, mode) {
|
|
366
|
+
return `SECURITY DISCLOSURE GATE: This issue appears to describe a security vulnerability (matched: '${matched}'). Do NOT create a public GitHub issue for security vulnerabilities.
|
|
367
|
+
|
|
368
|
+
CORRECT DISCLOSURE PATH:
|
|
369
|
+
1. Use GitHub Security Advisories (private):
|
|
370
|
+
gh api repos/{owner}/{repo}/security-advisories --method POST --input - <<'JSON'
|
|
371
|
+
{ "summary": "...", "description": "...", "severity": "medium|high|critical",
|
|
372
|
+
"vulnerabilities": [{"package": {"name": "@pkg", "ecosystem": "npm"}}] }
|
|
373
|
+
JSON
|
|
374
|
+
2. Or navigate to: Security tab → Advisories → 'Report a vulnerability'
|
|
375
|
+
3. Or email security@bookedsolid.tech (see SECURITY.md)
|
|
376
|
+
|
|
377
|
+
The finding will be publicly disclosed AFTER a patch is released (coordinated disclosure).
|
|
378
|
+
|
|
379
|
+
WHY: Public issues expose vulnerabilities before users can patch. This is enforced by the
|
|
380
|
+
security-disclosure-gate hook (REA_DISCLOSURE_MODE=${mode}).
|
|
381
|
+
|
|
382
|
+
If this is NOT a security vulnerability, rephrase the issue to avoid triggering
|
|
383
|
+
security patterns, then retry.`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Pure executor.
|
|
387
|
+
*/
|
|
388
|
+
export async function runSecurityDisclosureGate(options = {}) {
|
|
389
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
390
|
+
const cwd = options.cwdOverride ?? process.cwd();
|
|
391
|
+
let stderr = '';
|
|
392
|
+
let stdout = '';
|
|
393
|
+
const writeStderr = (s) => {
|
|
394
|
+
stderr += s;
|
|
395
|
+
if (options.stderrWrite)
|
|
396
|
+
options.stderrWrite(s);
|
|
397
|
+
};
|
|
398
|
+
const writeStdout = (s) => {
|
|
399
|
+
stdout += s;
|
|
400
|
+
if (options.stdoutWrite)
|
|
401
|
+
options.stdoutWrite(s);
|
|
402
|
+
};
|
|
403
|
+
// 1. HALT check.
|
|
404
|
+
const halt = checkHalt(reaRoot);
|
|
405
|
+
if (halt.halted) {
|
|
406
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
407
|
+
return { exitCode: 2, stderr, stdout };
|
|
408
|
+
}
|
|
409
|
+
// 2. Disclosure mode.
|
|
410
|
+
const rawMode = options.disclosureModeOverride ?? process.env['REA_DISCLOSURE_MODE'];
|
|
411
|
+
const mode = normalizeDisclosureMode(rawMode);
|
|
412
|
+
if (mode === 'disabled') {
|
|
413
|
+
return { exitCode: 0, stderr, stdout };
|
|
414
|
+
}
|
|
415
|
+
// 3. Stdin.
|
|
416
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
417
|
+
? options.stdinOverride
|
|
418
|
+
: await readStdinWithTimeout(5_000);
|
|
419
|
+
let toolName = '';
|
|
420
|
+
let cmd = '';
|
|
421
|
+
try {
|
|
422
|
+
const payload = parseHookPayload(stdinRaw);
|
|
423
|
+
toolName = payload.toolName;
|
|
424
|
+
cmd = payload.command;
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
428
|
+
writeStderr(`security-disclosure-gate: ${err.message} — refusing on uncertainty.\n`);
|
|
429
|
+
return { exitCode: 2, stderr, stdout };
|
|
430
|
+
}
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
if (toolName !== '' && toolName !== 'Bash') {
|
|
434
|
+
return { exitCode: 0, stderr, stdout };
|
|
435
|
+
}
|
|
436
|
+
if (cmd.length === 0) {
|
|
437
|
+
return { exitCode: 0, stderr, stdout };
|
|
438
|
+
}
|
|
439
|
+
// 4. Only intercept `gh issue create` (head-anchored).
|
|
440
|
+
if (!anySegmentStartsWith(cmd, 'gh\\s+issue\\s+create')) {
|
|
441
|
+
return { exitCode: 0, stderr, stdout };
|
|
442
|
+
}
|
|
443
|
+
// 5. Body-file resolution.
|
|
444
|
+
const bodyTokens = extractBodyFilePaths(cmd);
|
|
445
|
+
let bodyFileText = '';
|
|
446
|
+
for (const tok of bodyTokens) {
|
|
447
|
+
if (tok.isStdinForm)
|
|
448
|
+
continue;
|
|
449
|
+
const r = resolveBodyFile(tok.raw, reaRoot, cwd);
|
|
450
|
+
if (r.kind === 'traversal') {
|
|
451
|
+
writeStderr(emitTraversalRefusal(r.raw, r.resolved));
|
|
452
|
+
return { exitCode: 2, stderr, stdout };
|
|
453
|
+
}
|
|
454
|
+
if (r.kind === 'unreadable') {
|
|
455
|
+
writeStderr(`security-disclosure-gate: --body-file ${r.raw} unreadable; skipping body scan\n`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const chunk = readBodyFileChunk(r.resolved);
|
|
459
|
+
if (chunk.length > 0)
|
|
460
|
+
bodyFileText += '\n' + chunk;
|
|
461
|
+
}
|
|
462
|
+
// 6. Pattern scan.
|
|
463
|
+
const fullText = bodyFileText + '\n' + cmd.toLowerCase();
|
|
464
|
+
let matched = '';
|
|
465
|
+
for (const p of SECURITY_PATTERNS) {
|
|
466
|
+
const re = new RegExp(p, 'i');
|
|
467
|
+
if (re.test(fullText)) {
|
|
468
|
+
matched = p;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (matched === '') {
|
|
473
|
+
return { exitCode: 0, stderr, stdout };
|
|
474
|
+
}
|
|
475
|
+
// 7. Mode-aware routing.
|
|
476
|
+
const reason = mode === 'issues'
|
|
477
|
+
? buildIssuesModeReason(matched)
|
|
478
|
+
: buildAdvisoryModeReason(matched, mode);
|
|
479
|
+
const blockOutput = emitBlockJsonAndStderr(reason);
|
|
480
|
+
writeStdout(blockOutput.json);
|
|
481
|
+
// 0.32.0 codex round 2 P2: also emit the remediation banner to
|
|
482
|
+
// stderr so hook runners that only surface stderr (legacy bash
|
|
483
|
+
// hook contract, non-Claude-Code wrappers) still see the
|
|
484
|
+
// operator-facing reason text. Claude Code itself prefers the
|
|
485
|
+
// JSON on stdout but tolerates duplicate stderr.
|
|
486
|
+
if (blockOutput.stderr.length > 0)
|
|
487
|
+
writeStderr(blockOutput.stderr);
|
|
488
|
+
return { exitCode: 2, stderr, stdout };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* CLI entry — `rea hook security-disclosure-gate`.
|
|
492
|
+
*/
|
|
493
|
+
export async function runHookSecurityDisclosureGate(options = {}) {
|
|
494
|
+
const result = await runSecurityDisclosureGate({
|
|
495
|
+
...options,
|
|
496
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
497
|
+
stdoutWrite: (s) => process.stdout.write(s),
|
|
498
|
+
});
|
|
499
|
+
process.exit(result.exitCode);
|
|
500
|
+
}
|
|
501
|
+
// Internal exports for tests.
|
|
502
|
+
export const __INTERNAL_SECURITY_PATTERNS_FOR_TESTS = SECURITY_PATTERNS;
|
|
@@ -242,7 +242,7 @@ _rea_load_protected_patterns() {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
# Test whether a project-relative path is in the documented husky
|
|
245
|
-
# extension surface (`.husky/commit-msg
|
|
245
|
+
# extension surface (`.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/*`).
|
|
246
246
|
# Returns 0 on match, 1 on no match. Case-insensitive.
|
|
247
247
|
#
|
|
248
248
|
# 0.16.4 helix-018 Option B: settings-protection.sh §5b has carved
|
|
@@ -253,17 +253,24 @@ _rea_load_protected_patterns() {
|
|
|
253
253
|
# redirect was refused by the bash-gate even though the equivalent
|
|
254
254
|
# Write-tool call would succeed. This helper bakes the carve-out
|
|
255
255
|
# into the shared lib so every caller inherits it uniformly.
|
|
256
|
+
#
|
|
257
|
+
# 0.32.0 codex round 2 P1: `.husky/prepare-commit-msg.d/*` joins the
|
|
258
|
+
# carve-out to match settings-protection.sh §5b — the Write-tier
|
|
259
|
+
# allow-list shipped earlier in 0.32.0 was incomplete without the
|
|
260
|
+
# Bash-tier parity. Without this update, the migration path in
|
|
261
|
+
# MIGRATING.md (`cat > .husky/prepare-commit-msg.d/...`) is refused
|
|
262
|
+
# by `protected-paths-bash-gate.sh` even though Write/Edit succeeds.
|
|
256
263
|
rea_path_is_extension_surface() {
|
|
257
264
|
local p_lc
|
|
258
265
|
p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
|
259
266
|
case "$p_lc" in
|
|
260
|
-
.husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*)
|
|
267
|
+
.husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*|.husky/prepare-commit-msg.d/*)
|
|
261
268
|
# Refuse the bare directory itself — only fragments INSIDE
|
|
262
269
|
# the surface count. `.husky/pre-push.d/` (trailing slash, no
|
|
263
270
|
# fragment) and `.husky/pre-push.d` (the dir node) both fall
|
|
264
271
|
# through to the protection check via the parent prefix.
|
|
265
272
|
case "$p_lc" in
|
|
266
|
-
.husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/) return 1 ;;
|
|
273
|
+
.husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/|.husky/prepare-commit-msg.d/) return 1 ;;
|
|
267
274
|
esac
|
|
268
275
|
return 0
|
|
269
276
|
;;
|