Remaining confirmed findings from the full-library audit, none release-blocking. PR #75 fixed the five that were.
Reason sanitisation — finish the job
#75 stopped URLAllowList echoing the URL. ShellCommandAllowList (rules.py, the not-allowlisted branch) still interpolates the first shlex token unescaped, and ESC survives shlex:
ShellCommandAllowList(["ls"])("'\x1b[1A\x1b[2K[modelfuzz] tool call ALLOWED: ls'")
# reason carries the escapes; on a tty-attached handler \x1b[1A\x1b[2K
# moves up one line and erases it — overwriting the preceding audit record
Unbounded too: a 5,000-character token produced a 5,028-character reason, which the README's recovery pattern hands straight back to the model.
Fix: one shared helper that strips C0/C1 and caps length, applied at every reason interpolation site. The principle is already stated in the source twice — make it structural.
Rule order silently rewrites the category an agent branches on
PolicyEngine short-circuits on the first violation. Same argument, same two rules:
[URLAllowList, SecretPatternFilter] -> not_allowlisted | URL domain not in allowlist: evil.com
[SecretPatternFilter, URLAllowList] -> credential | String contains a possible AWS access key ID
The README documents category as the stable field to branch on, with "retry without the offending argument" as the suggested recovery. In the first order the credential is never surfaced — so a loop that auto-retries not_allowlisted minus the URL will resend the AWS key to an allowlisted host. Both composed examples in the README put the coarse rule first.
Fix: either document the ordering consequence with a recommended ordering (specific before coarse), or collect all violations and report the most specific.
Packaging
typer>=0.12 is an unconditional runtime dependency used only by the CLI. It pulls rich, shellingham, annotated-doc and colorama into every MCP server's import graph. Move it to the scan extra.
__init__.py — __version__ = _version("modelfuzz") raises PackageNotFoundError at import for source/vendored/zipapp/PyInstaller consumers. Wrap with a fallback.
(Wheel and sdist contents were checked and are clean — no .claude, __pycache__, .coverage; py.typed present in both.)
Doc corrections
- README Limitations has no performance caveat. Argument inspection is linear in argument size, there is no input-size cap anywhere, and
_enforce runs synchronously before the await on async tools — so a large argument blocks the event loop. Worth a bullet, or a size cap.
- Custom-object arguments are framed as an edge case. For FastMCP and LangChain
args_schema, a pydantic BaseModel argument is the default delivery form, not an edge case. Integrators of those two frameworks should understand the walk does not reach their arguments.
- README scan example shows no
--budget-s but the transcript header says "45s budget". Add --budget-s 45 to the printed command.
- The trust boundary is nowhere stated. A guarded argument reaches the tool body as the same mutable object the policy inspected. ModelFuzz defends against untrusted data, not untrusted code in its own interpreter. Worth saying explicitly.
Compatibility (not security)
functools.partial and callable class instances raise AttributeError in _enforce/_wrap because func.__name__ is evaluated eagerly. Fails closed and breaks the allow path too, so nothing insecure ships — but StructuredTool.func and MCP handler factories produce exactly these shapes. getattr(func, "__name__", type(func).__name__).
Remaining confirmed findings from the full-library audit, none release-blocking. PR #75 fixed the five that were.
Reason sanitisation — finish the job
#75 stopped
URLAllowListechoing the URL.ShellCommandAllowList(rules.py, the not-allowlisted branch) still interpolates the first shlex token unescaped, and ESC survivesshlex:Unbounded too: a 5,000-character token produced a 5,028-character reason, which the README's recovery pattern hands straight back to the model.
Fix: one shared helper that strips C0/C1 and caps length, applied at every reason interpolation site. The principle is already stated in the source twice — make it structural.
Rule order silently rewrites the
categoryan agent branches onPolicyEngineshort-circuits on the first violation. Same argument, same two rules:The README documents
categoryas the stable field to branch on, with "retry without the offending argument" as the suggested recovery. In the first order the credential is never surfaced — so a loop that auto-retriesnot_allowlistedminus the URL will resend the AWS key to an allowlisted host. Both composed examples in the README put the coarse rule first.Fix: either document the ordering consequence with a recommended ordering (specific before coarse), or collect all violations and report the most specific.
Packaging
typer>=0.12is an unconditional runtime dependency used only by the CLI. It pullsrich,shellingham,annotated-docandcoloramainto every MCP server's import graph. Move it to thescanextra.__init__.py—__version__ = _version("modelfuzz")raisesPackageNotFoundErrorat import for source/vendored/zipapp/PyInstaller consumers. Wrap with a fallback.(Wheel and sdist contents were checked and are clean — no
.claude,__pycache__,.coverage;py.typedpresent in both.)Doc corrections
_enforceruns synchronously before theawaiton async tools — so a large argument blocks the event loop. Worth a bullet, or a size cap.args_schema, a pydanticBaseModelargument is the default delivery form, not an edge case. Integrators of those two frameworks should understand the walk does not reach their arguments.--budget-sbut the transcript header says "45s budget". Add--budget-s 45to the printed command.Compatibility (not security)
functools.partialand callable class instances raiseAttributeErrorin_enforce/_wrapbecausefunc.__name__is evaluated eagerly. Fails closed and breaks the allow path too, so nothing insecure ships — butStructuredTool.funcand MCP handler factories produce exactly these shapes.getattr(func, "__name__", type(func).__name__).