@codyswann/lisa 2.298.0 → 2.299.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 (117) hide show
  1. package/all/copy-contents/.gitattributes +6 -0
  2. package/dist/cli/gate-commands.d.ts +8 -6
  3. package/dist/cli/gate-commands.d.ts.map +1 -1
  4. package/dist/cli/gate-commands.js +21 -0
  5. package/dist/cli/gate-commands.js.map +1 -1
  6. package/dist/cli/learnings-overflow-cmd.d.ts +27 -0
  7. package/dist/cli/learnings-overflow-cmd.d.ts.map +1 -0
  8. package/dist/cli/learnings-overflow-cmd.js +57 -0
  9. package/dist/cli/learnings-overflow-cmd.js.map +1 -0
  10. package/dist/core/learnings-alias.d.ts +156 -0
  11. package/dist/core/learnings-alias.d.ts.map +1 -0
  12. package/dist/core/learnings-alias.js +199 -0
  13. package/dist/core/learnings-alias.js.map +1 -0
  14. package/dist/core/learnings-document.d.ts +18 -0
  15. package/dist/core/learnings-document.d.ts.map +1 -1
  16. package/dist/core/learnings-document.js +23 -2
  17. package/dist/core/learnings-document.js.map +1 -1
  18. package/dist/core/learnings-merge-driver.d.ts +8 -33
  19. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  20. package/dist/core/learnings-merge-driver.js +15 -0
  21. package/dist/core/learnings-merge-driver.js.map +1 -1
  22. package/dist/core/learnings-overflow.d.ts +76 -0
  23. package/dist/core/learnings-overflow.d.ts.map +1 -0
  24. package/dist/core/learnings-overflow.js +212 -0
  25. package/dist/core/learnings-overflow.js.map +1 -0
  26. package/dist/core/learnings-writer.d.ts +6 -0
  27. package/dist/core/learnings-writer.d.ts.map +1 -1
  28. package/dist/core/learnings-writer.js +57 -8
  29. package/dist/core/learnings-writer.js.map +1 -1
  30. package/dist/core/learnings.d.ts +2 -0
  31. package/dist/core/learnings.d.ts.map +1 -1
  32. package/dist/core/learnings.js +2 -0
  33. package/dist/core/learnings.js.map +1 -1
  34. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  35. package/dist/core/upstream-evidence-manifest.js +15 -6
  36. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  37. package/dist/opencode/plugin-templates/parity-safety-net-heredoc.py +232 -40
  38. package/dist/opencode/plugin-templates/parity-safety-net.sh +21 -1
  39. package/package.json +3 -1
  40. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa/.codex-plugin/skills/lisa-learnings-audit/SKILL.md +42 -6
  43. package/plugins/lisa/.codex-plugin/skills/lisa-parity-safety-net-rules/SKILL.md +38 -1
  44. package/plugins/lisa/.codex-plugin/skills/lisa-persist-learning/SKILL.md +7 -2
  45. package/plugins/lisa/hooks/parity-safety-net-heredoc.py +232 -40
  46. package/plugins/lisa/hooks/parity-safety-net.sh +21 -1
  47. package/plugins/lisa/skills/lisa-learnings-audit/SKILL.md +42 -6
  48. package/plugins/lisa/skills/lisa-parity-safety-net-rules/SKILL.md +38 -1
  49. package/plugins/lisa/skills/lisa-persist-learning/SKILL.md +7 -2
  50. package/plugins/lisa-agy/hooks/parity-safety-net-heredoc.py +232 -40
  51. package/plugins/lisa-agy/hooks/parity-safety-net.sh +21 -1
  52. package/plugins/lisa-agy/plugin.json +1 -1
  53. package/plugins/lisa-agy/skills/lisa-learnings-audit/SKILL.md +42 -6
  54. package/plugins/lisa-agy/skills/lisa-parity-safety-net-rules/SKILL.md +38 -1
  55. package/plugins/lisa-agy/skills/lisa-persist-learning/SKILL.md +7 -2
  56. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  59. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-copilot/hooks/parity-safety-net-heredoc.py +232 -40
  63. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +21 -1
  64. package/plugins/lisa-copilot/skills/lisa-learnings-audit/SKILL.md +42 -6
  65. package/plugins/lisa-copilot/skills/lisa-parity-safety-net-rules/SKILL.md +38 -1
  66. package/plugins/lisa-copilot/skills/lisa-persist-learning/SKILL.md +7 -2
  67. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-cursor/hooks/parity-safety-net-heredoc.py +232 -40
  69. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +21 -1
  70. package/plugins/lisa-cursor/skills/lisa-learnings-audit/SKILL.md +42 -6
  71. package/plugins/lisa-cursor/skills/lisa-parity-safety-net-rules/SKILL.md +38 -1
  72. package/plugins/lisa-cursor/skills/lisa-persist-learning/SKILL.md +7 -2
  73. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-expo-agy/plugin.json +1 -1
  76. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  86. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  91. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  96. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-rails-agy/plugin.json +1 -1
  101. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  106. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  111. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/src/base/hooks/parity-safety-net-heredoc.py +232 -40
  114. package/plugins/src/base/hooks/parity-safety-net.sh +21 -1
  115. package/plugins/src/base/skills/lisa-learnings-audit/SKILL.md +42 -6
  116. package/plugins/src/base/skills/lisa-parity-safety-net-rules/SKILL.md +38 -1
  117. package/plugins/src/base/skills/lisa-persist-learning/SKILL.md +7 -2
@@ -1,5 +1,64 @@
1
1
  #!/usr/bin/env python3
2
- """Classify the only heredoc forms whose payload is non-executable text."""
2
+ """Classify the only heredoc forms whose payload is non-executable text.
3
+
4
+ SCOPE — read this before hardening anything here (issue #1993).
5
+
6
+ This module is a HEURISTIC RE-IMPLEMENTATION of bash's here-doc lexing, not an
7
+ adversary-resistant control. Every divergence between this Python model and real
8
+ bash is, by construction, a potential mis-classification, and five rounds of
9
+ fix-then-re-attack on issue #1958 plus three more here (#1993) established that
10
+ enumerating those divergences is an open-ended arms race. Treat the class as
11
+ CLOSED: a newly found member is documented, not chased, unless someone first
12
+ demonstrates it defeats the CONTENT GUARDS, which are the layer that matters.
13
+
14
+ The one hard guarantee, and the reason a mis-classification is survivable:
15
+
16
+ Exit SAFE(0) is the ONLY path that strips payload text, and it requires the
17
+ narrow, fully-quoted `gh issue|pr create/edit/comment` + `<<'EOF'` grammar in
18
+ ``classify_safe``. EVERY other path — UNSUPPORTED(10) and MALFORMED(20) —
19
+ hands the RAW, unmodified command to the content guards. So a here-doc the
20
+ classifier mis-reads degrades to the guards; it does not bypass them. Any
21
+ change here must preserve that property: widen ``classify_safe`` only with
22
+ proof, and keep every other exit raw-pass-through.
23
+
24
+ Known accepted bypass class (measured, deliberately not chased further):
25
+ R5a — a trailing backslash on the last body line is a bash continuation that
26
+ swallows the terminator; R5b — an odd apostrophe in an unquoted body poisons a
27
+ flat quote scanner past the terminator; R5c — `<<\\DELIM` is a quoted delimiter
28
+ this parser did not model. All three are FIXED below, but they are members of a
29
+ family, not the family itself. Each requires deliberately-crafted input; none is
30
+ a shape an agent emits by accident; and post-#1982 every destructive class the
31
+ safety net exists to stop is independently blocked by the content guards on the
32
+ raw text.
33
+
34
+ The two bash invariants this file relies on — both measured directly against
35
+ /bin/bash, not inferred — so a future round starts from a spec:
36
+
37
+ 1. UNQUOTED delimiter (`<<EOF`): the body performs NO quote or comment
38
+ processing (`'`, `"`, `#` are literal data bytes) but STILL expands `$(...)`,
39
+ backticks and parameters, and bash removes `\\<newline>` continuations BEFORE
40
+ matching the delimiter. Measured: `cat <<EOF` / `foo\\` / `EOF` / `bar` emits
41
+ `fooEOF\\nbar` — the here-doc never terminated.
42
+ 2. QUOTED delimiter in any spelling (`<<'EOF'`, `<<"EOF"`, `<<\\EOF`): the body is
43
+ fully literal, no expansion of any kind, and backslash is DATA rather than an
44
+ escape — so the delimiter IS matched per physical line there. Measured: the
45
+ same trailing-backslash shape under `<<'EOF'` emits `foo\\` and then fails on
46
+ `bar: command not found`.
47
+
48
+ That asymmetry is why the continuation handling in ``terminator_line_index`` is
49
+ conditioned on ``Marker.quoted`` rather than applied uniformly.
50
+
51
+ ``Marker.quoted`` means "bash makes this BODY literal" and nothing more. The
52
+ three spellings above are interchangeable for body semantics ONLY — they are NOT
53
+ interchangeable on the writer path. ``classify_safe`` still demands the literal
54
+ ``<<'DELIM'`` spelling, so `<<"EOF"` and `<<\\EOF` can never reach SAFE(0); since
55
+ #1993 they additionally record a Marker, which arms the writer-owns-a-real-marker
56
+ and multi-marker rules and lands a `gh … <<\\EOF` writer on MALFORMED(20) where it
57
+ used to be UNSUPPORTED(10). That is a deliberate, test-pinned over-block: the
58
+ documented payload-strip workaround is spelled `<<'EOF'`. Widening
59
+ ``classify_safe`` to admit other spellings is a separate change needing its own
60
+ proof — do not do it as a side effect of body-semantics work.
61
+ """
3
62
 
4
63
  from __future__ import annotations
5
64
 
@@ -494,6 +553,105 @@ def collapse_body_continuations(body: str) -> str:
494
553
  return "".join(result)
495
554
 
496
555
 
556
+ def trailing_continuation(line: str) -> str | None:
557
+ """Strip a bash ``\\<newline>`` continuation backslash, or ``None``.
558
+
559
+ A here-doc body line ends in a continuation exactly when it ends in an ODD
560
+ run of backslashes: each pair is one escaped literal backslash, so a leftover
561
+ single backslash is the one that escapes the following newline. Parity of the
562
+ TRAILING run is sufficient — any backslash earlier in the line is separated
563
+ from the run by a non-backslash byte, which ends that escape.
564
+ """
565
+ stripped = line.rstrip("\\")
566
+ if (len(line) - len(stripped)) % 2 == 0:
567
+ return None
568
+ return line[:-1]
569
+
570
+
571
+ def terminator_line_index(command: str, marker: Marker) -> int | None:
572
+ """Index of the physical line that closes ``marker``, or ``None``.
573
+
574
+ THE single home of "where does this here-doc body end", consulted by the
575
+ closure check, the body scanner, the literal-body strip and the body
576
+ neutraliser so no two of them can ever disagree about the window (issue
577
+ #1993 R5a).
578
+
579
+ Bash removes ``\\<newline>`` continuations from an UNQUOTED here-doc body
580
+ BEFORE it matches the delimiter, so a trailing backslash on the last body
581
+ line JOINS that line to the delimiter line and the here-doc is NOT closed
582
+ there — the real body swallows the apparent terminator and keeps consuming
583
+ following lines, where a ``$(...)`` the classifier believed was ordinary
584
+ post-here-doc text is expanded and EXECUTED. Matching the delimiter per
585
+ PHYSICAL line therefore under-reads the body window for unquoted delimiters.
586
+ Measured ground truth: ``cat <<EOF`` / ``foo\\`` / ``EOF`` / ``bar`` prints
587
+ ``fooEOF\\nbar`` (never terminated), while the same shape with ``<<'EOF'``
588
+ prints ``foo\\`` and then fails on ``bar: command not found``.
589
+
590
+ A QUOTED (or backslash-quoted) delimiter makes the body fully literal —
591
+ backslash is DATA there, not an escape — so those markers keep the exact
592
+ per-physical-line match, which the same measurement proves correct.
593
+ """
594
+ lines = bash_lines(command)
595
+ marker_line = command.count("\n", 0, marker.start)
596
+ pending: list[str] = []
597
+ for index in range(marker_line + 1, len(lines)):
598
+ line = lines[index]
599
+ if not marker.quoted:
600
+ continued = trailing_continuation(line)
601
+ if continued is not None:
602
+ pending.append(continued)
603
+ continue
604
+ candidate = "".join(pending) + line
605
+ pending = []
606
+ if marker.strip_tabs:
607
+ candidate = candidate.lstrip("\t")
608
+ if candidate == marker.delimiter:
609
+ return index
610
+ return None
611
+
612
+
613
+ def neutralize_heredoc_bodies(command: str, markers: list[Marker]) -> str:
614
+ """Blank every real here-doc body window, preserving offsets.
615
+
616
+ ``has_active_command_substitution`` runs ONE flat single/double-quote state
617
+ machine across the whole command — including here-doc bodies, where bash
618
+ applies NO quote processing at all. An odd apostrophe in an unquoted body
619
+ (``it's fine``) therefore opens a phantom single-quoted string in the flat
620
+ scanner that persists PAST the terminator, hiding a live ``$(...)`` on a
621
+ FOLLOWING command line that bash happily executes (issue #1993 R5b). The
622
+ trigger is precisely an odd ``'`` count: an even ``''``, an odd ``"``, a
623
+ ``#``, an ANSI-C ``$'a`` and a lone trailing backslash all leave the flat
624
+ state correct, which is what pins the defect to this one branch.
625
+
626
+ ``strip_provably_literal_body`` cannot fix this: it bails whenever the marker
627
+ is unquoted, so the poisoning body is exactly the one it never touches. So
628
+ blank the body window of EVERY marker bash really treats as a here-doc
629
+ redirection before the flat scan runs. Bytes are replaced with spaces and
630
+ newlines are preserved, so marker offsets and line numbering stay valid for
631
+ every later walker.
632
+
633
+ Blanking loses nothing: a substitution INSIDE an unquoted body is still
634
+ caught by ``unquoted_heredoc_body_has_substitution`` (scanned with correct
635
+ here-doc-body semantics), and a quoted body is genuinely inert to bash. A
636
+ marker that is not closed blanks to end of command — that command is
637
+ MALFORMED on the closure check regardless, and its trailing region is still
638
+ body-scanned. As everywhere else, a marker nested inside an open quoted
639
+ string is NOT a here-doc to bash and is skipped, so its "body" stays fully
640
+ visible to the flat scan (issue #1958 Finding 1).
641
+ """
642
+ lines = bash_lines(command)
643
+ blanked = list(lines)
644
+ for marker in markers:
645
+ if cross_line_quote_state(command, marker.start) != "plain":
646
+ continue
647
+ marker_line = command.count("\n", 0, marker.start)
648
+ end = terminator_line_index(command, marker)
649
+ stop = len(lines) if end is None else end
650
+ for index in range(marker_line + 1, stop):
651
+ blanked[index] = " " * len(lines[index])
652
+ return "\n".join(blanked)
653
+
654
+
497
655
  def body_line_has_substitution(line: str) -> bool:
498
656
  """True if an unquoted-heredoc-body line contains an active substitution.
499
657
 
@@ -553,22 +711,22 @@ def unquoted_heredoc_body_has_substitution(
553
711
  if cross_line_quote_state(command, marker.start) != "plain":
554
712
  continue
555
713
  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])
714
+ end = terminator_line_index(command, marker)
715
+ stop = len(lines) if end is None else end
716
+ body_lines = lines[marker_line + 1 : stop]
564
717
  # Join the raw body window under bash's unquoted-here-doc ``\<newline>``
565
718
  # removal BEFORE scanning, so a ``$(`` a caller split across a line
566
719
  # 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.
720
+ # window itself comes from ``terminator_line_index``, which applies that
721
+ # SAME removal before matching the delimiter because bash does (issue
722
+ # #1993 R5a). An earlier revision matched the terminator per physical
723
+ # line here on the belief that bash matches the delimiter before
724
+ # continuation processing; that is false for an unquoted delimiter, and
725
+ # it let a trailing backslash on the last body line swallow the
726
+ # terminator so this scan stopped short of the region bash still treats
727
+ # as body. This does not rely on ``collapse_line_continuations`` — whose
728
+ # flat quote state a body apostrophe corrupts — which is the whole point
729
+ # of Finding R4.
572
730
  body = collapse_body_continuations("\n".join(body_lines))
573
731
  if body_line_has_substitution(body):
574
732
  return True
@@ -595,16 +753,42 @@ def parse_marker(line: str, start: int, offset: int) -> Marker | None:
595
753
  delimiter = line[index:end]
596
754
  final = end + 1
597
755
  # 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.
756
+ # non-expanding when ANY part of the delimiter is quoted. Partial forms
757
+ # like `<<EO'F'` stay unmodelled (mis-tokenized as delimiter EO, so the
758
+ # terminator never matches and the command fails closed as MALFORMED).
759
+ # Only a delimiter this parser can PROVE was one full quote pair earns
760
+ # quoted=True, and trailing word characters after the closing quote
761
+ # (`<<'EOF'X` real bash delimiter EOFX) keep it conservative too: the
762
+ # next character must end the token.
763
+ quoted = final >= len(line) or line[final] in " \t;&|)<>#"
764
+ elif line[index] == "\\":
765
+ # `<<\DELIM` is a BACKSLASH-quoted delimiter: bash and POSIX make its
766
+ # body fully literal, exactly like `<<'DELIM'` (measured: `cat <<\EOF`
767
+ # with body `it's $(echo RAN)` prints that text verbatim, unexpanded).
768
+ # This spelling used to record NO Marker at all, on the rationale that an
769
+ # unmodelled body stays raw-visible to every content guard. That covers
770
+ # the body's own contents but NOT the body's ability to corrupt the flat
771
+ # scanner's quote state for text AFTER the terminator: an apostrophe in
772
+ # an invisible body still opened a phantom string that hid a live
773
+ # `$(...)` on a following line (issue #1993 R5c). Recording it as the
774
+ # quoted marker bash actually treats it as is both more faithful and
775
+ # what brings it under `neutralize_heredoc_bodies`. The same
776
+ # token-must-end discipline as the quote-pair branch applies, so exotic
777
+ # spellings (`<<\EOF'x'` — real bash delimiter EOFx) stay conservative.
778
+ # quoted=True here is a statement about BODY semantics only. Recording a
779
+ # Marker where there was none also arms the writer-owns-a-real-marker and
780
+ # multi-marker rules, so `gh issue create --body-file - <<\EOF` and
781
+ # `cat <<\A <<B` now fail closed at MALFORMED instead of passing through
782
+ # at UNSUPPORTED. Both are valid, harmless bash, so that is a real
783
+ # over-block — accepted and test-pinned, because `classify_safe` requires
784
+ # the literal `<<'DELIM'` spelling and `<<\EOF` can never reach SAFE
785
+ # anyway. The documented payload-strip workaround is `<<'EOF'`.
786
+ index += 1
787
+ match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[index:])
788
+ if match is None:
789
+ return None
790
+ delimiter = match.group(0)
791
+ final = index + len(delimiter)
608
792
  quoted = final >= len(line) or line[final] in " \t;&|)<>#"
609
793
  else:
610
794
  match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[index:])
@@ -698,21 +882,22 @@ def strip_provably_literal_body(command: str, markers: list[Marker]) -> str:
698
882
  return command
699
883
  lines = bash_lines(command)
700
884
  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
885
+ end = terminator_line_index(command, marker)
886
+ if end is None:
887
+ return command
888
+ return "\n".join(lines[: marker_line + 1] + lines[end:])
706
889
 
707
890
 
708
891
  def marker_is_closed(command: str, marker: Marker) -> bool:
709
- marker_line = command.count("\n", 0, marker.start)
710
- lines = bash_lines(command)
711
- for line in lines[marker_line + 1 :]:
712
- candidate = line.lstrip("\t") if marker.strip_tabs else line
713
- if candidate == marker.delimiter:
714
- return True
715
- return False
892
+ """True when bash really terminates this here-doc inside the command.
893
+
894
+ Delegates to ``terminator_line_index`` so the closure verdict and the body
895
+ window are decided by one rule. For an unquoted delimiter that rule removes
896
+ ``\\<newline>`` continuations first, so a trailing backslash on the last body
897
+ line correctly reads as NOT closed — which is what bash does, and what makes
898
+ the swallowed-terminator shape fail closed as MALFORMED (issue #1993 R5a).
899
+ """
900
+ return terminator_line_index(command, marker) is not None
716
901
 
717
902
 
718
903
  def main() -> int:
@@ -748,11 +933,18 @@ def main() -> int:
748
933
  return MALFORMED
749
934
 
750
935
  # Nested substitution plus a heredoc is executable shell syntax unless it
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.
936
+ # matched the one exact quoted `--body "$(cat ...)"` form above. Here-doc
937
+ # bodies are neutralised before the flat scan: bash applies NO quote
938
+ # processing inside a body, so leaving those bytes in a flat quote state
939
+ # machine let an odd body apostrophe poison the scanner PAST the terminator
940
+ # and hide a live substitution on a following command line (issue #1993
941
+ # R5b). Neutralising preserves offsets, so the narrower literal-body strip
942
+ # still applies on top; the text outside every body window is still scanned,
943
+ # and in-body substitutions are covered by the body-semantics scan below.
754
944
  if has_active_command_substitution(
755
- strip_provably_literal_body(logical_command, markers)
945
+ strip_provably_literal_body(
946
+ neutralize_heredoc_bodies(logical_command, markers), markers
947
+ )
756
948
  ) or unquoted_heredoc_body_has_substitution(logical_command, markers):
757
949
  return MALFORMED
758
950
  if len(markers) > 1:
@@ -54,7 +54,27 @@
54
54
  # would never expand — a single-quoted `echo '$(rm -rf /)'` or an escaped
55
55
  # `echo "\$(rm -rf /)"` — because the scan has no quote-context awareness. That
56
56
  # over-block stays inside this accepted class. Workaround: quote-break the string
57
- # or use the gh-writer heredoc form, whose payload is stripped before the guards run.
57
+ # or use the gh-writer heredoc form, whose payload is stripped before the guards
58
+ # run — spelled with a QUOTE-PAIR delimiter (`<<'EOF'`). That exact spelling is
59
+ # what the SAFE path recognises; `<<"EOF"` and `<<\EOF` do not reach it, and
60
+ # since #1993 a `gh … <<\EOF` writer fails closed instead (see parse_marker).
61
+ #
62
+ # Known accepted BYPASS class (the symmetric error direction, issue #1993): the
63
+ # heredoc classifier in parity-safety-net-heredoc.py re-implements bash's
64
+ # here-doc lexing in Python, and any divergence from real bash is a shape it
65
+ # mis-classifies. Five rounds on #1958 and three more on #1993 (R5a swallowed
66
+ # terminator, R5b body-apostrophe quote-state poisoning, R5c backslash-quoted
67
+ # delimiter) established that enumerating those divergences is an open-ended
68
+ # arms race, so the class is CLOSED as accepted rather than chased further. This
69
+ # is survivable because of where a mis-classification lands: ONLY the narrow
70
+ # gh-writer SAFE path strips payload text, and every other classifier exit hands
71
+ # the RAW command to the guards below. A missed here-doc therefore degrades to
72
+ # the content guards; it does not bypass them. Since #1982 taught those guards to
73
+ # see through substitution wrappers, every destructive class this net exists to
74
+ # stop is blocked on the raw text regardless of how the here-doc was read. The
75
+ # net catches ACCIDENTAL catastrophic commands; it is not a wall against an
76
+ # adversary who knows bash's lexer, and it was never the only thing standing
77
+ # between an agent and arbitrary execution.
58
78
  #
59
79
  # Operators extend the built-in rules with a project-local rule file — one
60
80
  # extended-regex (ERE) per line, blank lines and `#` comments ignored — managed
@@ -40,17 +40,25 @@ memorized list:
40
40
  exemption from projection-only serving) plus `projectLearnings` for the
41
41
  bounded projection (to measure **budget pressure**: how many entries the
42
42
  projection omits). Never hand-parse or hand-edit the raw file.
43
- 2. **Rules trees** — the plugin `rules/eager/` + `rules/reference/` pairs AND
43
+ 2. **The overflow buffer** — captures the ledger had no budget to accept, held
44
+ in `PROJECT_LEARNINGS.overflow.md` beside the resolved ledger. Read it ONLY
45
+ through the executable contract: `bunx @codyswann/lisa learnings-overflow`,
46
+ which prints `{ file, pending, entries }` as JSON. Never hand-parse or
47
+ hand-edit it. These are judged-durable learnings with nowhere to live, so a
48
+ non-empty overflow is the single strongest budget-pressure evidence there is
49
+ — stronger than an omission count, because room was not merely tight, a real
50
+ capture was turned away. Drain it every run (see The audit cycle).
51
+ 3. **Rules trees** — the plugin `rules/eager/` + `rules/reference/` pairs AND
44
52
  the host project's `.claude/rules/` (e.g. `PROJECT_RULES.md`, which is
45
53
  human-authored only — its existing sections are still audit candidates;
46
54
  first-run candidates come from exactly there).
47
- 3. **Skills** — `.claude/skills/` and the plugin skill roots the runtime
55
+ 4. **Skills** — `.claude/skills/` and the plugin skill roots the runtime
48
56
  exposes (descriptions are eager context; bodies load on invoke).
49
- 4. **The wiki index** — `wiki/index.md` when the project has a wiki.
50
- 5. **Mechanical-control surfaces** — lint configs (ESLint/oxlint), ast-grep
57
+ 5. **The wiki index** — `wiki/index.md` when the project has a wiki.
58
+ 6. **Mechanical-control surfaces** — lint configs (ESLint/oxlint), ast-grep
51
59
  rules, git hooks, test suites, and `package.lisa.json` force sections:
52
60
  the surfaces that answer "does a mechanical owner already exist?".
53
- 6. **The tracker** — prior gardener tickets (open, done, and closed-rejected)
61
+ 7. **The tracker** — prior gardener tickets (open, done, and closed-rejected)
54
62
  are its memory; see Idempotency.
55
63
 
56
64
  ## Candidate-selection rules
@@ -64,7 +72,7 @@ Gather, per item, the five evidence axes:
64
72
  | **Staleness** | Does the item reference files, flags, versions, or tools that no longer exist? | Glob/Grep the referenced paths and configs. |
65
73
  | **Redundancy** | Does a mechanical owner already enforce the invariant? (The double-payment hunter.) | Check lint/ast-grep/hook/test/force surfaces for the same invariant. |
66
74
  | **Contradiction** | Does the item contradict another rule, skill, config, or observed current behavior? | Cross-reference the inventoried surfaces. |
67
- | **Budget pressure** | Is the ledger projection omitting entries, or the eager tier growing? | `projectLearnings` omission count; eager-tree token/size trend. |
75
+ | **Budget pressure** | Is the ledger projection omitting entries, captures landing in the overflow, or the eager tier growing? | `projectLearnings` omission count; `learnings-overflow` pending count; eager-tree token/size trend. |
68
76
 
69
77
  Selection outcomes per candidate: **PROMOTE** (up the ladder), **DEMOTE**
70
78
  (down the ladder), **CONFIRM** (evidence the entry demonstrably applied —
@@ -117,6 +125,34 @@ audits the auditor without re-running it.
117
125
  ## The audit cycle
118
126
 
119
127
  1. **Inventory** the sources of truth above.
128
+ 1.5. **Drain the overflow** — before anything else, because these captures are
129
+ the only knowledge in the system with no durable home at all. For each entry
130
+ `bunx @codyswann/lisa learnings-overflow` reports:
131
+
132
+ - **File its durable home first.** Route the entry through the ladder router
133
+ like any other candidate and file the resulting ticket, carrying the
134
+ entry's `rule`, `why`, `provenance`, and `confidence` verbatim so nothing
135
+ is paraphrased away. Use the ordinary `[lisa-gardener]` marker dedupe, so a
136
+ re-run never files the same overflowed capture twice.
137
+ - **Drain it only after that ticket exists**, one call naming exactly the ids
138
+ that now have a home:
139
+
140
+ ```bash
141
+ bunx @codyswann/lisa learnings-overflow --drain <id> [<id>...]
142
+ ```
143
+
144
+ Never drain first and file second, and never drain ids in bulk "to clean
145
+ up": the buffer is the only copy, so an entry removed before its ticket
146
+ exists is gone. Filing first makes a partial run — a rate limit, a network
147
+ failure — safely resumable, because everything unfiled is still in the
148
+ buffer for the next run. An id the command reports as `absent` was already
149
+ drained by an earlier run; that is expected, not an error.
150
+ - **Report the drain in the run proof**: how many were pending, which ids
151
+ were re-homed to which tickets, and how many remain.
152
+
153
+ A non-empty overflow also means the ledger turned a judged-durable capture
154
+ away, so treat it as top-priority budget-pressure evidence when selecting
155
+ RETIRE/PROMOTE candidates this run — the room has to come from somewhere.
120
156
  2. **Evidence** — gather the five axes per item; drop items with none.
121
157
  3. **Classify** — pass each candidate (`rule`, `why`, `provenance`,
122
158
  `evidence`) to the ladder router (the `skill-evaluator` agent). The router
@@ -103,7 +103,44 @@ same reason the rm guard treats substitution-wrapping as verdict-neutral (issue
103
103
  #1982): an executable `echo "$(rm -rf /)"` is blocked, and so are inert twins the
104
104
  shell would never expand — a single-quoted `echo '$(rm -rf /)'` or an escaped
105
105
  `echo "\$(rm -rf /)"` — since the scan has no quote-context awareness. The same
106
- quote-break or heredoc workaround applies.
106
+ quote-break or heredoc workaround applies. Spell that heredoc workaround with a
107
+ **quote-pair** delimiter (`gh issue create --body-file - <<'EOF'`) — only that
108
+ spelling reaches the payload-strip path. `<<"EOF"` and `<<\EOF` do not, and a
109
+ `gh … <<\EOF` writer now fails closed instead.
110
+
111
+ ## What this does and does not defend against
112
+
113
+ Read this before treating the safety net as a wall.
114
+
115
+ **What it is for:** stopping *accidental* catastrophic commands — an agent (or a
116
+ human) that reaches for `rm -rf /`, force-pushes `main`, drops a table, or writes
117
+ to a raw device by mistake. Within that job the guards above are the floor and
118
+ they are always on.
119
+
120
+ **What it is not:** a security boundary against a deliberate adversary. Two
121
+ structural reasons, both accepted rather than open bugs:
122
+
123
+ - **It is a text scan, not a shell engine.** It has no quote-context awareness,
124
+ which produces the false positives described above and means an operation
125
+ spelled in a way the patterns do not anticipate is not matched.
126
+ - **The here-doc classifier re-implements bash's lexer in Python** to decide
127
+ which payload text is inert data. Every divergence from real bash is a shape it
128
+ mis-classifies. Eight distinct divergences were found and fixed across issues
129
+ #1958 and #1993; the class is open-ended, so it is now closed as an accepted
130
+ limitation rather than chased indefinitely.
131
+
132
+ **Why that is tolerable:** a here-doc mis-classification degrades to the content
133
+ guards rather than bypassing them. Only the narrow `gh issue|pr` writer form ever
134
+ strips payload text; every other path passes the **raw** command to the guards,
135
+ and since issue #1982 those guards see through substitution wrappers. So a
136
+ mis-read here-doc costs no guard coverage for any destructive class listed above.
137
+
138
+ **The honest bottom line:** an agent that is actively trying to run something
139
+ destructive has many paths that never involve a here-doc at all — the safety net
140
+ does not claim to close them. Do not rely on it as the only control over what an
141
+ agent may execute; treat it as the last line of defence against mistakes, and
142
+ keep the real boundary (credentials, permissions, environment isolation) outside
143
+ the agent.
107
144
 
108
145
  ## View the current rules
109
146
 
@@ -187,8 +187,13 @@ No learning content is ever committed without a PR — there is no other write p
187
187
  Learnings ledger saturated — a durable capture was dropped for budget. The projection is now omitting entries; promote or retire a learning to reclaim room. This is an operator-visible budget-pressure signal, not itself a new learning.
188
188
  ```
189
189
 
190
- The `[lisa-ledger-saturated]` marker sits **outside the `[lisa-learning-*]` namespace** that the gardener (`lisa-learnings-audit`) auto-excludes from candidacy. The gardener already derives budget pressure independently — it measures the entries its `projectLearnings` projection has to omit — so this ticket does not feed that measurement mechanically; its job is an idempotent, operator-visible notification that saturation happened. Keeping it outside `[lisa-learning-*]` matters so the audit tooling does not silently filter it as learning-machinery noise before an operator sees it. **Do not reuse a `[lisa-learning-*]` marker here.** The candidate itself is still dropped; this signal records the saturation, it does not persist the rule.
191
- 4. **Branch + commit.** Work on branch `learning/<fingerprint>`. Commit only the learnings file; verify with `git diff --name-only` that the diff touches nothing else.
190
+ The `[lisa-ledger-saturated]` marker sits **outside the `[lisa-learning-*]` namespace** that the gardener (`lisa-learnings-audit`) auto-excludes from candidacy. The gardener already derives budget pressure independently — it measures the entries its `projectLearnings` projection has to omit — so this ticket does not feed that measurement mechanically; its job is an idempotent, operator-visible notification that saturation happened. Keeping it outside `[lisa-learning-*]` matters so the audit tooling does not silently filter it as learning-machinery noise before an operator sees it. **Do not reuse a `[lisa-learning-*]` marker here.** The signal records the saturation; it does not persist the rule.
191
+ - **The dropped capture is preserved for you — ship it.** The writer does this itself, so there is nothing to compose by hand: when the budget re-assertion fails, `persistConsolidatedLearning` / `persistLearningEntry` write the rejected entry to the **overflow buffer** — `PROJECT_LEARNINGS.overflow.md`, a sibling of the resolved ledger — and then re-raise a budget error naming that file. The thrown error is the confirmation; if it does not name the overflow, the capture was NOT preserved (the overflow is full too) and that is what the error says instead.
192
+
193
+ The overflow is git-tracked and bound to the same union merge driver as the ledger, so **still open the pull request on a budget-forced drop** — carrying the overflow file instead of the ledger. That is the whole point of preserving it: a capture that reaches only a disposable `learning/<fingerprint>` worktree is durable in name only, and the gardener draining `main` would never see it. Never hand-edit the overflow, and never delete an entry from it to make room; the gardener drains it.
194
+
195
+ So a budget-forced drop produces **both** halves and neither substitutes for the other: the `[lisa-ledger-saturated]` ticket (the signal that room must be reclaimed) and a pull request carrying the overflow file (the content itself). Report both in the run summary.
196
+ 4. **Branch + commit.** Work on branch `learning/<fingerprint>`. Commit only the learnings surface — the ledger, or the overflow buffer on a budget-forced drop; verify with `git diff --name-only` that the diff touches nothing else.
192
197
  5. **PR body.** Exactly one marker line plus the reviewable story:
193
198
 
194
199
  ```markdown
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.298.0",
3
+ "version": "2.299.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"