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.
- recusal-0.1.0/.claude/hooks/recusal_gate.py +438 -0
- recusal-0.1.0/.claude/settings.json.example +16 -0
- recusal-0.1.0/.gitattributes +4 -0
- recusal-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +50 -0
- recusal-0.1.0/.github/ISSUE_TEMPLATE/config.yml +8 -0
- recusal-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +43 -0
- recusal-0.1.0/.github/PULL_REQUEST_TEMPLATE.md +31 -0
- recusal-0.1.0/.github/workflows/ci.yml +31 -0
- recusal-0.1.0/.github/workflows/release.yml +74 -0
- recusal-0.1.0/.gitignore +41 -0
- recusal-0.1.0/.pre-commit-config.yaml +14 -0
- recusal-0.1.0/CHANGELOG.md +114 -0
- recusal-0.1.0/CITATION.cff +27 -0
- recusal-0.1.0/CODE_OF_CONDUCT.md +122 -0
- recusal-0.1.0/CONSTITUTION.md +91 -0
- recusal-0.1.0/CONTRIBUTING.md +56 -0
- recusal-0.1.0/LICENSE +201 -0
- recusal-0.1.0/PKG-INFO +542 -0
- recusal-0.1.0/README.md +311 -0
- recusal-0.1.0/SECURITY.md +82 -0
- recusal-0.1.0/assets/apple-touch-icon.png +0 -0
- recusal-0.1.0/assets/banner-gate-strip-light.png +0 -0
- recusal-0.1.0/assets/banner-gate-strip.png +0 -0
- recusal-0.1.0/assets/banner-gate.png +0 -0
- recusal-0.1.0/assets/banner-ledger.png +0 -0
- recusal-0.1.0/assets/banner-seal-light.png +0 -0
- recusal-0.1.0/assets/banner-seal.png +0 -0
- recusal-0.1.0/assets/favicon-16.png +0 -0
- recusal-0.1.0/assets/favicon-32.png +0 -0
- recusal-0.1.0/assets/favicon-48.png +0 -0
- recusal-0.1.0/assets/favicon.ico +0 -0
- recusal-0.1.0/assets/icon-512.png +0 -0
- recusal-0.1.0/assets/social-preview.png +0 -0
- recusal-0.1.0/conftest.py +2 -0
- recusal-0.1.0/docs/COOKBOOK.md +361 -0
- recusal-0.1.0/docs/EVIDENCE.md +114 -0
- recusal-0.1.0/docs/EXAMPLE.md +144 -0
- recusal-0.1.0/docs/EXTENDING.md +130 -0
- recusal-0.1.0/docs/FAQ.md +157 -0
- recusal-0.1.0/docs/HOWTO.md +220 -0
- recusal-0.1.0/docs/LANDSCAPE.md +82 -0
- recusal-0.1.0/docs/PROVEN.md +109 -0
- recusal-0.1.0/docs/README.md +54 -0
- recusal-0.1.0/docs/REFERENCES.md +232 -0
- recusal-0.1.0/docs/WHY.md +151 -0
- recusal-0.1.0/examples/README.md +42 -0
- recusal-0.1.0/examples/agent_loop.py +76 -0
- recusal-0.1.0/examples/allowlist_gate.py +99 -0
- recusal-0.1.0/examples/audit_demo.py +56 -0
- recusal-0.1.0/examples/classify_demo.py +37 -0
- recusal-0.1.0/examples/claude_agent_live.py +150 -0
- recusal-0.1.0/examples/claude_code_gate.py +61 -0
- recusal-0.1.0/examples/claude_refusal.py +85 -0
- recusal-0.1.0/examples/gallery.py +91 -0
- recusal-0.1.0/examples/injection_quarantine.py +104 -0
- recusal-0.1.0/examples/quickstart.py +38 -0
- recusal-0.1.0/examples/scenarios.py +123 -0
- recusal-0.1.0/pyproject.toml +64 -0
- recusal-0.1.0/recusal/__init__.py +69 -0
- recusal-0.1.0/recusal/audit.py +177 -0
- recusal-0.1.0/recusal/checks.py +242 -0
- recusal-0.1.0/recusal/classify.py +207 -0
- recusal-0.1.0/recusal/claude.py +119 -0
- recusal-0.1.0/recusal/claude_code.py +310 -0
- recusal-0.1.0/recusal/evidence.py +228 -0
- recusal-0.1.0/recusal/gates.py +172 -0
- recusal-0.1.0/recusal/py.typed +0 -0
- recusal-0.1.0/tests/test_audit.py +121 -0
- recusal-0.1.0/tests/test_audit_hardening.py +68 -0
- recusal-0.1.0/tests/test_checks.py +71 -0
- recusal-0.1.0/tests/test_checks_edge.py +84 -0
- recusal-0.1.0/tests/test_classify.py +123 -0
- recusal-0.1.0/tests/test_claude.py +58 -0
- recusal-0.1.0/tests/test_claude_code.py +73 -0
- recusal-0.1.0/tests/test_claude_code_allowlist.py +161 -0
- recusal-0.1.0/tests/test_claude_code_robust.py +103 -0
- recusal-0.1.0/tests/test_coerce_strict.py +23 -0
- recusal-0.1.0/tests/test_contract_invariants.py +102 -0
- recusal-0.1.0/tests/test_demos_smoke.py +67 -0
- recusal-0.1.0/tests/test_dogfood.py +134 -0
- recusal-0.1.0/tests/test_dogfood_redteam.py +186 -0
- recusal-0.1.0/tests/test_evidence.py +43 -0
- recusal-0.1.0/tests/test_gates.py +98 -0
- recusal-0.1.0/tests/test_gates_completeness.py +53 -0
- recusal-0.1.0/tests/test_scenarios.py +82 -0
- recusal-0.1.0/tests/test_subversion_adapters.py +137 -0
- recusal-0.1.0/tests/test_subversion_audit.py +143 -0
- recusal-0.1.0/tests/test_subversion_classify.py +66 -0
- recusal-0.1.0/tests/test_subversion_hook.py +365 -0
- recusal-0.1.0/tests/test_subversion_kernel.py +176 -0
- 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,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
|