@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
@@ -10,7 +10,7 @@
10
10
  # `git -c "core.hooksPath=/dev/null"`.
11
11
  #
12
12
  # Capability names below are the canonical guard's, kept identical on every agent.
13
- # 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
13
+ # 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
14
14
  set -euo pipefail
15
15
 
16
16
  input="$(cat 2>/dev/null || true)"
@@ -1024,7 +1024,7 @@ def executed_scripts(tokens):
1024
1024
  if reason:
1025
1025
  reasons.append((reason, statement[0]))
1026
1026
  elif path:
1027
- paths.append(path)
1027
+ paths.append((path, "stdin"))
1028
1028
  previous_shell_awaiting_stdin = False
1029
1029
  if not statement:
1030
1030
  continue
@@ -1056,7 +1056,7 @@ def executed_scripts(tokens):
1056
1056
  if reason:
1057
1057
  reasons.append((reason, operand))
1058
1058
  elif path:
1059
- paths.append(path)
1059
+ paths.append((path, "source" if name in SOURCE_BUILTINS else "execute"))
1060
1060
  return paths, reasons
1061
1061
 
1062
1062
 
@@ -1071,11 +1071,529 @@ def read_script(path):
1071
1071
  """
1072
1072
  try:
1073
1073
  with open(path, encoding="utf-8", errors="replace") as handle:
1074
- return strip_shell_comments(handle.read(FOLLOW_MAX_BYTES))
1074
+ return strip_shell_comments(strip_heredocs(handle.read(FOLLOW_MAX_BYTES)))
1075
1075
  except OSError:
1076
1076
  return ""
1077
1077
 
1078
1078
 
1079
+ def raw_reach_statements(text):
1080
+ """Keep quotes and complete substitutions for ordered file reach.
1081
+
1082
+ shlex intentionally removes quotes, and splits the nested quotes in the
1083
+ canonical dirname idiom. Those tokens cannot prove an assignment or tell
1084
+ a literal '$DIR' from a variable. This lexer only preserves raw words; it
1085
+ never evaluates substitutions. The existing command classifier still owns
1086
+ interpreter options, wrappers and dispatchers.
1087
+ """
1088
+ text = text.replace("\\\n", "")
1089
+
1090
+ def quoted_end(start, quote, depth=0):
1091
+ if depth > 32:
1092
+ raise ValueError("shell nesting exceeds inspection budget")
1093
+ index = start + 1
1094
+ while index < len(text):
1095
+ char = text[index]
1096
+ if char == "\\" and quote != "'":
1097
+ index += 2
1098
+ continue
1099
+ if quote == '"' and text.startswith("$(", index):
1100
+ index = substitution_end(index + 1, depth + 1)
1101
+ continue
1102
+ if char == quote:
1103
+ return index + 1
1104
+ index += 1
1105
+ raise ValueError("unclosed shell quote")
1106
+
1107
+ def substitution_end(start, depth=0):
1108
+ if depth > 32:
1109
+ raise ValueError("shell nesting exceeds inspection budget")
1110
+ index = start + 1
1111
+ nesting = 1
1112
+ while index < len(text):
1113
+ char = text[index]
1114
+ if char == "\\":
1115
+ index += 2
1116
+ continue
1117
+ if char in "'\"`":
1118
+ index = quoted_end(index, char, depth + 1)
1119
+ continue
1120
+ if char == "(":
1121
+ nesting += 1
1122
+ elif char == ")":
1123
+ nesting -= 1
1124
+ if not nesting:
1125
+ return index + 1
1126
+ index += 1
1127
+ raise ValueError("unclosed command substitution")
1128
+
1129
+ statements = []
1130
+ words = []
1131
+ separator = ""
1132
+ index = 0
1133
+ while index < len(text):
1134
+ char = text[index]
1135
+ if char in " \t\r":
1136
+ index += 1
1137
+ continue
1138
+ if char in "\n;|&()<>":
1139
+ end = index + 1
1140
+ if char in "|&<>" and end < len(text) and text[end] == char:
1141
+ end += 1
1142
+ statements.append((separator, words))
1143
+ words = []
1144
+ separator = ";" if char == "\n" else text[index:end]
1145
+ index = end
1146
+ continue
1147
+ start = index
1148
+ while index < len(text) and text[index] not in " \t\r\n;|&()<>":
1149
+ if text[index] == "\\":
1150
+ index += 2
1151
+ elif text[index] in "'\"`":
1152
+ index = quoted_end(index, text[index])
1153
+ elif text.startswith("$(", index):
1154
+ index = substitution_end(index + 1)
1155
+ else:
1156
+ index += 1
1157
+ words.append(text[start:index])
1158
+ statements.append((separator, words))
1159
+ return statements
1160
+
1161
+
1162
+ DIRECTORY_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)(?:\[[^\]]*\])?(\+?=)(.*)$", re.S)
1163
+ DIRECTORY_OPERAND = re.compile(
1164
+ r'^\$(?:\{([A-Za-z_][A-Za-z_0-9]*)\}|([A-Za-z_][A-Za-z_0-9]*))([^$`\'"\\]*)$'
1165
+ )
1166
+ DIRECTORY_MUTATORS = {
1167
+ "eval", "read", "readarray", "mapfile", "unset", "let", "declare",
1168
+ "typeset", "local", "export", "readonly", "trap", "alias", "unalias",
1169
+ "enable", "getopts",
1170
+ }
1171
+ PARENT_PARAMETER_WRITE = re.compile(
1172
+ r"\$\{[A-Za-z_][A-Za-z_0-9]*(?:\[[^\]]*\])?:?="
1173
+ )
1174
+ PARENT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[")
1175
+ CONSTANT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]")
1176
+ CONSTANT_ARRAY_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]\+?=")
1177
+ SPECIAL_DIRECTORY_VARIABLES = {
1178
+ "_",
1179
+ "RANDOM", "SRANDOM", "SECONDS", "LINENO", "EPOCHSECONDS", "EPOCHREALTIME",
1180
+ "PWD", "OLDPWD", "DIRSTACK", "FUNCNAME", "GROUPS", "PIPESTATUS",
1181
+ "UID", "EUID", "PPID", "BASHPID", "SHLVL", "OPTIND", "OPTERR",
1182
+ }
1183
+
1184
+
1185
+ def bound_directory_operand(raw, context):
1186
+ """Expand one proven binding, retaining raw quote provenance."""
1187
+ if context is None:
1188
+ return None
1189
+ quoted = raw.startswith('"') and raw.endswith('"')
1190
+ candidate = raw[1:-1] if quoted else raw
1191
+ match = DIRECTORY_OPERAND.fullmatch(candidate)
1192
+ if match:
1193
+ variable = match[1] or match[2]
1194
+ value = context["bindings"].get(variable)
1195
+ if value is not None:
1196
+ result = value + match[3]
1197
+ if (quoted or (context["ifs_default"] and not re.search(r"[\s*?\[{}]", result))) and (os.path.isabs(value) or context["cwd_known"]):
1198
+ return result
1199
+ return None
1200
+
1201
+
1202
+ def directory_assignment_value(raw, context):
1203
+ """Prove only literal directory idioms in the followed file's context."""
1204
+ if context is None:
1205
+ return None
1206
+ if context["assignment_types_unknown"]:
1207
+ return None
1208
+ derived = bound_directory_operand(raw, context)
1209
+ if derived is not None:
1210
+ return derived
1211
+ if raw == '"${BASH_SOURCE[0]%/*}"':
1212
+ pathname = context["file"]
1213
+ if pathname is None:
1214
+ return None
1215
+ return pathname.rsplit("/", 1)[0] if "/" in pathname else pathname
1216
+ if context["shadowed"]:
1217
+ return None
1218
+ # $0 belongs to the outer executed script when this file was sourced.
1219
+ source = '"${BASH_SOURCE[0]}"'
1220
+ zero = '"$0"'
1221
+ candidates = [(source, context["file"]), (zero, context["zero"])]
1222
+ for reference, pathname in candidates:
1223
+ if pathname is None or "\n" in pathname:
1224
+ continue
1225
+ directory = os.path.dirname(pathname) or "."
1226
+ if raw == '"$(dirname ' + reference + ')"':
1227
+ return directory
1228
+ if raw == '"$(cd "$(dirname ' + reference + ')" && pwd)"':
1229
+ if os.path.isabs(directory):
1230
+ return os.path.normpath(directory)
1231
+ if context["cwd_known"] and not context["cdpath"]:
1232
+ return os.path.abspath(directory)
1233
+ return None
1234
+
1235
+
1236
+ def parent_expansion_writes(raw):
1237
+ """Detect possible parent-shell writes without reading quoted data as code."""
1238
+ index = 0
1239
+ double_quoted = False
1240
+ while index < len(raw):
1241
+ if raw[index] == "\\":
1242
+ index += 2
1243
+ continue
1244
+ if raw[index] == '"':
1245
+ double_quoted = not double_quoted
1246
+ index += 1
1247
+ continue
1248
+ if raw[index] == "'" and not double_quoted:
1249
+ end = raw.find("'", index + 1)
1250
+ if end < 0:
1251
+ return True
1252
+ index = end + 1
1253
+ continue
1254
+ if raw.startswith("$((", index) or raw.startswith("$[", index):
1255
+ return True
1256
+ if raw.startswith("${", index):
1257
+ if PARENT_PARAMETER_WRITE.match(raw, index):
1258
+ return True
1259
+ if PARENT_ARRAY_READ.match(raw, index) and not CONSTANT_ARRAY_READ.match(raw, index):
1260
+ return True
1261
+ index += 1
1262
+ return False
1263
+
1264
+
1265
+ def directory_variable_write(context, name):
1266
+ """Invalidate shell facts as well as bindings when a variable can change."""
1267
+ if context is None:
1268
+ return
1269
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*)(?:\[[0-9]+\])?(?:=.*)?", name, re.S)
1270
+ variable = match[1] if match else None
1271
+ if variable is not None:
1272
+ context["bindings"].pop(variable, None)
1273
+ if variable is None or variable == "PATH":
1274
+ context["shadowed"] = True
1275
+ if variable is None or variable == "CDPATH":
1276
+ context["cdpath"] = True
1277
+ if variable is None or variable == "IFS":
1278
+ context["ifs_default"] = False
1279
+ if variable is None or variable in {"BASH_ENV", "ENV"}:
1280
+ context["startup_unknown"] = True
1281
+ if variable is None or variable == "BASH_SOURCE":
1282
+ context["file"] = None
1283
+ if variable is None or variable == "BASH_ARGV0":
1284
+ context["zero"] = None
1285
+ if variable is None:
1286
+ context["assignment_types_unknown"] = True
1287
+
1288
+
1289
+ def directory_command(words):
1290
+ """Strip command-position control prefixes while counting their scopes."""
1291
+ index = 0
1292
+ openings = 0
1293
+ braces = 0
1294
+ while index < len(words) and words[index] in {"if", "elif", "while", "until", "then", "else", "do", "{", "!"}:
1295
+ if words[index] in {"if", "while", "until"}:
1296
+ openings += 1
1297
+ if words[index] == "{":
1298
+ braces += 1
1299
+ index += 1
1300
+ return words[index:], openings, braces
1301
+
1302
+
1303
+ def directory_reach(text, context, inspect, inspect_payload):
1304
+ """Follow commands in order, retaining only proven parent-shell facts.
1305
+
1306
+ Sourcing shares bindings; a new interpreter has its own bindings and $0.
1307
+ Assignments inside control flow, functions or subshells never authorize a
1308
+ later source. Possible writes invalidate facts, including writes in a
1309
+ library inspected immediately before its caller continues.
1310
+ """
1311
+ try:
1312
+ statements = raw_reach_statements(text)
1313
+ except ValueError:
1314
+ paths, reasons = executed_scripts(shell_tokens(text))
1315
+ if context is not None:
1316
+ context["bindings"].clear()
1317
+ return paths, reasons
1318
+ bindings = context["bindings"] if context is not None else {}
1319
+ definition_headers = set()
1320
+ definition_bodies = {}
1321
+ if context is not None:
1322
+ discovered_functions = set()
1323
+ for index, (_, raw_header) in enumerate(statements):
1324
+ words, _, _ = directory_command(raw_header)
1325
+ header_offset = len(raw_header) - len(words)
1326
+ name = None
1327
+ if len(words) >= 2 and words[0] == "function":
1328
+ name = words[1]
1329
+ elif len(words) == 1 and index + 2 < len(statements):
1330
+ closing, body = statements[index + 2]
1331
+ if statements[index + 1] == ("(", []) and closing == ")" and body and body[0] == "{":
1332
+ name = words[0]
1333
+ if name is not None:
1334
+ try:
1335
+ decoded_name = shlex.split(name, posix=True)
1336
+ except ValueError:
1337
+ decoded_name = []
1338
+ if len(decoded_name) == 1 and not COMPUTED_VALUE.search(decoded_name[0]):
1339
+ name = decoded_name[0]
1340
+ else:
1341
+ context["shadowed"] = True
1342
+ context["assignment_types_unknown"] = True
1343
+ definition_headers.add(index)
1344
+ if "{" in words:
1345
+ definition_bodies[index] = header_offset + words.index("{")
1346
+ elif index + 2 < len(statements) and "{" in statements[index + 2][1]:
1347
+ definition_bodies[index + 2] = statements[index + 2][1].index("{")
1348
+ discovered_functions.add(name)
1349
+ if name in {"dirname", "cd", "pwd"}:
1350
+ context["shadowed"] = True
1351
+ context["functions"] = context["functions"] | discovered_functions
1352
+ control_scope = 0
1353
+ paren_scope = 0
1354
+ brace_scope = 0
1355
+ function_frames = []
1356
+ previous_stdin = False
1357
+ for statement_index, (separator, raw_words) in enumerate(statements):
1358
+ if separator == "(" and statement_index and statements[statement_index - 1] == ("(", []):
1359
+ # Arithmetic evaluates variable contents as expressions; even a
1360
+ # seemingly unrelated counter can conceal a write through them.
1361
+ bindings.clear()
1362
+ if context is not None:
1363
+ context["cdpath"] = True
1364
+ context["shadowed"] = True
1365
+ if separator == "(":
1366
+ paren_scope += 1
1367
+ elif separator == ")":
1368
+ paren_scope = max(0, paren_scope - 1)
1369
+ if not raw_words:
1370
+ continue
1371
+ head = raw_words[0]
1372
+ enclosing_control = control_scope
1373
+ enclosing_parens = paren_scope
1374
+ normalized, openings, brace_openings = directory_command(raw_words)
1375
+ control_scope += openings
1376
+ if normalized and normalized[0] in {"for", "select", "case"}:
1377
+ control_scope += 1
1378
+ elif head in {"fi", "done", "esac"}:
1379
+ control_scope = max(0, control_scope - 1)
1380
+ brace_scope += brace_openings
1381
+ if normalized and normalized[0] == "function" and "{" in normalized:
1382
+ brace_scope += 1
1383
+ if head == "}":
1384
+ brace_scope = max(0, brace_scope - 1)
1385
+ if function_frames and brace_scope < function_frames[-1][0]:
1386
+ _, context, control_scope, paren_scope = function_frames.pop()
1387
+ bindings = context["bindings"] if context is not None else {}
1388
+ raw_words = raw_words[1:]
1389
+ if not raw_words:
1390
+ continue
1391
+ head = raw_words[0]
1392
+ if statement_index in definition_bodies and context is not None:
1393
+ if len(function_frames) >= FOLLOW_MAX_DEPTH:
1394
+ return [], [("an unproved function body beyond the inspection cap", head)]
1395
+ function_frames.append((brace_scope, context, enclosing_control, enclosing_parens))
1396
+ # A definition does not execute its body. Inspect potential file
1397
+ # executions without borrowing directory facts from the defining
1398
+ # moment: the body may be called after the caller changes them.
1399
+ # Its speculative writes must not erase the caller's facts either.
1400
+ context = {
1401
+ **context, "bindings": {},
1402
+ "cwd_known": False, "assignment_types_unknown": True,
1403
+ "startup_unknown": True,
1404
+ }
1405
+ bindings = context["bindings"]
1406
+ raw_words = raw_words[definition_bodies[statement_index] + 1:]
1407
+ if not raw_words:
1408
+ continue
1409
+ head = raw_words[0]
1410
+ scope = control_scope + paren_scope + brace_scope
1411
+ outgoing = statements[statement_index + 1][0] if statement_index + 1 < len(statements) else ""
1412
+ parent_context = context
1413
+ isolated = separator == "|" or outgoing in {"|", "&"}
1414
+ if context is not None and isolated:
1415
+ context = {**context, "bindings": bindings.copy()}
1416
+ bindings = context["bindings"]
1417
+ raw_command, _, _ = directory_command(raw_words)
1418
+ if raw_command and raw_command[0] in {"for", "select"} and len(raw_command) > 1:
1419
+ directory_variable_write(context, raw_command[1])
1420
+ if any(parent_expansion_writes(raw) for raw in raw_words):
1421
+ bindings.clear()
1422
+ if context is not None:
1423
+ context["shadowed"] = True
1424
+ context["cdpath"] = True
1425
+ context["ifs_default"] = False
1426
+ # Function definitions may shadow the commands used by dirname. Do
1427
+ # not treat a definition as an executed directory assignment.
1428
+ for raw_assignment in raw_command:
1429
+ assignment = DIRECTORY_ASSIGNMENT.fullmatch(raw_assignment)
1430
+ if assignment is None:
1431
+ break
1432
+ variable, operator, value = assignment.groups()
1433
+ proven = directory_assignment_value(value, context)
1434
+ # Bash's dynamic, readonly and array variables do not have the
1435
+ # ordinary scalar assignment/expansion semantics this proof uses.
1436
+ if variable in SPECIAL_DIRECTORY_VARIABLES or variable.startswith("BASH_"):
1437
+ proven = None
1438
+ if context is not None and variable == "BASH_SOURCE":
1439
+ context["file"] = None
1440
+ if context is not None and variable == "BASH_ARGV0":
1441
+ context["zero"] = None
1442
+ if raw_assignment.startswith(variable + "[") and not CONSTANT_ARRAY_ASSIGNMENT.match(raw_assignment):
1443
+ bindings.clear()
1444
+ if context is not None:
1445
+ context["shadowed"] = True
1446
+ context["ifs_default"] = False
1447
+ context["cdpath"] = True
1448
+ bindings.pop(variable, None)
1449
+ if context is not None and variable == "CDPATH":
1450
+ context["cdpath"] = True
1451
+ if context is not None and variable == "PATH":
1452
+ context["shadowed"] = True
1453
+ if context is not None and variable == "IFS":
1454
+ context["ifs_default"] = False
1455
+ if context is not None and variable in {"BASH_ENV", "ENV"}:
1456
+ context["startup_unknown"] = value not in {"", "''", '\"\"'}
1457
+ if len(raw_words) == 1 and not scope and not isolated and separator not in {"&&", "||", "|", "&"} and operator == "=" and head.startswith(variable + "="):
1458
+ if proven is not None:
1459
+ bindings[variable] = proven
1460
+ words = []
1461
+ for raw in raw_words:
1462
+ try:
1463
+ decoded = shlex.split(raw, posix=True)
1464
+ word = decoded[0] if len(decoded) == 1 else raw
1465
+ except ValueError:
1466
+ word = raw
1467
+ # Expansion is confined to one proven variable plus a literal
1468
+ # suffix. Single quotes and escaped dollars never get substituted.
1469
+ expanded = bound_directory_operand(raw, context)
1470
+ if expanded is not None:
1471
+ word = expanded
1472
+ if word in COMMAND_SEPARATORS:
1473
+ word = "\x00" + word
1474
+ words.append(word)
1475
+ # Prefix identity comes from raw words, before quote removal. A quoted
1476
+ # exclamation mark is an executable name, not the negation operator.
1477
+ command_words = words[len(raw_words) - len(raw_command):]
1478
+ program, args, _ = command_word(command_words)
1479
+ eval_context = {**context, "bindings": bindings.copy()} if context is not None and program == "eval" else context
1480
+ actual_head = next((word for word in command_words if not DIRECTORY_ASSIGNMENT.fullmatch(word)), None)
1481
+ if context is not None:
1482
+ called_function = (program in context["functions"] or actual_head in context["functions"]) and statement_index not in definition_headers
1483
+ arithmetic_test = program in {"test", "[", "[["} and any(
1484
+ arg in {"-v", "-R"} and index + 1 < len(args)
1485
+ and "[" in args[index + 1]
1486
+ and not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]", args[index + 1])
1487
+ for index, arg in enumerate(args)
1488
+ )
1489
+ if program == "[[":
1490
+ # [[ arithmetic comparisons evaluate variable contents as
1491
+ # expressions. Literal numbers and numeric special parameters
1492
+ # cannot conceal writes; test/[ instead parse integer strings.
1493
+ for index, arg in enumerate(args):
1494
+ if arg in {"-eq", "-ne", "-lt", "-le", "-gt", "-ge"}:
1495
+ operands = args[max(0, index - 1):index] + args[index + 1:index + 2]
1496
+ if len(operands) != 2 or any(not re.fullmatch(r"[+-]?[0-9]+|\$[?#$]", value) for value in operands):
1497
+ arithmetic_test = True
1498
+ if program in DIRECTORY_MUTATORS or called_function or arithmetic_test or (program == "printf" and "-v" in args) or (separator == "(" and head.startswith("(")):
1499
+ bindings.clear()
1500
+ context["cdpath"] = True
1501
+ context["ifs_default"] = False
1502
+ if program == "eval" or called_function or arithmetic_test:
1503
+ context["cwd_known"] = False
1504
+ context["shadowed"] = True
1505
+ context["assignment_types_unknown"] = True
1506
+ if program == "eval" or called_function:
1507
+ context["startup_unknown"] = True
1508
+ if program == "printf" and "-v" in args:
1509
+ target_index = args.index("-v") + 1
1510
+ directory_variable_write(context, args[target_index] if target_index < len(args) else "")
1511
+ elif program == "getopts":
1512
+ directory_variable_write(context, args[1] if len(args) > 1 else "")
1513
+ elif program in {"read", "readarray", "mapfile", "unset", "declare", "typeset", "local", "export", "readonly"}:
1514
+ for arg in args:
1515
+ if not arg.startswith("-"):
1516
+ directory_variable_write(context, arg)
1517
+ elif program == "let":
1518
+ directory_variable_write(context, "")
1519
+ invocation_context = context
1520
+ if context is not None and program in SHELL_PROGRAMS:
1521
+ operand = shell_execution_operand(args)
1522
+ if isinstance(operand, str) or operand is SHELL_COMMAND_STRING:
1523
+ prefix = command_words[:len(command_words) - len(args)]
1524
+ invocation_context = context.copy()
1525
+ if any(word.startswith("CDPATH=") for word in prefix):
1526
+ invocation_context["cdpath"] = True
1527
+ if any(word.startswith("PATH=") for word in prefix):
1528
+ invocation_context["shadowed"] = True
1529
+ if any(word.startswith(("BASH_ENV=", "ENV=")) and word.split("=", 1)[1] for word in prefix):
1530
+ invocation_context["startup_unknown"] = True
1531
+ startup_args = args[:args.index(operand)] if isinstance(operand, str) else args
1532
+ if shell_startup_unknown(startup_args):
1533
+ invocation_context["startup_unknown"] = True
1534
+ if context is not None:
1535
+ if program in {"alias", "unalias", "enable"}:
1536
+ context["shadowed"] = True
1537
+ if program in {"declare", "typeset", "local"} and any(arg.startswith("-") and any(flag in arg[1:] for flag in "nirlu") for arg in args):
1538
+ context["assignment_types_unknown"] = True
1539
+ trap_args = args[1:] if args and args[0] == "--" else args
1540
+ exit_only_trap = len(trap_args) >= 2 and all(signal in {"EXIT", "0"} for signal in trap_args[1:])
1541
+ if program == "readonly" or (program == "trap" and not exit_only_trap):
1542
+ context["assignment_types_unknown"] = True
1543
+ if program in {"cd", "pushd", "popd"}:
1544
+ context["cwd_known"] = False
1545
+ for variable, value in list(bindings.items()):
1546
+ if not os.path.isabs(value):
1547
+ del bindings[variable]
1548
+ # A conditional assignment is still a possible overwrite. Ignore
1549
+ # assignment-looking data belonging to an ordinary command.
1550
+ if head in {"then", "else", "do"}:
1551
+ for raw in raw_words[1:]:
1552
+ mutation = DIRECTORY_ASSIGNMENT.fullmatch(raw)
1553
+ if mutation:
1554
+ bindings.pop(mutation[1], None)
1555
+ tokens = (["bash", "<"] if separator == "<" and previous_stdin else []) + command_words
1556
+ paths, reasons = executed_scripts(tokens)
1557
+ if reasons:
1558
+ return [], reasons
1559
+ if program in SHELL_PROGRAMS:
1560
+ payload = nested_shell_payload([program, *args], 0)
1561
+ if isinstance(payload, str) and inspect_payload(payload, "execute", invocation_context):
1562
+ return [], [("a nested shell command bypasses git's verification hooks", program)]
1563
+ elif program == "eval":
1564
+ for payload in eval_payloads(command_words):
1565
+ if inspect_payload(payload, "source", eval_context):
1566
+ return [], [("an evaluated command bypasses git's verification hooks", program)]
1567
+ previous_stdin = program in SHELL_PROGRAMS and shell_execution_operand(args) is SHELL_STDIN
1568
+ for path, mode in paths:
1569
+ if context is not None and not context["cwd_known"] and not os.path.isabs(path):
1570
+ return [], [("a computed path after an unproved working-directory change", path)]
1571
+ before = bindings.copy()
1572
+ if inspect(path, mode, invocation_context):
1573
+ return [], [("a followed script bypasses git's verification hooks", path)]
1574
+ if mode == "source" and (scope or separator in {"&&", "||", "|", "&"}):
1575
+ for variable, value in list(bindings.items()):
1576
+ if variable not in before or before[variable] != value:
1577
+ del bindings[variable]
1578
+ if isolated:
1579
+ if separator == "|" and parent_context is not None:
1580
+ # The last pipeline command can run in the parent (`lastpipe`
1581
+ # or another shell). Retain only facts unchanged in both
1582
+ # execution models; never promote a child-only assignment.
1583
+ for variable, value in list(parent_context["bindings"].items()):
1584
+ if bindings.get(variable) != value:
1585
+ del parent_context["bindings"][variable]
1586
+ parent_context["cwd_known"] &= context["cwd_known"]
1587
+ parent_context["ifs_default"] &= context["ifs_default"]
1588
+ for fact in ("cdpath", "shadowed", "assignment_types_unknown", "startup_unknown"):
1589
+ parent_context[fact] |= context[fact]
1590
+ if parent_context["functions"] is not context["functions"]:
1591
+ parent_context["functions"] |= context["functions"]
1592
+ context = parent_context
1593
+ bindings = context["bindings"] if context is not None else {}
1594
+ return [], []
1595
+
1596
+
1079
1597
  def git_argv_disables_verification(argv):
1080
1598
  """Whether a git invocation's argv carries `--no-verify` or an abbreviation.
1081
1599
 
@@ -1398,17 +1916,47 @@ def eval_payloads(scoped_tokens):
1398
1916
  return payloads
1399
1917
 
1400
1918
 
1401
- def verdict(text, depth=0, followed=None):
1919
+ def shell_startup_unknown(args):
1920
+ """Whether invocation options permit uninspected interactive/login startup."""
1921
+ options = []
1922
+ index = 0
1923
+ while index < len(args):
1924
+ option = args[index]
1925
+ if option == "--" or not option.startswith(("-", "+")):
1926
+ break
1927
+ options.append(option)
1928
+ if option.startswith("-") and not option.startswith("--") and "c" in option[1:]:
1929
+ break
1930
+ index += 2 if option in SHELL_OPTIONS_SEPARATE_VALUE else 1
1931
+ interactive = any(option == "--interactive" or (option.startswith("-") and not option.startswith("--") and "i" in option[1:]) for option in options)
1932
+ login = any(option == "--login" or (option.startswith("-") and not option.startswith("--") and "l" in option[1:]) for option in options)
1933
+ return (interactive and "--norc" not in options) or (login and "--noprofile" not in options)
1934
+
1935
+
1936
+ def initial_directory_context(startup_unknown=False):
1937
+ startup_unknown |= bool(os.environ.get("BASH_ENV") or os.environ.get("ENV"))
1938
+ return {
1939
+ "file": None, "zero": None, "bindings": {}, "cwd_known": True,
1940
+ "cdpath": bool(os.environ.get("CDPATH")), "shadowed": False,
1941
+ "functions": frozenset(), "ifs_default": True,
1942
+ "assignment_types_unknown": startup_unknown,
1943
+ "startup_unknown": startup_unknown,
1944
+ }
1945
+
1946
+
1947
+ def verdict(text, depth=0, followed=None, context=None, follow_scripts=True):
1402
1948
  """Whether this text, or a script it runs, bypasses verification.
1403
1949
 
1404
1950
  Args:
1405
1951
  text: A command line or a followed script's contents.
1406
1952
  depth: Number of scripts already followed.
1407
- followed: Real paths already inspected, so a cycle terminates.
1953
+ followed: Shared inspection count, including repeats and nested payloads.
1408
1954
 
1409
1955
  Returns:
1410
1956
  True when the command must be refused.
1411
1957
  """
1958
+ if followed is None:
1959
+ followed = []
1412
1960
  if git_skips_verification(text):
1413
1961
  return True
1414
1962
  # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
@@ -1435,7 +1983,7 @@ def verdict(text, depth=0, followed=None):
1435
1983
  except ValueError:
1436
1984
  eval_scoped = []
1437
1985
  for payload in eval_payloads(eval_scoped):
1438
- if verdict(payload, depth + 1, followed):
1986
+ if verdict(payload, depth + 1, followed, follow_scripts=False):
1439
1987
  return True
1440
1988
  # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
1441
1989
  # into a `bash -c` payload, but only into ITSELF — so it carries the git
@@ -1450,50 +1998,91 @@ def verdict(text, depth=0, followed=None):
1450
1998
  if not shell_starts_command(eval_scoped, index):
1451
1999
  continue
1452
2000
  nested = nested_shell_payload(eval_scoped, index)
1453
- if isinstance(nested, str) and verdict(nested, depth + 1, followed):
2001
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed, follow_scripts=False):
1454
2002
  return True
1455
2003
 
1456
2004
  tokens = flat_tokens(text)
1457
2005
  if tokens is not None and token_bypass(tokens):
1458
2006
  return True
2007
+ if not follow_scripts:
2008
+ return False
1459
2009
  try:
1460
2010
  scoped_tokens = shell_tokens(text)
1461
2011
  except ValueError:
1462
2012
  # Text this parser cannot lex is left to the checks above, which is the
1463
2013
  # same accepted fail-open the header records for an unlexable command.
1464
2014
  return False
1465
- paths, reasons = executed_scripts(scoped_tokens)
1466
- if reasons:
1467
- reason, operand = reasons[0]
1468
- print(
1469
- f"block-no-verify: refusing an execution it cannot inspect — {reason}"
1470
- + (f" ({operand})" if operand else ""),
1471
- file=sys.stderr,
1472
- )
1473
- return True
1474
- if followed is None:
1475
- followed = set()
1476
- for path in paths:
1477
- try:
1478
- key = os.path.realpath(path)
1479
- except OSError:
1480
- key = path
1481
- if key in followed:
1482
- continue
2015
+
2016
+ def inspect(path, mode, parent_context):
2017
+ key = os.path.realpath(path)
2018
+ sourced = mode == "source"
1483
2019
  if len(followed) >= FOLLOW_MAX_FILES or depth >= FOLLOW_MAX_DEPTH:
1484
2020
  print(
1485
2021
  "block-no-verify: refusing at the script-following cap rather "
1486
- f"than skipping past it ({path})",
1487
- file=sys.stderr,
2022
+ f"than skipping past it ({path})", file=sys.stderr,
1488
2023
  )
1489
2024
  return True
1490
- followed.add(key)
1491
- if verdict(read_script(path), depth + 1, followed):
2025
+ # Count inspections rather than unique names: a repeated source must
2026
+ # be read under its current bindings, and still consumes the budget.
2027
+ followed.append(key)
2028
+ inherited = sourced and parent_context is not None
2029
+ child = {
2030
+ "file": None if mode == "stdin" else path,
2031
+ "zero": parent_context["zero"] if inherited else (path if mode == "execute" else None),
2032
+ "bindings": parent_context["bindings"] if inherited else {},
2033
+ "cwd_known": parent_context["cwd_known"] if parent_context else True,
2034
+ "cdpath": parent_context["cdpath"] if parent_context else bool(os.environ.get("CDPATH")),
2035
+ "shadowed": parent_context["shadowed"] if parent_context else False,
2036
+ # Exported functions can survive a new interpreter. Retain the
2037
+ # caller's known candidates conservatively; an unused definition
2038
+ # does not invalidate a binding, while a possible call does.
2039
+ "functions": parent_context["functions"],
2040
+ "ifs_default": parent_context["ifs_default"] if inherited else True,
2041
+ "assignment_types_unknown": parent_context["assignment_types_unknown"] if inherited else parent_context["startup_unknown"],
2042
+ "startup_unknown": parent_context["startup_unknown"],
2043
+ }
2044
+ refused = verdict(read_script(path), depth + 1, followed, child)
2045
+ if inherited:
2046
+ for fact in ("cwd_known", "cdpath", "shadowed", "ifs_default", "assignment_types_unknown", "startup_unknown", "functions", "file", "zero"):
2047
+ if fact == "file":
2048
+ if child[fact] is None:
2049
+ parent_context[fact] = None
2050
+ continue
2051
+ parent_context[fact] = child[fact]
2052
+ if refused:
1492
2053
  print(
1493
2054
  f"block-no-verify: {path} — a script this command runs — "
1494
- "bypasses git's verification hooks.",
1495
- file=sys.stderr,
2055
+ "bypasses git's verification hooks.", file=sys.stderr,
1496
2056
  )
2057
+ return refused
2058
+
2059
+ if context is None:
2060
+ context = initial_directory_context()
2061
+
2062
+ def inspect_payload(payload, mode, parent_context):
2063
+ if depth >= FOLLOW_MAX_DEPTH:
2064
+ print("block-no-verify: refusing at the script-following cap for a nested payload", file=sys.stderr)
2065
+ return True
2066
+ child = parent_context
2067
+ if mode == "execute":
2068
+ child = {
2069
+ **parent_context, "file": None, "zero": None, "bindings": {},
2070
+ "ifs_default": True,
2071
+ "assignment_types_unknown": parent_context["startup_unknown"],
2072
+ }
2073
+ return verdict(payload, depth + 1, followed, child)
2074
+
2075
+ paths, reasons = directory_reach(text, context, inspect, inspect_payload)
2076
+ if reasons:
2077
+ reason, operand = reasons[0]
2078
+ print(
2079
+ f"block-no-verify: refusing an execution it cannot inspect — {reason}"
2080
+ + (f" ({operand})" if operand else ""),
2081
+ file=sys.stderr,
2082
+ )
2083
+ return True
2084
+ for path, mode in paths:
2085
+ if inspect(path, mode, context):
1497
2086
  return True
1498
2087
  return False
1499
2088