@codyswann/lisa 4.39.0 → 4.40.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 (88) hide show
  1. package/all/copy-overwrite/scripts/lisa-cross-worktree-guard.mjs +333 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +17 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +14 -0
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +28 -0
  5. package/dist/codex/scripts/block-no-verify.sh +21 -0
  6. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.js +22 -0
  8. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  9. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +19 -13
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +14 -0
  14. package/dist/opencode/plugin-templates/block-no-verify.sh +28 -0
  15. package/npm-package/create-only/.github/workflows/publish-to-npm.yml +48 -19
  16. package/package.json +4 -4
  17. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa/hooks/block-direct-issue-create.sh +17 -0
  20. package/plugins/lisa/hooks/block-managed-file-edits.sh +14 -0
  21. package/plugins/lisa/hooks/block-no-verify.agy.sh +21 -0
  22. package/plugins/lisa/hooks/block-no-verify.sh +28 -0
  23. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +17 -0
  24. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +14 -0
  25. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +21 -0
  26. package/plugins/lisa-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +17 -0
  34. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +14 -0
  35. package/plugins/lisa-copilot/hooks/block-no-verify.sh +28 -0
  36. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +17 -0
  38. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +14 -0
  39. package/plugins/lisa-cursor/hooks/block-no-verify.sh +28 -0
  40. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-agy/plugin.json +1 -1
  43. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  63. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-agy/plugin.json +1 -1
  68. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  73. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  78. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/src/base/hooks/block-direct-issue-create.sh +17 -0
  81. package/plugins/src/base/hooks/block-managed-file-edits.sh +14 -0
  82. package/plugins/src/base/hooks/block-no-verify.agy.sh +21 -0
  83. package/plugins/src/base/hooks/block-no-verify.sh +28 -0
  84. package/rails/copy-contents/scripts/lisa-mutation.sh +8 -1
  85. package/scripts/two-channel-couplings.json +1 -1
  86. package/typescript/copy-contents/.husky/pre-commit +40 -0
  87. package/typescript/copy-overwrite/.prettierignore +1 -0
  88. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +20 -14
@@ -719,6 +719,20 @@ def shell_script_operand(args):
719
719
  # not a script file.
720
720
  if not token.startswith("--") and "c" in token[1:]:
721
721
  return None
722
+ # `-n` is noexec: the shell READS and parses the file, then exits
723
+ # without executing a line of it. `bash -n <file>` is the one shape
724
+ # that puts a path at a command position while provably running
725
+ # nothing — the exact seam between reading and running, and this
726
+ # walk got it wrong (CodySwannGT/lisa#3781). Here that meant a
727
+ # syntax check of any script mentioning a managed path was refused
728
+ # as though it were about to write one.
729
+ #
730
+ # `bash <file>` still executes and is still refused. Noexec is the
731
+ # distinction, not the program.
732
+ if token == "--noexec" or (
733
+ not token.startswith("--") and "n" in token[1:]
734
+ ):
735
+ return None
722
736
  continue
723
737
  return token
724
738
  return None
@@ -24,6 +24,13 @@
24
24
  # `source`, `.`, `bash < nv.sh`, and an interpreter a `find -exec` or
25
25
  # `xargs` puts at a command position.
26
26
  #
27
+ # NOT vector 11: `bash -n nv.sh`. `-n` is noexec — the shell reads and parses
28
+ # the file, then exits without running a line — so a syntax check is the one
29
+ # shape that puts a path at a command position while executing nothing. It used
30
+ # to be refused, which made `bash -n` unusable on this file, on every husky
31
+ # hook, and on most of this repository (#3781). The distinction is noexec, not
32
+ # the program: `bash nv.sh` still executes and is still refused.
33
+ #
27
34
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
28
35
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
29
36
  # "differs from mine", and guessing "behind" is how a fork's stronger guard gets
@@ -1031,6 +1038,10 @@ def resolve_script(token):
1031
1038
 
1032
1039
  SHELL_COMMAND_STRING = object()
1033
1040
  SHELL_STDIN = object()
1041
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
1042
+ # executing a line of it. Distinct from the two above because nothing runs at
1043
+ # all — there is no payload to scan and no stdin script to wait for.
1044
+ SHELL_NOEXEC = object()
1034
1045
 
1035
1046
 
1036
1047
  def shell_execution_operand(args):
@@ -1061,12 +1072,25 @@ def shell_execution_operand(args):
1061
1072
  index += 1
1062
1073
  continue
1063
1074
  if token.startswith("--"):
1075
+ if token == "--noexec":
1076
+ return SHELL_NOEXEC
1064
1077
  if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1065
1078
  opaque_option_seen = True
1066
1079
  index += 1
1067
1080
  continue
1068
1081
  if "c" in token[1:]:
1069
1082
  return SHELL_COMMAND_STRING
1083
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
1084
+ # a command position while provably executing nothing, so it is the
1085
+ # exact seam between reading and running — and reach got it wrong in
1086
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
1087
+ # of any file merely DISCUSSING the bypass was refused, which is most of
1088
+ # this repository's own guards and every husky hook.
1089
+ #
1090
+ # `bash <file>` still executes and is still refused. Noexec is the
1091
+ # distinction, not the program: this is not a read-only exemption.
1092
+ if "n" in token[1:]:
1093
+ return SHELL_NOEXEC
1070
1094
  index += 1
1071
1095
  return args[index] if index < len(args) else SHELL_STDIN
1072
1096
 
@@ -1111,6 +1135,10 @@ def executed_scripts(tokens):
1111
1135
  continue
1112
1136
  if operand is SHELL_COMMAND_STRING:
1113
1137
  continue
1138
+ if operand is SHELL_NOEXEC:
1139
+ # Nothing runs, so there is nothing to follow and nothing
1140
+ # to fail closed about.
1141
+ continue
1114
1142
  else:
1115
1143
  continue
1116
1144
  if operand is None:
@@ -48,19 +48,47 @@ jobs:
48
48
  publish:
49
49
  name: Publish to npm
50
50
  runs-on: ubuntu-latest
51
+ # Job-level so every step below reads these from the environment instead of
52
+ # having them substituted into its script. A `${{ }}` expression is
53
+ # substituted by Actions BEFORE the shell parses the script, so an
54
+ # interpolated value is shell SOURCE TEXT rather than an argument - and
55
+ # `tag`, `version` and `package_manager` are all free-text `type: string`
56
+ # inputs that reach `git checkout`, `npm version` and a bare command name.
57
+ # Paired with the shape gates in Validate inputs below (#3717).
58
+ env:
59
+ RELEASE_TAG: ${{ inputs.tag }}
60
+ RELEASE_VERSION: ${{ inputs.version }}
61
+ PACKAGE_MANAGER: ${{ inputs.package_manager }}
51
62
  steps:
52
63
  - name: Validate inputs
53
64
  run: |
54
- if [ -z "${{ inputs.tag }}" ]; then
65
+ if [ -z "$RELEASE_TAG" ]; then
55
66
  echo "::error::Tag input is empty - cannot publish without a version tag"
56
67
  echo "This usually means the version job was skipped (e.g., promotion merge detected)"
57
68
  exit 1
58
69
  fi
59
- if [ -z "${{ inputs.version }}" ]; then
70
+ if [ -z "$RELEASE_VERSION" ]; then
60
71
  echo "::error::Version input is empty - cannot publish without a version"
61
72
  exit 1
62
73
  fi
63
- echo "✅ Inputs validated: tag=${{ inputs.tag }}, version=${{ inputs.version }}"
74
+ # Shape gates, so a value carrying shell metacharacters is refused
75
+ # before it reaches `git checkout`, `npm version`, or a command name.
76
+ if ! printf '%s' "$RELEASE_TAG" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
77
+ echo "::error::Tag input is not a valid version tag"
78
+ exit 1
79
+ fi
80
+ if ! printf '%s' "$RELEASE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
81
+ echo "::error::Version input is not a valid semantic version"
82
+ exit 1
83
+ fi
84
+ case "$PACKAGE_MANAGER" in
85
+ npm|yarn|pnpm|bun) ;;
86
+ *)
87
+ echo "::error::Unsupported package manager: must be npm, yarn, pnpm or bun"
88
+ exit 1
89
+ ;;
90
+ esac
91
+ echo "✅ Inputs validated: tag=$RELEASE_TAG, version=$RELEASE_VERSION"
64
92
 
65
93
  - name: Checkout
66
94
  uses: actions/checkout@v6
@@ -69,12 +97,12 @@ jobs:
69
97
 
70
98
  - name: Wait for tag to be available
71
99
  run: |
72
- echo "Waiting for tag ${{ inputs.tag }} to be available..."
100
+ echo "Waiting for tag $RELEASE_TAG to be available..."
73
101
  for i in {1..30}; do
74
102
  git fetch --tags
75
- if git tag --list | grep -q "^${{ inputs.tag }}$"; then
76
- echo "Tag ${{ inputs.tag }} is now available"
77
- git checkout ${{ inputs.tag }}
103
+ if git tag --list | grep -qx "$RELEASE_TAG"; then
104
+ echo "Tag $RELEASE_TAG is now available"
105
+ git checkout "$RELEASE_TAG"
78
106
  break
79
107
  fi
80
108
  echo "Attempt $i/30: Tag not found yet, waiting 10 seconds..."
@@ -82,8 +110,8 @@ jobs:
82
110
  done
83
111
 
84
112
  # Final check
85
- if [ "$(git describe --exact-match --tags 2>/dev/null)" != "${{ inputs.tag }}" ]; then
86
- echo "Error: Tag ${{ inputs.tag }} is still not available after 5 minutes"
113
+ if [ "$(git describe --exact-match --tags 2>/dev/null)" != "$RELEASE_TAG" ]; then
114
+ echo "Error: Tag $RELEASE_TAG is still not available after 5 minutes"
87
115
  exit 1
88
116
  fi
89
117
 
@@ -107,24 +135,25 @@ jobs:
107
135
 
108
136
  - name: Install dependencies
109
137
  run: |
110
- if [ "${{ inputs.package_manager }}" = "npm" ]; then
138
+ if [ "$PACKAGE_MANAGER" = "npm" ]; then
111
139
  npm ci
112
- elif [ "${{ inputs.package_manager }}" = "yarn" ]; then
140
+ elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
113
141
  yarn install --frozen-lockfile
114
- elif [ "${{ inputs.package_manager }}" = "bun" ]; then
142
+ elif [ "$PACKAGE_MANAGER" = "bun" ]; then
115
143
  bun install --frozen-lockfile
116
144
  fi
117
145
 
118
146
  - name: Build package
119
- run: ${{ inputs.package_manager }} run build
147
+ run: |
148
+ "$PACKAGE_MANAGER" run build
120
149
 
121
150
  - name: Update package version
122
151
  run: |
123
152
  # Update package.json to match the release version
124
- VERSION="${{ inputs.version }}"
153
+ VERSION="$RELEASE_VERSION"
125
154
  if [ -n "$VERSION" ]; then
126
155
  echo "Updating package.json to version $VERSION"
127
- npm version $VERSION --no-git-tag-version --allow-same-version
156
+ npm version "$VERSION" --no-git-tag-version --allow-same-version
128
157
  fi
129
158
 
130
159
  - name: Publish to npm with OIDC
@@ -136,22 +165,22 @@ jobs:
136
165
  if: ${{ hashFiles('eslint-plugin-code-organization/package.json') != '' }}
137
166
  run: |
138
167
  cd eslint-plugin-code-organization
139
- npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
168
+ npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
140
169
  npm publish --access public --provenance
141
170
 
142
171
  - name: Publish @codyswann/eslint-plugin-component-structure
143
172
  if: ${{ hashFiles('eslint-plugin-component-structure/package.json') != '' }}
144
173
  run: |
145
174
  cd eslint-plugin-component-structure
146
- npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
175
+ npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
147
176
  npm publish --access public --provenance
148
177
 
149
178
  - name: Publish @codyswann/eslint-plugin-ui-standards
150
179
  if: ${{ hashFiles('eslint-plugin-ui-standards/package.json') != '' }}
151
180
  run: |
152
181
  cd eslint-plugin-ui-standards
153
- npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
182
+ npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
154
183
  npm publish --access public --provenance
155
184
 
156
185
  - name: Notify on success
157
- run: echo "Successfully published version ${{ inputs.version }} to npm with provenance"
186
+ run: echo "Successfully published version $RELEASE_VERSION to npm with provenance"
package/package.json CHANGED
@@ -171,7 +171,7 @@
171
171
  "zod-validation-error": "^4.0.0"
172
172
  },
173
173
  "name": "@codyswann/lisa",
174
- "version": "4.39.0",
174
+ "version": "4.40.1",
175
175
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
176
176
  "main": "dist/index.js",
177
177
  "exports": {
@@ -319,7 +319,7 @@
319
319
  "test": "tests"
320
320
  },
321
321
  "types": "./dist/index.d.ts",
322
- "lisaReleaseCommit": "e7d63c1fa3c6f7f706b013daa00901b4701ef8d9",
323
- "gitHead": "e7d63c1fa3c6f7f706b013daa00901b4701ef8d9",
324
- "lisaReleaseTag": "v4.39.0"
322
+ "lisaReleaseCommit": "ec7fc892e6ac49b4162920b7aecc37157877d4eb",
323
+ "gitHead": "ec7fc892e6ac49b4162920b7aecc37157877d4eb",
324
+ "lisaReleaseTag": "v4.40.1"
325
325
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.39.0",
3
+ "version": "4.40.1",
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.39.0",
3
+ "version": "4.40.1",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1708,6 +1708,23 @@ def executed_operand(argv):
1708
1708
  # ordinary shell options and do not consume the script path.
1709
1709
  if not head.startswith("--") and "c" in head[1:]:
1710
1710
  return None
1711
+ # `-n` is noexec: the shell READS and parses the file and then
1712
+ # exits without running a line of it. `bash -n <file>` is the
1713
+ # one shape that puts a path at a command position while
1714
+ # provably executing nothing, so it is the exact seam between
1715
+ # reading and running — and this walk got it wrong in BOTH
1716
+ # directions (CodySwannGT/lisa#3781). A creation-shaped file
1717
+ # earned a false refusal; a file carrying a human-gate marker
1718
+ # earned a false ALLOW, adjudicating as "declared" a command
1719
+ # that filed nothing. The silent direction is the worse one.
1720
+ #
1721
+ # Not a read-only-command exemption: `bash <file>` still runs
1722
+ # the file and is still refused. The distinction is noexec, not
1723
+ # the program.
1724
+ if head == "--noexec" or (
1725
+ not head.startswith("--") and "n" in head[1:]
1726
+ ):
1727
+ return None
1711
1728
  elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1712
1729
  return None
1713
1730
  takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
@@ -719,6 +719,20 @@ def shell_script_operand(args):
719
719
  # not a script file.
720
720
  if not token.startswith("--") and "c" in token[1:]:
721
721
  return None
722
+ # `-n` is noexec: the shell READS and parses the file, then exits
723
+ # without executing a line of it. `bash -n <file>` is the one shape
724
+ # that puts a path at a command position while provably running
725
+ # nothing — the exact seam between reading and running, and this
726
+ # walk got it wrong (CodySwannGT/lisa#3781). Here that meant a
727
+ # syntax check of any script mentioning a managed path was refused
728
+ # as though it were about to write one.
729
+ #
730
+ # `bash <file>` still executes and is still refused. Noexec is the
731
+ # distinction, not the program.
732
+ if token == "--noexec" or (
733
+ not token.startswith("--") and "n" in token[1:]
734
+ ):
735
+ return None
722
736
  continue
723
737
  return token
724
738
  return None
@@ -961,6 +961,10 @@ def resolve_script(token):
961
961
 
962
962
  SHELL_COMMAND_STRING = object()
963
963
  SHELL_STDIN = object()
964
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
965
+ # executing a line of it. Distinct from the two above because nothing runs at
966
+ # all — there is no payload to scan and no stdin script to wait for.
967
+ SHELL_NOEXEC = object()
964
968
 
965
969
 
966
970
  def shell_execution_operand(args):
@@ -991,12 +995,25 @@ def shell_execution_operand(args):
991
995
  index += 1
992
996
  continue
993
997
  if token.startswith("--"):
998
+ if token == "--noexec":
999
+ return SHELL_NOEXEC
994
1000
  if token not in SHELL_LONG_OPTIONS_NO_VALUE:
995
1001
  opaque_option_seen = True
996
1002
  index += 1
997
1003
  continue
998
1004
  if "c" in token[1:]:
999
1005
  return SHELL_COMMAND_STRING
1006
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
1007
+ # a command position while provably executing nothing, so it is the
1008
+ # exact seam between reading and running — and reach got it wrong in
1009
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
1010
+ # of any file merely DISCUSSING the bypass was refused, which is most of
1011
+ # this repository's own guards and every husky hook.
1012
+ #
1013
+ # `bash <file>` still executes and is still refused. Noexec is the
1014
+ # distinction, not the program: this is not a read-only exemption.
1015
+ if "n" in token[1:]:
1016
+ return SHELL_NOEXEC
1000
1017
  index += 1
1001
1018
  return args[index] if index < len(args) else SHELL_STDIN
1002
1019
 
@@ -1041,6 +1058,10 @@ def executed_scripts(tokens):
1041
1058
  continue
1042
1059
  if operand is SHELL_COMMAND_STRING:
1043
1060
  continue
1061
+ if operand is SHELL_NOEXEC:
1062
+ # Nothing runs, so there is nothing to follow and nothing
1063
+ # to fail closed about.
1064
+ continue
1044
1065
  else:
1045
1066
  continue
1046
1067
  if operand is None:
@@ -24,6 +24,13 @@
24
24
  # `source`, `.`, `bash < nv.sh`, and an interpreter a `find -exec` or
25
25
  # `xargs` puts at a command position.
26
26
  #
27
+ # NOT vector 11: `bash -n nv.sh`. `-n` is noexec — the shell reads and parses
28
+ # the file, then exits without running a line — so a syntax check is the one
29
+ # shape that puts a path at a command position while executing nothing. It used
30
+ # to be refused, which made `bash -n` unusable on this file, on every husky
31
+ # hook, and on most of this repository (#3781). The distinction is noexec, not
32
+ # the program: `bash nv.sh` still executes and is still refused.
33
+ #
27
34
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
28
35
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
29
36
  # "differs from mine", and guessing "behind" is how a fork's stronger guard gets
@@ -1031,6 +1038,10 @@ def resolve_script(token):
1031
1038
 
1032
1039
  SHELL_COMMAND_STRING = object()
1033
1040
  SHELL_STDIN = object()
1041
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
1042
+ # executing a line of it. Distinct from the two above because nothing runs at
1043
+ # all — there is no payload to scan and no stdin script to wait for.
1044
+ SHELL_NOEXEC = object()
1034
1045
 
1035
1046
 
1036
1047
  def shell_execution_operand(args):
@@ -1061,12 +1072,25 @@ def shell_execution_operand(args):
1061
1072
  index += 1
1062
1073
  continue
1063
1074
  if token.startswith("--"):
1075
+ if token == "--noexec":
1076
+ return SHELL_NOEXEC
1064
1077
  if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1065
1078
  opaque_option_seen = True
1066
1079
  index += 1
1067
1080
  continue
1068
1081
  if "c" in token[1:]:
1069
1082
  return SHELL_COMMAND_STRING
1083
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
1084
+ # a command position while provably executing nothing, so it is the
1085
+ # exact seam between reading and running — and reach got it wrong in
1086
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
1087
+ # of any file merely DISCUSSING the bypass was refused, which is most of
1088
+ # this repository's own guards and every husky hook.
1089
+ #
1090
+ # `bash <file>` still executes and is still refused. Noexec is the
1091
+ # distinction, not the program: this is not a read-only exemption.
1092
+ if "n" in token[1:]:
1093
+ return SHELL_NOEXEC
1070
1094
  index += 1
1071
1095
  return args[index] if index < len(args) else SHELL_STDIN
1072
1096
 
@@ -1111,6 +1135,10 @@ def executed_scripts(tokens):
1111
1135
  continue
1112
1136
  if operand is SHELL_COMMAND_STRING:
1113
1137
  continue
1138
+ if operand is SHELL_NOEXEC:
1139
+ # Nothing runs, so there is nothing to follow and nothing
1140
+ # to fail closed about.
1141
+ continue
1114
1142
  else:
1115
1143
  continue
1116
1144
  if operand is None:
@@ -1708,6 +1708,23 @@ def executed_operand(argv):
1708
1708
  # ordinary shell options and do not consume the script path.
1709
1709
  if not head.startswith("--") and "c" in head[1:]:
1710
1710
  return None
1711
+ # `-n` is noexec: the shell READS and parses the file and then
1712
+ # exits without running a line of it. `bash -n <file>` is the
1713
+ # one shape that puts a path at a command position while
1714
+ # provably executing nothing, so it is the exact seam between
1715
+ # reading and running — and this walk got it wrong in BOTH
1716
+ # directions (CodySwannGT/lisa#3781). A creation-shaped file
1717
+ # earned a false refusal; a file carrying a human-gate marker
1718
+ # earned a false ALLOW, adjudicating as "declared" a command
1719
+ # that filed nothing. The silent direction is the worse one.
1720
+ #
1721
+ # Not a read-only-command exemption: `bash <file>` still runs
1722
+ # the file and is still refused. The distinction is noexec, not
1723
+ # the program.
1724
+ if head == "--noexec" or (
1725
+ not head.startswith("--") and "n" in head[1:]
1726
+ ):
1727
+ return None
1711
1728
  elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1712
1729
  return None
1713
1730
  takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
@@ -719,6 +719,20 @@ def shell_script_operand(args):
719
719
  # not a script file.
720
720
  if not token.startswith("--") and "c" in token[1:]:
721
721
  return None
722
+ # `-n` is noexec: the shell READS and parses the file, then exits
723
+ # without executing a line of it. `bash -n <file>` is the one shape
724
+ # that puts a path at a command position while provably running
725
+ # nothing — the exact seam between reading and running, and this
726
+ # walk got it wrong (CodySwannGT/lisa#3781). Here that meant a
727
+ # syntax check of any script mentioning a managed path was refused
728
+ # as though it were about to write one.
729
+ #
730
+ # `bash <file>` still executes and is still refused. Noexec is the
731
+ # distinction, not the program.
732
+ if token == "--noexec" or (
733
+ not token.startswith("--") and "n" in token[1:]
734
+ ):
735
+ return None
722
736
  continue
723
737
  return token
724
738
  return None
@@ -961,6 +961,10 @@ def resolve_script(token):
961
961
 
962
962
  SHELL_COMMAND_STRING = object()
963
963
  SHELL_STDIN = object()
964
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
965
+ # executing a line of it. Distinct from the two above because nothing runs at
966
+ # all — there is no payload to scan and no stdin script to wait for.
967
+ SHELL_NOEXEC = object()
964
968
 
965
969
 
966
970
  def shell_execution_operand(args):
@@ -991,12 +995,25 @@ def shell_execution_operand(args):
991
995
  index += 1
992
996
  continue
993
997
  if token.startswith("--"):
998
+ if token == "--noexec":
999
+ return SHELL_NOEXEC
994
1000
  if token not in SHELL_LONG_OPTIONS_NO_VALUE:
995
1001
  opaque_option_seen = True
996
1002
  index += 1
997
1003
  continue
998
1004
  if "c" in token[1:]:
999
1005
  return SHELL_COMMAND_STRING
1006
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
1007
+ # a command position while provably executing nothing, so it is the
1008
+ # exact seam between reading and running — and reach got it wrong in
1009
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
1010
+ # of any file merely DISCUSSING the bypass was refused, which is most of
1011
+ # this repository's own guards and every husky hook.
1012
+ #
1013
+ # `bash <file>` still executes and is still refused. Noexec is the
1014
+ # distinction, not the program: this is not a read-only exemption.
1015
+ if "n" in token[1:]:
1016
+ return SHELL_NOEXEC
1000
1017
  index += 1
1001
1018
  return args[index] if index < len(args) else SHELL_STDIN
1002
1019
 
@@ -1041,6 +1058,10 @@ def executed_scripts(tokens):
1041
1058
  continue
1042
1059
  if operand is SHELL_COMMAND_STRING:
1043
1060
  continue
1061
+ if operand is SHELL_NOEXEC:
1062
+ # Nothing runs, so there is nothing to follow and nothing
1063
+ # to fail closed about.
1064
+ continue
1044
1065
  else:
1045
1066
  continue
1046
1067
  if operand is None:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.39.0",
3
+ "version": "4.40.1",
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.39.0",
3
+ "version": "4.40.1",
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.39.0",
3
+ "version": "4.40.1",
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.39.0",
3
+ "version": "4.40.1",
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.39.0",
3
+ "version": "4.40.1",
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.39.0",
3
+ "version": "4.40.1",
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",
3
- "version": "4.39.0",
3
+ "version": "4.40.1",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1708,6 +1708,23 @@ def executed_operand(argv):
1708
1708
  # ordinary shell options and do not consume the script path.
1709
1709
  if not head.startswith("--") and "c" in head[1:]:
1710
1710
  return None
1711
+ # `-n` is noexec: the shell READS and parses the file and then
1712
+ # exits without running a line of it. `bash -n <file>` is the
1713
+ # one shape that puts a path at a command position while
1714
+ # provably executing nothing, so it is the exact seam between
1715
+ # reading and running — and this walk got it wrong in BOTH
1716
+ # directions (CodySwannGT/lisa#3781). A creation-shaped file
1717
+ # earned a false refusal; a file carrying a human-gate marker
1718
+ # earned a false ALLOW, adjudicating as "declared" a command
1719
+ # that filed nothing. The silent direction is the worse one.
1720
+ #
1721
+ # Not a read-only-command exemption: `bash <file>` still runs
1722
+ # the file and is still refused. The distinction is noexec, not
1723
+ # the program.
1724
+ if head == "--noexec" or (
1725
+ not head.startswith("--") and "n" in head[1:]
1726
+ ):
1727
+ return None
1711
1728
  elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1712
1729
  return None
1713
1730
  takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
@@ -719,6 +719,20 @@ def shell_script_operand(args):
719
719
  # not a script file.
720
720
  if not token.startswith("--") and "c" in token[1:]:
721
721
  return None
722
+ # `-n` is noexec: the shell READS and parses the file, then exits
723
+ # without executing a line of it. `bash -n <file>` is the one shape
724
+ # that puts a path at a command position while provably running
725
+ # nothing — the exact seam between reading and running, and this
726
+ # walk got it wrong (CodySwannGT/lisa#3781). Here that meant a
727
+ # syntax check of any script mentioning a managed path was refused
728
+ # as though it were about to write one.
729
+ #
730
+ # `bash <file>` still executes and is still refused. Noexec is the
731
+ # distinction, not the program.
732
+ if token == "--noexec" or (
733
+ not token.startswith("--") and "n" in token[1:]
734
+ ):
735
+ return None
722
736
  continue
723
737
  return token
724
738
  return None