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.
Files changed (43) hide show
  1. package/adapters/cursor/agents/executor.md +17 -0
  2. package/adapters/cursor/agents/expert.md +70 -0
  3. package/adapters/cursor/agents/explore.md +16 -0
  4. package/adapters/cursor/agents/implementer.md +18 -0
  5. package/adapters/cursor/agents/investigator.md +18 -0
  6. package/adapters/cursor/agents/planner.md +28 -0
  7. package/adapters/cursor/agents/reviewer.md +33 -0
  8. package/adapters/opencode/agents.json +66 -0
  9. package/adapters/opencode/plugin.js +186 -0
  10. package/config/models.json +62 -0
  11. package/hooks/bash-guard.py +541 -0
  12. package/hooks/cursor-guard.py +84 -0
  13. package/hooks/hooks-cursor.json +11 -0
  14. package/hooks/hooks.json +20 -0
  15. package/hooks/session-start.py +121 -0
  16. package/package.json +16 -0
  17. package/roles/executor.md +15 -0
  18. package/roles/expert.md +67 -0
  19. package/roles/explore.md +13 -0
  20. package/roles/implementer.md +16 -0
  21. package/roles/investigator.md +15 -0
  22. package/roles/planner.md +25 -0
  23. package/roles/reviewer.md +30 -0
  24. package/scripts/render_adapters.py +326 -0
  25. package/scripts/state.py +127 -0
  26. package/settings.json +7 -0
  27. package/skills/.gitkeep +0 -0
  28. package/skills/brainstorming/SKILL.md +109 -0
  29. package/skills/debugging/SKILL.md +98 -0
  30. package/skills/delegation/SKILL.md +141 -0
  31. package/skills/executing-plans/SKILL.md +116 -0
  32. package/skills/finishing-a-branch/SKILL.md +123 -0
  33. package/skills/test-first/SKILL.md +90 -0
  34. package/skills/using-leo/SKILL.md +89 -0
  35. package/skills/using-leo/references/claude-mapping.md +11 -0
  36. package/skills/using-leo/references/codex-mapping.md +24 -0
  37. package/skills/using-leo/references/cursor-mapping.md +22 -0
  38. package/skills/using-leo/references/hermes-mapping.md +26 -0
  39. package/skills/using-leo/references/opencode-mapping.md +28 -0
  40. package/skills/verification/SKILL.md +102 -0
  41. package/skills/worktrees/SKILL.md +129 -0
  42. package/skills/writing-plans/SKILL.md +96 -0
  43. 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
+ }
@@ -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
+ }