Skip to content

Refuse a string UTF-8 cannot encode, in words rather than as a UnicodeEncodeError - #94

Merged
arpanghoshal merged 3 commits into
mainfrom
fix/canonical-surrogate
Sep 6, 2026
Merged

Refuse a string UTF-8 cannot encode, in words rather than as a UnicodeEncodeError#94
arpanghoshal merged 3 commits into
mainfrom
fix/canonical-surrogate

Conversation

@arpanghoshal

Copy link
Copy Markdown
Contributor

Stacked on #92. Base is ci/fuzzing, because this removes a KNOWN_FINDINGS entry that only exists there. Merge #92 first, and do not use --delete-branch on ci/fuzzing — that would close this PR.

The finding

fuzz/ found this on its first run, before Atheris was even wired up.

canonical_bytes raised UnicodeEncodeError for a string holding an unpaired UTF-16 surrogate. It arrives the ordinary way — json.loads produces one from a six-character escape — so an MCP tool call carried it into the action path. Action(...) accepted it; action_hash was where it failed.

Fail-closed held. The gateway and Control both wrap the action path in except Exception, so this was a contract violation and a crash, not an authorization bypass. What it broke is the closed error set in errors.py: a caller catching CTRLRunError did not catch this one.

Two checks, neither subsumed by the other

  • _encodable, at construction — beside the float and non-string-key checks, so an Action cannot be built holding a value it could never hash. An object that constructs fine and explodes later is a trap set in one place and sprung in another.
  • canonical_bytes, at the encode — the only guard on the path that never goes through Action. The policy hash and the receipt chain call canonical_bytes directly on documents.

I expected the second to be redundant and mutation-testing said otherwise: removing it turns four fuzz tests red.

A refusal, not a repair

errors="replace" would map two distinct arguments onto one canonical form — precisely the collision §2.3 exists to prevent.

Checked at the encode rather than per string, so it costs nothing on the path that succeeds: the encode already walks every character.

A paired surrogate is unaffected, and a negative control asserts it. A check keyed on "contains a surrogate code point" rather than on encodability would reject an ordinary emoji and still pass every other test here — that mutation is in the table below and is caught.

§2.3's rule, without a schema bump

test_T164b_every_recorded_hash_still_verifies passes. Every input whose behaviour changed is one that previously raised, so no hash that ever succeeded is different.

Mutation table

mutation verdict
construction-time check removed CAUGHT
key check removed CAUGHT
encode-time check in canonical_bytes removed CAUGHT (4 fuzz tests — not subsumed)
refusal turned into a repair (errors="replace") CAUGHT
keyed on surrogate code points, not encodability CAUGHT (nested-surrogate)

The KNOWN_FINDINGS mechanism emptied itself

I did not have to remember to remove the entry. Its own test asserted the finding still reproduced, so fixing the bug turned that test red and forced the entry out. fuzz/README.md now keeps it as a worked example of the mechanism rather than as a live defect, and the reproducer stays in the corpus as a regression input.

Two generated-docs updates

Regenerated the API reference (docstrings changed), and repointed three docs/CLAIMS.md anchors that named action.py:47 for the float refusal and had drifted to line 71. The CLAIMS line-anchor guard caught that itself.

Checks

ruff format --check, ruff check, mypy --strict src clean; full suite 3946 passed, 45 skipped.

One process note: an earlier full-suite run came back green and I discarded it — a timed-out mutation task had restored action.py mid-run, so the tree under test was not the tree being committed. The clean re-run found the two docs failures above. A green suite on a tree you cannot vouch for is worth less than no run at all.

@arpanghoshal
arpanghoshal changed the base branch from ci/fuzzing to main September 6, 2026 18:16
…eEncodeError

`fuzz/` found this on its first run, before Atheris was even wired up.

`canonical_bytes` raised `UnicodeEncodeError` for a string holding an unpaired
UTF-16 surrogate. It arrives the ordinary way -- `json.loads` produces one from a
six-character escape -- so an MCP tool call carried it into the action path;
`Action(...)` accepted it and `action_hash` was where it failed.

Fail-closed **held**: the gateway and `Control` both wrap the action path in
`except Exception`, so this was a contract violation and a crash, not an
authorization bypass. What it broke is the closed error set in `errors.py`. A
caller catching `CTRLRunError` did not catch this one.

**Two checks, and neither is subsumed by the other.** `_encodable` refuses at
construction, beside the `float` and non-string-key checks, so an `Action` cannot
be built holding a value it could never hash -- a trap set at construction and
sprung somewhere else. `canonical_bytes` refuses again at the encode, which is
the only guard on the path that does not go through `Action` at all: the policy
hash and the receipt chain call it directly on documents. Mutation-tested
separately; removing the second turns four fuzz tests red.

**A refusal, not a repair.** `errors="replace"` would map two distinct arguments
onto one canonical form, which is the collision §2.3 exists to prevent. Checked
at the encode rather than per string, so it costs nothing on the path that
succeeds -- the encode already walks every character.

A *paired* surrogate is unaffected, and a negative control asserts it: a check
keyed on "contains a surrogate code point" rather than on encodability would
reject an ordinary emoji and still pass every other test here. That mutation is
in the table and is caught.

§2.3's rule is satisfied without a schema bump:
`test_T164b_every_recorded_hash_still_verifies` passes, because every input that
changed behaviour is one that previously raised.

Five mutations, five caught.

`fuzz/KNOWN_FINDINGS` is empty again, and it emptied itself: the entry's own test
asserted the finding *still reproduced*, so fixing the bug turned it red and
forced the entry out. `fuzz/README.md` keeps it as a worked example of the
mechanism rather than as a live defect, and the reproducer stays in the corpus as
a regression input.

Also regenerated: the API reference, whose docstrings changed, and three
`docs/CLAIMS.md` anchors that pointed at `action.py:47` for the float refusal and
had drifted to line 71.
@arpanghoshal
arpanghoshal force-pushed the fix/canonical-surrogate branch from 7dfa851 to 4557279 Compare September 6, 2026 18:21
@mintlify

mintlify Bot commented Sep 6, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
ctrlrun 🟢 Ready View Preview Sep 6, 2026, 6:22 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

Comment thread tests/test_fuzzing.py Fixed
…the style

`py/empty-except` on the regression test's `try/except CTRLRunError: pass`.

The finding reads as a style nit and is not one. `InvalidArgument` **is** a
`CTRLRunError`, so the `pytest.raises(InvalidArgument)` on the line above already
proved that branch, and the `except UnicodeEncodeError` sitting beside it was
unreachable from any input. A subsumed guard and an orphaned handler in six
lines, in a test whose whole subject is guards that do not fire.

Replaced with two assertions on the raised exception: that it is in the closed
set, so `except CTRLRunError` catches it, and that it is not the
`UnicodeEncodeError` this used to raise, which no such handler would have caught.
Both are reachable and both are asserted.

Mutation-checked: removing `_encodable` from `_frozen_value` still turns this
test red.
@arpanghoshal
arpanghoshal merged commit 12f74e2 into main Sep 6, 2026
11 checks passed
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.

2 participants