Skip to content

Audit follow-ups: reason sanitisation, rule-order affects category, packaging, doc corrections #78

Description

@higagan

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__).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions