@codyswann/lisa 2.250.0 → 2.251.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 (72) hide show
  1. package/dist/opencode/plugin-templates/parity-safety-net.sh +16 -4
  2. package/eslint-plugin-code-organization/rules/enforce-statement-order.js +1 -1
  3. package/package.json +1 -1
  4. package/plugins/lisa/.claude-plugin/plugin.json +5 -1
  5. package/plugins/lisa/.codex-plugin/hooks.json +4 -0
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/hooks/block-shell-json-parsing.agy.sh +52 -0
  8. package/plugins/lisa/hooks/block-shell-json-parsing.sh +220 -0
  9. package/plugins/lisa/hooks/parity-safety-net.sh +16 -4
  10. package/plugins/lisa-agy/hooks/block-shell-json-parsing.agy.sh +52 -0
  11. package/plugins/lisa-agy/hooks/block-shell-json-parsing.sh +220 -0
  12. package/plugins/lisa-agy/hooks/parity-safety-net.sh +16 -4
  13. package/plugins/lisa-agy/hooks.json +13 -0
  14. package/plugins/lisa-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
  21. package/plugins/lisa-copilot/hooks/block-shell-json-parsing.sh +220 -0
  22. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +16 -4
  23. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cursor/hooks/block-shell-json-parsing.sh +220 -0
  25. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  26. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +16 -4
  27. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-agy/plugin.json +1 -1
  30. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  50. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-agy/plugin.json +1 -1
  55. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  60. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  65. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/src/base/.claude-plugin/plugin.json +4 -0
  68. package/plugins/src/base/hooks/block-shell-json-parsing.agy.sh +52 -0
  69. package/plugins/src/base/hooks/block-shell-json-parsing.sh +220 -0
  70. package/plugins/src/base/hooks/parity-safety-net.sh +16 -4
  71. package/scripts/generate-agy-plugin-artifacts.mjs +8 -0
  72. package/scripts/lib/per-agent-hook-filter.mjs +7 -0
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for Bash: blocks structural JSON parsing with text tools.
3
+ # Text tools (grep/sed/cut/awk) break on valid JSON — multiline values, escaped
4
+ # quotes, reordered keys, nested objects — producing silently wrong output
5
+ # instead of errors. jq is the required tool for structural JSON reads and
6
+ # writes in shell. Promoted from PROJECT_RULES.md prose to an executable
7
+ # control by the learnings gardener (issue #1787).
8
+ #
9
+ # Precision-first: this hook fires only on high-confidence STRUCTURAL parsing
10
+ # of a *.json input — never on plain text search. Blocked signatures:
11
+ # 1. `sed -i` (or an s/…/…/ program) applied to a .json file or stream;
12
+ # 2. `cut -d` with a structural delimiter (quote/colon/comma) on .json input;
13
+ # 3. `awk` field extraction (-F or a $N program) on .json input;
14
+ # 4. `grep -o` on .json input (value extraction, not search).
15
+ # A "json input" is a *.json argument, a `< file.json` redirection, or a
16
+ # pipeline stream originating from a .json file (e.g. `cat x.json | …`).
17
+ #
18
+ # Exemptions (allowed):
19
+ # - any command that invokes jq anywhere (already compliant or mixed-legit);
20
+ # - search-only usage: plain grep / grep -l / rg with no extraction signature;
21
+ # - *.jsonl targets (line-delimited streams are legitimately line-tooled);
22
+ # - heredoc payload text (stripped before classification).
23
+ set -euo pipefail
24
+
25
+ input="$(cat)"
26
+
27
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
28
+ if [ "$tool_name" != "Bash" ]; then
29
+ exit 0
30
+ fi
31
+
32
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
33
+ if [ -z "$command_str" ]; then
34
+ exit 0
35
+ fi
36
+
37
+ # Fast path: no ".json" reference at all means nothing to classify.
38
+ case "$command_str" in
39
+ *.json*) ;;
40
+ *) exit 0 ;;
41
+ esac
42
+
43
+ command -v python3 >/dev/null 2>&1 || exit 0
44
+
45
+ if ! BLOCK_SHELL_JSON_COMMAND="$command_str" python3 - <<'PY'
46
+ import os
47
+ import re
48
+ import shlex
49
+ import sys
50
+
51
+ command = os.environ.get("BLOCK_SHELL_JSON_COMMAND", "")
52
+
53
+ TEXT_TOOLS = {"grep", "egrep", "fgrep", "sed", "gsed", "awk", "gawk", "cut"}
54
+ WRAPPERS = {"command", "env", "sudo", "time", "nohup"}
55
+ ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
56
+
57
+
58
+ def strip_heredoc_bodies(text: str) -> str:
59
+ """Remove heredoc payload lines so prose .json mentions never classify."""
60
+ lines = text.splitlines()
61
+ output = []
62
+ marker_pattern = re.compile(
63
+ r"<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|\\?([A-Za-z_][A-Za-z0-9_]*))"
64
+ )
65
+ index = 0
66
+ while index < len(lines):
67
+ line = lines[index]
68
+ output.append(line)
69
+ markers = [
70
+ next(group for group in match.groups() if group)
71
+ for match in marker_pattern.finditer(line)
72
+ ]
73
+ index += 1
74
+ for marker in markers:
75
+ while index < len(lines):
76
+ if lines[index].strip() == marker:
77
+ index += 1
78
+ break
79
+ index += 1
80
+ return "\n".join(output)
81
+
82
+
83
+ def tokenize(text: str):
84
+ lex = shlex.shlex(text, posix=True, punctuation_chars="|&;<>")
85
+ lex.whitespace_split = True
86
+ try:
87
+ return list(lex)
88
+ except ValueError:
89
+ return None
90
+
91
+
92
+ def is_json_path(token: str) -> bool:
93
+ return token.lower().rstrip(")};").endswith(".json")
94
+
95
+
96
+ def split_pipelines(tokens):
97
+ """Yield lists of pipe-connected segments; each segment is a token list."""
98
+ pipelines, pipeline, segment = [], [], []
99
+ for token in tokens:
100
+ if token in ("|", "|&"):
101
+ pipeline.append(segment)
102
+ segment = []
103
+ elif token in (";", "&&", "||", "&", "\n"):
104
+ pipeline.append(segment)
105
+ pipelines.append(pipeline)
106
+ pipeline, segment = [], []
107
+ else:
108
+ segment.append(token)
109
+ pipeline.append(segment)
110
+ pipelines.append(pipeline)
111
+ return pipelines
112
+
113
+
114
+ def segment_parts(segment):
115
+ """Return (tool, args, reads_json) for one pipeline segment."""
116
+ reads_json = False
117
+ tool = None
118
+ args = []
119
+ index = 0
120
+ while index < len(segment):
121
+ token = segment[index]
122
+ if token == "<":
123
+ if index + 1 < len(segment) and is_json_path(segment[index + 1]):
124
+ reads_json = True
125
+ index += 2
126
+ continue
127
+ if token in (">", ">>", "<<", "<<<"):
128
+ index += 2
129
+ continue
130
+ if tool is None and (ASSIGNMENT.match(token) or token in WRAPPERS):
131
+ index += 1
132
+ continue
133
+ if tool is None:
134
+ tool = token.rsplit("/", 1)[-1]
135
+ else:
136
+ args.append(token)
137
+ if is_json_path(token):
138
+ reads_json = True
139
+ index += 1
140
+ return tool, args, reads_json
141
+
142
+
143
+ def flags_of(args):
144
+ return [a for a in args if a.startswith("-")]
145
+
146
+
147
+ def is_violation(tool, args, has_json_input):
148
+ if not has_json_input:
149
+ return False
150
+ flags = flags_of(args)
151
+ if tool in ("grep", "egrep", "fgrep"):
152
+ if any(f in ("-l", "-L", "--files-with-matches", "--files-without-match") for f in flags):
153
+ return False
154
+ return any(f == "-o" or f == "--only-matching" or ("o" in f[1:] and not f.startswith("--")) for f in flags)
155
+ if tool in ("sed", "gsed"):
156
+ if any(f.startswith("-i") or f == "--in-place" for f in flags):
157
+ return True
158
+ return any(re.match(r"^-?\d*s[/|#]", a) or a.startswith("s/") or a.startswith("s|") for a in args)
159
+ if tool == "cut":
160
+ for i, arg in enumerate(args):
161
+ if arg == "-d" and i + 1 < len(args) and args[i + 1] in ('"', "'", ":", ","):
162
+ return True
163
+ if arg.startswith("-d") and len(arg) > 2 and arg[2:] in ('"', "'", ":", ","):
164
+ return True
165
+ return False
166
+ if tool in ("awk", "gawk"):
167
+ if any(f == "-F" or f.startswith("-F") for f in flags):
168
+ return True
169
+ return any(re.search(r"\$\d", a) for a in args if not a.startswith("-"))
170
+ return False
171
+
172
+
173
+ tokens = tokenize(strip_heredoc_bodies(command))
174
+ if tokens is None:
175
+ sys.exit(0)
176
+
177
+ pipelines = split_pipelines(tokens)
178
+
179
+ # Whole-command jq exemption: any jq stage means the author is already using
180
+ # the right tool; mixed pipelines (jq | grep) are legitimate.
181
+ for pipeline in pipelines:
182
+ for segment in pipeline:
183
+ tool, _args, _reads = segment_parts(segment)
184
+ if tool == "jq":
185
+ sys.exit(0)
186
+
187
+ for pipeline in pipelines:
188
+ json_stream = False
189
+ for segment in pipeline:
190
+ tool, args, reads_json = segment_parts(segment)
191
+ if tool is None:
192
+ continue
193
+ has_json_input = reads_json or json_stream
194
+ if tool in TEXT_TOOLS and is_violation(tool, args, has_json_input):
195
+ sys.exit(1)
196
+ # Streams originating from a .json file stay json-classified through
197
+ # pass-through text stages (cat x.json | grep … | cut …).
198
+ if reads_json or (json_stream and tool in TEXT_TOOLS | {"cat", "head", "tail", "sort", "uniq", "tr", "xargs"}):
199
+ json_stream = True
200
+ else:
201
+ json_stream = False
202
+
203
+ sys.exit(0)
204
+ PY
205
+ then
206
+ cat >&2 <<'EOF'
207
+ BLOCKED: this command parses JSON with text tools (grep/sed/cut/awk). Text
208
+ tools break on valid JSON — multiline values, escaped quotes, reordered keys,
209
+ nested objects — producing silently wrong output instead of errors. Use jq for
210
+ all structural JSON reads and writes in shell. Typical fixes:
211
+ read a field: jq -r '.field' file.json
212
+ filter items: jq '.items[] | select(.name=="x")' file.json
213
+ edit in place: jq '.key="value"' file.json > tmp && mv tmp file.json
214
+ If you are only SEARCHING text inside a JSON file (not extracting values), use
215
+ `rg <pattern> file.json` with no extraction pipe and this hook will not fire.
216
+ EOF
217
+ exit 2
218
+ fi
219
+
220
+ exit 0
@@ -50,16 +50,28 @@ EOF
50
50
  # text from the destructive-command scans below. Unknown executable heredocs
51
51
  # remain visible to every built-in and custom rule. Ambiguous or malformed
52
52
  # heredocs fail closed instead of guessing which text the shell would execute.
53
+ #
54
+ # block_heredoc() teaches the remediation the moment the wall is hit: heredoc
55
+ # denials overwhelmingly come from `git commit -m "$(cat <<EOF …)"` attempts,
56
+ # and a bare denial strands the agent with no path forward (gardener #1789).
57
+ block_heredoc() {
58
+ block "$1
59
+ Heredoc commit invocations are blocked (the payload is executable shell).
60
+ Fix: write the commit message to a file and run \`git commit -F <file>\`.
61
+ Every commit must also carry a Co-authored-by trailer for a supported agent
62
+ (Claude/Codex/OpenCode) — the commit-msg hook enforces this."
63
+ }
64
+
53
65
  command_for_guards="$command_str"
54
66
  case "$command_str" in
55
67
  *'<<'*)
56
68
  hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
57
69
  heredoc_parser="$hook_dir/parity-safety-net-heredoc.py"
58
70
  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"
71
+ block_heredoc "cannot safely classify heredoc command because its parser runtime is unavailable"
60
72
  fi
61
73
  if ! printf '%s\n' "$command_str" | /bin/bash -n >/dev/null 2>&1; then
62
- block "malformed heredoc command failed shell syntax validation"
74
+ block_heredoc "malformed heredoc command failed shell syntax validation"
63
75
  fi
64
76
 
65
77
  parser_status=0
@@ -72,8 +84,8 @@ case "$command_str" in
72
84
  case "$parser_status" in
73
85
  0) command_for_guards="$parser_output" ;;
74
86
  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" ;;
87
+ 20) block_heredoc "malformed or ambiguous heredoc command cannot be safely classified" ;;
88
+ *) block_heredoc "heredoc parser failed; command was denied fail-closed" ;;
77
89
  esac
78
90
  ;;
79
91
  esac
@@ -24,5 +24,18 @@
24
24
  ]
25
25
  }
26
26
  ]
27
+ },
28
+ "lisa-block-shell-json-parsing": {
29
+ "PreToolUse": [
30
+ {
31
+ "matcher": "run_command",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "bash \"$HOME/.gemini/config/plugins/lisa/hooks/block-shell-json-parsing.agy.sh\""
36
+ }
37
+ ]
38
+ }
39
+ ]
27
40
  }
28
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "type": "command",
50
50
  "command": "${CLAUDE_PLUGIN_ROOT}/hooks/parity-safety-net.sh"
51
+ },
52
+ {
53
+ "type": "command",
54
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-shell-json-parsing.sh"
51
55
  }
52
56
  ]
53
57
  },
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for Bash: blocks structural JSON parsing with text tools.
3
+ # Text tools (grep/sed/cut/awk) break on valid JSON — multiline values, escaped
4
+ # quotes, reordered keys, nested objects — producing silently wrong output
5
+ # instead of errors. jq is the required tool for structural JSON reads and
6
+ # writes in shell. Promoted from PROJECT_RULES.md prose to an executable
7
+ # control by the learnings gardener (issue #1787).
8
+ #
9
+ # Precision-first: this hook fires only on high-confidence STRUCTURAL parsing
10
+ # of a *.json input — never on plain text search. Blocked signatures:
11
+ # 1. `sed -i` (or an s/…/…/ program) applied to a .json file or stream;
12
+ # 2. `cut -d` with a structural delimiter (quote/colon/comma) on .json input;
13
+ # 3. `awk` field extraction (-F or a $N program) on .json input;
14
+ # 4. `grep -o` on .json input (value extraction, not search).
15
+ # A "json input" is a *.json argument, a `< file.json` redirection, or a
16
+ # pipeline stream originating from a .json file (e.g. `cat x.json | …`).
17
+ #
18
+ # Exemptions (allowed):
19
+ # - any command that invokes jq anywhere (already compliant or mixed-legit);
20
+ # - search-only usage: plain grep / grep -l / rg with no extraction signature;
21
+ # - *.jsonl targets (line-delimited streams are legitimately line-tooled);
22
+ # - heredoc payload text (stripped before classification).
23
+ set -euo pipefail
24
+
25
+ input="$(cat)"
26
+
27
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
28
+ if [ "$tool_name" != "Bash" ]; then
29
+ exit 0
30
+ fi
31
+
32
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
33
+ if [ -z "$command_str" ]; then
34
+ exit 0
35
+ fi
36
+
37
+ # Fast path: no ".json" reference at all means nothing to classify.
38
+ case "$command_str" in
39
+ *.json*) ;;
40
+ *) exit 0 ;;
41
+ esac
42
+
43
+ command -v python3 >/dev/null 2>&1 || exit 0
44
+
45
+ if ! BLOCK_SHELL_JSON_COMMAND="$command_str" python3 - <<'PY'
46
+ import os
47
+ import re
48
+ import shlex
49
+ import sys
50
+
51
+ command = os.environ.get("BLOCK_SHELL_JSON_COMMAND", "")
52
+
53
+ TEXT_TOOLS = {"grep", "egrep", "fgrep", "sed", "gsed", "awk", "gawk", "cut"}
54
+ WRAPPERS = {"command", "env", "sudo", "time", "nohup"}
55
+ ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
56
+
57
+
58
+ def strip_heredoc_bodies(text: str) -> str:
59
+ """Remove heredoc payload lines so prose .json mentions never classify."""
60
+ lines = text.splitlines()
61
+ output = []
62
+ marker_pattern = re.compile(
63
+ r"<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|\\?([A-Za-z_][A-Za-z0-9_]*))"
64
+ )
65
+ index = 0
66
+ while index < len(lines):
67
+ line = lines[index]
68
+ output.append(line)
69
+ markers = [
70
+ next(group for group in match.groups() if group)
71
+ for match in marker_pattern.finditer(line)
72
+ ]
73
+ index += 1
74
+ for marker in markers:
75
+ while index < len(lines):
76
+ if lines[index].strip() == marker:
77
+ index += 1
78
+ break
79
+ index += 1
80
+ return "\n".join(output)
81
+
82
+
83
+ def tokenize(text: str):
84
+ lex = shlex.shlex(text, posix=True, punctuation_chars="|&;<>")
85
+ lex.whitespace_split = True
86
+ try:
87
+ return list(lex)
88
+ except ValueError:
89
+ return None
90
+
91
+
92
+ def is_json_path(token: str) -> bool:
93
+ return token.lower().rstrip(")};").endswith(".json")
94
+
95
+
96
+ def split_pipelines(tokens):
97
+ """Yield lists of pipe-connected segments; each segment is a token list."""
98
+ pipelines, pipeline, segment = [], [], []
99
+ for token in tokens:
100
+ if token in ("|", "|&"):
101
+ pipeline.append(segment)
102
+ segment = []
103
+ elif token in (";", "&&", "||", "&", "\n"):
104
+ pipeline.append(segment)
105
+ pipelines.append(pipeline)
106
+ pipeline, segment = [], []
107
+ else:
108
+ segment.append(token)
109
+ pipeline.append(segment)
110
+ pipelines.append(pipeline)
111
+ return pipelines
112
+
113
+
114
+ def segment_parts(segment):
115
+ """Return (tool, args, reads_json) for one pipeline segment."""
116
+ reads_json = False
117
+ tool = None
118
+ args = []
119
+ index = 0
120
+ while index < len(segment):
121
+ token = segment[index]
122
+ if token == "<":
123
+ if index + 1 < len(segment) and is_json_path(segment[index + 1]):
124
+ reads_json = True
125
+ index += 2
126
+ continue
127
+ if token in (">", ">>", "<<", "<<<"):
128
+ index += 2
129
+ continue
130
+ if tool is None and (ASSIGNMENT.match(token) or token in WRAPPERS):
131
+ index += 1
132
+ continue
133
+ if tool is None:
134
+ tool = token.rsplit("/", 1)[-1]
135
+ else:
136
+ args.append(token)
137
+ if is_json_path(token):
138
+ reads_json = True
139
+ index += 1
140
+ return tool, args, reads_json
141
+
142
+
143
+ def flags_of(args):
144
+ return [a for a in args if a.startswith("-")]
145
+
146
+
147
+ def is_violation(tool, args, has_json_input):
148
+ if not has_json_input:
149
+ return False
150
+ flags = flags_of(args)
151
+ if tool in ("grep", "egrep", "fgrep"):
152
+ if any(f in ("-l", "-L", "--files-with-matches", "--files-without-match") for f in flags):
153
+ return False
154
+ return any(f == "-o" or f == "--only-matching" or ("o" in f[1:] and not f.startswith("--")) for f in flags)
155
+ if tool in ("sed", "gsed"):
156
+ if any(f.startswith("-i") or f == "--in-place" for f in flags):
157
+ return True
158
+ return any(re.match(r"^-?\d*s[/|#]", a) or a.startswith("s/") or a.startswith("s|") for a in args)
159
+ if tool == "cut":
160
+ for i, arg in enumerate(args):
161
+ if arg == "-d" and i + 1 < len(args) and args[i + 1] in ('"', "'", ":", ","):
162
+ return True
163
+ if arg.startswith("-d") and len(arg) > 2 and arg[2:] in ('"', "'", ":", ","):
164
+ return True
165
+ return False
166
+ if tool in ("awk", "gawk"):
167
+ if any(f == "-F" or f.startswith("-F") for f in flags):
168
+ return True
169
+ return any(re.search(r"\$\d", a) for a in args if not a.startswith("-"))
170
+ return False
171
+
172
+
173
+ tokens = tokenize(strip_heredoc_bodies(command))
174
+ if tokens is None:
175
+ sys.exit(0)
176
+
177
+ pipelines = split_pipelines(tokens)
178
+
179
+ # Whole-command jq exemption: any jq stage means the author is already using
180
+ # the right tool; mixed pipelines (jq | grep) are legitimate.
181
+ for pipeline in pipelines:
182
+ for segment in pipeline:
183
+ tool, _args, _reads = segment_parts(segment)
184
+ if tool == "jq":
185
+ sys.exit(0)
186
+
187
+ for pipeline in pipelines:
188
+ json_stream = False
189
+ for segment in pipeline:
190
+ tool, args, reads_json = segment_parts(segment)
191
+ if tool is None:
192
+ continue
193
+ has_json_input = reads_json or json_stream
194
+ if tool in TEXT_TOOLS and is_violation(tool, args, has_json_input):
195
+ sys.exit(1)
196
+ # Streams originating from a .json file stay json-classified through
197
+ # pass-through text stages (cat x.json | grep … | cut …).
198
+ if reads_json or (json_stream and tool in TEXT_TOOLS | {"cat", "head", "tail", "sort", "uniq", "tr", "xargs"}):
199
+ json_stream = True
200
+ else:
201
+ json_stream = False
202
+
203
+ sys.exit(0)
204
+ PY
205
+ then
206
+ cat >&2 <<'EOF'
207
+ BLOCKED: this command parses JSON with text tools (grep/sed/cut/awk). Text
208
+ tools break on valid JSON — multiline values, escaped quotes, reordered keys,
209
+ nested objects — producing silently wrong output instead of errors. Use jq for
210
+ all structural JSON reads and writes in shell. Typical fixes:
211
+ read a field: jq -r '.field' file.json
212
+ filter items: jq '.items[] | select(.name=="x")' file.json
213
+ edit in place: jq '.key="value"' file.json > tmp && mv tmp file.json
214
+ If you are only SEARCHING text inside a JSON file (not extracting values), use
215
+ `rg <pattern> file.json` with no extraction pipe and this hook will not fire.
216
+ EOF
217
+ exit 2
218
+ fi
219
+
220
+ exit 0
@@ -50,16 +50,28 @@ EOF
50
50
  # text from the destructive-command scans below. Unknown executable heredocs
51
51
  # remain visible to every built-in and custom rule. Ambiguous or malformed
52
52
  # heredocs fail closed instead of guessing which text the shell would execute.
53
+ #
54
+ # block_heredoc() teaches the remediation the moment the wall is hit: heredoc
55
+ # denials overwhelmingly come from `git commit -m "$(cat <<EOF …)"` attempts,
56
+ # and a bare denial strands the agent with no path forward (gardener #1789).
57
+ block_heredoc() {
58
+ block "$1
59
+ Heredoc commit invocations are blocked (the payload is executable shell).
60
+ Fix: write the commit message to a file and run \`git commit -F <file>\`.
61
+ Every commit must also carry a Co-authored-by trailer for a supported agent
62
+ (Claude/Codex/OpenCode) — the commit-msg hook enforces this."
63
+ }
64
+
53
65
  command_for_guards="$command_str"
54
66
  case "$command_str" in
55
67
  *'<<'*)
56
68
  hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
57
69
  heredoc_parser="$hook_dir/parity-safety-net-heredoc.py"
58
70
  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"
71
+ block_heredoc "cannot safely classify heredoc command because its parser runtime is unavailable"
60
72
  fi
61
73
  if ! printf '%s\n' "$command_str" | /bin/bash -n >/dev/null 2>&1; then
62
- block "malformed heredoc command failed shell syntax validation"
74
+ block_heredoc "malformed heredoc command failed shell syntax validation"
63
75
  fi
64
76
 
65
77
  parser_status=0
@@ -72,8 +84,8 @@ case "$command_str" in
72
84
  case "$parser_status" in
73
85
  0) command_for_guards="$parser_output" ;;
74
86
  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" ;;
87
+ 20) block_heredoc "malformed or ambiguous heredoc command cannot be safely classified" ;;
88
+ *) block_heredoc "heredoc parser failed; command was denied fail-closed" ;;
77
89
  esac
78
90
  ;;
79
91
  esac
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.250.0",
3
+ "version": "2.251.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"