@codyswann/lisa 2.222.2 → 2.223.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 (121) hide show
  1. package/dist/opencode/hooks-installer.d.ts.map +1 -1
  2. package/dist/opencode/hooks-installer.js +23 -2
  3. package/dist/opencode/hooks-installer.js.map +1 -1
  4. package/dist/opencode/plugin-templates/lisa-parity-safety-net.ts +43 -0
  5. package/dist/opencode/plugin-templates/parity-safety-net-heredoc.py +417 -0
  6. package/dist/opencode/plugin-templates/parity-safety-net.sh +155 -0
  7. package/dist/opencode/support-file-resolver.d.ts +8 -0
  8. package/dist/opencode/support-file-resolver.d.ts.map +1 -0
  9. package/dist/opencode/support-file-resolver.js +16 -0
  10. package/dist/opencode/support-file-resolver.js.map +1 -0
  11. package/package.json +1 -1
  12. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/skills/lisa-github-add-journey/SKILL.md +4 -3
  15. package/plugins/lisa/.codex-plugin/skills/lisa-github-validate-issue/SKILL.md +3 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-jira-add-journey/SKILL.md +4 -3
  17. package/plugins/lisa/.codex-plugin/skills/lisa-jira-validate-ticket/SKILL.md +3 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-linear-add-journey/SKILL.md +4 -3
  19. package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +3 -1
  20. package/plugins/lisa/hooks/parity-safety-net-heredoc.py +417 -0
  21. package/plugins/lisa/hooks/parity-safety-net.agy.sh +50 -0
  22. package/plugins/lisa/hooks/parity-safety-net.sh +33 -51
  23. package/plugins/lisa/rules/eager/verification.md +1 -1
  24. package/plugins/lisa/rules/reference/verification.md +8 -0
  25. package/plugins/lisa/skills/lisa-github-add-journey/SKILL.md +4 -3
  26. package/plugins/lisa/skills/lisa-github-validate-issue/SKILL.md +3 -1
  27. package/plugins/lisa/skills/lisa-jira-add-journey/SKILL.md +4 -3
  28. package/plugins/lisa/skills/lisa-jira-validate-ticket/SKILL.md +3 -1
  29. package/plugins/lisa/skills/lisa-linear-add-journey/SKILL.md +4 -3
  30. package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +3 -1
  31. package/plugins/lisa-agy/hooks/parity-safety-net-heredoc.py +417 -0
  32. package/plugins/lisa-agy/hooks/parity-safety-net.agy.sh +50 -0
  33. package/plugins/lisa-agy/hooks/parity-safety-net.sh +155 -0
  34. package/plugins/lisa-agy/hooks.json +13 -0
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/skills/lisa-github-add-journey/SKILL.md +4 -3
  37. package/plugins/lisa-agy/skills/lisa-github-validate-issue/SKILL.md +3 -1
  38. package/plugins/lisa-agy/skills/lisa-jira-add-journey/SKILL.md +4 -3
  39. package/plugins/lisa-agy/skills/lisa-jira-validate-ticket/SKILL.md +3 -1
  40. package/plugins/lisa-agy/skills/lisa-linear-add-journey/SKILL.md +4 -3
  41. package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +3 -1
  42. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  45. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-copilot/hooks/parity-safety-net-heredoc.py +417 -0
  49. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +33 -51
  50. package/plugins/lisa-copilot/rules/eager/verification.md +1 -1
  51. package/plugins/lisa-copilot/rules/reference/verification.md +8 -0
  52. package/plugins/lisa-copilot/skills/lisa-github-add-journey/SKILL.md +4 -3
  53. package/plugins/lisa-copilot/skills/lisa-github-validate-issue/SKILL.md +3 -1
  54. package/plugins/lisa-copilot/skills/lisa-jira-add-journey/SKILL.md +4 -3
  55. package/plugins/lisa-copilot/skills/lisa-jira-validate-ticket/SKILL.md +3 -1
  56. package/plugins/lisa-copilot/skills/lisa-linear-add-journey/SKILL.md +4 -3
  57. package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +3 -1
  58. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cursor/hooks/parity-safety-net-heredoc.py +417 -0
  60. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +33 -51
  61. package/plugins/lisa-cursor/rules/verification-reference.mdc +8 -0
  62. package/plugins/lisa-cursor/rules/verification.mdc +1 -1
  63. package/plugins/lisa-cursor/skills/lisa-github-add-journey/SKILL.md +4 -3
  64. package/plugins/lisa-cursor/skills/lisa-github-validate-issue/SKILL.md +3 -1
  65. package/plugins/lisa-cursor/skills/lisa-jira-add-journey/SKILL.md +4 -3
  66. package/plugins/lisa-cursor/skills/lisa-jira-validate-ticket/SKILL.md +3 -1
  67. package/plugins/lisa-cursor/skills/lisa-linear-add-journey/SKILL.md +4 -3
  68. package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +3 -1
  69. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-expo-agy/plugin.json +1 -1
  72. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  82. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  87. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  92. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  96. package/plugins/lisa-rails-agy/plugin.json +1 -1
  97. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  101. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  102. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  106. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  107. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  109. package/plugins/src/base/hooks/parity-safety-net-heredoc.py +417 -0
  110. package/plugins/src/base/hooks/parity-safety-net.agy.sh +50 -0
  111. package/plugins/src/base/hooks/parity-safety-net.sh +33 -51
  112. package/plugins/src/base/rules/eager/verification.md +1 -1
  113. package/plugins/src/base/rules/reference/verification.md +8 -0
  114. package/plugins/src/base/skills/lisa-github-add-journey/SKILL.md +4 -3
  115. package/plugins/src/base/skills/lisa-github-validate-issue/SKILL.md +3 -1
  116. package/plugins/src/base/skills/lisa-jira-add-journey/SKILL.md +4 -3
  117. package/plugins/src/base/skills/lisa-jira-validate-ticket/SKILL.md +3 -1
  118. package/plugins/src/base/skills/lisa-linear-add-journey/SKILL.md +4 -3
  119. package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +3 -1
  120. package/scripts/copy-opencode-plugin-templates.mjs +12 -0
  121. package/scripts/generate-agy-plugin-artifacts.mjs +24 -10
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env python3
2
+ """Classify the only heredoc forms whose payload is non-executable text."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import shlex
8
+ import sys
9
+ from dataclasses import dataclass
10
+
11
+ SAFE = 0
12
+ UNSUPPORTED = 10
13
+ MALFORMED = 20
14
+
15
+ DELIMITER = r"(?P<delimiter>[A-Za-z_][A-Za-z0-9_]*)"
16
+ BODY_CAT_MARKER = re.compile(
17
+ r"(?P<prefix>.*(?:^|\s)--body\s+)\"\$\(cat\s+<<'"
18
+ + DELIMITER
19
+ + r"'\s*$"
20
+ )
21
+ ALLOWED_GROUPS = {"issue", "pr"}
22
+ ALLOWED_ACTIONS = {"create", "edit", "comment"}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Marker:
27
+ start: int
28
+ end: int
29
+ delimiter: str
30
+ strip_tabs: bool
31
+
32
+
33
+ def shell_tokens(prefix: str) -> list[str] | None:
34
+ """Return literal simple-command tokens, rejecting executable shell syntax."""
35
+ state = "plain"
36
+ escaped = False
37
+ index = 0
38
+ while index < len(prefix):
39
+ char = prefix[index]
40
+ if escaped:
41
+ escaped = False
42
+ elif state == "single":
43
+ if char == "'":
44
+ state = "plain"
45
+ elif state == "double":
46
+ if char == '"':
47
+ state = "plain"
48
+ elif char == "\\":
49
+ escaped = True
50
+ elif char == "`" or (
51
+ char == "$"
52
+ and index + 1 < len(prefix)
53
+ and prefix[index + 1] in "([{"):
54
+ return None
55
+ elif char == "'":
56
+ state = "single"
57
+ elif char == '"':
58
+ state = "double"
59
+ elif char == "\\":
60
+ escaped = True
61
+ elif char == "#" and (index == 0 or prefix[index - 1].isspace()):
62
+ return None
63
+ elif char in ";|&()<>`\n\r":
64
+ return None
65
+ elif char == "$" and index + 1 < len(prefix) and prefix[index + 1] in "([{":
66
+ return None
67
+ index += 1
68
+
69
+ if state != "plain" or escaped:
70
+ return None
71
+ try:
72
+ return shlex.split(prefix, comments=False, posix=True)
73
+ except ValueError:
74
+ return None
75
+
76
+
77
+ def is_allowed_gh(tokens: list[str]) -> bool:
78
+ return (
79
+ len(tokens) >= 3
80
+ and tokens[0] == "gh"
81
+ and tokens[1] in ALLOWED_GROUPS
82
+ and tokens[2] in ALLOWED_ACTIONS
83
+ )
84
+
85
+
86
+ def line_has_allowed_writer(line: str) -> bool:
87
+ """Find a writer after shell quote-concatenation and control operators."""
88
+ try:
89
+ lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|()")
90
+ lexer.whitespace_split = True
91
+ lexer.commenters = "#"
92
+ tokens = list(lexer)
93
+ except ValueError:
94
+ return False
95
+ return any(
96
+ is_allowed_gh(tokens[index : index + 3])
97
+ for index in range(max(0, len(tokens) - 2))
98
+ )
99
+
100
+
101
+ def has_body_file_stdin(tokens: list[str]) -> bool:
102
+ return "--body-file=-" in tokens or any(
103
+ tokens[index : index + 2] == ["--body-file", "-"]
104
+ for index in range(len(tokens) - 1)
105
+ )
106
+
107
+
108
+ def exact_terminator(lines: list[str], delimiter: str, start: int) -> int | None:
109
+ for index in range(start, len(lines)):
110
+ if lines[index] == delimiter:
111
+ return index
112
+ return None
113
+
114
+
115
+ def only_whitespace(lines: list[str], start: int) -> bool:
116
+ return all(not line.strip() for line in lines[start:])
117
+
118
+
119
+ def classify_safe(command: str) -> str | None:
120
+ lines = command.splitlines()
121
+ if len(lines) < 2 or "\x00" in command or "\r" in command:
122
+ return None
123
+
124
+ header = lines[0]
125
+ body_cat = BODY_CAT_MARKER.search(header)
126
+ if body_cat is not None:
127
+ tokens = shell_tokens(body_cat.group("prefix"))
128
+ if tokens is None or not is_allowed_gh(tokens):
129
+ return None
130
+ terminator = exact_terminator(lines, body_cat.group("delimiter"), 1)
131
+ if terminator is None:
132
+ raise ValueError("unclosed body cat heredoc")
133
+ if terminator + 1 >= len(lines) or lines[terminator + 1].strip() != ')"':
134
+ raise ValueError("body cat substitution is not closed exactly")
135
+ if not only_whitespace(lines, terminator + 2):
136
+ raise ValueError("command follows body cat heredoc")
137
+ return body_cat.group("prefix") + '"<heredoc-text>"'
138
+
139
+ header_markers = top_level_markers(header)
140
+ if len(header_markers) != 1:
141
+ return None
142
+ direct = header_markers[0]
143
+ raw_marker = header[direct.start : direct.end]
144
+ if raw_marker != f"<<'{direct.delimiter}'" or header[direct.end :].strip():
145
+ return None
146
+ prefix = header[: direct.start]
147
+ tokens = shell_tokens(prefix)
148
+ if tokens is None or not is_allowed_gh(tokens) or not has_body_file_stdin(tokens):
149
+ return None
150
+ terminator = exact_terminator(lines, direct.delimiter, 1)
151
+ if terminator is None:
152
+ raise ValueError("unclosed direct heredoc")
153
+ if not only_whitespace(lines, terminator + 1):
154
+ raise ValueError("command follows direct heredoc")
155
+ return prefix.rstrip()
156
+
157
+
158
+ def top_level_markers(command: str) -> list[Marker]:
159
+ """Find conservative top-level markers for malformed/duplicate detection."""
160
+ markers: list[Marker] = []
161
+ lines = command.splitlines()
162
+ offset = 0
163
+ for line in lines:
164
+ state = "plain"
165
+ escaped = False
166
+ index = 0
167
+ while index < len(line):
168
+ char = line[index]
169
+ if escaped:
170
+ escaped = False
171
+ elif state == "single":
172
+ if char == "'":
173
+ state = "plain"
174
+ elif state == "double":
175
+ if char == '"':
176
+ state = "plain"
177
+ elif char == "\\":
178
+ escaped = True
179
+ elif char == "'":
180
+ state = "single"
181
+ elif char == '"':
182
+ state = "double"
183
+ elif char == "\\":
184
+ escaped = True
185
+ elif char == "#" and (index == 0 or line[index - 1].isspace()):
186
+ break
187
+ elif line.startswith("<<", index) and not line.startswith("<<<", index):
188
+ marker = parse_marker(line, index, offset)
189
+ if marker is not None:
190
+ markers.append(marker)
191
+ index = marker.end - offset - 1
192
+ index += 1
193
+ offset += len(line) + 1
194
+ return markers
195
+
196
+
197
+ def unquoted_code_and_comment(line: str) -> tuple[str, str]:
198
+ """Return executable text and any shell comment, hiding quoted prose."""
199
+ code: list[str] = []
200
+ state = "plain"
201
+ escaped = False
202
+ index = 0
203
+ while index < len(line):
204
+ char = line[index]
205
+ if escaped:
206
+ code.append(" ")
207
+ escaped = False
208
+ elif state == "single":
209
+ code.append(" ")
210
+ if char == "'":
211
+ state = "plain"
212
+ elif state == "double":
213
+ code.append(" ")
214
+ if char == '"':
215
+ state = "plain"
216
+ elif char == "\\":
217
+ escaped = True
218
+ elif char == "'":
219
+ code.append(" ")
220
+ state = "single"
221
+ elif char == '"':
222
+ code.append(" ")
223
+ state = "double"
224
+ elif char == "\\":
225
+ code.append(" ")
226
+ escaped = True
227
+ elif char == "#" and (index == 0 or line[index - 1].isspace()):
228
+ return "".join(code), line[index + 1 :]
229
+ else:
230
+ code.append(char)
231
+ index += 1
232
+ return "".join(code), ""
233
+
234
+
235
+ def collapse_line_continuations(command: str) -> str:
236
+ """Remove Bash backslash-newline pairs outside single-quoted text."""
237
+ result: list[str] = []
238
+ state = "plain"
239
+ escaped = False
240
+ index = 0
241
+ while index < len(command):
242
+ char = command[index]
243
+ if escaped:
244
+ result.append(char)
245
+ escaped = False
246
+ elif state == "single":
247
+ result.append(char)
248
+ if char == "'":
249
+ state = "plain"
250
+ elif char == "\\" and command.startswith("\n", index + 1):
251
+ index += 2
252
+ continue
253
+ elif char == "\\":
254
+ result.append(char)
255
+ escaped = True
256
+ elif char == "'" and state == "plain":
257
+ result.append(char)
258
+ state = "single"
259
+ elif char == '"':
260
+ result.append(char)
261
+ state = "plain" if state == "double" else "double"
262
+ else:
263
+ result.append(char)
264
+ index += 1
265
+ return "".join(result)
266
+
267
+
268
+ def writer_owns_real_marker(command: str, markers: list[Marker]) -> bool:
269
+ """Detect a supported writer on a line containing a real heredoc marker."""
270
+ logical_command = collapse_line_continuations(command)
271
+ logical_markers = (
272
+ markers if logical_command == command else top_level_markers(logical_command)
273
+ )
274
+ lines = logical_command.splitlines()
275
+ for marker in logical_markers:
276
+ line_index = logical_command.count("\n", 0, marker.start)
277
+ if line_has_allowed_writer(lines[line_index]):
278
+ return True
279
+ return False
280
+
281
+
282
+ def writer_has_commented_marker_and_following_code(command: str) -> bool:
283
+ """Reject fake writer markers whose following lines would execute."""
284
+ lines = command.splitlines()
285
+ for index, line in enumerate(lines):
286
+ _code, comment = unquoted_code_and_comment(line)
287
+ if (
288
+ line_has_allowed_writer(line)
289
+ and "<<" in comment
290
+ and any(candidate.strip() for candidate in lines[index + 1 :])
291
+ ):
292
+ return True
293
+ return False
294
+
295
+
296
+ def has_active_command_substitution(command: str) -> bool:
297
+ """Detect substitutions outside single-quoted text and shell comments."""
298
+ state = "plain"
299
+ escaped = False
300
+ index = 0
301
+ while index < len(command):
302
+ char = command[index]
303
+ if escaped:
304
+ escaped = False
305
+ elif state == "single":
306
+ if char == "'":
307
+ state = "plain"
308
+ elif state == "double":
309
+ if char == '"':
310
+ state = "plain"
311
+ elif char == "\\":
312
+ escaped = True
313
+ elif char == "`" or command.startswith("$(", index):
314
+ return True
315
+ elif char == "'":
316
+ state = "single"
317
+ elif char == '"':
318
+ state = "double"
319
+ elif char == "\\":
320
+ escaped = True
321
+ elif char == "#" and (
322
+ index == 0 or command[index - 1].isspace()
323
+ ):
324
+ newline = command.find("\n", index)
325
+ if newline < 0:
326
+ return False
327
+ index = newline
328
+ elif char == "`" or command.startswith("$(", index):
329
+ return True
330
+ index += 1
331
+ return False
332
+
333
+
334
+ def parse_marker(line: str, start: int, offset: int) -> Marker | None:
335
+ index = start + 2
336
+ strip_tabs = False
337
+ if index < len(line) and line[index] == "-":
338
+ strip_tabs = True
339
+ index += 1
340
+ while index < len(line) and line[index] in " \t":
341
+ index += 1
342
+ if index >= len(line):
343
+ return None
344
+ quote = line[index] if line[index] in "'\"" else None
345
+ if quote:
346
+ index += 1
347
+ end = line.find(quote, index)
348
+ if end < 0:
349
+ return None
350
+ delimiter = line[index:end]
351
+ final = end + 1
352
+ else:
353
+ match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[index:])
354
+ if match is None:
355
+ return None
356
+ delimiter = match.group(0)
357
+ final = index + len(delimiter)
358
+ if not delimiter:
359
+ return None
360
+ return Marker(offset + start, offset + final, delimiter, strip_tabs)
361
+
362
+
363
+ def marker_is_closed(command: str, marker: Marker) -> bool:
364
+ marker_line = command.count("\n", 0, marker.start)
365
+ lines = command.splitlines()
366
+ for line in lines[marker_line + 1 :]:
367
+ candidate = line.lstrip("\t") if marker.strip_tabs else line
368
+ if candidate == marker.delimiter:
369
+ return True
370
+ return False
371
+
372
+
373
+ def main() -> int:
374
+ command = sys.stdin.read()
375
+ if "<<" not in command:
376
+ print(command, end="")
377
+ return SAFE
378
+ try:
379
+ sanitized = classify_safe(command)
380
+ except ValueError:
381
+ return MALFORMED
382
+ if sanitized is not None:
383
+ print(sanitized, end="")
384
+ return SAFE
385
+
386
+ logical_command = collapse_line_continuations(command)
387
+ markers = top_level_markers(logical_command)
388
+ if writer_has_commented_marker_and_following_code(logical_command):
389
+ return MALFORMED
390
+ if writer_owns_real_marker(logical_command, markers):
391
+ return MALFORMED
392
+ # Textual operators inside quotes or comments are not heredocs. Leave the
393
+ # raw command unchanged so ordinary guard matching proceeds normally.
394
+ if not markers and not has_active_command_substitution(logical_command):
395
+ return UNSUPPORTED
396
+
397
+ # A supported writer that failed the exact safe grammar is ambiguous: do
398
+ # not let chaining, alternate redirects, or an expanding delimiter turn a
399
+ # would-be payload exemption into a bypass.
400
+ if logical_command.splitlines() and line_has_allowed_writer(
401
+ logical_command.splitlines()[0]
402
+ ):
403
+ return MALFORMED
404
+
405
+ # Nested substitution plus a heredoc is executable shell syntax unless it
406
+ # matched the one exact quoted `--body "$(cat ...)"` form above.
407
+ if has_active_command_substitution(logical_command):
408
+ return MALFORMED
409
+ if len(markers) > 1:
410
+ return MALFORMED
411
+ if markers and not marker_is_closed(logical_command, markers[0]):
412
+ return MALFORMED
413
+ return UNSUPPORTED
414
+
415
+
416
+ if __name__ == "__main__":
417
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Antigravity (agy) PreToolUse adapter for Lisa's canonical parity safety net.
3
+ #
4
+ # agy sends `{toolCall:{name:"run_command",args:{CommandLine:"..."}}}` and
5
+ # expects an allow/deny JSON object on stdout. The canonical hook consumes the
6
+ # Claude Bash-hook envelope and communicates denial with exit status 2. This
7
+ # adapter only translates protocols; all classification stays in the canonical
8
+ # parity-safety-net.sh and parity-safety-net-heredoc.py files beside it.
9
+ set -uo pipefail
10
+
11
+ allow() {
12
+ printf '%s\n' '{"decision":"allow"}'
13
+ exit 0
14
+ }
15
+
16
+ deny() {
17
+ local reason="$1"
18
+ if command -v jq >/dev/null 2>&1; then
19
+ jq -cn --arg reason "$reason" '{decision:"deny",reason:$reason}'
20
+ else
21
+ printf '%s\n' '{"decision":"deny","reason":"Blocked by safety-net: the required JSON runtime is unavailable, so this command could not be classified safely."}'
22
+ fi
23
+ exit 0
24
+ }
25
+
26
+ input="$(cat 2>/dev/null || true)"
27
+ [ -z "$input" ] && allow
28
+ command -v jq >/dev/null 2>&1 || deny "Blocked by safety-net: the required JSON runtime is unavailable, so this command could not be classified safely."
29
+
30
+ tool_name="$(printf '%s' "$input" | jq -r '.toolCall.name // empty' 2>/dev/null || true)"
31
+ [ "$tool_name" != "run_command" ] && allow
32
+ command_str="$(printf '%s' "$input" | jq -r '.toolCall.args.CommandLine // empty' 2>/dev/null || true)"
33
+ [ -z "$command_str" ] && allow
34
+
35
+ hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
36
+ canonical_hook="$hook_dir/parity-safety-net.sh"
37
+ [ -r "$canonical_hook" ] || deny "Blocked by safety-net: the canonical safety policy is unavailable, so this command could not be classified safely."
38
+
39
+ canonical_input="$(jq -cn --arg command "$command_str" '{tool_name:"Bash",tool_input:{command:$command}}')"
40
+ canonical_output=""
41
+ canonical_status=0
42
+ if canonical_output="$(printf '%s' "$canonical_input" | /bin/bash "$canonical_hook" 2>&1)"; then
43
+ canonical_status=0
44
+ else
45
+ canonical_status=$?
46
+ fi
47
+
48
+ [ "$canonical_status" -eq 0 ] && allow
49
+ [ -n "$canonical_output" ] || canonical_output="Blocked by safety-net: the canonical safety policy failed closed."
50
+ deny "$canonical_output"
@@ -32,57 +32,6 @@ if [ -z "$command_str" ]; then
32
32
  exit 0
33
33
  fi
34
34
 
35
- command_for_guards="$command_str"
36
- if command -v python3 >/dev/null 2>&1; then
37
- command_for_guards="$(SAFETY_NET_COMMAND="$command_str" python3 - <<'PY'
38
- import os
39
- import re
40
-
41
- command = os.environ.get("SAFETY_NET_COMMAND", "")
42
-
43
-
44
- def strip_heredocs(text: str) -> str:
45
- lines = text.splitlines()
46
- output = []
47
- pending = []
48
- # Quoted strings are matched (and thereby skipped over) as plain,
49
- # non-capturing alternatives BEFORE the heredoc-marker alternative gets a
50
- # chance to run, so a "<<MARKER"-looking sequence inside a string literal
51
- # (e.g. `echo "hello <<MARKER"`) is never mistaken for a real heredoc
52
- # start. Only the heredoc-marker alternative captures a group.
53
- marker_pattern = re.compile(
54
- r'"(?:\\.|[^"])*"|\'[^\']*\''
55
- r"|<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))"
56
- )
57
- index = 0
58
- while index < len(lines):
59
- line = lines[index]
60
- output.append(line)
61
- pending.extend(
62
- next(group for group in match.groups() if group)
63
- for match in marker_pattern.finditer(line)
64
- if any(match.groups())
65
- )
66
- index += 1
67
- # Consume every pending heredoc body in order (chained same-line
68
- # heredocs, e.g. `cat <<A <<B`, push more than one marker at once).
69
- # Do NOT stop after the first terminator — dropping the `break` lets
70
- # this loop keep dropping body lines until each pending marker is
71
- # matched, instead of leaking the second body back into `output`
72
- # where the destructive-pattern guards would see it.
73
- while pending and index < len(lines):
74
- if lines[index].strip() == pending[0]:
75
- output.append(lines[index])
76
- pending.pop(0)
77
- index += 1
78
- return "\n".join(output)
79
-
80
-
81
- print(strip_heredocs(command), end="")
82
- PY
83
- )"
84
- fi
85
-
86
35
  # block() prints the reason to stderr (surfaced to the model) and exits 2 so the
87
36
  # Bash tool call is denied. $1 = human-readable reason for the block.
88
37
  block() {
@@ -96,6 +45,39 @@ EOF
96
45
  exit 2
97
46
  }
98
47
 
48
+ # Heredoc payloads are data only for a deliberately narrow set of GitHub CLI
49
+ # write commands. A companion parser proves that shape before removing payload
50
+ # text from the destructive-command scans below. Unknown executable heredocs
51
+ # remain visible to every built-in and custom rule. Ambiguous or malformed
52
+ # heredocs fail closed instead of guessing which text the shell would execute.
53
+ command_for_guards="$command_str"
54
+ case "$command_str" in
55
+ *'<<'*)
56
+ hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
57
+ heredoc_parser="$hook_dir/parity-safety-net-heredoc.py"
58
+ if ! command -v python3 >/dev/null 2>&1 || [ ! -r "$heredoc_parser" ]; then
59
+ block "cannot safely classify heredoc command because its parser runtime is unavailable"
60
+ fi
61
+ if ! printf '%s\n' "$command_str" | /bin/bash -n >/dev/null 2>&1; then
62
+ block "malformed heredoc command failed shell syntax validation"
63
+ fi
64
+
65
+ parser_status=0
66
+ if parser_output="$(printf '%s' "$command_str" | python3 "$heredoc_parser" 2>/dev/null)"; then
67
+ parser_status=0
68
+ else
69
+ parser_status=$?
70
+ fi
71
+
72
+ case "$parser_status" in
73
+ 0) command_for_guards="$parser_output" ;;
74
+ 10) command_for_guards="$command_str" ;;
75
+ 20) block "malformed or ambiguous heredoc command cannot be safely classified" ;;
76
+ *) block "heredoc parser failed; command was denied fail-closed" ;;
77
+ esac
78
+ ;;
79
+ esac
80
+
99
81
  # 1. Recursive forced delete (`rm -rf`) of a filesystem root, home, or top-level
100
82
  # wildcard. Two gates ANDed: the command must invoke `rm` with BOTH a
101
83
  # recursive and a force flag, AND name a catastrophic target. Splitting the
@@ -15,7 +15,7 @@
15
15
  - **After verifying empirically, codify it as a regression test** via the `codify-verification` skill — Playwright for UI, integration test for API/DB/auth, benchmark for performance. Codification is mandatory for every verification type except PR/Documentation/Deploy and Investigate-Only spikes. For **frontend work**, codification is dual-runner: a Playwright spec in the project's Playwright test runner AND a Maestro flow in the Maestro test runner whenever the project supports Maestro (`.maestro/` directory, `maestro:test` script, or Maestro CI workflow) — both encoding the same verified journey, neither a substitute for the other.
16
16
  - **The codified proof re-runs in CI.** Codified runtime verification lives where the project's e2e/Playwright tests live (`tests/e2e/**`) and runs as a required CI check for types with verification enforced — so the proof re-runs on every PR, not once by hand.
17
17
  - **Commit the evidence.** Write a durable artifact to `evidence/<ticket>/` — the acceptance criteria with per-criterion pass/fail + note, the observed state, screenshots/recording, and a `verdict.json`. The transient `.lisa/verification-status.json` is the session gate; `evidence/<ticket>/` is the committed proof.
18
- - **Evidence markers are typed artifacts, not assertion labels.** A Validation Journey marker is `[EVIDENCE: <artifact-type>: <name>]` where the type (screenshot, recording, http-transcript, cli-output, log-snippet, db-query-output, perf-trace, test-run-log, deploy-log, state-dump) says HOW the proof is captured and the name says WHAT it proves. `[EVIDENCE: works-gracefully]` is a claim, not evidence — write `[EVIDENCE: screenshot: load-failure-error-state]`. Completion requires a captured artifact **of the declared type** per marker.
18
+ - **Evidence markers are typed artifacts, not assertion labels.** A Validation Journey marker is `[EVIDENCE: <artifact-type>: <name>]` where the type (screenshot, recording, http-transcript, cli-output, log-snippet, db-query-output, perf-trace, test-run-log, deploy-log, state-dump) says HOW the proof is captured and the name says WHAT it proves. `[EVIDENCE: works-gracefully]` is a claim, not evidence — write `[EVIDENCE: screenshot: load-failure-error-state]`. Completion requires a captured artifact **of the declared type** per marker. To cite another ticket's artifact without claiming it, use `[EVIDENCE-REF: <tracker-ref>: <artifact-type>: <name>]`; validators ignore `EVIDENCE-REF` when building the current issue's manifest, and agents must not quote sibling `[EVIDENCE: ...]` markers directly.
19
19
  - **Per-change verification is mandatory.** Every `feat`/`fix` adds or extends a verification (e2e) spec mapped to its acceptance criteria. The only exception is a genuinely non-behavioral change explicitly marked with the **logged** `verification-exempt` label — never a silent skip.
20
20
  - **Drive the playthrough with `/lisa:product-walkthrough`** (it walks the live product through a real browser); each project type plugs in its own drive mechanism (e.g. a Phaser game is driven through Playwright + an in-game verification test bridge that seeds RNG, reads state, injects input, and steps frames).
21
21
  - **Every PR must include reviewer replay steps** — the exact human steps to use the software and confirm the change works. Not test commands. If a reviewer can't reproduce from the PR description alone, the PR is incomplete.
@@ -122,6 +122,14 @@ The declaration is not a separate field — it is the set of `[EVIDENCE: <artifa
122
122
  - `<artifact-type>` — HOW the proof is captured, from the fixed taxonomy below.
123
123
  - `<kebab-case-name>` — WHAT it proves, unique within the ticket.
124
124
 
125
+ To reference a sibling or parent artifact without adding it to the current work unit's manifest, use the non-binding cross-reference form:
126
+
127
+ ```text
128
+ [EVIDENCE-REF: <tracker-ref>: <artifact-type>: <kebab-case-name>]
129
+ ```
130
+
131
+ `<tracker-ref>` is the owning ticket or issue (`#123`, `PROJ-123`, `ENG-123`, `owner/repo#123`, or a tracker URL). Validators and journey runners MUST ignore `EVIDENCE-REF` markers when building the current work unit's evidence manifest. Never quote a sibling's `[EVIDENCE: ...]` marker directly in prose; that syntax always belongs to the current work unit.
132
+
125
133
  Example transformation (the failure mode this grammar exists to prevent):
126
134
 
127
135
  | Assertion label (invalid) | Typed artifact (valid) |
@@ -83,9 +83,10 @@ Compose the journey with typed `[EVIDENCE: <artifact-type>: <name>]` markers at
83
83
  2. **Concrete, runnable steps** — `Run \`curl -s localhost:3000/health | jq .status\`` not "Check the endpoint".
84
84
  3. **Include environment setup** — Database connection, running services, env vars.
85
85
  4. **Markers are typed artifacts, not assertion labels** — `[EVIDENCE: <artifact-type>: <kebab-name>]`. `[EVIDENCE: load-failure-handled-gracefully]` names a claim with nothing to capture; write `[EVIDENCE: screenshot: load-failure-error-state]` or `[EVIDENCE: perf-trace: pipeline-load-tti]`. Names are kebab-case and unique within the ticket.
86
- 5. **Assertions are measurable** — `Returns 200 with {status: ok}` not "API works correctly".
87
- 6. **Cover happy path AND error path** — At minimum, one success and one failure marker.
88
- 7. **On a leaf work unit, the markers are binding** — For a Bug / Task / Sub-task / Improvement, every typed `[EVIDENCE: <artifact-type>: <name>]` here is the issue's evidence manifest: validation gate S14 requires at least one, and the issue cannot be closed until each named artifact is captured **in its declared type** and attached (see the "Per-Work-Unit Evidence Contract" in the `verification` rule). Name only evidence you intend to capture — and name all of it.
86
+ 5. **Cross-ticket references are non-binding** — If the journey needs to mention another issue's artifact, use `[EVIDENCE-REF: <tracker-ref>: <artifact-type>: <kebab-name>]` (for example, `[EVIDENCE-REF: #123: cli-output: upstream-contract-pass]`). Do not paste a sibling issue's `[EVIDENCE: ...]` marker into this issue; S14 treats `[EVIDENCE: ...]` as this issue's own manifest and ignores `EVIDENCE-REF`.
87
+ 6. **Assertions are measurable** — `Returns 200 with {status: ok}` not "API works correctly".
88
+ 7. **Cover happy path AND error path** — At minimum, one success and one failure marker.
89
+ 8. **On a leaf work unit, the markers are binding** — For a Bug / Task / Sub-task / Improvement, every typed `[EVIDENCE: <artifact-type>: <name>]` here is the issue's evidence manifest: validation gate S14 requires at least one, and the issue cannot be closed until each named artifact is captured **in its declared type** and attached (see the "Per-Work-Unit Evidence Contract" in the `verification` rule). Name only evidence you intend to capture — and name all of it; `EVIDENCE-REF` never satisfies or extends the manifest.
89
90
 
90
91
  ### Step 6: Present to User for Approval
91
92
 
@@ -205,9 +205,11 @@ Each marker must satisfy ALL of:
205
205
  - `<artifact-type>` is one of the fixed taxonomy: `screenshot`, `recording`, `http-transcript`, `cli-output`, `log-snippet`, `db-query-output`, `perf-trace`, `test-run-log`, `deploy-log`, `state-dump`. (The legacy `[SCREENSHOT: name]` form is accepted as `screenshot`.)
206
206
  - `<name>` is kebab-case and unique within the issue.
207
207
 
208
+ Non-binding cross-references use `[EVIDENCE-REF: <tracker-ref>: <artifact-type>: <name>]`, where `<tracker-ref>` is the owning issue (`#123`, `owner/repo#123`, or a GitHub issue URL). S14 MUST ignore `EVIDENCE-REF` markers when deriving this issue's manifest, and a valid `EVIDENCE-REF` does not satisfy the "at least one marker" requirement. Agents must use `EVIDENCE-REF` instead of quoting a sibling issue's `[EVIDENCE: ...]` marker in prose.
209
+
208
210
  **A marker names an artifact, not an assertion.** An untyped marker (`[EVIDENCE: load-failure-handled-gracefully]`) is an assertion label with nothing to capture and must FAIL, with a remediation that shows the typed transformation (e.g. → `[EVIDENCE: screenshot: load-failure-error-state]`, `[EVIDENCE: perf-trace: pipeline-load-tti]`).
209
211
 
210
- FAIL when the Validation Journey is present but declares zero markers, when any marker is untyped or uses a type outside the taxonomy, or when any name is empty, duplicated, or not kebab-case. A behavior-changing work unit SHOULD declare both a success marker and an error/edge marker; a journey with only one marker passes but the remediation should recommend adding the error/edge case.
212
+ FAIL when the Validation Journey is present but declares zero binding `[EVIDENCE: ...]` markers, when any binding marker is untyped or uses a type outside the taxonomy, or when any binding name is empty, duplicated, or not kebab-case. A behavior-changing work unit SHOULD declare both a success marker and an error/edge marker; a journey with only one binding marker passes but the remediation should recommend adding the error/edge case.
211
213
 
212
214
  This gate depends on S11. It is `N/A` for containers — an **Epic**, or any item with open child work (coordination containers, not work units) — and for leaf units with `runtime_behavior_change = false` (doc-only / config-only / type-only). If S11 fails because the Validation Journey is absent, S14 also FAILs (there is no manifest to bind) with remediation pointing back to `lisa-github-add-journey`.
213
215
 
@@ -93,9 +93,10 @@ h3. Assertions
93
93
  2. **Concrete, runnable steps** — "Run `curl -s localhost:3000/health | jq .status`" not "Check the endpoint"
94
94
  3. **Include environment setup** — Database connection, running services, env vars
95
95
  4. **Markers are typed artifacts, not assertion labels** — `[EVIDENCE: <artifact-type>: <kebab-name>]`. `[EVIDENCE: load-failure-handled-gracefully]` names a claim with nothing to capture; write `[EVIDENCE: screenshot: load-failure-error-state]` or `[EVIDENCE: perf-trace: pipeline-load-tti]`. Names are kebab-case and unique within the ticket.
96
- 5. **Assertions are measurable** — "Returns 200 with `{status: ok}`" not "API works correctly"
97
- 6. **Cover happy path and error path** — At minimum, one success and one failure evidence marker
98
- 7. **On a leaf work unit, the markers are binding** — For a Bug / Task / Sub-task / Improvement, every typed `[EVIDENCE: <artifact-type>: <name>]` here is the ticket's evidence manifest: validation gate S14 requires at least one, and the ticket cannot be closed until each named artifact is captured **in its declared type** and attached (see the "Per-Work-Unit Evidence Contract" in the `verification` rule). Name only evidence you intend to capture — and name all of it.
96
+ 5. **Cross-ticket references are non-binding** — If the journey needs to mention another ticket's artifact, use `[EVIDENCE-REF: <tracker-ref>: <artifact-type>: <kebab-name>]` (for example, `[EVIDENCE-REF: PROJ-123: cli-output: upstream-contract-pass]`). Do not paste a sibling ticket's `[EVIDENCE: ...]` marker into this ticket; S14 treats `[EVIDENCE: ...]` as this ticket's own manifest and ignores `EVIDENCE-REF`.
97
+ 6. **Assertions are measurable** — "Returns 200 with `{status: ok}`" not "API works correctly"
98
+ 7. **Cover happy path and error path** — At minimum, one success and one failure evidence marker
99
+ 8. **On a leaf work unit, the markers are binding** — For a Bug / Task / Sub-task / Improvement, every typed `[EVIDENCE: <artifact-type>: <name>]` here is the ticket's evidence manifest: validation gate S14 requires at least one, and the ticket cannot be closed until each named artifact is captured **in its declared type** and attached (see the "Per-Work-Unit Evidence Contract" in the `verification` rule). Name only evidence you intend to capture — and name all of it; `EVIDENCE-REF` never satisfies or extends the manifest.
99
100
 
100
101
  ### Step 6: Present to User for Approval
101
102
 
@@ -206,9 +206,11 @@ Each marker must satisfy ALL of:
206
206
  - `<artifact-type>` is one of the fixed taxonomy: `screenshot`, `recording`, `http-transcript`, `cli-output`, `log-snippet`, `db-query-output`, `perf-trace`, `test-run-log`, `deploy-log`, `state-dump`. (The legacy `[SCREENSHOT: name]` form is accepted as `screenshot`.)
207
207
  - `<name>` is kebab-case and unique within the ticket.
208
208
 
209
+ Non-binding cross-references use `[EVIDENCE-REF: <tracker-ref>: <artifact-type>: <name>]`, where `<tracker-ref>` is the owning ticket or issue (`PROJ-123`, `#123`, `owner/repo#123`, or a tracker URL). S14 MUST ignore `EVIDENCE-REF` markers when deriving this ticket's manifest, and a valid `EVIDENCE-REF` does not satisfy the "at least one marker" requirement. Agents must use `EVIDENCE-REF` instead of quoting a sibling ticket's `[EVIDENCE: ...]` marker in prose.
210
+
209
211
  **A marker names an artifact, not an assertion.** An untyped marker (`[EVIDENCE: load-failure-handled-gracefully]`) is an assertion label with nothing to capture and must FAIL, with a remediation that shows the typed transformation (e.g. → `[EVIDENCE: screenshot: load-failure-error-state]`, `[EVIDENCE: perf-trace: pipeline-load-tti]`).
210
212
 
211
- FAIL when the Validation Journey is present but declares zero markers, when any marker is untyped or uses a type outside the taxonomy, or when any name is empty, duplicated, or not kebab-case. A behavior-changing work unit SHOULD declare both a success marker and an error/edge marker; a journey with only one marker passes but the remediation should recommend adding the error/edge case.
213
+ FAIL when the Validation Journey is present but declares zero binding `[EVIDENCE: ...]` markers, when any binding marker is untyped or uses a type outside the taxonomy, or when any binding name is empty, duplicated, or not kebab-case. A behavior-changing work unit SHOULD declare both a success marker and an error/edge marker; a journey with only one binding marker passes but the remediation should recommend adding the error/edge case.
212
214
 
213
215
  This gate depends on S11. It is `N/A` for containers — an **Epic**, or any item with open child work (coordination containers, not work units) — and for leaf units with `runtime_behavior_change = false` (doc-only / config-only / type-only). If S11 fails because the Validation Journey is absent, S14 also FAILs (there is no manifest to bind) with remediation pointing back to `lisa-jira-add-journey`.
214
216