Repository navigation
fix: keep the last raw-byte truncations on a rune boundary - #1726
Conversation
Round 3 of the UTF-8-safe truncation sweep (smart-mcp-proxy#1647, smart-mcp-proxy#1664, smart-mcp-proxy#1665). Three production sites still cut text with a raw byte slice, so a non-ASCII value whose cut offset lands inside a multi-byte rune is published as invalid UTF-8 and JSON encoding rewrites the tail to U+FFFD. - internal/storage: truncateResponse caps the activity-log response at activity_max_response_size. It now goes through truncateRuneSafe, the helper the per-server tool-call records in the same package already use, so the stored copy, GET /api/v1/activities and the CSV export all stay valid UTF-8. - internal/outputvalidation: the 500-byte schema-violation Reason. A jsonschema message quotes the offending instance location, so a non-ASCII property name or value sits astride the cut. - internal/registries: the 300-byte Pulse description cap. Registry descriptions are upstream-authored free text. ASCII behaviour is unchanged at all three sites; the cut never exceeds the cap.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Dumbris
left a comment
There was a problem hiding this comment.
Verified on a merge with main: build, CI lint config, race tests for storage, registries and outputvalidation; cross-reviewed clean.
|
Merged. Thank you, @rudycelekli! That closes the last raw-byte truncation sites in Contributions like this are what keep MCPProxy moving, and they matter a lot to the project. One small ask, only if you feel like it: a short public note about your change (a tweet, a LinkedIn post, a line in a community you are part of) helps more people discover MCPProxy. No pressure at all, and I would be glad to repost it. |
…1728) # Pull Request ## Description ### The refusal names a character that is not in the name `ValidateName` walks the name byte by byte — correctly, that loop is the confinement boundary — but reported the offending character as `string(name[i])`. Converting a *byte* to a string yields the code point of the same value, so the first byte of `語` (0xE8) was rendered as `è`: ``` before: invalid script name "語": character "è" is not allowed (names are 1-64 characters of A-Z, a-z, 0-9, '-' or '_' …) after: invalid script name "語": character "語" is not allowed (names are 1-64 characters of A-Z, a-z, 0-9, '-' or '_' …) ``` A caller is told to remove a character it never sent. A byte that begins no valid rune is not a character at all, and is now reported as one: ``` before: invalid script name "a\x80b": character "\u0080" is not allowed … after: invalid script name "a\x80b": byte 0x80 is not allowed … ``` **Reachability.** `codescripts.Resolve` / `ResolveScoped` call `ValidateName` before any filesystem access (`codescripts.go:394`), and `internal/httpapi/code_exec.go:244` maps `*InvalidNameError` straight to an HTTP 400 `INVALID_SCRIPT_NAME` whose message body **is** `invalidName.Error()`. So any `code_execution` call naming a non-ASCII script — i.e. the exact case the validator is there to refuse — returns this message to the agent. The input is one the caller chooses freely; nothing upstream narrows it to ASCII first. Note the order of the checks: the byte-length check fires first, so the mojibake path needs a *short* non-ASCII name (`語` is 3 bytes, under the 64 limit) and the echo path below needs a long one. Both are ordinary requests. ### The echoed name is cut inside a rune `truncateForMessage` bounds the echoed name at `MaxNameLen+16` bytes with a raw byte slice. For a long non-ASCII name the cut lands inside a rune and `%q` then renders the dangling bytes as `\xNN` escapes, so the refusal ends in hex noise instead of the name it is quoting. It now goes through `stringutil.UTF8Prefix`, the helper added in #1664 (see also #1647, #1665, and #1726 / #1727 which finish that sweep elsewhere). The byte-wise validation loop, the confinement property and every ASCII refusal are unchanged. ### One thing I did *not* change `len(name) > MaxNameLen` is a **byte** check, while `MaxNameLen` is documented and reported as a **character** count ("names are 1-64 characters"). They agree for every name the token can accept, since a valid name is ASCII — but a 30-character Japanese name is refused as "name is 90 characters long", which reads as a contradiction against a 64-character limit. Reporting the rune count instead makes it worse ("name is 30 characters long" against a 64-character limit), because the real divergence is in the check, not the message. That is a product decision about whether the limit is bytes or characters, so I left it exactly as it is rather than guess. Happy to follow up either way. ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass New tests in `internal/codescripts/invalid_name_message_test.go`: | Test | Asserts | |---|---| | `TestInvalidNameErrorNamesTheRealCharacter` | the refusal contains `語` and does not contain `è` | | `TestInvalidNameErrorReportsMalformedByte` | a stray `0x80` is reported as `byte 0x80` | | `TestInvalidNameErrorEchoIsNotCutInsideARune` | 3 byte offsets inside a 3-byte rune; no `\x` escape survives in the echo | | `TestValidateNameASCIIMessagesUnchanged` | pins the full text of the empty / disallowed-character / oversized ASCII refusals, and that a valid name is still accepted | **Mutation check.** With `internal/codescripts/codescripts.go` reverted to the base SHA (`14cb288`) and the test file kept, the first three fail by name and `TestValidateNameASCIIMessagesUnchanged` passes — which is what shows the change is behaviour-preserving for ASCII. With the fix applied all four pass. Commands run (exit status in brackets): ``` gofmt -l internal/codescripts [0, no output] git diff --check [0] go vet ./internal/codescripts [0] go test -count=1 ./internal/codescripts [0, whole package] ``` Not run: `-race` and `./scripts/run-all-tests.sh` (local disk constraints on this machine). Nothing here is concurrent.
# Pull Request ## Description Finishes the UTF-8-safe truncation sweep of #1647 / #1664 / #1665 in the part of `cmd/mcpproxy` those PRs explicitly set aside ("I left the CLI table description truncations in `cmd/mcpproxy` alone to keep this focused" — #1664). Companion to #1726, which covers the remaining `internal/` sites. Four table cuts existed as independent copies of the same arithmetic, and all four cut at a raw byte offset: | Site | Column | Data | |---|---|---| | `shorten` (`profile_cmd.go`) | 30 | `EffectiveServers`, comma-joined | | `truncateStr` (`registry_cmd.go`, `catalog_cmd.go`) | 60 / 45 / 40 | registry `description`, catalog `Title` | | inline `desc[:57] + "..."` (`upstream_cmd.go`) | 60 | upstream tool `Description` | | inline `result[:maxLen-3] + "..."` (`token_cmd.go`) | 23 | `allowed_servers`, comma-joined | **Reachability.** Every one of these cells is upstream- or operator-authored text: - `mcpproxy registry list` / `registry search` print descriptions straight from the registry API response (`mapString(r, "description")`), and `catalog list` prints `r.Title`. Both are free text from a public registry. - `mcpproxy upstream tools` prints the tool `Description` advertised by the upstream server. - `allowed_servers` and `EffectiveServers` are server names, which the config only forbids from being empty or containing `:` (`internal/config/server_edition_config.go:176`) — a non-ASCII name is legal. A value longer than its column whose byte at the cut offset is a UTF-8 continuation byte ends in a partial rune on the terminal. With a 3-byte rune, 2 of every 3 offsets are mid-rune; the test sweeps all three at each of the six real column widths. All four now go through one `truncateCell` built on `stringutil.UTF8Prefix`, in a file of its own so a fifth table does not grow a fifth copy. `joinInterfaceSlice` keeps `maxLen == 0` meaning "do not truncate". ASCII output is byte-for-byte unchanged and the result still never exceeds the column width — both asserted. **Left alone, deliberately:** - `cmd/mcpproxy/security_cmd.go` and `cmd/mcpproxy/main.go` have the same shape but are touched by open PRs (#1714, #1720, #1725), so I have not gone near them. Happy to follow up once those land. - `activity_cmd.go`'s `actType[:12]` and `upstream_cmd.go`'s `hash[:12]` cut an internal activity-type enum and a hex digest, where no multi-byte rune can reach the boundary. Not touched — a guard there would protect against a state the code cannot produce. ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass New tests in `cmd/mcpproxy/table_cell_utf8_test.go`: - `TestTruncateCellKeepsRuneBoundary` — 6 real column widths x 3 byte offsets. - `TestTruncateCellASCIIUnchanged` — pins the ASCII output of all four removed helpers, including the `maxLen <= 3` edge. - `TestJoinInterfaceSliceTruncationIsRuneSafe` — drives the composition (`joinInterfaceSlice` -> `truncateCell`), not just the helper, and pins `maxLen == 0`. **Mutation check.** With `truncateCell` reverted to the raw byte cut (`s[:maxLen-3] + "..."`) the two boundary tests fail by name — 12 sub-cases in `TestTruncateCellKeepsRuneBoundary`, plus `TestJoinInterfaceSliceTruncationIsRuneSafe` — while `TestTruncateCellASCIIUnchanged` still passes, which is what shows the change is behaviour-preserving for ASCII. Restoring the fix returns the package to green. Commands run (exit status in brackets): ``` gofmt -l cmd/mcpproxy [0, no output] git diff --check [0] CGO_ENABLED=0 go vet ./cmd/mcpproxy [0] CGO_ENABLED=0 go build ./cmd/mcpproxy [0] CGO_ENABLED=0 go test -count=1 ./cmd/mcpproxy [0, whole package] ``` `CGO_ENABLED=0` because this machine's Command Line Tools SDK is currently broken for cgo linking (`arm64e.x1-macos: unknown architecture` out of `CoreFoundation.tbd`), which is unrelated to this change. Nothing here is cgo-dependent. I did not run `-race` or `./scripts/run-all-tests.sh` (local disk constraints); none of this code is concurrent.
Pull Request
Description
Round 3 of the UTF-8-safe truncation sweep started in #1647 and continued in #1664 / #1665. Three production sites still cut text with a raw byte slice (
s[:n]), so a non-ASCII value whose cut offset lands inside a multi-byte rune is published as invalid UTF-8, and JSON encoding rewrites the tail to U+FFFD.1.
internal/storage— the activity-log per-record captruncateResponseis the only thing bounding a single activity record. It now goes throughtruncateRuneSafe, the helper the per-server tool-call records in the same package (internal/storage/tool_call_bounds.go) already use for exactly this reason.Reachability. An upstream tool response arrives as the
responsepayload of an activity event, andActivityService.handleEvent→truncateForStorage→storage.TruncateActivityResponse→truncateResponseappliesactivity_max_response_size(default 64 KB) on the write path (internal/runtime/activity_service.go:267). Any response over the cap whose byte at the cut offset is a UTF-8 continuation byte — ordinary CJK, Cyrillic, or emoji text — lands inconfig.dbmalformed, and from there inGET /api/v1/activities,GET /api/v1/activities/{id}and the CSV export. With a 3-byte rune, 2 of every 3 cut offsets are mid-rune.2.
internal/outputvalidation— the 500-byte violationReasonA
jsonschemamessage quotes the offending instance location, so a non-ASCII property name or value sits astride the cut.Reachability.
Validator.Validatecallstruncate(err.Error(), 500)(validator.go:157) on any structured tool output that fails itsoutputSchema; theReasongoes back to the agent and into the audit line. Measured on the pre-fix code with a 200-rune non-ASCII property name, shifting the cut by one byte at a time: offsets 0 and 2 produce invalid UTF-8, offset 1 is valid — i.e. 2 of 3.3.
internal/registries— the 300-byte Pulse description capRegistry descriptions are upstream-authored free text.
Reachability.
parsePulseWithoutGuesserfillsServerEntry.Descriptionfromshort_description/EXPERIMENTAL_ai_generated_descriptionfor every result of a Pulse registry search, which the REST API and the Web UI catalog then JSON-encode. Same 2-of-3 pattern in the test.The 300 literal is now a named constant (
pulseDescriptionMaxBytes) since it appeared twice.ASCII behaviour is unchanged at all three sites, and the retained prefix never exceeds the cap.
Out of scope, deliberately, so this stays reviewable:
cmd/mcpproxythat fix: do not cut multi-byte runes when truncating error and diagnostic text #1664 also left alone — sent separately.cmd/mcpproxy/security_cmd.goandcmd/mcpproxy/main.gohave the same shape but are under open PRs (fix(security): close the round-2 TPA red-team findings — scanned baseline, diacritics, tr/el corpus, name squat (R2-1..R2-5) #1714, feat(security): Spec 101 US3 TPA bundle refresh lifecycle and publication channel #1720, fix(runtime): require an access token for OAuth status #1725), so I have not touched them.internal/configimport.SanitizeServerNameandinternal/dockernaming.SanitizeServerNamealso cut at a byte offset, but both strip everything outside[A-Za-z0-9._-]first, so no multi-byte rune can reach the cut. Left as they are.Testing
New tests, each failing by name at the base SHA (
14cb288) and passing after:TestTruncateResponseKeepsRuneBoundary,TestTruncateResponseASCIIUnchangedinternal/storageTestViolationReasonIsValidUTF8,TestTruncateASCIIUnchangedinternal/outputvalidationTestPulseDescriptionTruncationIsValidUTF8internal/registriesEach boundary test sweeps every offset inside a 3-byte rune rather than asserting one fixed cap, so it cannot pass by luck.
Commands run (exit status in brackets):
The
internal/runtimeselection is the end-to-end activity-truncation suite that drives the storage cut through the real event path, included to show the write-path behaviour and theResponseTruncatedflag semantics are unchanged.Not run:
./scripts/run-all-tests.shand-race(local disk constraints on this machine). Nothing here is concurrent.