@bookedsolid/rea 0.22.0 → 0.23.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 (55) hide show
  1. package/README.md +15 -0
  2. package/THREAT_MODEL.md +582 -0
  3. package/dist/audit/append.js +1 -1
  4. package/dist/cli/doctor.js +11 -12
  5. package/dist/cli/hook.d.ts +37 -3
  6. package/dist/cli/hook.js +167 -5
  7. package/dist/cli/init.js +14 -26
  8. package/dist/cli/install/canonical.js +18 -3
  9. package/dist/cli/install/commit-msg.js +1 -2
  10. package/dist/cli/install/copy.js +4 -13
  11. package/dist/cli/install/fs-safe.js +5 -16
  12. package/dist/cli/install/gitignore.js +1 -5
  13. package/dist/cli/install/pre-push.js +3 -8
  14. package/dist/cli/install/settings-merge.js +79 -16
  15. package/dist/cli/upgrade.js +14 -10
  16. package/dist/gateway/downstream.js +1 -2
  17. package/dist/gateway/live-state.js +3 -1
  18. package/dist/gateway/log.js +1 -3
  19. package/dist/gateway/middleware/audit.js +1 -1
  20. package/dist/gateway/middleware/injection.js +3 -9
  21. package/dist/gateway/middleware/policy.js +3 -1
  22. package/dist/gateway/middleware/redact.js +1 -1
  23. package/dist/gateway/observability/codex-telemetry.js +1 -2
  24. package/dist/gateway/reviewers/claude-self.js +10 -6
  25. package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
  26. package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
  27. package/dist/hooks/bash-scanner/index.d.ts +41 -0
  28. package/dist/hooks/bash-scanner/index.js +62 -0
  29. package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
  30. package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
  31. package/dist/hooks/bash-scanner/parser.d.ts +42 -0
  32. package/dist/hooks/bash-scanner/parser.js +92 -0
  33. package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
  34. package/dist/hooks/bash-scanner/protected-scan.js +815 -0
  35. package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
  36. package/dist/hooks/bash-scanner/verdict.js +49 -0
  37. package/dist/hooks/bash-scanner/walker.d.ts +165 -0
  38. package/dist/hooks/bash-scanner/walker.js +7954 -0
  39. package/dist/hooks/push-gate/base.js +2 -6
  40. package/dist/hooks/push-gate/codex-runner.js +3 -1
  41. package/dist/hooks/push-gate/index.js +9 -10
  42. package/dist/policy/loader.js +4 -1
  43. package/dist/registry/tofu-gate.js +2 -2
  44. package/hooks/blocked-paths-bash-gate.sh +142 -272
  45. package/hooks/protected-paths-bash-gate.sh +227 -511
  46. package/package.json +3 -2
  47. package/profiles/bst-internal-no-codex.yaml +1 -1
  48. package/profiles/bst-internal.yaml +1 -1
  49. package/profiles/client-engagement.yaml +1 -1
  50. package/profiles/lit-wc.yaml +1 -1
  51. package/profiles/minimal.yaml +1 -1
  52. package/profiles/open-source-no-codex.yaml +1 -1
  53. package/profiles/open-source.yaml +1 -1
  54. package/scripts/postinstall.mjs +1 -2
  55. package/scripts/run-vitest.mjs +117 -0
@@ -138,9 +138,7 @@ export function mergeSettings(existing, desired) {
138
138
  type: wantHook.type,
139
139
  command: wantHook.command,
140
140
  ...(wantHook.timeout !== undefined ? { timeout: wantHook.timeout } : {}),
141
- ...(wantHook.statusMessage !== undefined
142
- ? { statusMessage: wantHook.statusMessage }
143
- : {}),
141
+ ...(wantHook.statusMessage !== undefined ? { statusMessage: wantHook.statusMessage } : {}),
144
142
  });
145
143
  seen.add(k);
146
144
  addedCount += 1;
@@ -257,31 +255,96 @@ export function defaultDesiredHooks() {
257
255
  event: 'PreToolUse',
258
256
  matcher: 'Bash',
259
257
  hooks: [
260
- { type: 'command', command: `${base}/dangerous-bash-interceptor.sh`, timeout: 10000, statusMessage: 'Checking command safety...' },
261
- { type: 'command', command: `${base}/env-file-protection.sh`, timeout: 5000, statusMessage: 'Checking for .env file reads...' },
262
- { type: 'command', command: `${base}/protected-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to protected paths...' },
263
- { type: 'command', command: `${base}/blocked-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to policy-blocked paths...' },
264
- { type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
265
- { type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
266
- { type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
267
- { type: 'command', command: `${base}/attribution-advisory.sh`, timeout: 5000, statusMessage: 'Checking for AI attribution...' },
258
+ {
259
+ type: 'command',
260
+ command: `${base}/dangerous-bash-interceptor.sh`,
261
+ timeout: 10000,
262
+ statusMessage: 'Checking command safety...',
263
+ },
264
+ {
265
+ type: 'command',
266
+ command: `${base}/env-file-protection.sh`,
267
+ timeout: 5000,
268
+ statusMessage: 'Checking for .env file reads...',
269
+ },
270
+ {
271
+ type: 'command',
272
+ command: `${base}/protected-paths-bash-gate.sh`,
273
+ timeout: 5000,
274
+ statusMessage: 'Checking for shell-redirect to protected paths...',
275
+ },
276
+ {
277
+ type: 'command',
278
+ command: `${base}/blocked-paths-bash-gate.sh`,
279
+ timeout: 5000,
280
+ statusMessage: 'Checking for shell-redirect to policy-blocked paths...',
281
+ },
282
+ {
283
+ type: 'command',
284
+ command: `${base}/dependency-audit-gate.sh`,
285
+ timeout: 15000,
286
+ statusMessage: 'Verifying package exists...',
287
+ },
288
+ {
289
+ type: 'command',
290
+ command: `${base}/security-disclosure-gate.sh`,
291
+ timeout: 5000,
292
+ statusMessage: 'Checking disclosure policy...',
293
+ },
294
+ {
295
+ type: 'command',
296
+ command: `${base}/pr-issue-link-gate.sh`,
297
+ timeout: 5000,
298
+ statusMessage: 'Checking PR for issue reference...',
299
+ },
300
+ {
301
+ type: 'command',
302
+ command: `${base}/attribution-advisory.sh`,
303
+ timeout: 5000,
304
+ statusMessage: 'Checking for AI attribution...',
305
+ },
268
306
  ],
269
307
  },
270
308
  {
271
309
  event: 'PreToolUse',
272
310
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
273
311
  hooks: [
274
- { type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
275
- { type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
276
- { type: 'command', command: `${base}/blocked-paths-enforcer.sh`, timeout: 5000, statusMessage: 'Checking blocked paths...' },
277
- { type: 'command', command: `${base}/changeset-security-gate.sh`, timeout: 5000, statusMessage: 'Checking changeset for security leaks...' },
312
+ {
313
+ type: 'command',
314
+ command: `${base}/secret-scanner.sh`,
315
+ timeout: 15000,
316
+ statusMessage: 'Scanning for credentials...',
317
+ },
318
+ {
319
+ type: 'command',
320
+ command: `${base}/settings-protection.sh`,
321
+ timeout: 5000,
322
+ statusMessage: 'Checking settings protection...',
323
+ },
324
+ {
325
+ type: 'command',
326
+ command: `${base}/blocked-paths-enforcer.sh`,
327
+ timeout: 5000,
328
+ statusMessage: 'Checking blocked paths...',
329
+ },
330
+ {
331
+ type: 'command',
332
+ command: `${base}/changeset-security-gate.sh`,
333
+ timeout: 5000,
334
+ statusMessage: 'Checking changeset for security leaks...',
335
+ },
278
336
  ],
279
337
  },
280
338
  {
281
339
  event: 'PostToolUse',
282
340
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
283
341
  hooks: [
284
- { type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
342
+ {
343
+ type: 'command',
344
+ command: `${base}/architecture-review-gate.sh`,
345
+ timeout: 10000,
346
+ statusMessage: 'Checking architecture impact...',
347
+ },
285
348
  ],
286
349
  },
287
350
  ];
@@ -42,11 +42,11 @@ import path from 'node:path';
42
42
  import * as p from '@clack/prompts';
43
43
  import { loadPolicy } from '../policy/loader.js';
44
44
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
45
- import { buildFragment, extractFragment, } from './install/claude-md.js';
45
+ import { buildFragment, extractFragment } from './install/claude-md.js';
46
46
  import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
47
47
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
48
48
  import { ensureReaGitignore } from './install/gitignore.js';
49
- import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
49
+ import { manifestExists, readManifest, writeManifestAtomic } from './install/manifest-io.js';
50
50
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
51
51
  import { err, getPkgVersion, log, warn } from './utils.js';
52
52
  // ---------------------------------------------------------------------------
@@ -219,7 +219,11 @@ async function promptDriftDecision(resolvedRoot, canonical, opts) {
219
219
  initialValue: 'keep',
220
220
  options: [
221
221
  { value: 'keep', label: 'keep', hint: 'leave your version untouched (default)' },
222
- { value: 'overwrite', label: 'overwrite', hint: `replace with canonical (rea v${getPkgVersion()})` },
222
+ {
223
+ value: 'overwrite',
224
+ label: 'overwrite',
225
+ hint: `replace with canonical (rea v${getPkgVersion()})`,
226
+ },
223
227
  { value: 'diff', label: 'diff', hint: 'show diff, then re-prompt' },
224
228
  ],
225
229
  });
@@ -302,8 +306,7 @@ async function classifyFiles(resolvedRoot, canonicalFiles, manifest) {
302
306
  // Removed-upstream: in manifest but not in canonical set.
303
307
  if (manifest !== null) {
304
308
  for (const entry of manifest.files) {
305
- if (entry.path === CLAUDE_MD_MANIFEST_PATH ||
306
- entry.path === SETTINGS_MANIFEST_PATH) {
309
+ if (entry.path === CLAUDE_MD_MANIFEST_PATH || entry.path === SETTINGS_MANIFEST_PATH) {
307
310
  continue; // synthetic entries handled separately
308
311
  }
309
312
  if (!canonicalByPath.has(entry.path)) {
@@ -555,9 +558,7 @@ export async function runUpgrade(options = {}) {
555
558
  }
556
559
  }
557
560
  else if (c.kind === 'removed-upstream') {
558
- const decision = dryRun
559
- ? 'skip'
560
- : await promptRemovedDecision(c.entry.path, options);
561
+ const decision = dryRun ? 'skip' : await promptRemovedDecision(c.entry.path, options);
561
562
  if (decision === 'delete') {
562
563
  console.log(` - ${c.entry.path} (deleted)`);
563
564
  if (!dryRun) {
@@ -626,9 +627,12 @@ export async function runUpgrade(options = {}) {
626
627
  if (dryRun) {
627
628
  console.log('');
628
629
  log('dry run — no changes written.');
629
- const planned = counts.new_ + counts.drifted + counts.removedUpstream +
630
+ const planned = counts.new_ +
631
+ counts.drifted +
632
+ counts.removedUpstream +
630
633
  (classifications.some((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha)
631
- ? classifications.filter((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha).length
634
+ ? classifications.filter((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha)
635
+ .length
632
636
  : 0);
633
637
  console.log(` ${planned} file action(s) planned.`);
634
638
  return;
@@ -214,8 +214,7 @@ export class DownstreamConnection {
214
214
  // loud instead.
215
215
  throw new TypeError(`DownstreamConnection#lastErrorMessage: expected string | null, got ${typeof msg}`);
216
216
  }
217
- this.#lastErrorBacking =
218
- msg === null ? null : boundedDiagnosticString(msg);
217
+ this.#lastErrorBacking = msg === null ? null : boundedDiagnosticString(msg);
219
218
  }
220
219
  constructor(config,
221
220
  /**
@@ -374,7 +374,9 @@ export class LiveStatePublisher {
374
374
  if (parsed.session_id === this.opts.sessionId)
375
375
  return true;
376
376
  // Foreign session_id. Use owner_pid to decide whether to yield or steal.
377
- if (typeof parsed.owner_pid !== 'number' || !Number.isFinite(parsed.owner_pid) || parsed.owner_pid <= 0) {
377
+ if (typeof parsed.owner_pid !== 'number' ||
378
+ !Number.isFinite(parsed.owner_pid) ||
379
+ parsed.owner_pid <= 0) {
378
380
  // Pre-0.9.0 file (no owner_pid recorded) or malformed value. We
379
381
  // cannot prove the writer is alive, and refusing to write forever
380
382
  // is the bigger hazard — claim the file. This is the same
@@ -317,9 +317,7 @@ export function buildRegexRedactor(patterns) {
317
317
  // the SafeRegex worker in the redact middleware) can leave non-zero,
318
318
  // causing String.replace to start mid-string and silently skip leading
319
319
  // secrets. The `y` (sticky) flag carries the same hazard as `g`.
320
- const re = (pattern.global || pattern.sticky)
321
- ? new RegExp(pattern.source, pattern.flags)
322
- : pattern;
320
+ const re = pattern.global || pattern.sticky ? new RegExp(pattern.source, pattern.flags) : pattern;
323
321
  out = out.replace(re, '[REDACTED]');
324
322
  }
325
323
  return out;
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { Tier, InvocationStatus } from '../../policy/types.js';
4
- import { computeHash, fsyncFile, readLastRecord, withAuditLock, } from '../../audit/fs.js';
4
+ import { computeHash, fsyncFile, readLastRecord, withAuditLock } from '../../audit/fs.js';
5
5
  import { maybeRotate } from '../audit/rotator.js';
6
6
  /**
7
7
  * Post-execution middleware: appends a hash-chained JSONL audit record.
@@ -260,15 +260,11 @@ export function compileInjectionPatterns(timeoutMs, onTimeout) {
260
260
  return {
261
261
  base64Token: wrapRegex(INJECTION_BASE64_PATTERN, {
262
262
  timeoutMs,
263
- ...(onTimeout
264
- ? { onTimeout: (_p, i) => onTimeout('INJECTION_BASE64_PATTERN', i) }
265
- : {}),
263
+ ...(onTimeout ? { onTimeout: (_p, i) => onTimeout('INJECTION_BASE64_PATTERN', i) } : {}),
266
264
  }),
267
265
  base64Shape: wrapRegex(INJECTION_BASE64_SHAPE, {
268
266
  timeoutMs,
269
- ...(onTimeout
270
- ? { onTimeout: (_p, i) => onTimeout('INJECTION_BASE64_SHAPE', i) }
271
- : {}),
267
+ ...(onTimeout ? { onTimeout: (_p, i) => onTimeout('INJECTION_BASE64_SHAPE', i) } : {}),
272
268
  }),
273
269
  };
274
270
  }
@@ -364,9 +360,7 @@ export function classifyInjection(scan, tier) {
364
360
  // Dedupe: a phrase that appears both literally AND in a base64-decoded
365
361
  // payload in the same input counts once in `matched_patterns`. Union via
366
362
  // Set before sorting.
367
- const matched = [
368
- ...new Set([...scan.literalMatches, ...scan.base64DecodedMatches]),
369
- ].sort();
363
+ const matched = [...new Set([...scan.literalMatches, ...scan.base64DecodedMatches])].sort();
370
364
  // Rule 2 — base64 always escalates, regardless of count or tier.
371
365
  if (base64Count > 0) {
372
366
  return {
@@ -12,7 +12,9 @@ function extractReaSubcommand(command) {
12
12
  if (first === undefined)
13
13
  return null;
14
14
  let idx = 0;
15
- if (first === 'npx' && tokens.length >= 2 && (tokens[1] === 'rea' || tokens[1] === '@bookedsolid/rea')) {
15
+ if (first === 'npx' &&
16
+ tokens.length >= 2 &&
17
+ (tokens[1] === 'rea' || tokens[1] === '@bookedsolid/rea')) {
16
18
  idx = 2;
17
19
  }
18
20
  else if (first === 'rea' || first.endsWith('/rea')) {
@@ -1,4 +1,4 @@
1
- import { wrapRegex } from '../redact-safe/match-timeout.js';
1
+ import { wrapRegex, } from '../redact-safe/match-timeout.js';
2
2
  /**
3
3
  * Patterns that match common secret formats.
4
4
  * Each pattern has a name (for audit logging) and a regex.
@@ -202,8 +202,7 @@ export async function summarizeTelemetry(baseDir, windowDays = 7) {
202
202
  if (!countsByKey.has(key))
203
203
  continue; // outside window
204
204
  countsByKey.set(key, (countsByKey.get(key) ?? 0) + 1);
205
- totalTokens +=
206
- (r.estimated_input_tokens ?? 0) + (r.estimated_output_tokens ?? 0);
205
+ totalTokens += (r.estimated_input_tokens ?? 0) + (r.estimated_output_tokens ?? 0);
207
206
  if (r.rate_limited)
208
207
  rateLimitedCount += 1;
209
208
  durationSum += r.duration_ms ?? 0;
@@ -140,10 +140,12 @@ function toReviewFinding(input) {
140
140
  'performance',
141
141
  ];
142
142
  const validSeverities = ['high', 'medium', 'low'];
143
- if (typeof o['category'] !== 'string' || !validCategories.includes(o['category'])) {
143
+ if (typeof o['category'] !== 'string' ||
144
+ !validCategories.includes(o['category'])) {
144
145
  return undefined;
145
146
  }
146
- if (typeof o['severity'] !== 'string' || !validSeverities.includes(o['severity'])) {
147
+ if (typeof o['severity'] !== 'string' ||
148
+ !validSeverities.includes(o['severity'])) {
147
149
  return undefined;
148
150
  }
149
151
  if (typeof o['file'] !== 'string')
@@ -210,9 +212,7 @@ export class ClaudeSelfReviewer {
210
212
  }
211
213
  const diffBytes = Buffer.byteLength(req.diff, 'utf8');
212
214
  const truncated = diffBytes > DIFF_TRUNCATE_BYTES;
213
- const effectiveDiff = truncated
214
- ? req.diff.slice(0, DIFF_TRUNCATE_BYTES)
215
- : req.diff;
215
+ const effectiveDiff = truncated ? req.diff.slice(0, DIFF_TRUNCATE_BYTES) : req.diff;
216
216
  const userMessage = buildUserMessage({ ...req, diff: effectiveDiff }, truncated);
217
217
  // G11.5 — measure the SDK call. The telemetry write is best-effort and
218
218
  // fire-and-forget; it MUST NOT block or fail the review. We call
@@ -237,7 +237,11 @@ export class ClaudeSelfReviewer {
237
237
  // Rate-limits, 5xx, network errors all land here. Surface the raw
238
238
  // message so operators can act on it; the caller decides whether
239
239
  // to retry or abort.
240
- const message = err instanceof APIError ? `API ${err.status ?? '?'}: ${err.message}` : err instanceof Error ? err.message : String(err);
240
+ const message = err instanceof APIError
241
+ ? `API ${err.status ?? '?'}: ${err.message}`
242
+ : err instanceof Error
243
+ ? err.message
244
+ : String(err);
241
245
  this.emitTelemetry({
242
246
  invocation_type: 'adversarial-review',
243
247
  input_text: userMessage,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Blocked-paths policy composition. Mirrors `blocked-paths-bash-gate.sh`
3
+ * + the `_match_blocked` helper byte-for-byte:
4
+ *
5
+ * - directory entry (ends with `/`): prefix match OR exact match
6
+ * against the bare-dir form (entry without trailing slash)
7
+ * - glob entry (contains `*`): convert to ERE (escape `.`, `*` → `.*`),
8
+ * anchored, case-insensitive
9
+ * - exact (case-insensitive) otherwise
10
+ *
11
+ * Path normalization is identical to protected-scan.ts: URL-decode,
12
+ * backslash → slash, leading-./ strip, `..` walk-up + outside-root
13
+ * sentinel, optional symlink-resolved form.
14
+ *
15
+ * Out-of-scope-of-blocked: paths outside REA_ROOT. blocked_paths is a
16
+ * project-relative concept; an outside-root write can't match a
17
+ * blocked_paths entry. The PROTECTED-paths gate handles outside-root
18
+ * rejection on the protected list itself.
19
+ */
20
+ import { type Verdict } from './verdict.js';
21
+ import type { DetectedWrite } from './walker.js';
22
+ export interface BlockedScanContext {
23
+ reaRoot: string;
24
+ blockedPaths: readonly string[];
25
+ }
26
+ export declare function scanForBlockedViolations(ctx: BlockedScanContext, detections: readonly DetectedWrite[]): Verdict;