@codyswann/lisa 4.71.13 → 4.72.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +769 -37
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +4 -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/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +13 -3
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/parity-safety-net.sh +769 -37
  11. package/package.json +4 -4
  12. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa/hooks/parity-safety-net.sh +769 -37
  15. package/plugins/lisa-agy/hooks/parity-safety-net.sh +769 -37
  16. package/plugins/lisa-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +769 -37
  24. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +769 -37
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/src/base/hooks/parity-safety-net.sh +769 -37
  67. package/scripts/two-channel-couplings.json +4 -4
@@ -515,6 +515,8 @@ readonly FOLLOW_MAX_BYTES=262144
515
515
  readonly FOLLOW_MAX_TOTAL_BYTES=524288
516
516
  readonly FOLLOW_MAX_FILES=8
517
517
  readonly FOLLOW_MAX_DEPTH=3
518
+ readonly FOLLOW_MAX_FUNCTION_DEPTH=16
519
+ readonly FOLLOW_MAX_FUNCTION_CALLS=128
518
520
 
519
521
  # Effective working directory established by a literal `cd` in the command, and
520
522
  # the flag that says a `cd` happened whose target could NOT be read literally.
@@ -526,6 +528,8 @@ follow_paths=$'\n'
526
528
  follow_names=""
527
529
  follow_count=0
528
530
  follow_bytes=0
531
+ follow_function_calls=0
532
+ follow_function_bytes=0
529
533
  followed_text=""
530
534
  followed_rm_text=""
531
535
  # Generated from the canonical reviewed supervisor, never from a host manifest.
@@ -727,8 +731,9 @@ follow_may_invoke() {
727
731
  # `;` or `|` inside a quoted string is not a statement boundary, and splitting
728
732
  # there would invent a command position in the middle of a quoted argument and
729
733
  # make prose look like an invocation — the #3604 failure mode, arrived at from
730
- # the other side. Parens are NOT separators, so `eval "$(cat <file>)"` stays one
731
- # statement and its command word is still `eval`.
734
+ # the other side. Literal subshell parentheses carry scope events, while quoted
735
+ # parentheses and substitutions stay in their original statement so `eval
736
+ # "$(cat <file>)"` retains its command position and executed-file inspection.
732
737
  #
733
738
  # awk rather than a bash char loop because this runs on EVERY intercepted Bash
734
739
  # command, and it addresses the text by index instead of accumulating it a
@@ -736,24 +741,229 @@ follow_may_invoke() {
736
741
  # arrives through the ENVIRONMENT: `awk -v` processes backslash escapes in the
737
742
  # value it is given and would rewrite the command before it was ever read.
738
743
  follow_split() {
739
- SN_FOLLOW_TEXT="$1" awk '
744
+ LC_ALL=C SN_FOLLOW_TEXT="$1" awk '
745
+ # Lexical lookahead keeps an asynchronous return from ending its parent
746
+ # scan before the trailing & event. Aggregate work is capped linearly in
747
+ # input size, including overlap from nested scopes; no partial parse passes.
748
+ function lookahead_step() {
749
+ if (++lookahead_work > 8 * n + 1024) { exit 2 }
750
+ }
751
+ function brace_pipe(from, opening, closing, j, c, quote, escape, braces, nesting, tail, tail_cases, tail_header, word, word_end) {
752
+ quote = ""; escape = 0; braces = 1; nesting = 0; brace_redirect = ""; tail_cases = 0; tail_header = 0;
753
+ for (j = from; j <= n; j++) {
754
+ lookahead_step(); c = substr(s, j, 1);
755
+ if (escape) { escape = 0; continue }
756
+ if (c == "\\" && quote != "\047") { escape = 1; continue }
757
+ if (quote != "") { if (c == quote) { quote = "" } continue }
758
+ if (c == "\"" || c == "\047" || c == "`") { quote = c; continue }
759
+ if (braces > 0) {
760
+ # A case-pattern ) is not the closing parenthesis of its shell.
761
+ if (opening == "(" && c ~ /[A-Za-z_]/) {
762
+ word_end = j + 1;
763
+ while (word_end <= n && substr(s, word_end, 1) ~ /[A-Za-z_0-9-]/) { lookahead_step(); word_end++ }
764
+ word = substr(s, j, word_end - j); j = word_end - 1;
765
+ if (word == "case" && (tail_cases == 0 || !tail_patterns[tail_cases])) { tail_header++ }
766
+ if (word == "in" && tail_header > 0) {
767
+ tail_header--; tail_patterns[++tail_cases] = 1; tail_levels[tail_cases] = braces;
768
+ }
769
+ if (word == "esac" && tail_cases > 0) { tail_cases-- }
770
+ continue;
771
+ }
772
+ if (tail_cases > 0 && tail_levels[tail_cases] == braces) {
773
+ if (c == ")" && tail_patterns[tail_cases]) { tail_patterns[tail_cases] = 0; continue }
774
+ if (c == ";" && substr(s, j + 1, 1) == ";") { tail_patterns[tail_cases] = 1; j++; continue }
775
+ }
776
+ if (c == opening) { braces++ }
777
+ if (c == closing && --braces == 0) { tail = j + 1 }
778
+ continue;
779
+ }
780
+ # Redirections belong to the compound command too. Quoted operator
781
+ # bytes and nested substitutions are operands, not the pipeline edge.
782
+ if (c == "(") { nesting++; continue }
783
+ if (c == ")") { if (nesting == 0) { brace_redirect = substr(s, tail, j - tail); return 0 } nesting--; continue }
784
+ if (nesting > 0) { continue }
785
+ if (c == "|") { brace_redirect = substr(s, tail, j - tail); return substr(s, j + 1, 1) != "|" }
786
+ if (c == ";" || c == "\n") { brace_redirect = substr(s, tail, j - tail); return 0 }
787
+ if (c == "&" && substr(s, j - 1, 1) !~ /[<>]/ && substr(s, j + 1, 1) != ">") { brace_redirect = substr(s, tail, j - tail); return 0 }
788
+ }
789
+ if (tail > 0) { brace_redirect = substr(s, tail) }
790
+ return 0;
791
+ }
792
+ function list_async(from, j, c, quote, escape, nesting, braces) {
793
+ quote = ""; escape = 0; nesting = 0; braces = 0;
794
+ for (j = from; j <= n; j++) {
795
+ lookahead_step();
796
+ c = substr(s, j, 1);
797
+ if (escape) { escape = 0; continue }
798
+ if (c == "\\" && quote != "\047") { escape = 1; continue }
799
+ if (quote != "") { if (c == quote) { quote = "" } continue }
800
+ if (c == "\"" || c == "\047" || c == "`") { quote = c; continue }
801
+ if (c == "(") { nesting++; continue }
802
+ if (c == ")") { if (nesting == 0) { return 0 } nesting--; continue }
803
+ if (c == "{") { braces++; continue }
804
+ if (c == "}") { if (braces > 0) { braces-- } continue }
805
+ if (nesting > 0 || braces > 0) { continue }
806
+ if (c == ";" || c == "\n") { return 0 }
807
+ if (c == "&" && substr(s, j - 1, 1) !~ /[&<>]/ && substr(s, j + 1, 1) !~ /[&>]/) { return 1 }
808
+ }
809
+ return 0;
810
+ }
740
811
  BEGIN {
741
812
  s = ENVIRON["SN_FOLLOW_TEXT"]; n = length(s);
742
- q = ""; esc = 0; start = 1; sep = "o";
813
+ q = ""; esc = 0; start = 1; sep = "o"; parens = 0;
814
+ functions = 0; cases = 0; backtick = 0; brace_depth = 0; post_brace = 0;
815
+ printf "L\t%d\n", list_async(1);
743
816
  for (i = 1; i <= n; i++) {
744
817
  c = substr(s, i, 1);
745
- if (esc == 1) { esc = 0; continue }
818
+ if (esc == 1) { if (c == "$") { literal_dollars[i] = 1 } esc = 0; continue }
746
819
  if (c == "\\" && q != "\047") { esc = 1; continue }
747
- if (q != "") { if (c == q) { q = "" } continue }
820
+ if (functions > 0) {
821
+ if (q != "") { if (c == q) { q = "" } continue }
822
+ if (c == "\"" || c == "\047") { q = c; continue }
823
+ if (c == "{") { functions++ }
824
+ if (c == "}" && --functions == 0) {
825
+ printf "N\t%s\t%s\n", sep, function_name;
826
+ body = substr(s, function_start, i - function_start);
827
+ count = split(body, body_lines, "\n");
828
+ for (j = 1; j <= count; j++) { printf "D\t%s\n", body_lines[j] }
829
+ printf "F\t\n"; start = i + 1; sep = "o";
830
+ }
831
+ continue;
832
+ }
833
+ quoted_expression = q == "\"" && ((c == "(" && substr(s, i - 1, 1) == "$" && !literal_dollars[i - 1]) || c == "`");
834
+ if (q != "" && !quoted_expression) { if (c == q) { q = "" } continue }
748
835
  if (c == "\"" || c == "\047") { q = c; continue }
836
+ if (c == "`") {
837
+ if (backtick) {
838
+ piece = substr(s, start, i - start);
839
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
840
+ printf "]\t\n"; start = backtick_start; q = backtick_quote; backtick = 0;
841
+ } else {
842
+ backtick_start = start; backtick_quote = q; q = ""; backtick = 1;
843
+ printf "[\t\nL\t%d\n", list_async(i + 1); start = i + 1;
844
+ }
845
+ continue;
846
+ }
847
+ if (i == start && c ~ /[[:space:]]/) { start = i + 1; continue }
848
+ if (c ~ /[[:space:]]/ && substr(s, i - 1, 1) !~ /[[:space:]]/ && substr(s, start, 4) == "case") {
849
+ piece = substr(s, start, i - start);
850
+ if (piece ~ /^case[[:space:]].*[[:space:]]in$/) {
851
+ case_scopes[++cases] = parens;
852
+ case_patterns[cases] = 1; printf "C\t\n"; start = i + 1; continue;
853
+ }
854
+ }
855
+ if (cases > 0 && c ~ /[[:space:];&|)]/ && substr(s, start, 4) == "esac") {
856
+ piece = substr(s, start, i - start);
857
+ if (piece ~ /^esac[[:space:]]*$/) { cases--; printf "K\t\n" }
858
+ }
859
+ if (cases > 0 && case_scopes[cases] == parens && case_patterns[cases]) {
860
+ if (c == ")") { case_patterns[cases] = 0; start = i + 1 }
861
+ continue;
862
+ }
863
+ if (c == "{") {
864
+ piece = substr(s, start, i - start);
865
+ definition = piece ~ /^[[:space:]]*([A-Za-z_][A-Za-z_0-9]*[[:space:]]*\([[:space:]]*\)|function[[:space:]]+[A-Za-z_][A-Za-z_0-9]*([[:space:]]*\([[:space:]]*\))?)[[:space:]]*$/;
866
+ if (definition) {
867
+ function_name = piece;
868
+ sub(/^[[:space:]]*(function[[:space:]]+)?/, "", function_name);
869
+ sub(/[[:space:]\(].*$/, "", function_name);
870
+ functions = 1; function_start = i + 1;
871
+ continue;
872
+ }
873
+ previous = i > 1 ? substr(s, i - 1, 1) : "";
874
+ group = (i == 1 || previous ~ /[[:space:];&|()]/) && substr(s, i + 1, 1) ~ /[[:space:];]/ &&
875
+ piece ~ /^[[:space:]]*((then|do|else|!|time)[[:space:]]+)*$/;
876
+ brace_groups[++brace_depth] = group;
877
+ if (group) {
878
+ brace_sep = sep;
879
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
880
+ # A pipeline owns the whole compound command, not just the final
881
+ # statement before |. Snapshot before any brace-body cd/return.
882
+ outgoing = brace_pipe(i + 1, "{", "}");
883
+ brace_pipes[brace_depth] = brace_sep == "|" || outgoing;
884
+ if (brace_pipes[brace_depth]) { printf "(\t%s%s\nL\t%d\n", (brace_sep == "|" ? "|" : ""), (outgoing ? "+" : ""), list_async(i + 1) }
885
+ printf "G\t%s\nL\t%d\n", brace_sep, list_async(i + 1);
886
+ if (brace_redirect ~ /[^[:space:]]/) { printf "I\t%s\n", brace_redirect }
887
+ # P retains incoming stdin provenance without wrapping each body
888
+ # statement in another child: all statements share the brace cwd.
889
+ if (brace_sep == "|") { sep = "P" }
890
+ start = i + 1; continue;
891
+ }
892
+ }
893
+ if (c == "}" && brace_depth > 0) {
894
+ group = brace_groups[brace_depth--];
895
+ if (group) {
896
+ piece = substr(s, start, i - start);
897
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
898
+ printf "H\t\n";
899
+ if (brace_pipes[brace_depth + 1]) { printf ")\t\n" }
900
+ start = i + 1; post_brace = 1; continue;
901
+ }
902
+ }
903
+ if (c == "(") {
904
+ previous = i > 1 ? substr(s, i - 1, 1) : "";
905
+ piece = substr(s, start, i - start);
906
+ header = piece ~ /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z_0-9]*[[:space:]]*$/ && substr(s, i + 1) ~ /^[[:space:]]*\)/;
907
+ expression = previous ~ /[$<>]/ && !literal_dollars[i - 1] && substr(s, i + 1, 1) != "(";
908
+ group = (parens == 0 || scopes[parens] != 0) &&
909
+ (i == 1 || previous ~ /[[:space:];&|()]/) && substr(s, i + 1, 1) != "(" && !header;
910
+ scopes[++parens] = expression ? 2 : group;
911
+ if (expression) {
912
+ expression_starts[parens] = start;
913
+ expression_quotes[parens] = q; q = "";
914
+ printf "[\t\nL\t%d\n", list_async(i + 1); start = i + 1; continue;
915
+ }
916
+ if (group) {
917
+ piece = substr(s, start, i - start);
918
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
919
+ outgoing = brace_pipe(i + 1, "(", ")");
920
+ printf "(\t%s%s\nL\t%d\n", sep, (outgoing ? "+" : ""), list_async(i + 1);
921
+ if (brace_redirect ~ /[^[:space:]]/) { printf "I\t%s\n", brace_redirect }
922
+ if (sep == "|") { sep = "P" }
923
+ start = i + 1; continue;
924
+ }
925
+ }
926
+ if (c == ")" && parens > 0) {
927
+ piece = substr(s, start, i - start);
928
+ group = scopes[parens--];
929
+ if (group == 2) {
930
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
931
+ printf "]\t\n"; start = expression_starts[parens + 1]; q = expression_quotes[parens + 1]; continue;
932
+ }
933
+ if (group) {
934
+ piece = substr(s, start, i - start);
935
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
936
+ printf ")\t\n"; start = i + 1; post_brace = 1; continue;
937
+ }
938
+ }
749
939
  if (c == ";" || c == "&" || c == "|" || c == "\n") {
750
- printf "%s\t%s\n", sep, substr(s, start, i - start);
940
+ if (parens > 0 && scopes[parens] == 0) { continue }
941
+ if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { continue }
942
+ piece = substr(s, start, i - start);
943
+ pipe = c == "|" && substr(s, i + 1, 1) != "|";
944
+ if (pipe || sep == "|") { printf "(\t%s%s\nL\t%d\n", (sep == "|" ? "|" : ""), (pipe ? "+" : ""), list_async(start) }
945
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", post_brace ? "R" : sep, piece }
946
+ post_brace = 0;
947
+ if (pipe || sep == "|") { printf ")\t\n" }
948
+ if (c == ";" && substr(s, i + 1, 1) == ";" && cases > 0) {
949
+ case_patterns[cases] = 1; printf "A\t\n"; i++;
950
+ }
951
+ if (c == "|" && substr(s, i + 1, 1) == "|") {
952
+ i++; sep = "?";
953
+ } else if (c == "&" && substr(s, i + 1, 1) == "&") {
954
+ i++; sep = "&";
955
+ } else if (c == "|") { sep = "|" } else { sep = "o" }
956
+ if (c == "&" && sep == "o" && substr(s, i - 1, 1) !~ /[&<>]/ && substr(s, i + 1, 1) != ">") {
957
+ printf "B\t\n";
958
+ }
959
+ if ((c == ";" || c == "\n" || c == "&") && sep == "o") { printf "L\t%d\n", list_async(i + 1) }
751
960
  start = i + 1;
752
- if (c == "|") { sep = "|" } else { sep = "o" }
753
961
  continue
754
962
  }
755
963
  }
964
+ if (sep == "|") { printf "(\t|\nL\t%d\n", list_async(start) }
756
965
  printf "%s\t%s\n", sep, substr(s, start, n - start + 1);
966
+ if (sep == "|") { printf ")\t\n" }
757
967
  }
758
968
  '
759
969
  }
@@ -818,7 +1028,12 @@ follow_cd() {
818
1028
  esac
819
1029
  case "$d" in
820
1030
  /*) ;;
821
- *) d="${follow_cwd:-$PWD}/$d" ;;
1031
+ *)
1032
+ # A relative cd cannot recover an unknown base. Falling back to this
1033
+ # hook's PWD would confidently inspect another directory (#4299).
1034
+ [ "$follow_cwd_unknown" -eq 0 ] || return 0
1035
+ d="${follow_cwd:-$PWD}/$d"
1036
+ ;;
822
1037
  esac
823
1038
  follow_normalize "$d"
824
1039
  d="$follow_normalized"
@@ -902,8 +1117,14 @@ follow_locate() {
902
1117
  # is never executed, so scanning it can only manufacture a false refusal.
903
1118
  follow_take() {
904
1119
  local path="$1" depth="$2" size="" content="" rm_content="${3-}"
1120
+ local invocation_kind=child
1121
+ if [ "$#" -eq 3 ]; then invocation_kind="$3"; fi
905
1122
  case "$follow_paths" in
906
- *$'\n'"$path"$'\n'*) return 0 ;;
1123
+ *$'\n'"$path"$'\n'*)
1124
+ # Sourcing executes in the caller each time; a read cache cannot erase
1125
+ # its directory changes or newly defined functions.
1126
+ [ "$invocation_kind" = source ] || return 0
1127
+ ;;
907
1128
  esac
908
1129
  follow_paths="$follow_paths$path"$'\n'
909
1130
  follow_count=$((follow_count + 1))
@@ -931,22 +1152,31 @@ follow_take() {
931
1152
  fi
932
1153
  follow_names="$follow_names $path"
933
1154
  followed_text="$followed_text"$'\n'"$content"
934
- if [ "$#" -lt 3 ]; then
1155
+ if [ "$#" -ne 4 ]; then
935
1156
  rm_content="$content"
936
1157
  else
937
1158
  rm_content="$(printf '%s' "$rm_content" | sed 's/^[[:space:]]*#.*$//')" || follow_refuse "$path" "supervisor cleanup view could not be read"
938
1159
  fi
939
1160
  followed_rm_text="$followed_rm_text"$'\n'"$rm_content"
940
1161
  if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
941
- # A `cd` inside the followed script governs what THAT script goes on to
942
- # execute, and nothing after it in the command that ran the script — the
943
- # child inherits this directory but cannot change the parent's. Saving and
944
- # restoring is what keeps a nested `cd` from silently re-pointing the rest
945
- # of the outer walk.
1162
+ # Child scripts inherit cwd but cannot change their parent. A sourced
1163
+ # script runs in the caller and keeps both cwd and function definitions.
946
1164
  local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
947
- follow_scan "$content" "$((depth + 1))"
948
- follow_cwd="$saved_cwd"
949
- follow_cwd_unknown="$saved_unknown"
1165
+ if [ "$#" -eq 4 ]; then
1166
+ # Only follow_supervisor reaches this form, after exact byte identity and
1167
+ # the public argv profile were authenticated. Its owned bootstrap is
1168
+ # already scanned in full by every text policy; its user payload is
1169
+ # followed separately by follow_supervisor. Re-expanding all bootstrap
1170
+ # functions would re-interpret its internal protocol, not inspect a new
1171
+ # user-controlled file, and multiplied its native hook time fourfold.
1172
+ follow_scan "$content" "$((depth + 1))" 0 1
1173
+ else
1174
+ follow_scan "$content" "$((depth + 1))" 0 0 "$invocation_kind"
1175
+ fi
1176
+ if [ "$invocation_kind" != source ]; then
1177
+ follow_cwd="$saved_cwd"
1178
+ follow_cwd_unknown="$saved_unknown"
1179
+ fi
950
1180
  fi
951
1181
  }
952
1182
 
@@ -954,7 +1184,14 @@ follow_take() {
954
1184
  # nested inside an already-followed script it is the documented residual above.
955
1185
  follow_target() {
956
1186
  local token="$1" depth="$2" path=""
1187
+ local invocation_kind="${3:-child}"
957
1188
  [ -n "$token" ] || return 0
1189
+ if [ "$follow_cwd_unknown" -ne 0 ]; then
1190
+ case "$token" in
1191
+ /* | '~/'*) ;;
1192
+ *) follow_refuse "$token" "the execution directory is unknown" ;;
1193
+ esac
1194
+ fi
958
1195
  # Entry authority belongs to each invocation, not the file-read cache. Only
959
1196
  # follow_supervisor may admit the exact public interpreter/argv profile.
960
1197
  case "$token" in
@@ -967,7 +1204,7 @@ follow_target() {
967
1204
  return 0
968
1205
  fi
969
1206
  path="$follow_located"
970
- follow_take "$path" "$depth"
1207
+ follow_take "$path" "$depth" "$invocation_kind"
971
1208
  }
972
1209
 
973
1210
  # A command word that IS the executable (`./deploy.sh`, `/tmp/x.sh`). Followed
@@ -1024,23 +1261,422 @@ follow_next_operand() {
1024
1261
  follow_operand_index="$j"
1025
1262
  }
1026
1263
 
1264
+ # Resolve a piped file in the PRODUCER's directory, which can differ from the
1265
+ # interpreter's after a subshell closes. Preserve unknown-state provenance too.
1266
+ follow_pipe_target() {
1267
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
1268
+ follow_cwd="$3"
1269
+ follow_cwd_unknown="$4"
1270
+ follow_target "$1" "$2"
1271
+ follow_cwd="$saved_cwd"
1272
+ follow_cwd_unknown="$saved_unknown"
1273
+ }
1274
+
1275
+ # Carry branch uncertainty into sourced declarations as well as cwd changes.
1276
+ # The local projection lasts only for this source invocation, not later scans.
1277
+ follow_source_target() {
1278
+ local follow_source_flow_unknown="$3"
1279
+ follow_target "$1" "$2" source
1280
+ }
1281
+
1282
+ # Extract the literal -c operand without evaluating it or treating positional
1283
+ # arguments after its closing quote as commands. Prefix indexing matches the
1284
+ # whitespace tokens used by follow_scan; the operand keeps its internal bytes.
1285
+ follow_inline() {
1286
+ local stmt="$1" from="$2" depth="$3" body
1287
+ local follow_inline_depth="${follow_inline_depth:-0}"
1288
+ [ "$follow_inline_depth" -lt "$FOLLOW_MAX_FUNCTION_DEPTH" ] || follow_refuse "-c" "inline shells exceed the inspection depth"
1289
+ follow_inline_depth=$((follow_inline_depth + 1))
1290
+ body="$(LC_ALL=C SN_INLINE_TEXT="$stmt" SN_INLINE_FROM="$from" awk '
1291
+ BEGIN {
1292
+ s = ENVIRON["SN_INLINE_TEXT"]; n = length(s); i = 1;
1293
+ for (word = 0; word < ENVIRON["SN_INLINE_FROM"]; word++) {
1294
+ while (i <= n && substr(s, i, 1) ~ /[[:space:]]/) { i++ }
1295
+ while (i <= n && substr(s, i, 1) !~ /[[:space:]]/) { i++ }
1296
+ }
1297
+ while (i <= n && substr(s, i, 1) ~ /[[:space:]]/) { i++ }
1298
+ q = substr(s, i, 1);
1299
+ if (q == "\047" || q == "\"") {
1300
+ start = ++i; esc = 0;
1301
+ for (; i <= n; i++) {
1302
+ c = substr(s, i, 1);
1303
+ if (esc) { esc = 0; continue }
1304
+ if (c == "\\" && q == "\"") { esc = 1; continue }
1305
+ if (c == q) {
1306
+ # Adjacent quoted/unquoted fragments are the same shell operand.
1307
+ # Refuse unsupported concatenation instead of scanning a prefix.
1308
+ if (i < n && substr(s, i + 1, 1) !~ /[[:space:]]/) { exit 1 }
1309
+ printf "%s", substr(s, start, i - start); exit;
1310
+ }
1311
+ }
1312
+ exit 1;
1313
+ }
1314
+ start = i;
1315
+ while (i <= n && substr(s, i, 1) !~ /[[:space:]]/) {
1316
+ c = substr(s, i, 1);
1317
+ if (c == "\047" || c == "\"" || c == "\\") { exit 1 }
1318
+ i++;
1319
+ }
1320
+ printf "%s", substr(s, start, i - start);
1321
+ }
1322
+ ')" || follow_refuse "-c" "its command string could not be determined"
1323
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
1324
+ follow_scan "$body" "$depth"
1325
+ follow_cwd="$saved_cwd"
1326
+ follow_cwd_unknown="$saved_unknown"
1327
+ }
1328
+
1329
+ # Resolve literal descriptor duplication without running the proposed shell.
1330
+ # Compound input is established before its body; output is projected when the
1331
+ # producer reaches the pipe. Unknown descriptors refuse rather than drop bytes.
1332
+ follow_redirect_io() {
1333
+ local statement="$1" mode="$2" result
1334
+ result="$(SN_COMPOUND_REDIRECT="$statement" SN_DESCRIPTOR_MAP="$follow_descriptor_map" SN_REDIRECT_CWD="$follow_cwd" SN_REDIRECT_UNKNOWN="$follow_cwd_unknown" python3 - "$mode" <<'PY'
1335
+ import json
1336
+ import os
1337
+ import sys
1338
+ try:
1339
+ mode = sys.argv[1]
1340
+ text = os.environ['SN_COMPOUND_REDIRECT']
1341
+ tokens = []
1342
+ cursor = 0
1343
+ # Keep syntax provenance and byte adjacency: quoted/escaped > is data,
1344
+ # and `cat 2 >&2` names a file rather than redirecting descriptor 2.
1345
+ while cursor < len(text):
1346
+ if text[cursor].isspace():
1347
+ cursor += 1
1348
+ continue
1349
+ start = cursor
1350
+ syntax = text[cursor] in '<>&'
1351
+ plain = True
1352
+ value = ''
1353
+ quote = ''
1354
+ if syntax:
1355
+ while cursor < len(text) and text[cursor] in '<>&':
1356
+ value += text[cursor]; cursor += 1
1357
+ else:
1358
+ while cursor < len(text):
1359
+ char = text[cursor]
1360
+ if not quote and (char.isspace() or char in '<>&'):
1361
+ break
1362
+ if char == '\\' and quote != "'":
1363
+ plain = False
1364
+ cursor += 1
1365
+ if cursor == len(text):
1366
+ raise ValueError('trailing escape')
1367
+ if text[cursor] == '\n':
1368
+ cursor += 1
1369
+ continue
1370
+ if quote == '"' and text[cursor] not in '$`"\\':
1371
+ value += '\\'
1372
+ value += text[cursor]; cursor += 1
1373
+ continue
1374
+ if char in ('"', "'"):
1375
+ if not quote:
1376
+ quote = char; plain = False; cursor += 1
1377
+ continue
1378
+ if quote == char:
1379
+ quote = ''; cursor += 1
1380
+ continue
1381
+ value += char; cursor += 1
1382
+ if quote:
1383
+ raise ValueError('unterminated quote')
1384
+ tokens.append((value, syntax, plain, start, cursor))
1385
+ descriptors = {int(fd): value for fd, value in json.loads(os.environ['SN_DESCRIPTOR_MAP']).items()}
1386
+ operators = ('<', '>', '>>', '<>', '<&', '>&', '&>', '&>>')
1387
+ index = 0
1388
+ while index < len(tokens):
1389
+ fd = None
1390
+ token, syntax, plain, start, end = tokens[index]
1391
+ if token.isdigit() and plain and index + 1 < len(tokens) and tokens[index + 1][1] and end == tokens[index + 1][3]:
1392
+ fd = int(token); index += 1
1393
+ token, syntax, plain, start, end = tokens[index]
1394
+ if not syntax or token not in operators:
1395
+ if mode == 'cat':
1396
+ index += 1
1397
+ continue
1398
+ sys.exit(2)
1399
+ if index + 1 >= len(tokens):
1400
+ sys.exit(2)
1401
+ operator = token
1402
+ operand, operand_syntax, _, _, _ = tokens[index + 1]
1403
+ if operand_syntax:
1404
+ sys.exit(2)
1405
+ index += 2
1406
+ if fd is None:
1407
+ fd = 0 if operator.startswith('<') else 1
1408
+ if operator in ('<&', '>&'):
1409
+ if operand == '-':
1410
+ descriptors[fd] = {'kind': 'closed'}
1411
+ elif operand.isdigit():
1412
+ descriptors[fd] = descriptors.get(int(operand), {'kind': 'unknown'})
1413
+ else:
1414
+ sys.exit(2)
1415
+ else:
1416
+ descriptors[fd] = {'kind': 'file', 'path': operand, 'cwd': os.environ['SN_REDIRECT_CWD'], 'unknown': int(os.environ['SN_REDIRECT_UNKNOWN'])}
1417
+ if operator.startswith('&>'):
1418
+ descriptors[2] = descriptors[fd]
1419
+ output = descriptors[1]['kind']
1420
+ if output == 'unknown' or descriptors[0]['kind'] == 'unknown':
1421
+ sys.exit(2)
1422
+ print(json.dumps({'input': descriptors[0], 'output': output, 'descriptors': descriptors}))
1423
+ except (ValueError, OSError):
1424
+ sys.exit(2)
1425
+ PY
1426
+ )" || follow_refuse "compound redirection" "its input or pipeline output could not be determined"
1427
+ follow_redirect_input="$(printf '%s' "$result" | jq -er '.input.kind')" || follow_refuse "compound redirection" "invalid input projection"
1428
+ follow_redirect_path="$(printf '%s' "$result" | jq -r '.input.path // ""')" || follow_refuse "compound redirection" "invalid input path"
1429
+ follow_redirect_map="$(printf '%s' "$result" | jq -ce '.descriptors')" || follow_refuse "compound redirection" "invalid descriptor projection"
1430
+ follow_redirect_input_cwd="$(printf '%s' "$result" | jq -r '.input.cwd // ""')" || follow_refuse "compound redirection" "invalid input cwd"
1431
+ follow_redirect_input_unknown="$(printf '%s' "$result" | jq -r '.input.unknown // 1')" || follow_refuse "compound redirection" "invalid input uncertainty"
1432
+ follow_redirect_output="$(printf '%s' "$result" | jq -er '.output')" || follow_refuse "compound redirection" "invalid output projection"
1433
+ }
1434
+
1027
1435
  # Walk one text for the files it executes. Runs in the PARENT shell, never in a
1028
1436
  # command substitution: follow_refuse() must be able to deny, and an exit inside
1029
1437
  # a subshell ends that subshell rather than this hook — the mistake documented
1030
1438
  # on the rm segmentation below.
1031
1439
  follow_scan() {
1032
1440
  local text="$1" depth="$2"
1441
+ local function_call_depth="${3:-0}"
1442
+ local authenticated_bootstrap="${4:-0}"
1443
+ local invocation_kind="${5:-child}"
1444
+ local function_flow_unknown="${6:-0}" function_lookup_allowed statement_conditional function_conditional=0
1445
+ local and_cwd="" and_unknown=1 and_ready=0 join_cwd join_unknown
1446
+ if [ "$invocation_kind" = source ]; then
1447
+ function_flow_unknown="${follow_source_flow_unknown:-0}"
1448
+ fi
1449
+ local return_scope_depth=-1
1450
+ local return_list_depth=-1
1451
+ local return_flow_unknown=0
1452
+ local list_depth=0 list_index
1033
1453
  local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
1034
1454
  local heredoc redirected pipe_cat prev_cat pending_operand stdin_mode
1455
+ local pipe_cwd pipe_unknown prev_cwd prev_unknown
1456
+ local follow_stdin_cat="${follow_stdin_cat:-}" follow_stdin_cwd="${follow_stdin_cwd:-}" follow_stdin_unknown="${follow_stdin_unknown:-1}"
1457
+ local follow_redirect_input follow_redirect_path follow_redirect_output follow_redirect_map follow_redirect_input_cwd follow_redirect_input_unknown
1458
+ local follow_descriptor_map="${follow_descriptor_map:-}"
1459
+ if [ -z "$follow_descriptor_map" ]; then
1460
+ follow_descriptor_map='{"0":{"kind":"input"},"1":{"kind":"pipe"},"2":{"kind":"stderr"}}'
1461
+ fi
1035
1462
  local find_mode dispatch_pending
1463
+ local scope_depth=0 case_depth=0 function_name="" function_body="" function_index function_found
1036
1464
  local -a toks=()
1037
- split_out="$(follow_split "$text")"
1465
+ local -a cwd_scopes=() unknown_scopes=() cat_scopes=() cat_cwd_scopes=() cat_unknown_scopes=() function_scopes=() flow_scopes=() return_flows=()
1466
+ local -a list_cwds=() list_unknowns=() list_functions=() list_flows=() list_asyncs=() list_scope_depths=() list_return_flows=() brace_flows=()
1467
+ local -a brace_descriptor_maps=() scope_descriptor_maps=()
1468
+ local -a brace_stdin_cats=() brace_stdin_cwds=() brace_stdin_unknowns=() scope_stdin_cats=() scope_stdin_cwds=() scope_stdin_unknowns=()
1469
+ local -a case_cwds=() case_unknowns=() case_changes=()
1470
+ if [ "$function_call_depth" -eq 0 ] && [ "$invocation_kind" != source ]; then
1471
+ # Ordinary child shells do not inherit unexported functions. Sourcing and
1472
+ # function calls share the caller's definitions instead.
1473
+ local function_count=0
1474
+ local -a function_names=() function_bodies=() function_uncertainties=()
1475
+ fi
1476
+ split_out="$(follow_split "$text")" || follow_refuse "shell scope" "scope parsing exceeds the inspection budget"
1038
1477
  prev_cat=""
1039
1478
  while IFS= read -r line; do
1040
1479
  sep="${line%%$'\t'*}"
1041
1480
  stmt="${line#*$'\t'}"
1042
- pipe_cat=""
1043
- [ "$sep" != "|" ] || pipe_cat="$prev_cat"
1481
+ statement_conditional=0
1482
+ case "$sep" in '&' | '?') statement_conditional=1 ;; esac
1483
+ # A reached && successor observes the preceding successful command's cwd.
1484
+ # Scope/list events invalidate this projection; it must never carry a
1485
+ # child's or a background list's directory into another shell/list.
1486
+ case "$sep" in
1487
+ L | G | H | B | N | F | C | A | K | '(' | '[' | ')' | ']') and_ready=0 ;;
1488
+ esac
1489
+ if [ "$return_list_depth" -ge 0 ]; then
1490
+ case "$sep" in
1491
+ B) [ "$list_depth" -eq "$return_list_depth" ] || continue ;;
1492
+ '(' | '[' | ')' | ']' | G | H) ;;
1493
+ *) continue ;;
1494
+ esac
1495
+ fi
1496
+ if [ "$return_scope_depth" -ge 0 ]; then
1497
+ # A return in a child scope ends that child, not its enclosing function.
1498
+ # Keep only scope events until the matching close restores the parent.
1499
+ case "$sep" in '(' | '[' | ')' | ']' | G | H) ;; *) continue ;; esac
1500
+ fi
1501
+ case "$sep" in
1502
+ L)
1503
+ list_cwds[list_depth]="$follow_cwd"
1504
+ list_unknowns[list_depth]="$follow_cwd_unknown"
1505
+ list_functions[list_depth]=$function_count
1506
+ list_flows[list_depth]=$function_flow_unknown
1507
+ list_asyncs[list_depth]="$stmt"
1508
+ list_scope_depths[list_depth]=$scope_depth
1509
+ list_return_flows[list_depth]=$return_flow_unknown
1510
+ continue
1511
+ ;;
1512
+ G)
1513
+ brace_flows[list_depth]=$function_flow_unknown
1514
+ brace_descriptor_maps[list_depth]="$follow_descriptor_map"
1515
+ brace_stdin_cats[list_depth]="$follow_stdin_cat"
1516
+ brace_stdin_cwds[list_depth]="$follow_stdin_cwd"
1517
+ brace_stdin_unknowns[list_depth]="$follow_stdin_unknown"
1518
+ if [ "$stmt" = "|" ]; then
1519
+ follow_stdin_cat="$prev_cat"; follow_stdin_cwd="${prev_cwd:-}"; follow_stdin_unknown="${prev_unknown:-1}"
1520
+ fi
1521
+ case "$stmt" in '&' | '?') function_flow_unknown=1 ;; esac
1522
+ list_depth=$((list_depth + 1)); continue
1523
+ ;;
1524
+ H)
1525
+ list_depth=$((list_depth - 1))
1526
+ function_flow_unknown="${brace_flows[list_depth]}"
1527
+ follow_descriptor_map="${brace_descriptor_maps[list_depth]}"
1528
+ follow_stdin_cat="${brace_stdin_cats[list_depth]}"
1529
+ follow_stdin_cwd="${brace_stdin_cwds[list_depth]}"
1530
+ follow_stdin_unknown="${brace_stdin_unknowns[list_depth]}"
1531
+ # A conditional return may exit this function/source before commands
1532
+ # after the brace. Unlike a child return, that uncertainty persists.
1533
+ if [ "$return_flow_unknown" -ne 0 ]; then function_flow_unknown=1; fi
1534
+ continue
1535
+ ;;
1536
+ B)
1537
+ # A single & runs the whole AND/OR list asynchronously. Its cwd and
1538
+ # definitions cannot escape into the parent's next list.
1539
+ follow_cwd="${list_cwds[list_depth]}"
1540
+ follow_cwd_unknown="${list_unknowns[list_depth]}"
1541
+ function_flow_unknown="${list_flows[list_depth]}"
1542
+ return_flow_unknown="${list_return_flows[list_depth]}"
1543
+ return_list_depth=-1
1544
+ while [ "$function_count" -gt "${list_functions[list_depth]}" ]; do
1545
+ function_count=$((function_count - 1))
1546
+ function_index=$function_count
1547
+ unset 'function_names[function_index]' 'function_bodies[function_index]' 'function_uncertainties[function_index]'
1548
+ done
1549
+ continue
1550
+ ;;
1551
+ N)
1552
+ function_name="${stmt#*$'\t'}"; function_body=""
1553
+ function_conditional="$function_flow_unknown"
1554
+ case "${stmt%%$'\t'*}" in '&' | '?') function_conditional=1 ;; esac
1555
+ continue
1556
+ ;;
1557
+ D)
1558
+ if [ "$authenticated_bootstrap" -eq 0 ]; then
1559
+ function_body="$function_body$stmt"$'\n'
1560
+ fi
1561
+ continue
1562
+ ;;
1563
+ F)
1564
+ [ "$authenticated_bootstrap" -eq 0 ] || continue
1565
+ function_index=$function_count
1566
+ function_names[function_index]="$function_name"
1567
+ function_bodies[function_index]="$function_body"
1568
+ function_uncertainties[function_index]="$function_conditional"
1569
+ function_count=$((function_count + 1))
1570
+ continue
1571
+ ;;
1572
+ C)
1573
+ function_flow_unknown=1
1574
+ case_cwds[case_depth]="$follow_cwd"
1575
+ case_unknowns[case_depth]="$follow_cwd_unknown"
1576
+ case_changes[case_depth]=0
1577
+ case_depth=$((case_depth + 1))
1578
+ continue
1579
+ ;;
1580
+ A | K)
1581
+ function_index=$((case_depth - 1))
1582
+ if [ "$follow_cwd" != "${case_cwds[function_index]}" ] || [ "$follow_cwd_unknown" != "${case_unknowns[function_index]}" ]; then
1583
+ case_changes[function_index]=1
1584
+ fi
1585
+ follow_cwd="${case_cwds[function_index]}"
1586
+ follow_cwd_unknown="${case_unknowns[function_index]}"
1587
+ if [ "$sep" = K ]; then
1588
+ if [ "${case_changes[function_index]}" -ne 0 ]; then
1589
+ follow_cwd=""; follow_cwd_unknown=1
1590
+ fi
1591
+ case_depth=$((case_depth - 1))
1592
+ fi
1593
+ continue
1594
+ ;;
1595
+ '(' | '[')
1596
+ cwd_scopes[scope_depth]="$follow_cwd"
1597
+ unknown_scopes[scope_depth]="$follow_cwd_unknown"
1598
+ cat_scopes[scope_depth]="$prev_cat"
1599
+ cat_cwd_scopes[scope_depth]="${prev_cwd:-}"
1600
+ cat_unknown_scopes[scope_depth]="${prev_unknown:-1}"
1601
+ scope_descriptor_maps[scope_depth]="$follow_descriptor_map"
1602
+ function_scopes[scope_depth]=$function_count
1603
+ flow_scopes[scope_depth]=$function_flow_unknown
1604
+ return_flows[scope_depth]=$return_flow_unknown
1605
+ scope_stdin_cats[scope_depth]="$follow_stdin_cat"
1606
+ scope_stdin_cwds[scope_depth]="$follow_stdin_cwd"
1607
+ scope_stdin_unknowns[scope_depth]="$follow_stdin_unknown"
1608
+ if [ "$stmt" = "|" ] || [ "$stmt" = "|+" ]; then
1609
+ follow_stdin_cat="$prev_cat"; follow_stdin_cwd="${prev_cwd:-}"; follow_stdin_unknown="${prev_unknown:-1}"
1610
+ follow_descriptor_map="$(printf '%s' "$follow_descriptor_map" | jq -ce '.["0"] = {kind:"input"}')" || follow_refuse "pipeline input" "its descriptor could not be established"
1611
+ fi
1612
+ case "$stmt" in
1613
+ *+)
1614
+ follow_descriptor_map="$(printf '%s' "$follow_descriptor_map" | jq -ce '.["1"] = {kind:"pipe"}')" || follow_refuse "pipeline output" "its descriptor could not be established"
1615
+ ;;
1616
+ esac
1617
+ scope_depth=$((scope_depth + 1))
1618
+ list_depth=$((list_depth + 1))
1619
+ continue
1620
+ ;;
1621
+ ')' | ']')
1622
+ scope_depth=$((scope_depth - 1))
1623
+ list_depth=$((list_depth - 1))
1624
+ follow_cwd="${cwd_scopes[scope_depth]}"
1625
+ follow_descriptor_map="${scope_descriptor_maps[scope_depth]}"
1626
+ follow_cwd_unknown="${unknown_scopes[scope_depth]}"
1627
+ function_flow_unknown="${flow_scopes[scope_depth]}"
1628
+ return_flow_unknown="${return_flows[scope_depth]}"
1629
+ follow_stdin_cat="${scope_stdin_cats[scope_depth]}"
1630
+ follow_stdin_cwd="${scope_stdin_cwds[scope_depth]}"
1631
+ follow_stdin_unknown="${scope_stdin_unknowns[scope_depth]}"
1632
+ while [ "$function_count" -gt "${function_scopes[scope_depth]}" ]; do
1633
+ function_count=$((function_count - 1))
1634
+ function_index=$function_count
1635
+ unset 'function_names[function_index]' 'function_bodies[function_index]' 'function_uncertainties[function_index]'
1636
+ done
1637
+ if [ "$sep" = ']' ]; then
1638
+ prev_cat="${cat_scopes[scope_depth]}"
1639
+ prev_cwd="${cat_cwd_scopes[scope_depth]}"
1640
+ prev_unknown="${cat_unknown_scopes[scope_depth]}"
1641
+ fi
1642
+ if [ "$return_scope_depth" -ge 0 ] && [ "$scope_depth" -lt "$return_scope_depth" ]; then
1643
+ return_scope_depth=-1
1644
+ fi
1645
+ continue
1646
+ ;;
1647
+ I)
1648
+ follow_redirect_io "$stmt" compound
1649
+ follow_descriptor_map="$follow_redirect_map"
1650
+ case "$follow_redirect_input" in
1651
+ file)
1652
+ follow_stdin_cat="$follow_redirect_path"
1653
+ follow_stdin_cwd="$follow_redirect_input_cwd"; follow_stdin_unknown="$follow_redirect_input_unknown"
1654
+ ;;
1655
+ closed) follow_stdin_cat="" ;;
1656
+ input) ;;
1657
+ *) follow_refuse "compound redirection" "its input source could not be determined" ;;
1658
+ esac
1659
+ continue
1660
+ ;;
1661
+ R)
1662
+ # The whole compound tail was applied before its body by I. Its cat
1663
+ # producer already records whether its effective output reaches pipe.
1664
+ continue
1665
+ ;;
1666
+ esac
1667
+ join_cwd="$follow_cwd"; join_unknown="$follow_cwd_unknown"
1668
+ if [ "$sep" = '&' ] && [ "$and_ready" -eq 1 ]; then
1669
+ follow_cwd="$and_cwd"; follow_cwd_unknown="$and_unknown"
1670
+ fi
1671
+ and_ready=0
1672
+ pipe_cat="$follow_stdin_cat"
1673
+ pipe_cwd="$follow_stdin_cwd"
1674
+ pipe_unknown="$follow_stdin_unknown"
1675
+ if [ "$sep" = "|" ]; then
1676
+ pipe_cat="$prev_cat"
1677
+ pipe_cwd="${prev_cwd:-}"
1678
+ pipe_unknown="${prev_unknown:-1}"
1679
+ fi
1044
1680
  prev_cat=""
1045
1681
  case "$stmt" in
1046
1682
  *[![:space:]]*) ;;
@@ -1056,6 +1692,7 @@ follow_scan() {
1056
1692
  [ "$n" -gt 0 ] || continue
1057
1693
  i=0
1058
1694
  cmd_pos=1
1695
+ function_lookup_allowed=1
1059
1696
  eval_mode=0
1060
1697
  arg_exec=0
1061
1698
  pending_operand=0
@@ -1107,7 +1744,65 @@ follow_scan() {
1107
1744
  follow_unwrap "${toks[i]}"
1108
1745
  t="$follow_unwrapped"
1109
1746
  fi
1747
+ function_found=0
1748
+ for ((function_index = function_count - 1; function_lookup_allowed && function_index >= 0; function_index--)); do
1749
+ if [ "$t" = "${function_names[function_index]}" ]; then
1750
+ [ "${function_uncertainties[function_index]}" -eq 0 ] || follow_refuse "$t" "its function definition is conditional"
1751
+ # Calls execute in this shell and do not consume a file-follow depth.
1752
+ # A separate bound refuses recursion without truncating the body.
1753
+ [ "$function_call_depth" -lt "$FOLLOW_MAX_FUNCTION_DEPTH" ] || follow_refuse "$t" "function calls exceed the inspection depth"
1754
+ follow_function_calls=$((follow_function_calls + 1))
1755
+ follow_function_bytes=$((follow_function_bytes + ${#function_bodies[function_index]}))
1756
+ if [ "$follow_function_calls" -gt "$FOLLOW_MAX_FUNCTION_CALLS" ] || [ "$follow_function_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
1757
+ follow_refuse "$t" "function expansion exceeds the inspection budget"
1758
+ fi
1759
+ local call_cwd="$follow_cwd" call_unknown="$follow_cwd_unknown"
1760
+ follow_scan "${function_bodies[function_index]}" "$depth" "$((function_call_depth + 1))" 0 child "$((function_flow_unknown || statement_conditional))"
1761
+ if { [ "$function_flow_unknown" -ne 0 ] || [ "$statement_conditional" -ne 0 ]; } && { [ "$follow_cwd" != "$call_cwd" ] || [ "$follow_cwd_unknown" != "$call_unknown" ]; }; then
1762
+ follow_cwd=""; follow_cwd_unknown=1
1763
+ fi
1764
+ function_found=1
1765
+ break
1766
+ fi
1767
+ done
1768
+ if [ "$function_found" -eq 1 ]; then
1769
+ cmd_pos=0; i=$((i + 1)); continue
1770
+ fi
1110
1771
  case "$t" in
1772
+ return)
1773
+ if [ "$statement_conditional" -ne 0 ]; then function_flow_unknown=1; fi
1774
+ if { [ "$function_call_depth" -gt 0 ] || [ "$invocation_kind" = source ]; } && [ "$function_flow_unknown" -ne 0 ]; then
1775
+ return_flow_unknown=1
1776
+ fi
1777
+ if { [ "$function_call_depth" -gt 0 ] || [ "$invocation_kind" = source ]; } && [ "$function_flow_unknown" -eq 0 ]; then
1778
+ # Ordinary braces share the shell but have distinct list frames.
1779
+ # A surrounding background brace list ends only its child; do not
1780
+ # cross a literal subshell boundary while finding that parent list.
1781
+ for ((list_index = list_depth; list_index >= 0; list_index--)); do
1782
+ [ "${list_scope_depths[list_index]}" -eq "$scope_depth" ] || break
1783
+ if [ "${list_asyncs[list_index]}" -ne 0 ]; then
1784
+ return_list_depth="$list_index"
1785
+ break
1786
+ fi
1787
+ done
1788
+ if [ "$return_list_depth" -ge 0 ]; then
1789
+ :
1790
+ elif [ "$scope_depth" -eq 0 ]; then
1791
+ return 0
1792
+ else
1793
+ return_scope_depth="$scope_depth"
1794
+ fi
1795
+ fi
1796
+ cmd_pos=0; i=$((i + 1)); continue
1797
+ ;;
1798
+ if | while | until)
1799
+ function_flow_unknown=1
1800
+ i=$((i + 1)); continue
1801
+ ;;
1802
+ for | select)
1803
+ function_flow_unknown=1
1804
+ cmd_pos=0; i=$((i + 1)); continue
1805
+ ;;
1111
1806
  # Prefixes that keep the command position open: assignments, wrappers,
1112
1807
  # and the shell keywords a statement can start with. Without them
1113
1808
  # `if …; then bash <file>` and `env bash <file>` walk past the
@@ -1142,12 +1837,17 @@ follow_scan() {
1142
1837
  # which keeps working when options sit in between — `timeout -s KILL
1143
1838
  # 5 bash <file>` reaches `bash` just as `timeout 5 bash <file>` does.
1144
1839
  pending_operand=1
1840
+ function_lookup_allowed=0
1145
1841
  i=$((i + 1))
1146
1842
  continue
1147
1843
  ;;
1148
1844
  sudo | env | command | exec | nohup | nice | time | builtin \
1149
- | do | then | else | while | until | if | '!' | '{' \
1845
+ | do | then | else | '!' | '{' \
1150
1846
  | */sudo | */env | */nohup | */nice | */time)
1847
+ case "$t" in
1848
+ sudo | env | command | exec | nohup | nice | builtin | */sudo | */env | */nohup | */nice) function_lookup_allowed=0 ;;
1849
+ do | then | else) function_flow_unknown=1 ;;
1850
+ esac
1151
1851
  i=$((i + 1))
1152
1852
  continue
1153
1853
  ;;
@@ -1185,6 +1885,9 @@ follow_scan() {
1185
1885
  # place would resolve later tokens from a directory the shell left.
1186
1886
  follow_cd "${HOME:-}"
1187
1887
  fi
1888
+ if [ "$function_flow_unknown" -ne 0 ]; then
1889
+ follow_cwd=""; follow_cwd_unknown=1
1890
+ fi
1188
1891
  cmd_pos=0
1189
1892
  i=$((i + 1))
1190
1893
  continue
@@ -1194,7 +1897,11 @@ follow_scan() {
1194
1897
  j="$follow_operand_index"
1195
1898
  if [ "$j" -lt "$n" ]; then
1196
1899
  follow_unwrap "${toks[j]}"
1197
- follow_target "$follow_unwrapped" "$depth"
1900
+ local source_cwd="$follow_cwd" source_unknown="$follow_cwd_unknown"
1901
+ follow_source_target "$follow_unwrapped" "$depth" "$((function_flow_unknown || statement_conditional))"
1902
+ if { [ "$function_flow_unknown" -ne 0 ] || [ "$statement_conditional" -ne 0 ]; } && { [ "$follow_cwd" != "$source_cwd" ] || [ "$follow_cwd_unknown" != "$source_unknown" ]; }; then
1903
+ follow_cwd=""; follow_cwd_unknown=1
1904
+ fi
1198
1905
  fi
1199
1906
  cmd_pos=0
1200
1907
  i=$((j + 1))
@@ -1203,14 +1910,32 @@ follow_scan() {
1203
1910
  cat | '<')
1204
1911
  follow_next_operand "$i" "$n"
1205
1912
  j="$follow_operand_index"
1206
- if [ "$j" -lt "$n" ]; then
1207
- follow_unwrap "${toks[j]}"
1913
+ follow_unwrapped=""
1914
+ if [ "$j" -lt "$n" ]; then follow_unwrap "${toks[j]}"; fi
1915
+ if [ "$t" = cat ] && { [ "$j" -ge "$n" ] || [ "$follow_unwrapped" = - ]; }; then
1916
+ # Bare cat and cat - forward the group's named stdin unchanged.
1917
+ prev_cat="$pipe_cat"; prev_cwd="$pipe_cwd"; prev_unknown="$pipe_unknown"
1918
+ if [ "$eval_mode" -eq 1 ] && [ -n "$prev_cat" ]; then
1919
+ follow_pipe_target "$prev_cat" "$depth" "$prev_cwd" "$prev_unknown"
1920
+ fi
1921
+ elif [ "$j" -lt "$n" ]; then
1208
1922
  if [ "$eval_mode" -eq 1 ]; then
1209
1923
  follow_target "$follow_unwrapped" "$depth"
1210
1924
  else
1211
- # Not executed here — but `cat <file> | bash` is, and the
1212
- # interpreter arm below reads this when the pipe hands it over.
1925
+ # Not executed here — the pipe consumer decides whether the
1926
+ # producer's file content becomes an executable shell input.
1213
1927
  prev_cat="$follow_unwrapped"
1928
+ prev_cwd="$follow_cwd"; prev_unknown="$follow_cwd_unknown"
1929
+ fi
1930
+ fi
1931
+ if [ -n "$prev_cat" ] && [ "$eval_mode" -eq 0 ]; then
1932
+ if [[ "$stmt" == *'>'* ]] || [ "$follow_descriptor_map" != '{"0":{"kind":"input"},"1":{"kind":"pipe"},"2":{"kind":"stderr"}}' ]; then
1933
+ follow_redirect_io "$stmt" cat
1934
+ case "$follow_redirect_output" in
1935
+ pipe) ;;
1936
+ file | closed | stderr | input) prev_cat="" ;;
1937
+ *) follow_refuse "cat redirection" "its output destination could not be determined" ;;
1938
+ esac
1214
1939
  fi
1215
1940
  fi
1216
1941
  cmd_pos=0
@@ -1295,18 +2020,17 @@ follow_scan() {
1295
2020
  continue
1296
2021
  fi
1297
2022
  if [ "$inline" -eq 1 ]; then
1298
- # `-c` takes a COMMAND STRING, not a script path. That string is text
1299
- # the guards already read, and it can itself invoke a script, so the
1300
- # walk re-enters it at a fresh command position rather than reading it
1301
- # as a filename — this is what catches `bash -c 'bash <file>'`.
1302
- cmd_pos=1
1303
- i="$j"
2023
+ # The command string runs in a child shell. It gets its own function
2024
+ # map and cwd effects; positional arguments are data, not commands.
2025
+ follow_inline "$stmt" "$j" "$depth"
2026
+ cmd_pos=0
2027
+ i="$n"
1304
2028
  continue
1305
2029
  fi
1306
2030
  if [ "$stdin_mode" -eq 1 ]; then
1307
2031
  # `sh -s -- -y` reads its program from stdin; tokens after `--` are
1308
2032
  # positional arguments to that program, never a script path.
1309
- [ -z "$pipe_cat" ] || follow_target "$pipe_cat" "$depth"
2033
+ [ -z "$pipe_cat" ] || follow_pipe_target "$pipe_cat" "$depth" "$pipe_cwd" "$pipe_unknown"
1310
2034
  cmd_pos=0
1311
2035
  i="$n"
1312
2036
  continue
@@ -1320,7 +2044,7 @@ follow_scan() {
1320
2044
  fi
1321
2045
  # No operand: an interactive or stdin-fed interpreter. Only the
1322
2046
  # `cat <file> | bash` shape names a FILE, and only that is followed.
1323
- [ -z "$pipe_cat" ] || follow_target "$pipe_cat" "$depth"
2047
+ [ -z "$pipe_cat" ] || follow_pipe_target "$pipe_cat" "$depth" "$pipe_cwd" "$pipe_unknown"
1324
2048
  cmd_pos=0
1325
2049
  i="$j"
1326
2050
  continue
@@ -1354,6 +2078,14 @@ follow_scan() {
1354
2078
  cmd_pos=0
1355
2079
  i=$((i + 1))
1356
2080
  done
2081
+ # Keep successful-branch provenance separate from the continuation join.
2082
+ # OR can skip its RHS and still reach a following &&, so it cannot prove
2083
+ # that RHS's cwd. A semicolon observes every possible preceding outcome.
2084
+ and_cwd="$follow_cwd"; and_unknown="$follow_cwd_unknown"
2085
+ if [ "$sep" != '?' ] && [ "$function_flow_unknown" -eq 0 ]; then and_ready=1; fi
2086
+ if [ "$statement_conditional" -ne 0 ] && { [ "$join_unknown" -ne 0 ] || [ "$follow_cwd_unknown" -ne 0 ] || [ "$follow_cwd" != "$join_cwd" ]; }; then
2087
+ follow_cwd=""; follow_cwd_unknown=1
2088
+ fi
1357
2089
  done <<<"$split_out"
1358
2090
  }
1359
2091