@codyswann/lisa 4.45.1 → 4.45.3

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 (70) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +49 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +115 -12
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +11 -6
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/parity-safety-net.sh +115 -12
  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 +115 -12
  15. package/plugins/lisa-agy/hooks/parity-safety-net.sh +115 -12
  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 +115 -12
  24. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +115 -12
  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 +115 -12
  67. package/scripts/check-typecheck-tests.mjs +229 -72
  68. package/scripts/lib/dependency-tree.mjs +136 -0
  69. package/scripts/lisa-enforcement-fallback.sh +49 -0
  70. package/typescript/copy-contents/.husky/pre-push +45 -0
@@ -405,7 +405,12 @@ readonly FOLLOW_MAX_TOTAL_BYTES=524288
405
405
  readonly FOLLOW_MAX_FILES=8
406
406
  readonly FOLLOW_MAX_DEPTH=3
407
407
 
408
- follow_bases=""
408
+ # Effective working directory established by a literal `cd` in the command, and
409
+ # the flag that says a `cd` happened whose target could NOT be read literally.
410
+ # Empty + 0 means no `cd` has been seen and the session's own cwd governs.
411
+ follow_cwd=""
412
+ follow_cwd_unknown=0
413
+ follow_normalized=""
409
414
  follow_paths=$'\n'
410
415
  follow_names=""
411
416
  follow_count=0
@@ -420,6 +425,10 @@ see is the thing it exists to stop.
420
425
 
421
426
  - Name the script by a LITERAL path that exists and is readable — \`bash
422
427
  ./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
428
+ - A leading \`cd\` the guard cannot read literally — \`cd \"\$DIR\" && bash x.sh\` —
429
+ leaves it unable to say WHICH copy of \`x.sh\` would run, and a same-named file
430
+ in another directory is a different file. Spell the directory out, or give the
431
+ script its full path.
423
432
  - A file past the inspection cap is refused rather than half-scanned, because a
424
433
  truncated scan reports a confident ALLOW about text it never read. Split it,
425
434
  or run it manually outside the agent.
@@ -550,15 +559,77 @@ follow_split() {
550
559
  '
551
560
  }
552
561
 
553
- # Record a literal `cd` target as an additional base for resolving a relative
554
- # executed path, so `cd sub && ./run.sh` resolves the way the shell will.
555
- follow_add_base() {
556
- local d="$1"
562
+ # Collapse `.`, `..` and empty segments so the path a verdict NAMES is the one a
563
+ # reader recognises. Purely lexical, and it cannot move a verdict: every
564
+ # component has already been proven to exist by the `-d` test that follows, and
565
+ # the `-f`/`-r` tests are applied to the collapsed path either way.
566
+ follow_normalize() {
567
+ local p="$1" out="" seg
568
+ local IFS=/
569
+ set -f
570
+ # The word split is the point: `$p` is being addressed one path segment at a
571
+ # time, and the glob guard above keeps a literal `*` from expanding.
572
+ # shellcheck disable=SC2086
573
+ for seg in $p; do
574
+ case "$seg" in
575
+ "" | .) ;;
576
+ ..) out="${out%/*}" ;;
577
+ *) out="$out/$seg" ;;
578
+ esac
579
+ done
580
+ set +f
581
+ follow_normalized="${out:-/}"
582
+ }
583
+
584
+ # Track the working directory a literal `cd` establishes, so a relative executed
585
+ # path resolves against the directory the command will actually have.
586
+ #
587
+ # This REPLACES the session's own cwd rather than adding to it. It used to be an
588
+ # extra base appended AFTER `$PWD`, so `$PWD` always won and
589
+ # `cd <other-checkout> && bash scripts/x.sh` was classified against the session
590
+ # checkout's same-named copy. That is not a resolution failure — it succeeds,
591
+ # against a real and readable file, and the verdict is reported with full
592
+ # confidence about text nobody was about to run (CodySwannGT/lisa#3933). Two
593
+ # checkouts of one repository differ by exactly the work in flight, which is the
594
+ # entire point of the second one, so the copy that would RUN is the only copy
595
+ # whose verdict means anything. Wrong in both directions, and the expensive
596
+ # direction is silent: the dangerous line sits in the copy that would run and
597
+ # the guard reads the pristine one.
598
+ #
599
+ # A target this hook cannot turn into a literal directory marks the cwd UNKNOWN
600
+ # instead of leaving a stale one in place. `follow_locate` then refuses a
601
+ # relative token rather than guessing, which is the honest-refusal doctrine the
602
+ # rest of this block already follows: a confident scan of the wrong file is
603
+ # strictly worse than saying it could not tell.
604
+ follow_cd() {
605
+ local d="$1" home="${HOME:-}"
606
+ # The tilde arms are LITERAL text — the unexpanded spelling the hook was
607
+ # handed — for the reason follow_locate's arms document.
608
+ # shellcheck disable=SC2088
609
+ case "$d" in
610
+ '~') d="$home" ;;
611
+ '~/'*) d="$home/${d#'~/'}" ;;
612
+ esac
613
+ case "$d" in
614
+ "" | -* | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*)
615
+ follow_cwd=""
616
+ follow_cwd_unknown=1
617
+ return 0
618
+ ;;
619
+ esac
557
620
  case "$d" in
558
- "" | -* | *'$'* | *'*'* | *'?'*) return 0 ;;
621
+ /*) ;;
622
+ *) d="${follow_cwd:-$PWD}/$d" ;;
559
623
  esac
560
- [ -d "$d" ] || return 0
561
- follow_bases="$follow_bases"$'\n'"$d"
624
+ follow_normalize "$d"
625
+ d="$follow_normalized"
626
+ if [ -d "$d" ]; then
627
+ follow_cwd="$d"
628
+ follow_cwd_unknown=0
629
+ else
630
+ follow_cwd=""
631
+ follow_cwd_unknown=1
632
+ fi
562
633
  }
563
634
 
564
635
  # Resolve one executed-file operand to a readable regular file, or fail.
@@ -571,6 +642,7 @@ follow_add_base() {
571
642
  # this hook cannot evaluate.
572
643
  follow_locate() {
573
644
  local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
645
+ local cwd="${follow_cwd:-$PWD}"
574
646
  follow_located=""
575
647
  tmp="${tmp%/}"
576
648
  [ -n "$tmp" ] || tmp="/tmp"
@@ -584,8 +656,14 @@ follow_locate() {
584
656
  '${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
585
657
  '$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
586
658
  '${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
587
- '$PWD' | '$PWD'/*) token="$PWD${token#'$PWD'}" ;;
588
- '${PWD}' | '${PWD}'/*) token="$PWD${token#'${PWD}'}" ;;
659
+ '$PWD' | '$PWD'/*)
660
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
661
+ token="$cwd${token#'$PWD'}"
662
+ ;;
663
+ '${PWD}' | '${PWD}'/*)
664
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
665
+ token="$cwd${token#'${PWD}'}"
666
+ ;;
589
667
  '$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
590
668
  '${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
591
669
  esac
@@ -597,13 +675,26 @@ follow_locate() {
597
675
  return 0
598
676
  ;;
599
677
  esac
678
+ # Past this point the token is RELATIVE, so which directory it is resolved
679
+ # from decides which file the verdict is about.
680
+ if [ "$follow_cwd_unknown" -eq 1 ]; then
681
+ return 1
682
+ fi
683
+ if [ -n "$follow_cwd" ]; then
684
+ # A literal `cd` names the directory the shell will be in. It is the only
685
+ # candidate — falling back to `$PWD` here is what made the guard report
686
+ # confidently about a same-named file in another checkout.
687
+ [ -f "$follow_cwd/$token" ] && [ -r "$follow_cwd/$token" ] || return 1
688
+ follow_located="$follow_cwd/$token"
689
+ return 0
690
+ fi
600
691
  while IFS= read -r base; do
601
692
  [ -n "$base" ] || continue
602
693
  if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
603
694
  follow_located="$base/$token"
604
695
  return 0
605
696
  fi
606
- done <<<"$PWD"$'\n'"$proj$follow_bases"
697
+ done <<<"$PWD"$'\n'"$proj"
607
698
  return 1
608
699
  }
609
700
 
@@ -636,7 +727,15 @@ follow_take() {
636
727
  follow_names="$follow_names $path"
637
728
  followed_text="$followed_text"$'\n'"$content"
638
729
  if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
730
+ # A `cd` inside the followed script governs what THAT script goes on to
731
+ # execute, and nothing after it in the command that ran the script — the
732
+ # child inherits this directory but cannot change the parent's. Saving and
733
+ # restoring is what keeps a nested `cd` from silently re-pointing the rest
734
+ # of the outer walk.
735
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
639
736
  follow_scan "$content" "$((depth + 1))"
737
+ follow_cwd="$saved_cwd"
738
+ follow_cwd_unknown="$saved_unknown"
640
739
  fi
641
740
  }
642
741
 
@@ -861,7 +960,11 @@ follow_scan() {
861
960
  cd)
862
961
  if [ $((i + 1)) -lt "$n" ]; then
863
962
  follow_unwrap "${toks[i + 1]}"
864
- follow_add_base "$follow_unwrapped"
963
+ follow_cd "$follow_unwrapped"
964
+ else
965
+ # A bare `cd` is `cd "$HOME"`, and leaving the previous directory in
966
+ # place would resolve later tokens from a directory the shell left.
967
+ follow_cd "${HOME:-}"
865
968
  fi
866
969
  cmd_pos=0
867
970
  i=$((i + 1))
package/package.json CHANGED
@@ -175,7 +175,7 @@
175
175
  "zod-validation-error": "^4.0.0"
176
176
  },
177
177
  "name": "@codyswann/lisa",
178
- "version": "4.45.1",
178
+ "version": "4.45.3",
179
179
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
180
180
  "main": "dist/index.js",
181
181
  "exports": {
@@ -323,7 +323,7 @@
323
323
  "test": "tests"
324
324
  },
325
325
  "types": "./dist/index.d.ts",
326
- "lisaReleaseCommit": "f394a3b24a93189315d2e975453b6f6c53a855bb",
327
- "gitHead": "f394a3b24a93189315d2e975453b6f6c53a855bb",
328
- "lisaReleaseTag": "v4.45.1"
326
+ "lisaReleaseCommit": "f771fcab3c0cc18480a639ae82b6d49e63e6716e",
327
+ "gitHead": "f771fcab3c0cc18480a639ae82b6d49e63e6716e",
328
+ "lisaReleaseTag": "v4.45.3"
329
329
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -405,7 +405,12 @@ readonly FOLLOW_MAX_TOTAL_BYTES=524288
405
405
  readonly FOLLOW_MAX_FILES=8
406
406
  readonly FOLLOW_MAX_DEPTH=3
407
407
 
408
- follow_bases=""
408
+ # Effective working directory established by a literal `cd` in the command, and
409
+ # the flag that says a `cd` happened whose target could NOT be read literally.
410
+ # Empty + 0 means no `cd` has been seen and the session's own cwd governs.
411
+ follow_cwd=""
412
+ follow_cwd_unknown=0
413
+ follow_normalized=""
409
414
  follow_paths=$'\n'
410
415
  follow_names=""
411
416
  follow_count=0
@@ -420,6 +425,10 @@ see is the thing it exists to stop.
420
425
 
421
426
  - Name the script by a LITERAL path that exists and is readable — \`bash
422
427
  ./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
428
+ - A leading \`cd\` the guard cannot read literally — \`cd \"\$DIR\" && bash x.sh\` —
429
+ leaves it unable to say WHICH copy of \`x.sh\` would run, and a same-named file
430
+ in another directory is a different file. Spell the directory out, or give the
431
+ script its full path.
423
432
  - A file past the inspection cap is refused rather than half-scanned, because a
424
433
  truncated scan reports a confident ALLOW about text it never read. Split it,
425
434
  or run it manually outside the agent.
@@ -550,15 +559,77 @@ follow_split() {
550
559
  '
551
560
  }
552
561
 
553
- # Record a literal `cd` target as an additional base for resolving a relative
554
- # executed path, so `cd sub && ./run.sh` resolves the way the shell will.
555
- follow_add_base() {
556
- local d="$1"
562
+ # Collapse `.`, `..` and empty segments so the path a verdict NAMES is the one a
563
+ # reader recognises. Purely lexical, and it cannot move a verdict: every
564
+ # component has already been proven to exist by the `-d` test that follows, and
565
+ # the `-f`/`-r` tests are applied to the collapsed path either way.
566
+ follow_normalize() {
567
+ local p="$1" out="" seg
568
+ local IFS=/
569
+ set -f
570
+ # The word split is the point: `$p` is being addressed one path segment at a
571
+ # time, and the glob guard above keeps a literal `*` from expanding.
572
+ # shellcheck disable=SC2086
573
+ for seg in $p; do
574
+ case "$seg" in
575
+ "" | .) ;;
576
+ ..) out="${out%/*}" ;;
577
+ *) out="$out/$seg" ;;
578
+ esac
579
+ done
580
+ set +f
581
+ follow_normalized="${out:-/}"
582
+ }
583
+
584
+ # Track the working directory a literal `cd` establishes, so a relative executed
585
+ # path resolves against the directory the command will actually have.
586
+ #
587
+ # This REPLACES the session's own cwd rather than adding to it. It used to be an
588
+ # extra base appended AFTER `$PWD`, so `$PWD` always won and
589
+ # `cd <other-checkout> && bash scripts/x.sh` was classified against the session
590
+ # checkout's same-named copy. That is not a resolution failure — it succeeds,
591
+ # against a real and readable file, and the verdict is reported with full
592
+ # confidence about text nobody was about to run (CodySwannGT/lisa#3933). Two
593
+ # checkouts of one repository differ by exactly the work in flight, which is the
594
+ # entire point of the second one, so the copy that would RUN is the only copy
595
+ # whose verdict means anything. Wrong in both directions, and the expensive
596
+ # direction is silent: the dangerous line sits in the copy that would run and
597
+ # the guard reads the pristine one.
598
+ #
599
+ # A target this hook cannot turn into a literal directory marks the cwd UNKNOWN
600
+ # instead of leaving a stale one in place. `follow_locate` then refuses a
601
+ # relative token rather than guessing, which is the honest-refusal doctrine the
602
+ # rest of this block already follows: a confident scan of the wrong file is
603
+ # strictly worse than saying it could not tell.
604
+ follow_cd() {
605
+ local d="$1" home="${HOME:-}"
606
+ # The tilde arms are LITERAL text — the unexpanded spelling the hook was
607
+ # handed — for the reason follow_locate's arms document.
608
+ # shellcheck disable=SC2088
609
+ case "$d" in
610
+ '~') d="$home" ;;
611
+ '~/'*) d="$home/${d#'~/'}" ;;
612
+ esac
613
+ case "$d" in
614
+ "" | -* | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*)
615
+ follow_cwd=""
616
+ follow_cwd_unknown=1
617
+ return 0
618
+ ;;
619
+ esac
557
620
  case "$d" in
558
- "" | -* | *'$'* | *'*'* | *'?'*) return 0 ;;
621
+ /*) ;;
622
+ *) d="${follow_cwd:-$PWD}/$d" ;;
559
623
  esac
560
- [ -d "$d" ] || return 0
561
- follow_bases="$follow_bases"$'\n'"$d"
624
+ follow_normalize "$d"
625
+ d="$follow_normalized"
626
+ if [ -d "$d" ]; then
627
+ follow_cwd="$d"
628
+ follow_cwd_unknown=0
629
+ else
630
+ follow_cwd=""
631
+ follow_cwd_unknown=1
632
+ fi
562
633
  }
563
634
 
564
635
  # Resolve one executed-file operand to a readable regular file, or fail.
@@ -571,6 +642,7 @@ follow_add_base() {
571
642
  # this hook cannot evaluate.
572
643
  follow_locate() {
573
644
  local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
645
+ local cwd="${follow_cwd:-$PWD}"
574
646
  follow_located=""
575
647
  tmp="${tmp%/}"
576
648
  [ -n "$tmp" ] || tmp="/tmp"
@@ -584,8 +656,14 @@ follow_locate() {
584
656
  '${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
585
657
  '$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
586
658
  '${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
587
- '$PWD' | '$PWD'/*) token="$PWD${token#'$PWD'}" ;;
588
- '${PWD}' | '${PWD}'/*) token="$PWD${token#'${PWD}'}" ;;
659
+ '$PWD' | '$PWD'/*)
660
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
661
+ token="$cwd${token#'$PWD'}"
662
+ ;;
663
+ '${PWD}' | '${PWD}'/*)
664
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
665
+ token="$cwd${token#'${PWD}'}"
666
+ ;;
589
667
  '$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
590
668
  '${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
591
669
  esac
@@ -597,13 +675,26 @@ follow_locate() {
597
675
  return 0
598
676
  ;;
599
677
  esac
678
+ # Past this point the token is RELATIVE, so which directory it is resolved
679
+ # from decides which file the verdict is about.
680
+ if [ "$follow_cwd_unknown" -eq 1 ]; then
681
+ return 1
682
+ fi
683
+ if [ -n "$follow_cwd" ]; then
684
+ # A literal `cd` names the directory the shell will be in. It is the only
685
+ # candidate — falling back to `$PWD` here is what made the guard report
686
+ # confidently about a same-named file in another checkout.
687
+ [ -f "$follow_cwd/$token" ] && [ -r "$follow_cwd/$token" ] || return 1
688
+ follow_located="$follow_cwd/$token"
689
+ return 0
690
+ fi
600
691
  while IFS= read -r base; do
601
692
  [ -n "$base" ] || continue
602
693
  if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
603
694
  follow_located="$base/$token"
604
695
  return 0
605
696
  fi
606
- done <<<"$PWD"$'\n'"$proj$follow_bases"
697
+ done <<<"$PWD"$'\n'"$proj"
607
698
  return 1
608
699
  }
609
700
 
@@ -636,7 +727,15 @@ follow_take() {
636
727
  follow_names="$follow_names $path"
637
728
  followed_text="$followed_text"$'\n'"$content"
638
729
  if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
730
+ # A `cd` inside the followed script governs what THAT script goes on to
731
+ # execute, and nothing after it in the command that ran the script — the
732
+ # child inherits this directory but cannot change the parent's. Saving and
733
+ # restoring is what keeps a nested `cd` from silently re-pointing the rest
734
+ # of the outer walk.
735
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
639
736
  follow_scan "$content" "$((depth + 1))"
737
+ follow_cwd="$saved_cwd"
738
+ follow_cwd_unknown="$saved_unknown"
640
739
  fi
641
740
  }
642
741
 
@@ -861,7 +960,11 @@ follow_scan() {
861
960
  cd)
862
961
  if [ $((i + 1)) -lt "$n" ]; then
863
962
  follow_unwrap "${toks[i + 1]}"
864
- follow_add_base "$follow_unwrapped"
963
+ follow_cd "$follow_unwrapped"
964
+ else
965
+ # A bare `cd` is `cd "$HOME"`, and leaving the previous directory in
966
+ # place would resolve later tokens from a directory the shell left.
967
+ follow_cd "${HOME:-}"
865
968
  fi
866
969
  cmd_pos=0
867
970
  i=$((i + 1))
@@ -405,7 +405,12 @@ readonly FOLLOW_MAX_TOTAL_BYTES=524288
405
405
  readonly FOLLOW_MAX_FILES=8
406
406
  readonly FOLLOW_MAX_DEPTH=3
407
407
 
408
- follow_bases=""
408
+ # Effective working directory established by a literal `cd` in the command, and
409
+ # the flag that says a `cd` happened whose target could NOT be read literally.
410
+ # Empty + 0 means no `cd` has been seen and the session's own cwd governs.
411
+ follow_cwd=""
412
+ follow_cwd_unknown=0
413
+ follow_normalized=""
409
414
  follow_paths=$'\n'
410
415
  follow_names=""
411
416
  follow_count=0
@@ -420,6 +425,10 @@ see is the thing it exists to stop.
420
425
 
421
426
  - Name the script by a LITERAL path that exists and is readable — \`bash
422
427
  ./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
428
+ - A leading \`cd\` the guard cannot read literally — \`cd \"\$DIR\" && bash x.sh\` —
429
+ leaves it unable to say WHICH copy of \`x.sh\` would run, and a same-named file
430
+ in another directory is a different file. Spell the directory out, or give the
431
+ script its full path.
423
432
  - A file past the inspection cap is refused rather than half-scanned, because a
424
433
  truncated scan reports a confident ALLOW about text it never read. Split it,
425
434
  or run it manually outside the agent.
@@ -550,15 +559,77 @@ follow_split() {
550
559
  '
551
560
  }
552
561
 
553
- # Record a literal `cd` target as an additional base for resolving a relative
554
- # executed path, so `cd sub && ./run.sh` resolves the way the shell will.
555
- follow_add_base() {
556
- local d="$1"
562
+ # Collapse `.`, `..` and empty segments so the path a verdict NAMES is the one a
563
+ # reader recognises. Purely lexical, and it cannot move a verdict: every
564
+ # component has already been proven to exist by the `-d` test that follows, and
565
+ # the `-f`/`-r` tests are applied to the collapsed path either way.
566
+ follow_normalize() {
567
+ local p="$1" out="" seg
568
+ local IFS=/
569
+ set -f
570
+ # The word split is the point: `$p` is being addressed one path segment at a
571
+ # time, and the glob guard above keeps a literal `*` from expanding.
572
+ # shellcheck disable=SC2086
573
+ for seg in $p; do
574
+ case "$seg" in
575
+ "" | .) ;;
576
+ ..) out="${out%/*}" ;;
577
+ *) out="$out/$seg" ;;
578
+ esac
579
+ done
580
+ set +f
581
+ follow_normalized="${out:-/}"
582
+ }
583
+
584
+ # Track the working directory a literal `cd` establishes, so a relative executed
585
+ # path resolves against the directory the command will actually have.
586
+ #
587
+ # This REPLACES the session's own cwd rather than adding to it. It used to be an
588
+ # extra base appended AFTER `$PWD`, so `$PWD` always won and
589
+ # `cd <other-checkout> && bash scripts/x.sh` was classified against the session
590
+ # checkout's same-named copy. That is not a resolution failure — it succeeds,
591
+ # against a real and readable file, and the verdict is reported with full
592
+ # confidence about text nobody was about to run (CodySwannGT/lisa#3933). Two
593
+ # checkouts of one repository differ by exactly the work in flight, which is the
594
+ # entire point of the second one, so the copy that would RUN is the only copy
595
+ # whose verdict means anything. Wrong in both directions, and the expensive
596
+ # direction is silent: the dangerous line sits in the copy that would run and
597
+ # the guard reads the pristine one.
598
+ #
599
+ # A target this hook cannot turn into a literal directory marks the cwd UNKNOWN
600
+ # instead of leaving a stale one in place. `follow_locate` then refuses a
601
+ # relative token rather than guessing, which is the honest-refusal doctrine the
602
+ # rest of this block already follows: a confident scan of the wrong file is
603
+ # strictly worse than saying it could not tell.
604
+ follow_cd() {
605
+ local d="$1" home="${HOME:-}"
606
+ # The tilde arms are LITERAL text — the unexpanded spelling the hook was
607
+ # handed — for the reason follow_locate's arms document.
608
+ # shellcheck disable=SC2088
609
+ case "$d" in
610
+ '~') d="$home" ;;
611
+ '~/'*) d="$home/${d#'~/'}" ;;
612
+ esac
613
+ case "$d" in
614
+ "" | -* | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*)
615
+ follow_cwd=""
616
+ follow_cwd_unknown=1
617
+ return 0
618
+ ;;
619
+ esac
557
620
  case "$d" in
558
- "" | -* | *'$'* | *'*'* | *'?'*) return 0 ;;
621
+ /*) ;;
622
+ *) d="${follow_cwd:-$PWD}/$d" ;;
559
623
  esac
560
- [ -d "$d" ] || return 0
561
- follow_bases="$follow_bases"$'\n'"$d"
624
+ follow_normalize "$d"
625
+ d="$follow_normalized"
626
+ if [ -d "$d" ]; then
627
+ follow_cwd="$d"
628
+ follow_cwd_unknown=0
629
+ else
630
+ follow_cwd=""
631
+ follow_cwd_unknown=1
632
+ fi
562
633
  }
563
634
 
564
635
  # Resolve one executed-file operand to a readable regular file, or fail.
@@ -571,6 +642,7 @@ follow_add_base() {
571
642
  # this hook cannot evaluate.
572
643
  follow_locate() {
573
644
  local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
645
+ local cwd="${follow_cwd:-$PWD}"
574
646
  follow_located=""
575
647
  tmp="${tmp%/}"
576
648
  [ -n "$tmp" ] || tmp="/tmp"
@@ -584,8 +656,14 @@ follow_locate() {
584
656
  '${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
585
657
  '$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
586
658
  '${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
587
- '$PWD' | '$PWD'/*) token="$PWD${token#'$PWD'}" ;;
588
- '${PWD}' | '${PWD}'/*) token="$PWD${token#'${PWD}'}" ;;
659
+ '$PWD' | '$PWD'/*)
660
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
661
+ token="$cwd${token#'$PWD'}"
662
+ ;;
663
+ '${PWD}' | '${PWD}'/*)
664
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
665
+ token="$cwd${token#'${PWD}'}"
666
+ ;;
589
667
  '$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
590
668
  '${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
591
669
  esac
@@ -597,13 +675,26 @@ follow_locate() {
597
675
  return 0
598
676
  ;;
599
677
  esac
678
+ # Past this point the token is RELATIVE, so which directory it is resolved
679
+ # from decides which file the verdict is about.
680
+ if [ "$follow_cwd_unknown" -eq 1 ]; then
681
+ return 1
682
+ fi
683
+ if [ -n "$follow_cwd" ]; then
684
+ # A literal `cd` names the directory the shell will be in. It is the only
685
+ # candidate — falling back to `$PWD` here is what made the guard report
686
+ # confidently about a same-named file in another checkout.
687
+ [ -f "$follow_cwd/$token" ] && [ -r "$follow_cwd/$token" ] || return 1
688
+ follow_located="$follow_cwd/$token"
689
+ return 0
690
+ fi
600
691
  while IFS= read -r base; do
601
692
  [ -n "$base" ] || continue
602
693
  if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
603
694
  follow_located="$base/$token"
604
695
  return 0
605
696
  fi
606
- done <<<"$PWD"$'\n'"$proj$follow_bases"
697
+ done <<<"$PWD"$'\n'"$proj"
607
698
  return 1
608
699
  }
609
700
 
@@ -636,7 +727,15 @@ follow_take() {
636
727
  follow_names="$follow_names $path"
637
728
  followed_text="$followed_text"$'\n'"$content"
638
729
  if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
730
+ # A `cd` inside the followed script governs what THAT script goes on to
731
+ # execute, and nothing after it in the command that ran the script — the
732
+ # child inherits this directory but cannot change the parent's. Saving and
733
+ # restoring is what keeps a nested `cd` from silently re-pointing the rest
734
+ # of the outer walk.
735
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
639
736
  follow_scan "$content" "$((depth + 1))"
737
+ follow_cwd="$saved_cwd"
738
+ follow_cwd_unknown="$saved_unknown"
640
739
  fi
641
740
  }
642
741
 
@@ -861,7 +960,11 @@ follow_scan() {
861
960
  cd)
862
961
  if [ $((i + 1)) -lt "$n" ]; then
863
962
  follow_unwrap "${toks[i + 1]}"
864
- follow_add_base "$follow_unwrapped"
963
+ follow_cd "$follow_unwrapped"
964
+ else
965
+ # A bare `cd` is `cd "$HOME"`, and leaving the previous directory in
966
+ # place would resolve later tokens from a directory the shell left.
967
+ follow_cd "${HOME:-}"
865
968
  fi
866
969
  cmd_pos=0
867
970
  i=$((i + 1))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"