loki-mode 11.15.0 → 11.17.5
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.
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/grill.sh +27 -3
- package/autonomy/hooks/precompact-flush.sh +51 -0
- package/autonomy/hooks/validate-bash.sh +941 -13
- package/autonomy/lib/chat.sh +116 -7
- package/autonomy/lib/claude-flags.sh +157 -0
- package/autonomy/lib/claude_permissions.py +60 -0
- package/autonomy/lib/decision_record.py +92 -3
- package/autonomy/lib/done-recognition.sh +8 -4
- package/autonomy/lib/efficiency_cost.py +102 -0
- package/autonomy/lib/engine10-push.sh +30 -5
- package/autonomy/lib/modernize/py_capture.py +62 -3
- package/autonomy/lib/prd-enrich.sh +30 -2
- package/autonomy/lib/proof-generator.py +5 -0
- package/autonomy/lib/proof-verify.py +109 -1
- package/autonomy/lib/trust_trajectory.py +13 -4
- package/autonomy/lib/voter-agents.sh +64 -7
- package/autonomy/lib/wiki-generator.py +58 -10
- package/autonomy/lib/wiki_llm.py +41 -2
- package/autonomy/loki +548 -36
- package/autonomy/quickstart.sh +21 -2
- package/autonomy/run.sh +217 -16
- package/autonomy/trigger-server.py +81 -7
- package/completions/_loki +3 -0
- package/completions/loki.bash +4 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +2 -2
- package/dashboard/audit.py +204 -17
- package/dashboard/build_supervisor.py +2 -0
- package/docs/CLI-REFERENCE.md +5 -1
- package/docs/EVALUATING.md +3 -3
- package/docs/INSTALLATION.md +1 -1
- package/docs/environment-variables.md +1 -0
- package/docs/trackers.md +6 -0
- package/docs/v10/BOARD.md +27 -1
- package/docs/v10/CLI-MODERN.md +10 -7
- package/docs/v10/DECISIONS.md +16 -0
- package/docs/v10/ENGINE.md +2 -1
- package/docs/v10/FAILURE-CLASSES.md +77 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/GUIDE-MODERNIZE.md +9 -4
- package/docs/v10/PLAN-V12.md +896 -0
- package/docs/v10/PROGRESS.md +11 -0
- package/docs/v11/COST-HALF.md +3 -1
- package/docs/visual-evidence.md +2 -0
- package/loki-ts/data/finding-schema.json +2 -1
- package/loki-ts/dist/loki.js +1180 -1112
- package/magic/core/debate.py +27 -1
- package/mcp/__init__.py +1 -1
- package/memory/retrieval.py +15 -3
- package/memory/schemas.py +43 -1
- package/memory/storage.py +11 -0
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +4154 -3551
- package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +7 -3
- package/providers/loader.sh +26 -0
- package/providers/model_catalog.json +6 -0
- package/providers/model_routes.sh +31 -0
- package/src/policies/claude_permissions.js +132 -0
- package/web-app/dist/assets/{AdminPage-bZlvH4TL.js → AdminPage-C0v2p3jN.js} +1 -1
- package/web-app/dist/assets/{Avatar-BZrBstQe.js → Avatar-Cgc0QepK.js} +1 -1
- package/web-app/dist/assets/{Badge-Q8l8r4Vq.js → Badge-1X4wQ0sU.js} +1 -1
- package/web-app/dist/assets/{Button-B8Bt0A3S.js → Button-CG79n6-I.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-CIYExrXa.js → CockpitPage-rSw2BTKk.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CBOmHdam.js → ComparePage-B9EVp__S.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BKwDPSnK.js → ErrorBoundary-DLQKY8LF.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-KqzjphWl.js → EvidenceReceiptPanel-DE0L1L2Q.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-lF3GkHRv.js → GitHubIssuesPanel-DlKtYrtk.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BCzipPWD.js → GitHubPRsPanel-C5c5tY3x.js} +1 -1
- package/web-app/dist/assets/{HomePage-CIdnkzaN.js → HomePage-ob1natMp.js} +2 -2
- package/web-app/dist/assets/{LoginPage-D-178nTT.js → LoginPage-CCQBgvzB.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BmHuzMT8.js → MagicPage-CKSuC0Pp.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CPCiyDFn.js → MetricsPage-nq_ygAuG.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-B2T6n1gx.js → NotFoundPage-B6gVLTAn.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-DQmpFhb0.js → ProjectPage-pUnvGriO.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BmugzFEd.js → ProjectsPage-DMFPGLkE.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-ZB-RrmgM.js → SettingsPage-DAB_cp87.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-BAhwHt4_.js → ShowcasePage-DTsx_XxI.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Bt7sf2Vu.js → SystemSettingsPage-C2UUqWhW.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B-v5j_8q.js → TeamsPage-C4Sws9U7.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-bdqUL-LG.js → TemplatesPage-B1-79O4n.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CRHEXBwS.js → TerminalOutput-CDSVt5TB.js} +1 -1
- package/web-app/dist/assets/{activity-C-ROkmXt.js → activity-CBEULbio.js} +1 -1
- package/web-app/dist/assets/{bell-DbiCBxQA.js → bell-Dp-j77Qo.js} +1 -1
- package/web-app/dist/assets/{bot-DsIgE_qr.js → bot-Dh1FlTEJ.js} +1 -1
- package/web-app/dist/assets/{check-CkAdmHWP.js → check-C_7N7W4q.js} +1 -1
- package/web-app/dist/assets/{chevron-left-CYTLivAo.js → chevron-left-DtZJiu3T.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BVIcAsFJ.js → circle-alert-CJK4sNof.js} +1 -1
- package/web-app/dist/assets/{clock-Dg3Rq0go.js → clock--0bfAUkP.js} +1 -1
- package/web-app/dist/assets/{cloud-CenrqHFl.js → cloud-CRa9nwiN.js} +1 -1
- package/web-app/dist/assets/{code-xml-C2mvfoug.js → code-xml-4gRrVGxx.js} +1 -1
- package/web-app/dist/assets/{database-BKa0_eCb.js → database-C-A2USGl.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DfelUK1S.js → dollar-sign-BshJt4ML.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CZKWoHVg.js → file-code-corner-Bd--i_IM.js} +1 -1
- package/web-app/dist/assets/{file-plus-CZ-m1zMX.js → file-plus-YiNg-aWn.js} +1 -1
- package/web-app/dist/assets/{globe-S0kv_GwC.js → globe-CfyHbAbI.js} +1 -1
- package/web-app/dist/assets/{hammer-BW1MDdu1.js → hammer-BlGC-yqz.js} +1 -1
- package/web-app/dist/assets/{index-ACEYsTc2.js → index-BlCg8mla.js} +3 -3
- package/web-app/dist/assets/{layers-BrXHYZ-L.js → layers-DLiA0AcW.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DOdhKFx0.js → loader-circle-CkOfS_Q6.js} +1 -1
- package/web-app/dist/assets/{lock-BcFTyg8J.js → lock-C5vRE8HM.js} +1 -1
- package/web-app/dist/assets/{package-D34cMVQG.js → package-BPzp43h4.js} +1 -1
- package/web-app/dist/assets/{plus-CEaMeBxL.js → plus-C4x6sD5J.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-D2Rzxr5U.js → refresh-cw-DZn-gB9J.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-C_-EjRLh.js → rotate-ccw-rH63YhnY.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BSSCMI_c.js → scroll-text-DTvZMQuh.js} +1 -1
- package/web-app/dist/assets/{server-BKvLonWt.js → server-DmwCdhKT.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DbbQbPdW.js → shield-alert-Dn5Rtkhx.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-NoBD1fUR.js → thumbs-up-D_lHdH-1.js} +1 -1
- package/web-app/dist/assets/{trash-2-BXz8rAyd.js → trash-2-CVfeP0Fb.js} +1 -1
- package/web-app/dist/assets/{trending-down-D8u535Re.js → trending-down-ouPrjBEm.js} +1 -1
- package/web-app/dist/assets/{trending-up-q8k3kgiy.js → trending-up-7H96_KlR.js} +1 -1
- package/web-app/dist/assets/{upload-Cp7_zeBI.js → upload-kTTgyfD6.js} +1 -1
- package/web-app/dist/assets/{usePolling-CTHVdcuX.js → usePolling-BFIMPvNM.js} +1 -1
- package/web-app/dist/assets/{user-DF7bfSf_.js → user-UDo6EQfn.js} +1 -1
- package/web-app/dist/index.html +1 -1
|
@@ -28,8 +28,27 @@ case "${LOKI_HOST_GUARD:-0}" in
|
|
|
28
28
|
1|true|yes|on) HOST_GUARD=true ;;
|
|
29
29
|
esac
|
|
30
30
|
|
|
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
|
|
33
|
+
# and permission mode decide. An explicit hook "allow" skips the permission
|
|
34
|
+
# prompt for every command that passes the blocklist, which would widen the
|
|
35
|
+
# compiled policy, so the hook never emits one and fails closed on bad input.
|
|
36
|
+
COMPILED_POLICY=false
|
|
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
|
|
48
|
+
|
|
31
49
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
32
50
|
"$HOST_GUARD" && deny "LOKI_HOST_GUARD: python3 is unavailable, so the Bash command cannot be inspected safely."
|
|
51
|
+
"$COMPILED_POLICY" && deny "LOKI_PERMISSION_POLICY=compiled: python3 is unavailable, so the Bash command cannot be inspected safely."
|
|
33
52
|
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
34
53
|
exit 0
|
|
35
54
|
fi
|
|
@@ -43,9 +62,14 @@ except Exception:
|
|
|
43
62
|
raise SystemExit(2)
|
|
44
63
|
if not isinstance(command, str) or not command.strip():
|
|
45
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)
|
|
46
69
|
sys.stdout.write(command)
|
|
47
70
|
'); then
|
|
48
71
|
"$HOST_GUARD" && deny "LOKI_HOST_GUARD: malformed or missing Bash command input was denied."
|
|
72
|
+
"$COMPILED_POLICY" && deny "LOKI_PERMISSION_POLICY=compiled: malformed or missing Bash command input was denied."
|
|
49
73
|
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
50
74
|
exit 0
|
|
51
75
|
fi
|
|
@@ -342,24 +366,925 @@ BLOCKED_PATTERNS=(
|
|
|
342
366
|
":\(\) *\{.*\|.*&"
|
|
343
367
|
)
|
|
344
368
|
|
|
345
|
-
# Safe path patterns that override blocked pattern matches
|
|
369
|
+
# Safe path patterns that override blocked pattern matches. They are matched
|
|
370
|
+
# per SEGMENT (the command split on ; & | newline CR), never against the whole
|
|
371
|
+
# string: an unanchored or per-line match let `rm -rf /tmp/x; rm -rf /` and
|
|
372
|
+
# `rm -rf /tmp/x<newline>rm -rf /` ride the override. Only the segment that hit
|
|
373
|
+
# a blocked pattern must be safe; an optional trailing /dev/null redirection is
|
|
374
|
+
# allowed, and `*` and `$$` in /tmp names. A `..` anywhere in the segment is
|
|
375
|
+
# never safe.
|
|
376
|
+
_SAFE_REDIR='([[:space:]]+[0-9]*>>?[[:space:]]*/dev/null)*[[:space:]]*'
|
|
377
|
+
_SAFE_ENV='([ABCDEFGHIJKLMNOPQRSTUVWXYZ_][ABCDEFGHIJKLMNOPQRSTUVWXYZ0-9_]*=[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0-9._/:-]*[[:space:]]+)*'
|
|
378
|
+
# A dot-leading path component with a glob char (`/tmp/.*` expands to `/tmp/..`).
|
|
379
|
+
_DOT_GLOB='/\.[^/[:space:]]*[*?[]'
|
|
346
380
|
SAFE_PATTERNS=(
|
|
347
|
-
"rm -rf /tmp/"
|
|
348
|
-
"rm -rf \.loki/queue/dead-letter"
|
|
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"'$'
|
|
349
383
|
)
|
|
350
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
|
+
|
|
425
|
+
_segment_is_safe() {
|
|
426
|
+
local seg="$1" safe
|
|
427
|
+
[[ "$seg" == *".."* ]] && return 1
|
|
428
|
+
_env_prefix_is_risky "$seg" && return 1
|
|
429
|
+
[[ "$seg" =~ $_DOT_GLOB ]] && return 1
|
|
430
|
+
for safe in "${SAFE_PATTERNS[@]}"; do
|
|
431
|
+
[[ "$seg" =~ $safe ]] && return 0
|
|
432
|
+
done
|
|
433
|
+
return 1
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# Succeeds when every segment hitting $1 is safe and at least one segment hits
|
|
437
|
+
# (a pattern that only matches across segments, like `curl x | sh`, is unsafe).
|
|
438
|
+
_blocked_hits_all_safe() {
|
|
439
|
+
local pat="$1" flat seg hit=false
|
|
440
|
+
# Strip fd duplication (2>&1) first: its `&` is not a command separator, and removing it hides no command.
|
|
441
|
+
flat=$(printf '%s' "$COMMAND" | sed -E 's/[0-9]*>&[0-9]+//g' | tr ';&|\r' '\n')
|
|
442
|
+
_all_segments_allowlisted "$flat" || return 1
|
|
443
|
+
while IFS= read -r seg; do
|
|
444
|
+
printf '%s' "$seg" | grep -qE "$pat" || continue
|
|
445
|
+
hit=true
|
|
446
|
+
_segment_is_safe "$seg" || return 1
|
|
447
|
+
done <<< "$flat"
|
|
448
|
+
"$hit"
|
|
449
|
+
}
|
|
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
|
+
|
|
351
1284
|
# Check for blocked patterns
|
|
352
1285
|
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
|
353
1286
|
if echo "$COMMAND" | grep -qE "$pattern"; then
|
|
354
|
-
|
|
355
|
-
is_safe=false
|
|
356
|
-
for safe in "${SAFE_PATTERNS[@]}"; do
|
|
357
|
-
if echo "$COMMAND" | grep -qE "$safe"; then
|
|
358
|
-
is_safe=true
|
|
359
|
-
break
|
|
360
|
-
fi
|
|
361
|
-
done
|
|
362
|
-
"$is_safe" && continue
|
|
1287
|
+
_blocked_hits_all_safe "$pattern" && continue
|
|
363
1288
|
deny "Blocked: potentially dangerous command pattern detected"
|
|
364
1289
|
fi
|
|
365
1290
|
done
|
|
@@ -370,7 +1295,10 @@ mkdir -p "$LOG_DIR"
|
|
|
370
1295
|
printf '%s' "{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"command\":$(echo "$COMMAND" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')}" >> "$LOG_DIR/bash-audit.jsonl"
|
|
371
1296
|
echo >> "$LOG_DIR/bash-audit.jsonl"
|
|
372
1297
|
|
|
373
|
-
# Allow command
|
|
1298
|
+
# Allow command. Compiled policy: no decision, the permission flow decides.
|
|
1299
|
+
if "$COMPILED_POLICY"; then
|
|
1300
|
+
exit 0
|
|
1301
|
+
fi
|
|
374
1302
|
printf '%s' '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}'
|
|
375
1303
|
|
|
376
1304
|
exit 0
|