Skip to content

feat(security): Spec 101 US3 TPA bundle refresh lifecycle and publication channel - #1720

Merged
Dumbris merged 89 commits into
mainfrom
claude/brave-torvalds-b654c3
Oct 22, 2026
Merged

Dumbris merged 89 commits into
mainfrom
claude/brave-torvalds-b654c3

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

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.go runs one cycle per tpa_refresh_interval (default 24h, minimum 1h, jittered). Each cycle has two legs:

  • The file leg re-reads tpa_bundle_path, so a dropped bundle activates without a reload or a restart.
  • The optional fetch leg runs after it.

Both legs feed the same source-neutral evaluateCandidate pipeline that US1 built. The verifier is not forked (Spec 101 D11).

Concurrency and scheduling:

  • A manual refresh, a timer tick and a config nudge that happen together join one cycle.
  • Network I/O runs outside bundleRT.mu.
  • Scheduling is one deadline held under the refresher's lock.
  • The refresher is joined in Runtime.Close before storage stops.

Signed fetch (Spec 087 US2, Spec 101 D1). The fetch is opt-in and off by default.

  • Keys: tpa_fetch_enabled, tpa_fetch_url, and the env override MCPPROXY_TPA_FETCH_ENABLED. The env override is never persisted and cannot change the URL.
  • Channel shapes: a GitHub Releases API releases/latest URL selects scanner-bundle-<seq>.json plus .sig. Any other URL is the "direct" shape: <url> plus <url>.sig.
  • Signing: fetched pairs are signed-only even with require_signed_bundle off. They are verified against the US1 authority trust set.
  • Transport:
    • https only, with plain http allowed only for a loopback host.
    • Redirects are capped and can never downgrade from https.
    • Egress is checked at dial time. Link-local and metadata addresses are always refused.
    • Bytes, the cycle and response headers are bounded.
    • ETag/If-None-Match is used, and Retry-After and rate-limit backoff are honoured.
    • Only the URL host ever reaches logs, status or errors.
  • Outcomes vs rejections: network outcomes (fetch_unavailable, not_modified, channel_invalid, superseded, disabled) are kept apart from last_rejection and never move the watermark.
  • Persistence: a fetched bundle survives reloads and restarts. Turning the fetch off keeps it serving.

Activation self-check (087 FR-008). tpa_selfcheck.go runs the CI gate decision against every candidate, layering it on the built-in checks:

  • recall ≥ 0.90 over gated categories,
  • hard-negative FP ≤ 0.05,
  • fail closed when the corpus is vacuous.

It uses an embedded byte copy of the eval corpus (bundled/selfcheck-corpus.json). A candidate that regresses is refused as gate_failure, and last-known-good keeps serving.

Freshness you can see (Spec 101 FR-020). BundleInfo gains last_checked_at, last_activated_at, stale/stale_after_days and refresh{…} (trigger, outcome, reason, fetch leg state, next check, consecutive failures), plus reprotect{…}. These show on:

  • GET /api/v1/security/bundle and /security/overview (swagger regenerated),
  • mcpproxy security bundle,
  • the Web UI signature panel.

mcpproxy doctor warns in three cases:

  • the active database is older than tpa_stale_after_days (default 30),
  • it is stuck on last-known-good after a refusal or after ≥3 failed fetches,
  • the fetch is enabled but cannot run (no URL, invalid URL, or no trusted signer).

Manual refresh. mcpproxy security bundle refresh calls POST /api/v1/security/bundle/refresh (admin only). It runs one joined cycle with both legs. reload still 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:

  • Leg (i) refreshes the held scan evidence of pending and changed tools, quarantined servers included.
  • Leg (ii) runs a cached-contract informational scan of approved toolsets on non-quarantined servers.
    • It uses no upstream connection and is pinned to the activated bundle snapshot.
    • It is settled by job ID, makes one job per (server, digest), and is gated by 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_scanner inside the closed anonymity contract:

  • bundle_refresh_cycles
  • bundle_refresh_activations
  • bundle_refresh_rejections
  • bundle_refresh_unavailable
  • bundle_reprotect_hits

source=fetch is mapped. An operator-signed fetch still reports other with no sequence.

Specs and docs.

  • SC-007's fetch clause moves from Spec 101 to Spec 087 as SC-009.
  • Spec 087 is marked implemented by Spec 101 US3. Its internal/security/bundle package was never built.
  • The US3 tasks (T300–T396) and the 2026-10-21 re-validation delta are in specs/101-tpa-db/tasks.md.
  • Docs updated:
    • 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,
    • quickstart, 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-db does not exist yet, and the publisher key set is still empty (US1 A1). The fetch therefore works today only with tpa_active_root: operator plus tpa_operator_signers. With the default publisher root it makes zero requests and doctor reports no_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-eval is package main and belongs to US2. Parity tests guard it:

    • corpus bytes,
    • an AST comparison with gate.go that accepts both the gateChecks() and gateChecks(bundle) shapes,
    • per-entry hard flags,
    • an optional CLI differential.

    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). /info is readable by agent tokens. Release status, which Spec 079 already provides, stays separate.

  • Turning the fetch off keeps the fetched bundle serving (B9). rollback --embedded is the explicit way back.

  • Re-protection leg (ii) depends on auto_baseline_scan (B17). While that switch is off the sweep reports suspended and 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_verdict on the server's review payload.

  • Malformed refresh keys never block boot. A malformed tpa_refresh_interval, tpa_stale_after_days, tpa_fetch_enabled or tpa_fetch_url in 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)

TestSelfCheckCorpusByteParity pins bundled/selfcheck-corpus.json to specs/065-evaluation-foundation/datasets/detect_corpus_v1.json. US2 grows that corpus, so whichever PR merges second runs:

cp specs/065-evaluation-foundation/datasets/detect_corpus_v1.json internal/security/scanner/bundled/selfcheck-corpus.json

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 -race run of the scanner package takes about 20 minutes, so give it -timeout 40m.

Verified on a live instance

These ran on head aba38112c with go build. The setup was:

  • port 18741 with a scratch data_dir and config,
  • tpa_active_root: operator, tpa_operator_baseline: 1, and a throwaway key from tpa-db keygen,
  • bundles made from the embedded corpus plus manifest fields, signed with tpa-db sign,
  • seq 3 and later carry one extra canary rule TPA-2099-0001, which matches the text zebra-canary-7731,
  • two stdio fixture servers whose tool descriptions contain that text: fx-approved (trusted, tool approved) and fx-held (quarantined, tool pending).

The output below is trimmed with jq.

# 1. start, signed seq 2 at tpa_bundle_path
{"source":"file","sequence":"2","signature_verified":true,"refresh":{"last_trigger":"startup","next_check_at":"…09:00:28"}}

# 2. drop seq 3 (adds TPA-2099-0001), no reload: the first timer cycle activates it
{"source":"file","sequence":"3","refresh":{"last_trigger":"timer","last_outcome":"activated"},"reprotect":{"state":"idle","servers_scanned":1,"servers_with_hits":1}}
# re-protection: the approved tool is flagged and stays approved; the held tool's evidence picks up the new signature
{"server":"fx-approved","quarantined":false,"tools":[{"name":"lookup_weather","approval_status":"approved","scan_verdict":"dangerous"}]}
{"server":"fx-held","quarantined":true,"tools":[{"name":"fetch_notes","approval_status":"pending","held_reason":"scan_findings","held_signals":["tpa.TPA-2099-0001.hidden_instruction"]}]}

# 3. tampered seq 4 + `mcpproxy security bundle refresh`: last-known-good keeps serving
{"sequence":"3","refresh":{"last_trigger":"manual","last_outcome":"rejected"},"last_rejection":{"reason":"tamper"}}
$ mcpproxy doctor
  • TPA signature bundle: a newer candidate was refused (tamper); still serving last-known-good (see `mcpproxy security bundle`)

# 4. valid seq 4 + refresh
{"source":"file","sequence":"4","refresh":{"last_trigger":"manual","last_outcome":"activated"}}

# 5. fetch OFF (loopback mirror serving signed seq 5 on :18742): refresh + reload
{"source":"file","sequence":"4","refresh":{"last_outcome":"disabled","last_reason":"fetch_off"}}
mirror requests with fetch off: 0

# 6. tpa_fetch_enabled: true via config hot reload, then refresh
mirror requests after reload, before refresh: 0
    Source:          fetch (http://127.0.0.1)
    Refresh:         activated, next check …
    Fetch host:      127.0.0.1
    Sequence:        5
    Signature:       verified
mirror log: "GET /seq5.json" 200, "GET /seq5.json.sig" 200
log lines containing the URL path: 0

# 7. restart: the fetched bundle persists (the older file is refused once as downgrade, a quiet reason)
{"source":"fetch","path":"http://127.0.0.1","sequence":"5","signature_verified":true}

# 8. signed seq 6 with generated_at 2026-09-10, reload, doctor
    STALE:           42 days old (threshold 30 days) - run `mcpproxy security bundle refresh`, or check that the refresh source is reachable
  • TPA signature bundle is stale: 42 days old (stale after 30 days); run `mcpproxy security bundle refresh` or check the refresh source

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.sh with the fixture upstream gave 127 passed, 3 skipped.

Review record

  • Internal:

    • An Opus judge over the full diff raised 10 defects, all real and all fixed.
    • A 6-dimension review workflow with 3 adversarial refuters per finding produced 14 findings. 5 survived and were fixed.
  • 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.

    Round Findings Chunks
    1 19 9 of 10
    2 11 5 of 9
    3 6 4 of 5
    4 4 5
    5 4 4 of 5
    6 0 all clean

    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-overflow alerts in files this PR does not change:

    • internal/security/scanner/tpa_trust_anchors.go
    • internal/server/mcp_max_result_size.go
    • internal/server/profile_tool.go

    The two profile_tool.go:413 alerts 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:

    • Checked: the receiver has only lower-bound schema gates, passes tpa_scanner through, and stores the raw payload, so schema 16 is accepted. No dashboard panel reads the new counters.
    • Not checked: the live dashboard is behind Cloudflare Access OTP and was not loaded.

Out of scope / follow-ups

  • Publication half of FR-017:
    • creating the corpus repository and its gate-then-sign-then-publish workflow,
    • shipping the first publisher key,
    • a signed release index.
  • A shared gate package used by both cmd/scan-eval and the self-check, after US2 lands.
  • An attention or review-queue kind for "an approved tool now matches signature X". This needs the Spec 109 parity matrix.
  • The receiver-side SC-006 freshness join.

🤖 Generated with Claude Code

Dumbris and others added 30 commits October 21, 2026 06:37
…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>
Dumbris and others added 22 commits October 22, 2026 07:02
- 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
Dumbris marked this pull request as ready for review October 22, 2026 06:36
@Dumbris
Dumbris merged commit 92dc64e into main Oct 22, 2026
56 checks passed
@Dumbris
Dumbris deleted the claude/brave-torvalds-b654c3 branch October 22, 2026 07:17
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.
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