@bookedsolid/rea 0.48.0 → 0.48.1

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.
@@ -250,10 +250,24 @@ shim_cache_disabled() {
250
250
  # but valid YAML). Pre-fix the bash matcher pinned column 0.
251
251
  # We strip leading whitespace from the line for matching
252
252
  # purposes; for the block-form sub-block scan we ALSO track the
253
- # opener's indentation depth so we don't mistake a deeper-
254
- # indented sibling block's `enabled: false` for ours. The
255
- # block-form-end heuristic is now "first non-empty line at or
256
- # below the opener's indent level".
253
+ # opener indent depth so we do not mistake a deeper-indented
254
+ # sibling block enabled: false for ours. The block-form-end
255
+ # heuristic is now first non-empty line at or below the opener
256
+ # indent level.
257
+ #
258
+ # 0.48.1 R10 P3: pure-comment lines (matching ^[[:space:]]*#) MUST
259
+ # NOT close the block. Pre-fix a top-level comment like
260
+ # shim_cache:\n# note\n enabled: false closed the block on the
261
+ # comment line (non-empty, indent 0 <= opener_indent 0) and the
262
+ # subsequent enabled: false was treated as a top-level key with
263
+ # no parent block, so the disable was silently ignored.
264
+ #
265
+ # 0.48.1 multi-line flow-form: shim_cache: {\n enabled: false\n}
266
+ # is valid YAML the TS loader accepts. We add a flow-block state
267
+ # that opens on shim_cache: { (with the { unmatched on the same
268
+ # line), accumulates body until }, and matches enabled: false in
269
+ # the assembled buffer. The single-line flow-form rule above
270
+ # still wins for shim_cache: { enabled: false } on one line.
257
271
  result=$(awk '
258
272
  {
259
273
  lc = tolower($0)
@@ -262,9 +276,82 @@ shim_cache_disabled() {
262
276
  indent_of_line = match(lc, /[^[:space:]]/) - 1
263
277
  if (indent_of_line < 0) indent_of_line = 0
264
278
  }
265
- # Flow-form: leading whitespace allowed before the key.
266
- lc ~ /^[[:space:]]*shim_cache:[[:space:]]*\{[^}]*enabled[[:space:]]*:[[:space:]]*false[^}]*\}([[:space:]]*(#.*)?)?$/ {
267
- print "off"; exit
279
+ # Pure-comment line: skip without affecting state. Must come
280
+ # before BOTH the flow-block accumulator AND the block-end
281
+ # heuristic so a comment inside either context is transparent.
282
+ lc ~ /^[[:space:]]*#/ {
283
+ next
284
+ }
285
+ # 0.48.1 round-2 P2: removed the narrow single-line flow regex
286
+ # that matched shim_cache: { enabled: false } with [^}]* — it
287
+ # had no concept of quoted scalars or trailing comments and
288
+ # mis-fired on shim_cache: { note: "enabled: false", enabled:
289
+ # true }. The single-line case now flows through the brace-
290
+ # depth path below (which strips quotes + trailing comments
291
+ # per line); the only behavior change is that the inline form
292
+ # gets the same sanitization the multi-line form already does.
293
+ # Flow-form multi-line opener: shim_cache: { with the first {
294
+ # unmatched on the same line. Start accumulating until the
295
+ # matching close brace.
296
+ #
297
+ # 0.48.1 round-1 P2-B: track BRACE DEPTH across the buffer
298
+ # instead of closing on the first }. Valid YAML such as
299
+ # shim_cache: { meta: { foo: bar }, enabled: false } has
300
+ # nested {} pairs; a quoted scalar like note: "}" embeds a
301
+ # brace inside a string. We strip "..." and *...* (single-quote
302
+ # placeholder is \047 to keep this awk single-quoted body
303
+ # apostrophe-clean) before counting so quoted braces do not
304
+ # affect depth. Approximate but matches every shape the TS
305
+ # loader accepts; on a malformed policy the worst case is
306
+ # cache-stays-on (the safe default).
307
+ in_flow == 0 && lc ~ /^[[:space:]]*shim_cache:[[:space:]]*\{/ {
308
+ # Build a sanitized line: strip quoted scalars first so
309
+ # quoted braces / quoted comments / quoted enabled: false
310
+ # tokens cannot pollute brace-depth or value detection;
311
+ # then strip trailing #-comments so they cannot either.
312
+ # 0.48.1 round-2 P2 fix.
313
+ line_stripped = lc
314
+ gsub(/"[^"]*"/, "", line_stripped)
315
+ gsub(/\047[^\047]*\047/, "", line_stripped)
316
+ gsub(/[[:space:]]*#.*$/, "", line_stripped)
317
+ opens = gsub(/\{/, "{", line_stripped)
318
+ closes = gsub(/\}/, "}", line_stripped)
319
+ flow_depth = opens - closes
320
+ if (flow_depth <= 0) {
321
+ # Already balanced on this line — single-line flow form,
322
+ # potentially with nested braces (e.g. { meta: { foo: bar
323
+ # }, enabled: false }). The narrow single-line rule above
324
+ # cannot match nested-brace shapes (its [^}]* fails on the
325
+ # inner closing brace), so we check the SANITIZED line
326
+ # (quotes + comments stripped) here. Anchoring on a token
327
+ # boundary defends against accidental substring noise
328
+ # like enabled-false-something.
329
+ if (line_stripped ~ /enabled[[:space:]]*:[[:space:]]*false([^[:alnum:]_]|$)/) {
330
+ print "off"; exit
331
+ }
332
+ next
333
+ }
334
+ in_flow = 1
335
+ flow_buf = line_stripped
336
+ next
337
+ }
338
+ # Flow-form continuation: accumulate sanitized + maintain depth.
339
+ in_flow == 1 {
340
+ line_stripped = lc
341
+ gsub(/"[^"]*"/, "", line_stripped)
342
+ gsub(/\047[^\047]*\047/, "", line_stripped)
343
+ gsub(/[[:space:]]*#.*$/, "", line_stripped)
344
+ flow_buf = flow_buf " " line_stripped
345
+ opens = gsub(/\{/, "{", line_stripped)
346
+ closes = gsub(/\}/, "}", line_stripped)
347
+ flow_depth += opens - closes
348
+ if (flow_depth <= 0) {
349
+ in_flow = 0
350
+ if (flow_buf ~ /enabled[[:space:]]*:[[:space:]]*false([^[:alnum:]_]|$)/) {
351
+ print "off"; exit
352
+ }
353
+ }
354
+ next
268
355
  }
269
356
  # Block-form opener: leading whitespace allowed.
270
357
  lc ~ /^[[:space:]]*shim_cache:[[:space:]]*(#.*)?$/ {
@@ -273,7 +360,8 @@ shim_cache_disabled() {
273
360
  next
274
361
  }
275
362
  # End the block when we see a non-empty line at or below the
276
- # opener indent (a sibling YAML key at the same level).
363
+ # opener indent (a sibling YAML key at the same level). Comment
364
+ # lines were already filtered above so they cannot close the block.
277
365
  in_block && lc !~ /^[[:space:]]*$/ && indent_of_line <= opener_indent {
278
366
  in_block = 0
279
367
  }
@@ -312,16 +312,66 @@ shim_run() {
312
312
  # 4. Resolve CLI.
313
313
  shim_resolve_cli
314
314
 
315
+ # 4a. Sandbox check (0.48.1 — moved earlier from step 5). Runs BEFORE
316
+ # the cache-key-prep block so the dist-tree `find` walk (added in
317
+ # 0.48.0) never recurses an out-of-project symlink target. Pre-
318
+ # 0.48.1 ordering was: cache prep → cache lookup → sandbox check;
319
+ # codex 0.48.0 round-10 P2 caught that a hostile workspace whose
320
+ # `node_modules/@bookedsolid/rea` (or `dist`) symlinks outside
321
+ # CLAUDE_PROJECT_DIR caused the find walk to traverse the external
322
+ # tree before step 5 refused at `bad:cli-escapes-project`. Moving
323
+ # sandbox earlier preserves the cheap-refusal posture the
324
+ # pre-0.48.0 hot path had.
325
+ #
326
+ # Tradeoff vs 0.48.0: a warm cache hit no longer skips sandbox
327
+ # (pre-fix the `_shim_cache_hit` guard at this site bypassed it).
328
+ # Sandbox is a single `node -e` (~30ms warm) so the lost
329
+ # optimization is acceptable; the cache's primary win is skipping
330
+ # the version probe at step 8 (which is a full CLI spawn,
331
+ # materially more expensive).
332
+ local sandbox_result=""
333
+ local sandbox_failed=0
334
+ local node_missing=0
335
+ if [ "${#REA_ARGV[@]}" -gt 0 ]; then
336
+ if ! command -v node >/dev/null 2>&1; then
337
+ # 0.38.1 round-2 P2 fix: pre-fix this branch exited 0/2 IMMEDIATELY
338
+ # without ever calling shim_policy_short_circuit, so a blocking-
339
+ # tier shim whose policy said "disabled" still refused when node
340
+ # was absent (which contradicts the pre-port body's no-op-on-
341
+ # disabled posture). Clear REA_ARGV here so Tier 1 (rea CLI)
342
+ # cannot fire — the policy reader degrades to Tier 2 (python3) /
343
+ # Tier 3 (awk), neither of which needs node. Track node-missing
344
+ # separately so the CLI-required branch below can emit the right
345
+ # banner if the policy did NOT short-circuit us out.
346
+ node_missing=1
347
+ REA_ARGV=()
348
+ else
349
+ sandbox_result=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
350
+ if [ "$sandbox_result" != "ok" ]; then
351
+ sandbox_failed=1
352
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
353
+ shim_emit_sandbox_skip_banner "$sandbox_result"
354
+ exit 0
355
+ fi
356
+ # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
357
+ # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
358
+ # of invoking the untrusted CLI.
359
+ REA_ARGV=()
360
+ fi
361
+ fi
362
+ fi
363
+
315
364
  # 4b. Per-session cache lookup (0.48.0). When the cache is enabled
316
365
  # AND the resolved CLI matches a recent same-session entry, the
317
- # sandbox check (step 5) AND version probe (step 8) can both be
318
- # skipped — those answers do not change for a stable CLI inside a
319
- # stable session. Cache MISS / disabled / corrupt → fall through
320
- # to the existing uncached hot path. NEVER fail closed on a cache
321
- # error (see hooks/_lib/shim-cache.sh header for the security
322
- # contract). The cache check runs AFTER `shim_is_relevant` (per
323
- # design memo concern #3) so we never pay a stat-per-fire cost
324
- # for irrelevant payloads.
366
+ # version probe (step 8) can be skipped that answer does not
367
+ # change for a stable CLI inside a stable session. Cache MISS /
368
+ # disabled / corrupt → fall through to the existing uncached hot
369
+ # path. NEVER fail closed on a cache error (see
370
+ # hooks/_lib/shim-cache.sh header for the security contract). The
371
+ # cache check runs AFTER `shim_is_relevant` (per design memo
372
+ # concern #3) so we never pay a stat-per-fire cost for irrelevant
373
+ # payloads. 0.48.1: also runs AFTER step 4a sandbox check so the
374
+ # dist-tree hash walk never traverses a symlinked-out CLI tree.
325
375
  local _shim_cache_hit=0
326
376
  local _shim_cache_key=""
327
377
  local _shim_cache_cli_real=""
@@ -333,7 +383,19 @@ shim_run() {
333
383
  local _shim_cache_dist_mtime=""
334
384
  local _shim_cache_node_real=""
335
385
  local _shim_cache_node_mtime=""
336
- if [ "${#REA_ARGV[@]}" -gt 0 ] && ! shim_cache_disabled; then
386
+ # 0.48.1: gated on `sandbox_failed -eq 0` so a sandbox refusal
387
+ # short-circuits BEFORE the dist-tree hash walk runs (was running
388
+ # against a possibly-symlinked-out target pre-0.48.1).
389
+ #
390
+ # 0.48.1 round-1 P2-A: also gated on SHIM_SKIP_VERSION_PROBE -eq 0.
391
+ # Skip-probe shims (delegation-advisory, delegation-capture) cannot
392
+ # write a cache entry (the step-8b write block also gates on
393
+ # SHIM_SKIP_VERSION_PROBE -eq 0 — 0.48.1 SOUNDNESS fix), so any
394
+ # cache-key prep + lookup is pure overhead with zero possible hit.
395
+ # Pre-fix the highest-frequency hooks paid dist-tree find/stat/hash
396
+ # + several `node -e` calls on EVERY write-class fire for nothing.
397
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && [ "$sandbox_failed" -eq 0 ] \
398
+ && [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ] && ! shim_cache_disabled; then
337
399
  local _stat_out=""
338
400
  local _proj_real=""
339
401
  local _euid=""
@@ -547,49 +609,12 @@ shim_run() {
547
609
  fi
548
610
  fi
549
611
 
550
- # 5. Sandbox check (when CLI was resolved). On failure clear REA_ARGV
551
- # + stash the reason so the eventual CLI-required branch can emit
552
- # the correct banner. Running the sandbox check BEFORE the policy
553
- # short-circuit prevents an unsandboxed CLI from being invoked by
554
- # Tier-1 of the policy reader (0.37.0 codex round-2 P1: applies to
555
- # shims like attribution-advisory whose policy_short_circuit may
556
- # use `policy_reader_get`).
557
- #
558
- # Advisory-tier: a sandbox failure exits 0 with the skip banner —
559
- # nothing to enforce for nudges. Blocking-tier: deferred to the
560
- # CLI-required branch below so we emit ONE banner per refusal
561
- # (instead of double-emitting sandbox + cli-missing).
562
- local sandbox_result=""
563
- local sandbox_failed=0
564
- local node_missing=0
565
- if [ "${#REA_ARGV[@]}" -gt 0 ] && [ "$_shim_cache_hit" -eq 0 ]; then
566
- if ! command -v node >/dev/null 2>&1; then
567
- # 0.38.1 round-2 P2 fix: pre-fix this branch exited 0/2 IMMEDIATELY
568
- # without ever calling shim_policy_short_circuit, so a blocking-
569
- # tier shim whose policy said "disabled" still refused when node
570
- # was absent (which contradicts the pre-port body's no-op-on-
571
- # disabled posture). Clear REA_ARGV here so Tier 1 (rea CLI)
572
- # can't fire — the policy reader degrades to Tier 2 (python3) /
573
- # Tier 3 (awk), neither of which needs node. Track node-missing
574
- # separately so the CLI-required branch below can emit the right
575
- # banner if the policy did NOT short-circuit us out.
576
- node_missing=1
577
- REA_ARGV=()
578
- else
579
- sandbox_result=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
580
- if [ "$sandbox_result" != "ok" ]; then
581
- sandbox_failed=1
582
- if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
583
- shim_emit_sandbox_skip_banner "$sandbox_result"
584
- exit 0
585
- fi
586
- # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
587
- # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
588
- # of invoking the untrusted CLI.
589
- REA_ARGV=()
590
- fi
591
- fi
592
- fi
612
+ # 5. (0.48.1: sandbox check moved to step 4a before cache prep — so
613
+ # a hostile workspace cannot make the dist-tree hash walk traverse
614
+ # a symlinked-out target. The original step-5 block lived between
615
+ # cache prep and policy short-circuit; that location was sound for
616
+ # correctness but the cache code regressed the cheap-refusal
617
+ # posture against symlink workspaces. See step 4a comment.)
593
618
 
594
619
  # 6. Policy short-circuit. Runs BEFORE the CLI-missing / node-missing
595
620
  # banners so a shim whose policy says "disabled" exits 0 cleanly
@@ -667,7 +692,17 @@ shim_run() {
667
692
  # entry; rewriting it would be wasted work AND would refresh
668
693
  # `cached_at_unix` past the TTL ceiling, defeating the staleness
669
694
  # bound).
670
- if [ "$_shim_cache_hit" -eq 0 ] && [ -n "$_shim_cache_key" ]; then
695
+ #
696
+ # 0.48.1 SOUNDNESS: also skipped when SHIM_SKIP_VERSION_PROBE=1
697
+ # (delegation-capture path). Pre-fix the cache write proceeded
698
+ # after a skipped probe, recording `shape_ok: true` from
699
+ # defaulted-true logic without the probe having actually run.
700
+ # On the next fire a cache hit would read that entry and trust
701
+ # a version-probe answer that was never produced. The cache MUST
702
+ # only persist real probe results; if the probe was bypassed,
703
+ # the next fire pays the cost of running it for real.
704
+ if [ "$_shim_cache_hit" -eq 0 ] && [ -n "$_shim_cache_key" ] \
705
+ && [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ]; then
671
706
  local _write_payload=""
672
707
  _write_payload=$(node -e '
673
708
  const args = process.argv.slice(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.48.0",
3
+ "version": "0.48.1",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -250,10 +250,24 @@ shim_cache_disabled() {
250
250
  # but valid YAML). Pre-fix the bash matcher pinned column 0.
251
251
  # We strip leading whitespace from the line for matching
252
252
  # purposes; for the block-form sub-block scan we ALSO track the
253
- # opener's indentation depth so we don't mistake a deeper-
254
- # indented sibling block's `enabled: false` for ours. The
255
- # block-form-end heuristic is now "first non-empty line at or
256
- # below the opener's indent level".
253
+ # opener indent depth so we do not mistake a deeper-indented
254
+ # sibling block enabled: false for ours. The block-form-end
255
+ # heuristic is now first non-empty line at or below the opener
256
+ # indent level.
257
+ #
258
+ # 0.48.1 R10 P3: pure-comment lines (matching ^[[:space:]]*#) MUST
259
+ # NOT close the block. Pre-fix a top-level comment like
260
+ # shim_cache:\n# note\n enabled: false closed the block on the
261
+ # comment line (non-empty, indent 0 <= opener_indent 0) and the
262
+ # subsequent enabled: false was treated as a top-level key with
263
+ # no parent block, so the disable was silently ignored.
264
+ #
265
+ # 0.48.1 multi-line flow-form: shim_cache: {\n enabled: false\n}
266
+ # is valid YAML the TS loader accepts. We add a flow-block state
267
+ # that opens on shim_cache: { (with the { unmatched on the same
268
+ # line), accumulates body until }, and matches enabled: false in
269
+ # the assembled buffer. The single-line flow-form rule above
270
+ # still wins for shim_cache: { enabled: false } on one line.
257
271
  result=$(awk '
258
272
  {
259
273
  lc = tolower($0)
@@ -262,9 +276,82 @@ shim_cache_disabled() {
262
276
  indent_of_line = match(lc, /[^[:space:]]/) - 1
263
277
  if (indent_of_line < 0) indent_of_line = 0
264
278
  }
265
- # Flow-form: leading whitespace allowed before the key.
266
- lc ~ /^[[:space:]]*shim_cache:[[:space:]]*\{[^}]*enabled[[:space:]]*:[[:space:]]*false[^}]*\}([[:space:]]*(#.*)?)?$/ {
267
- print "off"; exit
279
+ # Pure-comment line: skip without affecting state. Must come
280
+ # before BOTH the flow-block accumulator AND the block-end
281
+ # heuristic so a comment inside either context is transparent.
282
+ lc ~ /^[[:space:]]*#/ {
283
+ next
284
+ }
285
+ # 0.48.1 round-2 P2: removed the narrow single-line flow regex
286
+ # that matched shim_cache: { enabled: false } with [^}]* — it
287
+ # had no concept of quoted scalars or trailing comments and
288
+ # mis-fired on shim_cache: { note: "enabled: false", enabled:
289
+ # true }. The single-line case now flows through the brace-
290
+ # depth path below (which strips quotes + trailing comments
291
+ # per line); the only behavior change is that the inline form
292
+ # gets the same sanitization the multi-line form already does.
293
+ # Flow-form multi-line opener: shim_cache: { with the first {
294
+ # unmatched on the same line. Start accumulating until the
295
+ # matching close brace.
296
+ #
297
+ # 0.48.1 round-1 P2-B: track BRACE DEPTH across the buffer
298
+ # instead of closing on the first }. Valid YAML such as
299
+ # shim_cache: { meta: { foo: bar }, enabled: false } has
300
+ # nested {} pairs; a quoted scalar like note: "}" embeds a
301
+ # brace inside a string. We strip "..." and *...* (single-quote
302
+ # placeholder is \047 to keep this awk single-quoted body
303
+ # apostrophe-clean) before counting so quoted braces do not
304
+ # affect depth. Approximate but matches every shape the TS
305
+ # loader accepts; on a malformed policy the worst case is
306
+ # cache-stays-on (the safe default).
307
+ in_flow == 0 && lc ~ /^[[:space:]]*shim_cache:[[:space:]]*\{/ {
308
+ # Build a sanitized line: strip quoted scalars first so
309
+ # quoted braces / quoted comments / quoted enabled: false
310
+ # tokens cannot pollute brace-depth or value detection;
311
+ # then strip trailing #-comments so they cannot either.
312
+ # 0.48.1 round-2 P2 fix.
313
+ line_stripped = lc
314
+ gsub(/"[^"]*"/, "", line_stripped)
315
+ gsub(/\047[^\047]*\047/, "", line_stripped)
316
+ gsub(/[[:space:]]*#.*$/, "", line_stripped)
317
+ opens = gsub(/\{/, "{", line_stripped)
318
+ closes = gsub(/\}/, "}", line_stripped)
319
+ flow_depth = opens - closes
320
+ if (flow_depth <= 0) {
321
+ # Already balanced on this line — single-line flow form,
322
+ # potentially with nested braces (e.g. { meta: { foo: bar
323
+ # }, enabled: false }). The narrow single-line rule above
324
+ # cannot match nested-brace shapes (its [^}]* fails on the
325
+ # inner closing brace), so we check the SANITIZED line
326
+ # (quotes + comments stripped) here. Anchoring on a token
327
+ # boundary defends against accidental substring noise
328
+ # like enabled-false-something.
329
+ if (line_stripped ~ /enabled[[:space:]]*:[[:space:]]*false([^[:alnum:]_]|$)/) {
330
+ print "off"; exit
331
+ }
332
+ next
333
+ }
334
+ in_flow = 1
335
+ flow_buf = line_stripped
336
+ next
337
+ }
338
+ # Flow-form continuation: accumulate sanitized + maintain depth.
339
+ in_flow == 1 {
340
+ line_stripped = lc
341
+ gsub(/"[^"]*"/, "", line_stripped)
342
+ gsub(/\047[^\047]*\047/, "", line_stripped)
343
+ gsub(/[[:space:]]*#.*$/, "", line_stripped)
344
+ flow_buf = flow_buf " " line_stripped
345
+ opens = gsub(/\{/, "{", line_stripped)
346
+ closes = gsub(/\}/, "}", line_stripped)
347
+ flow_depth += opens - closes
348
+ if (flow_depth <= 0) {
349
+ in_flow = 0
350
+ if (flow_buf ~ /enabled[[:space:]]*:[[:space:]]*false([^[:alnum:]_]|$)/) {
351
+ print "off"; exit
352
+ }
353
+ }
354
+ next
268
355
  }
269
356
  # Block-form opener: leading whitespace allowed.
270
357
  lc ~ /^[[:space:]]*shim_cache:[[:space:]]*(#.*)?$/ {
@@ -273,7 +360,8 @@ shim_cache_disabled() {
273
360
  next
274
361
  }
275
362
  # End the block when we see a non-empty line at or below the
276
- # opener indent (a sibling YAML key at the same level).
363
+ # opener indent (a sibling YAML key at the same level). Comment
364
+ # lines were already filtered above so they cannot close the block.
277
365
  in_block && lc !~ /^[[:space:]]*$/ && indent_of_line <= opener_indent {
278
366
  in_block = 0
279
367
  }
@@ -312,16 +312,66 @@ shim_run() {
312
312
  # 4. Resolve CLI.
313
313
  shim_resolve_cli
314
314
 
315
+ # 4a. Sandbox check (0.48.1 — moved earlier from step 5). Runs BEFORE
316
+ # the cache-key-prep block so the dist-tree `find` walk (added in
317
+ # 0.48.0) never recurses an out-of-project symlink target. Pre-
318
+ # 0.48.1 ordering was: cache prep → cache lookup → sandbox check;
319
+ # codex 0.48.0 round-10 P2 caught that a hostile workspace whose
320
+ # `node_modules/@bookedsolid/rea` (or `dist`) symlinks outside
321
+ # CLAUDE_PROJECT_DIR caused the find walk to traverse the external
322
+ # tree before step 5 refused at `bad:cli-escapes-project`. Moving
323
+ # sandbox earlier preserves the cheap-refusal posture the
324
+ # pre-0.48.0 hot path had.
325
+ #
326
+ # Tradeoff vs 0.48.0: a warm cache hit no longer skips sandbox
327
+ # (pre-fix the `_shim_cache_hit` guard at this site bypassed it).
328
+ # Sandbox is a single `node -e` (~30ms warm) so the lost
329
+ # optimization is acceptable; the cache's primary win is skipping
330
+ # the version probe at step 8 (which is a full CLI spawn,
331
+ # materially more expensive).
332
+ local sandbox_result=""
333
+ local sandbox_failed=0
334
+ local node_missing=0
335
+ if [ "${#REA_ARGV[@]}" -gt 0 ]; then
336
+ if ! command -v node >/dev/null 2>&1; then
337
+ # 0.38.1 round-2 P2 fix: pre-fix this branch exited 0/2 IMMEDIATELY
338
+ # without ever calling shim_policy_short_circuit, so a blocking-
339
+ # tier shim whose policy said "disabled" still refused when node
340
+ # was absent (which contradicts the pre-port body's no-op-on-
341
+ # disabled posture). Clear REA_ARGV here so Tier 1 (rea CLI)
342
+ # cannot fire — the policy reader degrades to Tier 2 (python3) /
343
+ # Tier 3 (awk), neither of which needs node. Track node-missing
344
+ # separately so the CLI-required branch below can emit the right
345
+ # banner if the policy did NOT short-circuit us out.
346
+ node_missing=1
347
+ REA_ARGV=()
348
+ else
349
+ sandbox_result=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
350
+ if [ "$sandbox_result" != "ok" ]; then
351
+ sandbox_failed=1
352
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
353
+ shim_emit_sandbox_skip_banner "$sandbox_result"
354
+ exit 0
355
+ fi
356
+ # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
357
+ # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
358
+ # of invoking the untrusted CLI.
359
+ REA_ARGV=()
360
+ fi
361
+ fi
362
+ fi
363
+
315
364
  # 4b. Per-session cache lookup (0.48.0). When the cache is enabled
316
365
  # AND the resolved CLI matches a recent same-session entry, the
317
- # sandbox check (step 5) AND version probe (step 8) can both be
318
- # skipped — those answers do not change for a stable CLI inside a
319
- # stable session. Cache MISS / disabled / corrupt → fall through
320
- # to the existing uncached hot path. NEVER fail closed on a cache
321
- # error (see hooks/_lib/shim-cache.sh header for the security
322
- # contract). The cache check runs AFTER `shim_is_relevant` (per
323
- # design memo concern #3) so we never pay a stat-per-fire cost
324
- # for irrelevant payloads.
366
+ # version probe (step 8) can be skipped that answer does not
367
+ # change for a stable CLI inside a stable session. Cache MISS /
368
+ # disabled / corrupt → fall through to the existing uncached hot
369
+ # path. NEVER fail closed on a cache error (see
370
+ # hooks/_lib/shim-cache.sh header for the security contract). The
371
+ # cache check runs AFTER `shim_is_relevant` (per design memo
372
+ # concern #3) so we never pay a stat-per-fire cost for irrelevant
373
+ # payloads. 0.48.1: also runs AFTER step 4a sandbox check so the
374
+ # dist-tree hash walk never traverses a symlinked-out CLI tree.
325
375
  local _shim_cache_hit=0
326
376
  local _shim_cache_key=""
327
377
  local _shim_cache_cli_real=""
@@ -333,7 +383,19 @@ shim_run() {
333
383
  local _shim_cache_dist_mtime=""
334
384
  local _shim_cache_node_real=""
335
385
  local _shim_cache_node_mtime=""
336
- if [ "${#REA_ARGV[@]}" -gt 0 ] && ! shim_cache_disabled; then
386
+ # 0.48.1: gated on `sandbox_failed -eq 0` so a sandbox refusal
387
+ # short-circuits BEFORE the dist-tree hash walk runs (was running
388
+ # against a possibly-symlinked-out target pre-0.48.1).
389
+ #
390
+ # 0.48.1 round-1 P2-A: also gated on SHIM_SKIP_VERSION_PROBE -eq 0.
391
+ # Skip-probe shims (delegation-advisory, delegation-capture) cannot
392
+ # write a cache entry (the step-8b write block also gates on
393
+ # SHIM_SKIP_VERSION_PROBE -eq 0 — 0.48.1 SOUNDNESS fix), so any
394
+ # cache-key prep + lookup is pure overhead with zero possible hit.
395
+ # Pre-fix the highest-frequency hooks paid dist-tree find/stat/hash
396
+ # + several `node -e` calls on EVERY write-class fire for nothing.
397
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && [ "$sandbox_failed" -eq 0 ] \
398
+ && [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ] && ! shim_cache_disabled; then
337
399
  local _stat_out=""
338
400
  local _proj_real=""
339
401
  local _euid=""
@@ -547,49 +609,12 @@ shim_run() {
547
609
  fi
548
610
  fi
549
611
 
550
- # 5. Sandbox check (when CLI was resolved). On failure clear REA_ARGV
551
- # + stash the reason so the eventual CLI-required branch can emit
552
- # the correct banner. Running the sandbox check BEFORE the policy
553
- # short-circuit prevents an unsandboxed CLI from being invoked by
554
- # Tier-1 of the policy reader (0.37.0 codex round-2 P1: applies to
555
- # shims like attribution-advisory whose policy_short_circuit may
556
- # use `policy_reader_get`).
557
- #
558
- # Advisory-tier: a sandbox failure exits 0 with the skip banner —
559
- # nothing to enforce for nudges. Blocking-tier: deferred to the
560
- # CLI-required branch below so we emit ONE banner per refusal
561
- # (instead of double-emitting sandbox + cli-missing).
562
- local sandbox_result=""
563
- local sandbox_failed=0
564
- local node_missing=0
565
- if [ "${#REA_ARGV[@]}" -gt 0 ] && [ "$_shim_cache_hit" -eq 0 ]; then
566
- if ! command -v node >/dev/null 2>&1; then
567
- # 0.38.1 round-2 P2 fix: pre-fix this branch exited 0/2 IMMEDIATELY
568
- # without ever calling shim_policy_short_circuit, so a blocking-
569
- # tier shim whose policy said "disabled" still refused when node
570
- # was absent (which contradicts the pre-port body's no-op-on-
571
- # disabled posture). Clear REA_ARGV here so Tier 1 (rea CLI)
572
- # can't fire — the policy reader degrades to Tier 2 (python3) /
573
- # Tier 3 (awk), neither of which needs node. Track node-missing
574
- # separately so the CLI-required branch below can emit the right
575
- # banner if the policy did NOT short-circuit us out.
576
- node_missing=1
577
- REA_ARGV=()
578
- else
579
- sandbox_result=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
580
- if [ "$sandbox_result" != "ok" ]; then
581
- sandbox_failed=1
582
- if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
583
- shim_emit_sandbox_skip_banner "$sandbox_result"
584
- exit 0
585
- fi
586
- # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
587
- # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
588
- # of invoking the untrusted CLI.
589
- REA_ARGV=()
590
- fi
591
- fi
592
- fi
612
+ # 5. (0.48.1: sandbox check moved to step 4a before cache prep — so
613
+ # a hostile workspace cannot make the dist-tree hash walk traverse
614
+ # a symlinked-out target. The original step-5 block lived between
615
+ # cache prep and policy short-circuit; that location was sound for
616
+ # correctness but the cache code regressed the cheap-refusal
617
+ # posture against symlink workspaces. See step 4a comment.)
593
618
 
594
619
  # 6. Policy short-circuit. Runs BEFORE the CLI-missing / node-missing
595
620
  # banners so a shim whose policy says "disabled" exits 0 cleanly
@@ -667,7 +692,17 @@ shim_run() {
667
692
  # entry; rewriting it would be wasted work AND would refresh
668
693
  # `cached_at_unix` past the TTL ceiling, defeating the staleness
669
694
  # bound).
670
- if [ "$_shim_cache_hit" -eq 0 ] && [ -n "$_shim_cache_key" ]; then
695
+ #
696
+ # 0.48.1 SOUNDNESS: also skipped when SHIM_SKIP_VERSION_PROBE=1
697
+ # (delegation-capture path). Pre-fix the cache write proceeded
698
+ # after a skipped probe, recording `shape_ok: true` from
699
+ # defaulted-true logic without the probe having actually run.
700
+ # On the next fire a cache hit would read that entry and trust
701
+ # a version-probe answer that was never produced. The cache MUST
702
+ # only persist real probe results; if the probe was bypassed,
703
+ # the next fire pays the cost of running it for real.
704
+ if [ "$_shim_cache_hit" -eq 0 ] && [ -n "$_shim_cache_key" ] \
705
+ && [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ]; then
671
706
  local _write_payload=""
672
707
  _write_payload=$(node -e '
673
708
  const args = process.argv.slice(1);