@bookedsolid/rea 0.46.0 → 0.48.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.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * `rea audit top-blocks [--since=DUR] [--limit=N] [--json]` — 0.47.0
3
+ * charter item 3.
4
+ *
5
+ * Surface the most recent refusal events from the audit log. Designed
6
+ * for the question "why was that refused?" — operators see the latest
7
+ * blocks at a glance with enough context (timestamp, tool, reason) to
8
+ * grep the offending Bash/Edit/Write call site or fix the policy that
9
+ * tripped the gate.
10
+ *
11
+ * A "refusal" in the rea audit schema is any record whose
12
+ * `InvocationStatus` is NOT `Allowed` — that's `Denied` (policy
13
+ * refused) OR `Error` (middleware exception or downstream failure).
14
+ * Both are interesting to operators debugging "why didn't this run".
15
+ *
16
+ * # Walk scope
17
+ *
18
+ * Mirrors `audit summary` / `audit by-tool` / `audit timeline`: the
19
+ * current `.rea/audit.jsonl` PLUS every rotated `audit-…jsonl` segment
20
+ * is walked regardless of `--since` (the per-record timestamp filter
21
+ * inside the main loop decides what counts). Rotated filename stamps
22
+ * mark the rotation INSTANT, not the earliest record contained
23
+ * (0.41.0 round-3 P2 / 0.42.0 charter item 3) — pruning by filename
24
+ * would silently drop in-window records from conservatively-rotated
25
+ * logs. Walking every segment is the only sound shape.
26
+ *
27
+ * # Output (default)
28
+ *
29
+ * rea audit top-blocks (last 24h, limit 20)
30
+ * ─────────────────────────────────────────
31
+ * a1b2c3d4 2026-05-17T12:34:56.789Z Bash rm -rf bypass attempted (...)
32
+ * deadbeef 2026-05-17T11:20:01.123Z Write blocked-path .env write
33
+ * …
34
+ * total: 4 refusal events in window
35
+ * files scanned: 2
36
+ *
37
+ * # JSON output
38
+ *
39
+ * {
40
+ * "schema_version": 1,
41
+ * "since": "24h",
42
+ * "limit": 20,
43
+ * "window": { "seconds": 86400, "start": "...", "end": "..." },
44
+ * "total_matched": 4,
45
+ * "events": [
46
+ * { "hash": "a1b2c3d4...", "timestamp": "...", "tool": "Bash",
47
+ * "status": "denied", "reason": "rm -rf bypass attempted (...)" },
48
+ * …
49
+ * ],
50
+ * "files_scanned": ["/abs/path/.rea/audit.jsonl"]
51
+ * }
52
+ *
53
+ * `total_matched` is the pre-limit count so dashboards can show "20 of
54
+ * 47 refusals in window". `events` is sorted newest-first and capped at
55
+ * `limit`.
56
+ */
57
+ import fs from 'node:fs/promises';
58
+ import path from 'node:path';
59
+ import { listRotatedAuditFiles } from './audit-specialists.js';
60
+ import { AuditSummarySinceError, parseDurationSeconds, } from './audit-summary.js';
61
+ import { AUDIT_FILE, REA_DIR, err } from './utils.js';
62
+ export const AUDIT_TOP_BLOCKS_SCHEMA_VERSION = 1;
63
+ /** Default `--limit` value. 20 fits a debugging session's eyeballable window. */
64
+ export const DEFAULT_LIMIT = 20;
65
+ /**
66
+ * Hard ceiling on `--limit`. Refusal events are typically a small slice
67
+ * of total traffic, but a 1000 cap keeps the renderer / JSON output
68
+ * bounded under a runaway misconfiguration that's denying everything.
69
+ */
70
+ export const MAX_LIMIT = 1000;
71
+ /** Max characters of refusal reason to display per row before truncation. */
72
+ const REASON_TRUNCATE = 80;
73
+ /** Short-hash prefix length for the displayed event ID. */
74
+ const SHORT_HASH_LEN = 8;
75
+ /**
76
+ * Thrown when `--limit` is outside [1, MAX_LIMIT] or `--since` fails to
77
+ * parse. The commander wrapper catches and exits 1. Distinct from
78
+ * `AuditByToolOptionError` / `AuditTimelineOptionError` so the
79
+ * caller-facing message names the right flag.
80
+ */
81
+ export class AuditTopBlocksOptionError extends Error {
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = 'AuditTopBlocksOptionError';
85
+ }
86
+ }
87
+ /**
88
+ * Resolve the audit files to walk. Identical strategy to the sibling
89
+ * audit commands — inlined to keep the public surface of
90
+ * `audit-summary.ts` narrow.
91
+ */
92
+ async function resolveTopBlocksFileWalk(baseDir) {
93
+ const reaDir = path.join(baseDir, REA_DIR);
94
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
95
+ const files = [];
96
+ const rotated = await listRotatedAuditFiles(reaDir);
97
+ for (const name of rotated)
98
+ files.push(path.join(reaDir, name));
99
+ try {
100
+ const stat = await fs.stat(currentAudit);
101
+ if (stat.isFile())
102
+ files.push(currentAudit);
103
+ }
104
+ catch (e) {
105
+ if (e.code !== 'ENOENT')
106
+ throw e;
107
+ }
108
+ return files;
109
+ }
110
+ function parseTimestamp(raw) {
111
+ if (typeof raw !== 'string')
112
+ return null;
113
+ const d = new Date(raw);
114
+ return Number.isNaN(d.getTime()) ? null : d;
115
+ }
116
+ /**
117
+ * Decide whether an audit record represents a refusal. The rea
118
+ * InvocationStatus enum has three values (`allowed`, `denied`,
119
+ * `error`); refusals are the non-`allowed` set. We accept any other
120
+ * string here too so a future status enum extension (or an unusual
121
+ * consumer-emitted status) surfaces in the report rather than silently
122
+ * dropping — the operator can decide whether the new bucket is signal
123
+ * or noise.
124
+ */
125
+ function isRefusal(status) {
126
+ if (typeof status !== 'string')
127
+ return false;
128
+ return status !== 'allowed';
129
+ }
130
+ /**
131
+ * Compute the top-blocks list. Pure (read-only). Throws
132
+ * `AuditTopBlocksOptionError` on bad `--since` / `--limit`.
133
+ */
134
+ export async function computeAuditTopBlocks(options = {}) {
135
+ const baseDir = options.baseDir ?? process.cwd();
136
+ const now = options.now ?? new Date();
137
+ // Resolve --limit first so a bad value fails fast before any I/O.
138
+ const limit = options.limit ?? DEFAULT_LIMIT;
139
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
140
+ throw new AuditTopBlocksOptionError(`--limit: must be an integer between 1 and ${String(MAX_LIMIT)}; got ${JSON.stringify(limit)}.`);
141
+ }
142
+ let windowSeconds = null;
143
+ let windowStart = null;
144
+ let windowEnd = null;
145
+ if (options.since !== undefined && options.since.length > 0) {
146
+ try {
147
+ windowSeconds = parseDurationSeconds(options.since);
148
+ }
149
+ catch (e) {
150
+ if (e instanceof AuditSummarySinceError) {
151
+ throw new AuditTopBlocksOptionError(e.message);
152
+ }
153
+ throw e;
154
+ }
155
+ windowEnd = now;
156
+ windowStart = new Date(now.getTime() - windowSeconds * 1000);
157
+ }
158
+ const files = await resolveTopBlocksFileWalk(baseDir);
159
+ const filesScanned = [];
160
+ // Codex round-10 P2 (0.47.0): in a policy-storm scenario (many
161
+ // refusals, verbose `error` strings) the prior shape accumulated
162
+ // every match into a flat array, sorted it, then sliced to
163
+ // `--limit`. Memory + runtime scaled with the total refusal count
164
+ // — exactly the case `top-blocks` was designed to debug. The
165
+ // bounded-buffer shape keeps memory O(limit): we maintain a
166
+ // sorted "top K newest" list of size <= limit and discard the
167
+ // oldest entry whenever a newer one displaces it. `totalMatched`
168
+ // counts every in-window refusal so the JSON shape still
169
+ // communicates "N of M shown".
170
+ const topBuf = [];
171
+ let totalMatched = 0;
172
+ // Insert into the bounded buffer, keeping it sorted newest-first
173
+ // by parsed instant (with hash tiebreaker for determinism). Drop
174
+ // the oldest when capacity exceeded.
175
+ const insertIntoTop = (event, parsedTime) => {
176
+ // Find insertion point — small linear scan; for limit=20 (the
177
+ // default) this is cheaper than a heap and keeps the code
178
+ // simple. For limit=1000 (the max) we're still O(limit) per
179
+ // insert in the worst case, well under the prior O(N log N)
180
+ // sort across N matches.
181
+ let idx = topBuf.length;
182
+ for (let i = 0; i < topBuf.length; i += 1) {
183
+ const cur = topBuf[i];
184
+ if (parsedTime > cur.parsedTime ||
185
+ (parsedTime === cur.parsedTime && event.hash.localeCompare(cur.event.hash) < 0)) {
186
+ idx = i;
187
+ break;
188
+ }
189
+ }
190
+ if (idx < limit) {
191
+ topBuf.splice(idx, 0, { event, parsedTime });
192
+ if (topBuf.length > limit)
193
+ topBuf.length = limit;
194
+ }
195
+ };
196
+ for (const filePath of files) {
197
+ let raw;
198
+ try {
199
+ raw = await fs.readFile(filePath, 'utf8');
200
+ }
201
+ catch (e) {
202
+ const errno = e.code;
203
+ if (errno === 'ENOENT')
204
+ continue;
205
+ // Mirror the sibling audit commands' stance: any non-ENOENT read
206
+ // error is fatal. A silent skip on a rotated segment that may
207
+ // contain in-window refusals would let `top-blocks` exit 0 with
208
+ // the operator's question unanswered.
209
+ throw new Error(`rea audit top-blocks: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
210
+ `An unreadable audit segment may contain in-window records, so the ` +
211
+ `refusal report would be silently incomplete. Fix permissions ` +
212
+ `(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
213
+ `if you no longer need it.`);
214
+ }
215
+ filesScanned.push(filePath);
216
+ for (const line of raw.split('\n')) {
217
+ if (line.length === 0)
218
+ continue;
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(line);
222
+ }
223
+ catch {
224
+ // Malformed line — `rea audit verify` is the right tool. Skip
225
+ // so a single corrupt line doesn't tank the report.
226
+ continue;
227
+ }
228
+ if (!isRefusal(parsed.status))
229
+ continue;
230
+ const ts = parseTimestamp(parsed.timestamp);
231
+ if (windowStart !== null && (ts === null || ts < windowStart))
232
+ continue;
233
+ if (windowEnd !== null && (ts === null || ts > windowEnd))
234
+ continue;
235
+ totalMatched += 1;
236
+ const tool = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
237
+ ? parsed.tool_name
238
+ : '(unknown)';
239
+ const errorText = typeof parsed.error === 'string' && parsed.error.length > 0
240
+ ? parsed.error
241
+ : `${typeof parsed.status === 'string' ? parsed.status : 'refused'}: ${tool}`;
242
+ const event = {
243
+ hash: typeof parsed.hash === 'string' ? parsed.hash : '',
244
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : '',
245
+ tool,
246
+ status: typeof parsed.status === 'string' ? parsed.status : '(unknown)',
247
+ reason: errorText,
248
+ session_id: typeof parsed.session_id === 'string' ? parsed.session_id : '',
249
+ };
250
+ // Codex round-2 P2 (0.47.0): parse the timestamp before
251
+ // comparing — `appendAuditRecord` accepts any ISO-8601 shape,
252
+ // so `2026-05-17T23:00:00+02:00` (= 21:00:00Z, OLDER) would
253
+ // lex-sort ahead of `2026-05-17T22:30:00Z` (NEWER) under a
254
+ // string compare.
255
+ const parsedTime = Date.parse(event.timestamp);
256
+ insertIntoTop(event, Number.isFinite(parsedTime) ? parsedTime : 0);
257
+ }
258
+ }
259
+ const capped = topBuf.map((entry) => entry.event);
260
+ return {
261
+ schema_version: AUDIT_TOP_BLOCKS_SCHEMA_VERSION,
262
+ since: options.since !== undefined && options.since.length > 0 ? options.since : null,
263
+ limit,
264
+ window: {
265
+ seconds: windowSeconds,
266
+ start: windowStart !== null ? windowStart.toISOString() : null,
267
+ end: windowEnd !== null ? windowEnd.toISOString() : null,
268
+ },
269
+ total_matched: totalMatched,
270
+ events: capped,
271
+ files_scanned: filesScanned,
272
+ };
273
+ }
274
+ /**
275
+ * Truncate a reason string to `REASON_TRUNCATE` chars for the human
276
+ * renderer. JSON consumers get the full string — they can render at
277
+ * any width.
278
+ *
279
+ * Codex round-10 P3 (0.47.0): refusal reasons often contain embedded
280
+ * newlines (shell stderr, Node stack traces). Writing them straight
281
+ * into a fixed-width row spills a single event across multiple
282
+ * terminal lines and breaks the hash/timestamp/tool columns. Collapse
283
+ * `\r`, `\n`, and tabs to single spaces FIRST, then truncate to the
284
+ * column width. The JSON path preserves the raw `reason` field so
285
+ * consumers see the full multiline message.
286
+ */
287
+ function truncateReason(reason) {
288
+ const collapsed = reason.replace(/[\r\n\t]+/g, ' ').replace(/ +/g, ' ').trim();
289
+ if (collapsed.length <= REASON_TRUNCATE)
290
+ return collapsed;
291
+ return collapsed.slice(0, REASON_TRUNCATE - 1) + '…';
292
+ }
293
+ /**
294
+ * Short-hash prefix for the displayed event ID. Falls back to the
295
+ * full string when it's shorter than the prefix length (degenerate
296
+ * inputs only — real hashes are always 64 hex chars).
297
+ */
298
+ function shortHash(hash) {
299
+ if (hash.length <= SHORT_HASH_LEN)
300
+ return hash || '(no-hash)';
301
+ return hash.slice(0, SHORT_HASH_LEN);
302
+ }
303
+ /**
304
+ * Compact human duration label. Mirrors the sibling audit commands.
305
+ */
306
+ function formatDurationShort(seconds) {
307
+ const units = [
308
+ ['w', 60 * 60 * 24 * 7],
309
+ ['d', 60 * 60 * 24],
310
+ ['h', 60 * 60],
311
+ ['m', 60],
312
+ ['s', 1],
313
+ ];
314
+ for (const [unit, factor] of units) {
315
+ if (seconds % factor === 0) {
316
+ return `last ${String(seconds / factor)}${unit}`;
317
+ }
318
+ }
319
+ return `last ${String(seconds)}s`;
320
+ }
321
+ /**
322
+ * Render the result as a human-readable terminal block. JSON callers
323
+ * bypass this; the rendering is intentionally minimal — a fixed-column
324
+ * table that scans cleanly in a typical terminal.
325
+ */
326
+ export function renderAuditTopBlocks(result) {
327
+ const lines = [];
328
+ const windowLabel = result.window.seconds !== null ? formatDurationShort(result.window.seconds) : 'all time';
329
+ lines.push(`rea audit top-blocks (${windowLabel}, limit ${String(result.limit)})`);
330
+ lines.push('─'.repeat(40));
331
+ if (result.total_matched === 0) {
332
+ lines.push(result.window.seconds !== null
333
+ ? 'No refusal events in the requested window.'
334
+ : 'No refusal events in the audit log.');
335
+ if (result.files_scanned.length === 0) {
336
+ lines.push('(no audit files found — has `rea serve` ever run?)');
337
+ }
338
+ lines.push('');
339
+ return lines.join('\n');
340
+ }
341
+ // Stable column widths for the table:
342
+ // short hash (8) + 2 | timestamp (24) + 2 | tool (max in view) + 2 | reason
343
+ const maxToolLen = result.events.reduce((m, ev) => Math.max(m, ev.tool.length), 4);
344
+ for (const ev of result.events) {
345
+ const h = shortHash(ev.hash).padEnd(SHORT_HASH_LEN);
346
+ const ts = ev.timestamp.padEnd(24); // ISO-8601 with ms is 24 chars
347
+ const tool = ev.tool.padEnd(maxToolLen);
348
+ const reason = truncateReason(ev.reason);
349
+ lines.push(`${h} ${ts} ${tool} ${reason}`);
350
+ }
351
+ lines.push('');
352
+ if (result.total_matched > result.events.length) {
353
+ lines.push(`total: ${String(result.events.length)} of ${String(result.total_matched)} refusal events shown (--limit=${String(result.limit)})`);
354
+ }
355
+ else {
356
+ lines.push(`total: ${String(result.total_matched)} refusal event${result.total_matched === 1 ? '' : 's'} in window`);
357
+ }
358
+ lines.push(`files scanned: ${String(result.files_scanned.length)}`);
359
+ lines.push('');
360
+ return lines.join('\n');
361
+ }
362
+ /** Commander entrypoint. */
363
+ export async function runAuditTopBlocks(options) {
364
+ let result;
365
+ try {
366
+ result = await computeAuditTopBlocks({
367
+ ...(options.since !== undefined ? { since: options.since } : {}),
368
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
369
+ ...(options.now !== undefined ? { now: options.now } : {}),
370
+ });
371
+ }
372
+ catch (e) {
373
+ if (e instanceof AuditTopBlocksOptionError) {
374
+ err(`rea audit top-blocks: ${e.message}`);
375
+ process.exit(1);
376
+ }
377
+ throw e;
378
+ }
379
+ if (options.json === true) {
380
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
381
+ return;
382
+ }
383
+ process.stdout.write(renderAuditTopBlocks(result));
384
+ }
385
+ /**
386
+ * Strict integer parser for the commander `--limit <n>` option.
387
+ *
388
+ * Mirrors the `parseTopOption` discipline in `audit-by-tool.ts`:
389
+ * reject anything that isn't a bare integer so `Number.parseInt`
390
+ * can't silently truncate (`1.5` → `1`, `10abc` → `10`).
391
+ */
392
+ export function parseLimitOption(raw) {
393
+ if (!/^-?\d+$/.test(raw.trim())) {
394
+ throw new AuditTopBlocksOptionError(`--limit: expected integer; got ${JSON.stringify(raw)}.`);
395
+ }
396
+ const n = Number.parseInt(raw.trim(), 10);
397
+ if (!Number.isFinite(n)) {
398
+ throw new AuditTopBlocksOptionError(`--limit: expected integer; got ${JSON.stringify(raw)}.`);
399
+ }
400
+ return n;
401
+ }
402
+ /**
403
+ * Register `rea audit top-blocks` under the `audit` command group.
404
+ */
405
+ export function registerAuditTopBlocksCommand(auditCommand) {
406
+ auditCommand
407
+ .command('top-blocks')
408
+ .description('Recent refusal events from the audit log — `--limit=N` (default 20, max 1000), `--since=DUR` window filter, `--json` for dashboards. Read-only.')
409
+ .option('--limit <n>', `cap the rendered / serialized list to the most recent N refusals (default ${String(DEFAULT_LIMIT)}, max ${String(MAX_LIMIT)})`, parseLimitOption)
410
+ .option('--since <duration>', 'filter to records within the last <duration>. Compact form: <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d).')
411
+ .option('--json', 'emit a JSON document instead of the human-readable table')
412
+ .action(async (opts) => {
413
+ await runAuditTopBlocks({
414
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
415
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
416
+ ...(opts.json === true ? { json: true } : {}),
417
+ });
418
+ });
419
+ }
package/dist/cli/index.js CHANGED
@@ -17,6 +17,7 @@ import { runUpgradeCheck } from './upgrade-check.js';
17
17
  import { registerAuditSummaryCommand } from './audit-summary.js';
18
18
  import { registerAuditByToolCommand } from './audit-by-tool.js';
19
19
  import { registerAuditTimelineCommand } from './audit-timeline.js';
20
+ import { registerAuditTopBlocksCommand } from './audit-top-blocks.js';
20
21
  import { registerVerifyClaimCommand } from './verify-claim.js';
21
22
  import { err, getPkgVersion } from './utils.js';
22
23
  async function main() {
@@ -154,6 +155,10 @@ async function main() {
154
155
  // [--since=DUR] [--json]`. Time-bucketed event counts with inline
155
156
  // histogram. Useful for spotting activity spikes + cadence patterns.
156
157
  registerAuditTimelineCommand(audit);
158
+ // 0.47.0 charter item 3 — `rea audit top-blocks [--limit=N]
159
+ // [--since=DUR] [--json]`. Most-recent refusal events (denied/error).
160
+ // The "why was that refused?" debugging lens.
161
+ registerAuditTopBlocksCommand(audit);
157
162
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
158
163
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
159
164
  registerHookCommand(program);
@@ -301,6 +301,38 @@ export function reaCommandTier(command) {
301
301
  // Write-tier default. Codex round 3 P2 (2026-05-12).
302
302
  if (sub2 === 'specialists')
303
303
  return Tier.Read;
304
+ // 0.47.0 codex round-11 P2: the audit-reader trio
305
+ // (`summary`, `by-tool`, `timeline`, `top-blocks`) all share
306
+ // the read-only contract — they walk audit.jsonl + rotated
307
+ // segments and emit aggregations to stdout. The pre-0.47.0
308
+ // tier-map only downgraded `verify` + `specialists`, leaving
309
+ // 0.41.0+ readers misclassified as Write under TRUSTED
310
+ // invocations — which made them unavailable in L0 sessions
311
+ // run from `/usr/local/bin/rea` or
312
+ // `/proj/node_modules/.bin/rea` despite being purely
313
+ // observational. Close the gap for all four readers here.
314
+ //
315
+ // 0.47.0 codex round-12 P2 (DELIBERATE NON-FIX): the weak-
316
+ // trust branch below intentionally returns null for Read-tier
317
+ // subcommands, including these audit readers. That keeps a
318
+ // bare `rea audit summary` (PATH-lookup or relative path) at
319
+ // generic Bash Write, where an attacker who shadowed `rea` on
320
+ // PATH cannot trick the gateway into downgrading their
321
+ // payload via a fake subcommand. Consumers needing Read
322
+ // semantics under L0 use the trusted invocation shapes
323
+ // (`/usr/local/bin/rea …`, `npx rea …`,
324
+ // `./node_modules/.bin/rea …`) — same contract as `verify`
325
+ // and `specialists` since 0.10.x / 0.29.0. The UX gap is
326
+ // documented in `docs/cli/trust-model.md` (see also the
327
+ // weak-trust branch comment below).
328
+ if (sub2 === 'summary')
329
+ return Tier.Read;
330
+ if (sub2 === 'by-tool')
331
+ return Tier.Read;
332
+ if (sub2 === 'timeline')
333
+ return Tier.Read;
334
+ if (sub2 === 'top-blocks')
335
+ return Tier.Read;
304
336
  if (sub2 === 'rotate')
305
337
  return Tier.Write;
306
338
  return Tier.Write;
@@ -301,6 +301,13 @@ declare const PolicySchema: z.ZodObject<{
301
301
  threshold?: number | undefined;
302
302
  exempt_subagents?: string[] | undefined;
303
303
  }>>;
304
+ shim_cache: z.ZodOptional<z.ZodObject<{
305
+ enabled: z.ZodDefault<z.ZodBoolean>;
306
+ }, "strict", z.ZodTypeAny, {
307
+ enabled: boolean;
308
+ }, {
309
+ enabled?: boolean | undefined;
310
+ }>>;
304
311
  }, "strict", z.ZodTypeAny, {
305
312
  version: string;
306
313
  profile: string;
@@ -379,6 +386,9 @@ declare const PolicySchema: z.ZodObject<{
379
386
  threshold: number;
380
387
  exempt_subagents: string[];
381
388
  } | undefined;
389
+ shim_cache?: {
390
+ enabled: boolean;
391
+ } | undefined;
382
392
  }, {
383
393
  version: string;
384
394
  profile: string;
@@ -457,6 +467,9 @@ declare const PolicySchema: z.ZodObject<{
457
467
  threshold?: number | undefined;
458
468
  exempt_subagents?: string[] | undefined;
459
469
  } | undefined;
470
+ shim_cache?: {
471
+ enabled?: boolean | undefined;
472
+ } | undefined;
460
473
  }>;
461
474
  /**
462
475
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -328,6 +328,32 @@ const DelegationAdvisoryPolicySchema = z
328
328
  ]),
329
329
  })
330
330
  .strict();
331
+ /**
332
+ * 0.48.0 — per-session shim cache policy. The
333
+ * `hooks/_lib/shim-cache.sh` helper, sourced by every Node-binary
334
+ * shim via `hooks/_lib/shim-runtime.sh`, caches the (sandbox-ok,
335
+ * shape-ok) tuple for a given (session, project, CLI realpath,
336
+ * mtime, size, euid, enforce_shape) key, with a 3600s TTL ceiling.
337
+ * The cache is an OPTIMIZATION — every cache-miss path falls through
338
+ * to the existing uncached hot path. See
339
+ * `docs/shim-session-cache-design.md` for the full contract.
340
+ *
341
+ * Strict mode rejects unknown keys so a typo (`enabld`, `enable`)
342
+ * fails loudly at policy load. The block is optional — vanilla
343
+ * installs with no `shim_cache:` block get the default behavior
344
+ * (cache enabled). To disable: `shim_cache: { enabled: false }`.
345
+ *
346
+ * The bash-tier helper does a narrow YAML grep for the field
347
+ * BEFORE the canonical 4-tier policy reader is available (cache
348
+ * runs in the shim's pre-CLI section). This zod schema validates
349
+ * the field at CLI load time so wrong types / typos are caught at
350
+ * the load boundary.
351
+ */
352
+ const ShimCachePolicySchema = z
353
+ .object({
354
+ enabled: z.boolean().default(true),
355
+ })
356
+ .strict();
331
357
  const PolicySchema = z
332
358
  .object({
333
359
  version: z.string(),
@@ -387,6 +413,16 @@ const PolicySchema = z
387
413
  // when unset/false). When the block IS present the inner schema
388
414
  // supplies defaults for any omitted field.
389
415
  delegation_advisory: DelegationAdvisoryPolicySchema.optional(),
416
+ // 0.48.0 per-session shim cache — drives `hooks/_lib/shim-cache.sh`
417
+ // which short-circuits the sandbox check + version probe in
418
+ // `hooks/_lib/shim-runtime.sh` on session-warm fires of the same
419
+ // shim. Optional — vanilla installs get the default behavior
420
+ // (cache enabled). The bash-tier `shim_cache_disabled` helper
421
+ // honors `enabled: false` via a narrow inline YAML grep before
422
+ // the canonical policy reader is reachable. `REA_SHIM_CACHE=0`
423
+ // in env overrides this to `false` for the current invocation
424
+ // regardless of policy.
425
+ shim_cache: ShimCachePolicySchema.optional(),
390
426
  })
391
427
  .strict();
392
428
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -533,4 +533,56 @@ export interface Policy {
533
533
  * blocks. See `DelegationAdvisoryPolicy` for the full contract.
534
534
  */
535
535
  delegation_advisory?: DelegationAdvisoryPolicy;
536
+ /**
537
+ * Per-session shim cache (0.48.0+).
538
+ *
539
+ * The `hooks/_lib/shim-cache.sh` helper, sourced by every Node-binary
540
+ * shim via `hooks/_lib/shim-runtime.sh`, records the answers to the
541
+ * sandbox check + version probe under a per-user, per-session,
542
+ * per-CLI key. Subsequent shim fires within the same Claude Code
543
+ * session against the same CLI (mtime + size unchanged) skip
544
+ * straight to the forward step.
545
+ *
546
+ * The cache is an OPTIMIZATION, not a security boundary. Cache miss
547
+ * / disabled / corruption all fall through to the existing uncached
548
+ * hot path — never fail closed.
549
+ *
550
+ * `enabled` default: `true`. Set `false` to disable the cache layer
551
+ * at the policy tier (equivalent effect to setting the
552
+ * `REA_SHIM_CACHE=0` env var on every invocation). Operators who
553
+ * want to measure unconditional steady-state latency should use the
554
+ * env-var form so the cache stays off only for the measurement
555
+ * window. See `docs/shim-session-cache-design.md` for the security
556
+ * contract and `docs/hook-perf-baseline.md` for the perf
557
+ * methodology note.
558
+ */
559
+ shim_cache?: ShimCachePolicy;
560
+ }
561
+ /**
562
+ * Per-session shim cache policy (0.48.0+).
563
+ *
564
+ * The cache short-circuits the sandbox check + version probe in
565
+ * `hooks/_lib/shim-runtime.sh` on session-warm fires of the same
566
+ * shim. The on-disk entry shape is bound to `schema_version: "v1"`
567
+ * — a schema bump (future cache field additions) invalidates every
568
+ * existing entry. TTL is hard-capped at 3600s (1h) inside the
569
+ * runtime; this block does not expose a TTL knob in 0.48.0 because
570
+ * the optimization is steady-state-bound and a longer TTL would
571
+ * extend staleness without measurable benefit.
572
+ */
573
+ export interface ShimCachePolicy {
574
+ /**
575
+ * Master switch. `true` (default) enables the cache. `false`
576
+ * disables both reads and writes — the runtime falls through to
577
+ * the existing uncached hot path on every fire. `REA_SHIM_CACHE=0`
578
+ * in env overrides this to `false` for the current invocation
579
+ * regardless of policy.
580
+ *
581
+ * NOTE 0.48.0: the bash-tier `shim_cache_disabled` helper consults
582
+ * this field via a narrow YAML grep BEFORE the canonical 4-tier
583
+ * policy reader is available (cache runs in the shim's pre-CLI
584
+ * section). The TS loader's schema validation runs at full CLI
585
+ * load time and catches typos / wrong types.
586
+ */
587
+ enabled?: boolean;
536
588
  }