@codyswann/lisa 4.32.0 → 4.32.2

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 (73) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +673 -2
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +6 -4
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/dist/opencode/plugin-templates/parity-safety-net.sh +673 -2
  10. package/package.json +4 -4
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/hooks/inject-resolved-config.mjs +224 -17
  14. package/plugins/lisa/hooks/inject-resolved-config.sh +28 -0
  15. package/plugins/lisa/hooks/parity-safety-net.sh +673 -2
  16. package/plugins/lisa-agy/hooks/parity-safety-net.sh +673 -2
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +224 -17
  25. package/plugins/lisa-copilot/hooks/inject-resolved-config.sh +28 -0
  26. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +673 -2
  27. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +224 -17
  29. package/plugins/lisa-cursor/hooks/inject-resolved-config.sh +28 -0
  30. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +673 -2
  31. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-agy/plugin.json +1 -1
  34. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  54. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-agy/plugin.json +1 -1
  59. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  64. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  69. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/src/base/hooks/inject-resolved-config.mjs +224 -17
  72. package/plugins/src/base/hooks/inject-resolved-config.sh +28 -0
  73. package/plugins/src/base/hooks/parity-safety-net.sh +673 -2
@@ -269,8 +269,11 @@ Every commit must also carry a Co-authored-by trailer for a supported agent
269
269
  (Claude/Codex/OpenCode) — the commit-msg hook enforces this."
270
270
  fi
271
271
  block "$1" "Heredoc payloads are blocked here (the payload is executable shell).
272
- Fix: write the payload to a file with the Write tool, then execute that file
273
- directly (for example \`python3 <file>\` or \`bash <file>\`)."
272
+ Fix: write the payload to a file with the Write tool, then run the file
273
+ (\`bash <file>\`). These guards READ the shell script an invocation runs, so a
274
+ destructive command inside that file is refused exactly as the inline form is:
275
+ the file is a way to get the payload out of a heredoc, never a way past the
276
+ guards."
274
277
  }
275
278
 
276
279
  command_for_guards="$command_str"
@@ -301,6 +304,674 @@ case "$command_str" in
301
304
  ;;
302
305
  esac
303
306
 
307
+ # ─── Follow execution into the file a command RUNS (issue #3612) ─────────────
308
+ #
309
+ # Every guard below classifies SHELL COMMAND TEXT, and until this block existed
310
+ # the only text they ever saw was the command string. A destructive line
311
+ # written into a file and then executed reached them as an interpreter name and
312
+ # a path, and was ALLOWED — silently, with no output at all — while the same
313
+ # line typed inline was refused. Nine spellings were measured against 4.31.1 and
314
+ # all nine allowed: `bash <file>`, `sh <file>`, `zsh <file>`, `env bash <file>`,
315
+ # `source <file>`, `. <file>`, a shebang script invoked directly,
316
+ # `bash -c 'bash <file>'`, and `eval "$(cat <file>)"`. Two more found while
317
+ # fixing it — `bash < <file>` and `cat <file> | bash` — allowed as well.
318
+ #
319
+ # Three more found by review before this shipped, all one shape: a WRAPPER'S OWN
320
+ # OPERAND closing the command position before the program is reached, so
321
+ # `nice -n 5 bash <file>`, `sudo -u nobody bash <file>` and `timeout 5 bash
322
+ # <file>` walked past the invocation. See the wrapper-prefix arms below.
323
+ #
324
+ # A guard that fails CLOSED spends time; this one failed OPEN on destructive
325
+ # actions and reported nothing, so an operator who installed it to gate `rm -rf`
326
+ # outside the project got no signal that the check had not run.
327
+ #
328
+ # WHY IT IS THE DEFAULT PATH rather than an edge case: block_heredoc() above
329
+ # refuses heredocs with an instruction to write the payload to a file and
330
+ # execute that file. The tooling therefore routed agents onto exactly the shape
331
+ # the destructive scan could not see. Nobody had to evade anything. That remedy
332
+ # is reworded above, and this block is the half that makes the reworded remedy
333
+ # true.
334
+ #
335
+ # THE RULE: the blind spot is precisely FILE CONTENTS. Every other part of a
336
+ # command — including the inner command of a substitution — is text these guards
337
+ # already read. So resolve the file an invocation actually RUNS, read it under a
338
+ # size cap, and hand it to the guards as further command text.
339
+ #
340
+ # WHAT THIS DELIBERATELY DOES NOT DO — the known-wrong fix, already made once in
341
+ # this family. #3484 was fixed by teaching a sibling guard to read the contents
342
+ # of any file a command NAMES, and that produced #3604: the guard now opens any
343
+ # readable file named by any argument and attributes its contents to the
344
+ # command, including a path quoted as prose inside a fenced code block in a
345
+ # `--body-file` markdown. A path is followed here ONLY when the command EXECUTES
346
+ # it. `grep -n x <file>`, `gh pr create --body-file <file>` and
347
+ # `git commit -F <file>` name a file as DATA — and $DESTRUCTIVE_GUIDANCE above
348
+ # tells operators to use exactly those forms when the destructive text is a
349
+ # deliverable, so reading them would break the one remedy that works.
350
+ #
351
+ # FAIL CLOSED ON WHAT IT CANNOT FOLLOW. At the top level a computed target
352
+ # (`bash "$SCRIPT"`), a target that does not exist or cannot be read, a file
353
+ # past the size cap, and an operand that is not a path at all (`find … -exec
354
+ # bash {} \;`) are each REFUSED with a reason. Silence on an unclassifiable
355
+ # destructive command is the bug being fixed, and a truncated scan would report
356
+ # a confident ALLOW about text it never read.
357
+ #
358
+ # NOT FOLLOWED, deliberately. Each one is text these guards were never able to
359
+ # classify in the first place, so failing closed on it would spend the operator
360
+ # a refusal it cannot buy anything with:
361
+ # - Non-shell interpreters (`python3 <file>`, `node <file>`). These patterns
362
+ # are shell syntax; running them over a Python or JS file buys
363
+ # mis-attribution, not coverage. The heredoc remedy no longer names one.
364
+ # - A payload piped from a program's OUTPUT (`curl … | bash`). There is no
365
+ # file to read, and the producing command's own text is already scanned.
366
+ # - An indirection nested inside an ALREADY-FOLLOWED file whose target is
367
+ # computed — `source "$(dirname "$0")/lib.sh"` is the universal shell idiom,
368
+ # and failing closed on it would refuse most real scripts. Top-level
369
+ # indirection is authored by the agent in the command it just wrote, which
370
+ # is where a refusal is both actionable and rare.
371
+ # Each is a fail-open the header's ACCEPTED-BYPASS reasoning already covers: the
372
+ # net catches ACCIDENTAL catastrophic commands.
373
+ readonly FOLLOW_MAX_BYTES=262144
374
+ readonly FOLLOW_MAX_TOTAL_BYTES=524288
375
+ readonly FOLLOW_MAX_FILES=8
376
+ readonly FOLLOW_MAX_DEPTH=3
377
+
378
+ follow_bases=""
379
+ follow_paths=$'\n'
380
+ follow_names=""
381
+ follow_count=0
382
+ follow_bytes=0
383
+ followed_text=""
384
+
385
+ FOLLOW_GUIDANCE="This command EXECUTES a file. These guards classify shell text, so the script an
386
+ invocation runs is read and scanned as part of the command — a destructive line
387
+ inside the file is refused exactly as the inline form is. This one could not be
388
+ read, so the guard denied rather than passed: a destructive command it cannot
389
+ see is the thing it exists to stop.
390
+
391
+ - Name the script by a LITERAL path that exists and is readable — \`bash
392
+ ./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
393
+ - A file past the inspection cap is refused rather than half-scanned, because a
394
+ truncated scan reports a confident ALLOW about text it never read. Split it,
395
+ or run it manually outside the agent.
396
+ - You genuinely intend to run it as it stands. Ask the user to confirm, then run
397
+ it manually outside the agent."
398
+
399
+ # Deny, naming the file the guard could not classify. Deliberately NOT the
400
+ # ordinary destructive guidance: that guidance is about a pattern the COMMAND
401
+ # matched, and this is a claim that the guard could not look at all.
402
+ follow_refuse() {
403
+ block "cannot classify the file this command executes ($1): $2" "$FOLLOW_GUIDANCE"
404
+ }
405
+
406
+ # Result of the last follow_unwrap / follow_next_operand. These two SET a global
407
+ # rather than printing, and every caller reads it.
408
+ #
409
+ # Printing would be the natural shape, but `$(follow_unwrap …)` is a FORK, and
410
+ # the walk runs it once per token of every followed file. Measured: a script
411
+ # just under the 256 KiB inspection cap holds ~48k tokens and cost 27 SECONDS of
412
+ # wall clock — inside a PreToolUse hook that gates every Bash call. Both
413
+ # functions are pure parameter expansion, so the subshell bought nothing.
414
+ #
415
+ # The concurrent-process ceiling was never the risk: the forks are sequential,
416
+ # so peak process count never rises. The latency was the whole problem.
417
+ follow_unwrapped=""
418
+ follow_operand_index=0
419
+
420
+ # Strip substitution, quote and alias wrappers off one token. A sibling of
421
+ # strip_subst_wrappers() below rather than a call to it: that one is defined
422
+ # after this block runs, and it exists for a different job (normalizing a
423
+ # DELETION TARGET). Kept separate so tightening either cannot silently move the
424
+ # other's verdicts.
425
+ follow_unwrap() {
426
+ local t="$1" prev=""
427
+ while [ "$t" != "$prev" ]; do
428
+ prev="$t"
429
+ case "$t" in
430
+ '$(('*) break ;;
431
+ '\'*) t="${t#\\}" ;;
432
+ '$('*) t="${t#'$('}" ;;
433
+ '<('*) t="${t#'<('}" ;;
434
+ '>('*) t="${t#'>('}" ;;
435
+ '"'*) t="${t#'"'}" ;;
436
+ "'"*) t="${t#\'}" ;;
437
+ '`'*) t="${t#'`'}" ;;
438
+ '('*) t="${t#(}" ;;
439
+ esac
440
+ done
441
+ prev=""
442
+ while [ "$t" != "$prev" ]; do
443
+ prev="$t"
444
+ case "$t" in
445
+ *')') t="${t%)}" ;;
446
+ *'`') t="${t%'`'}" ;;
447
+ *'"') t="${t%'"'}" ;;
448
+ *"'") t="${t%\'}" ;;
449
+ *';') t="${t%;}" ;;
450
+ esac
451
+ done
452
+ follow_unwrapped="$t"
453
+ }
454
+
455
+ # A shell interpreter, bare or path-prefixed. Only shells: see NOT FOLLOWED.
456
+ follow_is_interpreter() {
457
+ case "$1" in
458
+ bash | sh | zsh | ksh | ksh93 | dash | ash \
459
+ | */bash | */sh | */zsh | */ksh | */ksh93 | */dash | */ash) return 0 ;;
460
+ esac
461
+ return 1
462
+ }
463
+
464
+ # Could this token itself be the thing a wrapper prefix goes on to invoke?
465
+ #
466
+ # Used ONLY to yield when stepping over a wrapper's own operand, so that
467
+ # step-over can never swallow the invocation. Deliberately generous, because the
468
+ # cost of the two answers is asymmetric: yielding wrongly leaves the walk where
469
+ # it already was, while consuming wrongly loses the program and the guard falls
470
+ # open — which is the entire defect this block exists to close. Anything
471
+ # path-shaped yields too, so `env -i <file>` still reaches the script rather
472
+ # than eating it as a value `-i` does not take.
473
+ follow_may_invoke() {
474
+ follow_is_interpreter "$1" && return 0
475
+ # shellcheck disable=SC2088
476
+ case "$1" in
477
+ "" | -* | [A-Za-z_]*=*) return 0 ;;
478
+ /* | ./* | ../* | '~/'* | */*) return 0 ;;
479
+ sudo | env | command | exec | nohup | nice | time | builtin | timeout \
480
+ | do | then | else | while | until | if | '!' | '{' \
481
+ | eval | find | xargs | cd | source | . | cat) return 0 ;;
482
+ esac
483
+ return 1
484
+ }
485
+
486
+ # Split the command into statements at UNQUOTED separators, printing one
487
+ # statement per line as "<separator><TAB><statement>". Quote-aware on purpose: a
488
+ # `;` or `|` inside a quoted string is not a statement boundary, and splitting
489
+ # there would invent a command position in the middle of a quoted argument and
490
+ # make prose look like an invocation — the #3604 failure mode, arrived at from
491
+ # the other side. Parens are NOT separators, so `eval "$(cat <file>)"` stays one
492
+ # statement and its command word is still `eval`.
493
+ #
494
+ # awk rather than a bash char loop because this runs on EVERY intercepted Bash
495
+ # command, and it addresses the text by index instead of accumulating it a
496
+ # character at a time so the cost stays linear on a followed file. The text
497
+ # arrives through the ENVIRONMENT: `awk -v` processes backslash escapes in the
498
+ # value it is given and would rewrite the command before it was ever read.
499
+ follow_split() {
500
+ SN_FOLLOW_TEXT="$1" awk '
501
+ BEGIN {
502
+ s = ENVIRON["SN_FOLLOW_TEXT"]; n = length(s);
503
+ q = ""; esc = 0; start = 1; sep = "o";
504
+ for (i = 1; i <= n; i++) {
505
+ c = substr(s, i, 1);
506
+ if (esc == 1) { esc = 0; continue }
507
+ if (c == "\\" && q != "\047") { esc = 1; continue }
508
+ if (q != "") { if (c == q) { q = "" } continue }
509
+ if (c == "\"" || c == "\047") { q = c; continue }
510
+ if (c == ";" || c == "&" || c == "|" || c == "\n") {
511
+ printf "%s\t%s\n", sep, substr(s, start, i - start);
512
+ start = i + 1;
513
+ if (c == "|") { sep = "|" } else { sep = "o" }
514
+ continue
515
+ }
516
+ }
517
+ printf "%s\t%s\n", sep, substr(s, start, n - start + 1);
518
+ }
519
+ '
520
+ }
521
+
522
+ # Record a literal `cd` target as an additional base for resolving a relative
523
+ # executed path, so `cd sub && ./run.sh` resolves the way the shell will.
524
+ follow_add_base() {
525
+ local d="$1"
526
+ case "$d" in
527
+ "" | -* | *'$'* | *'*'* | *'?'*) return 0 ;;
528
+ esac
529
+ [ -d "$d" ] || return 0
530
+ follow_bases="$follow_bases"$'\n'"$d"
531
+ }
532
+
533
+ # Resolve one executed-file operand to a readable regular file, or fail.
534
+ #
535
+ # The variable expansions are LOCATE-ONLY and cannot widen a verdict: a wrong
536
+ # guess simply fails to find a file, and an unresolved target is refused, not
537
+ # passed. They are here because an agent's own scratch path arrives spelled with
538
+ # one of them, and refusing every `bash "$TMPDIR/x.sh"` would be a wall with no
539
+ # door. Nothing else is expanded — any remaining `$`, glob or brace is a target
540
+ # this hook cannot evaluate.
541
+ follow_locate() {
542
+ local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
543
+ tmp="${tmp%/}"
544
+ [ -n "$tmp" ] || tmp="/tmp"
545
+ # The tilde arms below are LITERAL text: this is the unexpanded spelling the
546
+ # hook was handed, and turning it into a path is the whole point of the arm.
547
+ # shellcheck disable=SC2088
548
+ case "$token" in
549
+ '~') token="$home" ;;
550
+ '~/'*) token="$home/${token#'~/'}" ;;
551
+ '$TMPDIR' | '$TMPDIR'/*) token="$tmp${token#'$TMPDIR'}" ;;
552
+ '${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
553
+ '$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
554
+ '${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
555
+ '$PWD' | '$PWD'/*) token="$PWD${token#'$PWD'}" ;;
556
+ '${PWD}' | '${PWD}'/*) token="$PWD${token#'${PWD}'}" ;;
557
+ '$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
558
+ '${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
559
+ esac
560
+ case "$token" in
561
+ "" | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*) return 1 ;;
562
+ /*)
563
+ [ -f "$token" ] && [ -r "$token" ] || return 1
564
+ printf '%s' "$token"
565
+ return 0
566
+ ;;
567
+ esac
568
+ while IFS= read -r base; do
569
+ [ -n "$base" ] || continue
570
+ if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
571
+ printf '%s' "$base/$token"
572
+ return 0
573
+ fi
574
+ done <<<"$PWD"$'\n'"$proj$follow_bases"
575
+ return 1
576
+ }
577
+
578
+ # Read one executed file into the text the guards will scan, then follow what IT
579
+ # executes, to a bounded depth. Full-line comments are dropped: a commented line
580
+ # is never executed, so scanning it can only manufacture a false refusal.
581
+ follow_take() {
582
+ local path="$1" depth="$2" size="" content=""
583
+ case "$follow_paths" in
584
+ *$'\n'"$path"$'\n'*) return 0 ;;
585
+ esac
586
+ follow_paths="$follow_paths$path"$'\n'
587
+ follow_count=$((follow_count + 1))
588
+ if [ "$follow_count" -gt "$FOLLOW_MAX_FILES" ]; then
589
+ follow_refuse "$path" "this command executes more files than the guard will inspect (${FOLLOW_MAX_FILES})"
590
+ fi
591
+ size="$(wc -c <"$path" 2>/dev/null | tr -cd '0-9')" || size=""
592
+ case "$size" in
593
+ "") follow_refuse "$path" "its size could not be determined" ;;
594
+ esac
595
+ if [ "$size" -gt "$FOLLOW_MAX_BYTES" ]; then
596
+ follow_refuse "$path" "it is larger than the ${FOLLOW_MAX_BYTES}-byte inspection cap"
597
+ fi
598
+ follow_bytes=$((follow_bytes + size))
599
+ if [ "$follow_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
600
+ follow_refuse "$path" "the files this command executes total more than the ${FOLLOW_MAX_TOTAL_BYTES}-byte inspection budget"
601
+ fi
602
+ content="$(LC_ALL=C tr -d '\000' <"$path" | sed 's/^[[:space:]]*#.*$//')" \
603
+ || follow_refuse "$path" "it could not be read"
604
+ follow_names="$follow_names $path"
605
+ followed_text="$followed_text"$'\n'"$content"
606
+ if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
607
+ follow_scan "$content" "$((depth + 1))"
608
+ fi
609
+ }
610
+
611
+ # An operand a command EXECUTES. Unresolvable at the top level is a refusal;
612
+ # nested inside an already-followed script it is the documented residual above.
613
+ follow_target() {
614
+ local token="$1" depth="$2" path=""
615
+ [ -n "$token" ] || return 0
616
+ if ! path="$(follow_locate "$token")"; then
617
+ if [ "$depth" -eq 0 ]; then
618
+ follow_refuse "$token" "no readable file could be resolved for it"
619
+ fi
620
+ return 0
621
+ fi
622
+ follow_take "$path" "$depth"
623
+ }
624
+
625
+ # A command word that IS the executable (`./deploy.sh`, `/tmp/x.sh`). Followed
626
+ # only when the file's shebang names a shell: a compiled binary or a Python/Node
627
+ # script is not text these patterns can classify, and `/usr/bin/git` sitting at
628
+ # a command position must stay an ordinary command.
629
+ #
630
+ # This arm alone does NOT fail closed on an unresolvable token, and the line is
631
+ # deliberate. Elsewhere the command TEXT declares that a file is being executed
632
+ # — `bash <file>` says so whether or not the file can be read — so "I cannot
633
+ # read it" is a real inability to classify a stated execution. Here the only
634
+ # evidence that the token is a script at all is the shebang inside it, so an
635
+ # unreadable token is not an unclassifiable execution; it is a command word this
636
+ # hook cannot see, and a shell that cannot see it cannot run it either. Refusing
637
+ # on shape alone would deny `/usr/bin/charm x` and `./scripts/confirm -rf /`,
638
+ # both of which the guard-parity matrix pins as ALLOW (#1960 PR-A2/PR-A3).
639
+ # Accepted residual: a shebang script whose relative path resolves only from a
640
+ # working directory this hook does not share is not followed.
641
+ follow_direct() {
642
+ local token="$1" depth="$2" path="" first=""
643
+ if ! path="$(follow_locate "$token")"; then
644
+ return 0
645
+ fi
646
+ first="$(LC_ALL=C head -c 256 -- "$path" 2>/dev/null | LC_ALL=C head -n 1 2>/dev/null | LC_ALL=C tr -dc '[:print:]' 2>/dev/null)" || first=""
647
+ case "$first" in
648
+ '#!'*[/[:space:]]bash* | '#!'*[/[:space:]]sh* | '#!'*[/[:space:]]zsh* \
649
+ | '#!'*[/[:space:]]ksh* | '#!'*[/[:space:]]dash* | '#!'*[/[:space:]]ash*) ;;
650
+ *) return 0 ;;
651
+ esac
652
+ follow_take "$path" "$depth"
653
+ }
654
+
655
+ # Index of the next non-option token after $1, or $2 when there is none.
656
+ follow_next_operand() {
657
+ local from="$1" upto="$2" j
658
+ j=$((from + 1))
659
+ while [ "$j" -lt "$upto" ]; do
660
+ follow_unwrap "${toks[j]}"
661
+ case "$follow_unwrapped" in
662
+ -*) j=$((j + 1)) ;;
663
+ *) break ;;
664
+ esac
665
+ done
666
+ follow_operand_index="$j"
667
+ }
668
+
669
+ # Walk one text for the files it executes. Runs in the PARENT shell, never in a
670
+ # command substitution: follow_refuse() must be able to deny, and an exit inside
671
+ # a subshell ends that subshell rather than this hook — the mistake documented
672
+ # on the rm segmentation below.
673
+ follow_scan() {
674
+ local text="$1" depth="$2"
675
+ local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
676
+ local heredoc redirected pipe_cat prev_cat pending_operand
677
+ local find_mode dispatch_pending
678
+ local -a toks=()
679
+ split_out="$(follow_split "$text")"
680
+ prev_cat=""
681
+ while IFS= read -r line; do
682
+ sep="${line%%$'\t'*}"
683
+ stmt="${line#*$'\t'}"
684
+ pipe_cat=""
685
+ [ "$sep" != "|" ] || pipe_cat="$prev_cat"
686
+ prev_cat=""
687
+ case "$stmt" in
688
+ *[![:space:]]*) ;;
689
+ *) continue ;;
690
+ esac
691
+ set -f
692
+ # The word split is the point: this is shell tokenization, and the glob
693
+ # guard above keeps a literal `*` from expanding against the hook's cwd.
694
+ # shellcheck disable=SC2206
695
+ toks=($stmt)
696
+ set +f
697
+ n=${#toks[@]}
698
+ [ "$n" -gt 0 ] || continue
699
+ i=0
700
+ cmd_pos=1
701
+ eval_mode=0
702
+ arg_exec=0
703
+ pending_operand=0
704
+ find_mode=0
705
+ dispatch_pending=0
706
+ while [ "$i" -lt "$n" ]; do
707
+ # Only a COMMAND POSITION can execute something. A path anywhere else is
708
+ # an argument, and an argument is data (#3604).
709
+ #
710
+ # The one exception is a dispatcher that BUILDS a command out of its own
711
+ # arguments: `find … -exec bash {} \;` and `xargs bash` put an interpreter
712
+ # at a command position the text does not spell as one. Scoping the
713
+ # exception to a `find`/`xargs` statement is what keeps it from becoming
714
+ # "an interpreter name anywhere is an invocation", which would read
715
+ # `echo "run bash <file>"` as one.
716
+ if [ "$cmd_pos" -ne 1 ]; then
717
+ # A dispatcher BUILDS a command out of its own arguments, putting an
718
+ # interpreter at a command position the text does not spell as one.
719
+ # That position is exactly ONE token: the word after `find -exec` or
720
+ # `-execdir`, and the first operand of `xargs`.
721
+ #
722
+ # Latching "any interpreter NAME until the statement ends" instead was
723
+ # a false positive, because an interpreter name is far more often data
724
+ # than a command: it refused `find . -name bash`, where `bash` is a
725
+ # filename PATTERN, and `find … | xargs grep bash`, where it is a
726
+ # search term. Both are ordinary commands that delete nothing.
727
+ if [ "$dispatch_pending" -ne 1 ]; then
728
+ if [ "$find_mode" -eq 1 ]; then
729
+ follow_unwrap "${toks[i]}"
730
+ case "$follow_unwrapped" in
731
+ -exec | -execdir) dispatch_pending=1 ;;
732
+ esac
733
+ fi
734
+ i=$((i + 1))
735
+ continue
736
+ fi
737
+ follow_unwrap "${toks[i]}"
738
+ t="$follow_unwrapped"
739
+ # `xargs -n1 bash` — the dispatcher's own options precede its command.
740
+ case "$t" in
741
+ -*)
742
+ i=$((i + 1))
743
+ continue
744
+ ;;
745
+ esac
746
+ dispatch_pending=0
747
+ cmd_pos=1
748
+ else
749
+ follow_unwrap "${toks[i]}"
750
+ t="$follow_unwrapped"
751
+ fi
752
+ case "$t" in
753
+ # Prefixes that keep the command position open: assignments, wrappers,
754
+ # and the shell keywords a statement can start with. Without them
755
+ # `if …; then bash <file>` and `env bash <file>` walk past the
756
+ # invocation.
757
+ "" | [A-Za-z_]*=* | --*) i=$((i + 1)); continue ;;
758
+ -*)
759
+ # A SHORT option inside a wrapper prefix can take a separate VALUE,
760
+ # and that value is not a command. `nice -n 5 bash <file>` and
761
+ # `sudo -u nobody bash <file>` put `5` and `nobody` where the walk
762
+ # expected the program, so the invocation was never reached and the
763
+ # file was never scanned — the same fail-open this whole block exists
764
+ # to close, one layer along. Step the value over.
765
+ #
766
+ # But YIELD whenever the next token could itself be the invocation,
767
+ # so `env -i bash <file>` still reads `bash` as the program: `-i`
768
+ # takes no value, and consuming one would lose the interpreter.
769
+ #
770
+ # Long options are deliberately left alone. Their value is spelled
771
+ # with `=`, and a `--foo <path> bash <file>` is rare enough that
772
+ # consuming the operand would cost more than it buys.
773
+ i=$((i + 1))
774
+ if [ "$i" -lt "$n" ]; then
775
+ follow_unwrap "${toks[i]}"
776
+ follow_may_invoke "$follow_unwrapped" || i=$((i + 1))
777
+ fi
778
+ continue
779
+ ;;
780
+ timeout | */timeout)
781
+ # `timeout` takes a mandatory DURATION before the program. A bare
782
+ # number is not an option, so the short-option arm above cannot see
783
+ # it. Mark the operand pending and let the fall-through consume it,
784
+ # which keeps working when options sit in between — `timeout -s KILL
785
+ # 5 bash <file>` reaches `bash` just as `timeout 5 bash <file>` does.
786
+ pending_operand=1
787
+ i=$((i + 1))
788
+ continue
789
+ ;;
790
+ sudo | env | command | exec | nohup | nice | time | builtin \
791
+ | do | then | else | while | until | if | '!' | '{' \
792
+ | */sudo | */env | */nohup | */nice | */time)
793
+ i=$((i + 1))
794
+ continue
795
+ ;;
796
+ eval)
797
+ # `eval` executes its ARGUMENTS as shell, so a file whose contents are
798
+ # interpolated into them is executed text.
799
+ eval_mode=1
800
+ i=$((i + 1))
801
+ continue
802
+ ;;
803
+ find | */find)
804
+ # `find` only builds a command after `-exec`/`-execdir`. Every other
805
+ # operand is a path, a predicate, or a pattern.
806
+ arg_exec=1
807
+ find_mode=1
808
+ cmd_pos=0
809
+ i=$((i + 1))
810
+ continue
811
+ ;;
812
+ xargs | */xargs)
813
+ # `xargs` takes its command as the first non-option operand; anything
814
+ # after that is an argument TO that command, not another command.
815
+ arg_exec=1
816
+ dispatch_pending=1
817
+ cmd_pos=0
818
+ i=$((i + 1))
819
+ continue
820
+ ;;
821
+ cd)
822
+ if [ $((i + 1)) -lt "$n" ]; then
823
+ follow_unwrap "${toks[i + 1]}"
824
+ follow_add_base "$follow_unwrapped"
825
+ fi
826
+ cmd_pos=0
827
+ i=$((i + 1))
828
+ continue
829
+ ;;
830
+ source | .)
831
+ follow_next_operand "$i" "$n"
832
+ j="$follow_operand_index"
833
+ if [ "$j" -lt "$n" ]; then
834
+ follow_unwrap "${toks[j]}"
835
+ follow_target "$follow_unwrapped" "$depth"
836
+ fi
837
+ cmd_pos=0
838
+ i=$((j + 1))
839
+ continue
840
+ ;;
841
+ cat | '<')
842
+ follow_next_operand "$i" "$n"
843
+ j="$follow_operand_index"
844
+ if [ "$j" -lt "$n" ]; then
845
+ follow_unwrap "${toks[j]}"
846
+ if [ "$eval_mode" -eq 1 ]; then
847
+ follow_target "$follow_unwrapped" "$depth"
848
+ else
849
+ # Not executed here — but `cat <file> | bash` is, and the
850
+ # interpreter arm below reads this when the pipe hands it over.
851
+ prev_cat="$follow_unwrapped"
852
+ fi
853
+ fi
854
+ cmd_pos=0
855
+ i=$((j + 1))
856
+ continue
857
+ ;;
858
+ esac
859
+ if follow_is_interpreter "$t"; then
860
+ inline=0
861
+ redirected=""
862
+ heredoc=0
863
+ j=$((i + 1))
864
+ while [ "$j" -lt "$n" ]; do
865
+ follow_unwrap "${toks[j]}"
866
+ tj="$follow_unwrapped"
867
+ case "$tj" in
868
+ --) j=$((j + 1)); break ;;
869
+ # A heredoc or here-string payload is IN the command text, which the
870
+ # guards already read. It is not a file, and treating it as one
871
+ # refused `bash <<EOF` outright.
872
+ '<<'*) heredoc=1; break ;;
873
+ # `bash < <file>` executes the file just as `bash <file>` does.
874
+ '<')
875
+ j=$((j + 1))
876
+ if [ "$j" -lt "$n" ]; then
877
+ follow_unwrap "${toks[j]}"
878
+ redirected="$follow_unwrapped"
879
+ fi
880
+ break
881
+ ;;
882
+ '<'*) redirected="${tj#<}"; break ;;
883
+ # An output redirection names a DESTINATION, never the script. Step
884
+ # over it (and its target when the operator stands alone) so the
885
+ # operand scan does not stop on `>` and read `/dev/null` as a
886
+ # script it cannot resolve.
887
+ *'>'*)
888
+ case "$tj" in
889
+ *'>') j=$((j + 2)) ;;
890
+ *) j=$((j + 1)) ;;
891
+ esac
892
+ ;;
893
+ --*) j=$((j + 1)) ;;
894
+ -*c*) inline=1; j=$((j + 1)) ;;
895
+ -*) j=$((j + 1)) ;;
896
+ *) break ;;
897
+ esac
898
+ done
899
+ if [ "$heredoc" -eq 1 ] && [ "$inline" -eq 0 ]; then
900
+ cmd_pos=0
901
+ i=$((j + 1))
902
+ continue
903
+ fi
904
+ if [ -n "$redirected" ]; then
905
+ follow_target "$redirected" "$depth"
906
+ cmd_pos=0
907
+ i=$((j + 1))
908
+ continue
909
+ fi
910
+ if [ "$inline" -eq 1 ]; then
911
+ # `-c` takes a COMMAND STRING, not a script path. That string is text
912
+ # the guards already read, and it can itself invoke a script, so the
913
+ # walk re-enters it at a fresh command position rather than reading it
914
+ # as a filename — this is what catches `bash -c 'bash <file>'`.
915
+ cmd_pos=1
916
+ i="$j"
917
+ continue
918
+ fi
919
+ if [ "$j" -ge "$n" ]; then
920
+ # A dispatcher supplies the script from ANOTHER command's output, so
921
+ # the operand exists but is unreadable here. That is the fail-closed
922
+ # case, not the interactive one.
923
+ if [ "$arg_exec" -eq 1 ]; then
924
+ follow_refuse "$t" "a dispatcher supplies its script from another command's output"
925
+ fi
926
+ # No operand: an interactive or stdin-fed interpreter. Only the
927
+ # `cat <file> | bash` shape names a FILE, and only that is followed.
928
+ [ -z "$pipe_cat" ] || follow_target "$pipe_cat" "$depth"
929
+ cmd_pos=0
930
+ i="$j"
931
+ continue
932
+ fi
933
+ follow_unwrap "${toks[j]}"
934
+ tj="$follow_unwrapped"
935
+ follow_target "$tj" "$depth"
936
+ cmd_pos=0
937
+ i=$((j + 1))
938
+ continue
939
+ fi
940
+ if [ "$pending_operand" -eq 1 ]; then
941
+ # A wrapper's own mandatory operand (a `timeout` duration), not the
942
+ # program it wraps. Consume it and leave the command position OPEN so
943
+ # the walk carries on to the invocation. Yields on anything that could
944
+ # itself be the program, for the reason follow_may_invoke documents.
945
+ pending_operand=0
946
+ if ! follow_may_invoke "$t"; then
947
+ i=$((i + 1))
948
+ continue
949
+ fi
950
+ fi
951
+ # The tilde arm is LITERAL text, not a path this line expands:
952
+ # follow_locate turns it into one.
953
+ # shellcheck disable=SC2088
954
+ case "$t" in
955
+ /* | ./* | ../* | '~/'* | */*) follow_direct "$t" "$depth" ;;
956
+ esac
957
+ cmd_pos=0
958
+ i=$((i + 1))
959
+ done
960
+ done <<<"$split_out"
961
+ }
962
+
963
+ follow_scan "$command_for_guards" 0
964
+ if [ -n "$follow_names" ]; then
965
+ command_for_guards="$command_for_guards"$'\n'"$followed_text"
966
+ # A refusal must say WHERE the match is. Without this the operator reads
967
+ # advice about rewording a quoted string for a line they never typed.
968
+ DESTRUCTIVE_GUIDANCE="$DESTRUCTIVE_GUIDANCE
969
+
970
+ The match may be inside a file this command EXECUTES rather than in the command
971
+ you typed. These scripts were read and scanned as part of it:$follow_names
972
+ Edit the file, or ask the user to confirm and run it manually outside the agent."
973
+ fi
974
+
304
975
  # Normalize bash line-continuations (a trailing backslash + newline → space)
305
976
  # before segmenting the command. Without this, "git push --force origin
306
977
  # \<newline>main" splits into a segment matching --force but not `main`, letting a