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