You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
stm_surfacing_stats warns on a zero-variance score distribution (#573, issue #560 step 3) — the flat-score state (every recorded surfacing score identical for weeks) was only discoverable by querying stm_feedback.db by hand; the stats tool reported the degenerate scores without flagging that they carry no ranking information, even though in that state the min_score filter is a step function at the single observed value and the auto-tuner walks a gradient that doesn't exist. FeedbackStore.get_stats() now returns a score_distribution aggregate (count / min / max), computed in the same pass — and against the same tool= / since= filter — as the per-tool breakdown, so the warning always describes exactly the window being rendered (min == max is the zero-variance predicate; booleans are excluded so a corrupt JSON true can't masquerade as score 1.0). The tool renders a WARNING: zero score variance line when 10+ recorded scores are all identical; below that, identical scores are expected small-sample noise. Zero-traffic output is byte-for-byte unchanged, and stats dicts without the new key degrade to silence. This is the regression tripwire for the #572 root-cause fix below: whatever the next cause of a degenerate score channel is, the stats output itself now says so.
Fixed
Circuit-breaker is_open / state / time_until_reset are pure reads (#602, issue #600) — the open→half-open transition was performed as a side effect of the property read, and stm_proxy_health renders the surfacing breaker by reading is_open — so once the breaker was open and reset_timeout had elapsed, running a health check flipped the breaker to half-open and then reported closed (healthy): a read that both mutated and misreported the state it claimed to observe. A new _effective_state() helper computes the effective state (open past its reset window → half-open) without assigning self._state; state is now committed only by record_success / record_failure, which recompute the effective state so a probe failing within the elapsed window re-opens the breaker exactly as before — driven by the outcome instead of by a prior read. Single-probe half-open enforcement (never actually enforced) stays out of scope and the docstring is corrected to stop overclaiming it. Behavior change: none external — is_open returns the same values and the gate/probe lifecycle is preserved; reading is_open / state / time_until_reset no longer mutates the breaker, so stm_proxy_health reports the true state, and time_until_reset returns None at/after the reset window rather than ~0.0.
_reconnect_server is serialized so a reconnect race can't orphan an AsyncExitStack (#599, issue #586) — the reconnect path is called from four unserialized retry-loop sites; two concurrent calls for the same dead upstream could interleave across the transport-spawn / initialize / list_tools awaits, so the loser's freshly-built AsyncExitStack (wrapping a live stdio child + fds) was assigned and then overwritten by the winner and never closed — a leaked child process + fds per race, repeating under a retry storm against a flapping upstream. A per-server asyncio.Lock plus a reconnect generation counter (captured before acquiring the lock; bumped only on a successful reconnect) now makes a caller that finds the generation advanced return without reconnecting — removing the orphaned-stack leak and collapsing N concurrent attempts into a single transport spawn. Behavior change: none user-visible; concurrent reconnects for one server serialize and deduplicate instead of racing.
atomic_write_text gains opt-in fsync durability for config/state files (#598, issue #585) — the temp-in-same-dir + os.replace funnel for every config/state write is crash-correct but had no fsync before the rename (nor a directory fsync after), so under power loss / kernel panic the rename could become durable ahead of the data blocks, leaving a zero-length or partial stm_proxy.json after reboot — and mms init refuses to recreate an existing config, so recovery is manual. A new durable: bool = False parameter flushes + os.fsynces the temp fd before the rename and best-effort fsyncs the parent directory after (POSIX; a dir-fsync failure never fails a write that already landed). Opted in for the single-source-of-truth low-frequency writers (all five cli/proxy.py writers, host-config apply, the four mms/state.py savers); the hot-path per-fact markdown writers and the ephemeral daemon handshake stay non-durable. Behavior change: config/state/registry writes now fsync before returning (one disk flush each); durable defaults off so hot-path writers are unaffected, and an unclean shutdown can no longer truncate a config file to zero bytes.
The four unbounded telemetry tables are now age-bounded (#597, issue #584) — surfacing_events, surfacing_feedback, progressive_reads, and compression_feedback had no DELETE FROM anywhere and grew forever (cleanup_expired_queries only nulls the query column, keeping the row); compounding this, the default no-sincestm_surfacing_stats call does an unbounded fetchall() + per-row json.loads synchronously on the event loop, so after a long deployment each stats call became a multi-second stall blocking every proxied call in flight. A config-gated age window now bounds all four: SurfacingConfig.stats_retention_days (default 90) drives a new delete_events_older_than() wired into the engine's opportunistic cleanup tick (running before the query-null pass), and retention_days (default 90) on ProgressiveReadsConfig / CompressionFeedbackConfig drives a once-per-process startup purge mirroring ProxyCache's. Bounding the table is also what fixes the get_stats stall. Set the relevant knob to 0 to keep the prior keep-forever behavior. Behavior change: rows older than 90 days are deleted (surfacing rows on the cleanup tick, the two proxy tables on next start); no change to the data served to callers.
stm_proxy_select_chunks / stm_proxy_read_more reach the SQLite pending store after a restart (#596, issue #583) — the opt-in pending_store: "sqlite" backend exists so pending TOC/progressive entries survive restarts, but both retrieval endpoints failed to reach it because the compressor and progressive store are built lazily inside a compress call: select_chunks early-returned "Selective compression not active" when _selective_compressor was still None, and read_more called _get_progressive_store() with no sel_cfg, constructing a fresh InMemoryPendingStore that reported "not found or expired" while the row sat live on disk. A new _fallback_selective_cfg() scans the configured servers (server-level then tool overrides, deterministically) for the first that opts into SQLite and builds the configured store when nothing has been built yet — for read_more only when _progressive_store is None, so a live in-memory store still holding keys isn't clobbered. Both degrade to their existing sentinel strings (with a WARNING) on a SQLite open failure and pin no bad state. Behavior change: under pending_store: "sqlite", the two retrieval endpoints now recover keys persisted before a restart instead of reporting "not active"/"not found"; no change for the default memory backend.
The mms project mutators are write-locked against lost updates (#595, #604, issue #582) — every registry-domain CLI mutator runs under the cross-process write lock exceptmms project, which did not import _write_lock at all: its init / enable / disable / list --prune each did an unlocked read-modify-write of the shared ~/.mms/projects.toml, so two concurrent runs (plausible with parallel agent sessions) both load the same base index and the loser's save silently drops the winner's row — no corruption, but a dropped project stops resolving by name until re-registered. init / enable / disable get the @with_write_lock decorator; list --prune wraps its load→prune→save span in with state.write_lock(enabled=prune) so a plain read-only list stays unlocked. The WriteLockTimeout attribution hint was also broadened to name the mms project mutators alongside the registry/sidecar commands that share the lock (#604, codex review of #595). Behavior change: concurrent mms project init/enable/disable/list --prune now serialize on the registry lock; a run that can't acquire it within the timeout fails with a clean attributed Error: (exit 1) instead of racing. No change to single-process usage or read-only show / list.
A detached daemon startup crash now logs its traceback (#594, issue #581) — the detached child runs with stderr=DEVNULL, and serve()'s try/except guarded only open_lock_fd, so an exception in _build_engine() or asyncio.start_server propagated uncaught through asyncio.run to an interpreter traceback on the devnulled stderr — never through a logger — leaving stm-daemon.log empty while mms daemon start told the user to "check the daemon log". run()'s asyncio.run(...) is now wrapped in a top-level exception barrier mirroring the MCP server's #209 barrier: a clean anyio cancel-scope teardown logs at WARNING and returns 0, any other exception is logger.exception-ed (landing in stm-daemon.log) and re-raised so the exit code still reflects the failure. Pure observability — no behavior change on a healthy start.
Startup-failed upstreams are surfaced in stm_proxy_health (#593, issue #580) — startup degrade for a failing upstream was already correct (_connect_server failure is caught, other upstreams proceed), but the degradation was invisible from inside the session: self._connections entries are created only on a successful connect and get_upstream_health iterates them only, so a startup-failed server did not appear in stm_proxy_health at all — the one logger.exception at startup was the sole trace. A new self._failed_servers map records the connect-error summary in the startup except; get_upstream_health merges these in as connected: False / tools: 0 with an error field (a live connection wins over a stale failed entry, and a successful reconnect pops the record), and stm_proxy_health renders the previously-dead DISCONNECTED branch plus a startup connect failed: <error> line. The retry/re-registration half of #580 is deliberately left open (there is no plumbing to re-register an upstream's tools with FastMCP after startup). Behavior change: stm_proxy_health now lists configured-but-unconnected upstreams as DISCONNECTED with their startup error, where previously they were absent entirely; no change for healthy servers.
Timeout-retry is gated on tool idempotency so a slow write isn't replayed (#592, issue #578) — the upstream retry loop retries on asyncio.TimeoutError by reconnecting and re-invoking the same tool up to max_retries times, but a per-attempt timeout only cancels our asyncio.wait_for — the upstream request may already have committed its side effect — so for a non-idempotent tool the proxy manufactured duplicate writes the client never asked for (up to 4 applications with max_retries=3), a semantic change relative to the client calling the upstream directly. A new _tool_idempotent_for_retry gate now decides whether a timed-out call may be re-invoked: only asyncio.TimeoutError is gated (connection-level failures stay retryable for every tool, since they overwhelmingly mean the request never completed); idempotency is readOnlyHint is True and destructiveHint is not True under both strict and conservative policies (a conservative retry verdict would re-execute an unknown tool's side effect, so unknown means don't replay); an explicit per-tool/server cache: true override or tool_annotation_policy: ignore keeps the old replay-on-timeout behavior. When the gate says no, the loop records the TIMEOUT metric, refreshes the connection, and re-raises the original TimeoutError without re-invoking. Behavior change: under the default conservative policy, a tool without readOnlyHint: true no longer has its call retried on a per-attempt timeout — the connection is still refreshed and the timeout propagates; transport-error retries are unchanged.
Surfacing timeouts now count toward the circuit breaker (#590, issue #579) — a hung (not erroring) LTM timed out on every surfacing call, but SurfacingEngine.surface()'s TimeoutError branch recorded only the error_timeout observability outcome and never called record_failure(), so the breaker never opened: every surfacing-eligible proxied call paid the full timeout_seconds (default 3.0s), and because the timeout cancels the adapter mid-RPC (setting _needs_reconnect), each call also tore down and respawned the LTM stdio child — while stm_proxy_health kept reporting the breaker closed (healthy). The timeout branch now records a breaker failure too; the distinct error_timeout outcome and the stm_surfacing_stats split are unchanged. Behavior change: after circuit_max_failures (default 3) consecutive timeouts the breaker opens and surfacing is skipped for circuit_reset_seconds (default 60s) instead of taxing every call, and the stm_proxy_health breaker line flips to open (failing).
A SELECTIVE/HYBRID pending-store write fault degrades to truncation instead of discarding the response (#589, issue #577) — the PROGRESSIVE branch and the ratio-guard fallback tiers already guard their store-touching calls (the in-code comment names the stake: an escape "DISCARDS an otherwise-successful upstream response"), but the SELECTIVE/HYBRID primary _apply_compression call was bare and SQLitePendingStore handles no sqlite3.Error anywhere. Under the opt-in pending_store: "sqlite" backend, a write fault — a writer holding the lock past the 3s busy timeout, disk-full, a corrupt DB — raised out of the pipeline, was recorded as INTERNAL_ERROR, and threw away the already-fetched successful upstream response. The call is now wrapped in sqlite3.Error (scoped to the store fault; a compressor logic error still propagates) and degrades to a boundary-aware truncation at the retention budget — the ratio ladder's terminal tier — labeled selective→truncate_on_store_error in metrics and excluded from the response cache, so a transient store failure can't pin the lossy truncation for the cache TTL and suppress the chunk-TOC protocol after the store recovers (the #496 lesson). Behavior change: such a fault now returns a truncated response (one WARNING, not cached) instead of failing the tool call; no change on a healthy store or the default pending_store: "memory" backend.
ProxyCache.get() degrades to a MISS on a SQLite fault (#588, issue #576) — the response cache was best-effort on the write side only (_store_cache wraps set(); degraded responses skip the cache per #496), but the lookup SELECT ran unguarded and both ProxyManager call sites are bare, so a runtime sqlite3.Error mid-session — disk I/O error, page-level corruption, an external process holding the file past the busy timeout — failed the proxied tool call with INTERNAL_ERROR even though the upstream was healthy and the cache is only an optimization; a persistently broken cache DB took down every cache-eligible call until restart. get() now logs a WARNING and returns a MISS so the call falls through to the upstream — the same degrade pattern its own privacy-eviction guard and GraphConsultCache.get() already use. Behavior change: a broken cache DB now costs cache hits, not tool calls; no change on a healthy DB.
Metrics/cache store init failure degrades to disabled instead of preventing startup (#587, issue #575) — metrics_store.initialize() and proxy_cache.initialize() were the only two store inits in the lifespan not wrapped in the degrade-to-disabled pattern their siblings use, so a corrupt DB file (first PRAGMA raises file is not a database) or a concurrent-migration race (two mms sessions both reading the pre-migration schema after a column-adding upgrade; the loser's ALTER TABLE raises duplicate column name) crashed app_lifespan — the MCP server never started and every proxied tool went down because an optional telemetry/cache DB was unhealthy. Both inits now log a WARNING and fall back to None (TokenTracker(metrics_store=None) / ProxyManager(cache=None) already tolerate the degraded state), and the metrics migration tolerates the lost-race duplicate column name per-column while re-raising any other OperationalError. Behavior change: on a corrupt/locked metrics or cache DB, mms now starts with that feature disabled (one WARNING per feature) instead of failing to start; no change on a healthy DB.
Surfacing requests the structured mem_search format by default, restoring real relevance scores (#572, issue #560) — every surfacing event since 2026-06-08 recorded scores of exactly 0.03 (zero variance across queries, tools, and weeks), leaving min_score and the auto-tuner with no signal to act on. The upstream LTM scores were fine; the flatness was a rendering artifact of the compact default: core's compact formatter rounds scores to two decimals, RRF fusion scores live in (0, ~0.033] (k=60 → max 2/61 ≈ 0.0328), so the whole distribution collapsed to {0.02, 0.03} and the min_score filter (default 0.03) passed only the 0.03 bucket. Notably the 0.03 default was calibrated in #329 against a raw-float score sweep, so under compact rounding the deployed filter never operated in the regime that calibration assumed — and rounding happened before the filter, so true scores down to ~0.025 passed a floor they were actually below. result_format now defaults to "structured" (four-decimal scores — ~330 distinct values over the RRF range instead of two — and real chunk ids end to end, so per-memory helpful boosts reach the underlying chunk instead of silently no-oping); the client auto-downgrades to compact when the core doesn't advertise "structured" in capabilities.search_formats, so older cores are unaffected. The fake test server now honors output_format like real core in both formats (it advertised structured while only emitting compact — latent while the default was compact), always emits chunk_id like core's structured formatter, and renders compact scores with core's two-decimal rounding so compact-path coverage reproduces the #560 failure mode instead of hiding it (codex review round 1). Behavior change: recorded surfacing scores become varied four-decimal values instead of a constant 0.03; effective selectivity tightens slightly (true scores in [~0.025, 0.03) no longer round up past the 0.03 floor) — the regime the #329 calibration chose; memories surfaced under the default carry real LTM chunk ids.
Cache hits reconcile with every number rendered next to them in stm_proxy_stats (#570, issue #558) — the #536 counters left three exclusions that made the stats output irreconcilable: a cache hit never entered total_calls (an operator reading "Total calls: 100 / Cache hits: 40" could not match the numbers to 140 actual invocations), the cache's benefit was structurally invisible in the compression-savings figure it sits next to (hits re-run no compression), and a tool whose responses the cache can never store — mixed or non-text-only content, empty responses, progressive-passthrough degradations, transient retrieval keys — re-missed forever, growing cache_misses unbounded with a permanent 0% hit-rate contribution that pointed diagnostics at a tool the cache cannot help. Rather than folding hits into total_calls (the denominator of the per-stage latency averages and the unit of the per-tool char counters — a hit runs none of that pipeline) the summary now exposes the reconciliation explicitly: total_invocations = total_calls + cache_hits + total_errors (the three counters are mutually exclusive per call — every pre-record() stage failure raises, and the hit path swallows surfacing errors), rendered as Total calls: N live [+ K failed] + M cache-served = T invocations; hits carry their served chars (cache_hit_chars, the honest figure available without a cache-schema migration) onto the hits line; and a new cache_unstorable counter counts each store refusal of a counted miss, gated on the exact lookup-path conditions so force-forwarded calls that recorded no miss count nothing — rendered (only when non-zero) as an Unstorable line plus an effective hit rate over storable lookups, so a never-cacheable-heavy workload no longer reads as a depressed hit rate. The Savings and Errors lines are labeled with their denominators (compression of live calls / % of live attempts). Converged through four codex review rounds (2→1→1 Major, then SHIP). Behavior change: an empty upstream response now records a 0/0 metrics row (in-memory and proxy_metrics.db), mirroring the non-text branch — previously it recorded nothing (the R8 characterization pin from the A1 refactor, amended deliberately); get_summary() gains total_invocations / cache_hit_chars / cache_unstorable (no existing key changes meaning) and the stm_proxy_stats lines change shape as described.
Upstream tool annotations are refreshed on tools/list_changed, and cache rows the change made unsafe are invalidated (#568, issue #557) — the cache-eligibility gate (#535) reads readOnlyHint / destructiveHint from the per-connection tool snapshot, which was populated only at connect/reconnect; nothing in the proxy consumed notifications/tools/list_changed, so an upstream that re-declared a tool from read-only to may-mutate at runtime kept its connect-time snapshot indefinitely and a second identical call was served the pre-flip cached success without re-executing the now-mutating side effect — the exact replay #535 closed, reopened by staleness, self-healing only on an error-driven reconnect. Both upstream ClientSession constructions now wire a message handler that reacts to the notification by scheduling a coalesced background refresh (one in-flight list_tools per server; a notification landing mid-refresh triggers exactly one more pass; a refresh racing a reconnect is dropped by a session-identity guard): the tool snapshot is reassigned and cache rows are deleted for any tool whose eligibility verdict flipped eligible→ineligible under the current config, plus tools that disappeared from the advertised list (their rows could only mask the removal). Deletion on top of the gate is deliberate — a pre-flip row would otherwise outlive the flip and could serve again once the annotations or overrides move the verdict back. Comparing verdicts rather than raw annotations means an operator's explicit per-tool cache: true override keeps suppressing invalidation. The refresh bookkeeping is reset in stop() and the double-start guard (a drain task cancelled before its first step never runs its finally, which would otherwise silently drop every later notification for that server on manager reuse — caught by codex cross-review). Behavior change: the proxy issues an extra upstream tools/list request per (coalesced) list_changed notification, previously dropped by the SDK's default handler; cache rows for tools that flip toward may-mutate or vanish are deleted at refresh time instead of surviving until TTL expiry; and the per-connection tool snapshot (hence the advertised set on the next tools/list) now tracks runtime list changes instead of staying frozen until reconnect. Upstreams that change annotations without emitting list_changed still heal on the next reconnect, as before.
ttl<=0 now invalidates a stale cached row on every response shape that bypasses the text store path (#548, #550, issue #541) — the store-side ttl<=0 self-heal in ProxyCache.set only ran on the TEXT store path, so a row left behind by an earlier text response for a (server, tool, args) key was never invalidated once caching was disabled and the same key returned a response that never reaches that store: a non-text-only response (early-returns before the Stage-5 store), a mixed text+non-text response (skipped by _store_cache's text-only gate), a text-bearing error (raises before the store), an empty response, or a text response that skips the store for any other reason (cache-ineligible tool, progressive-passthrough degradation, transient retrieval key). While ttl<=0 the lookup is bypassed so the stale row is never served during the disabled window, but raising the TTL back within the row's frozen window (per-row TTL is frozen at write time) made the now-eligible lookup serve the stale text — most likely on the cache_ttl_seconds: 0 headline case (disabling caching for a volatile/binary tool whose response shape flips text→non-text). The delete-by-key is now factored into ProxyCache.invalidate; _store_cache resolves the TTL once and, when it is <=0, invalidates and returns above the whole store-skip chain, while the non-text / error / empty early-return paths invalidate inline — so every non-self-healing path drops a stale row when the resolved TTL is <=0. This preserves #536's zero-I/O posture for the steady-state text / no-row disabled-cache path; the only added I/O is one DELETE per such call under a disabled cache, bounded to calls that actually occur.
Security
Credential-bearing connection-lifecycle cleanup logs are redacted on the reconnect / double-start / stop paths (#606, issue #605) — the deferred sibling of #593. #593 closed a credential-leak class on the startup connect path (httpx transport exceptions embed the credentialed request URL, and logger.debug(..., exc_info=True) re-emits it in the traceback tail) via the single choke point ProxyManager._redacted_error(exc, url). The same treatment now covers the sibling cleanup logs that close or reopen AsyncExitStacks wrapping transports opened with the same credentialed cfg.url — the start() double-start guard's per-connection stack close, stop()'s "Failed to close connection stack", both _reconnect_server cleanup logs, and the three _fetch_upstream post-deadline / post-error reconnect messages (which also gain the previously-absent server name). Each now renders the exception via _redacted_error(exc, url) (URL userinfo scrubbed, then capped) and drops exc_info=True. Sites with no credentialed URL in the exception path (tool-graph / store / telemetry closes, the empty self._stack.aclose()) deliberately keep exc_info for non-credential debugging. Behavior change: these DEBUG logs lose the exc_info traceback (the leak vector) in exchange for the redacted exception text; every credential-free exc_info debug path is untouched.
Quoted-JSON credential labels are caught beyond the AWS spellings (#565, issue #562) — #553 added quoted-key detection for the two AWS secret-material labels only; the identical gap remained for the rest of the label vocabulary. The generic label rules end in \s*[:=], and a quoted JSON key's closing quote sits between the label and the colon, so "password": "hunter2", "api_key": "sk-…", camelCase "accessToken": "ya29.…", and dict-repr {'password': 'hunter2'} matched zero patterns — and JSON-serialized credentials are high-frequency tool output (docker inspect, kubectl get secret -o json, DB connection configs, OAuth token responses). One general quoted-label rule reuses #553's FP-guard shape — quote directly on both sides of the label, value must open as a string — so JSON-Schema object values ("access_token": {"type"… — login/OAuth tool schemas carry these constantly), embedded labels ("my_api_key_name": …), and prefixed keys ("tools.api_key": …) never fire; the optional [_-]? separator lets camelCase keys match under (?i). pwd is deliberately excluded from the quoted vocabulary — shell/file tools legitimately return "pwd": "/home/user" working-directory fields. Behavior change: responses carrying quoted credential labels now route away from external-LLM compression strategies and are excluded from the response cache, and tool metadata carrying them is withheld from tools/list under the strict exposure profile — the same fail-safe treatment the unquoted spellings already get.
The x-amz-security-token wire label is caught (header + presigned-URL query param) (#564, issue #561) — #553 catches AWS secret material by config/JSON label, but the label AWS actually puts on the wire for the same session-token material was covered by no pattern: the x-amz-security-token request header (emitted verbatim in botocore DEBUG logs and HTTP-header dumps) and the presigned-URL X-Amz-Security-Token=… query parameter (present in every presigned S3 URL generated with temporary credentials). session[_-]?token cannot cross the security-token spelling and the kebab shape has no aws separator before the label, so detection relied on an ASIA… key ID co-occurring in the same text. One new rule mirroring #553's two-alternative shape: a quoted form (serialized header dicts; string-opening value, so OpenAPI/JSON-Schema header definitions with object values never fire) and an unquoted form (raw header line, presigned-URL query param) whose left boundary rejects only a directly preceding separator ((?<![_.\-])) — kebab/dotted compounds that merely name the header (forward-x-amz-security-token: true config knobs, proxy.headers.x-amz-security-token telemetry heads) stay negative, while bytes-repr wire dumps, which render the newline before the header line as a literal \r\n and so put an alphanumeric directly before the label (send: b'…\r\nx-amz-security-token: FwoG…' — http.client debuglevel), stay positive. The boundary class was adjudicated against a codex cross-review finding (full details in the #564 review comment). Behavior change: responses carrying this label now route away from external-LLM compression strategies and are excluded from the response cache, and tool metadata carrying it is withheld from tools/list under the strict exposure profile. (Sync note: #564 + #565 move CREDENTIAL_PATTERNS 17 → 19; the LTM forward-sync carrying both rules is memtomem#1541, restoring byte-identical same-order equality at 19 — after which the #559 content-hash pin can land.)
AWS secret material is caught by label (SECRET_ACCESS_KEY / SESSION_TOKEN) (#553) — the credential scan caught AWS key IDs (AKIA/ASIA prefixes) but not the secret material those IDs unlock: secret[_-]?key needs its two words adjacent (secret_access_key splits them) and access[_-]?token needs the literal access (session_token has neither), so a tool response echoing an env | grep AWS block or an STS AssumeRole JSON scanned clean and could be routed to an external-LLM compressor or stored in the response cache. One new CREDENTIAL_PATTERNS rule with two alternatives: a quoted-key form ("SessionToken": "…" — STS JSON, python-dict repr, kebab-case serialized headers; the quote must sit directly on both sides of the label and the value must open as a string, so JSON-Schema properties ("session_token": {"type"…) and tool-name-keyed telemetry dicts ("aws.get_session_token": …) never fire) and an unquoted label form (AWS_SECRET_ACCESS_KEY=, aws_session_token = — env output, ~/.aws/credentials INI, TOML dotted keys, namespaced TF_VAR_aws_secret_access_key=) carrying a left boundary (token start or a directly preceding aws separator) so identifiers that merely embed the label — get_session_token: unhealthy, supports_session_token: true, rotateSecretAccessKey: done — don't mask benign tools or degrade routing. STM-origin: CREDENTIAL_PATTERNS moves to 17 while LTM's mirrored set stays at 16 until the forward-sync into memtomem LTM's privacy.py lands (the inverse of the #1488→#1491 reverse-sync direction; a content-hash pin over the shared subset is tracked in #559). Behavior change: responses carrying these labels now route away from external-LLM compression strategies and are excluded from the response cache — the same fail-safe treatment every other credential label already gets — and tool metadata carrying them is withheld from tools/list under the strict exposure profile.
Compression-routing credential scan mirrors LTM's seven provider-token patterns (#549) — STM's CREDENTIAL_PATTERNS (the compression-ROUTING signal that steers a credential-bearing payload away from external-LLM strategies) was blind to seven secret formats that memtomem LTM blocks at its write-rejection boundary: modern OpenAI keys (sk-proj/svcacct/admin, T3BlbkFJ-anchored), Anthropic (sk-ant-NN-…AA), the gh[ousr]_ GitHub token family, Google AIza, GitLab glpat-, Hugging Face hf_, and PyPI macaroons. A response carrying one of these could be routed to an external-LLM compressor here even though LTM refuses to store it. The seven are now mirrored back (reverse sync) so STM's CREDENTIAL_PATTERNS equals LTM's DEFAULT_PATTERNS exactly (16 secret-class patterns); the regex strings are byte-identical and case-sensitive on the fixed-case provider prefixes (sk-, AIza, glpat-, hf_, gh*_) to keep re.search linear-time and the false-positive profile tight. PII (email) is deliberately NOT crossed over — it stays in STM's storage-gating PII_PATTERNS only — per the asymmetric-sync rule now spelled out in the privacy.py module docstring. The source issues #1488 (LTM-side origin) / #1491 (reverse-sync tracking) refer to the memtomem LTM repo (github.com/memtomem/memtomem), not this one. No proxy runtime or config surface changes. (Set-equality note: #553 above adds a 17th, STM-origin pattern, so the byte-identical-equality claim holds for the 16 mirrored patterns only until the LTM forward-sync restores full equality — integrity of the shared subset is tracked by the #559 content-hash pin.)