Recusal

Latest version: v0.5.12

Safety actively analyzes 971914 Python packages for vulnerabilities to keep your Python projects secure.

Scan your dependencies

Page 1 of 4

0.5.12

Hardens the 0.5.11 package-manager self-protection: the matcher now covers the valid,
statically visible command forms the flat regex missed, and the customization surface
gets explicit contracts. Narrow scope, no API change.

Security
- **Package-manager matcher widened to option/value and launcher forms.** A bounded
token walk now runs in union with the 0.5.11 regex (either matching refuses, so the
addition can never regress existing coverage). It pairs a global option with its
separate value token, which a flat regex cannot, and strips Windows launcher
suffixes: `pip --python .venv uninstall recusal`, `python -m pip --python .venv
install recusal`, `uv --project . remove recusal`, `uv --directory . remove recusal`,
`uv --offline remove recusal`, `uv pip --python .venv uninstall recusal`, `pip.exe
uninstall recusal`, and `python.exe -m pip uninstall recusal` are all refused; every
one of them deferred in 0.5.11. Scope stays the documented pip + uv surface: pipx,
`uv tool`, conda, and poetry are considered and excluded (isolated tool environments
or foreign resolvers, not the import path the gate's venv resolves), and the named
ceiling is unchanged (`pip install -e .`, `-r requirements.txt` remain unreadable to
a string matcher; the pinned venv stays the real defense).
- **Protected package names are canonical distribution identities (PEP 503).**
Configured and parsed names are compared case-insensitively with runs of `-`/`_`/`.`
collapsed, so `protected_packages=("My.Gate",)` now refuses `pip uninstall my_gate`,
`my-gate`, and `MY.GATE` alike. In 0.5.11 a mixed-case configured name silently
matched nothing (the pattern kept its case while commands are matched lowercased),
so the advertised customization point failed open for any non-lowercase input.
- **Empty protected names are a construction-time `ValueError`.**
`protected_packages=("",)` (or a separator-only name) previously compiled to a
pattern matching every package mutation, a silent deny-all; it now raises at
`deny_list_policy()` construction and in `analyze_command`.
- **Substring matching is now an explicit contract.** A mutating package command whose
argument merely contains a protected name (`notrecusal`, `recusal-helper`,
`./fake-recusal`, `recusal-replacement.tar.gz`) is refused toward safety, by design:
a name-containing source is a plausible shadow of the gate, and the cost of the
safe-side false positive is a deferral to a human. Previously this was an accidental
regex property, now documented and pinned both directions (ordinary package
commands, `pip install requests`, `uv add httpx`, `pip show recusal`, still defer).
- 32 new deterministic tests pin the widened forms, the canonicalization, the
validation, the substring contract, and the negative space (1046 total; the 66
package-protection tests shipped in 0.5.11 all pass unchanged against the widened
matcher).

Documentation
- **Audit claim tightened one degree.** The README's "this is the artifact your
auditor reads" sentence now states the disciplined version: the log can provide one
auditable decision artifact within the broader system of records, controls, evidence
retention, and deployment governance.

0.5.11

Closes the package-manager self-protection gap and consolidates the front door: the
README now leads with the capability, and the full MCP governance statement moves to
`docs/MCP.md` unchanged.

Security
- **`pip uninstall recusal` is now refused (deny-list).** The self-protect verb pattern
matched `\binstall\b`, which never matches "uninstall" (there is no word boundary
inside a word), and a bare package name carries no `recusal/` path segment, so
uninstalling the enforcement package deferred straight through the gate. A new
`package_self_protection` check refuses package-manager mutation of the enforcement
package: uninstall, reinstall/downgrade, and install-time shadowing
(`pip install -e ./fake-recusal`), across the `pip` / `pip3` / `python -m pip` /
`py -m pip` / `uv pip` / `uv add` / `uv remove` spellings, obfuscated forms included,
through Bash and through command-like keys on any other tool. Parameterized as
`protected_packages` (default `("recusal",)`), and `uninstall` joins the
self-protect verb list. 66 new tests pin the positive, obfuscated, and negative
cases (no new false positives on ordinary package commands: `pip install requests`,
`pip show recusal`, `pip install -e .` all still defer). The named ceiling is
unchanged and documented: an install that provides the package without naming it
(`pip install -e .`, `-r requirements.txt`) is unreadable to a string matcher; the
pinned, write-protected venv stays the real defense.

Documentation
- **README front door reordered, capability first.** The page now opens with the
problem, what Recusal does (the deny that holds in bypass mode with the isolation
caveat in the same breath, MCP capability integrity, the audit record shaped for
EU AI Act Article 12 record-keeping), then the 20-second demo and install; allowlist
mode is presented above the deny-list as the enforcement posture; the independence
thesis moves to a "Why an independent gate" section; the launcher/exit-code/Windows
detail folds into a collapsible block. Claim language is unchanged.
- **MCP governance statement extracted to `docs/MCP.md`.** The README keeps a summary
(same gate, pin/verify, no-pin-no-MCP, the point-in-time boundary in one line) and
links to the full statement with every named residual; the text moved with link
paths adjusted and content otherwise unchanged. Docs index updated.

0.5.10

Aligns the published package with current main's stronger loader invariant: the
normal `recusal mcp pin` path in 0.5.9 already rejected callable collisions, so this
closes the remaining gap for hand-edited and externally generated manifests.

Fixed
- **Callable-collision refusal is a loader invariant (review 13, P1).** The builder
refused two raw plugin tools normalizing to one callable, but `_validate_manifest`
checked each pin independently - so an externally generated or hand-edited manifest
whose pins were EACH individually canonical could carry a collision, which would
collapse into one runtime string in `manifest_policy`. The loader now tracks
callable identity per plugin server and refuses the collision; `manifest_policy`
fails closed on such an artifact and `diff_observation` refuses it before comparing.
Collision refusal is now an invariant of every accepted manifest, not only of the
preferred builder path (three regression tests, hand-edited-artifact shaped).

Documentation
- Stale "manifest v5" wording in the verifier docstrings and CLI comment replaced
with schema-stable phrasing ("the current complete manifest verifier"); the README
test references include `tests/test_mcp_runtime_identity.py`.

0.5.9

The single carry-forward item from the review sequence, pulled forward from 0.6.0 and
closed: manifest v6 models raw MCP declaration identity and Claude plugin callable
identity separately. Tightly scoped; the 0.5.8 boundaries are otherwise unchanged.

Added
- **Manifest v6: explicit runtime identity (MANIFEST_VERSION = 6).** Each server
entry carries a canonical `runtime: {"mode": "standard_mcp" | "claude_plugin"}`,
declared explicitly (`build_manifest(runtime_modes=...)`, CLI
`recusal mcp pin --claude-plugin NAME`) and never inferred from a key's spelling.
In `claude_plugin` mode each tool pin stores its `callable_name`, derived by
Claude's documented normalization (any character outside `A-Z a-z 0-9 _ -`
becomes `_`, exposed as `recusal.mcp.plugin_callable_name`); the raw declaration
name remains the pin key and fingerprint subject. Discovery and drift verification
compare RAW identity; `PreToolUse` membership checks CALLABLE identity - so a
spec-valid dotted plugin tool (`admin.tools.list`) pins and authorizes under
Claude's spelling, and the raw dotted spelling is not treated as a callable. Two
raw names normalizing to one callable REFUSE the pin (ambiguous callable identity
certifies nothing); a `claude_plugin` server key must already be the callable-safe
runtime segment (refused, never silently rewritten); the loader re-derives every
stored `callable_name` and refuses mismatches, requires the canonical runtime
record, and refuses `callable_name` on standard pins. Runtime identity lives in
the manifest bytes, so it is inside the digest that audit provenance records.
- **Backward compatibility, explicitly**: manifest v5 is refused with a re-pin
instruction naming the gap it predates (a plugin tool whose raw name requires
normalization could be refused under Claude's spelling, or alias an approved
callable); the call-time bridge fails closed on a v5 manifest, never open.

Changed
- The plugin claim in the README is upgraded to match: full spec-valid plugin tool
names are supported under the explicit plugin mode. The boundary that remains,
stated as before: `PreToolUse` carries only the callable name, so a
post-verification raw-declaration swap preserving an approved callable is
indistinguishable at call time until the next `verify`, which now refuses on raw
identity - exactly why both identities are pinned.

0.5.8

A claim-correction patch from an eleventh external review, whose central finding was
a failure of OUR verification, not of the reviewed code: 0.5.7 published that Claude
does not document plugin callable-name normalization, when the rule IS explicitly in
the Claude Code MCP reference ("any character outside A-Z, a-z, 0-9, _, and - is
replaced with _"). The sweep missed it on the page it read, and the resulting text
also falsely rejected the tenth review's correct claim.

Documentation
- **The plugin callable-name boundary corrected to the documented rule** (README,

0.5.7

A small correctness patch from a tenth external review (which found no P0 and
recommended folding these into 0.6.0; shipped now as 0.5.7 instead): all-servers-
removed semantics made deliberate, exception-contract consistency, and the plugin
callable-name boundary stated to exactly what the documentation establishes.

Fixed
- **Full decommission refuses with the precise reason (P1).** Acknowledging removal
of EVERY pinned server tripped the generic empty-observation refusal alongside the
nonblocking removal warnings - safe-side, never a bypass, but inconsistent with the
removal wording. Deliberate now: `removed` supports transitions where at least one
pinned server remains observable; acknowledging all of them refuses with
`mcp_full_decommission_unsupported`, naming the real decommission path (an empty
observation certifies nothing, and the manifest keeps authorizing every pinned
runtime name regardless - remove or replace the manifest itself: no pin, no MCP).
Stated in the CLI help and verify docstring.
- **Malformed sequence members raise the documented `ValueError` (P1).** An
unhashable member of `unverifiable`/`removed` (a list, a dict, a bytearray) raised
`TypeError` from duplicate detection before its type was rejected. Element types
now validate before duplicate detection; the 0.5.6 changelog's "fully strict" is
amended in place.

Documentation
- **The plugin callable-name boundary (P1).** *(Amended 2026-07-12, same day: as
first published, this entry said Claude's docs "do not currently specify" the
plugin callable normalization and rejected the tenth review's
replace-with-underscore claim. That was OUR verification failure, not the
review's error: the rule IS explicitly documented in the Claude Code MCP
reference ("any character outside A-Z, a-z, 0-9, _, and - is replaced with _"),
and the sweep missed it on the page it read. The eleventh review caught it.
Consequences corrected with it: "false denial, never a false allow" was too
strong - two raw names can normalize to the same callable, so a
post-verification raw-declaration swap can alias an already-approved callable
name until the next verify; and "observe and pin the runtime spelling" was not
implementable, since the manifest keys tools by raw declaration name. The
supported boundary is now stated exactly: plugin call-time mapping only for raw
components already in the callable-safe set; raw-vs-callable identity modeling
with collision refusal is 0.6.0 scope.)*
- The unified-verifier composition comment includes removal acknowledgements and
whole-server inventory; the verify docstring's remote OAuth wording is
per-transport (http/sse pin OAuth fields; ws is header-only).

Page 1 of 4

© 2026 Safety CLI Cybersecurity Inc. All Rights Reserved.