Skip to content

Commit 52ab27c

Browse files
authored
Release 3.8.1 (#107)
Outbound redaction searches only the text written in a request; thinkwatch-core v0.74.0.
1 parent c19e82f commit 52ab27c

10 files changed

Lines changed: 339 additions & 87 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,56 @@ target.
1111

1212
## [Unreleased]
1313

14+
## [3.8.1] — 2026-10-13
15+
16+
Outbound redaction searches only the text written in a request, located
17+
by the structure of the request's format, and no longer changes ids or
18+
other values the protocol needs as they are. The thinkwatch-core crates
19+
move from v0.73.0 to v0.74.0.
20+
21+
### Read before upgrading
22+
23+
- **Only the written text of a request is searched.** Fields outside it,
24+
such as `metadata`, `user`, tool parameter schemas, response format
25+
schemas and stop sequences, and blocks or fields the gateway does not
26+
recognise, are no longer searched: a value there goes upstream as
27+
written and is not reported. Hits inside ids and other protocol values
28+
are no longer reported either, so fewer redaction events
29+
(`gateway.redaction_replaced`, `gateway.redaction_flagged`) may be
30+
written than before.
31+
- **Stored bodies are masked as before.** With `audit.body_redact_pii` on,
32+
stored request and response bodies are still searched over their whole
33+
text, fields outside the written text included, and so is the answer a
34+
log row stores under a content rule that checks answers.
35+
36+
### Fixed
37+
38+
- **Redaction no longer changes ids or protocol values.** Before a request
39+
left, the built-in rules searched the whole body, ids included, and
40+
custom rules skipped only some identifiers, by key name. With outbound
41+
redaction On and the Chinese mainland mobile number rule
42+
(`cn-mobile-phone`, off by default) enabled, a Codex reasoning item id
43+
(`rs_…`) holding a run of 11 digits was taken for a mobile number and
44+
replaced with a placeholder. The upstream refused the request, and since
45+
Codex sends its reasoning items back on every turn, every later turn of
46+
that conversation was refused as well. Under Monitor the id was reported
47+
as a hit.
48+
49+
The rules now search only the text written in the request: system
50+
prompts, the messages of every role (earlier answers and text documents
51+
included), reasoning text and summaries, tool-call arguments and inputs,
52+
tool results and tool descriptions, as well as Chat Completions'
53+
predicted output and the code a Gemini model executed and its output.
54+
Ids, types, roles, names, the model, signatures, encrypted content,
55+
media and settings are never searched. Each hop is searched in the
56+
format it is sent in: the caller's when the request is forwarded, the
57+
upstream's when it is converted.
58+
59+
### Changed
60+
61+
- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard)
62+
v0.73.0 → v0.74.0.
63+
1464
## [3.8.0] — 2026-10-13
1565

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

2358-
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.0...HEAD
2408+
[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.1...HEAD
2409+
[3.8.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.1
23592410
[3.8.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.0
23602411
[3.7.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.1
23612412
[3.7.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.0

‎Cargo.lock‎

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ members = [
1010
]
1111

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

7878
# Web framework
7979
axum = { version = "0.8", features = ["macros", "ws"] }

‎crates/common/src/pii.rs‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ use tw_guard::redact::rules::RuleSet;
2424
/// built-in rule's id or the custom rule's name. Nothing is kept to restore
2525
/// them: the result is write-only audit data.
2626
///
27-
/// Found the way a request body is searched (`tw_guard::redact::flow::hits`):
28-
/// JSON escapes are read, a custom rule's match stays inside one JSON
29-
/// string so the result is still JSON, base64 payloads are left alone,
30-
/// and so are the gateway's own placeholders (a captured answer still
31-
/// carries them). The one base64 payload that is read is a compaction
32-
/// summary a conversion carries (see [`redact_carried_summaries`]).
27+
/// Found over the whole text (`tw_guard::redact::flow::hits`), not only
28+
/// the text written in a request the way a request on the wire is
29+
/// searched: what is stored is never sent, and a captured body may be an
30+
/// answer, a stream or a tool result. JSON escapes are read, a custom
31+
/// rule's match stays inside one JSON string so the result is still JSON,
32+
/// object keys and base64 payloads are left alone, and so are the
33+
/// gateway's own placeholders (a captured answer still carries them).
34+
/// The one base64 payload that is read is a compaction summary a
35+
/// conversion carries (see [`redact_carried_summaries`]).
3336
pub fn redact_blob(rules: &RuleSet, input: &str) -> String {
3437
if rules.is_empty() {
3538
return input.to_string();

‎crates/gateway/src/lifecycle/mod.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1490,7 +1490,10 @@ mod tests {
14901490
{"role": "user", "content": "my email is alice@corp.example.org"}
14911491
]})
14921492
.to_string();
1493-
let (_, ledger) = redaction.look(request.as_bytes());
1493+
let (_, ledger) = redaction.look(
1494+
request.as_bytes(),
1495+
crate::redaction::Shape::Generate(Dialect::Chat),
1496+
);
14941497
let content = crate::content_filter::ContentFilter::new(&ContentPolicy {
14951498
mode: Mode::Enforce,
14961499
custom: vec![CustomContentRule {

‎crates/gateway/src/proxy/generate.rs‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
//! The content filter reads the caller's text where the request's own
2727
//! format puts it, and a rule that deletes or replaces text does so there:
2828
//! the request goes on as the changed one, decoded again. Outbound redaction
29-
//! then searches the whole request, numbers what it finds once, and every
30-
//! hop — forwarded or converted — goes out through it (see
31-
//! `crate::redaction`). Both are thinkwatch-core's, shared with the
32-
//! desktop gateway.
29+
//! then searches the text written in the request, numbers what it finds
30+
//! once, and every hop — forwarded or converted — goes out through it,
31+
//! searched as the format it is sent in (see `crate::redaction`). Both
32+
//! are thinkwatch-core's, shared with the desktop gateway.
3333
3434
use std::convert::Infallible;
3535

@@ -61,7 +61,7 @@ use crate::guards::Caller;
6161
use crate::lifecycle::Completed;
6262
use crate::metadata::RequestMetadata;
6363
use crate::protocol::UpstreamProtocol;
64-
use crate::redaction::Redaction;
64+
use crate::redaction::{Redaction, Shape};
6565
use crate::router::RouteEntry;
6666
use tw_guard::redact::replace::Ledger;
6767

@@ -446,7 +446,11 @@ impl Outbound {
446446
if let Some(stripped) = tw_dialect::convert::strip_carried(client, &bytes) {
447447
bytes = stripped;
448448
}
449-
let (bytes, ledger) = self.redaction.replace(bytes, &self.ledger);
449+
// Sent in the caller's own format: its written text is where
450+
// the caller's format puts it.
451+
let (bytes, ledger) =
452+
self.redaction
453+
.replace(bytes, &self.ledger, Shape::Generate(client));
450454
return Ok(Wire {
451455
body: bytes,
452456
path,
@@ -479,8 +483,11 @@ impl Outbound {
479483
body = serde_json::to_vec(&v).unwrap_or(body);
480484
}
481485
// The conversion moved the placeholders along with the text; one it
482-
// assembled from two pieces is numbered here.
483-
let (body, ledger) = self.redaction.replace(body, &self.ledger);
486+
// assembled from two pieces is numbered here. The text is searched
487+
// where the upstream's format puts it.
488+
let (body, ledger) =
489+
self.redaction
490+
.replace(body, &self.ledger, Shape::Generate(protocol.dialect()));
484491
// With none of the caller's own, any breakpoints are the ones the
485492
// conversion added for Claude.
486493
let auto_cache = decoded.request.cache.is_empty()
@@ -763,21 +770,24 @@ async fn run(
763770
let decoded =
764771
tw_dialect::convert::decode(surface.dialect, &raw, path, internal_query(surface.dialect));
765772

766-
// 5. Outbound redaction: the whole request is searched and what is
767-
// found numbered once, in the order the caller wrote it.
773+
// 5. Outbound redaction: the text written in the request — located by
774+
// the structure of the caller's format, never its ids, types or
775+
// names — is searched and what is found numbered once, in the order
776+
// the caller wrote it.
768777
// Placeholders are stable per value, so two callers sending the same
769778
// structure redact to the same bytes and share a cache slot; each
770779
// restores their own values on the way out.
771780
//
772781
// The audit row keeps what the caller wrote (their at-rest
773782
// redaction setting applies on top).
774-
let (findings, ledger) = guards.redaction.look(&body);
783+
let shape = Shape::Generate(surface.dialect);
784+
let (findings, ledger) = guards.redaction.look(&body, shape);
775785
crate::redaction::record(&state.audit, &caller, &guards.redaction, &findings);
776786
let request_for_audit = body.to_vec();
777787
let outbound_body = if ledger.is_empty() {
778788
raw
779789
} else {
780-
let (replaced, _) = guards.redaction.replace(body.to_vec(), &ledger);
790+
let (replaced, _) = guards.redaction.replace(body.to_vec(), &ledger, shape);
781791
serde_json::from_slice(&replaced).map_err(|_| {
782792
ctx.emit(GatewayError::TransformError(
783793
"The request could not be read after redaction.".into(),
@@ -1227,7 +1237,7 @@ mod tests {
12271237
mode: tw_guard::policy::Mode::Off,
12281238
..Default::default()
12291239
});
1230-
let (_, ledger) = redaction.look(b"{}");
1240+
let (_, ledger) = redaction.look(b"{}", Shape::Generate(surface.dialect));
12311241
Outbound {
12321242
surface,
12331243
path: path.into(),

‎crates/gateway/src/proxy/shaper.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,15 +326,21 @@ fn raw(f: &Frame) -> String {
326326
mod tests {
327327
use super::*;
328328

329+
/// The test requests are written as chat completions.
330+
const CHAT: crate::redaction::Shape = crate::redaction::Shape::Generate(Dialect::Chat);
331+
329332
/// A ledger that issued `<<TW_EMAIL_1>>` for `a@x.com`, or nothing.
330333
fn ctx(email: Option<&str>) -> Ledger {
331334
let r = crate::redaction::Redaction::new(&tw_guard::policy::RedactPolicy {
332335
mode: tw_guard::policy::Mode::Enforce,
333336
enable: vec!["email".into()],
334337
..Default::default()
335338
});
336-
let body = serde_json::json!({"text": email.unwrap_or("")}).to_string();
337-
let ledger = r.look(body.as_bytes()).1;
339+
let body = serde_json::json!({"messages": [
340+
{"role": "user", "content": email.unwrap_or("")}
341+
]})
342+
.to_string();
343+
let ledger = r.look(body.as_bytes(), CHAT).1;
338344
assert_eq!(ledger.len(), usize::from(email.is_some()));
339345
ledger
340346
}
@@ -551,8 +557,8 @@ mod tests {
551557
mode: tw_guard::policy::Mode::Enforce,
552558
..Default::default()
553559
});
554-
let body = serde_json::json!({"text": PEM}).to_string();
555-
let ledger = r.look(body.as_bytes()).1;
560+
let body = serde_json::json!({"messages": [{"role": "user", "content": PEM}]}).to_string();
561+
let ledger = r.look(body.as_bytes(), CHAT).1;
556562
assert_eq!(ledger.len(), 1);
557563
ledger
558564
}

0 commit comments

Comments
 (0)