The default filter inspects dict values but silently passes several other carriers. Reproduced against current main (0.3.7):
dict KEY {"password": "x"} -> EXECUTED (fail-open) # JSON-native, model-reachable
kwarg NAME send(api_key="abc") -> EXECUTED (fail-open)
kwarg NAME send(password="x") -> EXECUTED (fail-open)
bytes b"my password" -> EXECUTED (fail-open)
set {"my password"} -> EXECUTED (fail-open)
dict VALUE {"k": "my password"} -> BLOCKED # the one path that works
Three distinct defects, in priority order:
1. Dict keys are never inspected — and this one is model-reachable
SensitiveDataFilter._check_recursive in src/modelfuzz/rules.py iterates .values(). Dicts are JSON-native, so this is reachable straight from model-supplied tool arguments with no help from your own code:
import json
payload = json.loads('{"password": "hunter2"}') # straight off the wire
send(data=payload) # executes
That's semantically the exact payload the README quickstart demonstrates blocking. This sub-case is why the issue is labelled security.
2. Keyword-argument names are never inspected either
src/modelfuzz/decorator.py iterates list(args) + list(kwargs.values()) — the keys of kwargs never reach the engine:
send(api_key="abc") # executes
send(password="x") # executes
Correction (see discussion below): do not fix this by feeding names through the filter.
A parameter legitimately named password / api_key / secret would then trip on every
call — def login(username, password) would be permanently unusable regardless of the value.
"This parameter is sensitive" belongs in the signature-binding work, where a policy can be
scoped per-parameter, not in a blanket keyword match on names. Out of scope here.
3. Unrecognized types fail open
bytes, bytearray, set, frozenset, objects and dataclasses all fall through to the implicit return None, which the engine reads as allowed. A guardrail whose default for "I don't understand this value" is allow has the wrong default. Reachability here is narrower than for dicts (LLM arguments arrive as JSON, so these only appear if your own code builds them) — a real fail-open default with developer-dependent reachability.
Note
This is partly disclosed today — the README Limitations section states "only str, list, tuple, and dict values are inspected." So it's documented behaviour, not a hidden claim. But the dict-key case (#1) is model-reachable and semantically identical to the quickstart's own blocked example, so it's worth closing rather than only documenting.
Proposal — scope
In scope (value-side; strictly widens inspection, breaks nothing):
- Iterate
.items() for dicts so keys are checked
- Explicit branches for
bytes / bytearray (decode, then check)
- Traverse non-str iterables (
set, frozenset) like list / tuple
- Optionally: objects via
model_dump() / dataclasses.asdict() / __dict__
Out of scope — deliberately deferred:
Pass keyword-argument names through from the decorator — see the correction above; it makes
any tool with a password / api_key parameter permanently unusable.
A final else that fails closed — that branch is reached by every ordinary scalar
(int, float, bool, None, date, Path, a callback function), so blanket fail-closed
would block routine tool calls for no security gain. If revisited, it needs to separate scalars
that cannot carry a string (pass) from opaque objects that might (configurable, e.g.
SensitiveDataFilter(on_uninspectable="allow"|"block"), defaulting to allow).
Acceptance criteria
The default filter inspects dict values but silently passes several other carriers. Reproduced against current
main(0.3.7):Three distinct defects, in priority order:
1. Dict keys are never inspected — and this one is model-reachable
SensitiveDataFilter._check_recursiveinsrc/modelfuzz/rules.pyiterates.values(). Dicts are JSON-native, so this is reachable straight from model-supplied tool arguments with no help from your own code:That's semantically the exact payload the README quickstart demonstrates blocking. This sub-case is why the issue is labelled
security.2. Keyword-argument names are never inspected either
src/modelfuzz/decorator.pyiterateslist(args) + list(kwargs.values())— the keys ofkwargsnever reach the engine:3. Unrecognized types fail open
bytes,bytearray,set,frozenset, objects and dataclasses all fall through to the implicitreturn None, which the engine reads as allowed. A guardrail whose default for "I don't understand this value" is allow has the wrong default. Reachability here is narrower than for dicts (LLM arguments arrive as JSON, so these only appear if your own code builds them) — a real fail-open default with developer-dependent reachability.Note
This is partly disclosed today — the README Limitations section states "only
str,list,tuple, anddictvalues are inspected." So it's documented behaviour, not a hidden claim. But the dict-key case (#1) is model-reachable and semantically identical to the quickstart's own blocked example, so it's worth closing rather than only documenting.Proposal — scope
In scope (value-side; strictly widens inspection, breaks nothing):
.items()for dicts so keys are checkedbytes/bytearray(decode, then check)set,frozenset) likelist/tuplemodel_dump()/dataclasses.asdict()/__dict__Out of scope — deliberately deferred:
Pass keyword-argument names through from the decorator— see the correction above; it makesany tool with a
password/api_keyparameter permanently unusable.A final— that branch is reached by every ordinary scalarelsethat fails closed(
int,float,bool,None,date,Path, a callback function), so blanket fail-closedwould block routine tool calls for no security gain. If revisited, it needs to separate scalars
that cannot carry a string (pass) from opaque objects that might (configurable, e.g.
SensitiveDataFilter(on_uninspectable="allow"|"block"), defaulting toallow).Acceptance criteria
bytes,bytearray,set,frozensetare inspectedsend(data={"api_key": "sk-12345"})is blockedint/float/bool/Nonestill succeeds (regression guard)