Skip to content

fix: async wrapping, stdout pollution, URL policy scoping, sdist contents - #4

Merged
higagan merged 1 commit into
mainfrom
fix/launch-blockers
Jul 30, 2026
Merged

fix: async wrapping, stdout pollution, URL policy scoping, sdist contents#4
higagan merged 1 commit into
mainfrom
fix/launch-blockers

Conversation

@higagan

@higagan higagan commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Fixes the four code-level launch blockers from the 0.3.1 readiness audit. Each was reproduced against the published wheel before fixing and re-verified against the rebuilt one. Version bumped to 0.3.2.

The README honesty pass (the keyword-filter framing) is deliberately not here — it's a separate PR.

1. @shield_tool broke async tools

_wrap always built a sync wrapper, so functools.wraps left inspect.iscoroutinefunction() returning False on a shielded async def. LangChain and MCP branch on exactly that to decide whether to await, so the body never ran and the model received a coroutine repr as its observation — with no exception, just a RuntimeWarning most apps never surface.

Coroutine functions and async generators are now wrapped in kind.

inspect.iscoroutinefunction = True
awaited result              = 'OK:http://ok.com'
blocked async call          = ModelFuzzBlockError(String contains sensitive keyword: 'password')

2. stdout pollution corrupted MCP stdio

print(f"ModelFuzz Intercepted: ...") fired on every allowed call. stdout is the JSON-RPC transport for MCP stdio servers, so the client died with Invalid JSON. The logging was also inverted: it announced allows and recorded nothing on deny.

Now: allows log at DEBUG, blocks log at WARNING on the modelfuzz logger with structured fields. Nothing writes to stdout.

level=WARNING tool=send_email rule=SensitiveDataFilter
reason=String contains sensitive keyword: 'password'
stdout on an allowed call: 'Sending to bob@corp.com: hello there\n'   # the tool's own output only

PolicyResult gained an optional violation field so the log can name the rule — it was previously discarded in engine.py. Additive, non-breaking.

3. URLAllowList blocked every legitimate multi-argument call

The engine applies every policy to every argument, so http_post(url, body) blocked its own valid calls — body isn't a URL. The rule now returns None for values it doesn't govern.

Slightly wider than asked, within the same function, because leaving a known fail-open in code I was rewriting wasn't defensible:

  • Scheme is now checked (http/https default). file://api.internal.com/etc/passwd previously passed for an allowlisted host — a fail-open.
  • Hostname via urlparse().hostname instead of hand-rolled netloc.split(":")[0], so HTTPS://API.INTERNAL.COM and a trailing dot now work. DNS is case-insensitive; these previously failed closed.
legit URL + prose body   -> ALLOWED
legit URL + int timeout  -> ALLOWED
UPPERCASE host           -> ALLOWED
evil host                -> BLOCKED  URL domain not in allowlist: evil.com
file:// scheme           -> BLOCKED  URL scheme not allowed: file
userinfo trick           -> BLOCKED  URL contains userinfo trick

Known tradeoff, documented in the docstring: a schemeless bare host ("evil.com") is not identifiable as a URL and now passes. Most HTTP clients reject a schemeless URL, but it is a real narrowing of the rule and the docstring says so.

4. sdist shipped local artifacts

No [tool.hatch.build.targets.sdist] meant hatchling swept the working tree — the published 0.3.1 sdist contains .claude/settings.local.json, and a rebuild would have added .coverage and .claude/settings.json. Now an explicit allowlist, plus .claude/, .coverage*, htmlcov/, .env added to .gitignore.

$ tar tzf dist/modelfuzz-0.3.2.tar.gz | grep -cE '\.claude|\.coverage|\.env|uv\.lock'
0

Verification

  • 60 tests pass, up from 25. decorator.py and engine.py at 100%; rules.py 97%.
  • New coverage for the paths whose absence let these ship: async/async-gen, capsys stdout assertions, structured-log assertions, keyword arguments, and the bare @shield_tool form.
  • The README's documented output is byte-identicalBlocked: String contains sensitive keyword: 'secret'. demo.py output unchanged.
  • Two remaining uncovered lines in rules.py are a defensive except ValueError around .hostname that I could not trigger on any Python version tested.

Note for review

Python's lastResort handler means a block prints to stderr when the host app hasn't configured logging. That's stderr not stdout, so MCP is unaffected, and a silent security denial seemed worse than a visible one — but it is a behavior choice worth a second opinion. Adding a NullHandler would silence it by default.

Not addressed here (audit's "fix soon after"): SensitiveDataFilter fails open on unrecognized types and never checks dict keys; ModelFuzzBlockError still carries only a string; no py.typed.

…ents

Four defects found by a launch-readiness audit of 0.3.1, each verified
against the published wheel.

- @shield_tool always built a sync wrapper, so inspect.iscoroutinefunction()
  returned False on a shielded async def. LangChain and MCP branch on that to
  decide whether to await, so the tool body never ran and the model received a
  coroutine repr as its observation. Coroutine functions and async generators
  are now wrapped in kind.

- The decorator printed to stdout on every allowed call. stdout is the
  transport for MCP stdio servers, so this corrupted the JSON-RPC stream.
  Blocks now log at WARNING on the "modelfuzz" logger with structured
  modelfuzz_tool / modelfuzz_rule / modelfuzz_reason fields; allows log at
  DEBUG. Nothing touches stdout.

- URLAllowList was applied to every argument, so http_post(url, body) blocked
  its own legitimate calls -- body is not a URL. It now returns None for
  values that are not URLs. While rewriting it: reject non-http(s) schemes
  (file://api.internal.com/etc/passwd previously passed) and compare
  hostnames case-insensitively via urlparse().hostname.

- No [tool.hatch.build.targets.sdist] meant hatchling swept the working tree,
  so the 0.3.1 sdist shipped .claude/settings.local.json. Now an allowlist.

PolicyResult carries the originating Violation so the log can name the rule.
Tests 25 -> 60; decorator.py and engine.py to 100%.
@higagan
higagan merged commit 144ec34 into main Jul 30, 2026
3 checks passed
@higagan
higagan deleted the fix/launch-blockers branch July 30, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant