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