@codyswann/lisa 2.295.5 → 2.296.0

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 (69) hide show
  1. package/dist/cli/doctor-readiness-operations.d.ts.map +1 -1
  2. package/dist/cli/doctor-readiness-operations.js +0 -1
  3. package/dist/cli/doctor-readiness-operations.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +4 -2
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/dist/opencode/plugin-templates/parity-safety-net-heredoc.py +362 -32
  8. package/dist/opencode/plugin-templates/parity-safety-net.sh +13 -7
  9. package/package.json +1 -1
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/hooks/parity-safety-net-heredoc.py +362 -32
  13. package/plugins/lisa/hooks/parity-safety-net.sh +13 -7
  14. package/plugins/lisa-agy/hooks/parity-safety-net-heredoc.py +362 -32
  15. package/plugins/lisa-agy/hooks/parity-safety-net.sh +13 -7
  16. package/plugins/lisa-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/hooks/parity-safety-net-heredoc.py +362 -32
  24. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +13 -7
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cursor/hooks/parity-safety-net-heredoc.py +362 -32
  27. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +13 -7
  28. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-agy/plugin.json +1 -1
  31. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  51. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-agy/plugin.json +1 -1
  56. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  61. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  66. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/src/base/hooks/parity-safety-net-heredoc.py +362 -32
  69. package/plugins/src/base/hooks/parity-safety-net.sh +13 -7
@@ -28,9 +28,95 @@ class Marker:
28
28
  end: int
29
29
  delimiter: str
30
30
  strip_tabs: bool
31
+ # True ONLY when the whole delimiter token was wrapped in one full single-
32
+ # or double-quote pair (<<'EOF' / <<"EOF") with nothing word-like attached
33
+ # after the closing quote. That is the only form whose body this parser can
34
+ # PROVE bash treats as non-expanding literal data (issue #1958).
31
35
  quoted: bool
32
36
 
33
37
 
38
+ def is_bash_blank(char: str) -> bool:
39
+ """True only for a byte bash treats as a word separator here: space, tab,
40
+ newline.
41
+
42
+ Python ``str.isspace()`` is a strict SUPERSET of bash's word-separator set —
43
+ it is also True for NBSP (``\\xa0``), the C0 separators FS/GS/RS/US
44
+ (``\\x1c``–``\\x1f``), NEL (``\\x85``), VT/FF (``\\x0b``/``\\x0c``), CR
45
+ (``\\r``), and the Unicode spaces (ideographic space ``\\u3000``, the
46
+ ``\\u2000``–``\\u200a`` run, ``\\u202f``, ``\\u205f``, ``\\u2028``/``\\u2029``).
47
+ Bash (C locale) treats NONE of these as a blank or a metacharacter, so a
48
+ ``#`` preceded by one stays INSIDE the current word — it does NOT start a
49
+ comment — and any ``$(...)`` / `` `...` `` in that word is still expanded and
50
+ EXECUTED. Every comment-boundary walker here must therefore match bash's
51
+ actual blank set, not Python's: using ``str.isspace()`` let the scanner call
52
+ ``#`` a comment where bash does not, skip to the newline, and go blind to a
53
+ live substitution — smuggling arbitrary command execution past the wall
54
+ (issue #1958 Finding R2). This is the single shared home of that predicate so
55
+ every walker stays in lockstep with bash's word-boundary rule; fail-closed on
56
+ ambiguity means narrowing (fewer bytes counted as blank), never widening.
57
+ """
58
+ return char in " \t\n"
59
+
60
+
61
+ def bash_lines(text: str) -> list[str]:
62
+ """Split into bash's notion of lines: on ``\\n`` ONLY.
63
+
64
+ Python ``str.splitlines()`` is the same over-broad classifier as
65
+ ``str.isspace()`` in disguise — it ALSO breaks on ``\\r``, VT/FF
66
+ (``\\x0b``/``\\x0c``), the C0 separators FS/GS/RS (``\\x1c``–``\\x1e``), NEL
67
+ (``\\x85``), and the Unicode line separators (``\\u2028``/``\\u2029``). Bash
68
+ ends a line only at an unquoted ``\\n``, so ``splitlines()`` invents line
69
+ breaks bash never sees. The concrete hole: ``strip_provably_literal_body``
70
+ splits with ``splitlines()`` then rejoins with ``\\n``, so a ``echo X\\x1c#$(…)``
71
+ argument gets normalised into ``echo X`` / ``#$(…)`` on separate lines — now
72
+ the ``#`` sits at a real line start, is treated as a comment, and the live
73
+ ``$(…)`` is smuggled past the wall exactly as the ``str.isspace()`` desync
74
+ did (issue #1958 Finding R2, FS/ideographic-space variant). Splitting on
75
+ ``\\n`` alone keeps this parser's lines in lockstep with bash and with the
76
+ ``command.count("\\n", …)`` offset arithmetic the markers already rely on.
77
+ """
78
+ return text.split("\n")
79
+
80
+
81
+ def ansi_c_quote_end(text: str, dollar_index: int) -> int | None:
82
+ """Index one past a bash ANSI-C ``$'...'`` token, or ``None``.
83
+
84
+ When an *unquoted* ``$`` is immediately followed by ``'`` bash opens an
85
+ ANSI-C-quoted string that ends at the first *unescaped* ``'``: a backslash
86
+ escapes the next single character, so ``\\'`` is a literal quote (does NOT
87
+ close the string) and ``\\\\`` is a literal backslash. Only literal
88
+ single quotes act as delimiters — value-only escapes like ``\\x27`` /
89
+ ``\\047`` / ``\\uHHHH`` decode to a quote *character* but never terminate
90
+ the token, so scanning for the first unescaped ``'`` yields exactly bash's
91
+ token boundary for every spelling.
92
+
93
+ A naive single-quote scanner that ignores ``$'...'`` desyncs from bash on an
94
+ odd number of ``\\'`` escapes: it reads three bare quotes as
95
+ single→plain→single and ends in a phantom open single-quote, going blind to
96
+ everything after it — including a live ``$(...)`` the following ``"`` really
97
+ exposes (issue #1958 Finding R1). Consuming the whole inert token here keeps
98
+ every shared walker in lockstep with bash.
99
+
100
+ Returns the index just past the closing quote, or ``None`` for a
101
+ non-``$'`` position or an unterminated token so callers fail closed. This is
102
+ the single shared home of ANSI-C token boundaries; every quote-state walker
103
+ consults it rather than re-deriving the rule.
104
+ """
105
+ if not text.startswith("$'", dollar_index):
106
+ return None
107
+ index = dollar_index + 2
108
+ length = len(text)
109
+ while index < length:
110
+ char = text[index]
111
+ if char == "\\":
112
+ index += 2
113
+ continue
114
+ if char == "'":
115
+ return index + 1
116
+ index += 1
117
+ return None
118
+
119
+
34
120
  def shell_tokens(prefix: str) -> list[str] | None:
35
121
  """Return literal simple-command tokens, rejecting executable shell syntax."""
36
122
  state = "plain"
@@ -53,13 +139,19 @@ def shell_tokens(prefix: str) -> list[str] | None:
53
139
  and index + 1 < len(prefix)
54
140
  and prefix[index + 1] in "([{"):
55
141
  return None
142
+ elif prefix.startswith("$'", index):
143
+ end = ansi_c_quote_end(prefix, index)
144
+ if end is None:
145
+ return None
146
+ index = end
147
+ continue
56
148
  elif char == "'":
57
149
  state = "single"
58
150
  elif char == '"':
59
151
  state = "double"
60
152
  elif char == "\\":
61
153
  escaped = True
62
- elif char == "#" and (index == 0 or prefix[index - 1].isspace()):
154
+ elif char == "#" and (index == 0 or is_bash_blank(prefix[index - 1])):
63
155
  return None
64
156
  elif char in ";|&()<>`\n\r":
65
157
  return None
@@ -118,7 +210,7 @@ def only_whitespace(lines: list[str], start: int) -> bool:
118
210
 
119
211
 
120
212
  def classify_safe(command: str) -> str | None:
121
- lines = command.splitlines()
213
+ lines = bash_lines(command)
122
214
  if len(lines) < 2 or "\x00" in command or "\r" in command:
123
215
  return None
124
216
 
@@ -159,7 +251,7 @@ def classify_safe(command: str) -> str | None:
159
251
  def top_level_markers(command: str) -> list[Marker]:
160
252
  """Find conservative top-level markers for malformed/duplicate detection."""
161
253
  markers: list[Marker] = []
162
- lines = command.splitlines()
254
+ lines = bash_lines(command)
163
255
  offset = 0
164
256
  for line in lines:
165
257
  state = "plain"
@@ -177,13 +269,19 @@ def top_level_markers(command: str) -> list[Marker]:
177
269
  state = "plain"
178
270
  elif char == "\\":
179
271
  escaped = True
272
+ elif line.startswith("$'", index):
273
+ end = ansi_c_quote_end(line, index)
274
+ if end is None:
275
+ break
276
+ index = end
277
+ continue
180
278
  elif char == "'":
181
279
  state = "single"
182
280
  elif char == '"':
183
281
  state = "double"
184
282
  elif char == "\\":
185
283
  escaped = True
186
- elif char == "#" and (index == 0 or line[index - 1].isspace()):
284
+ elif char == "#" and (index == 0 or is_bash_blank(line[index - 1])):
187
285
  break
188
286
  elif line.startswith("<<", index) and not line.startswith("<<<", index):
189
287
  marker = parse_marker(line, index, offset)
@@ -216,6 +314,15 @@ def unquoted_code_and_comment(line: str) -> tuple[str, str]:
216
314
  state = "plain"
217
315
  elif char == "\\":
218
316
  escaped = True
317
+ elif line.startswith("$'", index):
318
+ end = ansi_c_quote_end(line, index)
319
+ if end is None:
320
+ code.append(" ")
321
+ index += 1
322
+ continue
323
+ code.append(" " * (end - index))
324
+ index = end
325
+ continue
219
326
  elif char == "'":
220
327
  code.append(" ")
221
328
  state = "single"
@@ -225,7 +332,7 @@ def unquoted_code_and_comment(line: str) -> tuple[str, str]:
225
332
  elif char == "\\":
226
333
  code.append(" ")
227
334
  escaped = True
228
- elif char == "#" and (index == 0 or line[index - 1].isspace()):
335
+ elif char == "#" and (index == 0 or is_bash_blank(line[index - 1])):
229
336
  return "".join(code), line[index + 1 :]
230
337
  else:
231
338
  code.append(char)
@@ -254,6 +361,18 @@ def collapse_line_continuations(command: str) -> str:
254
361
  elif char == "\\":
255
362
  result.append(char)
256
363
  escaped = True
364
+ elif state == "plain" and command.startswith("$'", index):
365
+ end = ansi_c_quote_end(command, index)
366
+ if end is None:
367
+ result.append(char)
368
+ else:
369
+ # Preserve the whole inert ANSI-C token verbatim so its bytes
370
+ # stay a single quoted unit to every downstream walker; a
371
+ # backslash-newline inside it is part of the token, not a line
372
+ # continuation to strip.
373
+ result.append(command[index:end])
374
+ index = end
375
+ continue
257
376
  elif char == "'" and state == "plain":
258
377
  result.append(char)
259
378
  state = "single"
@@ -272,7 +391,7 @@ def writer_owns_real_marker(command: str, markers: list[Marker]) -> bool:
272
391
  logical_markers = (
273
392
  markers if logical_command == command else top_level_markers(logical_command)
274
393
  )
275
- lines = logical_command.splitlines()
394
+ lines = bash_lines(logical_command)
276
395
  for marker in logical_markers:
277
396
  line_index = logical_command.count("\n", 0, marker.start)
278
397
  if line_has_allowed_writer(lines[line_index]):
@@ -282,7 +401,7 @@ def writer_owns_real_marker(command: str, markers: list[Marker]) -> bool:
282
401
 
283
402
  def writer_has_commented_marker_and_following_code(command: str) -> bool:
284
403
  """Reject fake writer markers whose following lines would execute."""
285
- lines = command.splitlines()
404
+ lines = bash_lines(command)
286
405
  for index, line in enumerate(lines):
287
406
  _code, comment = unquoted_code_and_comment(line)
288
407
  if (
@@ -313,6 +432,12 @@ def has_active_command_substitution(command: str) -> bool:
313
432
  escaped = True
314
433
  elif char == "`" or command.startswith("$(", index):
315
434
  return True
435
+ elif command.startswith("$'", index):
436
+ end = ansi_c_quote_end(command, index)
437
+ if end is None:
438
+ return True
439
+ index = end
440
+ continue
316
441
  elif char == "'":
317
442
  state = "single"
318
443
  elif char == '"':
@@ -320,7 +445,7 @@ def has_active_command_substitution(command: str) -> bool:
320
445
  elif char == "\\":
321
446
  escaped = True
322
447
  elif char == "#" and (
323
- index == 0 or command[index - 1].isspace()
448
+ index == 0 or is_bash_blank(command[index - 1])
324
449
  ):
325
450
  newline = command.find("\n", index)
326
451
  if newline < 0:
@@ -332,6 +457,124 @@ def has_active_command_substitution(command: str) -> bool:
332
457
  return False
333
458
 
334
459
 
460
+ def collapse_body_continuations(body: str) -> str:
461
+ """Remove bash ``\\<newline>`` line continuations under heredoc-body rules.
462
+
463
+ An UNQUOTED here-doc body performs NO quote processing (``'``/``"``/``#`` are
464
+ literal bytes), but bash STILL removes an unescaped ``\\<newline>`` — the
465
+ bash manual: for an unquoted here-doc "the character sequence ``\\newline`` is
466
+ ignored". The whole-command ``collapse_line_continuations`` applies a FLAT
467
+ shell quote state, so a single body apostrophe flips it into a phantom
468
+ single-quoted string and it REFUSES to join a ``$\\<newline>(`` continuation,
469
+ leaving ``$`` and ``(`` on separate lines where the per-line substitution
470
+ scan sees neither — smuggling a live ``$(...)`` past the wall (issue #1958
471
+ Finding R4). The body window has no quote state to get wrong, so this joins
472
+ continuations with backslash-only semantics: a backslash escapes the next
473
+ single byte (so ``\\\\`` is a literal backslash whose following newline is
474
+ real, and ``\\$`` cannot start a substitution), and a backslash directly
475
+ before a newline is dropped together with that newline. Feed the joined body
476
+ to ``body_line_has_substitution`` so a split ``$(`` is scanned as the one
477
+ contiguous token bash will execute. Fail-closed: a trailing lone backslash is
478
+ kept verbatim (it can start no substitution).
479
+ """
480
+ result: list[str] = []
481
+ index = 0
482
+ length = len(body)
483
+ while index < length:
484
+ char = body[index]
485
+ if char == "\\":
486
+ if index + 1 < length and body[index + 1] == "\n":
487
+ index += 2
488
+ continue
489
+ result.append(body[index : index + 2])
490
+ index += 2
491
+ continue
492
+ result.append(char)
493
+ index += 1
494
+ return "".join(result)
495
+
496
+
497
+ def body_line_has_substitution(line: str) -> bool:
498
+ """True if an unquoted-heredoc-body line contains an active substitution.
499
+
500
+ In an unquoted-delimiter heredoc body bash suppresses ALL quote and comment
501
+ processing — ``'``, ``"`` and ``#`` are literal bytes — but keeps
502
+ parameter/command/arithmetic expansion active, so ``$(...)`` and `` `...` ``
503
+ still EXECUTE. The only in-body metacharacter is the backslash, which escapes
504
+ the following ``$``, `` ` ``, ``\\`` or newline; every other byte (including
505
+ the quotes and ``#`` that blind the flat scanner) is inert text. So scan the
506
+ body raw, honouring only that escaping, and flag a bare ``$(`` or backtick —
507
+ the same command-substitution tokens ``has_active_command_substitution``
508
+ detects elsewhere, just without the quote/comment state machine that is wrong
509
+ for this region (issue #1958 Finding R3).
510
+ """
511
+ index = 0
512
+ length = len(line)
513
+ while index < length:
514
+ char = line[index]
515
+ if char == "\\":
516
+ index += 2
517
+ continue
518
+ if char == "`" or line.startswith("$(", index):
519
+ return True
520
+ index += 1
521
+ return False
522
+
523
+
524
+ def unquoted_heredoc_body_has_substitution(
525
+ command: str, markers: list[Marker]
526
+ ) -> bool:
527
+ """Detect a live substitution inside an unquoted top-level heredoc body.
528
+
529
+ ``has_active_command_substitution`` applies flat shell quote/comment
530
+ semantics to the whole command, but an UNQUOTED ``<<EOF`` body follows
531
+ different rules (see ``body_line_has_substitution``): a lone apostrophe or
532
+ ``#`` in the body flips the flat scanner's state and hides a live
533
+ ``$(...)``/backtick that bash nonetheless expands, smuggling execution past
534
+ the wall (issue #1958 Finding R3). This complements the flat scan by walking
535
+ only the body window of each unquoted, top-level, closed heredoc with
536
+ heredoc-body semantics.
537
+
538
+ A quoted-delimiter body (``Marker.quoted``) is genuinely inert to bash and is
539
+ skipped — that is the whole point of the Finding-1 literal-payload win.
540
+ Before scanning, the marker is re-verified to sit at bash top level under
541
+ CROSS-LINE quote tracking (mirroring ``strip_provably_literal_body``): a
542
+ heredoc-shaped ``<<EOF`` nested inside an open single-quoted string is not a
543
+ real heredoc — its body is literal string data bash never expands — so
544
+ scanning it would over-block ordinary quoted prose; and one nested inside an
545
+ open double-quoted string has its ``$(...)`` already caught by the flat scan.
546
+ Either way, only markers bash actually treats as top-level heredoc
547
+ redirections get the body scan.
548
+ """
549
+ lines = bash_lines(command)
550
+ for marker in markers:
551
+ if marker.quoted:
552
+ continue
553
+ if cross_line_quote_state(command, marker.start) != "plain":
554
+ continue
555
+ marker_line = command.count("\n", 0, marker.start)
556
+ body_lines: list[str] = []
557
+ for index in range(marker_line + 1, len(lines)):
558
+ candidate = (
559
+ lines[index].lstrip("\t") if marker.strip_tabs else lines[index]
560
+ )
561
+ if candidate == marker.delimiter:
562
+ break
563
+ body_lines.append(lines[index])
564
+ # Join the raw body window under bash's unquoted-here-doc ``\<newline>``
565
+ # removal BEFORE scanning, so a ``$(`` a caller split across a line
566
+ # continuation is seen as the one contiguous token bash executes. The
567
+ # terminator was matched per physical line above (bash matches the
568
+ # delimiter before continuation processing), so continuations are joined
569
+ # only WITHIN the body window, never across the delimiter. This does not
570
+ # rely on ``collapse_line_continuations`` — whose flat quote state a body
571
+ # apostrophe corrupts — which is the whole point of Finding R4.
572
+ body = collapse_body_continuations("\n".join(body_lines))
573
+ if body_line_has_substitution(body):
574
+ return True
575
+ return False
576
+
577
+
335
578
  def parse_marker(line: str, start: int, offset: int) -> Marker | None:
336
579
  index = start + 2
337
580
  strip_tabs = False
@@ -343,6 +586,7 @@ def parse_marker(line: str, start: int, offset: int) -> Marker | None:
343
586
  if index >= len(line):
344
587
  return None
345
588
  quote = line[index] if line[index] in "'\"" else None
589
+ quoted = False
346
590
  if quote:
347
591
  index += 1
348
592
  end = line.find(quote, index)
@@ -350,6 +594,18 @@ def parse_marker(line: str, start: int, offset: int) -> Marker | None:
350
594
  return None
351
595
  delimiter = line[index:end]
352
596
  final = end + 1
597
+ # Deliberate POSIX divergence, fail-safe: POSIX makes the body
598
+ # non-expanding when ANY part of the delimiter is quoted — including
599
+ # `<<\EOF` (invisible to this parser: backslash is not a quote char and
600
+ # the identifier regex rejects it, so no Marker is recorded and the
601
+ # body stays raw-visible to every guard) and partial forms like
602
+ # `<<EO'F'` (mis-tokenized as delimiter EO, so the terminator never
603
+ # matches and the command fails closed as MALFORMED). Only a delimiter
604
+ # this parser can PROVE was one full quote pair earns quoted=True, and
605
+ # trailing word characters after the closing quote (`<<'EOF'X` — real
606
+ # bash delimiter EOFX) keep it conservative too: the next character
607
+ # must end the token.
608
+ quoted = final >= len(line) or line[final] in " \t;&|)<>#"
353
609
  else:
354
610
  match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[index:])
355
611
  if match is None:
@@ -358,12 +614,100 @@ def parse_marker(line: str, start: int, offset: int) -> Marker | None:
358
614
  final = index + len(delimiter)
359
615
  if not delimiter:
360
616
  return None
361
- return Marker(offset + start, offset + final, delimiter, strip_tabs, quote is not None)
617
+ return Marker(offset + start, offset + final, delimiter, strip_tabs, quoted)
618
+
619
+
620
+ def cross_line_quote_state(command: str, offset: int) -> str:
621
+ """Return the single/double/plain quote state bash sees at ``offset``.
622
+
623
+ Unlike ``top_level_markers`` (which re-initialises quote state at the start
624
+ of every line), this walks the whole command with a SINGLE cross-line state
625
+ machine — the same quote/escape/comment model ``has_active_command_substitution``
626
+ uses. It answers "is this position inside an open single- or double-quoted
627
+ string?" so a heredoc-shaped token can be checked against bash's real parse.
628
+ """
629
+ state = "plain"
630
+ escaped = False
631
+ index = 0
632
+ limit = min(offset, len(command))
633
+ while index < limit:
634
+ char = command[index]
635
+ if escaped:
636
+ escaped = False
637
+ elif state == "single":
638
+ if char == "'":
639
+ state = "plain"
640
+ elif state == "double":
641
+ if char == '"':
642
+ state = "plain"
643
+ elif char == "\\":
644
+ escaped = True
645
+ elif command.startswith("$'", index):
646
+ end = ansi_c_quote_end(command, index)
647
+ if end is None or end > limit:
648
+ # The offset sits inside (or an unterminated) ANSI-C token: bash
649
+ # is not at plain top level there, so any heredoc-shaped marker
650
+ # is inert. Report a non-plain state so the strip gate refuses.
651
+ return "single"
652
+ index = end
653
+ continue
654
+ elif char == "'":
655
+ state = "single"
656
+ elif char == '"':
657
+ state = "double"
658
+ elif char == "\\":
659
+ escaped = True
660
+ elif char == "#" and (index == 0 or is_bash_blank(command[index - 1])):
661
+ newline = command.find("\n", index)
662
+ if newline < 0 or newline >= limit:
663
+ return state
664
+ index = newline
665
+ continue
666
+ index += 1
667
+ return state
668
+
669
+
670
+ def strip_provably_literal_body(command: str, markers: list[Marker]) -> str:
671
+ """Drop the body window of a single fully-quoted, closed heredoc.
672
+
673
+ A fully-quoted delimiter (Marker.quoted) makes the body literal data to
674
+ bash, so substitution tokens inside it must not flip the classification to
675
+ MALFORMED (issue #1958). The exclusion is deliberately narrow: EXACTLY one
676
+ marker, provably quoted, provably closed, and — critically — a marker bash
677
+ actually treats as a top-level heredoc redirection. ``top_level_markers``
678
+ resets quote state per line, so a ``<<'DELIM'`` line nested inside an open
679
+ multi-line single/double-quoted string is mis-recorded as a top-level
680
+ quoted heredoc. To bash there is NO heredoc there — the whole thing is one
681
+ string, and a ``$(...)`` inside a double-quoted string is EXECUTED. Excluding
682
+ that fake "body" window would delete the live substitution before the scan
683
+ runs, smuggling arbitrary command execution past the wall (issue #1958
684
+ Finding 1). So before excluding, re-verify the marker sits at bash top level
685
+ (quote state ``plain``) under CROSS-LINE quote tracking; if it is inside an
686
+ open quote, strip nothing and let ``has_active_command_substitution`` see the
687
+ real substitution. Anything else returns the command unchanged so the scan
688
+ stays fail-closed. Only the body lines are removed; the header line,
689
+ terminator line, and everything after remain, so a substitution on the
690
+ command line proper is still detected. The raw payload itself still reaches
691
+ every content guard because this classifier returns UNSUPPORTED (raw
692
+ pass-through) for the target class, never SAFE.
693
+ """
694
+ if len(markers) != 1 or not markers[0].quoted:
695
+ return command
696
+ marker = markers[0]
697
+ if cross_line_quote_state(command, marker.start) != "plain":
698
+ return command
699
+ lines = bash_lines(command)
700
+ marker_line = command.count("\n", 0, marker.start)
701
+ for index in range(marker_line + 1, len(lines)):
702
+ candidate = lines[index].lstrip("\t") if marker.strip_tabs else lines[index]
703
+ if candidate == marker.delimiter:
704
+ return "\n".join(lines[: marker_line + 1] + lines[index:])
705
+ return command
362
706
 
363
707
 
364
708
  def marker_is_closed(command: str, marker: Marker) -> bool:
365
709
  marker_line = command.count("\n", 0, marker.start)
366
- lines = command.splitlines()
710
+ lines = bash_lines(command)
367
711
  for line in lines[marker_line + 1 :]:
368
712
  candidate = line.lstrip("\t") if marker.strip_tabs else line
369
713
  if candidate == marker.delimiter:
@@ -371,19 +715,6 @@ def marker_is_closed(command: str, marker: Marker) -> bool:
371
715
  return False
372
716
 
373
717
 
374
- def single_closed_quoted_nonwriter_heredoc(command: str, marker: Marker) -> bool:
375
- """Allow guard scanning for inert quoted heredoc payloads outside gh writers."""
376
- if not marker.quoted or not marker_is_closed(command, marker):
377
- return False
378
- line_end = command.find("\n", marker.start)
379
- if line_end < 0:
380
- line_end = len(command)
381
- header = command[:line_end]
382
- if has_active_command_substitution(header):
383
- return False
384
- return True
385
-
386
-
387
718
  def main() -> int:
388
719
  command = sys.stdin.read()
389
720
  if "<<" not in command:
@@ -411,19 +742,18 @@ def main() -> int:
411
742
  # A supported writer that failed the exact safe grammar is ambiguous: do
412
743
  # not let chaining, alternate redirects, or an expanding delimiter turn a
413
744
  # would-be payload exemption into a bypass.
414
- if logical_command.splitlines() and line_has_allowed_writer(
415
- logical_command.splitlines()[0]
745
+ if bash_lines(logical_command) and line_has_allowed_writer(
746
+ bash_lines(logical_command)[0]
416
747
  ):
417
748
  return MALFORMED
418
749
 
419
- if len(markers) == 1 and single_closed_quoted_nonwriter_heredoc(
420
- logical_command, markers[0]
421
- ):
422
- return UNSUPPORTED
423
-
424
750
  # Nested substitution plus a heredoc is executable shell syntax unless it
425
- # matched the one exact quoted `--body "$(cat ...)"` form above.
426
- if has_active_command_substitution(logical_command):
751
+ # matched the one exact quoted `--body "$(cat ...)"` form above. The body
752
+ # of a single provably-literal heredoc is excluded from this scan (its
753
+ # tokens are inert data); the text outside that window is still scanned.
754
+ if has_active_command_substitution(
755
+ strip_provably_literal_body(logical_command, markers)
756
+ ) or unquoted_heredoc_body_has_substitution(logical_command, markers):
427
757
  return MALFORMED
428
758
  if len(markers) > 1:
429
759
  return MALFORMED
@@ -96,11 +96,17 @@ EOF
96
96
  # remain visible to every built-in and custom rule. Ambiguous or malformed
97
97
  # heredocs fail closed instead of guessing which text the shell would execute.
98
98
  #
99
- # block_heredoc() teaches the remediation the moment the wall is hit. Commit
100
- # heredocs need commit-message-file guidance; other heredocs should point agents
101
- # at script/payload files instead of implying every denial was a commit attempt.
99
+ # block_heredoc() teaches the remediation the moment the wall is hit: a bare
100
+ # denial strands the agent with no path forward (gardener #1789). The remedy
101
+ # depends on the command shape (issue #1958): `git commit -m "$(cat <<EOF …)"`
102
+ # attempts get the commit -F text; every other heredoc denial gets the
103
+ # file-based execution guidance instead — the commit text is misleading there.
104
+ # The git-commit detection inlines the GIT_GLOBAL_OPTS shape (defined later in
105
+ # this file, after the heredoc dispatch runs) so `git -C <path> commit` and
106
+ # `git -c k=v commit` spellings are still recognized.
102
107
  block_heredoc() {
103
- if printf '%s' "$command_str" | grep -Eq '(^|[^[:alnum:]_-])git[[:space:]]+([^;&|[:space:]]+[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
108
+ if printf '%s' "$command_str" \
109
+ | grep -Eq -- '(^|[^[:alnum:]_-])git[[:space:]]+(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
104
110
  block "$1
105
111
  Heredoc commit invocations are blocked (the payload is executable shell).
106
112
  Fix: write the commit message to a file and run \`git commit -F <file>\`.
@@ -108,9 +114,9 @@ Every commit must also carry a Co-authored-by trailer for a supported agent
108
114
  (Claude/Codex/OpenCode) — the commit-msg hook enforces this."
109
115
  fi
110
116
  block "$1
111
- Heredoc command invocations are blocked when Lisa cannot prove the payload is
112
- non-executable text.
113
- Fix: write the payload to a file and execute or pass that file explicitly."
117
+ Heredoc payloads are blocked here (the payload is executable shell).
118
+ Fix: write the payload to a file with the Write tool, then execute that file
119
+ directly (for example \`python3 <file>\` or \`bash <file>\`)."
114
120
  }
115
121
 
116
122
  command_for_guards="$command_str"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.295.5",
3
+ "version": "2.296.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"