Skip to content

fix(security): close the round-2 TPA red-team findings — scanned baseline, diacritics, tr/el corpus, name squat (R2-1..R2-5) - #1714

Open
Dumbris wants to merge 14 commits into
mainfrom
fix/tpa-round2-protections
Open

Dumbris wants to merge 14 commits into
mainfrom
fix/tpa-round2-protections

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 20, 2026

Copy link
Copy Markdown
Member

Summary

Round-2 TPA red team (2026-10-20) against the hardened detect engine and tool-level quarantine: every finding was reproduced on this machine first, then closed with one commit-sized change per component, each verified end to end on the local instance and cross-reviewed. Report: docs/research/2026-10-11-tpa-attack-lab/round2-2026-10-20.md (spec, evidence, post-fix results, review rounds).

Finding Before Protection After
R2-1 (HIGH) auto-baseline poisoning: an unquarantined first-seen server auto-approved its whole toolset with no scan; an 18-vector poisoned catalog was live, indexed and callable R2-1 HOLE (22 approved rows, poisoned tool called) P1 internal/runtime/tool_quarantine.go: the first-seen toolset is scanned once with scanner.ScanToolMetadataVerdict (peers included, the same fail-closed seam trust_mode: scan uses); verdict read per tool — clean → auto-baseline, hard/soft finding → pending with hold evidence, degraded scan → every tool pending. Follow-ups: the stranded-pending promotion (MCP-2931 #4) is gated on the same scan (an all-held toolset writes no marker, so the next identical list used to promote it); a clean verdict never erases existing hold evidence R2-1 HOLDS (both poisoned vectors pending, benign controls still auto-baseline, call BLOCKED)
R2-3 Turkish/Greek overrides scan clean 4 BYPASS rows P3 phrase_injection_i18n.go: el verb-first family (final-sigma-insensitive); tr SOV override + verb-first + system-prompt disclosure families, imperative suffixes only, consumed trailing boundary (phraseFamily.trailingBoundary) so suffix negation stays clean; corpus now de fr es it pt nl pl ru uk el tr zh ja ko DOWNGRADED (soft, like every non-English family)
R2-4 combining diacritics defeat de-obfuscation 2 BYPASS rows P2 deobfuscate.go: NFD → homoglyph fold → strip Mn on Latin bases → NFC, on the phrase view and the mixed-script view; other scripts' marks untouched BLOCKED (hard)
R2-5 name squat with rewritten description (round-1 survivor) 1 BYPASS P4 shadowing.go: SOFT shape 1b — distinctive shared name, genuinely different description, no own-server provenance in the text; MCP-3520 pair unchanged DOWNGRADED (soft)
R2-2 unquarantine race (control) HOLDS — HOLDS

Offline harness: 79 blocked / 40 downgraded / 8 BYPASS → 83 / 44 / 0 BYPASS, every class change upward.

Verification (all on this machine)

  • go build ./..., go vet, go test ./internal/runtime ./internal/qapilot/... ./internal/security/... ./internal/storage/... ./tests/tpa-attack-lab green; CI-config golangci-lint (bare and -tags server) clean except the pre-existing event_bus.go:1020 reflect.Ptr nit (untouched).
  • scripts/tpa-lab-redteam-experiments.sh (fresh state, full rebuild): suite result: PASS (every experiment holds) — EXP-4 (TOCTOU) now holds at discovery and was re-premised accordingly (an approved poisoned first-seen contract is scored HOLE).
  • SKIP_BUILD=1 scripts/tpa-lab-redteam-round2.sh: R2-1 HOLDS, R2-2 HOLDS, round-2 result: PASS.
  • go run ./cmd/tpa-redteam: 0 BYPASS, 0 baseline-miss.

Review

  • Multi-lens adversarial review (5 lenses × 3 skeptics per finding): 20 confirmed findings, all fixed or documented in the report (incl. the repeat-pass gap it found independently of the EXP-4 lab run).
  • opencode gpt-6.1-sol read-only cross-review, 3 rounds: REQUEST_CHANGES (7, 6 fixed + 1 refuted with a pinning test) → REQUEST_CHANGES (1, fixed) → VERDICT: SHIP. Verbatim in the report.

Documented, not implemented (maintainer decisions)

  • llm_judge backstop for languages the corpus does not cover (own spec).
  • P4's provenance exemption is text-based and therefore gameable, the squat finding is symmetric/order-dependent, and the live ElevenLabs/Kaggle list_models descriptions name no server (both would be soft-flagged once). Keep the exemption, drop it, or demote the shape to info is a product call; the shape is a review hint, not a boundary.
  • The plain server-unquarantine door still promotes pending tools without a scan (R2-1's second reach); the informed review path shows findings first.

Rebased onto current main (Spec 118 auto-rescan, #1689): the only conflict was an adjacent insertion in tool_quarantine.go (kept both); one TOCTOU test assertion now pins Spec 118's evidence semantics (a moved pending definition is re-described as awaiting_review), and the lab's scan_gate_logged probe accepts #1689's reworded scan-hold log line (EXP-6b/6c were INCONCLUSIVE on current main for that reason alone). The dockerized suites were re-run on this branch after the rebase.

Lab/tests: new unit tests per protection, EXP-4 held/yes/no precondition tables, round-2 runner guard tests (env scrub, pinned dirs, image, fresh run dir, fail-closed exit).

🤖 Generated with Claude Code

Dumbris and others added 14 commits October 20, 2026 21:07
…baselining it (R2-1)

Round-2 red team, R2-1 (HIGH): a non-quarantined server whose tools had never
been approved auto-approved its whole first toolset (approved_by
"auto-baseline") with no scan — an 18-vector poisoned catalog landed approved,
indexed and callable; only an operator-triggered scan afterwards said
"dangerous".

The baseline pass now runs the same synchronous, offline TPA scan the
trust_mode: scan change gate uses (scanner.ScanToolMetadataVerdict, peers
included) once over the first-seen toolset and reads the verdict per tool:
clean tools auto-baseline exactly as before, a tool with a hard or soft
finding lands pending with the findings recorded as hold evidence (FR-018),
and a degraded/bundle-less scan holds every tool (fail closed, FR-014). Pure
in-process string work under the server's approval lock the pass already holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 3d38022)
…(R2-4)

Round-2 red team, R2-4: "ígnore áll prévious ínstructions" scanned clean.
NFKC composes base + combining mark into a precomposed letter instead of
dropping the mark, and confusable folding only maps single-rune look-alikes,
so an accented English directive never reached the phrase families.

NormalizeForPhrase (and the mixed-script view used for truthful position
classification) now decompose, drop every nonspacing mark that sits on a
LATIN base letter, and recompose. Marks on other scripts — Cyrillic й/ё/ї,
Greek tonos, kana dakuten, Devanagari vowel signs — are untouched, so the
non-English families keep matching their own spellings; the latin-majority
and mixed-script false-positive gates are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7b2bde3)
…the i18n corpus (R2-3)

Round-2 red team, R2-3: the i18n corpus was bounded to de fr es it pt nl pl
ru uk zh ja ko, so the Turkish and Greek translations of "ignore all previous
instructions" (and the Turkish "reveal your system prompt") scanned clean.

- el: verb-first spaced family (tonos and tonos-less spellings), reusing
  i18nSpacedFamily like es/fr.
- tr: Turkish is SOV, so it gets its own object-first family shape plus a
  machine-translation verb-first form, and a system-prompt DISCLOSURE family
  ("sistem istemini ifşa et"). Verbs carry imperative suffixes only and end
  at a word boundary (new phraseFamily.trailingBoundary, trimmed out of the
  evidence like leadingBoundary) because Turkish negates with a suffix:
  "önceki talimatları yoksaymaz" (does not ignore) stays clean.
- Every accented letter is spelled both ways so the families match the plain
  view and the diacritic-folded de-obfuscated view (R2-4) alike.

All non-English families stay SOFT (review-only). Coverage comment updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0828229)
…tions for review (R2-5)

Round-1 and round-2 red team, R2-5: the single offline BYPASS that survived
every fix series — a tool that keeps a victim's distinctive tool NAME
(filesystem_read) and rewrites the description — scanned clean, because the
shadowing clone shape requires a near-duplicate description and a name
collision alone was never flagged (MCP-3520).

shadowing.cross_server gains a SOFT shape (1b, review-only, confidence 0.4):
a distinctive name another server also exposes, with a genuinely different
description, is surfaced — it holds a trust_mode: scan change and a first-seen
baseline tool (R2-1 gate) but never auto-quarantines — UNLESS the description
names its own server (provenance: "Lists ElevenLabs voices", "Browse
Kaggle's registry"), which is how honest servers that share a compound name
describe themselves and what an impersonator cannot afford to write. The
MCP-3520 pair stays clean under that rule; common/short names and identical
sub-floor descriptions are unchanged (no signal).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fd25827)
…utations and the R2 experiment script

- cmd/tpa-redteam: translate-turkish, translate-greek, translate-korean,
  combining-diacritics, translate-turkish-combined mutations (R2-3/R2-4 probes).
- scripts/tpa-lab-redteam-round2.sh: R2-1 auto-baseline poisoning and R2-2
  unquarantine-race experiments against a real daemon with Docker-isolated
  upstreams (same safety model as the round-1 suite).
- handoff prompt for the round-2 verification/fix engagement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e64b381)
…line passes (R2-1 follow-up)

The EXP-4 TOCTOU lab run exposed a gap in the first R2-1 fix: when EVERY
first-seen tool is held by the baseline scan, no baseline marker is written
(FR-025), so the next identical tools/list is again a baseline pass and the
"stranded pending record, unchanged since recorded" promotion (MCP-2931 #4)
approved the held tool as auto-baseline and cleared its scan evidence.

The promotion now also requires the baseline scan to be green for the tool.
The hold evidence on a pending record is refreshed against the LIVE contract
on every baseline pass, so it always describes what the operator sees: a
poisoned contract stays pending with its evidence across any number of
repeats; a server that swaps to a clean contract gets only that clean
contract baselined (once seen unchanged), and a swap back is a rug pull.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9ecc8b0)
… name squat is review-only

- EXP-4 (TOCTOU) premised on discovery APPROVING the poisoned list-1 contract.
  Since R2-1 that contract is scanned and held at discovery, so the
  precondition now reads the daemon log too: 'held' (both poisoned tools
  held with a dangerous verdict) is the HOLDS premise, 'yes' (a poisoned
  first-seen contract approved) is scored HOLE as an R2-1 regression, and
  'no' stays INCONCLUSIVE. Stricter than before: the hole is caught one gate
  earlier and an approved poisoned contract can no longer score HOLDS.
- lab_test: the M05 name squat must be surfaced for review (soft) and must
  never be HARD (MCP-3520 unchanged).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit bc01f01)
…ing record already carries

Follow-up to the repeat-pass gate: the baseline gate only WRITES evidence
(why the live contract is held); a clean verdict on a pass that does not
promote leaves existing evidence alone — a legacy collapsed lock adopted with
its scan signals (Spec 105 FR-009), or the refused first contract of a TOCTOU
swap — so the review screen keeps showing what was refused until promotion
(ClearScanHold) or an operator approval clears it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit add399e)
Adversarial review (5 lenses, 3 skeptics per finding) of the round-2 series:

- R2-4: decompose (NFD) BEFORE homoglyph folding and strip Latin-base marks
  AFTER it, so a combining mark on a Cyrillic/Greek look-alike base (а́ll,
  ignόre) folds to the plain word instead of leaving a residual accented Latin
  letter that downgraded the override to the squashed soft pass. Pure-ASCII
  fast path; non-ASCII text is always recomposed.
- The mixed-script view's finding detail says 'diacritics on Latin letters'
  when no word mixes scripts (detect.HasMixedScriptWords); the #1589
  line-break cap folds the raw word stream the same way as the mixed view so
  an accented matched word no longer escapes the cap.
- R2-3 Greek: final sigma is matched as [σς] (strings.ToLower maps Σ to σ
  word-finally, so an ALL-CAPS override scanned clean).
- R2-3 Turkish: 'edin/ediniz' imperative of 'et' compounds (ifşa edin, iptal
  edin, göz ardı edin, reddedin); bare-ASCII 'paylas' survives Normalize's
  stem(); verbal nouns that double as negative imperatives (uyma, izleme,
  uygulama, takma, umursama, dikkate alma) count only in the polite form or
  clause-finally; 'mevcut' dropped from the qualifiers; 'istem' spelled with
  its case endings so 'istemci' (client) does not match; the disclosure
  family takes 'göster/yazdır/açıkla/tekrarla' only on the prompt noun or
  after 'gizli', so 'Sistem mesajlarını göster' stays clean.
- cmd/tpa-redteam: mutation descriptions no longer claim tr/el/ko are
  uncovered.
- scripts/tpa-lab-redteam-round2.sh: R2-1 HOLDS requires BOTH poisoned
  vectors pending-or-worse AND a blocked call; an empty or unparsed status is
  INCONCLUSIVE, an approved vector is a HOLE.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit d8c22a5)
…ter the Sol 6.1 cross-review

opencode gpt-6.1-sol REQUEST_CHANGES, 7 findings; 6 fixed, 1 refuted:

- (refuted, pinned) trust_mode: auto is a lifted gate (IsQuarantineSkipped →
  !enforceNewTools), so it never reaches the baseline scan and the new-record
  and pending-record branches cannot disagree under auto; the scan is the
  manual/scan-mode baseline's gate by design. Test
  TestCheckToolApprovals_AutoTrustMode_IsNotScanGated.
- Turkish disclosure family: 'istem' with bounded case endings, so 'Sistem
  istemcisini göster' (show the system client) stays clean.
- exp4_poison_recorded honours the record status: an APPROVED poisoned
  summarize_text is 'yes' (R2-1 regression), a PENDING/changed one is the
  hold itself ('held'); the export lists pending records too.
- scripts/tpa-lab-redteam-round2.sh: sources the lab lib; lab_scrub_mcpproxy_env
  before anything runs; --data-dir/--listen pinned; lab_config_require before
  serve; lab_config_use_image so IMAGE is the image under test; fresh mktemp
  run dir per invocation (round2-latest symlink) so R2-1 is always first-seen;
  lab_port_busy preflight without killing; result lines printed as they land;
  fail-closed exit via suite_exit_status.
- Lab tests: round-2 runner ignores inherited overrides and pins flags,
  refuses an unchecked template without invoking the binary, uses a fresh run
  dir per invocation; EXP-4 tables carry real export statuses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 609c5d8)
…oval of either is the regression

Sol 6.1 round-2 review, finding 1: the stored-record path set 'held' from
summarize_text alone, so a run that held the summary but approved the
poisoned convert_currency scored HOLDS. A second marker identifies the
poisoned currency contract; 'yes' (HOLE) on an approved poisoned record of
either tool takes precedence, 'held' needs both tools pending/changed with
their poisoned descriptions (or the daemon-log line that already required
both), and mixed-status cases are pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a2ba913)
…e and name-squat shape in the feature docs

- round2-2026-10-20.md: per-finding before/after, the commands run and
  their output, EXP-4 under R2-1, the unit tests added, the adversarial
  review round (20 confirmed findings and their outcomes), the three
  opencode gpt-6.1-sol cross-review rounds verbatim (REQUEST_CHANGES ×2 →
  SHIP), and what was considered but not implemented (llm_judge backstop,
  whole-server hold, the plain unquarantine door, the P4 exemption limits).
- security-quarantine.md / tool-quarantine.md: the trusted first-seen
  baseline is scanned before it is trusted; flagged tools land pending with
  evidence; the server-approval promotion does not re-scan.
- tool-scanner.md / security-quarantine.md / agent-tokens.md: the soft
  name-squat shape of shadowing.cross_server and its provenance exemption.
- CLAUDE.md: one clause on the scanned baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f349c77)
…ntract under the baseline gate

On main the Spec 118 evidence scan (#1689) re-describes a pending record whose
definition moved, so a TOCTOU swap to a clean contract reads awaiting_review
(current definition) rather than keeping the refused contract's
scan_findings. The baseline gate still never erases evidence itself; the
assertion now pins the merged behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…log line

#1689 reworded the synchronous scan's non-green hold line (one line now for
the scan-mode gate and the manual-mode evidence scan), so EXP-6b/6c's
scan-gate proof came back INCONCLUSIVE on current main. The probe accepts the
new wording alongside the old; the generic 'changed since approval' line still
does not count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: d0e9a17
Status: ✅  Deploy successful!
Preview URL: https://914026c8.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-tpa-round2-protections.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Oct 20, 2026 •

Copy link
Copy Markdown

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

Codecov Report

❌ Patch coverage is 75.19380% with 64 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/tpa-redteam/main.go 0.00% 52 Missing ⚠️
internal/security/detect/checks/shadowing.go 90.38% 3 Missing and 2 partials ⚠️
internal/security/detect/deobfuscate.go 83.33% 5 Missing ⚠️
internal/runtime/tool_quarantine.go 96.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/tpa-round2-protections

Available Artifacts

  • archive-darwin-amd64 (32 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (28 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (25 MB)
  • smart-mcp-proxymcpproxy-goOA4ZAM.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 39473821545 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris pushed a commit that referenced this pull request Oct 22, 2026
# 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 cap

`truncateResponse` is the only thing bounding a single activity record.
It now goes through `truncateRuneSafe`, 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 `response`
payload of an activity event, and `ActivityService.handleEvent` →
`truncateForStorage` → `storage.TruncateActivityResponse` →
`truncateResponse` applies `activity_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 in
`config.db` malformed, and from there in `GET /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 violation `Reason`

A `jsonschema` message quotes the offending instance location, so a
non-ASCII property name or value sits astride the cut.

**Reachability.** `Validator.Validate` calls `truncate(err.Error(),
500)` (`validator.go:157`) on any structured tool output that fails its
`outputSchema`; the `Reason` goes 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 cap

Registry descriptions are upstream-authored free text.

**Reachability.** `parsePulseWithoutGuesser` fills
`ServerEntry.Description` from `short_description` /
`EXPERIMENTAL_ai_generated_description` for 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:
- The CLI table cells in `cmd/mcpproxy` that #1664 also left alone —
sent separately.
- `cmd/mcpproxy/security_cmd.go` and `cmd/mcpproxy/main.go` have the
same shape but are under open PRs (#1714, #1720, #1725), so I have not
touched them.
- `internal/configimport.SanitizeServerName` and
`internal/dockernaming.SanitizeServerName` also 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

- [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, each failing by name at the base SHA (`14cb288`) and passing
after:

| Test | Package |
|---|---|
| `TestTruncateResponseKeepsRuneBoundary`,
`TestTruncateResponseASCIIUnchanged` | `internal/storage` |
| `TestViolationReasonIsValidUTF8`, `TestTruncateASCIIUnchanged` |
`internal/outputvalidation` |
| `TestPulseDescriptionTruncationIsValidUTF8` | `internal/registries` |

Each 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):

```
git diff --check                                                            [0]
gofmt -l internal/storage internal/outputvalidation internal/registries     [0, no output]
go vet ./internal/storage ./internal/outputvalidation ./internal/registries [0]
go test ./internal/storage ./internal/outputvalidation ./internal/registries [0]
go test ./internal/runtime -run 'TestToolCallResponseTruncatedForStorage|TestInternalToolCallResponseTruncatedForStorage|TestStorageTruncatedBuiltinStillCountsDeliveredBytes|TestPromptGetResponseTruncatedForStorage|TestDefaultResponseCapAppliesWithoutConfig|TestEmitterTruncationFlagSurvivesUntruncatedRecord'  [0, 6/6 pass]
```

The `internal/runtime` selection 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 the `ResponseTruncated` flag
semantics are unchanged.

Not run: `./scripts/run-all-tests.sh` and `-race` (local disk
constraints on this machine). Nothing here is concurrent.
Dumbris pushed a commit that referenced this pull request Oct 22, 2026
# 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.

This branch has not been deployed

No deployments
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