@codyswann/lisa 2.295.6 → 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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +4 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net-heredoc.py +362 -32
- package/dist/opencode/plugin-templates/parity-safety-net.sh +13 -7
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/parity-safety-net-heredoc.py +362 -32
- package/plugins/lisa/hooks/parity-safety-net.sh +13 -7
- package/plugins/lisa-agy/hooks/parity-safety-net-heredoc.py +362 -32
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +13 -7
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net-heredoc.py +362 -32
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +13 -7
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net-heredoc.py +362 -32
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +13 -7
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/parity-safety-net-heredoc.py +362 -32
- 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]
|
|
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
|
|
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
|
|
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]
|
|
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]
|
|
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
|
|
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
|
|
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]
|
|
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,
|
|
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
|
|
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
|
|
415
|
-
logical_command
|
|
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
|
-
|
|
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
|
|
100
|
-
#
|
|
101
|
-
#
|
|
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"
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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"
|
package/package.json
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"brace-expansion": ">=5.0.6"
|
|
114
114
|
},
|
|
115
115
|
"name": "@codyswann/lisa",
|
|
116
|
-
"version": "2.
|
|
116
|
+
"version": "2.296.0",
|
|
117
117
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
118
118
|
"main": "dist/index.js",
|
|
119
119
|
"exports": {
|