loki-mode 11.17.1 → 11.17.6

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 (109) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/grill.sh +24 -2
  4. package/autonomy/hooks/precompact-flush.sh +51 -0
  5. package/autonomy/hooks/validate-bash.sh +895 -5
  6. package/autonomy/lib/chat.sh +8 -3
  7. package/autonomy/lib/claude-flags.sh +143 -6
  8. package/autonomy/lib/claude_permissions.py +24 -4
  9. package/autonomy/lib/decision_record.py +92 -3
  10. package/autonomy/lib/efficiency_cost.py +102 -0
  11. package/autonomy/lib/modernize/py_capture.py +62 -3
  12. package/autonomy/lib/prd-enrich.sh +28 -2
  13. package/autonomy/lib/voter-agents.sh +61 -6
  14. package/autonomy/lib/wiki-generator.py +58 -10
  15. package/autonomy/lib/wiki_llm.py +41 -2
  16. package/autonomy/loki +391 -28
  17. package/autonomy/quickstart.sh +1 -1
  18. package/autonomy/run.sh +201 -10
  19. package/autonomy/trigger-server.py +70 -2
  20. package/completions/_loki +3 -0
  21. package/completions/loki.bash +2 -1
  22. package/dashboard/__init__.py +1 -1
  23. package/dashboard/audit.py +204 -17
  24. package/docs/CLI-REFERENCE.md +4 -1
  25. package/docs/EVALUATING.md +2 -2
  26. package/docs/INSTALLATION.md +1 -1
  27. package/docs/environment-variables.md +1 -0
  28. package/docs/trackers.md +6 -0
  29. package/docs/v10/BOARD.md +7 -4
  30. package/docs/v10/CLI-MODERN.md +8 -5
  31. package/docs/v10/DECISIONS.md +31 -1
  32. package/docs/v10/ENGINE.md +1 -0
  33. package/docs/v10/FAILURE-CLASSES.md +60 -1
  34. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  35. package/docs/v10/GUIDE-MODERNIZE.md +9 -4
  36. package/docs/v10/PLAN-V12.md +896 -0
  37. package/docs/v10/PROGRESS.md +10 -0
  38. package/docs/v11/COST-HALF.md +3 -1
  39. package/docs/visual-evidence.md +2 -0
  40. package/loki-ts/dist/loki.js +1104 -1057
  41. package/mcp/__init__.py +1 -1
  42. package/memory/retrieval.py +15 -3
  43. package/memory/schemas.py +43 -1
  44. package/memory/storage.py +11 -0
  45. package/package.json +2 -2
  46. package/packages/control-plane/dist/server.js +989 -693
  47. package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
  48. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  49. package/providers/claude.sh +1 -0
  50. package/providers/loader.sh +26 -0
  51. package/providers/model_routes.sh +31 -0
  52. package/src/policies/claude_permissions.js +20 -2
  53. package/web-app/dist/assets/{AdminPage-OVzN6eLm.js → AdminPage-CCedYmn3.js} +1 -1
  54. package/web-app/dist/assets/{Avatar-hCCrxrCZ.js → Avatar-DMrDsuGe.js} +1 -1
  55. package/web-app/dist/assets/{Badge-CDcpkhbJ.js → Badge-K4Ui2tJz.js} +1 -1
  56. package/web-app/dist/assets/{Button-8mwWlhEu.js → Button-Bhjts5QZ.js} +1 -1
  57. package/web-app/dist/assets/{CockpitPage-D8cf3u22.js → CockpitPage-Be7sEVqh.js} +1 -1
  58. package/web-app/dist/assets/{ComparePage-CIgkbDRY.js → ComparePage-D4ty3jiF.js} +1 -1
  59. package/web-app/dist/assets/{ErrorBoundary-99kJH9g2.js → ErrorBoundary-Cmt2x_mL.js} +1 -1
  60. package/web-app/dist/assets/{EvidenceReceiptPanel-C9fj32eS.js → EvidenceReceiptPanel-D2IXLnp9.js} +1 -1
  61. package/web-app/dist/assets/{GitHubIssuesPanel-De43YQ_C.js → GitHubIssuesPanel-CyXitr6h.js} +1 -1
  62. package/web-app/dist/assets/{GitHubPRsPanel-7g7ZTnkx.js → GitHubPRsPanel-RRM3H-xd.js} +1 -1
  63. package/web-app/dist/assets/{HomePage-CcNA24ny.js → HomePage-CMtrrgfs.js} +2 -2
  64. package/web-app/dist/assets/{LoginPage-BYjlb-IK.js → LoginPage-BrzNhnMJ.js} +1 -1
  65. package/web-app/dist/assets/{MagicPage-C0Dwt9Na.js → MagicPage-D7VynOKM.js} +1 -1
  66. package/web-app/dist/assets/{MetricsPage-jDGmdcgg.js → MetricsPage-DJ-gDbfP.js} +1 -1
  67. package/web-app/dist/assets/{NotFoundPage-C_ZO8tLE.js → NotFoundPage-BJh0XVTj.js} +1 -1
  68. package/web-app/dist/assets/{ProjectPage-B5wnurfQ.js → ProjectPage-CAodVUpE.js} +3 -3
  69. package/web-app/dist/assets/{ProjectsPage--EyKt5-D.js → ProjectsPage-m3siS0Rf.js} +1 -1
  70. package/web-app/dist/assets/{SettingsPage-cB0lnfti.js → SettingsPage-CFGgjOvy.js} +1 -1
  71. package/web-app/dist/assets/{ShowcasePage-Dl16FDkK.js → ShowcasePage-Dd9tpSKJ.js} +1 -1
  72. package/web-app/dist/assets/{SystemSettingsPage-BmAXd4V2.js → SystemSettingsPage-DXd0PtTI.js} +1 -1
  73. package/web-app/dist/assets/{TeamsPage-BFkWVOZe.js → TeamsPage-C5KaLIGW.js} +1 -1
  74. package/web-app/dist/assets/{TemplatesPage-Bf2kmEUM.js → TemplatesPage-CDbzE0ZZ.js} +1 -1
  75. package/web-app/dist/assets/{TerminalOutput-BItv9Wa6.js → TerminalOutput-CKG79r_-.js} +1 -1
  76. package/web-app/dist/assets/{activity-Df2Awg6I.js → activity-BaQQRhDp.js} +1 -1
  77. package/web-app/dist/assets/{bell-D-1ztTsJ.js → bell-OeFqvU4e.js} +1 -1
  78. package/web-app/dist/assets/{bot-A4qgZsKi.js → bot-CSxaA0KU.js} +1 -1
  79. package/web-app/dist/assets/{check-BPfFpF0c.js → check-DJml2Xdn.js} +1 -1
  80. package/web-app/dist/assets/{chevron-left-CfSJvmDi.js → chevron-left-DGnt-DtK.js} +1 -1
  81. package/web-app/dist/assets/{circle-alert-wx2d1dzb.js → circle-alert-BV5rVL7K.js} +1 -1
  82. package/web-app/dist/assets/{clock-pQMai6l-.js → clock-CZ7SvULz.js} +1 -1
  83. package/web-app/dist/assets/{cloud-DWL470r6.js → cloud-lkIzrZps.js} +1 -1
  84. package/web-app/dist/assets/{code-xml-BaEQpQLZ.js → code-xml-DzA2wL8F.js} +1 -1
  85. package/web-app/dist/assets/{database-h-N7WkQN.js → database-CLFG5upC.js} +1 -1
  86. package/web-app/dist/assets/{dollar-sign-B5oYiD27.js → dollar-sign-DgWePGsv.js} +1 -1
  87. package/web-app/dist/assets/{file-code-corner-fbTtTy0o.js → file-code-corner-DGQX2bmv.js} +1 -1
  88. package/web-app/dist/assets/{file-plus-PaF6HMo8.js → file-plus-LkaXeYd6.js} +1 -1
  89. package/web-app/dist/assets/{globe-Ct-GlWID.js → globe-rSsEzPYv.js} +1 -1
  90. package/web-app/dist/assets/{hammer-C1oZFX51.js → hammer-CPogONt9.js} +1 -1
  91. package/web-app/dist/assets/{index-C2egrRhz.js → index-DpBLuMuz.js} +3 -3
  92. package/web-app/dist/assets/{layers-Dt5ZK4v2.js → layers-CwFaqidn.js} +1 -1
  93. package/web-app/dist/assets/{loader-circle-DrpMziic.js → loader-circle-C43izEm5.js} +1 -1
  94. package/web-app/dist/assets/{lock-DOez8MmK.js → lock-Bc_Uqxpp.js} +1 -1
  95. package/web-app/dist/assets/{package-ClGIkerr.js → package-BM25lcR-.js} +1 -1
  96. package/web-app/dist/assets/{plus-C004i9ua.js → plus-D0xoiY6N.js} +1 -1
  97. package/web-app/dist/assets/{refresh-cw-Ddfk-7Sy.js → refresh-cw-COI1R5Oo.js} +1 -1
  98. package/web-app/dist/assets/{rotate-ccw-Bu1KgDjS.js → rotate-ccw-BoNZx4xe.js} +1 -1
  99. package/web-app/dist/assets/{scroll-text-3q1lW5pm.js → scroll-text-Cakp8XHd.js} +1 -1
  100. package/web-app/dist/assets/{server-pd6x4HW1.js → server-CjfIlPmE.js} +1 -1
  101. package/web-app/dist/assets/{shield-alert-B9jGJepT.js → shield-alert-BilhziJF.js} +1 -1
  102. package/web-app/dist/assets/{thumbs-up-Cdcc9nQf.js → thumbs-up-CglMrtrt.js} +1 -1
  103. package/web-app/dist/assets/{trash-2-foSojXHK.js → trash-2-B6zVvnoG.js} +1 -1
  104. package/web-app/dist/assets/{trending-down-CTTiqlJ1.js → trending-down-BVhwD9bt.js} +1 -1
  105. package/web-app/dist/assets/{trending-up-B_MgmRAx.js → trending-up-CVJ1ox6c.js} +1 -1
  106. package/web-app/dist/assets/{upload-g6BfALzG.js → upload-DmuR09WL.js} +1 -1
  107. package/web-app/dist/assets/{usePolling-DrHwvzuh.js → usePolling-BTALfO91.js} +1 -1
  108. package/web-app/dist/assets/{user-COp75m7l.js → user-CRdHMZNH.js} +1 -1
  109. package/web-app/dist/index.html +1 -1
@@ -28,12 +28,23 @@ case "${LOKI_HOST_GUARD:-0}" in
28
28
  1|true|yes|on) HOST_GUARD=true ;;
29
29
  esac
30
30
 
31
- # P12-26d: under LOKI_PERMISSION_POLICY=compiled the compiled allow/deny list
31
+ # P12-26d: when the resolved policy is compiled (explicit compiled, or unset with an
32
+ # operator claude_permissions policy present) the compiled allow/deny list
32
33
  # and permission mode decide. An explicit hook "allow" skips the permission
33
34
  # prompt for every command that passes the blocklist, which would widen the
34
35
  # compiled policy, so the hook never emits one and fails closed on bad input.
35
36
  COMPILED_POLICY=false
36
- [ "${LOKI_PERMISSION_POLICY:-}" = "compiled" ] && COMPILED_POLICY=true
37
+ case "${LOKI_PERMISSION_POLICY:-}" in
38
+ legacy) ;;
39
+ "")
40
+ _vb_helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/src/policies/claude_permissions.js"
41
+ if command -v node >/dev/null 2>&1 && [ -f "$_vb_helper" ] \
42
+ && [ "$(node "$_vb_helper" --has-operator-policy 2>/dev/null)" = "1" ]; then
43
+ COMPILED_POLICY=true
44
+ fi
45
+ ;;
46
+ *) COMPILED_POLICY=true ;;
47
+ esac
37
48
 
38
49
  if ! command -v python3 >/dev/null 2>&1; then
39
50
  "$HOST_GUARD" && deny "LOKI_HOST_GUARD: python3 is unavailable, so the Bash command cannot be inspected safely."
@@ -51,6 +62,10 @@ except Exception:
51
62
  raise SystemExit(2)
52
63
  if not isinstance(command, str) or not command.strip():
53
64
  raise SystemExit(2)
65
+ # Join backslash-newline continuations as bash does (an odd run of backslashes
66
+ # before the newline), so every later split and pattern match sees one command.
67
+ import re
68
+ command = re.sub(r"(?<!\\)((?:\\\\)*)\\\r?\n", r"\1", command)
54
69
  sys.stdout.write(command)
55
70
  '); then
56
71
  "$HOST_GUARD" && deny "LOKI_HOST_GUARD: malformed or missing Bash command input was denied."
@@ -359,17 +374,58 @@ BLOCKED_PATTERNS=(
359
374
  # allowed, and `*` and `$$` in /tmp names. A `..` anywhere in the segment is
360
375
  # never safe.
361
376
  _SAFE_REDIR='([[:space:]]+[0-9]*>>?[[:space:]]*/dev/null)*[[:space:]]*'
362
- _SAFE_ENV='([A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9._/:-]*[[:space:]]+)*'
377
+ _SAFE_ENV='([ABCDEFGHIJKLMNOPQRSTUVWXYZ_][ABCDEFGHIJKLMNOPQRSTUVWXYZ0-9_]*=[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0-9._/:-]*[[:space:]]+)*'
363
378
  # A dot-leading path component with a glob char (`/tmp/.*` expands to `/tmp/..`).
364
379
  _DOT_GLOB='/\.[^/[:space:]]*[*?[]'
365
380
  SAFE_PATTERNS=(
366
- '^[[:space:]]*'"$_SAFE_ENV"'rm -rf (/tmp/([A-Za-z0-9._/*-]|\$\$)+[[:space:]]*)+'"$_SAFE_REDIR"'$'
367
- '^[[:space:]]*'"$_SAFE_ENV"'rm -rf \.loki/queue/dead-letter[A-Za-z0-9._/-]*'"$_SAFE_REDIR"'$'
381
+ '^[[:space:]]*'"$_SAFE_ENV"'rm -rf (/tmp/([abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0-9._/*-]|\$\$)+[[:space:]]*)+'"$_SAFE_REDIR"'$'
382
+ '^[[:space:]]*'"$_SAFE_ENV"'rm -rf \.loki/queue/dead-letter[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0-9._/-]*'"$_SAFE_REDIR"'$'
368
383
  )
369
384
 
385
+ # Env names that change what the allowed command executes or loads; a prefix
386
+ # naming one never rides the safe-path override.
387
+ _env_prefix_is_risky() {
388
+ local rest="$1" name
389
+ while [[ "$rest" =~ ^[[:space:]]*([abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_][abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0-9_]*)=[^[:space:]]*(.*)$ ]]; do
390
+ name="${BASH_REMATCH[1]}"
391
+ rest="${BASH_REMATCH[2]}"
392
+ case "$name" in
393
+ PATH|LD_*|DYLD_*|BASH_ENV|ENV|IFS|PS4|SHELLOPTS|BASHOPTS|PROMPT_COMMAND|GIT_SSH_COMMAND|GIT_EXEC_PATH|NODE_OPTIONS|PYTHONPATH|PERL5OPT|GCONV_PATH|LOCPATH|EXECIGNORE) return 0 ;;
394
+ esac
395
+ done
396
+ return 1
397
+ }
398
+
399
+ # Allowlist: the safe-path override applies only when EVERY segment of the
400
+ # command is blank, an accepted safe rm, or a bare `NAME=value` assignment
401
+ # (uppercase name not on the risky list, value free of quoting, expansion,
402
+ # grouping and redirection characters). Anything else (export, declare, unset,
403
+ # eval, source, hash, alias, function, subshell, echo, cd, ...) can change how a
404
+ # later rm resolves, so it disables the override. Over-denial is intended.
405
+ _ASSIGN_ONLY='^[[:space:]]*([ABCDEFGHIJKLMNOPQRSTUVWXYZ_][ABCDEFGHIJKLMNOPQRSTUVWXYZ_0-9]*)=([^][\"'"'"'\\$`(){}<>[:space:]]*)[[:space:]]*$'
406
+ _segment_is_inert() {
407
+ local seg="$1"
408
+ [[ "$seg" =~ ^[[:space:]]*(true|false|:)?[[:space:]]*$ ]] && return 0
409
+ if [[ "$seg" =~ $_ASSIGN_ONLY ]]; then
410
+ _env_prefix_is_risky "$seg" && return 1
411
+ return 0
412
+ fi
413
+ return 1
414
+ }
415
+
416
+ _all_segments_allowlisted() {
417
+ local seg
418
+ while IFS= read -r seg; do
419
+ _segment_is_inert "$seg" && continue
420
+ _segment_is_safe "$seg" || return 1
421
+ done <<< "$1"
422
+ return 0
423
+ }
424
+
370
425
  _segment_is_safe() {
371
426
  local seg="$1" safe
372
427
  [[ "$seg" == *".."* ]] && return 1
428
+ _env_prefix_is_risky "$seg" && return 1
373
429
  [[ "$seg" =~ $_DOT_GLOB ]] && return 1
374
430
  for safe in "${SAFE_PATTERNS[@]}"; do
375
431
  [[ "$seg" =~ $safe ]] && return 0
@@ -383,6 +439,7 @@ _blocked_hits_all_safe() {
383
439
  local pat="$1" flat seg hit=false
384
440
  # Strip fd duplication (2>&1) first: its `&` is not a command separator, and removing it hides no command.
385
441
  flat=$(printf '%s' "$COMMAND" | sed -E 's/[0-9]*>&[0-9]+//g' | tr ';&|\r' '\n')
442
+ _all_segments_allowlisted "$flat" || return 1
386
443
  while IFS= read -r seg; do
387
444
  printf '%s' "$seg" | grep -qE "$pat" || continue
388
445
  hit=true
@@ -391,6 +448,839 @@ _blocked_hits_all_safe() {
391
448
  "$hit"
392
449
  }
393
450
 
451
+ # A recursive rm whose target IS a temp root, or a bare glob directly under
452
+ # one, wipes every tenant of the temp dir. The target is normalized first
453
+ # (quotes and backslashes dropped, a glued trailing redirect cut, repeated and
454
+ # trailing slashes and `/./` collapsed, `..` resolved lexically) so `//tmp`,
455
+ # `/tmp/x/../*`, `/t\mp` and `"$TMPDIR"/*` cannot hide. Single-level brace
456
+ # alternatives are expanded and every alternative is checked. A named child
457
+ # (`/tmp/loki-run.abc123`, `/tmp/foo*`) is not a bare glob and stays ok.
458
+ _normalize_rm_target() {
459
+ local t="${1%%[<>]*}" prev="" comp joined="" sc
460
+ local -a parts=() stack=()
461
+ t="${t//[\"\'\\]/}"
462
+ while [ "$t" != "$prev" ]; do
463
+ prev="$t"
464
+ t="${t//\/\//\/}"
465
+ t="${t//\/.\//\/}"
466
+ done
467
+ if [[ "$t" == */* ]]; then
468
+ IFS=/ read -r -a parts <<< "$t"
469
+ for comp in ${parts[@]+"${parts[@]}"}; do
470
+ case "$comp" in
471
+ ''|.) ;;
472
+ ..)
473
+ # A dotdot that would pop a temp root component is
474
+ # unresolvable lexically ($TMPDIR/.. is the root's parent
475
+ # on some hosts): report the temp root itself (deny).
476
+ joined=""
477
+ for sc in ${stack[@]+"${stack[@]}"}; do joined="${joined}/${sc}"; done
478
+ case "$joined" in
479
+ /tmp|/var/tmp|/private/tmp|/private/var/tmp|'/$TMPDIR'|'/${TMPDIR}')
480
+ printf '%s' "/tmp"
481
+ return 0
482
+ ;;
483
+ esac
484
+ joined=""
485
+ if [ "${#stack[@]}" -gt 1 ] || { [ "${#stack[@]}" -eq 1 ] && [[ "$t" == /* ]]; }; then
486
+ unset 'stack[${#stack[@]}-1]'
487
+ fi
488
+ ;;
489
+ *) stack+=("$comp") ;;
490
+ esac
491
+ done
492
+ for comp in ${stack[@]+"${stack[@]}"}; do joined="${joined}/${comp}"; done
493
+ if [[ "$t" == /* ]]; then
494
+ t="${joined:-/}"
495
+ else
496
+ t="${joined#/}"
497
+ fi
498
+ fi
499
+ while [[ "$t" == */. && "$t" != "/." ]]; do t="${t%/.}"; done
500
+ while [[ "$t" == */ && "$t" != "/" ]]; do t="${t%/}"; done
501
+ printf '%s' "$t"
502
+ }
503
+
504
+ # Expand single-level comma brace groups into the global _ALTS array without
505
+ # a subshell. Fails closed (returns 1) on nested braces, `{x..y}` ranges, or
506
+ # more than 64 alternatives; the caller denies.
507
+ _BRACE_CAP=64
508
+ _expand_braces() {
509
+ local s="$1" cur pre body post b alt
510
+ local -a work=("$s")
511
+ local temp=false
512
+ _ALTS=()
513
+ # Fail-closed rules (nested braces, ranges, cap, residual groups) apply to
514
+ # every target except a plain relative path (no leading / ~ $ {, no `..`
515
+ # component). The run-owned LOKI_RUN_TMP variable is also exempt.
516
+ local q="${s//[\"\'\\]/}" stripped
517
+ temp=true
518
+ case "$q" in
519
+ '$LOKI_RUN_TMP'|'$LOKI_RUN_TMP/'*|'${LOKI_RUN_TMP}'|'${LOKI_RUN_TMP}/'*) temp=false ;;
520
+ /*|'~'*|'$'*|'{'*) ;;
521
+ *)
522
+ if [[ "/${q}/" != *"/../"* ]]; then temp=false; fi
523
+ ;;
524
+ esac
525
+ if $temp; then
526
+ # Drop parameter expansions (innermost first) so `${A:-${B}}` is not nesting.
527
+ stripped="$s"
528
+ while [[ "$stripped" =~ \$\{[^\{\}]*\} ]]; do
529
+ stripped="${stripped/"${BASH_REMATCH[0]}"/}"
530
+ done
531
+ # A numeric range under a non-TMPDIR variable only appends digits, so it
532
+ # cannot build a temp root (`$BUILD_DIR/shard{1..4}`).
533
+ if [[ "$q" =~ ^\$\{?([A-Za-z_][A-Za-z0-9_]*) && "${BASH_REMATCH[1]}" != TMPDIR ]]; then
534
+ while [[ "$stripped" =~ \{-?[0-9]+\.\.-?[0-9]+\} ]]; do
535
+ stripped="${stripped/"${BASH_REMATCH[0]}"/}"
536
+ done
537
+ fi
538
+ if [[ "$stripped" =~ \{[^\}]*\{ ]] || [[ "$stripped" =~ \{[^\}]*\.\.[^\}]*\} ]]; then
539
+ return 1
540
+ fi
541
+ fi
542
+ while [ "${#work[@]}" -gt 0 ]; do
543
+ cur="${work[0]}"
544
+ work=("${work[@]:1}")
545
+ if [[ "$cur" =~ ^((\$\{[^\}]*\}|[^\{])*)\{([^\}]*,[^\}]*)\}(.*)$ ]]; then
546
+ pre="${BASH_REMATCH[1]}"; body="${BASH_REMATCH[3]}"; post="${BASH_REMATCH[4]}"
547
+ b="$body"
548
+ while :; do
549
+ alt="${b%%,*}"
550
+ work+=("${pre}${alt}${post}")
551
+ [[ "$b" == *,* ]] || break
552
+ b="${b#*,}"
553
+ done
554
+ [ $(( ${#work[@]} + ${#_ALTS[@]} )) -le "$_BRACE_CAP" ] || { $temp && return 1; return 2; }
555
+ else
556
+ # A comma group the regex could not peel off stays unexpanded.
557
+ if $temp && [[ "$cur" =~ \{[^\}]*,[^\}]*\} ]]; then
558
+ return 1
559
+ fi
560
+ _ALTS+=("$cur")
561
+ [ $(( ${#work[@]} + ${#_ALTS[@]} )) -le "$_BRACE_CAP" ] || { $temp && return 1; return 2; }
562
+ fi
563
+ done
564
+ return 0
565
+ }
566
+
567
+ # A component is glob-only when it is only `*`, `?`, `.`, or when it holds a
568
+ # `[` with no literal prefix before it (bracket parsing is ambiguous: `[!]]`,
569
+ # `[]a]`, `[=a=]`, so fail closed).
570
+ _glob_only() {
571
+ local c="$1" pre
572
+ [[ "$c" =~ ^[*?.]+$ ]] && return 0
573
+ if [[ "$c" == *"["* ]]; then
574
+ pre="${c%%\[*}"
575
+ [[ "$pre" =~ ^[*?.]*$ ]] && return 0
576
+ fi
577
+ return 1
578
+ }
579
+ _temp_target_is_root_or_glob() {
580
+ local t rest
581
+ t=$(_normalize_rm_target "$1")
582
+ case "$t" in
583
+ /tmp|/var/tmp|/private/tmp|/private/var/tmp|'$TMPDIR'|'${TMPDIR}') return 0 ;;
584
+ /private/var/tmp/*) rest="${t#/private/var/tmp/}" ;;
585
+ /private/tmp/*) rest="${t#/private/tmp/}" ;;
586
+ /var/tmp/*) rest="${t#/var/tmp/}" ;;
587
+ /tmp/*) rest="${t#/tmp/}" ;;
588
+ '$TMPDIR'/*) rest="${t#\$TMPDIR/}" ;;
589
+ '${TMPDIR}'/*) rest="${t#\$\{TMPDIR\}/}" ;;
590
+ /private/var/tmp*) rest="${t#/private/var/tmp}"; _glob_only "$rest"; return ;;
591
+ /private/tmp*) rest="${t#/private/tmp}"; _glob_only "$rest"; return ;;
592
+ /var/tmp*) rest="${t#/var/tmp}"; _glob_only "$rest"; return ;;
593
+ /tmp*) rest="${t#/tmp}"; _glob_only "$rest"; return ;;
594
+ '$TMPDIR'*) rest="${t#\$TMPDIR}"; _glob_only "$rest"; return ;;
595
+ '${TMPDIR}'*) rest="${t#\$\{TMPDIR\}}"; _glob_only "$rest"; return ;;
596
+ *) return 1 ;;
597
+ esac
598
+ # first component made only of glob characters, dots and bracket expressions
599
+ _glob_only "${rest%%/*}" && return 0
600
+ return 1
601
+ }
602
+
603
+ _rm_target_is_temp_root_or_glob() {
604
+ local alt
605
+ if [[ "$1" == *"{"* ]]; then
606
+ _expand_braces "$1" || {
607
+ # 1 = fail closed (temp target); 2 = over cap on a non-temp target
608
+ [ "$?" -eq 2 ] && return 1
609
+ return 0
610
+ }
611
+ for alt in ${_ALTS[@]+"${_ALTS[@]}"}; do
612
+ _temp_target_is_root_or_glob "$alt" && return 0
613
+ done
614
+ return 1
615
+ fi
616
+ _temp_target_is_root_or_glob "$1"
617
+ }
618
+
619
+ # P12-26g-c: residual shapes that reach a temp root or the filesystem root
620
+ # without naming it. Each helper returns 0 to DENY (fail closed).
621
+ _RUN_TMP_RE='^/([^/]+/)*loki-run\.[A-Za-z0-9]+$'
622
+ _run_tmp_valid() {
623
+ local v="${LOKI_RUN_TMP:-}"
624
+ [[ "$v" =~ $_RUN_TMP_RE ]] || return 1
625
+ [[ "/${v}/" != *"/../"* && "/${v}/" != *"/./"* ]]
626
+ }
627
+
628
+ # A component that can expand to `..`: a literal `..`, one built by gluing
629
+ # variables onto dots (`..$X`, `.$X.`; X may be empty or `.`), or a dot-leading
630
+ # glob (`.?`, `.*`, `..*`, `.[.]`; bash < 5.2 has no globskipdots, so these
631
+ # match `..`). Worst case is assumed everywhere.
632
+ _is_dotdotish() {
633
+ local c="$1" s="$1"
634
+ [[ "$c" == *.* ]] || return 1
635
+ while [[ "$s" =~ (\$\{[^\}]*\}|\$[[:alpha:]_][[:alnum:]_]*) ]]; do
636
+ s="${s/"${BASH_REMATCH[0]}"/}"
637
+ done
638
+ [ "$s" = "." ] && [ "$s" = "$c" ] && return 1
639
+ [[ "$s" =~ ^\.[.*?]*$ ]] || [[ "$s" =~ ^\.[.*?]*\[ ]]
640
+ }
641
+
642
+ _has_dotdot_component() {
643
+ local c
644
+ local -a p=()
645
+ [[ "$1" == *.* ]] || return 1
646
+ IFS=/ read -r -a p <<< "$1"
647
+ for c in ${p[@]+"${p[@]}"}; do _is_dotdotish "$c" && return 0; done
648
+ return 1
649
+ }
650
+
651
+ # Lexical resolve of an absolute path; `..` (or a possible `..`) at the root
652
+ # stays at the root.
653
+ _lex_resolve() {
654
+ local comp out=""
655
+ local -a parts=() stack=()
656
+ IFS=/ read -r -a parts <<< "$1"
657
+ for comp in ${parts[@]+"${parts[@]}"}; do
658
+ case "$comp" in
659
+ ''|.) ;;
660
+ *)
661
+ if _is_dotdotish "$comp"; then
662
+ [ "${#stack[@]}" -gt 0 ] && unset 'stack[${#stack[@]}-1]'
663
+ else
664
+ stack+=("$comp")
665
+ fi
666
+ ;;
667
+ esac
668
+ done
669
+ for comp in ${stack[@]+"${stack[@]}"}; do out="${out}/${comp}"; done
670
+ printf '%s' "${out:-/}"
671
+ }
672
+
673
+ # Resolved path is a temp root, a bare temp glob, or an ancestor of a temp root.
674
+ _resolved_reaches_temp() {
675
+ case "$1" in
676
+ /|/tmp|/var|/var/tmp|/private|/private/tmp|/private/var|/private/var/tmp) return 0 ;;
677
+ esac
678
+ _temp_target_is_root_or_glob "$1"
679
+ }
680
+
681
+ # `..` climbing from a known base: deny when the result leaves the base or
682
+ # reaches a temp root; an unknown or relative base is unresolvable (deny).
683
+ _dotdot_escapes_base() {
684
+ local base="$1" rel="$2" res
685
+ [[ "$base" == /* ]] || return 0
686
+ base=$(_lex_resolve "$base")
687
+ res=$(_lex_resolve "${base}/${rel}")
688
+ _resolved_reaches_temp "$res" && return 0
689
+ [[ "$res" == "$base" || "$res" == "$base"/* ]] || return 0
690
+ return 1
691
+ }
692
+
693
+ # Absolute path an empty or defaulted variable can build: deny when it is, or
694
+ # climbs to, a temp root or the filesystem root, or holds a glob root.
695
+ _abs_path_deny() {
696
+ _resolved_reaches_temp "$(_lex_resolve "$1")" && return 0
697
+ _alt_extra_deny "$1"
698
+ }
699
+
700
+ _alt_extra_deny() {
701
+ local a="$1" name="" mod="" rest="" rest0="" r n c1 c2 c3 w combined
702
+ local multi=false
703
+ if [[ "$a" =~ ^\$\{([A-Za-z_][A-Za-z0-9_]*)([^\}]*)\}(.*)$ ]]; then
704
+ name="${BASH_REMATCH[1]}"; mod="${BASH_REMATCH[2]}"; rest="${BASH_REMATCH[3]}"
705
+ elif [[ "$a" =~ ^\$([A-Za-z_][A-Za-z0-9_]*)(.*)$ ]]; then
706
+ name="${BASH_REMATCH[1]}"; rest="${BASH_REMATCH[2]}"
707
+ fi
708
+ rest0="$rest"
709
+ if [ -n "$name" ]; then
710
+ # Strip every further leading expansion ($X$Y/..., "$X""$Y"/...).
711
+ while [[ "$rest" =~ ^(\$\{[[:alpha:]_][[:alnum:]_]*[^\}]*\}|\$[[:alpha:]_][[:alnum:]_]*)(.*)$ ]]; do
712
+ multi=true
713
+ rest="${BASH_REMATCH[2]}"
714
+ done
715
+ # A default or alternative word can supply the root: ${X:-/}tmp/*,
716
+ # ${X:-/tmp}. Check the word plus the rest as the path it can build.
717
+ if [ -n "$mod" ] && [ "$name" != "LOKI_RUN_TMP" ]; then
718
+ case "$mod" in
719
+ :-*|:=*|:+*) w="${mod:2}" ;;
720
+ -*|=*|+*) w="${mod:1}" ;;
721
+ *) w="" ;;
722
+ esac
723
+ combined="${w}${rest0}"
724
+ if [[ "$combined" == /* ]]; then
725
+ _abs_path_deny "$combined" && return 0
726
+ fi
727
+ fi
728
+ case "$name" in
729
+ LOKI_RUN_TMP*)
730
+ [ "$name" = "LOKI_RUN_TMP" ] || return 0
731
+ [ -z "$mod" ] || return 0
732
+ "$multi" && return 0
733
+ _run_tmp_valid || return 0
734
+ [ -z "$rest" ] && return 1
735
+ [[ "$rest" == /* ]] || return 0
736
+ _has_dotdot_component "$rest" && return 0
737
+ [[ "$rest" =~ $_DOT_GLOB ]] && return 0
738
+ return 1
739
+ ;;
740
+ TMPDIR) "$multi" || return 1 ;;
741
+ HOME)
742
+ if ! "$multi"; then
743
+ _has_dotdot_component "$rest" || return 1
744
+ _dotdot_escapes_base "${HOME:-}" "$rest"
745
+ return
746
+ fi
747
+ ;;
748
+ esac
749
+ _has_dotdot_component "$rest" && return 0
750
+ if [[ "$rest" == /* ]]; then
751
+ r="$rest"
752
+ while [[ "$r" == /* || "$r" == ./* ]]; do r="${r#/}"; r="${r#./}"; done
753
+ case "$r" in ''|'*'*|'?'*|'['*|'{'*) return 0 ;; esac
754
+ # An unset variable makes the rest an absolute path: deny when it
755
+ # is, or climbs to, a temp root, or any component holds a glob.
756
+ # The only brace left after expansion that stays ok is a numeric
757
+ # range glued to a literal prefix in the last component.
758
+ local lastc="${rest##*/}" headp="${rest%/*}"
759
+ [[ "$headp" == *[\*\?\[\{]* ]] && return 0
760
+ [[ "$lastc" == *[\*\?\[]* ]] && return 0
761
+ if [[ "$lastc" == *"{"* ]]; then
762
+ [[ "$lastc" =~ ^[^\{]+\{-?[0-9]+\.\.-?[0-9]+\}$ ]] || return 0
763
+ fi
764
+ _resolved_reaches_temp "$(_lex_resolve "$rest")" && return 0
765
+ fi
766
+ return 1
767
+ fi
768
+ # shellcheck disable=SC2088 # literal tilde in a string being inspected
769
+ case "$a" in
770
+ '~'|'~/'*)
771
+ rest="${a#\~}"
772
+ _has_dotdot_component "$rest" || return 1
773
+ _dotdot_escapes_base "${HOME:-}" "$rest"
774
+ return
775
+ ;;
776
+ '~+'*|'~-'*)
777
+ # PWD / OLDPWD: OLDPWD is unknown, so a glob, dotdot or bare form denies.
778
+ rest="${a#\~?}"
779
+ [ -z "$rest" ] && return 0
780
+ [[ "$rest" == /* ]] || return 1
781
+ r="${rest#/}"
782
+ case "$r" in ''|'*'*|'?'*|'['*|'{'*) return 0 ;; esac
783
+ _has_dotdot_component "$rest" && return 0
784
+ return 1
785
+ ;;
786
+ '$'*|'{'*) return 1 ;;
787
+ /*)
788
+ n="$a"
789
+ r=""
790
+ while [ "$n" != "$r" ]; do r="$n"; n="${n//\/\//\/}"; n="${n//\/.\//\/}"; done
791
+ if _has_dotdot_component "$n"; then
792
+ n=$(_lex_resolve "$n")
793
+ _resolved_reaches_temp "$n" && return 0
794
+ fi
795
+ n="${n#/}"
796
+ c1="${n%%/*}"
797
+ c2=""
798
+ [[ "$n" == */* ]] && { c2="${n#*/}"; c2="${c2%%/*}"; }
799
+ case "$c1" in *[\*\?\[]*) return 0 ;; esac
800
+ case "$c2" in *[\*\?\[]*) [ "$c1" = "tmp" ] || return 0 ;; esac
801
+ if [ "$c1" = "private" ] && [ "$c2" = "var" ]; then
802
+ c3="${n#*/}"; c3="${c3#*/}"; c3="${c3%%/*}"
803
+ case "$c3" in *[\*\?\[]*) return 0 ;; esac
804
+ fi
805
+ return 1
806
+ ;;
807
+ esac
808
+ _has_dotdot_component "$a" || return 1
809
+ _dotdot_escapes_base "$CWD" "$a"
810
+ }
811
+
812
+ # Over-cap groups cannot be enumerated: deny when any dot touches a brace,
813
+ # comma, glob, bracket or variable (a possible `..`), or a `*` alternative.
814
+ _OVERCAP_RE='\.\.|[{,]\.|\.[{},]|\.[^/,{}]*[*?[$]|[{,]\*[,}]'
815
+ _rm_target_extra_deny() {
816
+ local q="${1%%[<>]*}" alt rc=0
817
+ q="${q//[\"\'\\]/}"
818
+ local -a alts=("$q")
819
+ if [[ "$q" == *"{"* ]]; then
820
+ _expand_braces "$q" || rc=$?
821
+ [ "$rc" -eq 1 ] && return 0
822
+ # Over cap (rc 2): a `.` or `..` alternative or a dot glued to a group
823
+ # can build a dotdot component bash will expand; fail closed.
824
+ if [ "$rc" -eq 2 ] && [[ "$q" =~ $_OVERCAP_RE ]]; then
825
+ return 0
826
+ fi
827
+ [ "$rc" -eq 0 ] && alts=(${_ALTS[@]+"${_ALTS[@]}"})
828
+ fi
829
+ local stripped
830
+ for alt in ${alts[@]+"${alts[@]}"}; do
831
+ # A comma group the expander could not peel (a parameter expansion
832
+ # inside it) is unenumerable: fail closed.
833
+ if [ "$rc" -eq 0 ] && [[ "$alt" == *"{"* ]]; then
834
+ stripped="$alt"
835
+ while [[ "$stripped" =~ \$\{[^\{\}]*\} ]]; do
836
+ stripped="${stripped/"${BASH_REMATCH[0]}"/}"
837
+ done
838
+ [[ "$stripped" =~ \{[^\}]*,[^\}]*\} ]] && return 0
839
+ fi
840
+ _alt_extra_deny "$alt" && return 0
841
+ done
842
+ return 1
843
+ }
844
+
845
+ # P12-26g-d: allow-grammar for recursive rm targets. A target is allowed only
846
+ # when, after quotes and braces are removed, it matches one of the shapes
847
+ # below; anything else denies (fail closed). It runs BEFORE the older
848
+ # temp-root checks, which still run on every target the grammar lets through.
849
+ # (a) plain relative path: first component not a glob, never climbs above start
850
+ # (b) absolute path outside every temp root and its ancestors, no glob in the
851
+ # first two components (except /tmp/<name>*), no expansion inside it
852
+ # (c) "$LOKI_RUN_TMP"[/...] with a valid LOKI_RUN_TMP
853
+ # (d) $TMPDIR or ${TMPDIR} with no modifier, then /<non-glob name>
854
+ # (e) $HOME or ~ then /..., staying inside HOME and off every temp root
855
+ # (f) another named variable, then /<non-glob, non-dotdot name>
856
+ # Denied outright: positional or special parameters, ${!X}, ${#X}, $'..',
857
+ # $"..", command substitution, nested ${..${..}}, any ${X<modifier>} other than
858
+ # a whole-target :- or - default, and any expansion inside an absolute path.
859
+ # Every function below returns 0 to DENY.
860
+ _G_CAP=64
861
+ _g_expand() {
862
+ local cur pre body post lo hi i span b alt
863
+ local -a work=("$1")
864
+ _G_ALTS=()
865
+ while [ "${#work[@]}" -gt 0 ]; do
866
+ cur="${work[0]}"
867
+ work=("${work[@]:1}")
868
+ # A leading sentinel keeps the regex free of an empty alternation.
869
+ if [[ "@${cur}" =~ ^(.*[^$])\{([^\{\}]*)\}(.*)$ ]]; then
870
+ pre="${BASH_REMATCH[1]#@}"; body="${BASH_REMATCH[2]}"; post="${BASH_REMATCH[3]}"
871
+ if [[ "$body" =~ ^(-?[0-9]+)\.\.(-?[0-9]+)$ ]]; then
872
+ lo="${BASH_REMATCH[1]}"; hi="${BASH_REMATCH[2]}"
873
+ [ "${#lo}" -le 6 ] && [ "${#hi}" -le 6 ] || return 2
874
+ case "$lo" in -*) lo=$(( -(10#${lo#-}) )) ;; *) lo=$(( 10#$lo )) ;; esac
875
+ case "$hi" in -*) hi=$(( -(10#${hi#-}) )) ;; *) hi=$(( 10#$hi )) ;; esac
876
+ span=$(( hi - lo )); [ "$span" -lt 0 ] && span=$(( -span ))
877
+ [ "$span" -lt "$_G_CAP" ] || return 2
878
+ i="$lo"
879
+ while :; do
880
+ work+=("${pre}${i}${post}")
881
+ [ "$i" -eq "$hi" ] && break
882
+ if [ "$lo" -le "$hi" ]; then i=$(( i + 1 )); else i=$(( i - 1 )); fi
883
+ done
884
+ elif [[ "$body" == *,* ]]; then
885
+ b="$body"
886
+ while :; do
887
+ alt="${b%%,*}"
888
+ work+=("${pre}${alt}${post}")
889
+ [[ "$b" == *,* ]] || break
890
+ b="${b#*,}"
891
+ done
892
+ else
893
+ return 1
894
+ fi
895
+ else
896
+ [[ "$cur" =~ (^|[^$])\{ ]] && return 1
897
+ _G_ALTS+=("$cur")
898
+ fi
899
+ [ $(( ${#work[@]} + ${#_G_ALTS[@]} )) -le "$_G_CAP" ] || return 2
900
+ done
901
+ return 0
902
+ }
903
+
904
+ # After the first variable, a component that is exactly $name or ${name}
905
+ # (not TMPDIR or HOME) is folded to a plain word; the glob and dotdot checks
906
+ # then judge the folded path.
907
+ _g_fold_vars() {
908
+ local rest="$1" c out="" n
909
+ local -a p=()
910
+ [[ "$rest" == /* ]] || { printf '%s' "$rest"; return; }
911
+ IFS=/ read -r -a p <<< "${rest#/}"
912
+ for c in ${p[@]+"${p[@]}"}; do
913
+ if [[ "$c" =~ ^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$ ]]; then
914
+ n="${BASH_REMATCH[1]}"
915
+ case "$n" in TMPDIR|HOME) ;; *) c="v" ;; esac
916
+ fi
917
+ out="${out}/${c}"
918
+ done
919
+ [[ "$rest" == */ ]] && out="${out}/"
920
+ printf '%s' "$out"
921
+ }
922
+
923
+ # Rest after a variable: "/" then a non-glob first component, and no
924
+ # dotdot-capable component anywhere.
925
+ _g_named_rest_deny() {
926
+ local rest="$1" c first=true
927
+ local -a p=()
928
+ [[ "$rest" == /* ]] || return 0
929
+ IFS=/ read -r -a p <<< "${rest#/}"
930
+ [ "${#p[@]}" -gt 0 ] || return 0
931
+ for c in "${p[@]}"; do
932
+ _is_dotdotish "$c" && return 0
933
+ if $first; then
934
+ [ -n "$c" ] || return 0
935
+ [[ "$c" == *[\*\?\[]* ]] && return 0
936
+ first=false
937
+ fi
938
+ done
939
+ return 1
940
+ }
941
+
942
+ _g_home_deny() {
943
+ local rest="$1" home="${HOME:-}" hr res
944
+ [[ "$home" == /* ]] || return 0
945
+ [ -n "$rest" ] || return 0
946
+ [[ "$rest" == /* ]] || return 0
947
+ hr=$(_lex_resolve "$home")
948
+ [ "$hr" != "/" ] || return 0
949
+ _resolved_reaches_temp "$hr" && return 0
950
+ res=$(_lex_resolve "${hr}${rest}")
951
+ _resolved_reaches_temp "$res" && return 0
952
+ [[ "$res" == "$hr"/* ]] || return 0
953
+ return 1
954
+ }
955
+
956
+ _g_abs_deny() {
957
+ local a="$1" r n c1 c2 c3
958
+ [[ "$a" == *'$'* ]] && return 0
959
+ [[ "$a" =~ $_DOT_GLOB ]] && return 0
960
+ r=$(_lex_resolve "$a")
961
+ _resolved_reaches_temp "$r" && return 0
962
+ n="${r#/}"
963
+ c1="${n%%/*}"
964
+ c2=""
965
+ [[ "$n" == */* ]] && { c2="${n#*/}"; c2="${c2%%/*}"; }
966
+ case "$c1" in *[\*\?\[]*) return 0 ;; esac
967
+ case "$c2" in *[\*\?\[]*) [ "$c1" = "tmp" ] || return 0 ;; esac
968
+ if [ "$c1" = "private" ] && [ "$c2" = "var" ]; then
969
+ c3="${n#*/}"; c3="${c3#*/}"; c3="${c3%%/*}"
970
+ case "$c3" in *[\*\?\[]*) return 0 ;; esac
971
+ fi
972
+ return 1
973
+ }
974
+
975
+ _g_rel_deny() {
976
+ local a="$1" c depth=0 seen=false
977
+ local -a p=()
978
+ [[ "$a" == *'$'* ]] && return 0
979
+ IFS=/ read -r -a p <<< "$a"
980
+ for c in ${p[@]+"${p[@]}"}; do
981
+ case "$c" in
982
+ '') continue ;;
983
+ .) seen=true; continue ;;
984
+ ..) depth=$(( depth - 1 )); [ "$depth" -ge 0 ] || return 0; continue ;;
985
+ esac
986
+ _is_dotdotish "$c" && return 0
987
+ if ! $seen; then
988
+ seen=true
989
+ case "$c" in [\*\?\[]*) return 0 ;; esac
990
+ fi
991
+ depth=$(( depth + 1 ))
992
+ done
993
+ [ "$depth" -gt 0 ] && return 1
994
+ return 0
995
+ }
996
+
997
+ _g_alt_deny() {
998
+ local a="$1" name rest w
999
+ [ -n "$a" ] || return 0
1000
+ # Whole-target default: ${N:-W} or ${N-W}, N not TMPDIR, W a variable, a
1001
+ # relative word with no leading glob, or an allowed absolute path.
1002
+ if [[ "$a" =~ ^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-|-)(.*)\}$ ]]; then
1003
+ name="${BASH_REMATCH[1]}"; w="${BASH_REMATCH[3]}"
1004
+ [ "$name" != "TMPDIR" ] && [ "$name" != "HOME" ] || return 0
1005
+ [ -n "$w" ] || return 0
1006
+ if [[ "$w" =~ ^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$ ]]; then
1007
+ [ "${BASH_REMATCH[1]}" != "TMPDIR" ] && [ "${BASH_REMATCH[1]}" != "HOME" ] || return 0
1008
+ return 1
1009
+ fi
1010
+ [[ "$w" == *'$'* || "$w" == *'{'* || "$w" == *'}'* ]] && return 0
1011
+ _g_alt_deny "$w"
1012
+ return
1013
+ fi
1014
+ # ${TMPDIR:-<temp root>}/<named child>
1015
+ if [[ "$a" =~ ^\$\{TMPDIR:-(/[^\}\$]*)\}(/.*)$ ]]; then
1016
+ w="${BASH_REMATCH[1]}"; rest="${BASH_REMATCH[2]}"
1017
+ case "$(_lex_resolve "$w")" in
1018
+ /tmp|/var/tmp|/private/tmp|/private/var/tmp) ;;
1019
+ *) return 0 ;;
1020
+ esac
1021
+ [[ "$rest" == *'$'* ]] && return 0
1022
+ _g_named_rest_deny "$rest"
1023
+ return
1024
+ fi
1025
+ # ${N:?} and ${N:?msg} guards behave exactly like a bare ${N}.
1026
+ if [[ "$a" =~ ^\$\{([A-Za-z_][A-Za-z0-9_]*):\?[^\}\$\{]*\}(.*)$ ]]; then
1027
+ a="\${${BASH_REMATCH[1]}}${BASH_REMATCH[2]}"
1028
+ fi
1029
+ # shellcheck disable=SC2088 # literal tilde in a string being inspected
1030
+ case "$a" in
1031
+ '$'*)
1032
+ if [[ "$a" =~ ^\$\{([A-Za-z_][A-Za-z0-9_]*)\}(.*)$ ]] || [[ "$a" =~ ^\$([A-Za-z_][A-Za-z0-9_]*)(.*)$ ]]; then
1033
+ name="${BASH_REMATCH[1]}"; rest="${BASH_REMATCH[2]}"
1034
+ else
1035
+ return 0
1036
+ fi
1037
+ case "$name" in
1038
+ LOKI_RUN_TMP|HOME|TMPDIR) ;;
1039
+ *) rest=$(_g_fold_vars "$rest") ;;
1040
+ esac
1041
+ [[ "$rest" == *'$'* || "$rest" == *'{'* || "$rest" == *'}'* ]] && return 0
1042
+ [ -z "$rest" ] || [[ "$rest" == /* ]] || return 0
1043
+ case "$name" in
1044
+ LOKI_RUN_TMP)
1045
+ _run_tmp_valid || return 0
1046
+ [ -z "$rest" ] && return 1
1047
+ _has_dotdot_component "$rest" && return 0
1048
+ [[ "$rest" =~ $_DOT_GLOB ]] && return 0
1049
+ return 1
1050
+ ;;
1051
+ HOME) _g_home_deny "$rest"; return ;;
1052
+ *)
1053
+ # A whole-target named variable (rm -r "$dir") is allowed;
1054
+ # TMPDIR bare names the temp root itself.
1055
+ if [ -z "$rest" ]; then
1056
+ [ "$name" != "TMPDIR" ] || return 0
1057
+ return 1
1058
+ fi
1059
+ _g_named_rest_deny "$rest"
1060
+ return
1061
+ ;;
1062
+ esac
1063
+ ;;
1064
+ '~'|'~/'*)
1065
+ [[ "$a" == *'$'* ]] && return 0
1066
+ _g_home_deny "${a#\~}"
1067
+ return
1068
+ ;;
1069
+ '~'*|'{'*|*'{'*|*'}'*) return 0 ;;
1070
+ /*) _g_abs_deny "$a"; return ;;
1071
+ esac
1072
+ _g_rel_deny "$a"
1073
+ }
1074
+
1075
+ _rm_grammar_deny() {
1076
+ local t="${1%%[<>]*}" q alt rc=0
1077
+ [ -n "$t" ] || return 1
1078
+ # $' and $" open ANSI-C / locale quoting; a $$ before the quote is a PID.
1079
+ [[ "$t" =~ (^|[^$])\$[\'\"] ]] && return 0
1080
+ q="${t//[\"\'\\]/}"
1081
+ # find -exec rm -r {} + / \; : a bare {} is find's placeholder, not a brace.
1082
+ [ "$q" = "{}" ] && return 1
1083
+ [[ "$q" == *'`'* || "$q" == *'$('* ]] && return 0
1084
+ # $$ is a PID only mid-component (/tmp/test-$$); a leading $$ denies below.
1085
+ while [[ "$q" =~ ^(.*[^/\$])\$\$(.*)$ ]]; do q="${BASH_REMATCH[1]}0${BASH_REMATCH[2]}"; done
1086
+ local -a alts=("$q")
1087
+ if [[ "$q" == *"{"* ]]; then
1088
+ _g_expand "$q" || rc=$?
1089
+ [ "$rc" -eq 1 ] && return 0
1090
+ if [ "$rc" -eq 2 ]; then
1091
+ # Over the cap: only a named relative first component, no
1092
+ # expansion and no dot trick, can be left unexpanded.
1093
+ [[ "$q" =~ ^[A-Za-z0-9_-][A-Za-z0-9._-]*/ ]] || return 0
1094
+ [[ "$q" == *'$'* ]] && return 0
1095
+ [[ "$q" =~ $_OVERCAP_RE ]] && return 0
1096
+ return 1
1097
+ fi
1098
+ alts=(${_G_ALTS[@]+"${_G_ALTS[@]}"})
1099
+ fi
1100
+ for alt in ${alts[@]+"${alts[@]}"}; do
1101
+ _g_alt_deny "$alt" && return 0
1102
+ done
1103
+ return 1
1104
+ }
1105
+
1106
+ # `rm`, `/bin/rm`, `\rm`, `"rm"` all name rm.
1107
+ _is_rm_token() {
1108
+ local x="${1//[\"\'\\]/}"
1109
+ [ "${x##*/}" = "rm" ]
1110
+ }
1111
+
1112
+ _segment_rm_hits_temp_root() {
1113
+ local seg="$1" tok recursive=false seen_rm=false
1114
+ local -a words
1115
+ # Command substitution and grouping open a new command word: split on them.
1116
+ [[ "$seg" == *'`'* ]] && seg="${seg//\`/ \$( }"
1117
+ seg="${seg//[()\`]/ }"
1118
+ read -r -a words <<< "$seg"
1119
+ for tok in "${words[@]}"; do
1120
+ if ! $seen_rm; then
1121
+ _is_rm_token "$tok" && seen_rm=true
1122
+ continue
1123
+ fi
1124
+ case "$tok" in
1125
+ --recursive) recursive=true ;;
1126
+ -*) [[ "$tok" =~ ^-[[:alpha:]]*[rR] ]] && recursive=true ;;
1127
+ esac
1128
+ done
1129
+ $seen_rm && $recursive || return 1
1130
+ seen_rm=false
1131
+ for tok in "${words[@]}"; do
1132
+ if ! $seen_rm; then
1133
+ _is_rm_token "$tok" && seen_rm=true
1134
+ continue
1135
+ fi
1136
+ case "$tok" in -*) continue ;; esac
1137
+ [ -n "${tok//[\"\'\\]/}" ] || continue
1138
+ # Fail closed before the hook timeout (Claude Code treats it as allow).
1139
+ [ $((SECONDS - _RM_T0)) -ge "$_RM_BUDGET" ] && return 0
1140
+ _rm_grammar_deny "$tok" && return 0
1141
+ _rm_target_is_temp_root_or_glob "$tok" && return 0
1142
+ _rm_target_extra_deny "$tok" && return 0
1143
+ done
1144
+ return 1
1145
+ }
1146
+
1147
+ _RM_T0=$SECONDS
1148
+ _RM_BUDGET="${LOKI_RM_GUARD_BUDGET_SECS:-20}"
1149
+ case "$_RM_BUDGET" in ""|*[!0-9]*) _RM_BUDGET=20 ;; esac
1150
+ # P12-26g-d: inspect only real command words. Quoted data of a final-in-pipeline
1151
+ # printf/echo/cat (and a cat heredoc body) is masked; any construct that can run
1152
+ # data as code (eval, shells, $( ), backticks, pipes into them) disables masking.
1153
+ _INSPECT=$(_LOKI_MASK_CMD="$COMMAND" python3 - <<'PY'
1154
+ import os
1155
+ import re
1156
+ import sys
1157
+
1158
+ cmd = os.environ.get("_LOKI_MASK_CMD", "")
1159
+ DATA = {"printf", "echo", "cat"}
1160
+ RISKY_WORD = re.compile(
1161
+ r"(^|[^\w./-])(eval|source|sh|bash|zsh|dash|ksh|xargs|exec|python3?|perl|ruby|node|ssh|find|parallel)(?![\w-])"
1162
+ )
1163
+ DOT_CMD = re.compile(r"(^|[;&|\n(]\s*)\.\s")
1164
+
1165
+
1166
+ def emit(text):
1167
+ sys.stdout.write(text)
1168
+ raise SystemExit(0)
1169
+
1170
+
1171
+ # Any construct that can turn data into code disables masking entirely.
1172
+ # ANSI-C / locale quoting ($' $") hides quote boundaries from this walker.
1173
+ if re.search(r"\x60|\$\(|<\(|>\(|\$['\"]", cmd) or RISKY_WORD.search(cmd) or DOT_CMD.search(cmd):
1174
+ emit(cmd)
1175
+
1176
+ n = len(cmd)
1177
+ out = list(cmd)
1178
+ HEREDOC = re.compile(r"(?<!<)<<(?!<)-?\s*([\"']?)([A-Za-z_]\w*)\1")
1179
+ ASSIGN = re.compile(r"^\s*[A-Za-z_]\w*=\S*\s+")
1180
+
1181
+
1182
+ def is_data(text):
1183
+ while ASSIGN.match(text):
1184
+ text = ASSIGN.sub("", text, count=1)
1185
+ m = re.match(r"\s*(\S+)", text)
1186
+ if not m or m.group(1) not in DATA:
1187
+ return False
1188
+ if m.group(1) == "printf" and re.search(r"(^|\s)-v", text):
1189
+ return False
1190
+ return True
1191
+
1192
+
1193
+ def mask(s, e):
1194
+ for k in range(s, e):
1195
+ if out[k] != "\n":
1196
+ out[k] = "x"
1197
+
1198
+
1199
+ i = 0
1200
+ unit_start = 0
1201
+ spans = []
1202
+ pending = [] # (delimiter, strip_tabs, mask_body)
1203
+ unit_hd = [] # heredocs opened by an unquoted << in the current unit
1204
+
1205
+
1206
+ def end_unit(end, piped):
1207
+ data = is_data(cmd[unit_start:end]) and not piped
1208
+ if data:
1209
+ for s, e in spans:
1210
+ mask(s, e)
1211
+ for delim, strip in unit_hd:
1212
+ pending.append((delim, strip, data))
1213
+
1214
+
1215
+ while i < n:
1216
+ c = cmd[i]
1217
+ if c == "\\":
1218
+ i += 2
1219
+ continue
1220
+ if c == "#" and (i == 0 or cmd[i - 1] in " \t;&|()\n"):
1221
+ j = cmd.find("\n", i)
1222
+ i = n if j < 0 else j
1223
+ continue
1224
+ if c == "<" and cmd.startswith("<<", i):
1225
+ m = HEREDOC.match(cmd, i)
1226
+ if m:
1227
+ unit_hd.append((m.group(2), cmd.startswith("<<-", i)))
1228
+ i = m.end()
1229
+ continue
1230
+ if c == "'":
1231
+ j = cmd.find("'", i + 1)
1232
+ if j < 0:
1233
+ emit(cmd)
1234
+ spans.append((i + 1, j))
1235
+ i = j + 1
1236
+ continue
1237
+ if c == '"':
1238
+ j = i + 1
1239
+ while j < n and cmd[j] != '"':
1240
+ j += 2 if cmd[j] == "\\" else 1
1241
+ if j >= n:
1242
+ emit(cmd)
1243
+ spans.append((i + 1, j))
1244
+ i = j + 1
1245
+ continue
1246
+ if c in ";&|\n":
1247
+ piped = c == "|" and cmd[i + 1:i + 2] != "|"
1248
+ end_unit(i, piped)
1249
+ spans = []
1250
+ unit_hd = []
1251
+ if c == "\n" and pending:
1252
+ pos = i + 1
1253
+ for delim, strip, do_mask in pending:
1254
+ while pos < n:
1255
+ nl = cmd.find("\n", pos)
1256
+ stop = n if nl < 0 else nl
1257
+ line = cmd[pos:stop]
1258
+ if (line.lstrip("\t") if strip else line) == delim:
1259
+ pos = stop
1260
+ break
1261
+ if do_mask:
1262
+ mask(pos, stop)
1263
+ pos = stop + 1
1264
+ pending = []
1265
+ i = pos
1266
+ unit_start = i
1267
+ continue
1268
+ i += 1
1269
+ unit_start = i
1270
+ continue
1271
+ i += 1
1272
+ end_unit(n, False)
1273
+ emit("".join(out))
1274
+ PY
1275
+ ) || _INSPECT="$COMMAND"
1276
+ [ -n "$_INSPECT" ] || _INSPECT="$COMMAND"
1277
+ _flat_for_temp_root=$(printf '%s' "$_INSPECT" | sed -E 's/[0-9]*>&[0-9]+//g' | tr ';&|\r' '\n')
1278
+ while IFS= read -r _seg; do
1279
+ if _segment_rm_hits_temp_root "$_seg"; then
1280
+ deny "Blocked: recursive delete of a temp root or a bare glob under it"
1281
+ fi
1282
+ done <<< "$_flat_for_temp_root"
1283
+
394
1284
  # Check for blocked patterns
395
1285
  for pattern in "${BLOCKED_PATTERNS[@]}"; do
396
1286
  if echo "$COMMAND" | grep -qE "$pattern"; then