Repository navigation
feat(security): Spec 101 US3 TPA bundle refresh lifecycle and publication channel - #1720
Merged
Merged
Conversation
…e-protect sweep Re-validates Spec 087 and Spec 101 US3 against main @ 14cb288 and appends the US3 task list (T300-T396, six Phase-1 groups) to specs/101-tpa-db/tasks.md. Spec 087 is marked implemented by Spec 101 US3: its internal/security/bundle package was never built; the refresh is a thin adapter over the US1 Candidate pipeline (D11). Related #938 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… amendments T300: amend Spec 101 spec.md (SC-007, FR-016a(c), FR-017, FR-019), Spec 087 spec.md (mapping note, SC-009, FR-013/014/016/017, hot-reload edge case, release-awareness pointers) and Spec 087 plan.md (superseded banner). T301: stub every symbol the US3 tests reference, each in the file that will hold its final implementation, so the six Wave 1 groups replace function bodies instead of moving stubs. No behaviour changes: config keys and accessors (defaults only), BundleInfo freshness fields, BundleSourceFetch, externalConfigured (path-only), runOffered (delegates to a normal run), evalEnv.MustSign (ignored), refresher/fetch/observer/self-check/doctor stubs, Service.RefreshBundle and StartScanFromCachedContracts, the SecurityController and securityScannerService methods with their fakes, reprotect handler stubs, telemetry recorders, runtime refresher slot and RefreshHeldScanEvidence, and the BundleReprotectState storage pair. Also allowlists Config.Security.TPARefreshInterval in the zero-value preservation test so existing tests stay green (0 and absent both mean 24h). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… surface (Spec 101 US3 T370) Fails on the stub tree with 404 for POST /security/bundle/refresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e and purge isolation T364: the marker is the startup catch-up's only memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Field declarations only so the G4 tests compile; the registry wiring, recorders, whitelist and schema bump follow in the implementation commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… provenance Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T369 (storage half): real BundleReprotectState Save/Load in MetaBucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… redaction (Spec 101 US3 T310-T311) Defaults, nil-safety, 1h interval clamp, MCPPROXY_TPA_FETCH_ENABLED both ways and never persisted, write-time-only shape checks (interval, scheme, userinfo, query, fragment, empty URL, operator root without signers, negative stale days) with messages that never echo the URL, boot acceptance, JSON round-trips, RedactedConfig/GET-PATCH pass-through and hot-reload change detection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntime warnings (Spec 101 US3 T372, T374) Fail on the stub tree: no refresh subcommand, no Last checked / Refresh / Re-protection / STALE lines, and a plain-string runtime warning is counted in total_issues but printed nowhere by doctor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (Spec 101 US3 T340-T342) Failing tests for the real activation self-check (Spec 087 FR-008, tasks B13/B14): one-rule regex boundaries over the corpus, vacuous-corpus fail-closed cases, checks-failed rejection, detail without pattern text, corpus byte parity, gate AST parity (tolerant to both gate.go shapes), baseline hard-flag parity, provenance, an optional gate CLI differential, and the pipeline behaviour after EnableActivationSelfCheck. tpa_selfcheck.go gains stubs for the helper symbols the tests reference (selfCheckBuiltins, selfCheckCategoryCheck, selfCheckCheckID, selfCheckEvaluate, embeddedSelfCheckCorpus); behaviour lands with T343. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five reset-on-accept integer counters on tpa_scanner (cycles, activations, rejections, unavailable, reprotect hits) wired through the registry, snapshot, drain, pending check and reset, the tpa_scanner isZero gate and the anonymity whitelist. source=fetch now maps through the server-side provenance gate; an operator-signed fetch reports other with no sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le (Spec 101 US3 T374) Fails on the stub, which returns no warnings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… counters Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… interval clamp (Spec 101 US3 T312) tpaRefreshErrors (write-time only, never boot-fatal, messages never echo the URL): interval under 1h, non-https URL unless loopback/localhost, userinfo, query, fragment, fetch enabled with an empty URL or with the operator root and no signers, negative stale days. IsTPAFetchEnabled reads MCPPROXY_TPA_FETCH_ENABLED bidirectionally inside the accessor (never persisted); EffectiveTPARefreshInterval clamps to MinTPARefreshInterval (1h). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…US3 T313) Rows for tpa_refresh_interval, tpa_fetch_enabled, tpa_fetch_url and tpa_stale_after_days plus MCPPROXY_TPA_FETCH_ENABLED, with the no-working- default-URL and fetch-needs-an-operator-root-today notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…blems (Spec 101 US3 T376) Fails on the current formatter: no Last checked / Last activated / Refresh / Re-protection rows and no refreshProblem. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin-only (ServerOpBundleManage), runs one joined refresh cycle through the SecurityController and answers with the resulting BundleInfo. Swag annotations added; make swagger runs once in wave 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Spec 101 US3 T373) Adds the refresh subcommand and extends signatureBundleLines (shared by status, overview and every mutation) with Last checked, Last activated, Refresh, Fetch host, STALE and Re-protection lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T360: start under the app context, join before StopAsync, clear the scanner slot, no goroutine after a Close racing a late Start. Compile prerequisite: the runtime field is an interface so the lifecycle is testable without the network scheduler; behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…374) The management Doctor emits runtime_warnings as strings; the pretty printer only rendered object entries, so a string warning was counted in total_issues (the all-clear verdict was suppressed) but listed nowhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pure BundleDoctorWarnings: stale (external/fetched, or fetch enabled), stuck on last-known-good (non-quiet rejection newer than the activation, or 3+ failed refreshes), and fetch enabled but inactive. No digest, path, URL or host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…blems (Spec 101 US3 T377) Adds Last checked / Last activated / Refresh / Fetch host / Re-protection rows and refreshProblem(s) mirroring the doctor's B16 conditions. The panel stays read-only; degraded remains coverage-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ose/Start pair Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T367 (lifecycle half): StartBackgroundInitialization launches the refresher under the app context; Close stops and joins it before the storage manager stops and clears the scanner slot when it still points at this instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T361: RefreshHeldScanEvidence updates only HeldVerdict/HeldSignals on pending and changed records (quarantined servers included), rescans a pending record discovery would skip, keeps a held tool held when a signature is removed, and never touches status, approved hash, approver or the server's quarantine flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T367 (evidence half): RefreshHeldScanEvidence re-scans the stored current definition of pending and changed records under the tool-approval lock and writes only HeldVerdict/HeldSignals (RT-02). Quarantined servers included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ional job T363: SourceMethod tool_definitions_only, accurate contract digests, zero upstream touches, no Pass 2, no telemetry, unscanned (never clean) without approved contracts, ErrScanInProgress when busy. Compile prerequisites: ScanContext.BundleDigest, ErrNoCachedContracts, the adapter's informational flag (no behaviour). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…details, gate-shape tolerance Adds the recall-leg bite test over a synthetic tpa_* corpus (the embedded corpus has none until US2), asserts the vacuity and corpus-unavailable details, keeps the gate.go parser honest for both the HEAD and the US2 shapes, and counts self-checks per evaluated candidate in the pipeline test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 101 US3 T343) Port of the CI gate (cmd/scan-eval --gate) into package scanner: a freshly compiled bundle is scored over the labeled detect corpus with the same decision (recall >= 0.90 over gated categories, hard-negative FP <= 0.05) and refused as gate_failure otherwise. Fails closed on an unparsable or vacuous corpus, on checks that panicked while scoring, and on a nil candidate; the rejection detail carries counts and corpus ids only. EnableActivationSelfCheck installs the hook under bundleRT.mu. The corpus is a byte copy of specs/065-evaluation-foundation/datasets/detect_corpus_v1.json embedded from bundled/selfcheck-corpus.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dle activates T368: StartScanFromCachedContracts builds an informational Pass-1 job from the approved records' cached contracts (tpa-descriptions only, no source dir, no upstream connection), labelled with the bundle digest. The scan callback skips Pass 2 and the anonymous scan telemetry for it. No contracts: ErrNoCachedContracts, no job, the server stays unscanned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- c1-fetch (multi-request fetch loses rate-limit backoff): fixed. The attempt now folds the RetryAt of every response (listing, asset downloads, direct bundle and sidecar, successful or not) into one value and applies it to every result, candidate and failure alike. - c2-refresh #1 (Start overwrites a concurrent Nudge): fixed. Nudge counts itself; Start initialises interval and fingerprint only when no Nudge ran since its config capture. - c2-refresh #2 (cancelled manual caller skips re-arm): fixed. The manual cycle's completion re-arm moved into executeCycle, with the early-nudge sequence captured when the cycle starts; the newer-early-re-arm protection is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1. Supersession during cached-scan preparation: FIXED. The bundle is now judged and pinned inside the registration critical section (StartScanFromCachedContracts, under the server approval lock): a request whose digest is no longer the active bundle's is refused with ErrBundleSuperseded (the sweep maps it to an abort, no retry), and the job is bound to the (check, info) snapshot read there (ScanRequest.pinnedBundle) so the asynchronous detector scans with the bundle the job is labelled with. An empty digest is labelled with the digest of the bundle actually used. 2. Completion bookkeeping consumed the server-wide latest summary: FIXED. New Service.GetScanJobSummary settles by the returned job.ID (terminal status and that job's findings; nil for another server, a replaced incarnation or a non-terminal job). The sweep uses it via an optional interface and treats a missing job verdict as no verdict (failure). RT-02 untouched: a cached job never approves anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sher tests A Nudge re-arms the refresher timer asynchronously. Ticking right after a reload could fire the timer the loop was about to replace, so the cycle never ran (seen on the linux/arm64 CI runner). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-arm the timer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- c7 duplicate refresh keys in the security object bypassed boot normalization: FIXED. Verified: with a malformed occurrence followed by a valid duplicate, map decoding kept only the last one so the normalizer was a no-op, then the typed decode failed on the first and the daemon would not boot (reproduced for all four refresh keys). The normalizer now scans every occurrence with an encoding/json token decoder; any duplicated (case insensitive) or malformed occurrence marks the key bad, all occurrences are dropped, fetch is forced off, and a value-free diagnostic is emitted. Write paths stay strict. Tests: tpa_refresh_load_sol_r4_test.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- P2 "a failure masks suspension" (c5a sweep / c5b scanner, same finding): FIXED. A sweep that both failed a leg and had cached scans skipped by the auto_baseline_scan kill switch returned reprotectSweepIncomplete, so the caller cleared st.suspended and a later re-enable never resumed the skipped scans (the digest stays active, so no new activation fires). Added reprotectSweepFailedSuspended: still reported as failed, still withholds the completion marker and resets st.last, but st.suspended stays true. The resume path reuses the completed-job dedup, so only unfinished servers are scanned and the failed legs are retried. Tests: TestReprotect_R4_*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- r4-c1-fetch (redirect responses lose rate-limit backoff): FIXED. The attempt's http.Client transport is wrapped in a RoundTripper observer that records Retry-After / X-RateLimit headers of every response in the chain, intermediate 3xx and redirects the client then refuses to follow included; the latest backoff is kept on every result. Tests: TestSolR4_Redirect*. - r4-c2-refresh (expired timer tick bypasses manual-completion re-arm): FIXED. executeCycle bumps manualDone in the same critical section that clears r.cycle; arm() records it; startTimerCycle checks staleness under r.mu and, for a tick armed before a completed manual cycle, consumes it and re-arms with nextDelay() (firstDelay() if a Nudge asked for an early look) instead of starting a cycle. Test: TestSolR4_StaleTimerTick* (via the new beforeTimerCycle seam). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Resumed same-digest sweep registered a second cached job for a server whose accepted job failed / timed out / had no readable verdict (r5-c5a and r5-c5b, identical): FIXED. Every accepted job (server, digest) -> job ID and outcome is remembered as soon as registration succeeds; a resume re-inspects that job by ID via the job-summary path (registering nothing), upgrades it to scanned if it has since produced a verdict, and otherwise counts it failed so the sweep stays incomplete and the completion marker is withheld. Busy (not-accepted) attempts still retry up to the existing cap. Tests: scan_bundle_reprotect_r5_test.go (failed job, timed-out job, late verdict read by job ID); verified failing before the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c7-surfaces P2 (fixed): the boot normaliser decoded the document into a map and re-marshalled it, which dropped a repeated/case-variant security object (e.g. require_signed_bundle) that the typed decoder would have merged, and re-ordered keys. It now never re-marshals: the raw bytes are scanned with encoding/json (InputOffset byte spans) for every occurrence of the four refresh keys in every top-level security object (case-insensitive), the malformed/duplicate decision is unchanged, and replacement value bytes are spliced in place (interval "0s", fetch_url/stale_after_days null, tpa_fetch_enabled false for every occurrence when any key is bad). Every other byte is preserved; an unscannable document is returned untouched. Regressions in tpa_refresh_load_sol_r5_test.go fail on the old implementation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the counter-based timer bookkeeping of rounds 3/4 (soonSeq, manualDone/armedManual, pending wake reasons, signalManualUnlessSoon) with one authoritative next-due time guarded by r.mu. Start, the completion of a cycle of any trigger, and a Nudge set it in the same critical section as the state change that causes it; starting a cycle clears it; a completion sets now+nextDelay (rate-limit reset when later) unless a Nudge asked for an early look after the cycle began, which stands. The loop only arms its timer to due; a tick that fires before due (with a fired timer counted as no earlier than the time it was armed for) is stale and re-arms without starting a cycle. Wakes only tell the loop to re-read due. - r5 #1 (P2, early Nudge timer discarded as stale after a superseded manual cycle): fixed. The manual completion keeps the early due, so the tick fires at due and runs a cycle. Regression: TestSolR5_EarlyLookArmedDuringASupersededManualCycleRuns. - r5 #2 (P2, manual wake consumed before cleanup published the counter, next daily tick discarded): fixed. Clearing r.cycle and setting due are one critical section and the loop is woken only after it. Regression: TestSolR5_ManualWakeConsumedBeforeCleanupKeepsTheNextTick (new test seam afterCompletionWake). Test adjustments: TestSolR2_ManualCompletionDoesNotOverwriteAnEarlyRearm called the removed signalManualUnlessSoon/takePending; it now pins the same rule on finishCycleLocked/scheduleEarlyLocked. TestRefreshScheduler_ ManualCycleKeepsAnEarlyRearm sets a different rand before the Nudge: on the frozen fake clock the early due equalled the instant the timer was already armed for, which needs no re-arm, so its arm-count precondition was an implementation detail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dumbris
marked this pull request as ready for review
October 22, 2026 06:36
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.
Dumbris
added a commit
that referenced
this pull request
Oct 22, 2026
## Description `TestInformationalRescan_BusyServerIsRetriedNotDropped` (Spec 118, #1689) failed on `main` in 4 of the last 8 unit-test runs and was the only red check on contributor PRs #1727, #1725 and #1715. **Cause.** `fireInformationalRescan` deletes the server's entry from `infoRescanTimers` when the debounce timer fires and re-creates it only after the gates and the claim ran. On every busy fire there is a short window in which "is a timer armed" is false. The test sampled the map at one fixed instant (`time.Sleep(4 * debounce)`), which under CI load lands in that window. Reproduced locally: 1 failure in 150 runs with `GOMAXPROCS=1` under 2×ncpu CPU burners. **Fix (test only).** Read `infoRescanSeq` under the mutex before scheduling; the change itself takes `before+1`, so `require.Eventually(rescanSeq("srv") > before+1)` is a timing-free proof that a busy fire re-armed the timer. The "no scan attempted on top of a claimed slot" and "a busy slot is not a failed attempt" assertions are unchanged. **Verification.** 400 runs of the fixed test under the same CPU contention: 0 failures. `go test -race -count=3 -run TestInformationalRescan ./internal/server/` green. `golangci-lint` with the CI v2 config clean. Cross-reviewed with opencode (gpt-6.1-sol), two rounds: round 1 found the baseline read could hit the same gap (fixed by reading the counter before scheduling), round 2 CLEAN. No production code changes. ## Second flake, same PR `TestRefreshConcurrency_ScansDuringASlowSelfCheckSeeOldOrNew` (from #1720) failed the darwin-amd64 Build Binaries job on this PR: `119.6ms is not less than 109.7ms (baseline median 1.19ms)`. Both refresh-concurrency guards compared the single worst of 300 scans against `50*median+50ms`, which one GC pause on a macOS runner exceeds. They now bound p90 relative to the baseline and keep an absolute 2 s ceiling on the worst scan (no scheduler stall reaches it, any wait behind the gate or a fetch deadline does). 30 runs under CPU contention: 0 failures. opencode Sol 6.1 round 1 flagged that p90 alone could hide one scan absorbing a timed-out wait; the absolute ceiling is the answer to that.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR is the third and last piece of the tpa-db epic. It implements Spec 101 User Story 3 together with Spec 087 User Stories 1 and 2. The TPA signature database now refreshes itself, can optionally be fetched as a signed artifact, proves a new corpus is no worse before it activates, shows how fresh it is, and re-checks servers that were already approved when new signatures arrive.
Related #938
What changes
Refresh lifecycle (Spec 087 US1).
internal/security/scanner/tpa_refresh.goruns one cycle pertpa_refresh_interval(default 24h, minimum 1h, jittered). Each cycle has two legs:tpa_bundle_path, so a dropped bundle activates without a reload or a restart.Both legs feed the same source-neutral
evaluateCandidatepipeline that US1 built. The verifier is not forked (Spec 101 D11).Concurrency and scheduling:
bundleRT.mu.Runtime.Closebefore storage stops.Signed fetch (Spec 087 US2, Spec 101 D1). The fetch is opt-in and off by default.
tpa_fetch_enabled,tpa_fetch_url, and the env overrideMCPPROXY_TPA_FETCH_ENABLED. The env override is never persisted and cannot change the URL.releases/latestURL selectsscanner-bundle-<seq>.jsonplus.sig. Any other URL is the "direct" shape:<url>plus<url>.sig.require_signed_bundleoff. They are verified against the US1 authority trust set.If-None-Matchis used, andRetry-Afterand rate-limit backoff are honoured.fetch_unavailable,not_modified,channel_invalid,superseded,disabled) are kept apart fromlast_rejectionand never move the watermark.Activation self-check (087 FR-008).
tpa_selfcheck.goruns the CI gate decision against every candidate, layering it on the built-in checks:It uses an embedded byte copy of the eval corpus (
bundled/selfcheck-corpus.json). A candidate that regresses is refused asgate_failure, and last-known-good keeps serving.Freshness you can see (Spec 101 FR-020).
BundleInfogainslast_checked_at,last_activated_at,stale/stale_after_daysandrefresh{…}(trigger, outcome, reason, fetch leg state, next check, consecutive failures), plusreprotect{…}. These show on:GET /api/v1/security/bundleand/security/overview(swagger regenerated),mcpproxy security bundle,mcpproxy doctorwarns in three cases:tpa_stale_after_days(default 30),Manual refresh.
mcpproxy security bundle refreshcallsPOST /api/v1/security/bundle/refresh(admin only). It runs one joined cycle with both legs.reloadstill never touches the network.Re-protection (Spec 101 FR-019). Every activation or rollback fires an observer that runs a sweep off the hot path:
auto_baseline_scan.RT-02 holds: nothing is approved, unquarantined, revoked or quarantined. A completion marker in BBolt drives a startup catch-up, on stdio too.
Telemetry schema 16. Five integer counters are added to
tpa_scannerinside the closed anonymity contract:bundle_refresh_cyclesbundle_refresh_activationsbundle_refresh_rejectionsbundle_refresh_unavailablebundle_reprotect_hitssource=fetchis mapped. An operator-signed fetch still reportsotherwith no sequence.Specs and docs.
internal/security/bundlepackage was never built.specs/101-tpa-db/tasks.md.docs/features/tpa-bundle-signing.md(refresh and fetch),docs/features/security-quarantine.md(re-protection),docs/features/tool-scanner.md(self-check),docs/configuration.md(keys),docs/cli/security-commands.md,docs/features/telemetry.md,CLAUDE.md, roadmap.No new dependencies.
Assumptions and decisions (no maintainer input requested; please sign off)
All of these are recorded in
specs/101-tpa-db/tasks.md(Δ15–Δ31, B1–B23).There is no working default fetch URL. The repository
smart-mcp-proxy/tpa-dbdoes not exist yet, and the publisher key set is still empty (US1 A1). The fetch therefore works today only withtpa_active_root: operatorplustpa_operator_signers. With the default publisher root it makes zero requests and doctor reportsno_trusted_signer.Channel shape (Δ22). Per-sequence asset names are only selection hints. Integrity comes from the signed pair, the per-authority watermark and the deny-list. Research D1's signed release index is deferred.
The self-check is a ported copy of the gate (Δ17). It is not shared code:
cmd/scan-evalispackage mainand belongs to US2. Parity tests guard it:gate.gothat accepts both thegateChecks()andgateChecks(bundle)shapes,This deviates from 101 FR-016a(c). Moving the gate into one shared package is a follow-up.
The self-check skips last-known-good restores (B13). It runs for normal activations and rollbacks only, so an upgrade cannot deactivate a bundle that already passed.
A download whose settings changed mid-flight is discarded as
superseded(Δ30). It is not activated under the old settings.Bundle status stays off
/api/v1/info(Δ19)./infois readable by agent tokens. Release status, which Spec 079 already provides, stays separate.Turning the fetch off keeps the fetched bundle serving (B9).
rollback --embeddedis the explicit way back.Re-protection leg (ii) depends on
auto_baseline_scan(B17). While that switch is off the sweep reportssuspendedand resumes when the switch comes back on.There is no new attention or review-queue kind (B18). Findings show in the scan summary, the badge, the overview, the activity log and the per-tool
scan_verdicton the server's review payload.Malformed refresh keys never block boot. A malformed
tpa_refresh_interval,tpa_stale_after_days,tpa_fetch_enabledortpa_fetch_urlin a hand-edited file is neutralised in place, with a value-free diagnostic, and the fetch is forced off. API writes stay strict.Merge order with US2 (#1702)
TestSelfCheckCorpusByteParitypinsbundled/selfcheck-corpus.jsontospecs/065-evaluation-foundation/datasets/detect_corpus_v1.json. US2 grows that corpus, so whichever PR merges second runs:The self-check tests were made independent of the corpus size, and they pass on a trial merge with US2 plus that copy. On US2's corpus a self-check takes about 3.3 s per candidate without
-race, and a full-racerun of the scanner package takes about 20 minutes, so give it-timeout 40m.Verified on a live instance
These ran on head
aba38112cwithgo build. The setup was:data_dirand config,tpa_active_root: operator,tpa_operator_baseline: 1, and a throwaway key fromtpa-db keygen,tpa-db sign,TPA-2099-0001, which matches the textzebra-canary-7731,fx-approved(trusted, tool approved) andfx-held(quarantined, tool pending).The output below is trimmed with
jq.The Web UI Review page on the same instance shows the new rows in the signature panel: last checked, last activated, refresh, fetch host, and the stale message.
./scripts/run-web-smoke.shwith the fixture upstream gave 127 passed, 3 skipped.Review record
Internal:
Cross-model: opencode GPT-6.1 Sol (variant high), file-scoped chunks, 6 rounds plus one small final chunk. Every reported finding was checked against the code before fixing. All were real and fixed test-first, with each test shown to fail on the old code.
Every non-test file in the diff is in a chunk whose last verdict is CLEAN at the current code. After round 4 the refresh timer was redesigned around a single deadline (escalated to Opus), and the config normaliser was changed to splice values in place instead of re-marshalling.
CI notes
CodeQL: red on 4
go/allocation-size-overflowalerts in files this PR does not change:internal/security/scanner/tpa_trust_anchors.gointernal/server/mcp_max_result_size.gointernal/server/profile_tool.goThe two
profile_tool.go:413alerts are already open on main (fix(runtime): serve indexed tools when StateView per-server cache is empty (MCP-2083) #635, chore(deps): bump the go-dependencies group with 5 updates #646). CodeQL says it re-attributes alerts when a diff is large. CodeQL is not a required check.Telemetry:
tpa_scannerthrough, and stores the raw payload, so schema 16 is accepted. No dashboard panel reads the new counters.Out of scope / follow-ups
cmd/scan-evaland the self-check, after US2 lands.🤖 Generated with Claude Code