Skip to content

fix: enforce a per-string length cap on tool arguments (#562) - #570

Open
Yatsuiii wants to merge 2 commits into
agentrust-io:mainfrom
Yatsuiii:fix/562-arg-string-length-cap
Open

fix: enforce a per-string length cap on tool arguments (#562)#570
Yatsuiii wants to merge 2 commits into
agentrust-io:mainfrom
Yatsuiii:fix/562-arg-string-length-cap

Conversation

@Yatsuiii

Copy link
Copy Markdown
Contributor

Closes #562.

docs/spec/proxy-security.md's Fuzzing Definition of Done specs MAX_STRING_LENGTH = 1 * 1024 * 1024 # 1MB per string field. It is not implemented anywhere in src/ or scripts/. 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-count arguments object passes both of them unbounded, up to whatever the whole-body byte cap happens to be.

This extends the existing _arg_shape_violation walk 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_LENGTH is 1MB. MAX_REQUEST_BYTES is 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:

_DEFAULT_MAX_REQUEST_BYTES = 1_000_000
...
_MAX_ARG_STRING_LENGTH = _DEFAULT_MAX_REQUEST_BYTES // 2

Hoisting the constructor default into _DEFAULT_MAX_REQUEST_BYTES is what makes that derivation possible without stating 1_000_000 twice. 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_KEYS bounds 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_violation was extracted to hold that without pushing _arg_shape_violation past 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 on json.loads needs 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.name is not shape-checked. It is a string field, so the spec's MAX_STRING_LENGTH arguably covers it, but it sits outside the arguments walk 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.py still does not require arguments to 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 as arguments now 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.py and src/cmcp_runtime/mcp/server.py.

The reason is that scripts/mock_upstream.py imports stdlib only. Giving it a shared module to import from cmcp_runtime would cost it the standalone property that lets it run as demo scaffolding from docker-compose.yml and docs/quickstart.md without 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

cap parity        : server 500,000 == mock 500,000
reachable         : over-cap string = 500,101 bytes on the wire vs the 1,000,000 body cap
                    over-cap key    = 500,099 bytes
helpers identical : diff across both files is empty

over-cap string value            -> string value over the length cap of 500000 bytes
over-cap object key              -> object key over the length cap of 500000 bytes
over-cap string in list          -> string value over the length cap of 500000 bytes
over-cap bare string arguments   -> string value over the length cap of 500000 bytes
at-cap string                    -> None          (boundary is >, not >=)
multibyte over cap by bytes only -> string value over the length cap of 500000 bytes
  • tests/unit/test_mock_upstream_gate.py and tests/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.
  • Full tests/unit: 1338 passed. The 6 test_startup.py failures I see locally are FileNotFoundError: 'tpm2_pcrread', reproduce identically on a clean origin/main with this diff stashed, and are a missing tpm2-tools binary on my machine rather than anything from this change.
  • ruff check on all four touched files is clean apart from one pre-existing T201 print at scripts/mock_upstream.py:241, confirmed pre-existing on main the same way. Left alone as unrelated.

Based on origin/main at a2893da, after #556 and #561 landed, so the diff here is only this change.

)

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
@Yatsuiii
Yatsuiii requested a review from a team as a code owner August 25, 2026 05:20
@codecov-commenter

codecov-commenter commented Aug 25, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

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
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.

proxy-security.md's MAX_STRING_LENGTH is spec'd but unenforced anywhere

2 participants