From 596c045fc8f7016fbe2a910550981ebcb654f208 Mon Sep 17 00:00:00 2001
From: fylorn <249551762+fylorn@users.noreply.github.com>
Date: Fri, 30 Oct 2026 09:28:46 +0800
Subject: [PATCH] Redaction exception values; scan cache for unchanged history
(core v0.86.0)
- Core crates pinned to v0.86.0.
- Exception values: stored in the redaction policy (`security.redact.
exceptions`, SHA-256 and masked form only). New endpoints list, add
(the pasted value is hashed on arrival and never stored, logged or
answered) and remove them, with the permissions of editing the
redaction policy; each add and remove is audited with the rule and the
masked value. The row is locked from read to write. A policy written
through the settings endpoint keeps the stored exceptions, since the
console writes the policy back from the rule view, which does not
carry them.
- Console: an Exceptions section under the redaction rules (masked
value, rule, added time, remove; add dialog; adding unavailable at the
cap); the trial marks excepted values as left as is.
- Gateway: one thinkwatch-core ScanCache per compiled redaction rule
set, used when looking at and replacing a request, so unchanged
conversation history is taken from the previous turn.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 37 ++-
Cargo.lock | 21 +-
Cargo.toml | 8 +-
crates/common/src/guard_policy/exceptions.rs | 102 ++++++++
crates/common/src/guard_policy/mod.rs | 1 +
crates/gateway/src/redaction.rs | 91 ++++++-
crates/server/src/app.rs | 10 +
crates/server/src/handlers/admin.rs | 7 +-
crates/server/src/handlers/admin/security.rs | 212 +++++++++++++++-
crates/server/src/handlers/admin/settings.rs | 19 +-
crates/server/src/openapi.rs | 3 +
crates/test-support/Cargo.toml | 1 +
.../test-support/tests/content_filter_pii.rs | 134 ++++++++++
web/src/i18n/en.json | 26 ++
web/src/i18n/zh.json | 26 ++
web/src/lib/security-types.ts | 28 ++-
.../gateway/security/exceptions-panel.tsx | 228 ++++++++++++++++++
.../routes/gateway/security/guard-panel.tsx | 2 +
web/src/routes/gateway/security/highlight.tsx | 8 +-
.../routes/gateway/security/rule-dialogs.tsx | 28 ++-
.../routes/gateway/security/security.test.tsx | 91 ++++++-
21 files changed, 1045 insertions(+), 38 deletions(-)
create mode 100644 crates/common/src/guard_policy/exceptions.rs
create mode 100644 web/src/routes/gateway/security/exceptions-panel.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a9155b3..16d18a1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,11 +15,14 @@ Secrets and privacy recognises far more: the built-in redaction catalog
grows from 30 rules to 120 in 20 groups, and the API keys saved
for the providers are recognised by value, whatever their format. The
console shows the rules' names and descriptions as thinkwatch-core
-writes them, in both languages. The thinkwatch-core crates move from
-v0.83.1 to v0.85.0.
+writes them, in both languages. A value recognised by mistake can be made
+an exception without switching its rule off, and long conversations are
+no longer scanned from the start every turn. The thinkwatch-core crates
+move from v0.83.1 to v0.86.0.
-No new setting, column or route; the redaction policy's shape is
-unchanged.
+No new setting or column. The redaction policy gains one optional field
+(`exceptions`), with endpoints of its own under
+`/api/admin/security/redact/exceptions`.
### Read before upgrading
@@ -40,7 +43,7 @@ unchanged.
### Added
-- **Expanded redaction rules** (thinkwatch-core v0.85.0): about 50 more
+- **Expanded redaction rules** (thinkwatch-core v0.85.0 and later): about 50 more
prefixed credentials (AI services, code hosting, cloud, payments and
collaboration), credentials by position (request headers, command
lines, URL parameters, bot webhooks, configuration assignments),
@@ -67,8 +70,32 @@ unchanged.
and no masked values; the security log shows it as the provider's API
key.
+- **Exception values** (`security.redact.exceptions`, the Exceptions
+ section under the Secrets and privacy rules). A value listed there is
+ neither replaced nor recorded, whichever rule finds it; the rules keep
+ working for every other value. A value is added by pasting it: the
+ server hashes it on arrival and keeps only its SHA-256, its masked form,
+ the rule that recognised it and when it was added, never the value. At
+ most 500; at the cap, adding is unavailable. Saved provider keys cannot
+ be exceptions. Listing them takes `settings:read`; adding and removing
+ take `settings:write` and `pii_redactor:write`, and each is written to
+ the audit log (`security.redact_exception_added`,
+ `security.redact_exception_removed`) with the rule and the masked value
+ only. The trial marks an excepted value as left as is.
+- **Unchanged conversation history is not scanned again.** Each gateway
+ instance keeps, in memory, where the redaction rules matched in the
+ parts of recent requests it has scanned (positions and rule ids, never
+ values); the next turn of the same conversation takes those parts from
+ it and scans only what is new. What is found is the same as without it.
+ The cache is rebuilt whenever the rules, the providers' keys or the
+ exceptions change.
+
### Changed
+- **A redaction policy written through `PATCH /api/admin/settings` keeps
+ the stored exceptions**, whatever it carries: they are changed only
+ through their own endpoints, and the console writes the rest of the
+ policy back from the rule view, which does not carry them.
- **Rule names and descriptions come from thinkwatch-core.** The security
page shows each built-in rule's name, reason and description and each
group's name as the rule view gives them, in English or Chinese; the
diff --git a/Cargo.lock b/Cargo.lock
index 1ca394cd..9c607902 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2944,7 +2944,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.5.10",
+ "socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
@@ -2983,7 +2983,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -4418,6 +4418,7 @@ dependencies = [
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
+ "tw-guard",
"url",
"uuid",
"wiremock",
@@ -4837,8 +4838,8 @@ dependencies = [
[[package]]
name = "tw-bedrock"
-version = "0.85.0"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b"
+version = "0.86.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -4852,16 +4853,16 @@ dependencies = [
[[package]]
name = "tw-breaker"
-version = "0.85.0"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b"
+version = "0.86.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20"
dependencies = [
"serde",
]
[[package]]
name = "tw-dialect"
-version = "0.85.0"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b"
+version = "0.86.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20"
dependencies = [
"memchr",
"serde",
@@ -4870,8 +4871,8 @@ dependencies = [
[[package]]
name = "tw-guard"
-version = "0.85.0"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b"
+version = "0.86.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20"
dependencies = [
"aho-corasick",
"base64 0.22.1",
diff --git a/Cargo.toml b/Cargo.toml
index a1099855..79fa1aa8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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.85.0" }
-tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" }
-tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" }
-tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" }
+tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
+tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
+tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
+tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
diff --git a/crates/common/src/guard_policy/exceptions.rs b/crates/common/src/guard_policy/exceptions.rs
new file mode 100644
index 00000000..5094821c
--- /dev/null
+++ b/crates/common/src/guard_policy/exceptions.rs
@@ -0,0 +1,102 @@
+//! Exception values of outbound redaction (`security.redact.exceptions`):
+//! values that are never replaced or recorded, whichever rule finds them.
+//!
+//! They live in the redaction policy, as thinkwatch-core defines it
+//! (`tw_guard::policy::RedactException`): **the SHA-256 of each value and
+//! its masked form, never the value**. The value an admin pastes is hashed
+//! as soon as it arrives and goes no further.
+//!
+//! **They are edited only through their own endpoints**
+//! (`/api/admin/security/redact/exceptions`). The console writes the rest
+//! of the policy back whole, rebuilt from the rule view, and the view does
+//! not carry them: a policy written through the settings endpoint keeps the
+//! stored exceptions ([`keep_stored`]) rather than losing them.
+
+use serde_json::Value;
+use tw_guard::policy::RedactException;
+
+/// The exceptions a stored redaction policy holds. A policy that is
+/// missing, or whose exceptions cannot be read, holds none.
+pub fn stored(policy: Option<&Value>) -> Vec {
+ policy
+ .and_then(|p| p.get("exceptions"))
+ .and_then(|e| serde_json::from_value(e.clone()).ok())
+ .unwrap_or_default()
+}
+
+/// `policy` (a stored redaction policy, or none) with `list` as its
+/// exceptions; the field is left out when the list is empty, as the
+/// factory policy leaves it out. Everything else stays as stored.
+pub fn with_list(policy: Option, list: &[RedactException]) -> Value {
+ let mut policy = match policy {
+ Some(Value::Object(map)) => Value::Object(map),
+ _ => Value::Object(Default::default()),
+ };
+ let map = policy.as_object_mut().expect("an object, just made one");
+ if list.is_empty() {
+ map.remove("exceptions");
+ } else {
+ map.insert(
+ "exceptions".into(),
+ serde_json::to_value(list).expect("exceptions serialise"),
+ );
+ }
+ policy
+}
+
+/// A redaction policy about to be saved through the settings endpoint,
+/// with the exceptions `stored` holds instead of whatever it carries.
+pub fn keep_stored(incoming: Value, stored_policy: Option<&Value>) -> Value {
+ match incoming {
+ Value::Object(_) => with_list(Some(incoming), &stored(stored_policy)),
+ // Not an object: validation refuses it as it is.
+ other => other,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn one(sha: char) -> RedactException {
+ RedactException {
+ sha256: sha.to_string().repeat(64),
+ masked: "sk-an…7f9c".into(),
+ rule: "anthropic-api-key".into(),
+ added_at: 1,
+ }
+ }
+
+ #[test]
+ fn a_settings_write_keeps_the_stored_exceptions() {
+ let stored_policy = with_list(Some(json!({"mode": "observe"})), &[one('a')]);
+ assert_eq!(stored(Some(&stored_policy)), [one('a')]);
+ // The console writes the policy without them, or with others.
+ for incoming in [
+ json!({"mode": "enforce"}),
+ json!({"mode": "enforce", "exceptions": [one('b')]}),
+ json!({"mode": "enforce", "exceptions": []}),
+ ] {
+ let kept = keep_stored(incoming, Some(&stored_policy));
+ assert_eq!(kept["mode"], "enforce");
+ assert_eq!(stored(Some(&kept)), [one('a')]);
+ }
+ // Nothing stored: none come in either.
+ let kept = keep_stored(json!({"exceptions": [one('b')]}), None);
+ assert!(kept.get("exceptions").is_none(), "{kept}");
+ // Not an object: left for validation to refuse.
+ assert_eq!(keep_stored(json!([]), Some(&stored_policy)), json!([]));
+ }
+
+ #[test]
+ fn an_empty_list_leaves_the_field_out_and_the_rest_as_stored() {
+ let p = with_list(
+ Some(json!({"mode": "enforce", "disable": ["jwt"], "exceptions": [one('a')]})),
+ &[],
+ );
+ assert_eq!(p, json!({"mode": "enforce", "disable": ["jwt"]}));
+ assert_eq!(with_list(None, &[]), json!({}));
+ assert!(stored(Some(&json!({"exceptions": "not a list"}))).is_empty());
+ }
+}
diff --git a/crates/common/src/guard_policy/mod.rs b/crates/common/src/guard_policy/mod.rs
index 193dddc0..876ebd7c 100644
--- a/crates/common/src/guard_policy/mod.rs
+++ b/crates/common/src/guard_policy/mod.rs
@@ -29,6 +29,7 @@ use tw_guard::redact::rules::RuleSet;
use crate::dynamic_config::DynamicConfig;
+pub mod exceptions;
pub mod legacy;
pub mod saved;
diff --git a/crates/gateway/src/redaction.rs b/crates/gateway/src/redaction.rs
index f7533eb7..ab939fd3 100644
--- a/crates/gateway/src/redaction.rs
+++ b/crates/gateway/src/redaction.rs
@@ -36,7 +36,8 @@ use std::sync::Arc;
use think_watch_common::audit::AuditLogger;
use think_watch_common::guard_policy::saved::{self, SavedSecrets};
use tw_guard::policy::{Mode, RedactPolicy};
-use tw_guard::redact::replace::Ledger;
+use tw_guard::redact::flow::ScanCache;
+use tw_guard::redact::replace::{Ledger, Scheme};
use tw_guard::redact::rules::{Finding, Kind, Rule, RuleSet, SavedSource};
pub use tw_guard::redact::flow::Shape;
@@ -44,11 +45,22 @@ pub use tw_guard::redact::flow::Shape;
use crate::guards::Caller;
/// Outbound redaction as configured: a mode, and the rules — with the
-/// providers' credentials, recognised by value.
+/// providers' credentials, recognised by value, and the exception values
+/// that are not.
+///
+/// **One scan cache per compiled rule set** (thinkwatch-core's
+/// [`ScanCache`]): a client sends the whole conversation every turn, and
+/// the parts of it already scanned last turn are taken from the cache
+/// instead of being scanned again. What is found is exactly what a scan
+/// without it finds. The cache keeps hit positions and rule ids, never a
+/// value, in this process's memory only; it is built with the rules and
+/// dropped with them when a policy, the providers' keys or the exceptions
+/// change.
#[derive(Debug, Clone)]
pub struct Redaction {
pub mode: Mode,
pub rules: Arc,
+ cache: Arc,
}
impl Redaction {
@@ -59,6 +71,7 @@ impl Redaction {
think_watch_common::guard_policy::redact_rules(policy),
saved,
)),
+ cache: Arc::new(ScanCache::new()),
}
}
@@ -66,7 +79,15 @@ impl Redaction {
/// in its written text (masked), and in enforce mode the ledger every
/// hop is replaced with. Nothing when off.
pub fn look(&self, body: &[u8], shape: Shape) -> (Vec, Ledger) {
- tw_guard::redact::flow::look(self.mode, &self.rules, body, shape)
+ let l = tw_guard::redact::flow::look_hits_with(
+ self.mode,
+ &self.rules,
+ body,
+ shape,
+ Ledger::new(Scheme::SECRET),
+ Some(&self.cache),
+ );
+ (l.found, l.ledger)
}
/// The bytes one hop sends, a request of `shape` — the format it goes
@@ -77,12 +98,13 @@ impl Redaction {
/// Byte for byte what came in, and the same ledger, unless in enforce
/// mode and something is found.
pub fn replace(&self, body: Vec, ledger: &Ledger, shape: Shape) -> (Vec, Ledger) {
- let (out, ledger) = tw_guard::redact::flow::replace(
+ let (out, ledger) = tw_guard::redact::flow::replace_with(
self.mode,
&self.rules,
bytes::Bytes::from(body),
ledger,
shape,
+ Some(&self.cache),
);
(Vec::from(out), ledger)
}
@@ -552,6 +574,67 @@ mod tests {
assert!(!writes_masked(&Rule::Custom("ssn".into())));
}
+ #[test]
+ fn an_exception_value_is_neither_replaced_nor_recorded() {
+ let other = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBB";
+ let r = Redaction::new(
+ &RedactPolicy {
+ mode: Mode::Enforce,
+ exceptions: vec![tw_guard::policy::RedactException {
+ sha256: tw_guard::redact::exceptions::sha256_hex(KEY),
+ masked: "sk-an…AAAA".into(),
+ rule: "anthropic-api-key".into(),
+ added_at: 1,
+ }],
+ ..Default::default()
+ },
+ &Default::default(),
+ );
+ let body = serde_json::json!({"messages": [
+ {"role": "user", "content": format!("{KEY} and {other}")}
+ ]})
+ .to_string();
+ let (found, ledger) = r.look(body.as_bytes(), CHAT);
+ assert_eq!(found.len(), 1, "{found:?}");
+ let (sent, _) = r.replace(body.into_bytes(), &ledger, CHAT);
+ let sent = String::from_utf8(sent).unwrap();
+ assert!(sent.contains(KEY) && !sent.contains(other), "{sent}");
+ }
+
+ /// The scan cache takes the unchanged history from the turn before; the
+ /// findings, the numbering and what is sent are the same as without it.
+ #[test]
+ fn a_growing_conversation_redacts_the_same_turn_after_turn() {
+ let r = redaction(Mode::Enforce);
+ let filler = "lorem ipsum dolor sit amet ".repeat(200);
+ let mut messages = Vec::new();
+ for turn in 0..30 {
+ let content = if turn % 7 == 0 {
+ format!(
+ "{filler} turn {turn} key sk-ant-api03-{}",
+ format!("{turn:02}").repeat(14)
+ )
+ } else {
+ format!("{filler} turn {turn}")
+ };
+ messages.push(serde_json::json!({"role": "user", "content": content}));
+ let body = serde_json::json!({"messages": messages}).to_string();
+ let cached = r.look(body.as_bytes(), CHAT);
+ let plain =
+ tw_guard::redact::flow::look(Mode::Enforce, &r.rules, body.as_bytes(), CHAT);
+ assert_eq!(cached.0, plain.0, "turn {turn}");
+ let (a, _) = r.replace(body.clone().into_bytes(), &cached.1, CHAT);
+ let (b, _) = tw_guard::redact::flow::replace(
+ Mode::Enforce,
+ &r.rules,
+ bytes::Bytes::from(body),
+ &plain.1,
+ CHAT,
+ );
+ assert_eq!(a, b.to_vec(), "turn {turn}");
+ }
+ }
+
#[test]
fn findings_are_one_entry_per_rule_and_a_custom_rule_keeps_no_values() {
let r = Redaction::new(
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index 2906396b..e4e551dc 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -1008,6 +1008,16 @@ pub fn create_console_app(config: &AppConfig, state: AppState) -> anyhow::Result
"/api/admin/security/{guard}/test",
post(handlers::admin::test_security),
)
+ // Redaction's exception values: their own endpoints, since the
+ // console writes the rest of the policy back whole.
+ .route(
+ "/api/admin/security/redact/exceptions",
+ get(handlers::admin::list_exceptions).post(handlers::admin::add_exception),
+ )
+ .route(
+ "/api/admin/security/redact/exceptions/{sha256}",
+ delete(handlers::admin::remove_exception),
+ )
// Log forwarders CRUD
.route(
"/api/admin/log-forwarders",
diff --git a/crates/server/src/handlers/admin.rs b/crates/server/src/handlers/admin.rs
index f9ac1362..f2b259db 100644
--- a/crates/server/src/handlers/admin.rs
+++ b/crates/server/src/handlers/admin.rs
@@ -19,7 +19,7 @@ pub use oidc::{
pub use retention::{
check_body_retention_vs_lifecycle, reconcile_blob_lifecycle, reconcile_clickhouse_ttls,
};
-pub use security::{get_security, test_security};
+pub use security::{add_exception, get_security, list_exceptions, remove_exception, test_security};
pub use settings::{
AuditConfigResponse, SystemInfo, UpdateSettingsRequest, get_all_settings, get_audit_settings,
get_settings_by_category, get_system_settings, update_settings,
@@ -42,7 +42,10 @@ pub use oidc::{
__path_update_oidc_draft,
};
#[allow(unused_imports)]
-pub use security::{__path_get_security, __path_test_security};
+pub use security::{
+ __path_add_exception, __path_get_security, __path_list_exceptions, __path_remove_exception,
+ __path_test_security,
+};
#[allow(unused_imports)]
pub use settings::{
__path_get_all_settings, __path_get_audit_settings, __path_get_settings_by_category,
diff --git a/crates/server/src/handlers/admin/security.rs b/crates/server/src/handlers/admin/security.rs
index d96c8a05..eccb5db5 100644
--- a/crates/server/src/handlers/admin/security.rs
+++ b/crates/server/src/handlers/admin/security.rs
@@ -14,14 +14,25 @@
//! `content_filter:read` for the content filter and tool-call inspection;
//! changing a policy takes `settings:write` and the guard's `:write`.
//! Reading the policies is reading settings.
+//!
+//! Redaction's exception values have endpoints of their own
+//! (`/api/admin/security/redact/exceptions`, see
+//! `think_watch_common::guard_policy::exceptions`): listing them is reading
+//! settings, adding and removing them is changing the redaction policy.
+//! A value pasted to add is hashed on arrival; only its SHA-256 and masked
+//! form are stored, answered or written to the audit log.
use axum::Json;
use axum::extract::{Path, State};
-use tw_guard::policy::Guard;
+use axum::http::StatusCode;
+use serde::{Deserialize, Serialize};
+use tw_guard::policy::{EXCEPTIONS_MAX, Guard, RedactException, RedactPolicy};
use tw_guard::trial::{TrialRequest, TrialResult};
use tw_guard::view::SecurityDetail;
+use think_watch_common::dynamic_config;
use think_watch_common::errors::AppError;
+use think_watch_common::guard_policy;
use crate::app::AppState;
use crate::middleware::auth_guard::AuthUser;
@@ -114,6 +125,205 @@ pub async fn test_security(
.map_err(|e| AppError::BadRequest(e.to_string()))
}
+/// `GET /api/admin/security/redact/exceptions`.
+#[derive(Debug, Serialize, utoipa::ToSchema)]
+pub struct ExceptionList {
+ /// `{ sha256, masked, rule?, added_at }`, in the order added.
+ #[schema(value_type = Vec