@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,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;
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -288,6 +288,19 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
288
288
|
skip_merge?: boolean | undefined;
|
|
289
289
|
} | undefined;
|
|
290
290
|
}>>;
|
|
291
|
+
delegation_advisory: z.ZodOptional<z.ZodObject<{
|
|
292
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
293
|
+
threshold: z.ZodDefault<z.ZodNumber>;
|
|
294
|
+
exempt_subagents: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
295
|
+
}, "strict", z.ZodTypeAny, {
|
|
296
|
+
enabled: boolean;
|
|
297
|
+
threshold: number;
|
|
298
|
+
exempt_subagents: string[];
|
|
299
|
+
}, {
|
|
300
|
+
enabled?: boolean | undefined;
|
|
301
|
+
threshold?: number | undefined;
|
|
302
|
+
exempt_subagents?: string[] | undefined;
|
|
303
|
+
}>>;
|
|
291
304
|
}, "strict", z.ZodTypeAny, {
|
|
292
305
|
version: string;
|
|
293
306
|
profile: string;
|
|
@@ -361,6 +374,11 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
361
374
|
skip_merge?: boolean | undefined;
|
|
362
375
|
} | undefined;
|
|
363
376
|
} | undefined;
|
|
377
|
+
delegation_advisory?: {
|
|
378
|
+
enabled: boolean;
|
|
379
|
+
threshold: number;
|
|
380
|
+
exempt_subagents: string[];
|
|
381
|
+
} | undefined;
|
|
364
382
|
}, {
|
|
365
383
|
version: string;
|
|
366
384
|
profile: string;
|
|
@@ -434,6 +452,11 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
434
452
|
skip_merge?: boolean | undefined;
|
|
435
453
|
} | undefined;
|
|
436
454
|
} | undefined;
|
|
455
|
+
delegation_advisory?: {
|
|
456
|
+
enabled?: boolean | undefined;
|
|
457
|
+
threshold?: number | undefined;
|
|
458
|
+
exempt_subagents?: string[] | undefined;
|
|
459
|
+
} | undefined;
|
|
437
460
|
}>;
|
|
438
461
|
/**
|
|
439
462
|
* Async policy loader with TTL cache and mtime-based invalidation.
|
package/dist/policy/loader.js
CHANGED
|
@@ -289,6 +289,45 @@ const AttributionPolicySchema = z
|
|
|
289
289
|
co_author: AttributionCoAuthorSchema.optional(),
|
|
290
290
|
})
|
|
291
291
|
.strict();
|
|
292
|
+
/**
|
|
293
|
+
* 0.31.0 — delegation-advisory nudge policy. Drives the
|
|
294
|
+
* `delegation-advisory.sh` PostToolUse hook (matcher
|
|
295
|
+
* `Bash|Edit|Write|MultiEdit|NotebookEdit`): when a session crosses
|
|
296
|
+
* `threshold` write-class tool calls without a `rea.delegation_signal`
|
|
297
|
+
* record (to a non-exempt subagent), the hook emits a one-time stderr
|
|
298
|
+
* advisory. The hook is advisory-only — exit 0 always except HALT.
|
|
299
|
+
*
|
|
300
|
+
* Defaults live here at the schema layer, not in the hook: a vanilla
|
|
301
|
+
* install with no `delegation_advisory` block gets `enabled: false`
|
|
302
|
+
* (silent no-op), `threshold: 25`, and the 5-entry built-in exempt
|
|
303
|
+
* list. The `bst-internal*` profiles pin `enabled: true`; OSS profiles
|
|
304
|
+
* leave it `false` so consumers opt in.
|
|
305
|
+
*
|
|
306
|
+
* `threshold` is a positive integer — a single write-class count
|
|
307
|
+
* rather than the 0.29.0 design memo's "15 edits + 5 Bash" split.
|
|
308
|
+
* Modeling the threshold as one number keeps the hook's counter file
|
|
309
|
+
* a single integer and the policy surface a single knob; the
|
|
310
|
+
* distinction between an Edit and a Bash call doesn't change the
|
|
311
|
+
* signal the nudge exists to send ("you've done a lot solo").
|
|
312
|
+
*
|
|
313
|
+
* Strict mode rejects unknown keys so a typo (`thresholds`,
|
|
314
|
+
* `exempt_subagent`) fails loudly at policy load.
|
|
315
|
+
*/
|
|
316
|
+
const DelegationAdvisoryPolicySchema = z
|
|
317
|
+
.object({
|
|
318
|
+
enabled: z.boolean().default(false),
|
|
319
|
+
threshold: z.number().int().positive().default(25),
|
|
320
|
+
exempt_subagents: z
|
|
321
|
+
.array(z.string())
|
|
322
|
+
.default([
|
|
323
|
+
'general-purpose',
|
|
324
|
+
'Explore',
|
|
325
|
+
'Plan',
|
|
326
|
+
'output-style-setup',
|
|
327
|
+
'statusline-setup',
|
|
328
|
+
]),
|
|
329
|
+
})
|
|
330
|
+
.strict();
|
|
292
331
|
const PolicySchema = z
|
|
293
332
|
.object({
|
|
294
333
|
version: z.string(),
|
|
@@ -341,6 +380,13 @@ const PolicySchema = z
|
|
|
341
380
|
// `AttributionCoAuthorSchema` fails closed when `enabled: true` but
|
|
342
381
|
// `name`/`email` are empty so we never ship a half-configured trailer.
|
|
343
382
|
attribution: AttributionPolicySchema.optional(),
|
|
383
|
+
// 0.31.0 delegation-advisory nudge — drives the
|
|
384
|
+
// `delegation-advisory.sh` PostToolUse hook. `.optional()` so a
|
|
385
|
+
// vanilla install with no block sees the hook as a silent no-op
|
|
386
|
+
// (the hook reads `enabled` via `rea hook policy-get` and exits 0
|
|
387
|
+
// when unset/false). When the block IS present the inner schema
|
|
388
|
+
// supplies defaults for any omitted field.
|
|
389
|
+
delegation_advisory: DelegationAdvisoryPolicySchema.optional(),
|
|
344
390
|
})
|
|
345
391
|
.strict();
|
|
346
392
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
|
@@ -108,6 +108,19 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
108
108
|
skip_merge?: boolean | undefined;
|
|
109
109
|
} | undefined;
|
|
110
110
|
}>>;
|
|
111
|
+
delegation_advisory: z.ZodOptional<z.ZodObject<{
|
|
112
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
113
|
+
threshold: z.ZodOptional<z.ZodNumber>;
|
|
114
|
+
exempt_subagents: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
115
|
+
}, "strict", z.ZodTypeAny, {
|
|
116
|
+
enabled?: boolean | undefined;
|
|
117
|
+
threshold?: number | undefined;
|
|
118
|
+
exempt_subagents?: string[] | undefined;
|
|
119
|
+
}, {
|
|
120
|
+
enabled?: boolean | undefined;
|
|
121
|
+
threshold?: number | undefined;
|
|
122
|
+
exempt_subagents?: string[] | undefined;
|
|
123
|
+
}>>;
|
|
111
124
|
}, "strict", z.ZodTypeAny, {
|
|
112
125
|
autonomy_level?: AutonomyLevel | undefined;
|
|
113
126
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -142,6 +155,11 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
142
155
|
skip_merge?: boolean | undefined;
|
|
143
156
|
} | undefined;
|
|
144
157
|
} | undefined;
|
|
158
|
+
delegation_advisory?: {
|
|
159
|
+
enabled?: boolean | undefined;
|
|
160
|
+
threshold?: number | undefined;
|
|
161
|
+
exempt_subagents?: string[] | undefined;
|
|
162
|
+
} | undefined;
|
|
145
163
|
}, {
|
|
146
164
|
autonomy_level?: AutonomyLevel | undefined;
|
|
147
165
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -176,6 +194,11 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
176
194
|
skip_merge?: boolean | undefined;
|
|
177
195
|
} | undefined;
|
|
178
196
|
} | undefined;
|
|
197
|
+
delegation_advisory?: {
|
|
198
|
+
enabled?: boolean | undefined;
|
|
199
|
+
threshold?: number | undefined;
|
|
200
|
+
exempt_subagents?: string[] | undefined;
|
|
201
|
+
} | undefined;
|
|
179
202
|
}>;
|
|
180
203
|
export type Profile = z.infer<typeof ProfileSchema>;
|
|
181
204
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -100,6 +100,22 @@ export const ProfileSchema = z
|
|
|
100
100
|
})
|
|
101
101
|
.strict()
|
|
102
102
|
.optional(),
|
|
103
|
+
// 0.31.0+ delegation-advisory nudge. `bst-internal*` profiles pin
|
|
104
|
+
// `enabled: true`; external profiles ship `enabled: false`. The
|
|
105
|
+
// profile-layer schema mirrors the policy-loader's
|
|
106
|
+
// `DelegationAdvisoryPolicySchema` but leaves every field optional
|
|
107
|
+
// — defaults are applied at the policy-loader layer when the
|
|
108
|
+
// materialized file is parsed, so a profile that only declares
|
|
109
|
+
// `enabled` doesn't need to also restate `threshold`. Strict mode
|
|
110
|
+
// still rejects typos at init time.
|
|
111
|
+
delegation_advisory: z
|
|
112
|
+
.object({
|
|
113
|
+
enabled: z.boolean().optional(),
|
|
114
|
+
threshold: z.number().int().positive().optional(),
|
|
115
|
+
exempt_subagents: z.array(z.string()).optional(),
|
|
116
|
+
})
|
|
117
|
+
.strict()
|
|
118
|
+
.optional(),
|
|
103
119
|
})
|
|
104
120
|
.strict();
|
|
105
121
|
/** Hard defaults applied before any profile or wizard answer. */
|