@codyswann/lisa 4.72.3 → 4.72.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +621 -32
- package/dist/codex/scripts/block-no-verify.sh +621 -32
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +10 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +621 -32
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-no-verify.agy.sh +621 -32
- package/plugins/lisa/hooks/block-no-verify.sh +621 -32
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +621 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +621 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +621 -32
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +621 -32
- 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
|
|
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:
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
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
|
|