@codyswann/lisa 2.324.0 → 2.324.1
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/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +71 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +102 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +220 -0
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +562 -0
- package/all/merge/.claude/settings.json +19 -4
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/standards/git-state.d.ts.map +1 -1
- package/dist/standards/git-state.js +10 -1
- package/dist/standards/git-state.js.map +1 -1
- 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/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa/rules/reference/tracked-work.md +1 -1
- 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/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa-copilot/rules/reference/tracked-work.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +1 -1
- package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
- 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/rules/eager/tracked-work.md +1 -1
- package/plugins/src/base/rules/reference/tracked-work.md +1 -1
- package/scripts/build-plugins.sh +32 -0
- package/scripts/generate-upstream-evidence-manifest.mjs +50 -11
- package/scripts/lisa-enforcement-fallback.sh +16 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Run Lisa's Bash enforcement guards when the plugin that normally provides them
|
|
4
|
+
# is not installed.
|
|
5
|
+
#
|
|
6
|
+
# Every PreToolUse guard — block-no-verify, parity-safety-net,
|
|
7
|
+
# block-shell-json-parsing — is declared in the Lisa plugin. A cloud session
|
|
8
|
+
# installs plugins at session start from the marketplace the repository
|
|
9
|
+
# declares, and when that does not happen the container runs with
|
|
10
|
+
# `installed_plugins.json` empty and no enforcement whatsoever.
|
|
11
|
+
#
|
|
12
|
+
# That is how a dispatched session committed with `--no-verify`: not by evading
|
|
13
|
+
# a guard, but in an environment where none existed. The guards failed open, and
|
|
14
|
+
# silently, which is the worst of the three ways they could fail.
|
|
15
|
+
#
|
|
16
|
+
# A repository hook is the delivery that cannot fail this way. `.claude/settings.json`
|
|
17
|
+
# is part of the clone, so it reaches a cloud session whether or not a plugin
|
|
18
|
+
# ever installs.
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
payload="$(cat)"
|
|
22
|
+
|
|
23
|
+
repo_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
24
|
+
[ -n "$repo_root" ] || exit 0
|
|
25
|
+
|
|
26
|
+
# Skip when the plugin is installed, so a developer machine does not run every
|
|
27
|
+
# guard twice and print every refusal twice. Absence is the interesting case and
|
|
28
|
+
# the only one this exists for.
|
|
29
|
+
#
|
|
30
|
+
# Read from the plugin registry rather than from CLAUDE_PLUGIN_ROOT: that
|
|
31
|
+
# variable is set for plugin hooks, and this hook is by definition not one.
|
|
32
|
+
installed="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/installed_plugins.json"
|
|
33
|
+
if [ -f "$installed" ] && grep -q '"lisa@lisa"' "$installed" 2>/dev/null; then
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Where the guards live depends on which repository this is.
|
|
38
|
+
#
|
|
39
|
+
# `plugins/lisa/hooks/` exists only in the Lisa monorepo. A host project gets
|
|
40
|
+
# the same three scripts written into its checkout by `lisa apply`, because a
|
|
41
|
+
# host project whose plugin install fails has exactly the same hole and no
|
|
42
|
+
# `plugins/` directory to fall back on.
|
|
43
|
+
status=0
|
|
44
|
+
for guard in block-no-verify parity-safety-net block-shell-json-parsing; do
|
|
45
|
+
script=""
|
|
46
|
+
for candidate in \
|
|
47
|
+
"$repo_root/scripts/lisa-hooks/$guard.sh" \
|
|
48
|
+
"$repo_root/plugins/lisa/hooks/$guard.sh"; do
|
|
49
|
+
if [ -f "$candidate" ]; then
|
|
50
|
+
script="$candidate"
|
|
51
|
+
break
|
|
52
|
+
fi
|
|
53
|
+
done
|
|
54
|
+
[ -n "$script" ] || continue
|
|
55
|
+
# Each guard reads the tool payload on stdin and signals a refusal with exit
|
|
56
|
+
# 2. The payload is replayed to every one of them, and the strongest refusal
|
|
57
|
+
# is returned — a guard that declines must not be able to clear one that did
|
|
58
|
+
# not.
|
|
59
|
+
#
|
|
60
|
+
# The status is captured from the pipeline directly rather than through `if !`,
|
|
61
|
+
# where `$?` is the negation and every refusal read as success: the guard
|
|
62
|
+
# printed its objection and the command ran anyway, which is the same
|
|
63
|
+
# fail-open this file exists to close.
|
|
64
|
+
printf '%s' "$payload" | bash "$script"
|
|
65
|
+
guard_status=$?
|
|
66
|
+
if [ "$guard_status" -gt "$status" ]; then
|
|
67
|
+
status="$guard_status"
|
|
68
|
+
fi
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
exit "$status"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse hook for Bash: blocks commands that bypass git's verification hooks.
|
|
3
|
+
# Bypassing pre-commit/pre-push hooks (which exist for a reason) is blocked in
|
|
4
|
+
# all of its forms; the fix is to address the underlying issue, not silence the
|
|
5
|
+
# check. See feedback_never_no_verify in user memory.
|
|
6
|
+
#
|
|
7
|
+
# Blocked bypass vectors:
|
|
8
|
+
# 1. the --no-verify long flag (any subcommand, any position, incl. subshells);
|
|
9
|
+
# 2. HUSKY=0 / HUSKY_SKIP_HOOKS=... — disables husky-managed git hooks;
|
|
10
|
+
# 3. core.hooksPath pointed at /dev/null or set empty — disables ALL git hooks.
|
|
11
|
+
#
|
|
12
|
+
# Shell-token matching avoids false positives from issue bodies, heredocs, and
|
|
13
|
+
# commit-message prose while still catching quoted real argv values such as
|
|
14
|
+
# `git -c "core.hooksPath=/dev/null"`.
|
|
15
|
+
#
|
|
16
|
+
# The short `-n` form is intentionally NOT matched (see block-no-verify.agy.sh):
|
|
17
|
+
# grep cannot distinguish a real -n option from -n in commit-message prose or an
|
|
18
|
+
# unrelated piped command, and -n is far more common than --no-verify.
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
input="$(cat)"
|
|
22
|
+
|
|
23
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
|
|
24
|
+
if [ "$tool_name" != "Bash" ]; then
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
|
|
29
|
+
if [ -z "$command_str" ]; then
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
command -v python3 >/dev/null 2>&1 || exit 0
|
|
34
|
+
|
|
35
|
+
if ! BLOCK_NO_VERIFY_COMMAND="$command_str" python3 - <<'PY'
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
command = os.environ.get("BLOCK_NO_VERIFY_COMMAND", "")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def strip_heredocs(text: str) -> str:
|
|
45
|
+
lines = text.splitlines()
|
|
46
|
+
output = []
|
|
47
|
+
pending = []
|
|
48
|
+
marker_pattern = re.compile(
|
|
49
|
+
r"<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))"
|
|
50
|
+
)
|
|
51
|
+
index = 0
|
|
52
|
+
while index < len(lines):
|
|
53
|
+
line = lines[index]
|
|
54
|
+
output.append(line)
|
|
55
|
+
pending.extend(
|
|
56
|
+
next(group for group in match.groups() if group)
|
|
57
|
+
for match in marker_pattern.finditer(line)
|
|
58
|
+
)
|
|
59
|
+
index += 1
|
|
60
|
+
while pending and index < len(lines):
|
|
61
|
+
if lines[index].strip() == pending[0]:
|
|
62
|
+
output.append(lines[index])
|
|
63
|
+
pending.pop(0)
|
|
64
|
+
index += 1
|
|
65
|
+
break
|
|
66
|
+
index += 1
|
|
67
|
+
return "\n".join(output)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
tokens = shlex.split(strip_heredocs(command), posix=True)
|
|
72
|
+
except ValueError:
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
|
|
75
|
+
normalized_tokens = [token.strip("();|&") for token in tokens]
|
|
76
|
+
|
|
77
|
+
for i, token in enumerate(normalized_tokens):
|
|
78
|
+
if token == "--no-verify":
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
if token.startswith("core.hooksPath="):
|
|
83
|
+
value = token.split("=", 1)[1]
|
|
84
|
+
if value in ("", "/dev/null"):
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
if token == "core.hooksPath" and i + 1 < len(normalized_tokens) and normalized_tokens[i + 1] in ("", "/dev/null"):
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
PY
|
|
91
|
+
then
|
|
92
|
+
cat >&2 <<'EOF'
|
|
93
|
+
Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify, HUSKY=0,
|
|
94
|
+
or core.hooksPath disabling). Fix the underlying issue (security audit, lint,
|
|
95
|
+
typecheck, tests, formatting) instead. If a fix is genuinely impossible, ask the
|
|
96
|
+
user to make the risk-acceptance decision and add a specific documented ignore;
|
|
97
|
+
never bypass the hook.
|
|
98
|
+
EOF
|
|
99
|
+
exit 2
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
exit 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
|