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