@codyswann/lisa 4.57.4 → 4.57.5

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 (68) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +93 -4
  2. package/dist/codex/scripts/block-no-verify.sh +93 -4
  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 +4 -3
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/block-no-verify.sh +93 -4
  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/block-no-verify.agy.sh +93 -4
  15. package/plugins/lisa/hooks/block-no-verify.sh +93 -4
  16. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +93 -4
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/hooks/block-no-verify.sh +93 -4
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cursor/hooks/block-no-verify.sh +93 -4
  27. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-agy/plugin.json +1 -1
  30. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  50. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-agy/plugin.json +1 -1
  55. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  60. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  65. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/src/base/hooks/block-no-verify.agy.sh +93 -4
  68. package/plugins/src/base/hooks/block-no-verify.sh +93 -4
@@ -43,7 +43,7 @@
43
43
  # Add a name here in the same commit that closes a vector. A hardening that
44
44
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
45
45
  # review time instead of a named capability.
46
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
46
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
47
47
  #
48
48
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
49
49
  # commit-message prose while still catching quoted real argv values such as
@@ -652,6 +652,15 @@ def shell_starts_command(tokens, index):
652
652
  return status == "ok" and not remainder
653
653
 
654
654
 
655
+ def printed_argument(tokens, index):
656
+ """Whether a token is data passed to an ordinary echo or printf command."""
657
+ start = index - 1
658
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
659
+ start -= 1
660
+ program, _, opaque = command_word(tokens[start + 1:index])
661
+ return not opaque and program in {"echo", "printf"}
662
+
663
+
655
664
  def env_uses_split_string(tokens, index):
656
665
  """Whether a command-position env invocation uses split-string parsing.
657
666
 
@@ -1249,6 +1258,8 @@ def git_skips_verification(text, depth=0):
1249
1258
  # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
1250
1259
  if token != "git" and not token.endswith("/git"):
1251
1260
  continue
1261
+ if printed_argument(scoped_tokens, index):
1262
+ continue
1252
1263
  # The LONG flag, scoped to this invocation's argv rather than matched
1253
1264
  # anywhere on the line. This replaces the unscoped token match that used
1254
1265
  # to live in the flat loop below.
@@ -1276,8 +1287,8 @@ def git_skips_verification(text, depth=0):
1276
1287
  def token_bypass(tokens):
1277
1288
  """Whether any token disables hooks by environment or by git config.
1278
1289
 
1279
- These stay UNSCOPED, unlike the `--no-verify` match that moved into
1280
- `git_argv_disables_verification`. The difference is what the token means
1290
+ Printed echo/printf arguments are filtered before this scan. Assignments
1291
+ otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
1281
1292
  where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1282
1293
  configure whatever git runs next, so they legitimately sit before the
1283
1294
  invocation and often on a different line of a script. `--no-verify` is an
@@ -1387,11 +1398,47 @@ def flat_tokens(text):
1387
1398
  The token list, or None.
1388
1399
  """
1389
1400
  try:
1390
- return [token.strip("();|&") for token in shlex.split(text, posix=True)]
1401
+ tokens = shell_tokens(text)
1402
+ return [
1403
+ token.strip("();|&")
1404
+ for index, token in enumerate(tokens)
1405
+ if not printed_argument(tokens, index)
1406
+ ]
1391
1407
  except ValueError:
1392
1408
  return None
1393
1409
 
1394
1410
 
1411
+ def eval_payloads(scoped_tokens):
1412
+ """Command strings an `eval` in command position would execute.
1413
+
1414
+ `eval` takes no options: it concatenates its arguments and runs the result,
1415
+ so the payload is every token up to the next separator, joined by a space.
1416
+ Quoting is already gone by the time `shell_tokens` hands them over, which is
1417
+ exactly why the payload is invisible to every other scan here — the argv
1418
+ scan sees one opaque token and the git scan sees no `git` token at all.
1419
+
1420
+ Args:
1421
+ scoped_tokens: Tokens of one command line.
1422
+
1423
+ Returns:
1424
+ A list of payload strings, one per `eval` found in command position.
1425
+ """
1426
+ payloads = []
1427
+ for index, token in enumerate(scoped_tokens):
1428
+ if token.rsplit("/", 1)[-1] != "eval":
1429
+ continue
1430
+ if not shell_starts_command(scoped_tokens, index):
1431
+ continue
1432
+ collected = []
1433
+ for follow in scoped_tokens[index + 1 :]:
1434
+ if follow in COMMAND_SEPARATORS:
1435
+ break
1436
+ collected.append(follow)
1437
+ if collected:
1438
+ payloads.append(" ".join(collected))
1439
+ return payloads
1440
+
1441
+
1395
1442
  def verdict(text, depth=0, followed=None):
1396
1443
  """Whether this text, or a script it runs, bypasses verification.
1397
1444
 
@@ -1405,6 +1452,48 @@ def verdict(text, depth=0, followed=None):
1405
1452
  """
1406
1453
  if git_skips_verification(text):
1407
1454
  return True
1455
+ # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
1456
+ # a command string, and it was absent from both recursion sets: it is not a
1457
+ # SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
1458
+ # whose argv carries through. So every vector this guard blocks was restored
1459
+ # by writing it inside `eval "..."` — measured on origin/main, nine of them,
1460
+ # including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
1461
+ # separator and one behind a `command` wrapper.
1462
+ #
1463
+ # Enumerating spelling twelve is how spelling thirteen appears, and this
1464
+ # repository has twice declined an arms race of that shape. Recursing through
1465
+ # `verdict` instead gives the payload the IDENTICAL analysis, so anything
1466
+ # covered outside is covered inside — including checks the reporter's own
1467
+ # suggested patch would have missed, because it recursed into the git half
1468
+ # only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
1469
+ #
1470
+ # Bounded by the same budget as a followed script: unbounded recursion on
1471
+ # adversarial input is itself a denial of the guard, and a Python traceback
1472
+ # exits non-zero, which the wrapper turns into a spurious block.
1473
+ if depth < FOLLOW_MAX_DEPTH:
1474
+ try:
1475
+ eval_scoped = shell_tokens(text)
1476
+ except ValueError:
1477
+ eval_scoped = []
1478
+ for payload in eval_payloads(eval_scoped):
1479
+ if verdict(payload, depth + 1, followed):
1480
+ return True
1481
+ # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
1482
+ # into a `bash -c` payload, but only into ITSELF — so it carries the git
1483
+ # half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
1484
+ # `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
1485
+ # exactly that reason, and it was found by running the adversarial round
1486
+ # rather than by reasoning about it. Recursing the payload through
1487
+ # `verdict` gives every level the whole analysis.
1488
+ for index, token in enumerate(eval_scoped):
1489
+ if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
1490
+ continue
1491
+ if not shell_starts_command(eval_scoped, index):
1492
+ continue
1493
+ nested = nested_shell_payload(eval_scoped, index)
1494
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed):
1495
+ return True
1496
+
1408
1497
  tokens = flat_tokens(text)
1409
1498
  if tokens is not None and token_bypass(tokens):
1410
1499
  return True
package/package.json CHANGED
@@ -187,7 +187,7 @@
187
187
  "zod-validation-error": "^4.0.0"
188
188
  },
189
189
  "name": "@codyswann/lisa",
190
- "version": "4.57.4",
190
+ "version": "4.57.5",
191
191
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
192
192
  "main": "dist/index.js",
193
193
  "exports": {
@@ -336,7 +336,7 @@
336
336
  "test": "tests"
337
337
  },
338
338
  "types": "./dist/index.d.ts",
339
- "lisaReleaseCommit": "f6f450438d19c364ff02140f32058a0b8c27f764",
340
- "gitHead": "f6f450438d19c364ff02140f32058a0b8c27f764",
341
- "lisaReleaseTag": "v4.57.4"
339
+ "lisaReleaseCommit": "d9d122fb978ba31b95f8c9fc3426c48e738f628c",
340
+ "gitHead": "d9d122fb978ba31b95f8c9fc3426c48e738f628c",
341
+ "lisaReleaseTag": "v4.57.5"
342
342
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.57.4",
3
+ "version": "4.57.5",
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.57.4",
3
+ "version": "4.57.5",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -28,7 +28,7 @@
28
28
  # not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
29
29
  #
30
30
  # Capability names below are the canonical guard's, kept identical on every agent.
31
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
31
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
32
32
  set -uo pipefail
33
33
 
34
34
  allow() {
@@ -551,6 +551,15 @@ def shell_starts_command(tokens, index):
551
551
  return status == "ok" and not remainder
552
552
 
553
553
 
554
+ def printed_argument(tokens, index):
555
+ """Whether a token is data passed to an ordinary echo or printf command."""
556
+ start = index - 1
557
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
558
+ start -= 1
559
+ program, _, opaque = command_word(tokens[start + 1:index])
560
+ return not opaque and program in {"echo", "printf"}
561
+
562
+
554
563
  def env_uses_split_string(tokens, index):
555
564
  """Whether a command-position env invocation uses split-string parsing.
556
565
 
@@ -1148,6 +1157,8 @@ def git_skips_verification(text, depth=0):
1148
1157
  # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
1149
1158
  if token != "git" and not token.endswith("/git"):
1150
1159
  continue
1160
+ if printed_argument(scoped_tokens, index):
1161
+ continue
1151
1162
  # The LONG flag, scoped to this invocation's argv rather than matched
1152
1163
  # anywhere on the line. This replaces the unscoped token match that used
1153
1164
  # to live in the flat loop below.
@@ -1175,8 +1186,8 @@ def git_skips_verification(text, depth=0):
1175
1186
  def token_bypass(tokens):
1176
1187
  """Whether any token disables hooks by environment or by git config.
1177
1188
 
1178
- These stay UNSCOPED, unlike the `--no-verify` match that moved into
1179
- `git_argv_disables_verification`. The difference is what the token means
1189
+ Printed echo/printf arguments are filtered before this scan. Assignments
1190
+ otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
1180
1191
  where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1181
1192
  configure whatever git runs next, so they legitimately sit before the
1182
1193
  invocation and often on a different line of a script. `--no-verify` is an
@@ -1286,11 +1297,47 @@ def flat_tokens(text):
1286
1297
  The token list, or None.
1287
1298
  """
1288
1299
  try:
1289
- return [token.strip("();|&") for token in shlex.split(text, posix=True)]
1300
+ tokens = shell_tokens(text)
1301
+ return [
1302
+ token.strip("();|&")
1303
+ for index, token in enumerate(tokens)
1304
+ if not printed_argument(tokens, index)
1305
+ ]
1290
1306
  except ValueError:
1291
1307
  return None
1292
1308
 
1293
1309
 
1310
+ def eval_payloads(scoped_tokens):
1311
+ """Command strings an `eval` in command position would execute.
1312
+
1313
+ `eval` takes no options: it concatenates its arguments and runs the result,
1314
+ so the payload is every token up to the next separator, joined by a space.
1315
+ Quoting is already gone by the time `shell_tokens` hands them over, which is
1316
+ exactly why the payload is invisible to every other scan here — the argv
1317
+ scan sees one opaque token and the git scan sees no `git` token at all.
1318
+
1319
+ Args:
1320
+ scoped_tokens: Tokens of one command line.
1321
+
1322
+ Returns:
1323
+ A list of payload strings, one per `eval` found in command position.
1324
+ """
1325
+ payloads = []
1326
+ for index, token in enumerate(scoped_tokens):
1327
+ if token.rsplit("/", 1)[-1] != "eval":
1328
+ continue
1329
+ if not shell_starts_command(scoped_tokens, index):
1330
+ continue
1331
+ collected = []
1332
+ for follow in scoped_tokens[index + 1 :]:
1333
+ if follow in COMMAND_SEPARATORS:
1334
+ break
1335
+ collected.append(follow)
1336
+ if collected:
1337
+ payloads.append(" ".join(collected))
1338
+ return payloads
1339
+
1340
+
1294
1341
  def verdict(text, depth=0, followed=None):
1295
1342
  """Whether this text, or a script it runs, bypasses verification.
1296
1343
 
@@ -1304,6 +1351,48 @@ def verdict(text, depth=0, followed=None):
1304
1351
  """
1305
1352
  if git_skips_verification(text):
1306
1353
  return True
1354
+ # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
1355
+ # a command string, and it was absent from both recursion sets: it is not a
1356
+ # SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
1357
+ # whose argv carries through. So every vector this guard blocks was restored
1358
+ # by writing it inside `eval "..."` — measured on origin/main, nine of them,
1359
+ # including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
1360
+ # separator and one behind a `command` wrapper.
1361
+ #
1362
+ # Enumerating spelling twelve is how spelling thirteen appears, and this
1363
+ # repository has twice declined an arms race of that shape. Recursing through
1364
+ # `verdict` instead gives the payload the IDENTICAL analysis, so anything
1365
+ # covered outside is covered inside — including checks the reporter's own
1366
+ # suggested patch would have missed, because it recursed into the git half
1367
+ # only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
1368
+ #
1369
+ # Bounded by the same budget as a followed script: unbounded recursion on
1370
+ # adversarial input is itself a denial of the guard, and a Python traceback
1371
+ # exits non-zero, which the wrapper turns into a spurious block.
1372
+ if depth < FOLLOW_MAX_DEPTH:
1373
+ try:
1374
+ eval_scoped = shell_tokens(text)
1375
+ except ValueError:
1376
+ eval_scoped = []
1377
+ for payload in eval_payloads(eval_scoped):
1378
+ if verdict(payload, depth + 1, followed):
1379
+ return True
1380
+ # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
1381
+ # into a `bash -c` payload, but only into ITSELF — so it carries the git
1382
+ # half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
1383
+ # `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
1384
+ # exactly that reason, and it was found by running the adversarial round
1385
+ # rather than by reasoning about it. Recursing the payload through
1386
+ # `verdict` gives every level the whole analysis.
1387
+ for index, token in enumerate(eval_scoped):
1388
+ if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
1389
+ continue
1390
+ if not shell_starts_command(eval_scoped, index):
1391
+ continue
1392
+ nested = nested_shell_payload(eval_scoped, index)
1393
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed):
1394
+ return True
1395
+
1307
1396
  tokens = flat_tokens(text)
1308
1397
  if tokens is not None and token_bypass(tokens):
1309
1398
  return True
@@ -43,7 +43,7 @@
43
43
  # Add a name here in the same commit that closes a vector. A hardening that
44
44
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
45
45
  # review time instead of a named capability.
46
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
46
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
47
47
  #
48
48
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
49
49
  # commit-message prose while still catching quoted real argv values such as
@@ -652,6 +652,15 @@ def shell_starts_command(tokens, index):
652
652
  return status == "ok" and not remainder
653
653
 
654
654
 
655
+ def printed_argument(tokens, index):
656
+ """Whether a token is data passed to an ordinary echo or printf command."""
657
+ start = index - 1
658
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
659
+ start -= 1
660
+ program, _, opaque = command_word(tokens[start + 1:index])
661
+ return not opaque and program in {"echo", "printf"}
662
+
663
+
655
664
  def env_uses_split_string(tokens, index):
656
665
  """Whether a command-position env invocation uses split-string parsing.
657
666
 
@@ -1249,6 +1258,8 @@ def git_skips_verification(text, depth=0):
1249
1258
  # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
1250
1259
  if token != "git" and not token.endswith("/git"):
1251
1260
  continue
1261
+ if printed_argument(scoped_tokens, index):
1262
+ continue
1252
1263
  # The LONG flag, scoped to this invocation's argv rather than matched
1253
1264
  # anywhere on the line. This replaces the unscoped token match that used
1254
1265
  # to live in the flat loop below.
@@ -1276,8 +1287,8 @@ def git_skips_verification(text, depth=0):
1276
1287
  def token_bypass(tokens):
1277
1288
  """Whether any token disables hooks by environment or by git config.
1278
1289
 
1279
- These stay UNSCOPED, unlike the `--no-verify` match that moved into
1280
- `git_argv_disables_verification`. The difference is what the token means
1290
+ Printed echo/printf arguments are filtered before this scan. Assignments
1291
+ otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
1281
1292
  where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1282
1293
  configure whatever git runs next, so they legitimately sit before the
1283
1294
  invocation and often on a different line of a script. `--no-verify` is an
@@ -1387,11 +1398,47 @@ def flat_tokens(text):
1387
1398
  The token list, or None.
1388
1399
  """
1389
1400
  try:
1390
- return [token.strip("();|&") for token in shlex.split(text, posix=True)]
1401
+ tokens = shell_tokens(text)
1402
+ return [
1403
+ token.strip("();|&")
1404
+ for index, token in enumerate(tokens)
1405
+ if not printed_argument(tokens, index)
1406
+ ]
1391
1407
  except ValueError:
1392
1408
  return None
1393
1409
 
1394
1410
 
1411
+ def eval_payloads(scoped_tokens):
1412
+ """Command strings an `eval` in command position would execute.
1413
+
1414
+ `eval` takes no options: it concatenates its arguments and runs the result,
1415
+ so the payload is every token up to the next separator, joined by a space.
1416
+ Quoting is already gone by the time `shell_tokens` hands them over, which is
1417
+ exactly why the payload is invisible to every other scan here — the argv
1418
+ scan sees one opaque token and the git scan sees no `git` token at all.
1419
+
1420
+ Args:
1421
+ scoped_tokens: Tokens of one command line.
1422
+
1423
+ Returns:
1424
+ A list of payload strings, one per `eval` found in command position.
1425
+ """
1426
+ payloads = []
1427
+ for index, token in enumerate(scoped_tokens):
1428
+ if token.rsplit("/", 1)[-1] != "eval":
1429
+ continue
1430
+ if not shell_starts_command(scoped_tokens, index):
1431
+ continue
1432
+ collected = []
1433
+ for follow in scoped_tokens[index + 1 :]:
1434
+ if follow in COMMAND_SEPARATORS:
1435
+ break
1436
+ collected.append(follow)
1437
+ if collected:
1438
+ payloads.append(" ".join(collected))
1439
+ return payloads
1440
+
1441
+
1395
1442
  def verdict(text, depth=0, followed=None):
1396
1443
  """Whether this text, or a script it runs, bypasses verification.
1397
1444
 
@@ -1405,6 +1452,48 @@ def verdict(text, depth=0, followed=None):
1405
1452
  """
1406
1453
  if git_skips_verification(text):
1407
1454
  return True
1455
+ # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
1456
+ # a command string, and it was absent from both recursion sets: it is not a
1457
+ # SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
1458
+ # whose argv carries through. So every vector this guard blocks was restored
1459
+ # by writing it inside `eval "..."` — measured on origin/main, nine of them,
1460
+ # including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
1461
+ # separator and one behind a `command` wrapper.
1462
+ #
1463
+ # Enumerating spelling twelve is how spelling thirteen appears, and this
1464
+ # repository has twice declined an arms race of that shape. Recursing through
1465
+ # `verdict` instead gives the payload the IDENTICAL analysis, so anything
1466
+ # covered outside is covered inside — including checks the reporter's own
1467
+ # suggested patch would have missed, because it recursed into the git half
1468
+ # only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
1469
+ #
1470
+ # Bounded by the same budget as a followed script: unbounded recursion on
1471
+ # adversarial input is itself a denial of the guard, and a Python traceback
1472
+ # exits non-zero, which the wrapper turns into a spurious block.
1473
+ if depth < FOLLOW_MAX_DEPTH:
1474
+ try:
1475
+ eval_scoped = shell_tokens(text)
1476
+ except ValueError:
1477
+ eval_scoped = []
1478
+ for payload in eval_payloads(eval_scoped):
1479
+ if verdict(payload, depth + 1, followed):
1480
+ return True
1481
+ # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
1482
+ # into a `bash -c` payload, but only into ITSELF — so it carries the git
1483
+ # half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
1484
+ # `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
1485
+ # exactly that reason, and it was found by running the adversarial round
1486
+ # rather than by reasoning about it. Recursing the payload through
1487
+ # `verdict` gives every level the whole analysis.
1488
+ for index, token in enumerate(eval_scoped):
1489
+ if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
1490
+ continue
1491
+ if not shell_starts_command(eval_scoped, index):
1492
+ continue
1493
+ nested = nested_shell_payload(eval_scoped, index)
1494
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed):
1495
+ return True
1496
+
1408
1497
  tokens = flat_tokens(text)
1409
1498
  if tokens is not None and token_bypass(tokens):
1410
1499
  return True
@@ -28,7 +28,7 @@
28
28
  # not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
29
29
  #
30
30
  # Capability names below are the canonical guard's, kept identical on every agent.
31
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
31
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
32
32
  set -uo pipefail
33
33
 
34
34
  allow() {
@@ -551,6 +551,15 @@ def shell_starts_command(tokens, index):
551
551
  return status == "ok" and not remainder
552
552
 
553
553
 
554
+ def printed_argument(tokens, index):
555
+ """Whether a token is data passed to an ordinary echo or printf command."""
556
+ start = index - 1
557
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
558
+ start -= 1
559
+ program, _, opaque = command_word(tokens[start + 1:index])
560
+ return not opaque and program in {"echo", "printf"}
561
+
562
+
554
563
  def env_uses_split_string(tokens, index):
555
564
  """Whether a command-position env invocation uses split-string parsing.
556
565
 
@@ -1148,6 +1157,8 @@ def git_skips_verification(text, depth=0):
1148
1157
  # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
1149
1158
  if token != "git" and not token.endswith("/git"):
1150
1159
  continue
1160
+ if printed_argument(scoped_tokens, index):
1161
+ continue
1151
1162
  # The LONG flag, scoped to this invocation's argv rather than matched
1152
1163
  # anywhere on the line. This replaces the unscoped token match that used
1153
1164
  # to live in the flat loop below.
@@ -1175,8 +1186,8 @@ def git_skips_verification(text, depth=0):
1175
1186
  def token_bypass(tokens):
1176
1187
  """Whether any token disables hooks by environment or by git config.
1177
1188
 
1178
- These stay UNSCOPED, unlike the `--no-verify` match that moved into
1179
- `git_argv_disables_verification`. The difference is what the token means
1189
+ Printed echo/printf arguments are filtered before this scan. Assignments
1190
+ otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
1180
1191
  where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1181
1192
  configure whatever git runs next, so they legitimately sit before the
1182
1193
  invocation and often on a different line of a script. `--no-verify` is an
@@ -1286,11 +1297,47 @@ def flat_tokens(text):
1286
1297
  The token list, or None.
1287
1298
  """
1288
1299
  try:
1289
- return [token.strip("();|&") for token in shlex.split(text, posix=True)]
1300
+ tokens = shell_tokens(text)
1301
+ return [
1302
+ token.strip("();|&")
1303
+ for index, token in enumerate(tokens)
1304
+ if not printed_argument(tokens, index)
1305
+ ]
1290
1306
  except ValueError:
1291
1307
  return None
1292
1308
 
1293
1309
 
1310
+ def eval_payloads(scoped_tokens):
1311
+ """Command strings an `eval` in command position would execute.
1312
+
1313
+ `eval` takes no options: it concatenates its arguments and runs the result,
1314
+ so the payload is every token up to the next separator, joined by a space.
1315
+ Quoting is already gone by the time `shell_tokens` hands them over, which is
1316
+ exactly why the payload is invisible to every other scan here — the argv
1317
+ scan sees one opaque token and the git scan sees no `git` token at all.
1318
+
1319
+ Args:
1320
+ scoped_tokens: Tokens of one command line.
1321
+
1322
+ Returns:
1323
+ A list of payload strings, one per `eval` found in command position.
1324
+ """
1325
+ payloads = []
1326
+ for index, token in enumerate(scoped_tokens):
1327
+ if token.rsplit("/", 1)[-1] != "eval":
1328
+ continue
1329
+ if not shell_starts_command(scoped_tokens, index):
1330
+ continue
1331
+ collected = []
1332
+ for follow in scoped_tokens[index + 1 :]:
1333
+ if follow in COMMAND_SEPARATORS:
1334
+ break
1335
+ collected.append(follow)
1336
+ if collected:
1337
+ payloads.append(" ".join(collected))
1338
+ return payloads
1339
+
1340
+
1294
1341
  def verdict(text, depth=0, followed=None):
1295
1342
  """Whether this text, or a script it runs, bypasses verification.
1296
1343
 
@@ -1304,6 +1351,48 @@ def verdict(text, depth=0, followed=None):
1304
1351
  """
1305
1352
  if git_skips_verification(text):
1306
1353
  return True
1354
+ # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
1355
+ # a command string, and it was absent from both recursion sets: it is not a
1356
+ # SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
1357
+ # whose argv carries through. So every vector this guard blocks was restored
1358
+ # by writing it inside `eval "..."` — measured on origin/main, nine of them,
1359
+ # including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
1360
+ # separator and one behind a `command` wrapper.
1361
+ #
1362
+ # Enumerating spelling twelve is how spelling thirteen appears, and this
1363
+ # repository has twice declined an arms race of that shape. Recursing through
1364
+ # `verdict` instead gives the payload the IDENTICAL analysis, so anything
1365
+ # covered outside is covered inside — including checks the reporter's own
1366
+ # suggested patch would have missed, because it recursed into the git half
1367
+ # only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
1368
+ #
1369
+ # Bounded by the same budget as a followed script: unbounded recursion on
1370
+ # adversarial input is itself a denial of the guard, and a Python traceback
1371
+ # exits non-zero, which the wrapper turns into a spurious block.
1372
+ if depth < FOLLOW_MAX_DEPTH:
1373
+ try:
1374
+ eval_scoped = shell_tokens(text)
1375
+ except ValueError:
1376
+ eval_scoped = []
1377
+ for payload in eval_payloads(eval_scoped):
1378
+ if verdict(payload, depth + 1, followed):
1379
+ return True
1380
+ # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
1381
+ # into a `bash -c` payload, but only into ITSELF — so it carries the git
1382
+ # half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
1383
+ # `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
1384
+ # exactly that reason, and it was found by running the adversarial round
1385
+ # rather than by reasoning about it. Recursing the payload through
1386
+ # `verdict` gives every level the whole analysis.
1387
+ for index, token in enumerate(eval_scoped):
1388
+ if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
1389
+ continue
1390
+ if not shell_starts_command(eval_scoped, index):
1391
+ continue
1392
+ nested = nested_shell_payload(eval_scoped, index)
1393
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed):
1394
+ return True
1395
+
1307
1396
  tokens = flat_tokens(text)
1308
1397
  if tokens is not None and token_bypass(tokens):
1309
1398
  return True
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.57.4",
3
+ "version": "4.57.5",
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.57.4",
3
+ "version": "4.57.5",
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.57.4",
3
+ "version": "4.57.5",
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.57.4",
3
+ "version": "4.57.5",
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.57.4",
3
+ "version": "4.57.5",
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.57.4",
3
+ "version": "4.57.5",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"