Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
a890511
chore: pin the core dependency to a tag instead of tracking main (#25)
fylorn Sep 23, 2026
a90e9f3
feat!: forward what can be forwarded, convert only what must be (#26)
fylorn Sep 23, 2026
ca7647c
refactor: take the circuit-breaker registry and metric labels back fr…
fylorn Sep 23, 2026
a6678cf
refactor: redact PII with core's guard engine, and restore tool argum…
fylorn Sep 23, 2026
437a671
feat: inspect the tool calls an upstream returns (#29)
fylorn Sep 23, 2026
038796b
build: keep only line tables in dev and test builds (#30)
fylorn Sep 24, 2026
05f52e8
feat: one circuit-breaker state machine, and hidden characters in req…
fylorn Sep 24, 2026
8a76395
fix: make the integration suite pass again (#32)
fylorn Sep 24, 2026
6482690
test: fix the two flaky integration tests (#33)
fylorn Sep 24, 2026
9f46116
fix: bill a Chat stream whose caller did not ask for usage (#34)
fylorn Sep 24, 2026
3c35ab8
fix: a request the upstream refuses no longer fails over or trips bre…
fylorn Sep 24, 2026
234fcce
Merge main (v1.1.0) into dev
fylorn Sep 24, 2026
82f8061
docs: cut releases from a release branch, and keep the tag's headings…
fylorn Sep 24, 2026
bd57b5d
ci: run checks on pull requests into dev, including the integration s…
fylorn Sep 24, 2026
c1b2085
fix: bill cached input at cache prices, estimate missing usage, strip…
fylorn Sep 24, 2026
ad5b75b
refactor: take back what only this side used from core (#41)
fylorn Sep 24, 2026
3a617f3
feat(gateway): take official hosts, output fallback and error shapes …
fylorn Sep 24, 2026
ed1c8b5
feat(gateway): accept Gemini clients, and the Responses API over a We…
fylorn Sep 24, 2026
28d4387
test: count the probes that miss before the streaming cache hit (#44)
fylorn Sep 24, 2026
d3f36fc
refactor(server): move the catalog's SQL into repositories (#45)
fylorn Sep 24, 2026
83e9bcc
refactor(server): move the dashboard, limits and log-forwarding handl…
fylorn Sep 24, 2026
9b5b326
refactor(server): move the MCP handlers' SQL into repositories (#50)
fylorn Sep 24, 2026
86beb82
refactor(gateway): run the request guards on core's tw-guard engines …
fylorn Sep 24, 2026
1c80b83
refactor(server): move the identity handlers' SQL into repositories (…
fylorn Sep 24, 2026
c25b44f
fix(mcp): revoking a default connection no longer fails with a 500 (#51)
fylorn Sep 24, 2026
dbce845
test: leave the cancelled stream after it has started, not on a timer…
fylorn Sep 24, 2026
c3d2d57
refactor(server): move the access handlers' SQL into repositories (#48)
fylorn Sep 24, 2026
d92c2d1
fix(settings): read security.totp_required as a boolean, and seed aut…
fylorn Sep 24, 2026
7e95183
feat(gateway): record a client that leaves before its response exists…
fylorn Sep 24, 2026
367df3c
feat(auth): enforce the TOTP requirement (#55)
fylorn Sep 24, 2026
443de8d
feat(gateway): keep the last response on a Responses WebSocket (#56)
fylorn Sep 24, 2026
6890c72
Merge main (v2.0.0) into dev
fylorn Sep 24, 2026
d4f6d5a
docs(release): CI runs the integration suite on the release PR (#58)
fylorn Sep 24, 2026
ef3de87
docs(contributing): point vulnerability reports at the organization's…
fylorn Sep 25, 2026
c4f8b1c
docs(readme): current description of ThinkWatch Lite and ThinkWatch C…
fylorn Sep 25, 2026
f259962
feat(providers): authenticate Bedrock with an API key (#61)
fylorn Sep 28, 2026
78f9ab5
test: hold the early-cancel client once its key is known, not on a ti…
fylorn Sep 28, 2026
1b752f2
feat(providers): list Bedrock's models, and let the route editor take…
fylorn Sep 28, 2026
1f08421
fix(bedrock): refuse keys that will not decrypt, and keep instance-ro…
fylorn Sep 28, 2026
e480bfd
refactor(bedrock): use core's shared tw-bedrock, and core v0.55.0 (#65)
fylorn Sep 29, 2026
e1e7999
docs(readme): rewrite both READMEs to be short and accurate (#66)
fylorn Sep 30, 2026
af9eb81
Merge main (v2.1.0) into dev
fylorn Sep 30, 2026
ed6dd39
docs(readme): 37 MCP templates, what the setup wizard does, body reda…
fylorn Sep 30, 2026
9afcc08
fix(rbac): make the seeded team_manager work at team scope (#69)
fylorn Sep 30, 2026
fa3a5b9
fix(rbac): require gateway use, and count only grants that give it (#70)
fylorn Sep 30, 2026
a1eed6f
Merge main (v2.2.0) into dev
fylorn Sep 30, 2026
da88b09
Merge main (sponsor line) into dev
fylorn Oct 2, 2026
1a399e7
Allow clippy::double_must_use on the async_trait BlobStore (#73)
fylorn Oct 2, 2026
8d8d96f
Unify the request guards with thinkwatch-core's rule model (#72)
fylorn Oct 3, 2026
2baa123
Fix what the pre-release review of the guard unification found (#75)
fylorn Oct 3, 2026
33c22dc
Merge main (v3.0.0) into dev
fylorn Oct 3, 2026
dcc8616
Merge main (README: ThinkWatch Lite for individual developers) into dev
fylorn Oct 4, 2026
6b73344
Merge main (v3.1.0) into dev
fylorn Oct 5, 2026
03c393c
Usage limits: token limits refuse, key limits count on the key, Retry…
fylorn Oct 5, 2026
4075855
fix(redis): connect over TLS for rediss:// URLs (#82)
fylorn Oct 5, 2026
33234ee
Concurrent startup, body retention on restart, and Helm network polic…
fylorn Oct 5, 2026
b7a34e1
Merge main (v3.2.0) into dev
fylorn Oct 5, 2026
2ce6471
chore(deps): update rust crate rand to v0.10.1 [security] (#77)
renovate[bot] Oct 6, 2026
0682864
chore(deps): update rust crate xxhash-rust to v0.8.16 [security] (#78)
renovate[bot] Oct 6, 2026
1fbe7bd
chore(deps): take the Renovate dashboard upgrades (#85)
fylorn Oct 6, 2026
2e23fd9
chore(deps): pnpm 12, and ClickHouse row validation on (#86)
fylorn Oct 6, 2026
977cab0
ci: save Rust caches only on pushes, and let releases read CI's image…
fylorn Oct 8, 2026
a19ed65
Merge main (v3.2.1) into dev
fylorn Oct 8, 2026
ea2a81f
Merge main (v3.3.0) into dev
fylorn Oct 9, 2026
e8ae1f1
Stream endings and in-stream errors logged like answers; core v0.68.0…
fylorn Oct 10, 2026
0e070a9
Merge main (v3.3.1) into dev
fylorn Oct 10, 2026
633ad90
Merge main (v3.4.0) into dev
fylorn Oct 10, 2026
235e38b
fix(limits): enforce route caps and close the places limits leaked (#94)
fylorn Oct 10, 2026
31cbfc8
feat: GET /v1/usage answers for the key or its owner; API key limits …
fylorn Oct 10, 2026
8151cee
Merge main (v3.5.0) into dev
fylorn Oct 10, 2026
721b5ee
Merge main (README zh) into dev
fylorn Oct 10, 2026
4f87839
feat(console): Add to ThinkWatch Lite; gateway address for every user…
fylorn Oct 11, 2026
042e605
Merge main (v3.6.0) into dev
fylorn Oct 11, 2026
ebdb9dc
chore: add a contributor license agreement signed by PR comment (#102)
fylorn Oct 11, 2026
133d610
ci: name the CLA job cla (#103)
fylorn Oct 12, 2026
eef9ff4
Merge main (3.7.0) back into dev
fylorn Oct 12, 2026
30c5f5b
Merge main (3.7.1) back into dev
fylorn Oct 12, 2026
12f4f82
Merge main (3.8.0) back into dev
fylorn Oct 13, 2026
45f965b
Redaction: search only the text written in a request, by its format
fylorn Oct 13, 2026
23be6b0
chore(release): tag 3.8.1
fylorn Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,56 @@ target.

## [Unreleased]

## [3.8.1] — 2026-10-13

Outbound redaction searches only the text written in a request, located
by the structure of the request's format, and no longer changes ids or
other values the protocol needs as they are. The thinkwatch-core crates
move from v0.73.0 to v0.74.0.

### Read before upgrading

- **Only the written text of a request is searched.** Fields outside it,
such as `metadata`, `user`, tool parameter schemas, response format
schemas and stop sequences, and blocks or fields the gateway does not
recognise, are no longer searched: a value there goes upstream as
written and is not reported. Hits inside ids and other protocol values
are no longer reported either, so fewer redaction events
(`gateway.redaction_replaced`, `gateway.redaction_flagged`) may be
written than before.
- **Stored bodies are masked as before.** With `audit.body_redact_pii` on,
stored request and response bodies are still searched over their whole
text, fields outside the written text included, and so is the answer a
log row stores under a content rule that checks answers.

### Fixed

- **Redaction no longer changes ids or protocol values.** Before a request
left, the built-in rules searched the whole body, ids included, and
custom rules skipped only some identifiers, by key name. With outbound
redaction On and the Chinese mainland mobile number rule
(`cn-mobile-phone`, off by default) enabled, a Codex reasoning item id
(`rs_…`) holding a run of 11 digits was taken for a mobile number and
replaced with a placeholder. The upstream refused the request, and since
Codex sends its reasoning items back on every turn, every later turn of
that conversation was refused as well. Under Monitor the id was reported
as a hit.

The rules now search only the text written in the request: system
prompts, the messages of every role (earlier answers and text documents
included), reasoning text and summaries, tool-call arguments and inputs,
tool results and tool descriptions, as well as Chat Completions'
predicted output and the code a Gemini model executed and its output.
Ids, types, roles, names, the model, signatures, encrypted content,
media and settings are never searched. Each hop is searched in the
format it is sent in: the caller's when the request is forwarded, the
upstream's when it is converted.

### Changed

- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
v0.73.0 → v0.74.0.

## [3.8.0] — 2026-10-13

The security page is organised by what each guard protects against: Secrets
Expand Down Expand Up @@ -2355,7 +2405,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql`
against PostgreSQL, restart against this tag. The schema is
idempotent end-to-end, so the apply is safe to repeat.

[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.0...HEAD
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.1...HEAD
[3.8.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.1
[3.8.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.0
[3.7.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.1
[3.7.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.0
Expand Down
28 changes: 14 additions & 14 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ members = [
]

[workspace.package]
version = "3.8.0"
version = "3.8.1"
edition = "2024"
# The MSRV: the newest `rust-version` among the locked dependencies
# (sqlx 0.9 and the aws-smithy crates under aws-sigv4). Without it,
Expand Down Expand Up @@ -70,10 +70,10 @@ opt-level = 3
# never re-exported through a local shim. And the reverse: something only
# this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error)
# lives here, not in core.
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" }
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" }
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" }
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" }

# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
Expand Down
15 changes: 9 additions & 6 deletions crates/common/src/pii.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@ use tw_guard::redact::rules::RuleSet;
/// built-in rule's id or the custom rule's name. Nothing is kept to restore
/// them: the result is write-only audit data.
///
/// Found the way a request body is searched (`tw_guard::redact::flow::hits`):
/// JSON escapes are read, a custom rule's match stays inside one JSON
/// string so the result is still JSON, base64 payloads are left alone,
/// and so are the gateway's own placeholders (a captured answer still
/// carries them). The one base64 payload that is read is a compaction
/// summary a conversion carries (see [`redact_carried_summaries`]).
/// Found over the whole text (`tw_guard::redact::flow::hits`), not only
/// the text written in a request the way a request on the wire is
/// searched: what is stored is never sent, and a captured body may be an
/// answer, a stream or a tool result. JSON escapes are read, a custom
/// rule's match stays inside one JSON string so the result is still JSON,
/// object keys and base64 payloads are left alone, and so are the
/// gateway's own placeholders (a captured answer still carries them).
/// The one base64 payload that is read is a compaction summary a
/// conversion carries (see [`redact_carried_summaries`]).
pub fn redact_blob(rules: &RuleSet, input: &str) -> String {
if rules.is_empty() {
return input.to_string();
Expand Down
5 changes: 4 additions & 1 deletion crates/gateway/src/lifecycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1490,7 +1490,10 @@ mod tests {
{"role": "user", "content": "my email is alice@corp.example.org"}
]})
.to_string();
let (_, ledger) = redaction.look(request.as_bytes());
let (_, ledger) = redaction.look(
request.as_bytes(),
crate::redaction::Shape::Generate(Dialect::Chat),
);
let content = crate::content_filter::ContentFilter::new(&ContentPolicy {
mode: Mode::Enforce,
custom: vec![CustomContentRule {
Expand Down
36 changes: 23 additions & 13 deletions crates/gateway/src/proxy/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
//! The content filter reads the caller's text where the request's own
//! format puts it, and a rule that deletes or replaces text does so there:
//! the request goes on as the changed one, decoded again. Outbound redaction
//! then searches the whole request, numbers what it finds once, and every
//! hop — forwarded or converted — goes out through it (see
//! `crate::redaction`). Both are thinkwatch-core's, shared with the
//! desktop gateway.
//! then searches the text written in the request, numbers what it finds
//! once, and every hop — forwarded or converted — goes out through it,
//! searched as the format it is sent in (see `crate::redaction`). Both
//! are thinkwatch-core's, shared with the desktop gateway.

use std::convert::Infallible;

Expand Down Expand Up @@ -61,7 +61,7 @@ use crate::guards::Caller;
use crate::lifecycle::Completed;
use crate::metadata::RequestMetadata;
use crate::protocol::UpstreamProtocol;
use crate::redaction::Redaction;
use crate::redaction::{Redaction, Shape};
use crate::router::RouteEntry;
use tw_guard::redact::replace::Ledger;

Expand Down Expand Up @@ -446,7 +446,11 @@ impl Outbound {
if let Some(stripped) = tw_dialect::convert::strip_carried(client, &bytes) {
bytes = stripped;
}
let (bytes, ledger) = self.redaction.replace(bytes, &self.ledger);
// Sent in the caller's own format: its written text is where
// the caller's format puts it.
let (bytes, ledger) =
self.redaction
.replace(bytes, &self.ledger, Shape::Generate(client));
return Ok(Wire {
body: bytes,
path,
Expand Down Expand Up @@ -479,8 +483,11 @@ impl Outbound {
body = serde_json::to_vec(&v).unwrap_or(body);
}
// The conversion moved the placeholders along with the text; one it
// assembled from two pieces is numbered here.
let (body, ledger) = self.redaction.replace(body, &self.ledger);
// assembled from two pieces is numbered here. The text is searched
// where the upstream's format puts it.
let (body, ledger) =
self.redaction
.replace(body, &self.ledger, Shape::Generate(protocol.dialect()));
// With none of the caller's own, any breakpoints are the ones the
// conversion added for Claude.
let auto_cache = decoded.request.cache.is_empty()
Expand Down Expand Up @@ -763,21 +770,24 @@ async fn run(
let decoded =
tw_dialect::convert::decode(surface.dialect, &raw, path, internal_query(surface.dialect));

// 5. Outbound redaction: the whole request is searched and what is
// found numbered once, in the order the caller wrote it.
// 5. Outbound redaction: the text written in the request — located by
// the structure of the caller's format, never its ids, types or
// names — is searched and what is found numbered once, in the order
// the caller wrote it.
// Placeholders are stable per value, so two callers sending the same
// structure redact to the same bytes and share a cache slot; each
// restores their own values on the way out.
//
// The audit row keeps what the caller wrote (their at-rest
// redaction setting applies on top).
let (findings, ledger) = guards.redaction.look(&body);
let shape = Shape::Generate(surface.dialect);
let (findings, ledger) = guards.redaction.look(&body, shape);
crate::redaction::record(&state.audit, &caller, &guards.redaction, &findings);
let request_for_audit = body.to_vec();
let outbound_body = if ledger.is_empty() {
raw
} else {
let (replaced, _) = guards.redaction.replace(body.to_vec(), &ledger);
let (replaced, _) = guards.redaction.replace(body.to_vec(), &ledger, shape);
serde_json::from_slice(&replaced).map_err(|_| {
ctx.emit(GatewayError::TransformError(
"The request could not be read after redaction.".into(),
Expand Down Expand Up @@ -1227,7 +1237,7 @@ mod tests {
mode: tw_guard::policy::Mode::Off,
..Default::default()
});
let (_, ledger) = redaction.look(b"{}");
let (_, ledger) = redaction.look(b"{}", Shape::Generate(surface.dialect));
Outbound {
surface,
path: path.into(),
Expand Down
14 changes: 10 additions & 4 deletions crates/gateway/src/proxy/shaper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,15 +326,21 @@ fn raw(f: &Frame) -> String {
mod tests {
use super::*;

/// The test requests are written as chat completions.
const CHAT: crate::redaction::Shape = crate::redaction::Shape::Generate(Dialect::Chat);

/// A ledger that issued `<<TW_EMAIL_1>>` for `a@x.com`, or nothing.
fn ctx(email: Option<&str>) -> Ledger {
let r = crate::redaction::Redaction::new(&tw_guard::policy::RedactPolicy {
mode: tw_guard::policy::Mode::Enforce,
enable: vec!["email".into()],
..Default::default()
});
let body = serde_json::json!({"text": email.unwrap_or("")}).to_string();
let ledger = r.look(body.as_bytes()).1;
let body = serde_json::json!({"messages": [
{"role": "user", "content": email.unwrap_or("")}
]})
.to_string();
let ledger = r.look(body.as_bytes(), CHAT).1;
assert_eq!(ledger.len(), usize::from(email.is_some()));
ledger
}
Expand Down Expand Up @@ -551,8 +557,8 @@ mod tests {
mode: tw_guard::policy::Mode::Enforce,
..Default::default()
});
let body = serde_json::json!({"text": PEM}).to_string();
let ledger = r.look(body.as_bytes()).1;
let body = serde_json::json!({"messages": [{"role": "user", "content": PEM}]}).to_string();
let ledger = r.look(body.as_bytes(), CHAT).1;
assert_eq!(ledger.len(), 1);
ledger
}
Expand Down
Loading
Loading