Fuzz canonicalization and the policy loader, and record the finding it turned up - #92
Conversation
…t turned up
Scorecard reads Fuzzing as 0/10. That is the reason this was picked up, but not
the reason it is worth having: canonicalization is the one place in this codebase
where a defect is silent. Two distinct actions sharing a canonical form share an
approval, and nothing in a receipt would look wrong. This repository has already
had one bug of exactly that shape -- `str(key)` folded `{"1": "a", 1: "b"}` into
a single key, with the survivor decided by insertion order -- and a review found
it. These are the properties that would have found it without a review.
Two targets, chosen because each is a promise already written down:
canonicalization (`v0.1 §2.3`), and `Policy.from_yaml`, whose docstring says
"anything malformed raises `PolicyError`" -- the fail-closed rule in a sentence.
**The invariants do not import Atheris.** `fuzz/properties.py` is plain stdlib,
so `tests/test_fuzzing.py` is a second driver for the same properties and they
run on every commit whether or not anybody has a fuzzing toolchain -- and a
contributor can reproduce a finding without one. `fuzz/fuzz_*.py` hold no
assertions of their own. CI runs the seed corpus as a gate that cannot be
skipped by a toolchain problem, then a bounded two-minute campaign per target;
a failure to install Atheris is a red build and not a skip.
## A finding, recorded rather than worked around
`canonical_bytes` raises `UnicodeEncodeError`, not `InvalidArgument`, for a
string holding an unpaired UTF-16 surrogate. It is reachable:
`json.loads('"\ud800"')` produces one, so an MCP tool call can carry it;
`Action(...)` accepts it and `action_hash` is where it fails.
Fail-closed **holds** -- the gateway and `Control` both wrap the action path in
`except Exception` -- so this is a contract violation and a crash, not an
authorization bypass. What it breaks is the closed error set in `errors.py`: a
caller catching `CTRLRunError` does not catch this.
It is in `KNOWN_FINDINGS` with a reproducer in the corpus, not pruned out of the
decoder, because a fuzzer whose corpus avoids its own findings reports zero
forever. `test_the_known_findings_still_reproduce` asserts it **still happens**,
so the day it is fixed that test goes red and the entry must be deleted. The fix
belongs to whoever owns `src/` and is argued in `fuzz/README.md`.
## What the positive controls found
Eleven controls, one per invariant. Writing them found three defects in the
properties themselves, all fixed here:
- **Only the first encode call was guarded.** An encoder broken on its second
call raised `TypeError` straight out of `check_canonical`. A property claiming
"nothing but InvalidArgument escapes" has to hold that for every call it makes.
Every call goes through `_encode` now.
- **Nothing asserted that floats and non-string keys are *refused*.** Checking
only what comes back cannot tell "refused correctly" from "never refused at
all". Both are now stated before the call.
- **Every control was really testing one branch.** The generated corpus is full
of non-string keys, so `must_refuse` fired first and each control -- whatever
it was named for -- passed on that. They use purpose-built documents now and
assert *which* message they got.
Eighteen mutations, eighteen caught.
## One existing test changed
`test_T124b_the_package_ships_nothing_from_research` substring-matched the whole
of `MANIFEST.in` including its comments, so a comment mentioning `research/`
beside the new `prune fuzz` failed a test about what setuptools ships. It parses
directives now. Mutation-tested three ways -- `recursive-include research *.py`,
`graft research`, `include research/soak/run.py` -- all caught, and the comment
that broke it passes.
…tring that said it was CI's fuzz job passed: green tick, 9,788,746 executions on canonicalization and 5,074,499 on the policy loader. `stat::new_units_added: 0` on both is what gave it away. A coverage-guided fuzzer that finds no new coverage unit in ten million runs is not coverage-guided. `import properties` sat at module scope, so by the time `atheris.instrument_imports()` ran the module was already in `sys.modules` and the instrumented re-import was a no-op. Nothing was instrumented -- not `properties`, and not `ctrlrun.action` underneath it, which is the code the campaign exists to cover. It ran blind random input at full speed and reported success, which is worse than not running: the job's green tick was evidence for a claim nothing supported. Both harnesses now import `properties` for the first time inside the instrumentation block, so the hook applies to it and transitively to `ctrlrun`. **And the test that should have caught it asserted the string.** `"instrument_imports" in source` was true of the broken version -- the call was right there, doing nothing. It is an AST walk now: no `import properties` at module scope, and an `import properties` inside a `with` whose context manager is `instrument_imports`. A structural property, because the difference between the two versions is structural and invisible to a substring. Two mutations, two caught: reintroducing the top-level import, and removing the instrumentation block. Neither is detectable by the check this replaces.
Follow-up commit: the campaign was instrumented by nothingThe
It ran blind random input at full speed and reported success. That is worse than not running at all: the green tick was evidence for a claim nothing supported. And the test asserted the string that said it was working
Two mutations, two caught — reintroducing the top-level import, and removing the instrumentation block. Neither is detectable by the check it replaces. Full suite after the fix: 3938 passed, 45 skipped. I am flagging this rather than quietly amending because the shape of it is the thing worth reviewing: this is the repository's own false-green pattern, in the PR whose subject is catching false greens, written by the person adding the rule. Worth a second pair of eyes on whether the AST check is the right guard. |
The fix, confirmed by CI rather than by the test
Execution rate is down about 15×. That is the instrumentation overhead and it is the trade worth making: far fewer executions, each guided by coverage feedback and accumulating a corpus, rather than 15 million blind random inputs that explored nothing. Before the fix the log contained no Both targets still report no new violations, and the seed gate still reproduces the one recorded finding. |
| _ = action.action_hash | ||
| except CTRLRunError: # pragma: no cover - the finding is that this does not happen | ||
| raise AssertionError("the finding is fixed; update KNOWN_FINDINGS") from None | ||
| except UnicodeEncodeError: |
Why
Scorecard reads
Fuzzingas 0/10 (weight 5 of 97.5, so ~+0.51). That is why it was picked up, not why it is worth having.Canonicalization is the one place in this codebase where a defect is silent. Two distinct actions sharing a canonical form share an approval, and nothing in a receipt would look wrong. This repository has already had one bug of exactly that shape —
str(key)folded{"1": "a", 1: "b"}into a single key, with the survivor decided by insertion order — and a review found it. These are the properties that would have found it without a review.Two targets, each a promise already written down: canonicalization (
SPEC-v0.1.md§2.3), andPolicy.from_yaml, whose docstring says anything malformed raisesPolicyError— the fail-closed rule in a sentence.The invariants do not import Atheris
fuzz/properties.pyis plain stdlib, sotests/test_fuzzing.pyis a second driver for the same properties. They run on every commit whether or not anybody has a fuzzing toolchain, and a contributor can reproduce a finding without one.fuzz/fuzz_*.pyhold no assertions of their own.CI runs the seed corpus as a gate that cannot be skipped by a toolchain problem, then a bounded 2-minute campaign per target. A failure to install Atheris is a red build, not a skip.
A finding — recorded, not worked around
canonical_bytesraisesUnicodeEncodeError, notInvalidArgument, for a string holding an unpaired UTF-16 surrogate.Reachable:
json.loadsproduces one from a\udXXXescape, so an MCP tool call can carry it.Action(...)accepts it;action_hashis where it fails.Fail-closed holds — the gateway and
Controlboth wrap the action path inexcept Exception— so this is a contract violation and a crash, not an authorization bypass. What it breaks is the closed error set inerrors.py: a caller catchingCTRLRunErrordoes not catch this.It is in
KNOWN_FINDINGSwith a reproducer in the corpus, rather than pruned out of the decoder, because a fuzzer whose corpus avoids its own findings reports zero forever.test_the_known_findings_still_reproduceasserts it still happens — the day it is fixed, that test goes red and the entry must be deleted.The fix touches
src/so it is not in this PR, per the merge rule. It is narrow — reject unencodable strings in_no_floatsbeside the float and non-string-key checks — and changes no hash that previously succeeded, so §2.3's "old hashes still verify" rule is satisfied without a schema bump. Argued infuzz/README.md.What the positive controls found
Eleven controls, one per invariant. Writing them found three defects in the properties themselves:
TypeErrorstraight out ofcheck_canonical. A property claiming "nothing butInvalidArgumentescapes" has to hold for every call it makes.must_refusefired first and each control — whatever it was named for — passed on that. They use purpose-built documents now and assert which message they got.18 mutations, 18 caught. 240,000 generated inputs across both properties produced exactly one finding — the one above.
One existing test changed
test_T124b_the_package_ships_nothing_from_researchsubstring-matched the whole ofMANIFEST.inincluding its comments, so a comment mentioningresearch/beside the newprune fuzzfailed a test about what setuptools ships. It parses directives now. Mutation-tested three ways —recursive-include research *.py,graft research,include research/soak/run.py— all caught, and the comment that broke it passes.Say the word if you would rather that were a separate PR.
Checks
ruff format --check,ruff check,mypy --strict srcclean; full suite 3936 passed, 45 skipped.