Skip to content

Mask secrets inside carried compaction summaries; fall back when automatic cache breakpoints are refused - #308

Merged
fylorn merged 1 commit into
mainfrom
fix/compaction-masking-and-cache-fallback
Oct 9, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/compaction-masking-and-cache-fallback

Conversation

@fylorn

@fylorn fylorn commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes for two problems the 0.67.0 release review found in #305 / #306. Meant for core 0.67.1.

1. Secrets inside carried compaction summaries at rest

The summary returned to Codex as a compaction item (#305) is tw1.c. + base64url text. Before storage, both masking passes look at the body: the rule-based replacement (Redaction::apply_found) and the shape-based tw_secret::mask_body. Neither can see inside that base64. Codex sends the item back on every request, so a key the summary mentions could be written to the stored request body.

Today the shape masker usually swallows the whole long token, since it looks like an opaque credential. That leaves the summary unreadable rather than masked, and nothing guarantees it: a short token, or a long one with fewer than two digits, isn't treated as a credential.

Fix (tw-gateway/src/bodies.rs): before a body is written, every decodable carried summary in it is decoded, masked by the same Redaction, re-encoded and put back in place. That covers rule hits, intercept-mode ledger placeholders, and shape masking. It applies to request, after-plugins and response bodies, streamed or whole.

  • Unchanged parts: the rest of the body is masked exactly as before. The hits found on the request path (C1's BodyRecord::found) are still reused for everything outside the summaries; the summaries are scanned fresh.
  • Segmenting is safe: segments split on the same token boundaries mask_body uses, so masking segment by segment equals masking the whole text.
  • Damaged values: a damaged or undecodable value (bad characters, cut by the 4 MB window, invalid UTF-8) is treated as ordinary text, masked by shape as a whole. A truncated but still decodable one is masked inside.
  • Read side: the transcript decodes the summary from the stored body and masks it again. The request-detail view still masks the opaque token as a whole.

2. Automatic cache breakpoints on Bedrock, and a fallback when they're refused

Allowlist

#306 marked every anthropic.claude* id. Automatic cachePoints now go only to the Claude models AWS lists under Supported models, Regions, and explicit caching limits (https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html, read 2026-10-09).

That table lists:

  • Haiku 4.5
  • Sonnet 4.5, 4.6, 5, 5.5
  • Opus 4.5, 4.6, 4.7, 4.8, 5, 5.5
  • Fable 5 and 5.1, Mythos 5 and 5.1
  • Claude 3.7 Sonnet, and Claude 3.5 Sonnet v2 (20241022, Preview)

Not listed: Claude 3 Haiku / Sonnet / Opus, 3.5 Sonnet v1, 3.5 Haiku, Sonnet 4, Opus 4 / 4.1.

Rule (bedrock::request::caches_automatically): parse family and version from the id, in both naming schemes (claude-sonnet-4-5-20250929-v1:0, claude-3-7-sonnet-…).

  • Automatic marks go when the version is ≥ 4.5, or for 3.7 Sonnet, or for 3.5 Sonnet 20241022.
  • Unlisted future ids are decided by version: every family from 4.5 on supports caching.
  • Cross-region inference profile prefixes (us., eu., apac., global.…), dates and -v1:0 tails don't matter. An inference-profile ARN that names the model is recognized; an application inference profile ARN (which names nothing) gets none.
  • Breakpoints the client set itself still follow the old, broader rule. They're the client's decision.

Safety net (Anthropic-format and Bedrock upstreams)

When a converted request carries only automatic breakpoints (the client set none) and the upstream answers 400 with a message that mentions cache_control, cachePoint or prompt caching:

  • Resend once: the hop is sent once more to the same upstream without them (tw_dialect::cache::strip_marks). Bedrock re-signs the new body.
  • Remember: that (upstream, model) pair is kept in an in-memory set (AppState::cache_marks), at most 1024 entries with the oldest dropped first, kept across config reloads. Later requests leave the marks out up front.
  • Client marks are never stripped: a refusal of a request that carries client-set breakpoints is passed back as it is.
  • Not in the attempt chain: this follows the existing sealed-reasoning resend, which also isn't recorded there. Both happen inside the same hop and are logged with tracing::info.
  • Speed probes (l3) no longer carry automatic breakpoints. They're too short to cache, and a refusal would count as a failed probe.

Docs (docs/config.md, docs/config.zh-CN.md) describe the Bedrock rule and the fallback. There is no config key; no msg! sentence changed.

API

Additive only: new tw_dialect::cache::{may_have_marks, strip_marks}. The Bedrock allowlist is private to the encoder. The gateway changes are in tw-gateway, which the enterprise edition doesn't use.

Tests

  • bodies.rs:
    • A summary containing a fake sk-ant-… key and an AKIA… id is masked inside, in a request body, a streamed response body and a whole response body. The JSON stays valid and the rest of the summary stays readable.
    • Reused request-path hits give the same result as a fresh scan, in observe and enforce modes; in enforce mode the summary carries the ledger placeholder.
    • Damaged values (tw1.c.a, non-base64, cut UTF-8, standard-alphabet base64, a bare prefix) give exactly the plain shape-masked result and never panic.
    • Summaries cut at 1–8 characters never leave a secret, whichever branch they take.
  • Gateway e2e:
    • The stored request body of a Codex request carrying a summary with a key holds a decodable, masked summary (conversion.rs).
    • A fake Anthropic-compatible upstream that rejects cache_control: one resend without it, then no marks on the next request (conversion.rs).
    • A fake Bedrock that rejects cachePoint: one re-signed resend, then remembered; an Anthropic client's own cache_control refused by Bedrock passes the 400 through unchanged (bedrock.rs).
  • Unit tests:
    • The Bedrock allowlist: 34 ids, covering every listed model, inference profiles, ARNs, future ids and unlisted older models.
    • strip_marks on both formats, including a body that only mentions the word.
    • Refusal detection on four real-shaped refusals and two unrelated 400s.
    • The bounded memory.

Checks: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace --no-fail-fast with proxies unset (3159 passed, 0 failed, 8 ignored), scripts/smoke.sh (79 passed, 0 failed).

🤖 Generated with Claude Code

…matic cache breakpoints are refused

Compaction summaries at rest. The summary we hand Codex as a compaction item
is `tw1.c.` plus base64url text, opaque to rule-based redaction and to
shape-based masking. Codex sends it back every turn, so a secret mentioned in
the summary could reach the stored request body. Before a body is written,
each decodable carried summary is now decoded, masked with the same rules,
ledger placeholders and shape masking, re-encoded and put back in place. The
rest of the body is masked as before, still reusing the hits found on the
request path. Damaged or truncated values are masked as ordinary text.

Automatic cache breakpoints.
- Bedrock: automatic `cachePoint`s now go only to Claude models AWS lists
  for prompt caching: every Claude from 4.5 on (unlisted newer ids included),
  plus 3.7 Sonnet and 3.5 Sonnet v2. Matched by family and version in the id,
  so cross-region inference profile ids and ARNs pointing at them are
  recognized. Sonnet 4, Opus 4/4.1, 3.5 Haiku and Claude 3 get none.
- Safety net for Anthropic-format and Bedrock upstreams: if the request
  carries only automatic breakpoints and the upstream answers 400 mentioning
  `cache_control` / `cachePoint` / prompt caching, the hop is sent once more
  without them. That upstream and model is remembered (in memory, bounded) so
  later requests leave them out. Breakpoints the client set itself are never
  removed.
- Speed probes no longer carry automatic breakpoints.

New `tw_dialect::cache::{may_have_marks, strip_marks}`; docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 250d46a into main Oct 9, 2026
5 checks passed
@fylorn
fylorn deleted the fix/compaction-masking-and-cache-fallback branch October 9, 2026 14:38
@fylorn fylorn mentioned this pull request Oct 9, 2026
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.

1 participant