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