recusal 0.1.0__tar.gz

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 (91) hide show
  1. recusal-0.1.0/.claude/hooks/recusal_gate.py +438 -0
  2. recusal-0.1.0/.claude/settings.json.example +16 -0
  3. recusal-0.1.0/.gitattributes +4 -0
  4. recusal-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +50 -0
  5. recusal-0.1.0/.github/ISSUE_TEMPLATE/config.yml +8 -0
  6. recusal-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +43 -0
  7. recusal-0.1.0/.github/PULL_REQUEST_TEMPLATE.md +31 -0
  8. recusal-0.1.0/.github/workflows/ci.yml +31 -0
  9. recusal-0.1.0/.github/workflows/release.yml +74 -0
  10. recusal-0.1.0/.gitignore +41 -0
  11. recusal-0.1.0/.pre-commit-config.yaml +14 -0
  12. recusal-0.1.0/CHANGELOG.md +114 -0
  13. recusal-0.1.0/CITATION.cff +27 -0
  14. recusal-0.1.0/CODE_OF_CONDUCT.md +122 -0
  15. recusal-0.1.0/CONSTITUTION.md +91 -0
  16. recusal-0.1.0/CONTRIBUTING.md +56 -0
  17. recusal-0.1.0/LICENSE +201 -0
  18. recusal-0.1.0/PKG-INFO +542 -0
  19. recusal-0.1.0/README.md +311 -0
  20. recusal-0.1.0/SECURITY.md +82 -0
  21. recusal-0.1.0/assets/apple-touch-icon.png +0 -0
  22. recusal-0.1.0/assets/banner-gate-strip-light.png +0 -0
  23. recusal-0.1.0/assets/banner-gate-strip.png +0 -0
  24. recusal-0.1.0/assets/banner-gate.png +0 -0
  25. recusal-0.1.0/assets/banner-ledger.png +0 -0
  26. recusal-0.1.0/assets/banner-seal-light.png +0 -0
  27. recusal-0.1.0/assets/banner-seal.png +0 -0
  28. recusal-0.1.0/assets/favicon-16.png +0 -0
  29. recusal-0.1.0/assets/favicon-32.png +0 -0
  30. recusal-0.1.0/assets/favicon-48.png +0 -0
  31. recusal-0.1.0/assets/favicon.ico +0 -0
  32. recusal-0.1.0/assets/icon-512.png +0 -0
  33. recusal-0.1.0/assets/social-preview.png +0 -0
  34. recusal-0.1.0/conftest.py +2 -0
  35. recusal-0.1.0/docs/COOKBOOK.md +361 -0
  36. recusal-0.1.0/docs/EVIDENCE.md +114 -0
  37. recusal-0.1.0/docs/EXAMPLE.md +144 -0
  38. recusal-0.1.0/docs/EXTENDING.md +130 -0
  39. recusal-0.1.0/docs/FAQ.md +157 -0
  40. recusal-0.1.0/docs/HOWTO.md +220 -0
  41. recusal-0.1.0/docs/LANDSCAPE.md +82 -0
  42. recusal-0.1.0/docs/PROVEN.md +109 -0
  43. recusal-0.1.0/docs/README.md +54 -0
  44. recusal-0.1.0/docs/REFERENCES.md +232 -0
  45. recusal-0.1.0/docs/WHY.md +151 -0
  46. recusal-0.1.0/examples/README.md +42 -0
  47. recusal-0.1.0/examples/agent_loop.py +76 -0
  48. recusal-0.1.0/examples/allowlist_gate.py +99 -0
  49. recusal-0.1.0/examples/audit_demo.py +56 -0
  50. recusal-0.1.0/examples/classify_demo.py +37 -0
  51. recusal-0.1.0/examples/claude_agent_live.py +150 -0
  52. recusal-0.1.0/examples/claude_code_gate.py +61 -0
  53. recusal-0.1.0/examples/claude_refusal.py +85 -0
  54. recusal-0.1.0/examples/gallery.py +91 -0
  55. recusal-0.1.0/examples/injection_quarantine.py +104 -0
  56. recusal-0.1.0/examples/quickstart.py +38 -0
  57. recusal-0.1.0/examples/scenarios.py +123 -0
  58. recusal-0.1.0/pyproject.toml +64 -0
  59. recusal-0.1.0/recusal/__init__.py +69 -0
  60. recusal-0.1.0/recusal/audit.py +177 -0
  61. recusal-0.1.0/recusal/checks.py +242 -0
  62. recusal-0.1.0/recusal/classify.py +207 -0
  63. recusal-0.1.0/recusal/claude.py +119 -0
  64. recusal-0.1.0/recusal/claude_code.py +310 -0
  65. recusal-0.1.0/recusal/evidence.py +228 -0
  66. recusal-0.1.0/recusal/gates.py +172 -0
  67. recusal-0.1.0/recusal/py.typed +0 -0
  68. recusal-0.1.0/tests/test_audit.py +121 -0
  69. recusal-0.1.0/tests/test_audit_hardening.py +68 -0
  70. recusal-0.1.0/tests/test_checks.py +71 -0
  71. recusal-0.1.0/tests/test_checks_edge.py +84 -0
  72. recusal-0.1.0/tests/test_classify.py +123 -0
  73. recusal-0.1.0/tests/test_claude.py +58 -0
  74. recusal-0.1.0/tests/test_claude_code.py +73 -0
  75. recusal-0.1.0/tests/test_claude_code_allowlist.py +161 -0
  76. recusal-0.1.0/tests/test_claude_code_robust.py +103 -0
  77. recusal-0.1.0/tests/test_coerce_strict.py +23 -0
  78. recusal-0.1.0/tests/test_contract_invariants.py +102 -0
  79. recusal-0.1.0/tests/test_demos_smoke.py +67 -0
  80. recusal-0.1.0/tests/test_dogfood.py +134 -0
  81. recusal-0.1.0/tests/test_dogfood_redteam.py +186 -0
  82. recusal-0.1.0/tests/test_evidence.py +43 -0
  83. recusal-0.1.0/tests/test_gates.py +98 -0
  84. recusal-0.1.0/tests/test_gates_completeness.py +53 -0
  85. recusal-0.1.0/tests/test_scenarios.py +82 -0
  86. recusal-0.1.0/tests/test_subversion_adapters.py +137 -0
  87. recusal-0.1.0/tests/test_subversion_audit.py +143 -0
  88. recusal-0.1.0/tests/test_subversion_classify.py +66 -0
  89. recusal-0.1.0/tests/test_subversion_hook.py +365 -0
  90. recusal-0.1.0/tests/test_subversion_kernel.py +176 -0
  91. recusal-0.1.0/tests/test_verdict.py +76 -0
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Recusal governs its own repository.
4
+
5
+ This is a real Claude Code ``PreToolUse`` hook, registered via
6
+ ``.claude/settings.json`` (copy ``.claude/settings.json.example`` to activate it, a
7
+ deliberate step Claude Code asks you to confirm). Once registered, when a Claude Code
8
+ session works on *this* repo, every tool call is adjudicated here first: destructive
9
+ shell commands, writes to
10
+ secret/protected files, and edits *or deletions* of the gate's own configuration
11
+ are refused before they run, even under bypassPermissions.
12
+
13
+ A substring/regex deny-list is a *baseline*, not a guarantee, a determined command
14
+ can be obfuscated past any literal matcher, and an allowlist posture is stronger. What
15
+ this proves is the seam, an independent gate that refuses before the tool runs and
16
+ that guards its own kill-switch, not that this exact list is exhaustive.
17
+
18
+ Hardening notes (what this hook does that a naive deny-list does not):
19
+
20
+ - **Uniform de-obfuscation.** Every check, destructive, secret, *and* self-protect,
21
+ runs against both the raw normalized command and a de-obfuscated form (quotes,
22
+ backticks, backslashes, ``$IFS`` word-splitting stripped). Self-protect and secret
23
+ checks additionally run against a path-normalized de-obfuscated form, so
24
+ ``.cla""ude/hooks`` or ``.claude\\hooks`` cannot walk past the kill-switch guard.
25
+ - **Delete, not just edit.** The kill-switch is protected against ``rm``/``mv``/``del``
26
+ and friends, not only ``Write``/``Edit`` and shell redirects, deleting the hook
27
+ disables it exactly like editing it.
28
+ - **Pipe into any interpreter.** ``| sh`` and ``| python``/``perl``/``ruby``/``node``/
29
+ ``php``/``pwsh`` are all refused, not just POSIX shells.
30
+ - **Reverse shells.** ``/dev/tcp`` redirects, ``nc -e`` and ``socat EXEC:`` back-connects refused.
31
+ - **Best-effort symlink resolution.** A tool-based write (``Write``/``Edit`` or an MCP
32
+ filesystem tool) whose innocent-looking path resolves through a symlink onto a protected
33
+ control path is refused (``_resolves_into_protected``), closing the classic
34
+ ``notes.txt`` -> ``.claude/settings.json`` TOCTOU. Best-effort: a not-yet-created link can't
35
+ be resolved, and ``Bash`` fragments stay string-matched, so an allowlist is still stronger.
36
+
37
+ The honest limit is unchanged: a deny-list cannot catch a command whose *name* is
38
+ built at runtime (hex/char-codes/``eval`` of decoded data) or code run inside a bare
39
+ interpreter (``python script.py``). For high-stakes tools use allowlist mode,
40
+ ``recusal.claude_code.allowlist_policy`` (see ``docs/COOKBOOK.md`` recipe 11), which
41
+ refuses both. That boundary is pinned as a test on each side.
42
+ """
43
+
44
+ import os
45
+ import re
46
+ import sys
47
+
48
+ # Make `recusal` importable from the repo without an install.
49
+ _REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
50
+ sys.path.insert(0, _REPO)
51
+
52
+ from recusal import Finding # noqa: E402
53
+ from recusal.claude_code import run_pretooluse_hook # noqa: E402
54
+
55
+ # Interpreters that execute piped stdin (or a process-substituted download) as code.
56
+ _INTERP = r"(?:sh|bash|zsh|dash|ksh|fish|python\d*|perl|ruby|node|php|pwsh|powershell)"
57
+
58
+ # Markers matched on a whitespace-normalized, lowercased command, so "rm -rf" == "rm -rf".
59
+ _DESTRUCTIVE = (
60
+ "git push --force",
61
+ "git push -f",
62
+ "reset --hard",
63
+ ":(){", # fork bomb
64
+ "mkfs",
65
+ "dd if=",
66
+ "dd of=",
67
+ "> /dev/sd",
68
+ )
69
+ _CHMOD_WORLD = re.compile(r"\bchmod\b.*-\w*r.*\b0?777\b") # recursive chmod to 777
70
+ _GIT_FORCE_REFSPEC = re.compile(r"\bgit\s+push\b.*\s\+\S") # force-push via +refspec
71
+ # curl/wget piped or process-substituted into ANY interpreter (not just sh/bash).
72
+ _PIPE_TO_SHELL = re.compile(r"(curl|wget)\b.*(\|\s*" + _INTERP + r"\b|<\(\s*(curl|wget))")
73
+ _PROCESS_SUB_TO_SHELL = re.compile(r"\b" + _INTERP + r"\b\s*<\(\s*(curl|wget)\b")
74
+ # Piping ANY output into a bare interpreter (defeats `... | base64 -d | sh|python|...`).
75
+ _PIPE_INTO_SHELL = re.compile(r"\|\s*" + _INTERP + r"\b")
76
+ # Reverse / bind shells: /dev/tcp back-connect, nc/ncat -e, interactive bash back-connect,
77
+ # and socat with an EXEC:/SYSTEM: payload (its shell-spawning form).
78
+ _REVERSE_SHELL = re.compile(
79
+ r"/dev/(tcp|udp)/|\b(nc|ncat|netcat)\b[^|&;]{0,256}-\w*e|\b(bash|sh)\b\s+-\w*i\b"
80
+ r"|\bsocat\b[^|&;]{0,256}(exec|system):"
81
+ )
82
+ # Destructive commands beyond `rm`: POSIX (shred, find -delete / -exec rm, truncate -s 0,
83
+ # unlink), git working-tree loss (clean -f, checkout -- , reset --hard), and Windows /
84
+ # PowerShell (rd/rmdir /s, del /s|/q, Remove-Item -Recurse).
85
+ _EXTRA_DESTRUCTIVE = re.compile(
86
+ r"\bshred\b"
87
+ r"|\bunlink\b"
88
+ r"|\bfind\b[^|&;]{0,256}\s-delete\b"
89
+ r"|\bfind\b[^|&;]{0,256}-exec[^|&;]{0,256}\b(rm|shred|unlink|truncate|dd|mv)\b"
90
+ r"|\btruncate\b[^|&;]{0,256}-s\s*0\b"
91
+ r"|\b(rd|rmdir)\b[^|&;]{0,256}\s/s\b"
92
+ r"|\bdel\b[^|&;]{0,256}\s/[sq]\b"
93
+ r"|\bremove-item\b[^|&;]{0,256}-recurse\b"
94
+ r"|\bgit\s+clean\b[^|&;]{0,256}-[a-z]{0,8}f" # git clean -f / -fd / -fdx (untracked loss)
95
+ r"|\bgit\s+checkout\b[^|&;]{0,256}\s--(\s|$)" # git checkout -- (discard working tree)
96
+ )
97
+ _MAX_CMD_LEN = 4096 # commands longer than this are refused, not adjudicated (DoS guard)
98
+ # \S{0,256} (bounded), not \S*, so a long run of '>' can't make this O(n^2) (ReDoS guard).
99
+ _REDIRECT_TO_SECRET = re.compile(
100
+ r">>?\s*\S{0,256}(\.env(?:\.[^\s'\"/\\]{1,64})?|\.pem|\.key|\.p12|id_rsa|id_ed25519)"
101
+ )
102
+ _WRITE_LIKE = re.compile(
103
+ r"\b(tee|sed\s+-i|python\d*\s+-c|perl\s+-e|ruby\s+-e|node\s+-e|cp|mv|copy|xcopy|robocopy|install|rsync|truncate|set-content|add-content|out-file)\b|>>?"
104
+ )
105
+ _SECRET_PATH_IN_CMD = re.compile(
106
+ r"(\.env(?:\.[^\s'\"/\\]+)?|\.pem\b|\.key\b|\.p12\b|id_rsa\b|id_ed25519\b)"
107
+ )
108
+ # Verbs that write, move, or delete a path (used to guard the kill-switch against being
109
+ # overwritten OR removed). Bare interpreters are excluded: `python .claude/hooks/x.py`
110
+ # only *reads* the hook. But their inline-code forms (`python -c`, `perl -e`, ...) can
111
+ # open(...,'w') a file, so those are included. `>`/`>>` (redirect-truncate) count.
112
+ _SELF_PROTECT_VERB = re.compile(
113
+ r"\b(rm|unlink|shred|truncate|mv|move|cp|copy|xcopy|robocopy|ren|rename|tee|dd|sed"
114
+ r"|install|rsync|ln|mklink|set-content|add-content|out-file|remove-item|del|rd|rmdir"
115
+ r"|chmod|chown|chattr)\b"
116
+ r"|\b(python\d*\s+-c|perl\s+-e|ruby\s+-e|node\s+-e)\b"
117
+ r"|>>?"
118
+ )
119
+
120
+ # `git config core.hooksPath <dir>` redirects git's hooks to an attacker-controlled
121
+ # directory -> arbitrary code exec on the next commit/checkout. A gate-disabling vector.
122
+ _GIT_HOOK_REDIRECT = re.compile(r"\bgit\s+config\b[^|&;]{0,256}core\.hookspath\b")
123
+
124
+ _SECRET_BASENAMES = {".env", "id_rsa", "id_ed25519"}
125
+ _SECRET_SUFFIXES = (".pem", ".key", ".p12")
126
+ # The gate's own kill-switch: settings that can disable hooks (settings.json /
127
+ # settings.local.json, matched by the ".claude/settings" prefix) and the hook scripts,
128
+ # plus `.git/hooks/` (writing a git hook is another run-code-on-commit vector).
129
+ _SELF_PROTECT = (".claude/settings", ".claude/hooks")
130
+ _PROTECTED_PATHS = _SELF_PROTECT + (".git/hooks",)
131
+
132
+ # A non-Bash tool that carries a shell command under one of these keys (an MCP shell, a
133
+ # task runner) gets the same command analysis as Bash, so it can't be a second, ungated
134
+ # shell. Kept narrow to keys that clearly imply shell execution (low false-positive risk).
135
+ # Matched case-insensitively and at ANY nesting depth (see ``_iter_command_values``) so a
136
+ # `"Command"` casing or a `{"payload": {"command": ...}}` wrapper can't smuggle a shell past.
137
+ _COMMAND_KEYS = frozenset({"command", "cmd", "shell", "script"})
138
+ # Built-in tools that only read; they may reference a protected path freely. Any OTHER
139
+ # non-Bash tool (Write/Edit, or an MCP filesystem tool) that touches a protected path is
140
+ # refused by the generic kill-switch guard. Names are compared lowercased.
141
+ _READ_ONLY_TOOLS = frozenset(
142
+ {"read", "glob", "grep", "ls", "notebookread", "webfetch", "websearch", "todowrite", "task"}
143
+ )
144
+
145
+
146
+ def _norm(cmd: str) -> str:
147
+ return re.sub(r"\s+", " ", cmd).strip().lower()
148
+
149
+
150
+ def _deobfuscate(cmd: str) -> str:
151
+ # Catch simple token-splitting obfuscations: r''m, g""it, cu\rl, rm${IFS}-rf, etc.
152
+ s = cmd.replace("'", "").replace('"', "").replace("`", "").replace("\\", "")
153
+ return re.sub(r"\$\{?ifs\}?", " ", s) # $IFS / ${IFS} word-splitting -> space
154
+
155
+
156
+ def _norm_path(s: str) -> str:
157
+ """Path-normalize a command string: backslash -> slash, collapse repeated slashes."""
158
+ return re.sub(r"/+", "/", s.replace("\\", "/"))
159
+
160
+
161
+ def _deobf_path(s: str) -> str:
162
+ """De-obfuscate *and* path-normalize: strip quotes/backticks and $IFS, then treat
163
+ backslashes as separators, so `.cla""ude/hooks` and `.claude\\hooks` both surface as
164
+ `.claude/hooks`. (Distinct from ``_deobfuscate``, which removes backslashes entirely
165
+ for verb matching, that would destroy Windows path separators.)"""
166
+ s = s.replace("'", "").replace('"', "").replace("`", "")
167
+ s = re.sub(r"\$\{?ifs\}?", " ", s)
168
+ return _norm_path(s)
169
+
170
+
171
+ def _resolves_into_protected(path: str) -> bool:
172
+ """Best-effort symlink resolution for the innocent-name -> protected-target TOCTOU case:
173
+ an attacker creates ``notes.txt`` -> ``.claude/settings.json`` and writes the innocent
174
+ *name*, whose string carries no protected segment. Resolve the path and, if a symlink
175
+ lands it on a protected control path, refuse. The resolved path is made relative to the
176
+ working directory before matching, so the repo's own location on disk can't trip a false
177
+ positive. Best-effort by nature: a link that does not exist at hook time cannot be
178
+ resolved, so an allowlist of writable paths stays the real defense (see SECURITY.md)."""
179
+ if not path:
180
+ return False
181
+ try:
182
+ rel = os.path.relpath(os.path.realpath(path), os.getcwd())
183
+ except (OSError, ValueError):
184
+ return False # unresolvable / different drive -> fall back to the string checks
185
+ norm = _norm_path(rel.lower())
186
+ return any(seg in norm for seg in _PROTECTED_PATHS)
187
+
188
+
189
+ def _search_any(rx: "re.Pattern[str]", variants) -> bool:
190
+ return any(rx.search(v) for v in variants)
191
+
192
+
193
+ def _rm_recursive(cmd: str) -> bool:
194
+ """True if the command is a recursive `rm` (any flag order). Force is not required:
195
+ `rm -r <dir>` destroys a tree just as `rm -rf` does."""
196
+ if not re.search(r"\brm\b", cmd):
197
+ return False
198
+ short = "".join(re.findall(r"(?:^|\s)-([a-z]+)", cmd)) # bundled short flags
199
+ return "r" in short or "--recursive" in cmd
200
+
201
+
202
+ def _iter_strings(obj) -> "list[str]":
203
+ """Every string value nested anywhere in a tool_input (dict/list/scalar)."""
204
+ out: "list[str]" = []
205
+ if isinstance(obj, str):
206
+ out.append(obj)
207
+ elif isinstance(obj, dict):
208
+ for v in obj.values():
209
+ out.extend(_iter_strings(v))
210
+ elif isinstance(obj, (list, tuple)):
211
+ for v in obj:
212
+ out.extend(_iter_strings(v))
213
+ return out
214
+
215
+
216
+ def _iter_command_values(obj) -> "list[str]":
217
+ """Every value that sits under a command-like key (``command``/``cmd``/``shell``/
218
+ ``script``, case-insensitive) anywhere in a tool_input, as a shell string. A list
219
+ value is treated as an argv vector and joined, so ``{"command": ["rm","-rf","/repo"]}``
220
+ is adjudicated exactly like ``"rm -rf /repo"``. This is what stops an MCP shell from
221
+ smuggling a command past the gate via casing (``Command``) or nesting (``args.command``)."""
222
+ out: "list[str]" = []
223
+ if isinstance(obj, dict):
224
+ for k, v in obj.items():
225
+ if isinstance(k, str) and k.lower() in _COMMAND_KEYS:
226
+ if isinstance(v, str) and v:
227
+ out.append(v)
228
+ elif isinstance(v, (list, tuple)):
229
+ joined = " ".join(str(x) for x in v if isinstance(x, (str, int, float)))
230
+ if joined.strip():
231
+ out.append(joined)
232
+ else:
233
+ out.extend(_iter_command_values(v))
234
+ else:
235
+ out.extend(_iter_command_values(v))
236
+ elif isinstance(obj, (list, tuple)):
237
+ for v in obj:
238
+ out.extend(_iter_command_values(v))
239
+ return out
240
+
241
+
242
+ def _analyze_command(raw: str) -> list:
243
+ """Adjudicate a single shell command string. Used for the Bash tool AND for any other
244
+ tool that carries a command under a `_COMMAND_KEYS` field, so an MCP shell can't be a
245
+ second, ungated shell."""
246
+ findings: list = []
247
+ if len(raw) > _MAX_CMD_LEN:
248
+ findings.append(
249
+ Finding.fail(
250
+ "command_too_long",
251
+ severity="CRITICAL",
252
+ message=f"refusing a {len(raw)}-char command the gate cannot adjudicate safely",
253
+ command=raw[:120],
254
+ )
255
+ )
256
+ return findings
257
+ cmd = _norm(raw)
258
+ cmd_deobf = _deobfuscate(cmd)
259
+ # Command variants for verb/marker matching, and path variants for path matching.
260
+ # Three path readings so neither a Windows separator (\ -> /), a quote split, nor a
261
+ # POSIX shell escape (\ dropped) can hide a protected path:
262
+ # _norm_path(cmd) -> `.claude\hooks` (Windows) => `.claude/hooks`
263
+ # _deobf_path(cmd) -> `.cla""ude/hooks` => `.claude/hooks`
264
+ # _norm_path(cmd_deobf) -> `.cl\aude/hooks` (escape) => `.claude/hooks`
265
+ variants = (cmd, cmd_deobf)
266
+ path_variants = (_norm_path(cmd), _deobf_path(cmd), _norm_path(cmd_deobf))
267
+
268
+ markers = [m for m in _DESTRUCTIVE if any(m in v for v in variants)]
269
+ if any(_rm_recursive(v) for v in variants):
270
+ markers.append("rm -r")
271
+ if _search_any(_CHMOD_WORLD, variants):
272
+ markers.append("chmod -R 777")
273
+ if _search_any(_GIT_FORCE_REFSPEC, variants):
274
+ markers.append("git push +force")
275
+ if _search_any(_GIT_HOOK_REDIRECT, variants):
276
+ markers.append("git hooksPath redirect")
277
+ if _search_any(_EXTRA_DESTRUCTIVE, variants):
278
+ markers.append("destructive")
279
+ if markers:
280
+ findings.append(
281
+ Finding.fail(
282
+ "destructive_command",
283
+ severity="CRITICAL",
284
+ message=f"refusing destructive command ({', '.join(sorted(set(markers)))})",
285
+ command=raw,
286
+ )
287
+ )
288
+ if (
289
+ _search_any(_PIPE_TO_SHELL, variants)
290
+ or _search_any(_PROCESS_SUB_TO_SHELL, variants)
291
+ or _search_any(_PIPE_INTO_SHELL, variants)
292
+ ):
293
+ findings.append(
294
+ Finding.fail(
295
+ "pipe_to_shell",
296
+ severity="CRITICAL",
297
+ message="refusing to pipe output straight into a shell/interpreter",
298
+ command=raw,
299
+ )
300
+ )
301
+ if _search_any(_REVERSE_SHELL, variants):
302
+ findings.append(
303
+ Finding.fail(
304
+ "reverse_shell",
305
+ severity="CRITICAL",
306
+ message="refusing a command that looks like a reverse/bind shell",
307
+ command=raw,
308
+ )
309
+ )
310
+ if _search_any(_REDIRECT_TO_SECRET, variants):
311
+ findings.append(
312
+ Finding.fail(
313
+ "secret_redirect",
314
+ severity="CRITICAL",
315
+ message="refusing a shell redirect that writes to a secret file",
316
+ command=raw,
317
+ )
318
+ )
319
+ if _search_any(_WRITE_LIKE, variants) and _search_any(_SECRET_PATH_IN_CMD, variants):
320
+ findings.append(
321
+ Finding.fail(
322
+ "secret_write_via_bash",
323
+ severity="CRITICAL",
324
+ message="refusing a Bash command that appears to write a secret file",
325
+ command=raw,
326
+ )
327
+ )
328
+ if _search_any(_SELF_PROTECT_VERB, variants) and any(
329
+ seg in pv for pv in path_variants for seg in _PROTECTED_PATHS
330
+ ):
331
+ findings.append(
332
+ Finding.fail(
333
+ "self_protection",
334
+ severity="CRITICAL",
335
+ message="refusing a command that edits or removes a protected control path "
336
+ "(gate config/hook or git hooks)",
337
+ command=raw,
338
+ )
339
+ )
340
+ return findings
341
+
342
+
343
+ def policy(tool_name: str, tool_input: dict) -> list:
344
+ """Refuse destructive shell, writes to protected files, and self-disabling edits.
345
+
346
+ Coverage is tool-agnostic where it matters: the Bash command analysis also runs on any
347
+ tool that carries a command under a `_COMMAND_KEYS` field (an MCP shell), and a generic
348
+ kill-switch guard refuses any non-read tool (Write/Edit or an MCP filesystem tool) that
349
+ touches a protected control path, so the gate cannot be disabled through a side channel.
350
+ """
351
+ findings: list = []
352
+ tl = tool_name.lower()
353
+
354
+ if tool_name == "Bash":
355
+ findings.extend(_analyze_command(str(tool_input.get("command", ""))))
356
+ else:
357
+ # A non-Bash tool that carries a shell command (MCP shell, task runner) is analyzed
358
+ # exactly like Bash, so it cannot become a second, ungated shell. Command-like keys
359
+ # are found case-insensitively and at any nesting depth, and argv arrays are joined.
360
+ for val in _iter_command_values(tool_input):
361
+ findings.extend(_analyze_command(val))
362
+
363
+ if tool_name in ("Write", "Edit", "MultiEdit", "NotebookEdit"):
364
+ path = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "")
365
+ base = os.path.basename(path).lower()
366
+ low_path = path.lower()
367
+ if (
368
+ base in _SECRET_BASENAMES
369
+ or base.startswith(".env.")
370
+ or base.endswith(".env") # .env, prod.env, production.env, ...
371
+ or low_path.endswith(_SECRET_SUFFIXES)
372
+ ):
373
+ findings.append(
374
+ Finding.fail(
375
+ "protected_file",
376
+ severity="CRITICAL",
377
+ message=f"refusing write to a protected/secret file: {path}",
378
+ path=path,
379
+ )
380
+ )
381
+ elif not any(
382
+ seg in _norm_path(low_path) or seg in _deobf_path(low_path) for seg in _PROTECTED_PATHS
383
+ ) and _resolves_into_protected(path):
384
+ # Innocent name, protected target: a symlink resolves the write onto the
385
+ # gate's own config/hook. Only reached when the *literal* path carries no
386
+ # protected segment (a direct reference is caught by the kill-switch guard
387
+ # below with an accurate message), so the "via a symlink" reason is truthful
388
+ # rather than firing on a plain path that merely lands on a protected target.
389
+ findings.append(
390
+ Finding.fail(
391
+ "self_protection",
392
+ severity="CRITICAL",
393
+ message=f"refusing a write whose path resolves via a symlink onto a "
394
+ f"protected control path: {path}",
395
+ path=path,
396
+ )
397
+ )
398
+
399
+ # Generic kill-switch guard: any non-Bash tool that is not a known read-only builtin
400
+ # (Write/Edit, or an arbitrary MCP filesystem tool) is refused if ANY of its string
401
+ # inputs references a protected control path, in either path reading. Bash is excluded
402
+ # (its verb-gated analysis correctly allows reads like `cat .claude/settings.json`).
403
+ if tool_name != "Bash" and tl not in _READ_ONLY_TOOLS:
404
+ for s in _iter_strings(tool_input):
405
+ low = s.lower()
406
+ # A path-like string also gets best-effort symlink resolution, so an MCP
407
+ # filesystem tool can't reach a protected target through an innocent-named link,
408
+ # including a *bare* filename with no separator (`notes.txt` -> `.claude/
409
+ # settings.json`), which a separator-only guard misses even though `Write`
410
+ # resolves it. Path-like = short and either carries a separator (dir/file, may
411
+ # contain spaces) OR is a single whitespace-free token (a bare filename). Prose/
412
+ # content blobs carry internal whitespace and no separator, so they are excluded
413
+ # from the stat walk.
414
+ stripped = s.strip()
415
+ path_like = (
416
+ len(s) <= 1024
417
+ and stripped != ""
418
+ and ("/" in stripped or "\\" in stripped or not re.search(r"\s", stripped))
419
+ )
420
+ if any(
421
+ seg in _norm_path(low) or seg in _deobf_path(low) for seg in _PROTECTED_PATHS
422
+ ) or (path_like and _resolves_into_protected(s)):
423
+ findings.append(
424
+ Finding.fail(
425
+ "self_protection",
426
+ severity="CRITICAL",
427
+ message=f"refusing a `{tool_name}` call that targets a protected "
428
+ f"control path (gate config/hook or git hooks): {s}",
429
+ path=s,
430
+ )
431
+ )
432
+ break
433
+
434
+ return findings
435
+
436
+
437
+ if __name__ == "__main__":
438
+ run_pretooluse_hook(policy)
@@ -0,0 +1,16 @@
1
+ {
2
+ "_comment": "Copy to .claude/settings.json to have Recusal govern this repo's Claude Code sessions. Installing a permission-changing hook is a deliberate step; Claude Code will ask you to confirm it. The command probes python3/python/py (macOS/Linux/Windows) and exits 2 -- a BLOCKING hook error -- if none work, so a missing interpreter fails closed, not open. On Windows, Claude Code runs hook commands under Git Bash.",
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": ".*",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "for p in python3 python py; do \"$p\" -c '' 2>/dev/null && exec \"$p\" \"$CLAUDE_PROJECT_DIR/.claude/hooks/recusal_gate.py\"; done; echo 'recusal gate: no working python interpreter; failing closed' >&2; exit 2"
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,4 @@
1
+ # Normalize line endings — LF in the repo, native on checkout.
2
+ * text=auto eol=lf
3
+ *.py text eol=lf
4
+ *.md text eol=lf
@@ -0,0 +1,50 @@
1
+ name: Bug report
2
+ description: Something in Recusal behaves incorrectly (a wrong verdict, a misfiring gate, a crash).
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for reporting. Recusal is a *governance* library, so correctness bugs
9
+ are high priority — a wrong `PASS` or a wrong `FAIL` is the worst thing it can do.
10
+ A minimal, runnable reproduction gets a fix fastest.
11
+ - type: textarea
12
+ id: what-happened
13
+ attributes:
14
+ label: What happened
15
+ description: What did Recusal decide, and what did you expect instead?
16
+ placeholder: |
17
+ compute_verdict([...]) returned PASS, but I expected FAIL because ...
18
+ validations:
19
+ required: true
20
+ - type: textarea
21
+ id: repro
22
+ attributes:
23
+ label: Minimal reproduction
24
+ description: The smallest snippet that shows the bug. Include the findings/evidence you passed in.
25
+ render: python
26
+ placeholder: |
27
+ from recusal import compute_verdict, Finding
28
+ v = compute_verdict([Finding.fail("x", severity="CRITICAL")])
29
+ print(v.decision) # got PASS, expected FAIL
30
+ validations:
31
+ required: true
32
+ - type: input
33
+ id: version
34
+ attributes:
35
+ label: Recusal version
36
+ placeholder: "0.1.0 (or a git SHA)"
37
+ validations:
38
+ required: true
39
+ - type: input
40
+ id: python
41
+ attributes:
42
+ label: Python version
43
+ placeholder: "3.11"
44
+ validations:
45
+ required: true
46
+ - type: textarea
47
+ id: context
48
+ attributes:
49
+ label: Surface / context (optional)
50
+ description: Claude Code hook, Agent SDK loop, Managed Agents, release gate, or direct `compute_verdict`?
@@ -0,0 +1,8 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Security vulnerability
4
+ url: https://github.com/philpaz/recusal/security/advisories/new
5
+ about: Please report security issues privately, not as a public issue. See SECURITY.md.
6
+ - name: Question / discussion
7
+ url: https://github.com/philpaz/recusal/discussions
8
+ about: For usage questions and design discussion (not bugs).
@@ -0,0 +1,43 @@
1
+ name: Feature request
2
+ description: Propose a new check, adapter, or capability.
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Recusal is deliberately small. Before proposing, please skim the
9
+ [CONSTITUTION](../blob/main/CONSTITUTION.md) and
10
+ [CONTRIBUTING](../blob/main/CONTRIBUTING.md): new capability is almost always a
11
+ **new check** (a function returning `Finding`s) or a **thin adapter** — not a
12
+ change to the kernel, and never a model in the decision path.
13
+ - type: textarea
14
+ id: problem
15
+ attributes:
16
+ label: What problem does this solve?
17
+ description: The agent failure mode or workflow gap you're hitting.
18
+ validations:
19
+ required: true
20
+ - type: dropdown
21
+ id: kind
22
+ attributes:
23
+ label: What kind of addition is this?
24
+ options:
25
+ - A new check (data/action → Finding)
26
+ - A new adapter (Verdict → some framework's allow/deny)
27
+ - A new failure-taxonomy class
28
+ - A docs/example improvement
29
+ - Something that changes the core (please justify carefully)
30
+ validations:
31
+ required: true
32
+ - type: textarea
33
+ id: sketch
34
+ attributes:
35
+ label: Rough API sketch (optional)
36
+ render: python
37
+ - type: checkboxes
38
+ id: principles
39
+ attributes:
40
+ label: Fit with the constitution
41
+ options:
42
+ - label: This keeps the verdict path deterministic (no model inside `compute_verdict`).
43
+ - label: This doesn't add a runtime dependency to `recusal/`.
@@ -0,0 +1,31 @@
1
+ <!--
2
+ Thanks for contributing to Recusal. Keeping the kernel small and deterministic is
3
+ the whole point, please make sure your change fits the constitution before opening.
4
+ -->
5
+
6
+ ## What this changes
7
+
8
+ <!-- One or two sentences. Link any issue it closes: "Closes #123". -->
9
+
10
+ ## Type of change
11
+
12
+ - [ ] New check (a function returning `Finding`s)
13
+ - [ ] New adapter (turns a `Verdict` into a framework's allow/deny shape)
14
+ - [ ] New failure-taxonomy class
15
+ - [ ] Bug fix (wrong verdict / misfiring gate / crash)
16
+ - [ ] Docs / examples
17
+ - [ ] Core change (please justify, these are rare)
18
+
19
+ ## Constitution checklist
20
+
21
+ <!-- See CONSTITUTION.md and CONTRIBUTING.md. PRs that miss these usually can't merge. -->
22
+
23
+ - [ ] No model call in the verdict/decision path (evidence-gathering upstream is fine).
24
+ - [ ] No new runtime dependency in `recusal/` (standard library only).
25
+ - [ ] `compute_verdict` is unchanged, **or** the change is justified above.
26
+ - [ ] Checks are pure (no I/O); structured detail goes in `context`, not the message.
27
+
28
+ ## Tests
29
+
30
+ - [ ] Added/updated tests, including edge cases (a check without edge-case tests won't merge).
31
+ - [ ] `ruff check .`, `ruff format --check .`, `mypy`, and `pytest -q` all pass locally.
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ # Actions are tag-pinned (reference-architecture convention). For production
17
+ # supply-chain hardening, pin each `uses:` to an immutable commit SHA.
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - name: Install
23
+ run: pip install -e ".[dev]"
24
+ - name: Lint
25
+ run: ruff check .
26
+ - name: Format
27
+ run: ruff format --check .
28
+ - name: Type-check
29
+ run: mypy
30
+ - name: Test
31
+ run: pytest -q