leos-agent 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/cursor/agents/executor.md +17 -0
- package/adapters/cursor/agents/expert.md +70 -0
- package/adapters/cursor/agents/explore.md +16 -0
- package/adapters/cursor/agents/implementer.md +18 -0
- package/adapters/cursor/agents/investigator.md +18 -0
- package/adapters/cursor/agents/planner.md +28 -0
- package/adapters/cursor/agents/reviewer.md +33 -0
- package/adapters/opencode/agents.json +66 -0
- package/adapters/opencode/plugin.js +186 -0
- package/config/models.json +62 -0
- package/hooks/bash-guard.py +541 -0
- package/hooks/cursor-guard.py +84 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +20 -0
- package/hooks/session-start.py +121 -0
- package/package.json +16 -0
- package/roles/executor.md +15 -0
- package/roles/expert.md +67 -0
- package/roles/explore.md +13 -0
- package/roles/implementer.md +16 -0
- package/roles/investigator.md +15 -0
- package/roles/planner.md +25 -0
- package/roles/reviewer.md +30 -0
- package/scripts/render_adapters.py +326 -0
- package/scripts/state.py +127 -0
- package/settings.json +7 -0
- package/skills/.gitkeep +0 -0
- package/skills/brainstorming/SKILL.md +109 -0
- package/skills/debugging/SKILL.md +98 -0
- package/skills/delegation/SKILL.md +141 -0
- package/skills/executing-plans/SKILL.md +116 -0
- package/skills/finishing-a-branch/SKILL.md +123 -0
- package/skills/test-first/SKILL.md +90 -0
- package/skills/using-leo/SKILL.md +89 -0
- package/skills/using-leo/references/claude-mapping.md +11 -0
- package/skills/using-leo/references/codex-mapping.md +24 -0
- package/skills/using-leo/references/cursor-mapping.md +22 -0
- package/skills/using-leo/references/hermes-mapping.md +26 -0
- package/skills/using-leo/references/opencode-mapping.md +28 -0
- package/skills/verification/SKILL.md +102 -0
- package/skills/worktrees/SKILL.md +129 -0
- package/skills/writing-plans/SKILL.md +96 -0
- package/workflows/cost-tiered-fix.js +259 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse guard for Bash: blocks the catastrophic-deletion command class.
|
|
3
|
+
|
|
4
|
+
Narrow tripwire for irreversible, home/system-scale damage — NOT a general command
|
|
5
|
+
policy (the host's permission classifier handles that). False positives are cheap
|
|
6
|
+
(the agent sees the reason and rephrases or asks); false negatives are not.
|
|
7
|
+
|
|
8
|
+
Exit 0 = allow (also for a non-Bash tool_name or an unparseable/empty payload — those
|
|
9
|
+
are not guardable commands). Exit 2 = block; the reason is written to stderr. An
|
|
10
|
+
internal error while checking a Bash command also exits 2: fail CLOSED, since a
|
|
11
|
+
broken guard must not let a catastrophic deletion through.
|
|
12
|
+
|
|
13
|
+
Accepted out-of-scope (other layers' job): obfuscation via scripts/eval/base64,
|
|
14
|
+
network exfiltration, `.env`/secret-read denies. `find -delete` and `find -exec rm`
|
|
15
|
+
ARE covered (see check_find).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pwd
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
HOME = os.path.realpath(os.path.expanduser("~"))
|
|
27
|
+
# Always resolvable (falls back to tempfile's default), unlike $PWD/cd-context which can be
|
|
28
|
+
# genuinely unknown — so $TMPDIR gets the same always-expand treatment as $HOME, not the
|
|
29
|
+
# conservative UNKNOWN_PATH fallback reserved for shell state the guard can't observe.
|
|
30
|
+
TMPDIR = os.path.realpath(os.environ.get("TMPDIR") or tempfile.gettempdir())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fs_case_insensitive(path):
|
|
34
|
+
"""True if `path` lives on a case-insensitive filesystem (default macOS APFS/HFS+).
|
|
35
|
+
|
|
36
|
+
os.path.realpath does NOT canonicalize case, so is_critical would compare `/users` against
|
|
37
|
+
`/Users` and miss it — a one-character case change bypassing the guard on the platform it
|
|
38
|
+
primarily runs on. When True, is_critical casefolds its comparisons. Fails safe to False
|
|
39
|
+
(exact matching) if the probe can't run."""
|
|
40
|
+
try:
|
|
41
|
+
base = os.path.realpath(path)
|
|
42
|
+
flipped = base.upper() if base != base.upper() else base.lower()
|
|
43
|
+
return flipped != base and os.path.exists(flipped) and os.path.samefile(base, flipped)
|
|
44
|
+
except OSError:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
CASE_INSENSITIVE = _fs_case_insensitive(HOME)
|
|
49
|
+
_RE_CASE_FLAG = re.IGNORECASE if CASE_INSENSITIVE else 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _norm_case(text):
|
|
53
|
+
"""Casefold for comparison only on a case-insensitive FS (identity on POSIX/case-sensitive)."""
|
|
54
|
+
return text.casefold() if CASE_INSENSITIVE else text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
WRAPPERS = {"sudo", "command", "env", "nice", "nohup", "time", "doas", "exec"}
|
|
58
|
+
CONTROL_PREFIXES = {"if", "then", "elif", "else", "while", "until", "for", "select", "do", "case"}
|
|
59
|
+
RECURSIVE_SHORT = re.compile(r"^-[a-zA-Z]*[rR]")
|
|
60
|
+
FORCEABLE = re.compile(r"^-[a-zA-Z]*f")
|
|
61
|
+
# `sh -c '...'` / `bash -c "..."` is a normal invocation form, not obfuscation (unlike eval,
|
|
62
|
+
# which the module docstring explicitly scopes out) — the string argument after -c is a real
|
|
63
|
+
# command that deserves the same scrutiny as anything typed directly.
|
|
64
|
+
SHELLS = {"sh", "bash", "zsh", "dash"}
|
|
65
|
+
MAX_SHELL_DEPTH = 3
|
|
66
|
+
|
|
67
|
+
CRITICAL_DIRS = {
|
|
68
|
+
"/", "/Users", "/home", "/root", "/dev", "/bin", "/boot", "/etc", "/lib",
|
|
69
|
+
"/lib64", "/sbin", "/usr", "/var", "/opt", "/System", "/Library",
|
|
70
|
+
"/Applications", "/private", "/private/etc", HOME,
|
|
71
|
+
}
|
|
72
|
+
# OS-standard home dirs that are never rm -rf'd unattended, joined to HOME.
|
|
73
|
+
HOME_TOPLEVEL = {os.path.join(HOME, d) for d in
|
|
74
|
+
("Desktop", "Documents", "Downloads", "Library", "Pictures", "Movies", "Music")}
|
|
75
|
+
HOME_REF = re.compile(r"(~([A-Za-z_][\w-]*)?(/|[\s*]|$)|\$\{?HOME\}?)")
|
|
76
|
+
WATCHED = {"rm", "dd", "chmod", "xargs", "cd", "find", "git"} | SHELLS
|
|
77
|
+
ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
78
|
+
UNKNOWN_DIR = "<unknown>"
|
|
79
|
+
UNKNOWN_PATH = "<unexpanded-shell-path>"
|
|
80
|
+
|
|
81
|
+
# Whole subtrees that are never rm -rf'd unattended. /var excepted for temp dirs;
|
|
82
|
+
# home containers (/Users, /home) handled separately so the caller's OWN home
|
|
83
|
+
# subtree stays allowed while OTHER users' home trees stay critical. /private is the
|
|
84
|
+
# macOS backing store for /etc, /var, /tmp (which are symlinks into it), so its
|
|
85
|
+
# subtree is critical except for the temp-dir exemptions below.
|
|
86
|
+
PREFIX_CRITICAL = ("/bin", "/boot", "/etc", "/lib", "/lib64", "/sbin", "/usr",
|
|
87
|
+
"/System", "/Library", "/Applications", "/dev", "/root", "/private")
|
|
88
|
+
PREFIX_EXEMPT = ("/var/folders", "/var/tmp", "/private/var/folders", "/private/tmp")
|
|
89
|
+
|
|
90
|
+
# Case-normalized once (identity on a case-sensitive FS, casefolded on macOS) so is_critical never
|
|
91
|
+
# re-casefolds the constant sets per call and Linux keeps exact matching.
|
|
92
|
+
_CRITICAL_DIRS_CI = {_norm_case(d) for d in CRITICAL_DIRS}
|
|
93
|
+
_HOME_TOPLEVEL_CI = {_norm_case(d) for d in HOME_TOPLEVEL}
|
|
94
|
+
_HOME_CI = _norm_case(HOME)
|
|
95
|
+
_PREFIX_CRITICAL_CI = tuple(_norm_case(p) for p in PREFIX_CRITICAL)
|
|
96
|
+
_PREFIX_EXEMPT_CI = tuple(_norm_case(p) for p in PREFIX_EXEMPT)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tokenize(segment):
|
|
100
|
+
try:
|
|
101
|
+
tokens = shlex.split(segment, posix=True)
|
|
102
|
+
except ValueError:
|
|
103
|
+
tokens = segment.split()
|
|
104
|
+
cleaned = []
|
|
105
|
+
for i, token in enumerate(tokens):
|
|
106
|
+
if token in ("(", ")", "{", "}"):
|
|
107
|
+
continue
|
|
108
|
+
if i == 0:
|
|
109
|
+
token = token.lstrip("({")
|
|
110
|
+
if i == len(tokens) - 1:
|
|
111
|
+
token = token.rstrip(")")
|
|
112
|
+
if not ("{" in token and "}" in token):
|
|
113
|
+
token = token.rstrip("}")
|
|
114
|
+
if token:
|
|
115
|
+
cleaned.append(token)
|
|
116
|
+
return cleaned
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def split_statements(command):
|
|
120
|
+
"""Split on ; && || & newline into statements; a statement may contain a pipeline.
|
|
121
|
+
|
|
122
|
+
Backslash-newline line continuation is joined first: `rm -rf \\\n~` is one statement
|
|
123
|
+
in bash (the backslash escapes the newline), so splitting on the raw newline would
|
|
124
|
+
detach the target from `rm -rf` and let a recursive delete slip past check_rm."""
|
|
125
|
+
command = re.sub(r"\\\r?\n", " ", command)
|
|
126
|
+
return [s for s in re.split(r"(?:\|\||&&|[;&\n])", command) if s.strip()]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def split_pipeline(statement):
|
|
130
|
+
return [s for s in statement.split("|") if s.strip()]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def strip_wrappers(tokens):
|
|
134
|
+
"""Strip leading VAR=val assignments; if a wrapper (sudo/env/...) leads, scan
|
|
135
|
+
forward to the first WATCHED command so wrapper flags AND their operands
|
|
136
|
+
(e.g. `sudo -u root rm`) can't shield the real command."""
|
|
137
|
+
i = 0
|
|
138
|
+
while i < len(tokens) and ASSIGN_RE.match(tokens[i]):
|
|
139
|
+
i += 1
|
|
140
|
+
tokens = tokens[i:]
|
|
141
|
+
if not tokens:
|
|
142
|
+
return []
|
|
143
|
+
first = os.path.basename(tokens[0])
|
|
144
|
+
function_prefix = bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*\(\)\{?", tokens[0]))
|
|
145
|
+
if first in WRAPPERS or first in CONTROL_PREFIXES or function_prefix:
|
|
146
|
+
for j in range(1, len(tokens)):
|
|
147
|
+
if os.path.basename(tokens[j]) in WATCHED or tokens[j].startswith("mkfs"):
|
|
148
|
+
return tokens[j:]
|
|
149
|
+
return []
|
|
150
|
+
return tokens
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _subst_var(text, name, value):
|
|
154
|
+
"""Boundary-aware $VAR/${VAR} substitution: never rewrites a longer variable that merely
|
|
155
|
+
shares the prefix ($PWD_old, $HOME2, ...) — those must stay unresolved so the caller's
|
|
156
|
+
conservative unknown-path block fires instead of a mis-expanded concrete path."""
|
|
157
|
+
return re.sub(r"\$\{" + name + r"\}|\$" + name + r"(?![A-Za-z0-9_])", lambda _m: value, text)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def expand(target, cwd, cd_context):
|
|
161
|
+
"""Expand only stable shell path values and resolve relative paths against cd-context/tool cwd.
|
|
162
|
+
|
|
163
|
+
``$PWD`` is deliberately modelled from the statement's preceding ``cd`` rather than the hook
|
|
164
|
+
process's own environment; that closes ``cd / && rm -rf $PWD``. Other shell expansion is not
|
|
165
|
+
safe to guess for a catastrophic operation, so it remains an explicit unknown for the caller
|
|
166
|
+
to block conservatively.
|
|
167
|
+
"""
|
|
168
|
+
if cd_context in (UNKNOWN_DIR, UNKNOWN_PATH):
|
|
169
|
+
# `$PWD` follows the shell's last successful `cd`; falling back to the hook payload's
|
|
170
|
+
# original cwd here would mis-model `cd $UNKNOWN && rm -rf $PWD` as a safe project delete.
|
|
171
|
+
workdir = None
|
|
172
|
+
else:
|
|
173
|
+
workdir = cd_context or cwd
|
|
174
|
+
t = _subst_var(target, "HOME", HOME)
|
|
175
|
+
t = _subst_var(t, "TMPDIR", TMPDIR)
|
|
176
|
+
if workdir:
|
|
177
|
+
t = _subst_var(t, "PWD", workdir)
|
|
178
|
+
elif "$PWD" in t or "${PWD}" in t:
|
|
179
|
+
return UNKNOWN_PATH
|
|
180
|
+
if "$" in t or "`" in t or "$(`" in t:
|
|
181
|
+
return UNKNOWN_PATH
|
|
182
|
+
if t == "~" or t.startswith("~/"):
|
|
183
|
+
t = HOME + t[1:]
|
|
184
|
+
else:
|
|
185
|
+
m = re.match(r"^~([A-Za-z_][\w-]*)(/.*)?$", t)
|
|
186
|
+
if m: # ~user expansion: macOS + Linux home containers
|
|
187
|
+
try:
|
|
188
|
+
t = pwd.getpwnam(m.group(1)).pw_dir + (m.group(2) or "")
|
|
189
|
+
except KeyError:
|
|
190
|
+
t = "/Users/" + m.group(1) + (m.group(2) or "")
|
|
191
|
+
base = cd_context or cwd
|
|
192
|
+
if base in (UNKNOWN_DIR, UNKNOWN_PATH):
|
|
193
|
+
base = None
|
|
194
|
+
if t and not t.startswith("/") and base:
|
|
195
|
+
t = os.path.join(base, t)
|
|
196
|
+
return os.path.realpath(t) if t.startswith("/") else t
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def brace_variants(path):
|
|
200
|
+
"""Small shell-brace model for critical-path checks; one comma group is enough for rm args."""
|
|
201
|
+
m = re.search(r"\{([^{}]+)\}", path)
|
|
202
|
+
if not m:
|
|
203
|
+
return [path]
|
|
204
|
+
parts = [p for p in m.group(1).split(",") if p]
|
|
205
|
+
if not parts:
|
|
206
|
+
return [path]
|
|
207
|
+
return [path[:m.start()] + p + path[m.end():] for p in parts]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def is_critical(path):
|
|
211
|
+
"""Is path a critical dir, inside a critical subtree, or a glob over one's contents?
|
|
212
|
+
|
|
213
|
+
Policy: only the root-level `/*`/`/.*` content-glob is modelled (below). Other shell
|
|
214
|
+
globs (`~/Doc*`, `/U*ers`, etc.) are NOT expanded or matched here — that would require
|
|
215
|
+
reimplementing glob semantics against the real filesystem, which is out of scope for an
|
|
216
|
+
accidental-command tripwire and starts to look like the obfuscation-defeating machinery
|
|
217
|
+
the module docstring disclaims. This is a deliberate scope decision, not an oversight:
|
|
218
|
+
such globs currently pass the guard.
|
|
219
|
+
"""
|
|
220
|
+
if not path:
|
|
221
|
+
return False
|
|
222
|
+
starred = path.endswith(("/*", "/.*")) or path in ("/*", "*")
|
|
223
|
+
if starred:
|
|
224
|
+
# A rooted content-glob strips to empty: `/*` and `/.*` mean "everything under root", so
|
|
225
|
+
# they ARE root-scale. normpath("") is "." (harmless-looking cwd) — map the emptied rooted
|
|
226
|
+
# glob back to "/" instead, or `rm -rf /*` (which --preserve-root does NOT stop) slips past.
|
|
227
|
+
stripped = re.sub(r"/\.?\*$", "", path)
|
|
228
|
+
norm = os.path.normpath(stripped) if stripped else "/"
|
|
229
|
+
if norm == "." and path.startswith("/"):
|
|
230
|
+
norm = "/"
|
|
231
|
+
else:
|
|
232
|
+
norm = os.path.normpath(path)
|
|
233
|
+
cnorm = _norm_case(norm)
|
|
234
|
+
if cnorm in _CRITICAL_DIRS_CI or cnorm in _HOME_TOPLEVEL_CI:
|
|
235
|
+
return True
|
|
236
|
+
# The caller's OWN home subtree is exempt (routine project deletes are allowed).
|
|
237
|
+
if cnorm.startswith(_HOME_CI + "/"):
|
|
238
|
+
return False
|
|
239
|
+
if norm.startswith("/") and not any(
|
|
240
|
+
cnorm == e or cnorm.startswith(e + "/") for e in _PREFIX_EXEMPT_CI):
|
|
241
|
+
for p in _PREFIX_CRITICAL_CI:
|
|
242
|
+
if cnorm == p or cnorm.startswith(p + "/"):
|
|
243
|
+
return True
|
|
244
|
+
# /var and its macOS alias /private/var (temp dirs exempted above)
|
|
245
|
+
if cnorm == "/var" or cnorm.startswith("/var/") \
|
|
246
|
+
or cnorm == "/private/var" or cnorm.startswith("/private/var/"):
|
|
247
|
+
return True
|
|
248
|
+
# ANY OTHER user's home tree (root or any depth) under /Users or /home.
|
|
249
|
+
# The caller's own home was already exempted above, so this only fires for
|
|
250
|
+
# other users' homes — critical on shared boxes, at any depth. IGNORECASE only on a
|
|
251
|
+
# case-insensitive FS, so `/users/other` is caught on macOS without over-blocking Linux.
|
|
252
|
+
if re.match(r"^/(Users|home)/[^/]+(/.*)?$", norm, _RE_CASE_FLAG):
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def statement_has_critical_literal(statement, cwd, cd_context):
|
|
258
|
+
"""Detect literal critical paths fed through a pipeline into xargs rm -r."""
|
|
259
|
+
for token in tokenize(statement):
|
|
260
|
+
normalized = token.replace("\\n", "\n").replace("\\0", "\0")
|
|
261
|
+
for frag in re.split(r"[\s\x00]+", normalized):
|
|
262
|
+
if not frag or frag.startswith("-") or "%" in frag:
|
|
263
|
+
continue
|
|
264
|
+
for candidate in brace_variants(expand(frag, cwd, cd_context)):
|
|
265
|
+
if is_critical(candidate):
|
|
266
|
+
return True
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def check_rm(tokens, cwd, cd_context):
|
|
271
|
+
"""tokens = wrapper-stripped command tokens with tokens[0] ~ rm."""
|
|
272
|
+
recursive = False
|
|
273
|
+
targets = []
|
|
274
|
+
for t in tokens[1:]:
|
|
275
|
+
if t == "--no-preserve-root":
|
|
276
|
+
return "rm --no-preserve-root"
|
|
277
|
+
if t in ("--recursive", "-R"):
|
|
278
|
+
recursive = True
|
|
279
|
+
elif t.startswith("--"):
|
|
280
|
+
continue
|
|
281
|
+
elif t.startswith("-"):
|
|
282
|
+
if RECURSIVE_SHORT.match(t):
|
|
283
|
+
recursive = True
|
|
284
|
+
else:
|
|
285
|
+
targets.append(t)
|
|
286
|
+
if not recursive:
|
|
287
|
+
return None
|
|
288
|
+
for raw in targets:
|
|
289
|
+
# Unknown working directory: relative sweeps are unverifiable — block conservatively.
|
|
290
|
+
if raw in (".", "..", "./", "../", "./*", "../*", "*") and not (cwd or cd_context):
|
|
291
|
+
return f"recursive rm of '{raw}' with unknown working directory"
|
|
292
|
+
for candidate in brace_variants(expand(raw, cwd, cd_context)):
|
|
293
|
+
if candidate == UNKNOWN_PATH:
|
|
294
|
+
return f"recursive rm with unexpanded shell path '{raw}'"
|
|
295
|
+
if is_critical(candidate):
|
|
296
|
+
return f"recursive rm targeting '{raw}'"
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_find(tokens, cwd, cd_context, statement):
|
|
301
|
+
"""find -delete and find -exec/-ok/-execdir/-okdir rm are recursive deletes wearing a find
|
|
302
|
+
hat; block when any path operand (after expand) is critical or unexpanded/unknown. Bare
|
|
303
|
+
`find -delete` with no path (cwd-relative) stays allowed when cwd is a known project dir,
|
|
304
|
+
matching the rm branch's cwd handling. Path operands are expanded and classified via
|
|
305
|
+
is_critical (the caller's own home subtree is exempt there), so ~/$HOME spellings of the
|
|
306
|
+
caller's own project behave like `rm -rf ~/project/...` rather than false-blocking."""
|
|
307
|
+
rest = tokens[1:]
|
|
308
|
+
deleting = False
|
|
309
|
+
i = 0
|
|
310
|
+
while i < len(rest):
|
|
311
|
+
t = rest[i]
|
|
312
|
+
if t == "-delete":
|
|
313
|
+
deleting = True
|
|
314
|
+
elif t in ("-exec", "-ok", "-execdir", "-okdir"):
|
|
315
|
+
# The token after the -exec* predicate (skipping find's own flags) is the command.
|
|
316
|
+
j = i + 1
|
|
317
|
+
while j < len(rest) and rest[j].startswith("-") and rest[j] not in (";", "+"):
|
|
318
|
+
j += 1
|
|
319
|
+
if j < len(rest) and os.path.basename(rest[j]) == "rm":
|
|
320
|
+
deleting = True
|
|
321
|
+
i += 1
|
|
322
|
+
if not deleting:
|
|
323
|
+
return None
|
|
324
|
+
# Collect path operands: non-flag tokens that are not the find predicate payload. A bare
|
|
325
|
+
# `find -delete` (no path) sweeps cwd; that is unverifiable without a known cwd.
|
|
326
|
+
path_operands = []
|
|
327
|
+
has_path_operand = False
|
|
328
|
+
for t in rest:
|
|
329
|
+
if t.startswith("-") or t in (";", "+") or t.startswith("{}"):
|
|
330
|
+
continue
|
|
331
|
+
has_path_operand = True
|
|
332
|
+
path_operands.append(t)
|
|
333
|
+
if not has_path_operand and not (cwd or cd_context):
|
|
334
|
+
return "find -delete / find -exec rm with unknown working directory"
|
|
335
|
+
for raw in path_operands:
|
|
336
|
+
for candidate in brace_variants(expand(raw, cwd, cd_context)):
|
|
337
|
+
if candidate == UNKNOWN_PATH:
|
|
338
|
+
return f"find delete/exec with unexpanded shell path '{raw}'"
|
|
339
|
+
if is_critical(candidate):
|
|
340
|
+
return f"find -delete / find -exec rm targeting '{raw}'"
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def check_git_clean(tokens, cwd, cd_context):
|
|
345
|
+
"""`git clean -f` (or any bundled short flag containing 'f', e.g. -xdff) irreversibly
|
|
346
|
+
deletes every untracked file under the target — squarely the guard's threat class, same
|
|
347
|
+
as recursive rm. Block only when a force flag is present AND the resolved target (an
|
|
348
|
+
explicit path argument, or the cwd/cd-context when none is given) is critical; reuses
|
|
349
|
+
is_critical/expand rather than new path logic."""
|
|
350
|
+
if len(tokens) < 2 or tokens[1] != "clean":
|
|
351
|
+
return None
|
|
352
|
+
force = False
|
|
353
|
+
targets = []
|
|
354
|
+
for t in tokens[2:]:
|
|
355
|
+
if t in ("-f", "--force"):
|
|
356
|
+
force = True
|
|
357
|
+
elif t.startswith("--"):
|
|
358
|
+
continue
|
|
359
|
+
elif t.startswith("-"):
|
|
360
|
+
if FORCEABLE.match(t):
|
|
361
|
+
force = True
|
|
362
|
+
else:
|
|
363
|
+
targets.append(t)
|
|
364
|
+
if not force:
|
|
365
|
+
return None
|
|
366
|
+
if targets:
|
|
367
|
+
for raw in targets:
|
|
368
|
+
for candidate in brace_variants(expand(raw, cwd, cd_context)):
|
|
369
|
+
if candidate == UNKNOWN_PATH or is_critical(candidate):
|
|
370
|
+
return f"git clean with a force flag targeting '{raw}'"
|
|
371
|
+
return None
|
|
372
|
+
base = cd_context or cwd
|
|
373
|
+
if base and base not in (UNKNOWN_DIR, UNKNOWN_PATH) and is_critical(base):
|
|
374
|
+
return "git clean with a force flag in a critical working directory"
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def handle_cd(tokens, cwd, cd_context):
|
|
379
|
+
"""Model cd: bare cd -> HOME; `cd -` -> unknown; skip flags/--."""
|
|
380
|
+
args = [t for t in tokens[1:] if not (t.startswith("-") and t != "-")]
|
|
381
|
+
if not args:
|
|
382
|
+
return HOME
|
|
383
|
+
if args[0] == "-":
|
|
384
|
+
return UNKNOWN_DIR
|
|
385
|
+
result = expand(args[0], cwd, cd_context)
|
|
386
|
+
return UNKNOWN_DIR if result == UNKNOWN_PATH else result
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def check_statement(statement, cwd, cd_context, depth=0):
|
|
390
|
+
"""Check one statement (possibly a pipeline). Returns (reason|None, new_cd_context)."""
|
|
391
|
+
stmt_home_ref = bool(HOME_REF.search(statement))
|
|
392
|
+
|
|
393
|
+
for stage in split_pipeline(statement):
|
|
394
|
+
tokens = strip_wrappers(tokenize(stage))
|
|
395
|
+
if not tokens:
|
|
396
|
+
continue
|
|
397
|
+
cmd = os.path.basename(tokens[0])
|
|
398
|
+
|
|
399
|
+
if cmd == "cd":
|
|
400
|
+
cd_context = handle_cd(tokens, cwd, cd_context)
|
|
401
|
+
continue
|
|
402
|
+
if cmd in SHELLS:
|
|
403
|
+
# `sh -c '<command>'` / `bash -c "<command>"` etc: the string argument after -c
|
|
404
|
+
# is a real command, not obfuscation, so recurse the same check() into it. shlex
|
|
405
|
+
# has already stripped the quoting, so the token after the option cluster is the
|
|
406
|
+
# literal script text. Depth-capped (not `eval`-style unbounded unwrapping) to
|
|
407
|
+
# bound self-nesting.
|
|
408
|
+
#
|
|
409
|
+
# Covered here: `-c` alone, and `c` bundled into any leading short-option cluster
|
|
410
|
+
# in any position (`-ec`, `-ce`, `-lc`, separate tokens like `-e -c`), stopping at
|
|
411
|
+
# a `--` terminator or the first non-option token. A cluster with no `c` (e.g.
|
|
412
|
+
# `sh -e script.sh`) is treated as a file argument and is NOT recursed into.
|
|
413
|
+
# Deliberately NOT covered (same scope as `eval`, per the module docstring):
|
|
414
|
+
# long-option spellings of script mode, and anything beyond MAX_SHELL_DEPTH of
|
|
415
|
+
# self-nesting — those are adversarial-evasion concerns, not accidental commands.
|
|
416
|
+
idx = 1
|
|
417
|
+
found_c = False
|
|
418
|
+
while idx < len(tokens):
|
|
419
|
+
t = tokens[idx]
|
|
420
|
+
if t == "--":
|
|
421
|
+
idx += 1
|
|
422
|
+
break
|
|
423
|
+
if t.startswith("--"):
|
|
424
|
+
idx += 1
|
|
425
|
+
continue
|
|
426
|
+
if t.startswith("-") and len(t) > 1:
|
|
427
|
+
if "c" in t[1:]:
|
|
428
|
+
found_c = True
|
|
429
|
+
idx += 1
|
|
430
|
+
break
|
|
431
|
+
idx += 1
|
|
432
|
+
continue
|
|
433
|
+
break
|
|
434
|
+
if found_c and idx < len(tokens) and depth < MAX_SHELL_DEPTH:
|
|
435
|
+
reason = check(tokens[idx], cwd, depth + 1)
|
|
436
|
+
if reason:
|
|
437
|
+
return reason, cd_context
|
|
438
|
+
continue
|
|
439
|
+
if cmd == "git":
|
|
440
|
+
reason = check_git_clean(tokens, cwd, cd_context)
|
|
441
|
+
if reason:
|
|
442
|
+
return reason, cd_context
|
|
443
|
+
if cmd == "rm":
|
|
444
|
+
reason = check_rm(tokens, cwd, cd_context)
|
|
445
|
+
if reason:
|
|
446
|
+
return reason, cd_context
|
|
447
|
+
if cmd == "find":
|
|
448
|
+
reason = check_find(tokens, cwd, cd_context, statement)
|
|
449
|
+
if reason:
|
|
450
|
+
return reason, cd_context
|
|
451
|
+
if cmd == "xargs":
|
|
452
|
+
# Scan past xargs flags/operands (-0, -n 1, -I{}, --no-run-if-empty...) to find rm.
|
|
453
|
+
rest = tokens[1:]
|
|
454
|
+
for j, t in enumerate(rest):
|
|
455
|
+
if os.path.basename(t) == "rm":
|
|
456
|
+
# `find /etc | xargs rm` (no -r) deletes every file fed in — the recursion
|
|
457
|
+
# flag is irrelevant when the pipeline already carries a critical/home path,
|
|
458
|
+
# so the -r gate is intentionally dropped here.
|
|
459
|
+
if stmt_home_ref or statement_has_critical_literal(
|
|
460
|
+
statement, cwd, cd_context):
|
|
461
|
+
return "piped xargs rm with a home/root reference in the pipeline", cd_context
|
|
462
|
+
break
|
|
463
|
+
if cmd == "mkfs" or cmd.startswith("mkfs."):
|
|
464
|
+
return "mkfs (filesystem format)", cd_context
|
|
465
|
+
if cmd == "dd":
|
|
466
|
+
for t in tokens[1:]:
|
|
467
|
+
if t.startswith("of=/dev/"):
|
|
468
|
+
return "dd writing to a raw device", cd_context
|
|
469
|
+
if cmd == "chmod":
|
|
470
|
+
rec = any(RECURSIVE_SHORT.match(t) or t == "--recursive" for t in tokens[1:] if t.startswith("-"))
|
|
471
|
+
if rec:
|
|
472
|
+
for t in tokens[1:]:
|
|
473
|
+
if t.startswith("-"):
|
|
474
|
+
continue
|
|
475
|
+
for candidate in brace_variants(expand(t, cwd, cd_context)):
|
|
476
|
+
if is_critical(candidate):
|
|
477
|
+
return "recursive chmod on /, home, or system path", cd_context
|
|
478
|
+
return None, cd_context
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
_IFS_RE = re.compile(r"\$\{IFS(?:[:#%/^,][^}]*)?\}|\$IFS(?![A-Za-z0-9_])")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _normalize_ifs(command):
|
|
485
|
+
"""Model shell IFS word-splitting so a flag-glued target can't hide from the tokenizer.
|
|
486
|
+
|
|
487
|
+
`$IFS` / `${IFS}` / `${IFS:-...}` expand to whitespace at runtime, so `rm -rf${IFS}/` actually
|
|
488
|
+
executes as `rm -rf /`. shlex.split does not expand them, leaving `-rf${IFS}/` a single token
|
|
489
|
+
with no target operand — a bypass. Substitute those forms with a space BEFORE tokenizing.
|
|
490
|
+
(Other obfuscation — eval/base64/hex — stays out of scope per the module docstring.)"""
|
|
491
|
+
return _IFS_RE.sub(" ", command)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def check(command, cwd, depth=0):
|
|
495
|
+
command = _normalize_ifs(command)
|
|
496
|
+
cd_context = None
|
|
497
|
+
for statement in split_statements(command):
|
|
498
|
+
reason, cd_context = check_statement(statement, cwd, cd_context, depth)
|
|
499
|
+
if reason:
|
|
500
|
+
return reason
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def main():
|
|
505
|
+
try:
|
|
506
|
+
payload = json.load(sys.stdin)
|
|
507
|
+
except Exception:
|
|
508
|
+
return 0
|
|
509
|
+
try:
|
|
510
|
+
if payload.get("tool_name") != "Bash":
|
|
511
|
+
return 0
|
|
512
|
+
command = (payload.get("tool_input") or {}).get("command", "")
|
|
513
|
+
if not isinstance(command, str) or not command:
|
|
514
|
+
return 0
|
|
515
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
|
|
516
|
+
try:
|
|
517
|
+
reason = check(command, cwd)
|
|
518
|
+
except Exception:
|
|
519
|
+
# Fail CLOSED: a bug inside check()/is_critical() during a guardable Bash command must
|
|
520
|
+
# not let a catastrophic deletion through. (Non-Bash tool_names and unparseable
|
|
521
|
+
# payloads are still returned 0 above, since those are not guardable.)
|
|
522
|
+
sys.stderr.write(
|
|
523
|
+
"[bash-guard] BLOCKED — internal error while evaluating the command. "
|
|
524
|
+
"Failing closed: this command class is irreversible, so a guard fault denies it.\n"
|
|
525
|
+
)
|
|
526
|
+
return 2
|
|
527
|
+
if reason:
|
|
528
|
+
sys.stderr.write(
|
|
529
|
+
f"[bash-guard] BLOCKED — {reason}. This command class is irreversible at "
|
|
530
|
+
f"home/system scale and is never run unattended. If the deletion is genuinely "
|
|
531
|
+
f"intended: use a narrower explicit path (never '~', '/', '.', or a home-level "
|
|
532
|
+
f"directory), prefer moving to trash, or ask the user to run it themselves.\n"
|
|
533
|
+
)
|
|
534
|
+
return 2
|
|
535
|
+
return 0
|
|
536
|
+
except Exception:
|
|
537
|
+
return 0
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
if __name__ == "__main__":
|
|
541
|
+
sys.exit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cursor beforeShellExecution guard: thin adapter over bash-guard.py's pure check().
|
|
3
|
+
|
|
4
|
+
Cursor's hook contract differs from Claude Code's: stdin carries {command, cwd, ...}
|
|
5
|
+
JSON, and the response is {"permission": "allow"|"deny", ...} written to stdout, always
|
|
6
|
+
exit 0 (Cursor has no separate block-via-exit-code channel here). This adapter never
|
|
7
|
+
duplicates bash-guard's detection logic — it imports the pure `check(command, cwd)`
|
|
8
|
+
function from bash-guard.py (via importlib.util.spec_from_file_location, since the
|
|
9
|
+
filename has a hyphen and isn't a valid module name) and translates its verdict into
|
|
10
|
+
Cursor's shape.
|
|
11
|
+
|
|
12
|
+
Fail-open only for genuinely unguardable input (unparseable stdin, missing/empty
|
|
13
|
+
command) — same as bash-guard's own non-Bash/unparseable exit 0. Fail CLOSED (deny) if
|
|
14
|
+
loading or calling check() raises, mirroring bash-guard's fail-closed exit 2 on an
|
|
15
|
+
internal error.
|
|
16
|
+
"""
|
|
17
|
+
import importlib.util
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
_BASH_GUARD_PATH = os.path.join(_HERE, "bash-guard.py")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_bash_guard():
|
|
27
|
+
spec = importlib.util.spec_from_file_location("bash_guard", _BASH_GUARD_PATH)
|
|
28
|
+
module = importlib.util.module_from_spec(spec)
|
|
29
|
+
spec.loader.exec_module(module)
|
|
30
|
+
return module
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _blocked_message(reason):
|
|
34
|
+
# Mirrors bash-guard.py's main() blocked-message wording pattern (not imported —
|
|
35
|
+
# main() writes to stderr with an exit code, which doesn't fit Cursor's JSON-on-stdout
|
|
36
|
+
# contract, so the wording is reproduced here instead).
|
|
37
|
+
return (
|
|
38
|
+
f"[bash-guard] BLOCKED — {reason}. This command class is irreversible at "
|
|
39
|
+
"home/system scale and is never run unattended. If the deletion is genuinely "
|
|
40
|
+
"intended: use a narrower explicit path (never '~', '/', '.', or a home-level "
|
|
41
|
+
"directory), prefer moving to trash, or ask the user to run it themselves."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
try:
|
|
47
|
+
payload = json.load(sys.stdin)
|
|
48
|
+
except Exception:
|
|
49
|
+
print(json.dumps({"permission": "allow"}))
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
command = payload.get("command") if isinstance(payload, dict) else None
|
|
53
|
+
if not isinstance(command, str) or not command:
|
|
54
|
+
print(json.dumps({"permission": "allow"}))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
cwd = payload.get("cwd") if isinstance(payload, dict) else None
|
|
58
|
+
if not isinstance(cwd, str):
|
|
59
|
+
cwd = None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
bash_guard = _load_bash_guard()
|
|
63
|
+
reason = bash_guard.check(command, cwd)
|
|
64
|
+
except Exception:
|
|
65
|
+
print(json.dumps({
|
|
66
|
+
"permission": "deny",
|
|
67
|
+
"agent_message": (
|
|
68
|
+
"[bash-guard] BLOCKED — internal error; failing closed on this "
|
|
69
|
+
"irreversible command class."
|
|
70
|
+
),
|
|
71
|
+
}))
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
if reason:
|
|
75
|
+
msg = _blocked_message(reason)
|
|
76
|
+
print(json.dumps({"permission": "deny", "user_message": msg, "agent_message": msg}))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
print(json.dumps({"permission": "allow"}))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"sessionStart": [
|
|
5
|
+
{ "command": "python3 \"$CURSOR_PLUGIN_ROOT/hooks/session-start.py\"" }
|
|
6
|
+
],
|
|
7
|
+
"beforeShellExecution": [
|
|
8
|
+
{ "command": "python3 \"$CURSOR_PLUGIN_ROOT/hooks/cursor-guard.py\"", "timeout": 10, "failClosed": true }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "startup|resume|clear|compact",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}/hooks/session-start.py\"", "timeout": 10 }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"PreToolUse": [
|
|
12
|
+
{
|
|
13
|
+
"matcher": "Bash",
|
|
14
|
+
"hooks": [
|
|
15
|
+
{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}/hooks/bash-guard.py\"", "timeout": 10 }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|