@codyswann/lisa 4.72.3 → 4.72.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 +621 -32
  2. package/dist/codex/scripts/block-no-verify.sh +621 -32
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +2 -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 +10 -3
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/block-no-verify.sh +621 -32
  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 +621 -32
  15. package/plugins/lisa/hooks/block-no-verify.sh +621 -32
  16. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +621 -32
  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 +621 -32
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cursor/hooks/block-no-verify.sh +621 -32
  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 +621 -32
  68. package/plugins/src/base/hooks/block-no-verify.sh +621 -32
@@ -40,7 +40,7 @@
40
40
  # Add a name here in the same commit that closes a vector. A hardening that
41
41
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
42
42
  # review time instead of a named capability.
43
- # 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
43
+ # 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, static-script-directory-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
44
44
  #
45
45
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
46
46
  # commit-message prose while still catching quoted real argv values such as
@@ -1146,7 +1146,7 @@ def executed_scripts(tokens):
1146
1146
  if reason:
1147
1147
  reasons.append((reason, statement[0]))
1148
1148
  elif path:
1149
- paths.append(path)
1149
+ paths.append((path, "stdin"))
1150
1150
  previous_shell_awaiting_stdin = False
1151
1151
  if not statement:
1152
1152
  continue
@@ -1178,7 +1178,7 @@ def executed_scripts(tokens):
1178
1178
  if reason:
1179
1179
  reasons.append((reason, operand))
1180
1180
  elif path:
1181
- paths.append(path)
1181
+ paths.append((path, "source" if name in SOURCE_BUILTINS else "execute"))
1182
1182
  return paths, reasons
1183
1183
 
1184
1184
 
@@ -1193,11 +1193,529 @@ def read_script(path):
1193
1193
  """
1194
1194
  try:
1195
1195
  with open(path, encoding="utf-8", errors="replace") as handle:
1196
- return strip_shell_comments(handle.read(FOLLOW_MAX_BYTES))
1196
+ return strip_shell_comments(strip_heredocs(handle.read(FOLLOW_MAX_BYTES)))
1197
1197
  except OSError:
1198
1198
  return ""
1199
1199
 
1200
1200
 
1201
+ def raw_reach_statements(text):
1202
+ """Keep quotes and complete substitutions for ordered file reach.
1203
+
1204
+ shlex intentionally removes quotes, and splits the nested quotes in the
1205
+ canonical dirname idiom. Those tokens cannot prove an assignment or tell
1206
+ a literal '$DIR' from a variable. This lexer only preserves raw words; it
1207
+ never evaluates substitutions. The existing command classifier still owns
1208
+ interpreter options, wrappers and dispatchers.
1209
+ """
1210
+ text = text.replace("\\\n", "")
1211
+
1212
+ def quoted_end(start, quote, depth=0):
1213
+ if depth > 32:
1214
+ raise ValueError("shell nesting exceeds inspection budget")
1215
+ index = start + 1
1216
+ while index < len(text):
1217
+ char = text[index]
1218
+ if char == "\\" and quote != "'":
1219
+ index += 2
1220
+ continue
1221
+ if quote == '"' and text.startswith("$(", index):
1222
+ index = substitution_end(index + 1, depth + 1)
1223
+ continue
1224
+ if char == quote:
1225
+ return index + 1
1226
+ index += 1
1227
+ raise ValueError("unclosed shell quote")
1228
+
1229
+ def substitution_end(start, depth=0):
1230
+ if depth > 32:
1231
+ raise ValueError("shell nesting exceeds inspection budget")
1232
+ index = start + 1
1233
+ nesting = 1
1234
+ while index < len(text):
1235
+ char = text[index]
1236
+ if char == "\\":
1237
+ index += 2
1238
+ continue
1239
+ if char in "'\"`":
1240
+ index = quoted_end(index, char, depth + 1)
1241
+ continue
1242
+ if char == "(":
1243
+ nesting += 1
1244
+ elif char == ")":
1245
+ nesting -= 1
1246
+ if not nesting:
1247
+ return index + 1
1248
+ index += 1
1249
+ raise ValueError("unclosed command substitution")
1250
+
1251
+ statements = []
1252
+ words = []
1253
+ separator = ""
1254
+ index = 0
1255
+ while index < len(text):
1256
+ char = text[index]
1257
+ if char in " \t\r":
1258
+ index += 1
1259
+ continue
1260
+ if char in "\n;|&()<>":
1261
+ end = index + 1
1262
+ if char in "|&<>" and end < len(text) and text[end] == char:
1263
+ end += 1
1264
+ statements.append((separator, words))
1265
+ words = []
1266
+ separator = ";" if char == "\n" else text[index:end]
1267
+ index = end
1268
+ continue
1269
+ start = index
1270
+ while index < len(text) and text[index] not in " \t\r\n;|&()<>":
1271
+ if text[index] == "\\":
1272
+ index += 2
1273
+ elif text[index] in "'\"`":
1274
+ index = quoted_end(index, text[index])
1275
+ elif text.startswith("$(", index):
1276
+ index = substitution_end(index + 1)
1277
+ else:
1278
+ index += 1
1279
+ words.append(text[start:index])
1280
+ statements.append((separator, words))
1281
+ return statements
1282
+
1283
+
1284
+ DIRECTORY_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)(?:\[[^\]]*\])?(\+?=)(.*)$", re.S)
1285
+ DIRECTORY_OPERAND = re.compile(
1286
+ r'^\$(?:\{([A-Za-z_][A-Za-z_0-9]*)\}|([A-Za-z_][A-Za-z_0-9]*))([^$`\'"\\]*)$'
1287
+ )
1288
+ DIRECTORY_MUTATORS = {
1289
+ "eval", "read", "readarray", "mapfile", "unset", "let", "declare",
1290
+ "typeset", "local", "export", "readonly", "trap", "alias", "unalias",
1291
+ "enable", "getopts",
1292
+ }
1293
+ PARENT_PARAMETER_WRITE = re.compile(
1294
+ r"\$\{[A-Za-z_][A-Za-z_0-9]*(?:\[[^\]]*\])?:?="
1295
+ )
1296
+ PARENT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[")
1297
+ CONSTANT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]")
1298
+ CONSTANT_ARRAY_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]\+?=")
1299
+ SPECIAL_DIRECTORY_VARIABLES = {
1300
+ "_",
1301
+ "RANDOM", "SRANDOM", "SECONDS", "LINENO", "EPOCHSECONDS", "EPOCHREALTIME",
1302
+ "PWD", "OLDPWD", "DIRSTACK", "FUNCNAME", "GROUPS", "PIPESTATUS",
1303
+ "UID", "EUID", "PPID", "BASHPID", "SHLVL", "OPTIND", "OPTERR",
1304
+ }
1305
+
1306
+
1307
+ def bound_directory_operand(raw, context):
1308
+ """Expand one proven binding, retaining raw quote provenance."""
1309
+ if context is None:
1310
+ return None
1311
+ quoted = raw.startswith('"') and raw.endswith('"')
1312
+ candidate = raw[1:-1] if quoted else raw
1313
+ match = DIRECTORY_OPERAND.fullmatch(candidate)
1314
+ if match:
1315
+ variable = match[1] or match[2]
1316
+ value = context["bindings"].get(variable)
1317
+ if value is not None:
1318
+ result = value + match[3]
1319
+ if (quoted or (context["ifs_default"] and not re.search(r"[\s*?\[{}]", result))) and (os.path.isabs(value) or context["cwd_known"]):
1320
+ return result
1321
+ return None
1322
+
1323
+
1324
+ def directory_assignment_value(raw, context):
1325
+ """Prove only literal directory idioms in the followed file's context."""
1326
+ if context is None:
1327
+ return None
1328
+ if context["assignment_types_unknown"]:
1329
+ return None
1330
+ derived = bound_directory_operand(raw, context)
1331
+ if derived is not None:
1332
+ return derived
1333
+ if raw == '"${BASH_SOURCE[0]%/*}"':
1334
+ pathname = context["file"]
1335
+ if pathname is None:
1336
+ return None
1337
+ return pathname.rsplit("/", 1)[0] if "/" in pathname else pathname
1338
+ if context["shadowed"]:
1339
+ return None
1340
+ # $0 belongs to the outer executed script when this file was sourced.
1341
+ source = '"${BASH_SOURCE[0]}"'
1342
+ zero = '"$0"'
1343
+ candidates = [(source, context["file"]), (zero, context["zero"])]
1344
+ for reference, pathname in candidates:
1345
+ if pathname is None or "\n" in pathname:
1346
+ continue
1347
+ directory = os.path.dirname(pathname) or "."
1348
+ if raw == '"$(dirname ' + reference + ')"':
1349
+ return directory
1350
+ if raw == '"$(cd "$(dirname ' + reference + ')" && pwd)"':
1351
+ if os.path.isabs(directory):
1352
+ return os.path.normpath(directory)
1353
+ if context["cwd_known"] and not context["cdpath"]:
1354
+ return os.path.abspath(directory)
1355
+ return None
1356
+
1357
+
1358
+ def parent_expansion_writes(raw):
1359
+ """Detect possible parent-shell writes without reading quoted data as code."""
1360
+ index = 0
1361
+ double_quoted = False
1362
+ while index < len(raw):
1363
+ if raw[index] == "\\":
1364
+ index += 2
1365
+ continue
1366
+ if raw[index] == '"':
1367
+ double_quoted = not double_quoted
1368
+ index += 1
1369
+ continue
1370
+ if raw[index] == "'" and not double_quoted:
1371
+ end = raw.find("'", index + 1)
1372
+ if end < 0:
1373
+ return True
1374
+ index = end + 1
1375
+ continue
1376
+ if raw.startswith("$((", index) or raw.startswith("$[", index):
1377
+ return True
1378
+ if raw.startswith("${", index):
1379
+ if PARENT_PARAMETER_WRITE.match(raw, index):
1380
+ return True
1381
+ if PARENT_ARRAY_READ.match(raw, index) and not CONSTANT_ARRAY_READ.match(raw, index):
1382
+ return True
1383
+ index += 1
1384
+ return False
1385
+
1386
+
1387
+ def directory_variable_write(context, name):
1388
+ """Invalidate shell facts as well as bindings when a variable can change."""
1389
+ if context is None:
1390
+ return
1391
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*)(?:\[[0-9]+\])?(?:=.*)?", name, re.S)
1392
+ variable = match[1] if match else None
1393
+ if variable is not None:
1394
+ context["bindings"].pop(variable, None)
1395
+ if variable is None or variable == "PATH":
1396
+ context["shadowed"] = True
1397
+ if variable is None or variable == "CDPATH":
1398
+ context["cdpath"] = True
1399
+ if variable is None or variable == "IFS":
1400
+ context["ifs_default"] = False
1401
+ if variable is None or variable in {"BASH_ENV", "ENV"}:
1402
+ context["startup_unknown"] = True
1403
+ if variable is None or variable == "BASH_SOURCE":
1404
+ context["file"] = None
1405
+ if variable is None or variable == "BASH_ARGV0":
1406
+ context["zero"] = None
1407
+ if variable is None:
1408
+ context["assignment_types_unknown"] = True
1409
+
1410
+
1411
+ def directory_command(words):
1412
+ """Strip command-position control prefixes while counting their scopes."""
1413
+ index = 0
1414
+ openings = 0
1415
+ braces = 0
1416
+ while index < len(words) and words[index] in {"if", "elif", "while", "until", "then", "else", "do", "{", "!"}:
1417
+ if words[index] in {"if", "while", "until"}:
1418
+ openings += 1
1419
+ if words[index] == "{":
1420
+ braces += 1
1421
+ index += 1
1422
+ return words[index:], openings, braces
1423
+
1424
+
1425
+ def directory_reach(text, context, inspect, inspect_payload):
1426
+ """Follow commands in order, retaining only proven parent-shell facts.
1427
+
1428
+ Sourcing shares bindings; a new interpreter has its own bindings and $0.
1429
+ Assignments inside control flow, functions or subshells never authorize a
1430
+ later source. Possible writes invalidate facts, including writes in a
1431
+ library inspected immediately before its caller continues.
1432
+ """
1433
+ try:
1434
+ statements = raw_reach_statements(text)
1435
+ except ValueError:
1436
+ paths, reasons = executed_scripts(shell_tokens(text))
1437
+ if context is not None:
1438
+ context["bindings"].clear()
1439
+ return paths, reasons
1440
+ bindings = context["bindings"] if context is not None else {}
1441
+ definition_headers = set()
1442
+ definition_bodies = {}
1443
+ if context is not None:
1444
+ discovered_functions = set()
1445
+ for index, (_, raw_header) in enumerate(statements):
1446
+ words, _, _ = directory_command(raw_header)
1447
+ header_offset = len(raw_header) - len(words)
1448
+ name = None
1449
+ if len(words) >= 2 and words[0] == "function":
1450
+ name = words[1]
1451
+ elif len(words) == 1 and index + 2 < len(statements):
1452
+ closing, body = statements[index + 2]
1453
+ if statements[index + 1] == ("(", []) and closing == ")" and body and body[0] == "{":
1454
+ name = words[0]
1455
+ if name is not None:
1456
+ try:
1457
+ decoded_name = shlex.split(name, posix=True)
1458
+ except ValueError:
1459
+ decoded_name = []
1460
+ if len(decoded_name) == 1 and not COMPUTED_VALUE.search(decoded_name[0]):
1461
+ name = decoded_name[0]
1462
+ else:
1463
+ context["shadowed"] = True
1464
+ context["assignment_types_unknown"] = True
1465
+ definition_headers.add(index)
1466
+ if "{" in words:
1467
+ definition_bodies[index] = header_offset + words.index("{")
1468
+ elif index + 2 < len(statements) and "{" in statements[index + 2][1]:
1469
+ definition_bodies[index + 2] = statements[index + 2][1].index("{")
1470
+ discovered_functions.add(name)
1471
+ if name in {"dirname", "cd", "pwd"}:
1472
+ context["shadowed"] = True
1473
+ context["functions"] = context["functions"] | discovered_functions
1474
+ control_scope = 0
1475
+ paren_scope = 0
1476
+ brace_scope = 0
1477
+ function_frames = []
1478
+ previous_stdin = False
1479
+ for statement_index, (separator, raw_words) in enumerate(statements):
1480
+ if separator == "(" and statement_index and statements[statement_index - 1] == ("(", []):
1481
+ # Arithmetic evaluates variable contents as expressions; even a
1482
+ # seemingly unrelated counter can conceal a write through them.
1483
+ bindings.clear()
1484
+ if context is not None:
1485
+ context["cdpath"] = True
1486
+ context["shadowed"] = True
1487
+ if separator == "(":
1488
+ paren_scope += 1
1489
+ elif separator == ")":
1490
+ paren_scope = max(0, paren_scope - 1)
1491
+ if not raw_words:
1492
+ continue
1493
+ head = raw_words[0]
1494
+ enclosing_control = control_scope
1495
+ enclosing_parens = paren_scope
1496
+ normalized, openings, brace_openings = directory_command(raw_words)
1497
+ control_scope += openings
1498
+ if normalized and normalized[0] in {"for", "select", "case"}:
1499
+ control_scope += 1
1500
+ elif head in {"fi", "done", "esac"}:
1501
+ control_scope = max(0, control_scope - 1)
1502
+ brace_scope += brace_openings
1503
+ if normalized and normalized[0] == "function" and "{" in normalized:
1504
+ brace_scope += 1
1505
+ if head == "}":
1506
+ brace_scope = max(0, brace_scope - 1)
1507
+ if function_frames and brace_scope < function_frames[-1][0]:
1508
+ _, context, control_scope, paren_scope = function_frames.pop()
1509
+ bindings = context["bindings"] if context is not None else {}
1510
+ raw_words = raw_words[1:]
1511
+ if not raw_words:
1512
+ continue
1513
+ head = raw_words[0]
1514
+ if statement_index in definition_bodies and context is not None:
1515
+ if len(function_frames) >= FOLLOW_MAX_DEPTH:
1516
+ return [], [("an unproved function body beyond the inspection cap", head)]
1517
+ function_frames.append((brace_scope, context, enclosing_control, enclosing_parens))
1518
+ # A definition does not execute its body. Inspect potential file
1519
+ # executions without borrowing directory facts from the defining
1520
+ # moment: the body may be called after the caller changes them.
1521
+ # Its speculative writes must not erase the caller's facts either.
1522
+ context = {
1523
+ **context, "bindings": {},
1524
+ "cwd_known": False, "assignment_types_unknown": True,
1525
+ "startup_unknown": True,
1526
+ }
1527
+ bindings = context["bindings"]
1528
+ raw_words = raw_words[definition_bodies[statement_index] + 1:]
1529
+ if not raw_words:
1530
+ continue
1531
+ head = raw_words[0]
1532
+ scope = control_scope + paren_scope + brace_scope
1533
+ outgoing = statements[statement_index + 1][0] if statement_index + 1 < len(statements) else ""
1534
+ parent_context = context
1535
+ isolated = separator == "|" or outgoing in {"|", "&"}
1536
+ if context is not None and isolated:
1537
+ context = {**context, "bindings": bindings.copy()}
1538
+ bindings = context["bindings"]
1539
+ raw_command, _, _ = directory_command(raw_words)
1540
+ if raw_command and raw_command[0] in {"for", "select"} and len(raw_command) > 1:
1541
+ directory_variable_write(context, raw_command[1])
1542
+ if any(parent_expansion_writes(raw) for raw in raw_words):
1543
+ bindings.clear()
1544
+ if context is not None:
1545
+ context["shadowed"] = True
1546
+ context["cdpath"] = True
1547
+ context["ifs_default"] = False
1548
+ # Function definitions may shadow the commands used by dirname. Do
1549
+ # not treat a definition as an executed directory assignment.
1550
+ for raw_assignment in raw_command:
1551
+ assignment = DIRECTORY_ASSIGNMENT.fullmatch(raw_assignment)
1552
+ if assignment is None:
1553
+ break
1554
+ variable, operator, value = assignment.groups()
1555
+ proven = directory_assignment_value(value, context)
1556
+ # Bash's dynamic, readonly and array variables do not have the
1557
+ # ordinary scalar assignment/expansion semantics this proof uses.
1558
+ if variable in SPECIAL_DIRECTORY_VARIABLES or variable.startswith("BASH_"):
1559
+ proven = None
1560
+ if context is not None and variable == "BASH_SOURCE":
1561
+ context["file"] = None
1562
+ if context is not None and variable == "BASH_ARGV0":
1563
+ context["zero"] = None
1564
+ if raw_assignment.startswith(variable + "[") and not CONSTANT_ARRAY_ASSIGNMENT.match(raw_assignment):
1565
+ bindings.clear()
1566
+ if context is not None:
1567
+ context["shadowed"] = True
1568
+ context["ifs_default"] = False
1569
+ context["cdpath"] = True
1570
+ bindings.pop(variable, None)
1571
+ if context is not None and variable == "CDPATH":
1572
+ context["cdpath"] = True
1573
+ if context is not None and variable == "PATH":
1574
+ context["shadowed"] = True
1575
+ if context is not None and variable == "IFS":
1576
+ context["ifs_default"] = False
1577
+ if context is not None and variable in {"BASH_ENV", "ENV"}:
1578
+ context["startup_unknown"] = value not in {"", "''", '\"\"'}
1579
+ if len(raw_words) == 1 and not scope and not isolated and separator not in {"&&", "||", "|", "&"} and operator == "=" and head.startswith(variable + "="):
1580
+ if proven is not None:
1581
+ bindings[variable] = proven
1582
+ words = []
1583
+ for raw in raw_words:
1584
+ try:
1585
+ decoded = shlex.split(raw, posix=True)
1586
+ word = decoded[0] if len(decoded) == 1 else raw
1587
+ except ValueError:
1588
+ word = raw
1589
+ # Expansion is confined to one proven variable plus a literal
1590
+ # suffix. Single quotes and escaped dollars never get substituted.
1591
+ expanded = bound_directory_operand(raw, context)
1592
+ if expanded is not None:
1593
+ word = expanded
1594
+ if word in COMMAND_SEPARATORS:
1595
+ word = "\x00" + word
1596
+ words.append(word)
1597
+ # Prefix identity comes from raw words, before quote removal. A quoted
1598
+ # exclamation mark is an executable name, not the negation operator.
1599
+ command_words = words[len(raw_words) - len(raw_command):]
1600
+ program, args, _ = command_word(command_words)
1601
+ eval_context = {**context, "bindings": bindings.copy()} if context is not None and program == "eval" else context
1602
+ actual_head = next((word for word in command_words if not DIRECTORY_ASSIGNMENT.fullmatch(word)), None)
1603
+ if context is not None:
1604
+ called_function = (program in context["functions"] or actual_head in context["functions"]) and statement_index not in definition_headers
1605
+ arithmetic_test = program in {"test", "[", "[["} and any(
1606
+ arg in {"-v", "-R"} and index + 1 < len(args)
1607
+ and "[" in args[index + 1]
1608
+ and not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]", args[index + 1])
1609
+ for index, arg in enumerate(args)
1610
+ )
1611
+ if program == "[[":
1612
+ # [[ arithmetic comparisons evaluate variable contents as
1613
+ # expressions. Literal numbers and numeric special parameters
1614
+ # cannot conceal writes; test/[ instead parse integer strings.
1615
+ for index, arg in enumerate(args):
1616
+ if arg in {"-eq", "-ne", "-lt", "-le", "-gt", "-ge"}:
1617
+ operands = args[max(0, index - 1):index] + args[index + 1:index + 2]
1618
+ if len(operands) != 2 or any(not re.fullmatch(r"[+-]?[0-9]+|\$[?#$]", value) for value in operands):
1619
+ arithmetic_test = True
1620
+ if program in DIRECTORY_MUTATORS or called_function or arithmetic_test or (program == "printf" and "-v" in args) or (separator == "(" and head.startswith("(")):
1621
+ bindings.clear()
1622
+ context["cdpath"] = True
1623
+ context["ifs_default"] = False
1624
+ if program == "eval" or called_function or arithmetic_test:
1625
+ context["cwd_known"] = False
1626
+ context["shadowed"] = True
1627
+ context["assignment_types_unknown"] = True
1628
+ if program == "eval" or called_function:
1629
+ context["startup_unknown"] = True
1630
+ if program == "printf" and "-v" in args:
1631
+ target_index = args.index("-v") + 1
1632
+ directory_variable_write(context, args[target_index] if target_index < len(args) else "")
1633
+ elif program == "getopts":
1634
+ directory_variable_write(context, args[1] if len(args) > 1 else "")
1635
+ elif program in {"read", "readarray", "mapfile", "unset", "declare", "typeset", "local", "export", "readonly"}:
1636
+ for arg in args:
1637
+ if not arg.startswith("-"):
1638
+ directory_variable_write(context, arg)
1639
+ elif program == "let":
1640
+ directory_variable_write(context, "")
1641
+ invocation_context = context
1642
+ if context is not None and program in SHELL_PROGRAMS:
1643
+ operand = shell_execution_operand(args)
1644
+ if isinstance(operand, str) or operand is SHELL_COMMAND_STRING:
1645
+ prefix = command_words[:len(command_words) - len(args)]
1646
+ invocation_context = context.copy()
1647
+ if any(word.startswith("CDPATH=") for word in prefix):
1648
+ invocation_context["cdpath"] = True
1649
+ if any(word.startswith("PATH=") for word in prefix):
1650
+ invocation_context["shadowed"] = True
1651
+ if any(word.startswith(("BASH_ENV=", "ENV=")) and word.split("=", 1)[1] for word in prefix):
1652
+ invocation_context["startup_unknown"] = True
1653
+ startup_args = args[:args.index(operand)] if isinstance(operand, str) else args
1654
+ if shell_startup_unknown(startup_args):
1655
+ invocation_context["startup_unknown"] = True
1656
+ if context is not None:
1657
+ if program in {"alias", "unalias", "enable"}:
1658
+ context["shadowed"] = True
1659
+ if program in {"declare", "typeset", "local"} and any(arg.startswith("-") and any(flag in arg[1:] for flag in "nirlu") for arg in args):
1660
+ context["assignment_types_unknown"] = True
1661
+ trap_args = args[1:] if args and args[0] == "--" else args
1662
+ exit_only_trap = len(trap_args) >= 2 and all(signal in {"EXIT", "0"} for signal in trap_args[1:])
1663
+ if program == "readonly" or (program == "trap" and not exit_only_trap):
1664
+ context["assignment_types_unknown"] = True
1665
+ if program in {"cd", "pushd", "popd"}:
1666
+ context["cwd_known"] = False
1667
+ for variable, value in list(bindings.items()):
1668
+ if not os.path.isabs(value):
1669
+ del bindings[variable]
1670
+ # A conditional assignment is still a possible overwrite. Ignore
1671
+ # assignment-looking data belonging to an ordinary command.
1672
+ if head in {"then", "else", "do"}:
1673
+ for raw in raw_words[1:]:
1674
+ mutation = DIRECTORY_ASSIGNMENT.fullmatch(raw)
1675
+ if mutation:
1676
+ bindings.pop(mutation[1], None)
1677
+ tokens = (["bash", "<"] if separator == "<" and previous_stdin else []) + command_words
1678
+ paths, reasons = executed_scripts(tokens)
1679
+ if reasons:
1680
+ return [], reasons
1681
+ if program in SHELL_PROGRAMS:
1682
+ payload = nested_shell_payload([program, *args], 0)
1683
+ if isinstance(payload, str) and inspect_payload(payload, "execute", invocation_context):
1684
+ return [], [("a nested shell command bypasses git's verification hooks", program)]
1685
+ elif program == "eval":
1686
+ for payload in eval_payloads(command_words):
1687
+ if inspect_payload(payload, "source", eval_context):
1688
+ return [], [("an evaluated command bypasses git's verification hooks", program)]
1689
+ previous_stdin = program in SHELL_PROGRAMS and shell_execution_operand(args) is SHELL_STDIN
1690
+ for path, mode in paths:
1691
+ if context is not None and not context["cwd_known"] and not os.path.isabs(path):
1692
+ return [], [("a computed path after an unproved working-directory change", path)]
1693
+ before = bindings.copy()
1694
+ if inspect(path, mode, invocation_context):
1695
+ return [], [("a followed script bypasses git's verification hooks", path)]
1696
+ if mode == "source" and (scope or separator in {"&&", "||", "|", "&"}):
1697
+ for variable, value in list(bindings.items()):
1698
+ if variable not in before or before[variable] != value:
1699
+ del bindings[variable]
1700
+ if isolated:
1701
+ if separator == "|" and parent_context is not None:
1702
+ # The last pipeline command can run in the parent (`lastpipe`
1703
+ # or another shell). Retain only facts unchanged in both
1704
+ # execution models; never promote a child-only assignment.
1705
+ for variable, value in list(parent_context["bindings"].items()):
1706
+ if bindings.get(variable) != value:
1707
+ del parent_context["bindings"][variable]
1708
+ parent_context["cwd_known"] &= context["cwd_known"]
1709
+ parent_context["ifs_default"] &= context["ifs_default"]
1710
+ for fact in ("cdpath", "shadowed", "assignment_types_unknown", "startup_unknown"):
1711
+ parent_context[fact] |= context[fact]
1712
+ if parent_context["functions"] is not context["functions"]:
1713
+ parent_context["functions"] |= context["functions"]
1714
+ context = parent_context
1715
+ bindings = context["bindings"] if context is not None else {}
1716
+ return [], []
1717
+
1718
+
1201
1719
  def git_argv_disables_verification(argv):
1202
1720
  """Whether a git invocation's argv carries `--no-verify` or an abbreviation.
1203
1721
 
@@ -1520,17 +2038,47 @@ def eval_payloads(scoped_tokens):
1520
2038
  return payloads
1521
2039
 
1522
2040
 
1523
- def verdict(text, depth=0, followed=None):
2041
+ def shell_startup_unknown(args):
2042
+ """Whether invocation options permit uninspected interactive/login startup."""
2043
+ options = []
2044
+ index = 0
2045
+ while index < len(args):
2046
+ option = args[index]
2047
+ if option == "--" or not option.startswith(("-", "+")):
2048
+ break
2049
+ options.append(option)
2050
+ if option.startswith("-") and not option.startswith("--") and "c" in option[1:]:
2051
+ break
2052
+ index += 2 if option in SHELL_OPTIONS_SEPARATE_VALUE else 1
2053
+ interactive = any(option == "--interactive" or (option.startswith("-") and not option.startswith("--") and "i" in option[1:]) for option in options)
2054
+ login = any(option == "--login" or (option.startswith("-") and not option.startswith("--") and "l" in option[1:]) for option in options)
2055
+ return (interactive and "--norc" not in options) or (login and "--noprofile" not in options)
2056
+
2057
+
2058
+ def initial_directory_context(startup_unknown=False):
2059
+ startup_unknown |= bool(os.environ.get("BASH_ENV") or os.environ.get("ENV"))
2060
+ return {
2061
+ "file": None, "zero": None, "bindings": {}, "cwd_known": True,
2062
+ "cdpath": bool(os.environ.get("CDPATH")), "shadowed": False,
2063
+ "functions": frozenset(), "ifs_default": True,
2064
+ "assignment_types_unknown": startup_unknown,
2065
+ "startup_unknown": startup_unknown,
2066
+ }
2067
+
2068
+
2069
+ def verdict(text, depth=0, followed=None, context=None, follow_scripts=True):
1524
2070
  """Whether this text, or a script it runs, bypasses verification.
1525
2071
 
1526
2072
  Args:
1527
2073
  text: A command line or a followed script's contents.
1528
2074
  depth: Number of scripts already followed.
1529
- followed: Real paths already inspected, so a cycle terminates.
2075
+ followed: Shared inspection count, including repeats and nested payloads.
1530
2076
 
1531
2077
  Returns:
1532
2078
  True when the command must be refused.
1533
2079
  """
2080
+ if followed is None:
2081
+ followed = []
1534
2082
  if git_skips_verification(text):
1535
2083
  return True
1536
2084
  # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
@@ -1557,7 +2105,7 @@ def verdict(text, depth=0, followed=None):
1557
2105
  except ValueError:
1558
2106
  eval_scoped = []
1559
2107
  for payload in eval_payloads(eval_scoped):
1560
- if verdict(payload, depth + 1, followed):
2108
+ if verdict(payload, depth + 1, followed, follow_scripts=False):
1561
2109
  return True
1562
2110
  # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
1563
2111
  # into a `bash -c` payload, but only into ITSELF — so it carries the git
@@ -1572,50 +2120,91 @@ def verdict(text, depth=0, followed=None):
1572
2120
  if not shell_starts_command(eval_scoped, index):
1573
2121
  continue
1574
2122
  nested = nested_shell_payload(eval_scoped, index)
1575
- if isinstance(nested, str) and verdict(nested, depth + 1, followed):
2123
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed, follow_scripts=False):
1576
2124
  return True
1577
2125
 
1578
2126
  tokens = flat_tokens(text)
1579
2127
  if tokens is not None and token_bypass(tokens):
1580
2128
  return True
2129
+ if not follow_scripts:
2130
+ return False
1581
2131
  try:
1582
2132
  scoped_tokens = shell_tokens(text)
1583
2133
  except ValueError:
1584
2134
  # Text this parser cannot lex is left to the checks above, which is the
1585
2135
  # same accepted fail-open the header records for an unlexable command.
1586
2136
  return False
1587
- paths, reasons = executed_scripts(scoped_tokens)
1588
- if reasons:
1589
- reason, operand = reasons[0]
1590
- print(
1591
- f"block-no-verify: refusing an execution it cannot inspect — {reason}"
1592
- + (f" ({operand})" if operand else ""),
1593
- file=sys.stderr,
1594
- )
1595
- return True
1596
- if followed is None:
1597
- followed = set()
1598
- for path in paths:
1599
- try:
1600
- key = os.path.realpath(path)
1601
- except OSError:
1602
- key = path
1603
- if key in followed:
1604
- continue
2137
+
2138
+ def inspect(path, mode, parent_context):
2139
+ key = os.path.realpath(path)
2140
+ sourced = mode == "source"
1605
2141
  if len(followed) >= FOLLOW_MAX_FILES or depth >= FOLLOW_MAX_DEPTH:
1606
2142
  print(
1607
2143
  "block-no-verify: refusing at the script-following cap rather "
1608
- f"than skipping past it ({path})",
1609
- file=sys.stderr,
2144
+ f"than skipping past it ({path})", file=sys.stderr,
1610
2145
  )
1611
2146
  return True
1612
- followed.add(key)
1613
- if verdict(read_script(path), depth + 1, followed):
2147
+ # Count inspections rather than unique names: a repeated source must
2148
+ # be read under its current bindings, and still consumes the budget.
2149
+ followed.append(key)
2150
+ inherited = sourced and parent_context is not None
2151
+ child = {
2152
+ "file": None if mode == "stdin" else path,
2153
+ "zero": parent_context["zero"] if inherited else (path if mode == "execute" else None),
2154
+ "bindings": parent_context["bindings"] if inherited else {},
2155
+ "cwd_known": parent_context["cwd_known"] if parent_context else True,
2156
+ "cdpath": parent_context["cdpath"] if parent_context else bool(os.environ.get("CDPATH")),
2157
+ "shadowed": parent_context["shadowed"] if parent_context else False,
2158
+ # Exported functions can survive a new interpreter. Retain the
2159
+ # caller's known candidates conservatively; an unused definition
2160
+ # does not invalidate a binding, while a possible call does.
2161
+ "functions": parent_context["functions"],
2162
+ "ifs_default": parent_context["ifs_default"] if inherited else True,
2163
+ "assignment_types_unknown": parent_context["assignment_types_unknown"] if inherited else parent_context["startup_unknown"],
2164
+ "startup_unknown": parent_context["startup_unknown"],
2165
+ }
2166
+ refused = verdict(read_script(path), depth + 1, followed, child)
2167
+ if inherited:
2168
+ for fact in ("cwd_known", "cdpath", "shadowed", "ifs_default", "assignment_types_unknown", "startup_unknown", "functions", "file", "zero"):
2169
+ if fact == "file":
2170
+ if child[fact] is None:
2171
+ parent_context[fact] = None
2172
+ continue
2173
+ parent_context[fact] = child[fact]
2174
+ if refused:
1614
2175
  print(
1615
2176
  f"block-no-verify: {path} — a script this command runs — "
1616
- "bypasses git's verification hooks.",
1617
- file=sys.stderr,
2177
+ "bypasses git's verification hooks.", file=sys.stderr,
1618
2178
  )
2179
+ return refused
2180
+
2181
+ if context is None:
2182
+ context = initial_directory_context()
2183
+
2184
+ def inspect_payload(payload, mode, parent_context):
2185
+ if depth >= FOLLOW_MAX_DEPTH:
2186
+ print("block-no-verify: refusing at the script-following cap for a nested payload", file=sys.stderr)
2187
+ return True
2188
+ child = parent_context
2189
+ if mode == "execute":
2190
+ child = {
2191
+ **parent_context, "file": None, "zero": None, "bindings": {},
2192
+ "ifs_default": True,
2193
+ "assignment_types_unknown": parent_context["startup_unknown"],
2194
+ }
2195
+ return verdict(payload, depth + 1, followed, child)
2196
+
2197
+ paths, reasons = directory_reach(text, context, inspect, inspect_payload)
2198
+ if reasons:
2199
+ reason, operand = reasons[0]
2200
+ print(
2201
+ f"block-no-verify: refusing an execution it cannot inspect — {reason}"
2202
+ + (f" ({operand})" if operand else ""),
2203
+ file=sys.stderr,
2204
+ )
2205
+ return True
2206
+ for path, mode in paths:
2207
+ if inspect(path, mode, context):
1619
2208
  return True
1620
2209
  return False
1621
2210