@codyswann/lisa 4.30.0 → 4.31.1

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 (71) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net-heredoc.py +961 -0
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +14 -12
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/expo/create-only/.github/required-checks.json +1 -1
  10. package/nestjs/create-only/.github/required-checks.json +1 -1
  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-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-agy/plugin.json +1 -1
  25. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  45. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-agy/plugin.json +1 -1
  50. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  55. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  60. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/materialized-artifacts.json +1 -0
  63. package/scripts/build-plugins.sh +25 -0
  64. package/scripts/materialize-copy-overwrite.mjs +9 -0
  65. package/scripts/two-channel-couplings.json +1 -1
  66. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +86 -143
  67. package/typescript/create-only/.github/required-checks.json +1 -1
  68. package/typescript/create-only/.github/workflows/review-evidence.yml +1 -1
  69. package/typescript/deletions.json +2 -1
  70. package/typescript/package-lisa/package.lisa.json +2 -2
  71. package/typescript/create-only/.github/workflows/required-checks-drift.yml +0 -91
@@ -0,0 +1,961 @@
1
+ #!/usr/bin/env python3
2
+ # This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ # Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ """Classify the only heredoc forms whose payload is non-executable text.
6
+
7
+ SCOPE — read this before hardening anything here (issue #1993).
8
+
9
+ This module is a HEURISTIC RE-IMPLEMENTATION of bash's here-doc lexing, not an
10
+ adversary-resistant control. Every divergence between this Python model and real
11
+ bash is, by construction, a potential mis-classification, and five rounds of
12
+ fix-then-re-attack on issue #1958 plus three more here (#1993) established that
13
+ enumerating those divergences is an open-ended arms race. Treat the class as
14
+ CLOSED: a newly found member is documented, not chased, unless someone first
15
+ demonstrates it defeats the CONTENT GUARDS, which are the layer that matters.
16
+
17
+ The one hard guarantee, and the reason a mis-classification is survivable:
18
+
19
+ Exit SAFE(0) is the ONLY path that strips payload text, and it requires the
20
+ narrow, fully-quoted `gh issue|pr create/edit/comment` + `<<'EOF'` grammar in
21
+ ``classify_safe``. EVERY other path — UNSUPPORTED(10) and MALFORMED(20) —
22
+ hands the RAW, unmodified command to the content guards. So a here-doc the
23
+ classifier mis-reads degrades to the guards; it does not bypass them. Any
24
+ change here must preserve that property: widen ``classify_safe`` only with
25
+ proof, and keep every other exit raw-pass-through.
26
+
27
+ Known accepted bypass class (measured, deliberately not chased further):
28
+ R5a — a trailing backslash on the last body line is a bash continuation that
29
+ swallows the terminator; R5b — an odd apostrophe in an unquoted body poisons a
30
+ flat quote scanner past the terminator; R5c — `<<\\DELIM` is a quoted delimiter
31
+ this parser did not model. All three are FIXED below, but they are members of a
32
+ family, not the family itself. Each requires deliberately-crafted input; none is
33
+ a shape an agent emits by accident; and post-#1982 every destructive class the
34
+ safety net exists to stop is independently blocked by the content guards on the
35
+ raw text.
36
+
37
+ The two bash invariants this file relies on — both measured directly against
38
+ /bin/bash, not inferred — so a future round starts from a spec:
39
+
40
+ 1. UNQUOTED delimiter (`<<EOF`): the body performs NO quote or comment
41
+ processing (`'`, `"`, `#` are literal data bytes) but STILL expands `$(...)`,
42
+ backticks and parameters, and bash removes `\\<newline>` continuations BEFORE
43
+ matching the delimiter. Measured: `cat <<EOF` / `foo\\` / `EOF` / `bar` emits
44
+ `fooEOF\\nbar` — the here-doc never terminated.
45
+ 2. QUOTED delimiter in any spelling (`<<'EOF'`, `<<"EOF"`, `<<\\EOF`): the body is
46
+ fully literal, no expansion of any kind, and backslash is DATA rather than an
47
+ escape — so the delimiter IS matched per physical line there. Measured: the
48
+ same trailing-backslash shape under `<<'EOF'` emits `foo\\` and then fails on
49
+ `bar: command not found`.
50
+
51
+ That asymmetry is why the continuation handling in ``terminator_line_index`` is
52
+ conditioned on ``Marker.quoted`` rather than applied uniformly.
53
+
54
+ ``Marker.quoted`` means "bash makes this BODY literal" and nothing more. The
55
+ three spellings above are interchangeable for body semantics ONLY — they are NOT
56
+ interchangeable on the writer path. ``classify_safe`` still demands the literal
57
+ ``<<'DELIM'`` spelling, so `<<"EOF"` and `<<\\EOF` can never reach SAFE(0); since
58
+ #1993 they additionally record a Marker, which arms the writer-owns-a-real-marker
59
+ and multi-marker rules and lands a `gh … <<\\EOF` writer on MALFORMED(20) where it
60
+ used to be UNSUPPORTED(10). That is a deliberate, test-pinned over-block: the
61
+ documented payload-strip workaround is spelled `<<'EOF'`. Widening
62
+ ``classify_safe`` to admit other spellings is a separate change needing its own
63
+ proof — do not do it as a side effect of body-semantics work.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import re
69
+ import shlex
70
+ import sys
71
+ from dataclasses import dataclass
72
+
73
+ SAFE = 0
74
+ UNSUPPORTED = 10
75
+ MALFORMED = 20
76
+
77
+ DELIMITER = r"(?P<delimiter>[A-Za-z_][A-Za-z0-9_]*)"
78
+ BODY_CAT_MARKER = re.compile(
79
+ r"(?P<prefix>.*(?:^|\s)--body\s+)\"\$\(cat\s+<<'"
80
+ + DELIMITER
81
+ + r"'\s*$"
82
+ )
83
+ ALLOWED_GROUPS = {"issue", "pr"}
84
+ ALLOWED_ACTIONS = {"create", "edit", "comment"}
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Marker:
89
+ start: int
90
+ end: int
91
+ delimiter: str
92
+ strip_tabs: bool
93
+ # True ONLY when the whole delimiter token was wrapped in one full single-
94
+ # or double-quote pair (<<'EOF' / <<"EOF") with nothing word-like attached
95
+ # after the closing quote. That is the only form whose body this parser can
96
+ # PROVE bash treats as non-expanding literal data (issue #1958).
97
+ quoted: bool
98
+
99
+
100
+ def is_bash_blank(char: str) -> bool:
101
+ """True only for a byte bash treats as a word separator here: space, tab,
102
+ newline.
103
+
104
+ Python ``str.isspace()`` is a strict SUPERSET of bash's word-separator set —
105
+ it is also True for NBSP (``\\xa0``), the C0 separators FS/GS/RS/US
106
+ (``\\x1c``–``\\x1f``), NEL (``\\x85``), VT/FF (``\\x0b``/``\\x0c``), CR
107
+ (``\\r``), and the Unicode spaces (ideographic space ``\\u3000``, the
108
+ ``\\u2000``–``\\u200a`` run, ``\\u202f``, ``\\u205f``, ``\\u2028``/``\\u2029``).
109
+ Bash (C locale) treats NONE of these as a blank or a metacharacter, so a
110
+ ``#`` preceded by one stays INSIDE the current word — it does NOT start a
111
+ comment — and any ``$(...)`` / `` `...` `` in that word is still expanded and
112
+ EXECUTED. Every comment-boundary walker here must therefore match bash's
113
+ actual blank set, not Python's: using ``str.isspace()`` let the scanner call
114
+ ``#`` a comment where bash does not, skip to the newline, and go blind to a
115
+ live substitution — smuggling arbitrary command execution past the wall
116
+ (issue #1958 Finding R2). This is the single shared home of that predicate so
117
+ every walker stays in lockstep with bash's word-boundary rule; fail-closed on
118
+ ambiguity means narrowing (fewer bytes counted as blank), never widening.
119
+ """
120
+ return char in " \t\n"
121
+
122
+
123
+ def bash_lines(text: str) -> list[str]:
124
+ """Split into bash's notion of lines: on ``\\n`` ONLY.
125
+
126
+ Python ``str.splitlines()`` is the same over-broad classifier as
127
+ ``str.isspace()`` in disguise — it ALSO breaks on ``\\r``, VT/FF
128
+ (``\\x0b``/``\\x0c``), the C0 separators FS/GS/RS (``\\x1c``–``\\x1e``), NEL
129
+ (``\\x85``), and the Unicode line separators (``\\u2028``/``\\u2029``). Bash
130
+ ends a line only at an unquoted ``\\n``, so ``splitlines()`` invents line
131
+ breaks bash never sees. The concrete hole: ``strip_provably_literal_body``
132
+ splits with ``splitlines()`` then rejoins with ``\\n``, so a ``echo X\\x1c#$(…)``
133
+ argument gets normalised into ``echo X`` / ``#$(…)`` on separate lines — now
134
+ the ``#`` sits at a real line start, is treated as a comment, and the live
135
+ ``$(…)`` is smuggled past the wall exactly as the ``str.isspace()`` desync
136
+ did (issue #1958 Finding R2, FS/ideographic-space variant). Splitting on
137
+ ``\\n`` alone keeps this parser's lines in lockstep with bash and with the
138
+ ``command.count("\\n", …)`` offset arithmetic the markers already rely on.
139
+ """
140
+ return text.split("\n")
141
+
142
+
143
+ def ansi_c_quote_end(text: str, dollar_index: int) -> int | None:
144
+ """Index one past a bash ANSI-C ``$'...'`` token, or ``None``.
145
+
146
+ When an *unquoted* ``$`` is immediately followed by ``'`` bash opens an
147
+ ANSI-C-quoted string that ends at the first *unescaped* ``'``: a backslash
148
+ escapes the next single character, so ``\\'`` is a literal quote (does NOT
149
+ close the string) and ``\\\\`` is a literal backslash. Only literal
150
+ single quotes act as delimiters — value-only escapes like ``\\x27`` /
151
+ ``\\047`` / ``\\uHHHH`` decode to a quote *character* but never terminate
152
+ the token, so scanning for the first unescaped ``'`` yields exactly bash's
153
+ token boundary for every spelling.
154
+
155
+ A naive single-quote scanner that ignores ``$'...'`` desyncs from bash on an
156
+ odd number of ``\\'`` escapes: it reads three bare quotes as
157
+ single→plain→single and ends in a phantom open single-quote, going blind to
158
+ everything after it — including a live ``$(...)`` the following ``"`` really
159
+ exposes (issue #1958 Finding R1). Consuming the whole inert token here keeps
160
+ every shared walker in lockstep with bash.
161
+
162
+ Returns the index just past the closing quote, or ``None`` for a
163
+ non-``$'`` position or an unterminated token so callers fail closed. This is
164
+ the single shared home of ANSI-C token boundaries; every quote-state walker
165
+ consults it rather than re-deriving the rule.
166
+ """
167
+ if not text.startswith("$'", dollar_index):
168
+ return None
169
+ index = dollar_index + 2
170
+ length = len(text)
171
+ while index < length:
172
+ char = text[index]
173
+ if char == "\\":
174
+ index += 2
175
+ continue
176
+ if char == "'":
177
+ return index + 1
178
+ index += 1
179
+ return None
180
+
181
+
182
+ def shell_tokens(prefix: str) -> list[str] | None:
183
+ """Return literal simple-command tokens, rejecting executable shell syntax."""
184
+ state = "plain"
185
+ escaped = False
186
+ index = 0
187
+ while index < len(prefix):
188
+ char = prefix[index]
189
+ if escaped:
190
+ escaped = False
191
+ elif state == "single":
192
+ if char == "'":
193
+ state = "plain"
194
+ elif state == "double":
195
+ if char == '"':
196
+ state = "plain"
197
+ elif char == "\\":
198
+ escaped = True
199
+ elif char == "`" or (
200
+ char == "$"
201
+ and index + 1 < len(prefix)
202
+ and prefix[index + 1] in "([{"):
203
+ return None
204
+ elif prefix.startswith("$'", index):
205
+ end = ansi_c_quote_end(prefix, index)
206
+ if end is None:
207
+ return None
208
+ index = end
209
+ continue
210
+ elif char == "'":
211
+ state = "single"
212
+ elif char == '"':
213
+ state = "double"
214
+ elif char == "\\":
215
+ escaped = True
216
+ elif char == "#" and (index == 0 or is_bash_blank(prefix[index - 1])):
217
+ return None
218
+ elif char in ";|&()<>`\n\r":
219
+ return None
220
+ elif char == "$" and index + 1 < len(prefix) and prefix[index + 1] in "([{":
221
+ return None
222
+ index += 1
223
+
224
+ if state != "plain" or escaped:
225
+ return None
226
+ try:
227
+ return shlex.split(prefix, comments=False, posix=True)
228
+ except ValueError:
229
+ return None
230
+
231
+
232
+ def is_allowed_gh(tokens: list[str]) -> bool:
233
+ return (
234
+ len(tokens) >= 3
235
+ and tokens[0] == "gh"
236
+ and tokens[1] in ALLOWED_GROUPS
237
+ and tokens[2] in ALLOWED_ACTIONS
238
+ )
239
+
240
+
241
+ def line_has_allowed_writer(line: str) -> bool:
242
+ """Find a writer after shell quote-concatenation and control operators."""
243
+ try:
244
+ lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|()")
245
+ lexer.whitespace_split = True
246
+ lexer.commenters = "#"
247
+ tokens = list(lexer)
248
+ except ValueError:
249
+ return False
250
+ return any(
251
+ is_allowed_gh(tokens[index : index + 3])
252
+ for index in range(max(0, len(tokens) - 2))
253
+ )
254
+
255
+
256
+ def has_body_file_stdin(tokens: list[str]) -> bool:
257
+ return "--body-file=-" in tokens or any(
258
+ tokens[index : index + 2] == ["--body-file", "-"]
259
+ for index in range(len(tokens) - 1)
260
+ )
261
+
262
+
263
+ def exact_terminator(lines: list[str], delimiter: str, start: int) -> int | None:
264
+ for index in range(start, len(lines)):
265
+ if lines[index] == delimiter:
266
+ return index
267
+ return None
268
+
269
+
270
+ def only_whitespace(lines: list[str], start: int) -> bool:
271
+ return all(not line.strip() for line in lines[start:])
272
+
273
+
274
+ def classify_safe(command: str) -> str | None:
275
+ lines = bash_lines(command)
276
+ if len(lines) < 2 or "\x00" in command or "\r" in command:
277
+ return None
278
+
279
+ header = lines[0]
280
+ body_cat = BODY_CAT_MARKER.search(header)
281
+ if body_cat is not None:
282
+ tokens = shell_tokens(body_cat.group("prefix"))
283
+ if tokens is None or not is_allowed_gh(tokens):
284
+ return None
285
+ terminator = exact_terminator(lines, body_cat.group("delimiter"), 1)
286
+ if terminator is None:
287
+ raise ValueError("unclosed body cat heredoc")
288
+ if terminator + 1 >= len(lines) or lines[terminator + 1].strip() != ')"':
289
+ raise ValueError("body cat substitution is not closed exactly")
290
+ if not only_whitespace(lines, terminator + 2):
291
+ raise ValueError("command follows body cat heredoc")
292
+ return body_cat.group("prefix") + '"<heredoc-text>"'
293
+
294
+ header_markers = top_level_markers(header)
295
+ if len(header_markers) != 1:
296
+ return None
297
+ direct = header_markers[0]
298
+ raw_marker = header[direct.start : direct.end]
299
+ if raw_marker != f"<<'{direct.delimiter}'" or header[direct.end :].strip():
300
+ return None
301
+ prefix = header[: direct.start]
302
+ tokens = shell_tokens(prefix)
303
+ if tokens is None or not is_allowed_gh(tokens) or not has_body_file_stdin(tokens):
304
+ return None
305
+ terminator = exact_terminator(lines, direct.delimiter, 1)
306
+ if terminator is None:
307
+ raise ValueError("unclosed direct heredoc")
308
+ if not only_whitespace(lines, terminator + 1):
309
+ raise ValueError("command follows direct heredoc")
310
+ return prefix.rstrip()
311
+
312
+
313
+ def top_level_markers(command: str) -> list[Marker]:
314
+ """Find conservative top-level markers for malformed/duplicate detection."""
315
+ markers: list[Marker] = []
316
+ lines = bash_lines(command)
317
+ offset = 0
318
+ for line in lines:
319
+ state = "plain"
320
+ escaped = False
321
+ index = 0
322
+ while index < len(line):
323
+ char = line[index]
324
+ if escaped:
325
+ escaped = False
326
+ elif state == "single":
327
+ if char == "'":
328
+ state = "plain"
329
+ elif state == "double":
330
+ if char == '"':
331
+ state = "plain"
332
+ elif char == "\\":
333
+ escaped = True
334
+ elif line.startswith("$'", index):
335
+ end = ansi_c_quote_end(line, index)
336
+ if end is None:
337
+ break
338
+ index = end
339
+ continue
340
+ elif char == "'":
341
+ state = "single"
342
+ elif char == '"':
343
+ state = "double"
344
+ elif char == "\\":
345
+ escaped = True
346
+ elif char == "#" and (index == 0 or is_bash_blank(line[index - 1])):
347
+ break
348
+ elif line.startswith("<<", index) and not line.startswith("<<<", index):
349
+ marker = parse_marker(line, index, offset)
350
+ if marker is not None:
351
+ markers.append(marker)
352
+ index = marker.end - offset - 1
353
+ index += 1
354
+ offset += len(line) + 1
355
+ return markers
356
+
357
+
358
+ def unquoted_code_and_comment(line: str) -> tuple[str, str]:
359
+ """Return executable text and any shell comment, hiding quoted prose."""
360
+ code: list[str] = []
361
+ state = "plain"
362
+ escaped = False
363
+ index = 0
364
+ while index < len(line):
365
+ char = line[index]
366
+ if escaped:
367
+ code.append(" ")
368
+ escaped = False
369
+ elif state == "single":
370
+ code.append(" ")
371
+ if char == "'":
372
+ state = "plain"
373
+ elif state == "double":
374
+ code.append(" ")
375
+ if char == '"':
376
+ state = "plain"
377
+ elif char == "\\":
378
+ escaped = True
379
+ elif line.startswith("$'", index):
380
+ end = ansi_c_quote_end(line, index)
381
+ if end is None:
382
+ code.append(" ")
383
+ index += 1
384
+ continue
385
+ code.append(" " * (end - index))
386
+ index = end
387
+ continue
388
+ elif char == "'":
389
+ code.append(" ")
390
+ state = "single"
391
+ elif char == '"':
392
+ code.append(" ")
393
+ state = "double"
394
+ elif char == "\\":
395
+ code.append(" ")
396
+ escaped = True
397
+ elif char == "#" and (index == 0 or is_bash_blank(line[index - 1])):
398
+ return "".join(code), line[index + 1 :]
399
+ else:
400
+ code.append(char)
401
+ index += 1
402
+ return "".join(code), ""
403
+
404
+
405
+ def collapse_line_continuations(command: str) -> str:
406
+ """Remove Bash backslash-newline pairs outside single-quoted text."""
407
+ result: list[str] = []
408
+ state = "plain"
409
+ escaped = False
410
+ index = 0
411
+ while index < len(command):
412
+ char = command[index]
413
+ if escaped:
414
+ result.append(char)
415
+ escaped = False
416
+ elif state == "single":
417
+ result.append(char)
418
+ if char == "'":
419
+ state = "plain"
420
+ elif char == "\\" and command.startswith("\n", index + 1):
421
+ index += 2
422
+ continue
423
+ elif char == "\\":
424
+ result.append(char)
425
+ escaped = True
426
+ elif state == "plain" and command.startswith("$'", index):
427
+ end = ansi_c_quote_end(command, index)
428
+ if end is None:
429
+ result.append(char)
430
+ else:
431
+ # Preserve the whole inert ANSI-C token verbatim so its bytes
432
+ # stay a single quoted unit to every downstream walker; a
433
+ # backslash-newline inside it is part of the token, not a line
434
+ # continuation to strip.
435
+ result.append(command[index:end])
436
+ index = end
437
+ continue
438
+ elif char == "'" and state == "plain":
439
+ result.append(char)
440
+ state = "single"
441
+ elif char == '"':
442
+ result.append(char)
443
+ state = "plain" if state == "double" else "double"
444
+ else:
445
+ result.append(char)
446
+ index += 1
447
+ return "".join(result)
448
+
449
+
450
+ def writer_owns_real_marker(command: str, markers: list[Marker]) -> bool:
451
+ """Detect a supported writer on a line containing a real heredoc marker."""
452
+ logical_command = collapse_line_continuations(command)
453
+ logical_markers = (
454
+ markers if logical_command == command else top_level_markers(logical_command)
455
+ )
456
+ lines = bash_lines(logical_command)
457
+ for marker in logical_markers:
458
+ line_index = logical_command.count("\n", 0, marker.start)
459
+ if line_has_allowed_writer(lines[line_index]):
460
+ return True
461
+ return False
462
+
463
+
464
+ def writer_has_commented_marker_and_following_code(command: str) -> bool:
465
+ """Reject fake writer markers whose following lines would execute."""
466
+ lines = bash_lines(command)
467
+ for index, line in enumerate(lines):
468
+ _code, comment = unquoted_code_and_comment(line)
469
+ if (
470
+ line_has_allowed_writer(line)
471
+ and "<<" in comment
472
+ and any(candidate.strip() for candidate in lines[index + 1 :])
473
+ ):
474
+ return True
475
+ return False
476
+
477
+
478
+ def has_active_command_substitution(command: str) -> bool:
479
+ """Detect substitutions outside single-quoted text and shell comments."""
480
+ state = "plain"
481
+ escaped = False
482
+ index = 0
483
+ while index < len(command):
484
+ char = command[index]
485
+ if escaped:
486
+ escaped = False
487
+ elif state == "single":
488
+ if char == "'":
489
+ state = "plain"
490
+ elif state == "double":
491
+ if char == '"':
492
+ state = "plain"
493
+ elif char == "\\":
494
+ escaped = True
495
+ elif char == "`" or command.startswith("$(", index):
496
+ return True
497
+ elif command.startswith("$'", index):
498
+ end = ansi_c_quote_end(command, index)
499
+ if end is None:
500
+ return True
501
+ index = end
502
+ continue
503
+ elif char == "'":
504
+ state = "single"
505
+ elif char == '"':
506
+ state = "double"
507
+ elif char == "\\":
508
+ escaped = True
509
+ elif char == "#" and (
510
+ index == 0 or is_bash_blank(command[index - 1])
511
+ ):
512
+ newline = command.find("\n", index)
513
+ if newline < 0:
514
+ return False
515
+ index = newline
516
+ elif char == "`" or command.startswith("$(", index):
517
+ return True
518
+ index += 1
519
+ return False
520
+
521
+
522
+ def collapse_body_continuations(body: str) -> str:
523
+ """Remove bash ``\\<newline>`` line continuations under heredoc-body rules.
524
+
525
+ An UNQUOTED here-doc body performs NO quote processing (``'``/``"``/``#`` are
526
+ literal bytes), but bash STILL removes an unescaped ``\\<newline>`` — the
527
+ bash manual: for an unquoted here-doc "the character sequence ``\\newline`` is
528
+ ignored". The whole-command ``collapse_line_continuations`` applies a FLAT
529
+ shell quote state, so a single body apostrophe flips it into a phantom
530
+ single-quoted string and it REFUSES to join a ``$\\<newline>(`` continuation,
531
+ leaving ``$`` and ``(`` on separate lines where the per-line substitution
532
+ scan sees neither — smuggling a live ``$(...)`` past the wall (issue #1958
533
+ Finding R4). The body window has no quote state to get wrong, so this joins
534
+ continuations with backslash-only semantics: a backslash escapes the next
535
+ single byte (so ``\\\\`` is a literal backslash whose following newline is
536
+ real, and ``\\$`` cannot start a substitution), and a backslash directly
537
+ before a newline is dropped together with that newline. Feed the joined body
538
+ to ``body_line_has_substitution`` so a split ``$(`` is scanned as the one
539
+ contiguous token bash will execute. Fail-closed: a trailing lone backslash is
540
+ kept verbatim (it can start no substitution).
541
+ """
542
+ result: list[str] = []
543
+ index = 0
544
+ length = len(body)
545
+ while index < length:
546
+ char = body[index]
547
+ if char == "\\":
548
+ if index + 1 < length and body[index + 1] == "\n":
549
+ index += 2
550
+ continue
551
+ result.append(body[index : index + 2])
552
+ index += 2
553
+ continue
554
+ result.append(char)
555
+ index += 1
556
+ return "".join(result)
557
+
558
+
559
+ def trailing_continuation(line: str) -> str | None:
560
+ """Strip a bash ``\\<newline>`` continuation backslash, or ``None``.
561
+
562
+ A here-doc body line ends in a continuation exactly when it ends in an ODD
563
+ run of backslashes: each pair is one escaped literal backslash, so a leftover
564
+ single backslash is the one that escapes the following newline. Parity of the
565
+ TRAILING run is sufficient — any backslash earlier in the line is separated
566
+ from the run by a non-backslash byte, which ends that escape.
567
+ """
568
+ stripped = line.rstrip("\\")
569
+ if (len(line) - len(stripped)) % 2 == 0:
570
+ return None
571
+ return line[:-1]
572
+
573
+
574
+ def terminator_line_index(command: str, marker: Marker) -> int | None:
575
+ """Index of the physical line that closes ``marker``, or ``None``.
576
+
577
+ THE single home of "where does this here-doc body end", consulted by the
578
+ closure check, the body scanner, the literal-body strip and the body
579
+ neutraliser so no two of them can ever disagree about the window (issue
580
+ #1993 R5a).
581
+
582
+ Bash removes ``\\<newline>`` continuations from an UNQUOTED here-doc body
583
+ BEFORE it matches the delimiter, so a trailing backslash on the last body
584
+ line JOINS that line to the delimiter line and the here-doc is NOT closed
585
+ there — the real body swallows the apparent terminator and keeps consuming
586
+ following lines, where a ``$(...)`` the classifier believed was ordinary
587
+ post-here-doc text is expanded and EXECUTED. Matching the delimiter per
588
+ PHYSICAL line therefore under-reads the body window for unquoted delimiters.
589
+ Measured ground truth: ``cat <<EOF`` / ``foo\\`` / ``EOF`` / ``bar`` prints
590
+ ``fooEOF\\nbar`` (never terminated), while the same shape with ``<<'EOF'``
591
+ prints ``foo\\`` and then fails on ``bar: command not found``.
592
+
593
+ A QUOTED (or backslash-quoted) delimiter makes the body fully literal —
594
+ backslash is DATA there, not an escape — so those markers keep the exact
595
+ per-physical-line match, which the same measurement proves correct.
596
+ """
597
+ lines = bash_lines(command)
598
+ marker_line = command.count("\n", 0, marker.start)
599
+ pending: list[str] = []
600
+ for index in range(marker_line + 1, len(lines)):
601
+ line = lines[index]
602
+ if not marker.quoted:
603
+ continued = trailing_continuation(line)
604
+ if continued is not None:
605
+ pending.append(continued)
606
+ continue
607
+ candidate = "".join(pending) + line
608
+ pending = []
609
+ if marker.strip_tabs:
610
+ candidate = candidate.lstrip("\t")
611
+ if candidate == marker.delimiter:
612
+ return index
613
+ return None
614
+
615
+
616
+ def neutralize_heredoc_bodies(command: str, markers: list[Marker]) -> str:
617
+ """Blank every real here-doc body window, preserving offsets.
618
+
619
+ ``has_active_command_substitution`` runs ONE flat single/double-quote state
620
+ machine across the whole command — including here-doc bodies, where bash
621
+ applies NO quote processing at all. An odd apostrophe in an unquoted body
622
+ (``it's fine``) therefore opens a phantom single-quoted string in the flat
623
+ scanner that persists PAST the terminator, hiding a live ``$(...)`` on a
624
+ FOLLOWING command line that bash happily executes (issue #1993 R5b). The
625
+ trigger is precisely an odd ``'`` count: an even ``''``, an odd ``"``, a
626
+ ``#``, an ANSI-C ``$'a`` and a lone trailing backslash all leave the flat
627
+ state correct, which is what pins the defect to this one branch.
628
+
629
+ ``strip_provably_literal_body`` cannot fix this: it bails whenever the marker
630
+ is unquoted, so the poisoning body is exactly the one it never touches. So
631
+ blank the body window of EVERY marker bash really treats as a here-doc
632
+ redirection before the flat scan runs. Bytes are replaced with spaces and
633
+ newlines are preserved, so marker offsets and line numbering stay valid for
634
+ every later walker.
635
+
636
+ Blanking loses nothing: a substitution INSIDE an unquoted body is still
637
+ caught by ``unquoted_heredoc_body_has_substitution`` (scanned with correct
638
+ here-doc-body semantics), and a quoted body is genuinely inert to bash. A
639
+ marker that is not closed blanks to end of command — that command is
640
+ MALFORMED on the closure check regardless, and its trailing region is still
641
+ body-scanned. As everywhere else, a marker nested inside an open quoted
642
+ string is NOT a here-doc to bash and is skipped, so its "body" stays fully
643
+ visible to the flat scan (issue #1958 Finding 1).
644
+ """
645
+ lines = bash_lines(command)
646
+ blanked = list(lines)
647
+ for marker in markers:
648
+ if cross_line_quote_state(command, marker.start) != "plain":
649
+ continue
650
+ marker_line = command.count("\n", 0, marker.start)
651
+ end = terminator_line_index(command, marker)
652
+ stop = len(lines) if end is None else end
653
+ for index in range(marker_line + 1, stop):
654
+ blanked[index] = " " * len(lines[index])
655
+ return "\n".join(blanked)
656
+
657
+
658
+ def body_line_has_substitution(line: str) -> bool:
659
+ """True if an unquoted-heredoc-body line contains an active substitution.
660
+
661
+ In an unquoted-delimiter heredoc body bash suppresses ALL quote and comment
662
+ processing — ``'``, ``"`` and ``#`` are literal bytes — but keeps
663
+ parameter/command/arithmetic expansion active, so ``$(...)`` and `` `...` ``
664
+ still EXECUTE. The only in-body metacharacter is the backslash, which escapes
665
+ the following ``$``, `` ` ``, ``\\`` or newline; every other byte (including
666
+ the quotes and ``#`` that blind the flat scanner) is inert text. So scan the
667
+ body raw, honouring only that escaping, and flag a bare ``$(`` or backtick —
668
+ the same command-substitution tokens ``has_active_command_substitution``
669
+ detects elsewhere, just without the quote/comment state machine that is wrong
670
+ for this region (issue #1958 Finding R3).
671
+ """
672
+ index = 0
673
+ length = len(line)
674
+ while index < length:
675
+ char = line[index]
676
+ if char == "\\":
677
+ index += 2
678
+ continue
679
+ if char == "`" or line.startswith("$(", index):
680
+ return True
681
+ index += 1
682
+ return False
683
+
684
+
685
+ def unquoted_heredoc_body_has_substitution(
686
+ command: str, markers: list[Marker]
687
+ ) -> bool:
688
+ """Detect a live substitution inside an unquoted top-level heredoc body.
689
+
690
+ ``has_active_command_substitution`` applies flat shell quote/comment
691
+ semantics to the whole command, but an UNQUOTED ``<<EOF`` body follows
692
+ different rules (see ``body_line_has_substitution``): a lone apostrophe or
693
+ ``#`` in the body flips the flat scanner's state and hides a live
694
+ ``$(...)``/backtick that bash nonetheless expands, smuggling execution past
695
+ the wall (issue #1958 Finding R3). This complements the flat scan by walking
696
+ only the body window of each unquoted, top-level, closed heredoc with
697
+ heredoc-body semantics.
698
+
699
+ A quoted-delimiter body (``Marker.quoted``) is genuinely inert to bash and is
700
+ skipped — that is the whole point of the Finding-1 literal-payload win.
701
+ Before scanning, the marker is re-verified to sit at bash top level under
702
+ CROSS-LINE quote tracking (mirroring ``strip_provably_literal_body``): a
703
+ heredoc-shaped ``<<EOF`` nested inside an open single-quoted string is not a
704
+ real heredoc — its body is literal string data bash never expands — so
705
+ scanning it would over-block ordinary quoted prose; and one nested inside an
706
+ open double-quoted string has its ``$(...)`` already caught by the flat scan.
707
+ Either way, only markers bash actually treats as top-level heredoc
708
+ redirections get the body scan.
709
+ """
710
+ lines = bash_lines(command)
711
+ for marker in markers:
712
+ if marker.quoted:
713
+ continue
714
+ if cross_line_quote_state(command, marker.start) != "plain":
715
+ continue
716
+ marker_line = command.count("\n", 0, marker.start)
717
+ end = terminator_line_index(command, marker)
718
+ stop = len(lines) if end is None else end
719
+ body_lines = lines[marker_line + 1 : stop]
720
+ # Join the raw body window under bash's unquoted-here-doc ``\<newline>``
721
+ # removal BEFORE scanning, so a ``$(`` a caller split across a line
722
+ # continuation is seen as the one contiguous token bash executes. The
723
+ # window itself comes from ``terminator_line_index``, which applies that
724
+ # SAME removal before matching the delimiter — because bash does (issue
725
+ # #1993 R5a). An earlier revision matched the terminator per physical
726
+ # line here on the belief that bash matches the delimiter before
727
+ # continuation processing; that is false for an unquoted delimiter, and
728
+ # it let a trailing backslash on the last body line swallow the
729
+ # terminator so this scan stopped short of the region bash still treats
730
+ # as body. This does not rely on ``collapse_line_continuations`` — whose
731
+ # flat quote state a body apostrophe corrupts — which is the whole point
732
+ # of Finding R4.
733
+ body = collapse_body_continuations("\n".join(body_lines))
734
+ if body_line_has_substitution(body):
735
+ return True
736
+ return False
737
+
738
+
739
+ def parse_marker(line: str, start: int, offset: int) -> Marker | None:
740
+ index = start + 2
741
+ strip_tabs = False
742
+ if index < len(line) and line[index] == "-":
743
+ strip_tabs = True
744
+ index += 1
745
+ while index < len(line) and line[index] in " \t":
746
+ index += 1
747
+ if index >= len(line):
748
+ return None
749
+ quote = line[index] if line[index] in "'\"" else None
750
+ quoted = False
751
+ if quote:
752
+ index += 1
753
+ end = line.find(quote, index)
754
+ if end < 0:
755
+ return None
756
+ delimiter = line[index:end]
757
+ final = end + 1
758
+ # Deliberate POSIX divergence, fail-safe: POSIX makes the body
759
+ # non-expanding when ANY part of the delimiter is quoted. Partial forms
760
+ # like `<<EO'F'` stay unmodelled (mis-tokenized as delimiter EO, so the
761
+ # terminator never matches and the command fails closed as MALFORMED).
762
+ # Only a delimiter this parser can PROVE was one full quote pair earns
763
+ # quoted=True, and trailing word characters after the closing quote
764
+ # (`<<'EOF'X` — real bash delimiter EOFX) keep it conservative too: the
765
+ # next character must end the token.
766
+ quoted = final >= len(line) or line[final] in " \t;&|)<>#"
767
+ elif line[index] == "\\":
768
+ # `<<\DELIM` is a BACKSLASH-quoted delimiter: bash and POSIX make its
769
+ # body fully literal, exactly like `<<'DELIM'` (measured: `cat <<\EOF`
770
+ # with body `it's $(echo RAN)` prints that text verbatim, unexpanded).
771
+ # This spelling used to record NO Marker at all, on the rationale that an
772
+ # unmodelled body stays raw-visible to every content guard. That covers
773
+ # the body's own contents but NOT the body's ability to corrupt the flat
774
+ # scanner's quote state for text AFTER the terminator: an apostrophe in
775
+ # an invisible body still opened a phantom string that hid a live
776
+ # `$(...)` on a following line (issue #1993 R5c). Recording it as the
777
+ # quoted marker bash actually treats it as is both more faithful and
778
+ # what brings it under `neutralize_heredoc_bodies`. The same
779
+ # token-must-end discipline as the quote-pair branch applies, so exotic
780
+ # spellings (`<<\EOF'x'` — real bash delimiter EOFx) stay conservative.
781
+ # quoted=True here is a statement about BODY semantics only. Recording a
782
+ # Marker where there was none also arms the writer-owns-a-real-marker and
783
+ # multi-marker rules, so `gh issue create --body-file - <<\EOF` and
784
+ # `cat <<\A <<B` now fail closed at MALFORMED instead of passing through
785
+ # at UNSUPPORTED. Both are valid, harmless bash, so that is a real
786
+ # over-block — accepted and test-pinned, because `classify_safe` requires
787
+ # the literal `<<'DELIM'` spelling and `<<\EOF` can never reach SAFE
788
+ # anyway. The documented payload-strip workaround is `<<'EOF'`.
789
+ index += 1
790
+ match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[index:])
791
+ if match is None:
792
+ return None
793
+ delimiter = match.group(0)
794
+ final = index + len(delimiter)
795
+ quoted = final >= len(line) or line[final] in " \t;&|)<>#"
796
+ else:
797
+ match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[index:])
798
+ if match is None:
799
+ return None
800
+ delimiter = match.group(0)
801
+ final = index + len(delimiter)
802
+ if not delimiter:
803
+ return None
804
+ return Marker(offset + start, offset + final, delimiter, strip_tabs, quoted)
805
+
806
+
807
+ def cross_line_quote_state(command: str, offset: int) -> str:
808
+ """Return the single/double/plain quote state bash sees at ``offset``.
809
+
810
+ Unlike ``top_level_markers`` (which re-initialises quote state at the start
811
+ of every line), this walks the whole command with a SINGLE cross-line state
812
+ machine — the same quote/escape/comment model ``has_active_command_substitution``
813
+ uses. It answers "is this position inside an open single- or double-quoted
814
+ string?" so a heredoc-shaped token can be checked against bash's real parse.
815
+ """
816
+ state = "plain"
817
+ escaped = False
818
+ index = 0
819
+ limit = min(offset, len(command))
820
+ while index < limit:
821
+ char = command[index]
822
+ if escaped:
823
+ escaped = False
824
+ elif state == "single":
825
+ if char == "'":
826
+ state = "plain"
827
+ elif state == "double":
828
+ if char == '"':
829
+ state = "plain"
830
+ elif char == "\\":
831
+ escaped = True
832
+ elif command.startswith("$'", index):
833
+ end = ansi_c_quote_end(command, index)
834
+ if end is None or end > limit:
835
+ # The offset sits inside (or an unterminated) ANSI-C token: bash
836
+ # is not at plain top level there, so any heredoc-shaped marker
837
+ # is inert. Report a non-plain state so the strip gate refuses.
838
+ return "single"
839
+ index = end
840
+ continue
841
+ elif char == "'":
842
+ state = "single"
843
+ elif char == '"':
844
+ state = "double"
845
+ elif char == "\\":
846
+ escaped = True
847
+ elif char == "#" and (index == 0 or is_bash_blank(command[index - 1])):
848
+ newline = command.find("\n", index)
849
+ if newline < 0 or newline >= limit:
850
+ return state
851
+ index = newline
852
+ continue
853
+ index += 1
854
+ return state
855
+
856
+
857
+ def strip_provably_literal_body(command: str, markers: list[Marker]) -> str:
858
+ """Drop the body window of a single fully-quoted, closed heredoc.
859
+
860
+ A fully-quoted delimiter (Marker.quoted) makes the body literal data to
861
+ bash, so substitution tokens inside it must not flip the classification to
862
+ MALFORMED (issue #1958). The exclusion is deliberately narrow: EXACTLY one
863
+ marker, provably quoted, provably closed, and — critically — a marker bash
864
+ actually treats as a top-level heredoc redirection. ``top_level_markers``
865
+ resets quote state per line, so a ``<<'DELIM'`` line nested inside an open
866
+ multi-line single/double-quoted string is mis-recorded as a top-level
867
+ quoted heredoc. To bash there is NO heredoc there — the whole thing is one
868
+ string, and a ``$(...)`` inside a double-quoted string is EXECUTED. Excluding
869
+ that fake "body" window would delete the live substitution before the scan
870
+ runs, smuggling arbitrary command execution past the wall (issue #1958
871
+ Finding 1). So before excluding, re-verify the marker sits at bash top level
872
+ (quote state ``plain``) under CROSS-LINE quote tracking; if it is inside an
873
+ open quote, strip nothing and let ``has_active_command_substitution`` see the
874
+ real substitution. Anything else returns the command unchanged so the scan
875
+ stays fail-closed. Only the body lines are removed; the header line,
876
+ terminator line, and everything after remain, so a substitution on the
877
+ command line proper is still detected. The raw payload itself still reaches
878
+ every content guard because this classifier returns UNSUPPORTED (raw
879
+ pass-through) for the target class, never SAFE.
880
+ """
881
+ if len(markers) != 1 or not markers[0].quoted:
882
+ return command
883
+ marker = markers[0]
884
+ if cross_line_quote_state(command, marker.start) != "plain":
885
+ return command
886
+ lines = bash_lines(command)
887
+ marker_line = command.count("\n", 0, marker.start)
888
+ end = terminator_line_index(command, marker)
889
+ if end is None:
890
+ return command
891
+ return "\n".join(lines[: marker_line + 1] + lines[end:])
892
+
893
+
894
+ def marker_is_closed(command: str, marker: Marker) -> bool:
895
+ """True when bash really terminates this here-doc inside the command.
896
+
897
+ Delegates to ``terminator_line_index`` so the closure verdict and the body
898
+ window are decided by one rule. For an unquoted delimiter that rule removes
899
+ ``\\<newline>`` continuations first, so a trailing backslash on the last body
900
+ line correctly reads as NOT closed — which is what bash does, and what makes
901
+ the swallowed-terminator shape fail closed as MALFORMED (issue #1993 R5a).
902
+ """
903
+ return terminator_line_index(command, marker) is not None
904
+
905
+
906
+ def main() -> int:
907
+ command = sys.stdin.read()
908
+ if "<<" not in command:
909
+ print(command, end="")
910
+ return SAFE
911
+ try:
912
+ sanitized = classify_safe(command)
913
+ except ValueError:
914
+ return MALFORMED
915
+ if sanitized is not None:
916
+ print(sanitized, end="")
917
+ return SAFE
918
+
919
+ logical_command = collapse_line_continuations(command)
920
+ markers = top_level_markers(logical_command)
921
+ if writer_has_commented_marker_and_following_code(logical_command):
922
+ return MALFORMED
923
+ if writer_owns_real_marker(logical_command, markers):
924
+ return MALFORMED
925
+ # Textual operators inside quotes or comments are not heredocs. Leave the
926
+ # raw command unchanged so ordinary guard matching proceeds normally.
927
+ if not markers and not has_active_command_substitution(logical_command):
928
+ return UNSUPPORTED
929
+
930
+ # A supported writer that failed the exact safe grammar is ambiguous: do
931
+ # not let chaining, alternate redirects, or an expanding delimiter turn a
932
+ # would-be payload exemption into a bypass.
933
+ if bash_lines(logical_command) and line_has_allowed_writer(
934
+ bash_lines(logical_command)[0]
935
+ ):
936
+ return MALFORMED
937
+
938
+ # Nested substitution plus a heredoc is executable shell syntax unless it
939
+ # matched the one exact quoted `--body "$(cat ...)"` form above. Here-doc
940
+ # bodies are neutralised before the flat scan: bash applies NO quote
941
+ # processing inside a body, so leaving those bytes in a flat quote state
942
+ # machine let an odd body apostrophe poison the scanner PAST the terminator
943
+ # and hide a live substitution on a following command line (issue #1993
944
+ # R5b). Neutralising preserves offsets, so the narrower literal-body strip
945
+ # still applies on top; the text outside every body window is still scanned,
946
+ # and in-body substitutions are covered by the body-semantics scan below.
947
+ if has_active_command_substitution(
948
+ strip_provably_literal_body(
949
+ neutralize_heredoc_bodies(logical_command, markers), markers
950
+ )
951
+ ) or unquoted_heredoc_body_has_substitution(logical_command, markers):
952
+ return MALFORMED
953
+ if len(markers) > 1:
954
+ return MALFORMED
955
+ if markers and not marker_is_closed(logical_command, markers[0]):
956
+ return MALFORMED
957
+ return UNSUPPORTED
958
+
959
+
960
+ if __name__ == "__main__":
961
+ raise SystemExit(main())