@codyswann/lisa 3.70.0 → 3.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +224 -7
  2. package/dist/cli/prompts.d.ts +52 -1
  3. package/dist/cli/prompts.d.ts.map +1 -1
  4. package/dist/cli/prompts.js +57 -2
  5. package/dist/cli/prompts.js.map +1 -1
  6. package/dist/codex/scripts/block-no-verify.sh +190 -3
  7. package/dist/configs/vitest/scratch-global-setup.d.ts +32 -0
  8. package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
  9. package/dist/configs/vitest/scratch-global-setup.js +48 -0
  10. package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  12. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  13. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  14. package/dist/core/lisa.d.ts.map +1 -1
  15. package/dist/core/lisa.js +4 -0
  16. package/dist/core/lisa.js.map +1 -1
  17. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  18. package/dist/core/upstream-evidence-manifest.js +7 -3
  19. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  20. package/dist/opencode/hooks-installer.d.ts.map +1 -1
  21. package/dist/opencode/hooks-installer.js +14 -0
  22. package/dist/opencode/hooks-installer.js.map +1 -1
  23. package/dist/strategies/copy-overwrite.d.ts.map +1 -1
  24. package/dist/strategies/copy-overwrite.js +30 -11
  25. package/dist/strategies/copy-overwrite.js.map +1 -1
  26. package/dist/strategies/strategy.interface.d.ts +15 -0
  27. package/dist/strategies/strategy.interface.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa/hooks/block-no-verify.agy.sh +200 -6
  32. package/plugins/lisa/hooks/block-no-verify.sh +224 -7
  33. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +200 -6
  34. package/plugins/lisa-agy/plugin.json +1 -1
  35. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  38. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-copilot/hooks/block-no-verify.sh +224 -7
  42. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cursor/hooks/block-no-verify.sh +224 -7
  44. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo-agy/plugin.json +1 -1
  47. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  67. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails-agy/plugin.json +1 -1
  72. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  77. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  82. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/src/base/hooks/block-no-verify.agy.sh +200 -6
  85. package/plugins/src/base/hooks/block-no-verify.sh +224 -7
@@ -9,7 +9,9 @@
9
9
  # 2. HUSKY=0 / HUSKY_SKIP_HOOKS=... — disables husky-managed git hooks;
10
10
  # 3. core.hooksPath pointed anywhere but an allowlisted in-repo hooks dir;
11
11
  # 4. --config-env=core.hooksPath=VAR, in both the one- and two-token spellings;
12
- # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config.
12
+ # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
13
+ # 6. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
14
+ # into a short-option cluster such as `-nm "msg"`.
13
15
  #
14
16
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
15
17
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -20,15 +22,31 @@
20
22
  # Add a name here in the same commit that closes a vector. A hardening that
21
23
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
22
24
  # review time instead of a named capability.
23
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key
25
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key, no-verify-short
24
26
  #
25
27
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
26
28
  # commit-message prose while still catching quoted real argv values such as
27
29
  # `git -c "core.hooksPath=/dev/null"`.
28
30
  #
29
- # The short `-n` form is intentionally NOT matched (see block-no-verify.agy.sh):
30
- # grep cannot distinguish a real -n option from -n in commit-message prose or an
31
- # unrelated piped command, and -n is far more common than --no-verify.
31
+ # The short `-n` form used to be excluded here, and the stated reason was that
32
+ # "grep cannot distinguish a real -n option from -n in commit-message prose or
33
+ # an unrelated piped command". That was true of the matcher it was written for.
34
+ # It is not true of this one: the command is TOKENIZED, not grepped, so `-m "fix
35
+ # the -n flag"` is one token holding the message and `grep -n` is a separate
36
+ # command — the two cases the comment called inseparable are separated by the
37
+ # same machinery `--config-env` and `GIT_CONFIG_KEY_<n>` already depend on.
38
+ #
39
+ # A rationale that outlives the implementation it describes is worse than none:
40
+ # it reads as a considered decision and stops the next reader from re-examining
41
+ # it. Measured against real git, `git commit -n`, `-nm msg`, and `-anm msg` all
42
+ # skip pre-commit exactly as completely as `--no-verify`, and `-nm` is the more
43
+ # likely spelling in practice because it reads as an ordinary message flag.
44
+ #
45
+ # What the old comment got right is the part about `-n` being common, and that
46
+ # concern is answered by SCOPE rather than by giving up: `-n` is `--dry-run` for
47
+ # `push`, `--no-stat` for `merge`, and a line count for head/tail/sort/grep, so
48
+ # the match is confined to the argv of a `git commit` invocation and nothing
49
+ # else. See `git_commit_skips_verification` below.
32
50
  set -euo pipefail
33
51
 
34
52
  input="$(cat)"
@@ -165,6 +183,203 @@ def disables_verification(token):
165
183
  and NO_VERIFY.startswith(token)
166
184
  )
167
185
 
186
+
187
+ # `git commit -n` is the short spelling of --no-verify and skips pre-commit and
188
+ # commit-msg identically. Matching it needs one thing the `--no-verify` scan
189
+ # above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
190
+ # merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
191
+ # would refuse ordinary commands all day. Only `git commit` reads it as a hook
192
+ # bypass, so only that invocation's argv is scanned.
193
+ #
194
+ # The tokenizer below is a SECOND pass, deliberately not shared with the one the
195
+ # checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
196
+ # which those checks paper over by stripping the punctuation off each token —
197
+ # fine when the question is "does this token look like a bypass anywhere in the
198
+ # line", fatal when the question is "where does this command end", because
199
+ # `git commit -m x && grep -n foo` would then read as one long git invocation
200
+ # and refuse the grep. `punctuation_chars=True` emits the operators as their own
201
+ # tokens, so the invocation's boundary is a token the scan can stop at.
202
+ COMMAND_SEPARATORS = {
203
+ ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
204
+ }
205
+
206
+ # git's own options that take a SEPARATE value token, which therefore must be
207
+ # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
208
+ # `git -C /repo commit` both reach `commit`.
209
+ GIT_GLOBAL_SEPARATE_VALUE = {
210
+ "-c", "-C", "--config-env", "--git-dir", "--work-tree",
211
+ "--namespace", "--exec-path", "--super-prefix",
212
+ }
213
+
214
+ # `git commit` long options whose value can be a separate token. Listed so the
215
+ # VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
216
+ # must stay allowed. Only genuinely value-taking options belong here: adding a
217
+ # boolean one by mistake would swallow the token after it, and `git commit
218
+ # --amend -n` would go unnoticed.
219
+ COMMIT_LONG_SEPARATE_VALUE = {
220
+ "--message", "--file", "--author", "--date", "--template", "--cleanup",
221
+ "--reuse-message", "--reedit-message", "--fixup", "--squash",
222
+ "--pathspec-from-file", "--trailer",
223
+ }
224
+
225
+ # Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
226
+ # In a cluster the value is whatever follows them in the same token, or the next
227
+ # token when nothing does — which is why `-mn` is the message "n" and not a
228
+ # bypass, and `-nm msg` IS one.
229
+ COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
230
+
231
+ # Short options taking an OPTIONAL value, which git only ever reads attached:
232
+ # `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
233
+ # ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
234
+ COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
235
+
236
+
237
+ def line_boundaries_as_separators(text):
238
+ """Turn newlines into command separators, keeping continuations intact.
239
+
240
+ A newline ends a command exactly as `;` does, but shlex treats it as plain
241
+ whitespace — so `git commit -m x` on one line and `grep -n foo` on the next
242
+ read as ONE invocation and the grep gets refused. That is the same
243
+ false-positive class the short-form match exists to avoid, arriving through
244
+ the boundary rather than through the token.
245
+
246
+ Backslash-newline is joined first, because there the newline is NOT a
247
+ boundary: `git commit \\` + newline + `-nm x` is one command, and turning
248
+ that newline into a separator would hide a real bypass.
249
+
250
+ Args:
251
+ text: The command line, heredoc payloads already stripped.
252
+
253
+ Returns:
254
+ The same command with line breaks spelled as separators.
255
+ """
256
+ joined = text.replace("\r\n", "\n").replace("\\\n", " ")
257
+ return joined.replace("\n", " ; ")
258
+
259
+
260
+ def shell_tokens(text):
261
+ """Tokenize a command with shell operators kept as their own tokens.
262
+
263
+ Args:
264
+ text: The command line, heredoc payloads already stripped.
265
+
266
+ Returns:
267
+ The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
268
+ """
269
+ lexer = shlex.shlex(
270
+ line_boundaries_as_separators(text), posix=True, punctuation_chars=True
271
+ )
272
+ lexer.whitespace_split = True
273
+ # shlex treats `#` as a comment introducer by default and would silently
274
+ # truncate the rest of the line; `shlex.split` disables it, and so must this.
275
+ lexer.commenters = ""
276
+ return list(lexer)
277
+
278
+
279
+ def cluster_skips_verification(cluster):
280
+ """Read a short-option cluster the way git's own parser reads it.
281
+
282
+ Args:
283
+ cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
284
+
285
+ Returns:
286
+ A pair (bypasses, consumes_next_token). `bypasses` is True when a real
287
+ `-n` option is present; `consumes_next_token` is True when the cluster
288
+ ends in a value-taking option whose value is the following token.
289
+ """
290
+ body = cluster[1:]
291
+ for offset, letter in enumerate(body):
292
+ if letter == "n":
293
+ return (True, False)
294
+ if letter in COMMIT_SHORT_REQUIRED_VALUE:
295
+ # Everything after this letter is the value. It is a separate token
296
+ # only when nothing is attached.
297
+ return (False, offset == len(body) - 1)
298
+ if letter in COMMIT_SHORT_OPTIONAL_VALUE:
299
+ return (False, False)
300
+ return (False, False)
301
+
302
+
303
+ def commit_bypasses_verification(argv):
304
+ """Whether a `git commit` invocation's argv carries a real short `-n`.
305
+
306
+ Args:
307
+ argv: Tokens following the `commit` subcommand, to the end of the line.
308
+
309
+ Returns:
310
+ True if git would read one of them as --no-verify.
311
+ """
312
+ index = 0
313
+ while index < len(argv):
314
+ token = argv[index]
315
+ index += 1
316
+ # `--` ends the options; everything after it is a pathspec, and a file
317
+ # legitimately named `-n` is not a bypass.
318
+ if token in COMMAND_SEPARATORS or token == "--":
319
+ return False
320
+ if token.startswith("--"):
321
+ if token in COMMIT_LONG_SEPARATE_VALUE:
322
+ index += 1
323
+ continue
324
+ # A bare `-` is git's stdin placeholder, not an option cluster.
325
+ if not token.startswith("-") or token == "-":
326
+ continue
327
+ bypasses, consumes_next = cluster_skips_verification(token)
328
+ if bypasses:
329
+ return True
330
+ if consumes_next:
331
+ index += 1
332
+ return False
333
+
334
+
335
+ def subcommand_after_git(tokens, start):
336
+ """Find the subcommand a `git` token introduces, and its argv.
337
+
338
+ Args:
339
+ tokens: The full operator-aware token list.
340
+ start: Index just past the `git` token.
341
+
342
+ Returns:
343
+ A pair (subcommand, argv), or None when the invocation names none.
344
+ """
345
+ index = start
346
+ while index < len(tokens):
347
+ token = tokens[index]
348
+ if token in COMMAND_SEPARATORS:
349
+ return None
350
+ if not token.startswith("-"):
351
+ return (token, tokens[index + 1:])
352
+ index += 2 if token in GIT_GLOBAL_SEPARATE_VALUE else 1
353
+ return None
354
+
355
+
356
+ def git_commit_skips_verification(text):
357
+ """Whether the command runs `git commit` with the short `-n` bypass.
358
+
359
+ Args:
360
+ text: The command line, heredoc payloads already stripped.
361
+
362
+ Returns:
363
+ True if any `git commit` invocation on the line skips verification.
364
+ """
365
+ try:
366
+ scoped_tokens = shell_tokens(text)
367
+ except ValueError:
368
+ return False
369
+ for index, token in enumerate(scoped_tokens):
370
+ # `/usr/bin/git` and a bare `git` are the same program; an env prefix
371
+ # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
372
+ if token != "git" and not token.endswith("/git"):
373
+ continue
374
+ found = subcommand_after_git(scoped_tokens, index + 1)
375
+ if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
376
+ return True
377
+ return False
378
+
379
+
380
+ if git_commit_skips_verification(strip_heredocs(command)):
381
+ sys.exit(1)
382
+
168
383
  for i, token in enumerate(normalized_tokens):
169
384
  if disables_verification(token):
170
385
  sys.exit(1)
@@ -235,8 +450,10 @@ sys.exit(0)
235
450
  PY
236
451
  then
237
452
  cat >&2 <<'EOF'
238
- Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify, HUSKY=0,
239
- or core.hooksPath disabling). Fix the underlying issue (security audit, lint,
453
+ Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify or its
454
+ short form -n, HUSKY=0, or core.hooksPath disabling). `git commit -n` and a
455
+ cluster such as `-nm "msg"` skip pre-commit exactly as completely as the long
456
+ flag. Fix the underlying issue (security audit, lint,
240
457
  typecheck, tests, formatting) instead. If a fix is genuinely impossible, ask the
241
458
  user to make the risk-acceptance decision and add a specific documented ignore;
242
459
  never bypass the hook.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -9,7 +9,9 @@
9
9
  # 2. HUSKY=0 / HUSKY_SKIP_HOOKS=... — disables husky-managed git hooks;
10
10
  # 3. core.hooksPath pointed anywhere but an allowlisted in-repo hooks dir;
11
11
  # 4. --config-env=core.hooksPath=VAR, in both the one- and two-token spellings;
12
- # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config.
12
+ # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
13
+ # 6. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
14
+ # into a short-option cluster such as `-nm "msg"`.
13
15
  #
14
16
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
15
17
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -20,15 +22,31 @@
20
22
  # Add a name here in the same commit that closes a vector. A hardening that
21
23
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
22
24
  # review time instead of a named capability.
23
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key
25
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key, no-verify-short
24
26
  #
25
27
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
26
28
  # commit-message prose while still catching quoted real argv values such as
27
29
  # `git -c "core.hooksPath=/dev/null"`.
28
30
  #
29
- # The short `-n` form is intentionally NOT matched (see block-no-verify.agy.sh):
30
- # grep cannot distinguish a real -n option from -n in commit-message prose or an
31
- # unrelated piped command, and -n is far more common than --no-verify.
31
+ # The short `-n` form used to be excluded here, and the stated reason was that
32
+ # "grep cannot distinguish a real -n option from -n in commit-message prose or
33
+ # an unrelated piped command". That was true of the matcher it was written for.
34
+ # It is not true of this one: the command is TOKENIZED, not grepped, so `-m "fix
35
+ # the -n flag"` is one token holding the message and `grep -n` is a separate
36
+ # command — the two cases the comment called inseparable are separated by the
37
+ # same machinery `--config-env` and `GIT_CONFIG_KEY_<n>` already depend on.
38
+ #
39
+ # A rationale that outlives the implementation it describes is worse than none:
40
+ # it reads as a considered decision and stops the next reader from re-examining
41
+ # it. Measured against real git, `git commit -n`, `-nm msg`, and `-anm msg` all
42
+ # skip pre-commit exactly as completely as `--no-verify`, and `-nm` is the more
43
+ # likely spelling in practice because it reads as an ordinary message flag.
44
+ #
45
+ # What the old comment got right is the part about `-n` being common, and that
46
+ # concern is answered by SCOPE rather than by giving up: `-n` is `--dry-run` for
47
+ # `push`, `--no-stat` for `merge`, and a line count for head/tail/sort/grep, so
48
+ # the match is confined to the argv of a `git commit` invocation and nothing
49
+ # else. See `git_commit_skips_verification` below.
32
50
  set -euo pipefail
33
51
 
34
52
  input="$(cat)"
@@ -165,6 +183,203 @@ def disables_verification(token):
165
183
  and NO_VERIFY.startswith(token)
166
184
  )
167
185
 
186
+
187
+ # `git commit -n` is the short spelling of --no-verify and skips pre-commit and
188
+ # commit-msg identically. Matching it needs one thing the `--no-verify` scan
189
+ # above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
190
+ # merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
191
+ # would refuse ordinary commands all day. Only `git commit` reads it as a hook
192
+ # bypass, so only that invocation's argv is scanned.
193
+ #
194
+ # The tokenizer below is a SECOND pass, deliberately not shared with the one the
195
+ # checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
196
+ # which those checks paper over by stripping the punctuation off each token —
197
+ # fine when the question is "does this token look like a bypass anywhere in the
198
+ # line", fatal when the question is "where does this command end", because
199
+ # `git commit -m x && grep -n foo` would then read as one long git invocation
200
+ # and refuse the grep. `punctuation_chars=True` emits the operators as their own
201
+ # tokens, so the invocation's boundary is a token the scan can stop at.
202
+ COMMAND_SEPARATORS = {
203
+ ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
204
+ }
205
+
206
+ # git's own options that take a SEPARATE value token, which therefore must be
207
+ # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
208
+ # `git -C /repo commit` both reach `commit`.
209
+ GIT_GLOBAL_SEPARATE_VALUE = {
210
+ "-c", "-C", "--config-env", "--git-dir", "--work-tree",
211
+ "--namespace", "--exec-path", "--super-prefix",
212
+ }
213
+
214
+ # `git commit` long options whose value can be a separate token. Listed so the
215
+ # VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
216
+ # must stay allowed. Only genuinely value-taking options belong here: adding a
217
+ # boolean one by mistake would swallow the token after it, and `git commit
218
+ # --amend -n` would go unnoticed.
219
+ COMMIT_LONG_SEPARATE_VALUE = {
220
+ "--message", "--file", "--author", "--date", "--template", "--cleanup",
221
+ "--reuse-message", "--reedit-message", "--fixup", "--squash",
222
+ "--pathspec-from-file", "--trailer",
223
+ }
224
+
225
+ # Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
226
+ # In a cluster the value is whatever follows them in the same token, or the next
227
+ # token when nothing does — which is why `-mn` is the message "n" and not a
228
+ # bypass, and `-nm msg` IS one.
229
+ COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
230
+
231
+ # Short options taking an OPTIONAL value, which git only ever reads attached:
232
+ # `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
233
+ # ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
234
+ COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
235
+
236
+
237
+ def line_boundaries_as_separators(text):
238
+ """Turn newlines into command separators, keeping continuations intact.
239
+
240
+ A newline ends a command exactly as `;` does, but shlex treats it as plain
241
+ whitespace — so `git commit -m x` on one line and `grep -n foo` on the next
242
+ read as ONE invocation and the grep gets refused. That is the same
243
+ false-positive class the short-form match exists to avoid, arriving through
244
+ the boundary rather than through the token.
245
+
246
+ Backslash-newline is joined first, because there the newline is NOT a
247
+ boundary: `git commit \\` + newline + `-nm x` is one command, and turning
248
+ that newline into a separator would hide a real bypass.
249
+
250
+ Args:
251
+ text: The command line, heredoc payloads already stripped.
252
+
253
+ Returns:
254
+ The same command with line breaks spelled as separators.
255
+ """
256
+ joined = text.replace("\r\n", "\n").replace("\\\n", " ")
257
+ return joined.replace("\n", " ; ")
258
+
259
+
260
+ def shell_tokens(text):
261
+ """Tokenize a command with shell operators kept as their own tokens.
262
+
263
+ Args:
264
+ text: The command line, heredoc payloads already stripped.
265
+
266
+ Returns:
267
+ The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
268
+ """
269
+ lexer = shlex.shlex(
270
+ line_boundaries_as_separators(text), posix=True, punctuation_chars=True
271
+ )
272
+ lexer.whitespace_split = True
273
+ # shlex treats `#` as a comment introducer by default and would silently
274
+ # truncate the rest of the line; `shlex.split` disables it, and so must this.
275
+ lexer.commenters = ""
276
+ return list(lexer)
277
+
278
+
279
+ def cluster_skips_verification(cluster):
280
+ """Read a short-option cluster the way git's own parser reads it.
281
+
282
+ Args:
283
+ cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
284
+
285
+ Returns:
286
+ A pair (bypasses, consumes_next_token). `bypasses` is True when a real
287
+ `-n` option is present; `consumes_next_token` is True when the cluster
288
+ ends in a value-taking option whose value is the following token.
289
+ """
290
+ body = cluster[1:]
291
+ for offset, letter in enumerate(body):
292
+ if letter == "n":
293
+ return (True, False)
294
+ if letter in COMMIT_SHORT_REQUIRED_VALUE:
295
+ # Everything after this letter is the value. It is a separate token
296
+ # only when nothing is attached.
297
+ return (False, offset == len(body) - 1)
298
+ if letter in COMMIT_SHORT_OPTIONAL_VALUE:
299
+ return (False, False)
300
+ return (False, False)
301
+
302
+
303
+ def commit_bypasses_verification(argv):
304
+ """Whether a `git commit` invocation's argv carries a real short `-n`.
305
+
306
+ Args:
307
+ argv: Tokens following the `commit` subcommand, to the end of the line.
308
+
309
+ Returns:
310
+ True if git would read one of them as --no-verify.
311
+ """
312
+ index = 0
313
+ while index < len(argv):
314
+ token = argv[index]
315
+ index += 1
316
+ # `--` ends the options; everything after it is a pathspec, and a file
317
+ # legitimately named `-n` is not a bypass.
318
+ if token in COMMAND_SEPARATORS or token == "--":
319
+ return False
320
+ if token.startswith("--"):
321
+ if token in COMMIT_LONG_SEPARATE_VALUE:
322
+ index += 1
323
+ continue
324
+ # A bare `-` is git's stdin placeholder, not an option cluster.
325
+ if not token.startswith("-") or token == "-":
326
+ continue
327
+ bypasses, consumes_next = cluster_skips_verification(token)
328
+ if bypasses:
329
+ return True
330
+ if consumes_next:
331
+ index += 1
332
+ return False
333
+
334
+
335
+ def subcommand_after_git(tokens, start):
336
+ """Find the subcommand a `git` token introduces, and its argv.
337
+
338
+ Args:
339
+ tokens: The full operator-aware token list.
340
+ start: Index just past the `git` token.
341
+
342
+ Returns:
343
+ A pair (subcommand, argv), or None when the invocation names none.
344
+ """
345
+ index = start
346
+ while index < len(tokens):
347
+ token = tokens[index]
348
+ if token in COMMAND_SEPARATORS:
349
+ return None
350
+ if not token.startswith("-"):
351
+ return (token, tokens[index + 1:])
352
+ index += 2 if token in GIT_GLOBAL_SEPARATE_VALUE else 1
353
+ return None
354
+
355
+
356
+ def git_commit_skips_verification(text):
357
+ """Whether the command runs `git commit` with the short `-n` bypass.
358
+
359
+ Args:
360
+ text: The command line, heredoc payloads already stripped.
361
+
362
+ Returns:
363
+ True if any `git commit` invocation on the line skips verification.
364
+ """
365
+ try:
366
+ scoped_tokens = shell_tokens(text)
367
+ except ValueError:
368
+ return False
369
+ for index, token in enumerate(scoped_tokens):
370
+ # `/usr/bin/git` and a bare `git` are the same program; an env prefix
371
+ # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
372
+ if token != "git" and not token.endswith("/git"):
373
+ continue
374
+ found = subcommand_after_git(scoped_tokens, index + 1)
375
+ if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
376
+ return True
377
+ return False
378
+
379
+
380
+ if git_commit_skips_verification(strip_heredocs(command)):
381
+ sys.exit(1)
382
+
168
383
  for i, token in enumerate(normalized_tokens):
169
384
  if disables_verification(token):
170
385
  sys.exit(1)
@@ -235,8 +450,10 @@ sys.exit(0)
235
450
  PY
236
451
  then
237
452
  cat >&2 <<'EOF'
238
- Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify, HUSKY=0,
239
- or core.hooksPath disabling). Fix the underlying issue (security audit, lint,
453
+ Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify or its
454
+ short form -n, HUSKY=0, or core.hooksPath disabling). `git commit -n` and a
455
+ cluster such as `-nm "msg"` skip pre-commit exactly as completely as the long
456
+ flag. Fix the underlying issue (security audit, lint,
240
457
  typecheck, tests, formatting) instead. If a fix is genuinely impossible, ask the
241
458
  user to make the risk-acceptance decision and add a specific documented ignore;
242
459
  never bypass the hook.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "3.70.0",
3
+ "version": "3.70.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"