@codyswann/lisa 2.222.2 → 2.222.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/opencode/hooks-installer.d.ts.map +1 -1
- package/dist/opencode/hooks-installer.js +23 -2
- package/dist/opencode/hooks-installer.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-parity-safety-net.ts +43 -0
- package/dist/opencode/plugin-templates/parity-safety-net-heredoc.py +417 -0
- package/dist/opencode/plugin-templates/parity-safety-net.sh +155 -0
- package/dist/opencode/support-file-resolver.d.ts +8 -0
- package/dist/opencode/support-file-resolver.d.ts.map +1 -0
- package/dist/opencode/support-file-resolver.js +16 -0
- package/dist/opencode/support-file-resolver.js.map +1 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/parity-safety-net-heredoc.py +417 -0
- package/plugins/lisa/hooks/parity-safety-net.agy.sh +50 -0
- package/plugins/lisa/hooks/parity-safety-net.sh +33 -51
- package/plugins/lisa-agy/hooks/parity-safety-net-heredoc.py +417 -0
- package/plugins/lisa-agy/hooks/parity-safety-net.agy.sh +50 -0
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +155 -0
- package/plugins/lisa-agy/hooks.json +13 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net-heredoc.py +417 -0
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +33 -51
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net-heredoc.py +417 -0
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +33 -51
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/parity-safety-net-heredoc.py +417 -0
- package/plugins/src/base/hooks/parity-safety-net.agy.sh +50 -0
- package/plugins/src/base/hooks/parity-safety-net.sh +33 -51
- package/scripts/copy-opencode-plugin-templates.mjs +12 -0
- package/scripts/generate-agy-plugin-artifacts.mjs +24 -10
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse hook for Bash: a safety net that blocks destructive shell commands
|
|
3
|
+
# before they run. Lisa-native reimplementation of the upstream
|
|
4
|
+
# `safety-net@cc-marketplace` plugin's PreToolUse Bash-guard (parity work, issue
|
|
5
|
+
# #1059). It does NOT port upstream code — it re-expresses the behavior in Lisa's
|
|
6
|
+
# hook conventions, modeled on block-no-verify.sh.
|
|
7
|
+
#
|
|
8
|
+
# It reads the hook stdin JSON, inspects the proposed Bash command, and EXITS
|
|
9
|
+
# NON-ZERO (2) to BLOCK when a known-destructive pattern matches:
|
|
10
|
+
# - `rm -rf /` (recursive forced delete of a root / home / wildcard path)
|
|
11
|
+
# - force-pushing a protected branch (main/master/production/release)
|
|
12
|
+
# - `git reset --hard` while the working tree is dirty (would discard work)
|
|
13
|
+
# - dropping or truncating a database/schema/table
|
|
14
|
+
# Otherwise it exits 0 and the command proceeds.
|
|
15
|
+
#
|
|
16
|
+
# Operators extend the built-in rules with a project-local rule file — one
|
|
17
|
+
# extended-regex (ERE) per line, blank lines and `#` comments ignored — managed
|
|
18
|
+
# by the parity-safety-net-rules skill. Default location (overridable via
|
|
19
|
+
# SAFETY_NET_RULES_FILE):
|
|
20
|
+
# ${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
input="$(cat)"
|
|
24
|
+
|
|
25
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
|
|
26
|
+
if [ "$tool_name" != "Bash" ]; then
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
|
|
31
|
+
if [ -z "$command_str" ]; then
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# block() prints the reason to stderr (surfaced to the model) and exits 2 so the
|
|
36
|
+
# Bash tool call is denied. $1 = human-readable reason for the block.
|
|
37
|
+
block() {
|
|
38
|
+
cat >&2 <<EOF
|
|
39
|
+
Blocked by safety-net: $1
|
|
40
|
+
|
|
41
|
+
This command matched a destructive-operation guard. If it is genuinely safe and
|
|
42
|
+
intentional, ask the user to confirm, then run it manually outside the agent, or
|
|
43
|
+
narrow the command so it no longer matches the guard.
|
|
44
|
+
EOF
|
|
45
|
+
exit 2
|
|
46
|
+
}
|
|
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
|
+
|
|
81
|
+
# 1. Recursive forced delete (`rm -rf`) of a filesystem root, home, or top-level
|
|
82
|
+
# wildcard. Two gates ANDed: the command must invoke `rm` with BOTH a
|
|
83
|
+
# recursive and a force flag, AND name a catastrophic target. Splitting the
|
|
84
|
+
# flag check from the target check keeps each regex legible and testable.
|
|
85
|
+
if printf '%s' "$command_for_guards" \
|
|
86
|
+
| grep -Eiq '(^|[^[:alnum:]_./-])rm([[:space:]]+-[[:alnum:]-]+)*[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)' \
|
|
87
|
+
|| printf '%s' "$command_for_guards" \
|
|
88
|
+
| grep -Eiq '(^|[^[:alnum:]_./-])rm[[:space:]].*(-r\b.*[[:space:]]-f\b|-f\b.*[[:space:]]-r\b|--recursive\b.*--force\b|--force\b.*--recursive\b)'; then
|
|
89
|
+
if printf '%s' "$command_for_guards" \
|
|
90
|
+
| grep -Eq '([[:space:]]|=)(/|/\*|/\.\*?|~|~/\*?|\$HOME\b|\$\{HOME\}|\*)([[:space:]]|/?\*?$)'; then
|
|
91
|
+
block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
|
|
92
|
+
fi
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
# 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
|
|
96
|
+
# non-clobbering alternative and is intentionally NOT blocked.
|
|
97
|
+
#
|
|
98
|
+
# The force flag AND the protected-branch name must appear in the SAME
|
|
99
|
+
# `git push` statement. Checking them independently over the whole command is
|
|
100
|
+
# a false-positive magnet: an unrelated `-f` (a `[ -f file ]` test, `rm -f`,
|
|
101
|
+
# `grep -f`, `tail -f`) plus an unrelated protected name (`--base main`,
|
|
102
|
+
# `origin/main`, `git fetch origin main`) alongside any feature-branch
|
|
103
|
+
# `git push` would wrongly block. So split the command into statements
|
|
104
|
+
# (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
|
|
105
|
+
# inspect each in isolation — a real `git push --force origin main` still
|
|
106
|
+
# matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
|
|
107
|
+
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
108
|
+
# before segmenting the command. Without this, "git push --force origin
|
|
109
|
+
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
110
|
+
# protected force-push slip past. Uses awk (POSIX) instead of a GNU-only
|
|
111
|
+
# `sed ':a;N;$!ba;…'`, which errors on BSD sed (macOS) and there silently no-ops.
|
|
112
|
+
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
113
|
+
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
114
|
+
|
|
115
|
+
while IFS= read -r push_stmt; do
|
|
116
|
+
if printf '%s' "$push_stmt" \
|
|
117
|
+
| grep -Eiq '(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
|
|
118
|
+
&& ! printf '%s' "$push_stmt" | grep -Eiq -- '--force-with-lease' \
|
|
119
|
+
&& printf '%s' "$push_stmt" \
|
|
120
|
+
| grep -Eiq '(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
|
|
121
|
+
block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
|
|
122
|
+
fi
|
|
123
|
+
done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
124
|
+
| grep -Ei '(^|[^[:alnum:]_-])git[[:space:]]+push\b')
|
|
125
|
+
|
|
126
|
+
# 3. `git reset --hard` while the working tree has uncommitted changes — this
|
|
127
|
+
# silently discards them. Only blocks when the tree is actually dirty, so a
|
|
128
|
+
# clean-tree reset (a legitimate workflow) still passes.
|
|
129
|
+
if printf '%s' "$command_for_guards" | grep -Eiq '(^|[^[:alnum:]_-])git[[:space:]]+reset\b.*--hard\b'; then
|
|
130
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
|
131
|
+
&& [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
|
132
|
+
block "git reset --hard on a dirty working tree would discard uncommitted changes (stash or commit first)"
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# 4. Dropping or truncating a database / schema / table.
|
|
137
|
+
if printf '%s' "$command_for_guards" \
|
|
138
|
+
| grep -Eiq '\b(drop[[:space:]]+(database|schema|table)|truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+)\b'; then
|
|
139
|
+
block "destructive SQL (DROP/TRUNCATE) detected"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
# 5. Project-local custom rules. Each non-comment line is an ERE; a match blocks.
|
|
143
|
+
rules_file="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
|
|
144
|
+
if [ -f "$rules_file" ]; then
|
|
145
|
+
while IFS= read -r rule || [ -n "$rule" ]; do
|
|
146
|
+
case "$rule" in
|
|
147
|
+
'' | '#'*) continue ;;
|
|
148
|
+
esac
|
|
149
|
+
if printf '%s' "$command_for_guards" | grep -Eiq -- "$rule"; then
|
|
150
|
+
block "matched a project custom safety rule (${rules_file##*/}): $rule"
|
|
151
|
+
fi
|
|
152
|
+
done <"$rules_file"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
exit 0
|
|
@@ -11,5 +11,18 @@
|
|
|
11
11
|
]
|
|
12
12
|
}
|
|
13
13
|
]
|
|
14
|
+
},
|
|
15
|
+
"lisa-parity-safety-net": {
|
|
16
|
+
"PreToolUse": [
|
|
17
|
+
{
|
|
18
|
+
"matcher": "run_command",
|
|
19
|
+
"hooks": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "bash \"$HOME/.gemini/config/plugins/lisa/hooks/parity-safety-net.agy.sh\""
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
14
27
|
}
|
|
15
28
|
}
|
|
@@ -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())
|