@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.
Files changed (51) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/audit-specialists.d.ts +106 -24
  4. package/dist/cli/audit-specialists.js +239 -64
  5. package/dist/cli/delegation-advisory.d.ts +161 -0
  6. package/dist/cli/delegation-advisory.js +433 -0
  7. package/dist/cli/doctor.d.ts +110 -39
  8. package/dist/cli/doctor.js +302 -90
  9. package/dist/cli/hook.d.ts +6 -0
  10. package/dist/cli/hook.js +45 -22
  11. package/dist/cli/index.js +1 -1
  12. package/dist/cli/install/settings-merge.js +25 -0
  13. package/dist/cli/roster.d.ts +119 -0
  14. package/dist/cli/roster.js +141 -0
  15. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  16. package/dist/hooks/_lib/halt-check.js +106 -0
  17. package/dist/hooks/_lib/payload.d.ts +86 -0
  18. package/dist/hooks/_lib/payload.js +166 -0
  19. package/dist/hooks/_lib/segments.d.ts +100 -0
  20. package/dist/hooks/_lib/segments.js +444 -0
  21. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  22. package/dist/hooks/attribution-advisory/index.js +233 -0
  23. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  24. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  25. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  26. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  27. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  28. package/dist/policy/loader.d.ts +23 -0
  29. package/dist/policy/loader.js +46 -0
  30. package/dist/policy/profiles.d.ts +23 -0
  31. package/dist/policy/profiles.js +16 -0
  32. package/dist/policy/types.d.ts +61 -0
  33. package/hooks/_lib/protected-paths.sh +10 -3
  34. package/hooks/attribution-advisory.sh +139 -131
  35. package/hooks/delegation-advisory.sh +162 -0
  36. package/hooks/pr-issue-link-gate.sh +114 -45
  37. package/hooks/security-disclosure-gate.sh +148 -316
  38. package/hooks/settings-protection.sh +13 -9
  39. package/package.json +1 -1
  40. package/profiles/bst-internal-no-codex.yaml +12 -0
  41. package/profiles/bst-internal.yaml +13 -0
  42. package/profiles/client-engagement.yaml +11 -0
  43. package/profiles/lit-wc.yaml +10 -0
  44. package/profiles/minimal.yaml +11 -0
  45. package/profiles/open-source-no-codex.yaml +11 -0
  46. package/profiles/open-source.yaml +11 -0
  47. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  48. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  49. package/templates/prepare-commit-msg.husky.sh +80 -6
  50. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  51. 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;
@@ -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.
@@ -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. */
@@ -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. */