fix: enforce a per-string length cap on tool arguments (#562) - #570
Conversation
) docs/spec/proxy-security.md's Fuzzing Definition of Done specs MAX_STRING_LENGTH at 1MB per string field. It was not implemented anywhere in src/ or scripts/. A single oversized string sits inside an otherwise shallow, low-key-count payload and passes the depth and key-count caps from agentrust-io#556/agentrust-io#561 unbounded, up to the whole-body byte cap. Extends the existing _arg_shape_violation walk in both files with a UTF-8 byte length check, covering string values and object keys. Keys are checked because the key-count cap bounds how many there are, not how large each one is, so one huge key would otherwise pass everything. The cap is not the spec's literal 1MB. That equals MAX_REQUEST_BYTES in both files today, so a 1MB string plus any surrounding JSON already exceeds the whole-body cap and the check could never fire before DOS-001's size rejection already had. It would have been dead code. Set to half the whole-body cap instead, derived from a named constant rather than a restated literal so raising the default carries this along with it. Verified reachable: an over-cap string is 500,101 bytes on the wire against a 1,000,000 byte body cap. The spec's 10MB MAX_REQUEST_BYTES versus the 1MB implemented in both files is left alone. Picking a number there is a maintainer call, not one to make inside this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The list branch of _arg_shape_violation returns None explicitly once the loop finds nothing, so that every branch terminates on its own rather than depending on an earlier branch's return for the string check below it to be reachable. That return had no test. The only list coverage was the rejection path, which would still pass if the walk wrongly rejected every list it saw. Adds the accepting case to both files so the two stay in step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg
imran-siddique
left a comment
There was a problem hiding this comment.
You flagged the deviation from the spec as the thing to look at first, so I looked at it first, and you are right.
At pinned 0d3cb8eb: docs/spec/proxy-security.md:41 specs MAX_STRING_LENGTH = 1 * 1024 * 1024, and the whole-body cap is 1_000_000 in scripts/mock_upstream.py and at server.py:258. A 1 MiB string is already larger than the entire permitted body before you add the JSON scaffolding around it, so a per-string check at the spec's literal value could not fire. Ever.
"Dead code that reads like a control" is exactly the right framing and it is worse than no control. A missing check is visible as missing. A present check that cannot trigger passes review, passes an audit read of the source, and gets counted as coverage in a Definition of Done. Implementing the spec's number would have satisfied #562 on paper and left the gap open.
Deriving it as _DEFAULT_MAX_REQUEST_BYTES // 2 rather than writing 500_000 is what stops this recurring. If someone raises the body cap later, the string cap moves with it and the derivation stays true; a restated literal would silently re-create the same dead-code condition at the new ratio without anyone noticing. Hoisting the constructor default into a named constant to make that possible is a small change carrying the whole argument.
The gap itself is real and is the natural third one: #556 and #561 bounded how deep and how wide a payload is, not how large any single piece of it is, so one oversized string in a shallow object with few keys walked through both.
Keeping the gateway and the mock in step again is right, for the same reason as last time: a mock that accepts what the gateway rejects is not a conformance reference.
That leaves the spec itself stating a number that cannot be enforced as written. Not your problem in this PR, and worth fixing separately so the next person implementing from that document does not reach the same conclusion the hard way.
Third one of these today, and each time you have named the thing you were unsure about rather than hoping nobody looked. That is why they are quick to review.
Approving and merging.
agentrust-io#562 was closed as completed by agentrust-io#570, so it is no longer a live tracker for the MAX_REQUEST_BYTES mismatch this section defers to. Filed agentrust-io#573 for that question specifically and points there instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg
* docs: make proxy-security.md's MAX_STRING_LENGTH derivable (#562) Follow-up from the #570 review. The document specs a per-string cap as a literal 1MB, which is the same number as the whole-body cap both implementations enforce. An implementer following the text literally produces a check that cannot fire, because a string at that size is already a request the body-size check rejects. That is worse than having no check. A missing control is visible as missing. A present one that cannot trigger passes review, passes an audit read of the source, and counts toward this Definition of Done. States the cap as a derivation of MAX_REQUEST_BYTES and writes down the invariant, so a later change to the body cap moves the string cap with it instead of silently recreating the unreachable condition at a new ratio. This matches what shipped in #570. Also records the open disagreement rather than resolving it: this document says 10MB and both implementations enforce 1MB, and nothing says whether that was deliberate tightening or drift. Left on #562 for a maintainer call, with a note telling implementers to follow the enforced cap meanwhile rather than raising a body cap to match the doc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg * docs: point the open body-cap question at #573 #562 was closed as completed by #570, so it is no longer a live tracker for the MAX_REQUEST_BYTES mismatch this section defers to. Filed #573 for that question specifically and points there instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #562.
docs/spec/proxy-security.md's Fuzzing Definition of Done specsMAX_STRING_LENGTH = 1 * 1024 * 1024 # 1MB per string field. It is not implemented anywhere insrc/orscripts/. The depth and key-count caps that landed in #556 and #561 bound how deep and how wide a payload is, not how large any one piece of it is, so a single oversized string sitting inside an otherwise shallow, low-key-countargumentsobject passes both of them unbounded, up to whatever the whole-body byte cap happens to be.This extends the existing
_arg_shape_violationwalk in both files with a UTF-8 byte length check.Why the cap is not the spec's literal 1MB
This is the part I would look at first if I were reviewing it.
The spec's
MAX_STRING_LENGTHis 1MB.MAX_REQUEST_BYTESis also 1MB in both files today. A 1MB string plus the JSON structure around it already exceeds the whole-body cap, so a check at the literal spec value could never fire before DOS-001's size rejection already had. Implemented at the spec's number, this would have been dead code that reads like a control.So the cap is half the whole-body limit instead, and it is derived from a named constant rather than a restated literal:
Hoisting the constructor default into
_DEFAULT_MAX_REQUEST_BYTESis what makes that derivation possible without stating1_000_000twice. Raising the default now carries the string cap along with it instead of silently leaving it behind at a stale absolute number.Verified reachable rather than assumed: an over-cap string is 500,101 bytes on the wire against a 1,000,000 byte body cap, and an over-cap key is 500,099. Both land inside the window where the string check is the thing that rejects them.
This is scoped against the default
max_request_bytes. A deployment that configures something smaller just has the whole-body cap bind first, which is a safe direction to fail in rather than a gap.Object keys, not only values
_MAX_ARG_KEYSbounds how many keys an object has, not how large each one is. A single huge key would otherwise pass every check in this function, so keys are measured against the same cap._object_shape_violationwas extracted to hold that without pushing_arg_shape_violationpast the complexity limit.Byte length, not character count
Measured as UTF-8 bytes. A codepoint count understates the real memory and processing cost of multi-byte text, and it is the byte length that the whole-body cap is already denominated in, so the two caps now speak the same unit. There is a test for a string that is under the cap by characters and over it by bytes.
What I am deliberately not doing here
MAX_REQUEST_BYTES, 10MB in the spec versus 1MB in both implementations. Flagged on #562 rather than resolved. It is not obvious whether 1MB was a deliberate tightening or spec drift, and picking a number is a maintainer call, not one to make inside a change scoped to a different constant. This PR works around the mismatch rather than resolving it.MAX_PARSE_TIME_MS = 100. Also unenforced anywhere, also left open on #562. A real wall-clock bound onjson.loadsneeds either a signal-based timeout, which does not work on the Windows CI legs this repo runs, or an executor or subprocess. That is a design decision rather than a missing check.params.nameis not shape-checked. It is a string field, so the spec'sMAX_STRING_LENGTHarguably covers it, but it sits outside theargumentswalk this PR extends and is bounded by the whole-body cap today. Naming it rather than widening the scope of a change that already touches two files.server.pystill does not requireargumentsto be an object, while the mock does. That gap was raised on #561 and left alone deliberately. One behaviour note for the record: a bare oversized string passed asargumentsnow gets rejected by the string cap on the gateway side, where before it fell through the shape walk untouched. Stricter, in the safe direction, but it is a change on a path that was previously discussed.On the duplication
Worth stating plainly, since it was noted on #561 that the caps are kept in sync by comment reference rather than by shared code. This change makes that worse, not better: it is now three constants and three functions duplicated across
scripts/mock_upstream.pyandsrc/cmcp_runtime/mcp/server.py.The reason is that
scripts/mock_upstream.pyimports stdlib only. Giving it a shared module to import fromcmcp_runtimewould cost it the standalone property that lets it run as demo scaffolding fromdocker-compose.ymlanddocs/quickstart.mdwithout the package installed. I did not think that trade was mine to make unilaterally, so I kept the duplication and the comment-reference sync. If the preference is a shared module and the mock taking a dependency on the package, that is a small follow-up and I am happy to do it.The two copies are byte-identical, which is at least checkable.
Verification
tests/unit/test_mock_upstream_gate.pyandtests/unit/test_mcp_server_auth.py: 90 passed, 9 of them new. Both suites drive the real handler over a socket or the real ASGI app rather than reimplementing the rules.tests/unit: 1338 passed. The 6test_startup.pyfailures I see locally areFileNotFoundError: 'tpm2_pcrread', reproduce identically on a cleanorigin/mainwith this diff stashed, and are a missing tpm2-tools binary on my machine rather than anything from this change.ruff checkon all four touched files is clean apart from one pre-existingT201 printatscripts/mock_upstream.py:241, confirmed pre-existing on main the same way. Left alone as unrelated.Based on
origin/mainat a2893da, after #556 and #561 landed, so the diff here is only this change.