diff --git a/CHANGELOG.md b/CHANGELOG.md
index cebd2d12..8eb5de1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,168 @@ target.
## [Unreleased]
+## [3.11.0] — 2026-11-03
+
+Execution and buffering now adapt automatically to available CPUs and memory,
+including container limits. CPU work expands with traffic and extra threads
+retire after ten idle seconds; no runtime configuration change is required.
+
+Secrets and privacy recognises far more: the built-in redaction catalog
+grows from 30 rules to 131 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. 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. A provider can set the
+slowest generation its streams may keep up, and a stream that falls
+below it is cut so the client can try again. The thinkwatch-core crates
+move from v0.83.1 to v0.89.0.
+
+No new runtime setting. Two new provider columns
+(`providers.min_speed_tokens_per_sec`, `providers.min_speed_window_secs`,
+added on startup). New metrics cover minimum generation speed, buffering
+pressure and audit backlog memory.
+The redaction policy gains one optional field (`exceptions`), with
+endpoints of its own under `/api/admin/security/redact/exceptions`.
+
+### Read before upgrading
+
+- **Buffering is admitted against a process-wide memory budget.** Request,
+ response, cache and retained streaming data use limits derived from the
+ container or machine's memory. Temporary pressure returns a retryable 429
+ with `Retry-After: 1`; an already-started stream receives an error event.
+ Large upstream responses and stream timelines are bounded, and new audit
+ entries are dropped with metrics if the separate audit byte budget fills.
+ See [automatic process resources](docs/operations/resources.md) for the
+ limits and monitoring. These allocation budgets are not a hard RSS cap.
+- **UUIDs and blockchain credentials gain built-in rules.** Core v0.88.0
+ added 11 rules, enabled by default, including UUIDs, wallet keys and
+ accounts, exchange credentials and recovery material. Monitor records
+ findings; installations with protection On replace matching message
+ text. UUIDs, hashes and public accounts do not independently trigger
+ credential-exfiltration blocking. Review any workflows that send order
+ or trace identifiers as message text.
+- **New credential rules ship on.** Under the On state of Secrets and
+ privacy (`security.redact` mode `enforce`), values they find start
+ being replaced with placeholders: among them credentials in request
+ headers, command lines, URLs and bot webhooks, and secrets assigned in
+ `.env`-style configuration (`DB_PASSWORD=…`). Under Monitor, the
+ factory state, they are only recorded. A rule that gets in the way can
+ be switched off on its own in the console.
+- **Some keys are reported under a rule of their own.** `sk-or-v1-`
+ (OpenRouter), `sk-lf-` (Langfuse), `sk-svcacct-`, `sk-admin-` and
+ `sk-api-` keys were reported as `openai-api-key`; they now have their
+ own rule ids, and so do audit events and the
+ `gateway_redaction_found_total` metric's `kind` label, which follows
+ the new groups (`ai-services`, `dev-platforms`, `cloud`, …) instead of
+ `api-keys` and `personal`.
+
+### Added
+
+- **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),
+ cryptocurrency recovery phrases and private keys, and personal
+ information: identity documents, foreign ID numbers, IBAN, phone
+ numbers, addresses and coordinates, vehicles, device identifiers and
+ values by field name. Of the new personal-information rules only the
+ two that can be checked digit by digit ship on (passport
+ machine-readable zone, IBAN), alongside the resident ID and bank card
+ numbers as before.
+- **Saved upstream keys are recognised by value.** The API keys in each
+ provider's credential headers (the `Bearer` scheme left out) and a
+ Bedrock provider's secret access key are matched wherever they appear
+ in a request, as written or JSON-escaped; values shorter than 12
+ characters are not. The list follows the providers: it is rebuilt when
+ they load, when one is created, changed or deleted, and on the other
+ instances through the router reload. The same keys count as
+ credentials for the dangerous-commands rule "credential sent to an
+ unknown host", each allowed to its own provider's host, and the
+ console's trial recognises them too.
+- **A saved key's audit event names its provider, never the value.** The
+ `gateway.redaction_*` event of the rule `thinkwatch-saved-secret`
+ carries `sources` (`[{"kind": "upstream-key", "name": }]`)
+ 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.
+- **Stop a stream that generates too slowly** (the provider's
+ "Stop when generation is slower than … tokens/s for … s" setting,
+ `min_speed_tokens_per_sec` 1–1000 and `min_speed_window_secs` 5–120,
+ default 20, on the provider endpoints; blank or `null` is off, the
+ default). From the first token on, a stream is judged once a second:
+ when the visible text it streamed over the last window (answer,
+ reasoning streamed as generated, and tool arguments; four ASCII
+ characters or one other character to a token) comes to fewer tokens
+ per second than the minimum, the stream ends with a 503 error in the
+ client's format (`overloaded_error` for Anthropic, `response.failed`
+ for Responses, `UNAVAILABLE` for Gemini), the upstream connection is
+ closed, and the route is charged a failure. Left out of the window:
+ pauses of a quarter of the window or more (at most 5 s), and
+ summarised reasoning (Responses reasoning summaries, Anthropic thinking
+ blocks, Gemini thought parts, Bedrock reasoning content), which arrives
+ in bursts while the model reasons; a window more than half left out is
+ not judged. Nor is a second while summarised reasoning is open, one
+ whose last content is more than a second old, or anything after the
+ upstream has ended the answer. Counts the upstream reports are not
+ used: Gemini's includes its hidden thoughts. The session's affinity to
+ that route is dropped, the answer so far is billed as for any stream
+ cut short, and the log row carries the error `UpstreamTooSlow` and the
+ speed at the cut (`tokens_per_sec`). Whole answers and Responses
+ WebSocket turns are not measured.
+
+### Changed
+
+- **CPU execution grows with load.** The permanent I/O scheduler uses at
+ most eight workers by default. Parsing, redaction, inspection and
+ conversion share CPU admission with password hashing; sufficiently
+ provisioned 80-CPU machines can run 80 CPU jobs. Extra threads retire
+ after ten idle seconds. Argon2 also observes a memory-based concurrency
+ limit, retained until a running hash finishes even if its caller leaves.
+- **Buffers and audit retries retain admission until their final owner
+ releases them.** Chunked AI/MCP responses, cache reads, transformed
+ output and detached stream tails use the same buffering budget. MCP
+ discovery, provider/registry catalogs, OAuth endpoints, S3 reads and
+ audit-forwarder diagnostics also use bounded reads. Local pressure does
+ not count as an upstream failure. Audit queue and ClickHouse retry
+ batches share a separate byte limit and expose buffered-memory and
+ dropped-entry pressure through metrics.
+- **Release binaries use mimalloc.** Linux also applies a process-local
+ transparent-huge-page policy before runtime threads start. Host sysctls
+ and deployment resource settings need no changes.
+- **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
+ console no longer keeps its own name tables. Security-log rows and
+ trial hits name the rule by looking its id up in the rule view. The
+ rule search also matches prefixes, field names and descriptions.
+- **The conversion of the old PII pattern list** switches on or off only
+ the four personal-information rules that list could express; the new
+ personal-information rules keep their factory setting.
+
## [3.10.0] — 2026-10-25
Upstreams that fail for the moment before any of the answer reached the
@@ -2694,7 +2856,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.10.0...HEAD
+[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.11.0...HEAD
+[3.11.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.11.0
[3.10.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.10.0
[3.9.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.9.1
[3.9.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.9.0
diff --git a/Cargo.lock b/Cargo.lock
index 12d5252b..fcd752a6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -443,6 +443,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bech32"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -610,7 +616,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -1335,6 +1341,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
[[package]]
name = "ff"
version = "0.13.1"
@@ -1575,8 +1587,8 @@ dependencies = [
"libc",
"log",
"rustversion",
- "windows-link",
- "windows-result",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
]
[[package]]
@@ -1919,7 +1931,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core",
+ "windows-core 0.62.2",
]
[[package]]
@@ -2108,7 +2120,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde_core",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -2162,7 +2174,7 @@ dependencies = [
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -2274,6 +2286,15 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+[[package]]
+name = "libmimalloc-sys"
+version = "0.1.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
@@ -2284,6 +2305,12 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "litemap"
version = "0.8.3"
@@ -2409,6 +2436,15 @@ dependencies = [
"sketches-ddsketch",
]
+[[package]]
+name = "mimalloc"
+version = "0.1.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
+dependencies = [
+ "libmimalloc-sys",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -2468,6 +2504,15 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2568,6 +2613,25 @@ dependencies = [
"url",
]
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2689,7 +2753,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -2944,7 +3008,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.6.5",
+ "socket2 0.5.10",
"thiserror 2.0.21",
"tokio",
"tracing",
@@ -2983,7 +3047,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.6.5",
+ "socket2 0.5.10",
"tracing",
"windows-sys 0.61.2",
]
@@ -3362,6 +3426,19 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustix"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "rustls"
version = "0.23.46"
@@ -4166,6 +4243,20 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "sysinfo"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows",
+]
+
[[package]]
name = "system-configuration"
version = "0.7.0"
@@ -4187,9 +4278,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.4",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "think-watch-auth"
-version = "3.10.0"
+version = "3.11.0"
dependencies = [
"anyhow",
"argon2",
@@ -4219,10 +4323,11 @@ dependencies = [
[[package]]
name = "think-watch-common"
-version = "3.10.0"
+version = "3.11.0"
dependencies = [
"aes-gcm",
"anyhow",
+ "async-stream",
"async-trait",
"aws-credential-types",
"aws-sigv4",
@@ -4236,6 +4341,8 @@ dependencies = [
"hex",
"hmac 0.13.0",
"http",
+ "http-body",
+ "http-body-util",
"hyper-util",
"metrics",
"rand 0.10.3",
@@ -4248,8 +4355,11 @@ dependencies = [
"sha1 0.11.0",
"sha2 0.11.0",
"sqlx",
+ "sysinfo",
+ "tempfile",
"thiserror 2.0.21",
"tokio",
+ "tower",
"tracing",
"tw-bedrock",
"tw-breaker",
@@ -4262,7 +4372,7 @@ dependencies = [
[[package]]
name = "think-watch-gateway"
-version = "3.10.0"
+version = "3.11.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -4301,7 +4411,7 @@ dependencies = [
[[package]]
name = "think-watch-mcp-gateway"
-version = "3.10.0"
+version = "3.11.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -4330,7 +4440,7 @@ dependencies = [
[[package]]
name = "think-watch-server"
-version = "3.10.0"
+version = "3.11.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -4344,8 +4454,10 @@ dependencies = [
"futures",
"hex",
"hmac 0.13.0",
+ "libc",
"metrics",
"metrics-exporter-prometheus",
+ "mimalloc",
"openidconnect",
"p256 0.14.0",
"rand 0.10.3",
@@ -4381,7 +4493,7 @@ dependencies = [
[[package]]
name = "think-watch-test-support"
-version = "3.10.0"
+version = "3.11.0"
dependencies = [
"anyhow",
"async-stream",
@@ -4418,6 +4530,7 @@ dependencies = [
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
+ "tw-guard",
"url",
"uuid",
"wiremock",
@@ -4837,8 +4950,8 @@ dependencies = [
[[package]]
name = "tw-bedrock"
-version = "0.83.1"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9"
+version = "0.89.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.89.0#c66a3b04ba297f608f2fc21501c73f21a78ab8ed"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -4852,16 +4965,16 @@ dependencies = [
[[package]]
name = "tw-breaker"
-version = "0.83.1"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9"
+version = "0.89.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.89.0#c66a3b04ba297f608f2fc21501c73f21a78ab8ed"
dependencies = [
"serde",
]
[[package]]
name = "tw-dialect"
-version = "0.83.1"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9"
+version = "0.89.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.89.0#c66a3b04ba297f608f2fc21501c73f21a78ab8ed"
dependencies = [
"memchr",
"serde",
@@ -4870,18 +4983,21 @@ dependencies = [
[[package]]
name = "tw-guard"
-version = "0.83.1"
-source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9"
+version = "0.89.0"
+source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.89.0#c66a3b04ba297f608f2fc21501c73f21a78ab8ed"
dependencies = [
"aho-corasick",
"base64 0.22.1",
+ "bech32",
"bytes",
+ "curve25519-dalek",
"memchr",
"regex",
"regex-syntax",
"serde",
"serde_json",
"serde_yaml_ng",
+ "sha2 0.11.0",
"thiserror 2.0.21",
"tw-dialect",
]
@@ -5236,6 +5352,41 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -5244,9 +5395,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
]
[[package]]
@@ -5271,21 +5433,46 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
- "windows-link",
- "windows-result",
- "windows-strings",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
]
[[package]]
@@ -5294,7 +5481,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
]
[[package]]
@@ -5303,7 +5499,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -5321,7 +5517,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -5340,6 +5536,15 @@ dependencies = [
"windows_x86_64_msvc",
]
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
diff --git a/Cargo.toml b/Cargo.toml
index ea2d34fc..4b64cd1f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
-version = "3.10.0"
+version = "3.11.0"
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,
@@ -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.83.1" }
-tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }
-tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }
-tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" }
+tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
+tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
+tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
+tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
# Web framework
axum = { version = "0.8", features = ["macros", "ws"] }
diff --git a/crates/auth/src/oauth/client.rs b/crates/auth/src/oauth/client.rs
index d4f62426..553914c7 100644
--- a/crates/auth/src/oauth/client.rs
+++ b/crates/auth/src/oauth/client.rs
@@ -98,7 +98,15 @@ pub async fn exchange_authorization_code(
.map_err(TokenExchangeError::Unreachable)?;
let status = resp.status();
- let resp_text = resp.text().await.map_err(TokenExchangeError::Unreachable)?;
+ let bytes = think_watch_common::resources::read_response(resp, 64 * 1024)
+ .await
+ .map_err(|e| match e {
+ think_watch_common::resources::ReadError::Transport(e) => {
+ TokenExchangeError::Unreachable(e)
+ }
+ other => TokenExchangeError::InvalidResponse(other.to_string()),
+ })?;
+ let resp_text = String::from_utf8_lossy(&bytes);
// Two cases land here:
// * non-2xx status (always treated as failure)
diff --git a/crates/auth/src/password.rs b/crates/auth/src/password.rs
index 4a2c3dae..4cb9446c 100644
--- a/crates/auth/src/password.rs
+++ b/crates/auth/src/password.rs
@@ -1,10 +1,7 @@
-use std::sync::{Arc, LazyLock};
-
use argon2::{
Argon2,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
-use tokio::sync::Semaphore;
const RANDOM_PASSWORD_LEN: usize = 16;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*";
@@ -60,24 +57,13 @@ pub fn verify_password_or_dummy(password: &str, hash: Option<&str>) -> bool {
// every other request scheduled on that thread, so the handlers use
// these versions, which run it on the blocking pool.
-/// How many Argon2 runs go at once: one per CPU, as many as the async
-/// workers allowed when they ran it. Each run holds 19 MiB (`m=19456`)
-/// while it lasts and the blocking pool grows to hundreds of threads, so
-/// without this a burst of logins would hold gigabytes at once.
-static ARGON2_PERMITS: LazyLock> = LazyLock::new(|| {
- Arc::new(Semaphore::new(
- std::thread::available_parallelism().map_or(1, |n| n.get()),
- ))
-});
-
-/// `f` on the blocking pool once an [`ARGON2_PERMITS`] place is free. The
-/// task keeps the place until it ends, even when the caller has gone.
+/// Share CPU admission with the data plane and limit Argon2 memory using
+/// the container allowance. The running task owns both places even when
+/// its caller is cancelled.
async fn on_blocking_pool(
f: impl FnOnce() -> T + Send + 'static,
) -> Result {
- // Never closed, so the acquire cannot fail; the permit inside the
- // result is held all the same.
- let permit = Arc::clone(&ARGON2_PERMITS).acquire_owned().await;
+ let permit = think_watch_common::resources::password_slot().await;
tokio::task::spawn_blocking(move || {
let _permit = permit;
f()
@@ -111,6 +97,48 @@ pub async fn verify_password_or_dummy_off_runtime(password: String, hash: Option
mod tests {
use super::*;
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn cancelling_callers_does_not_admit_more_running_password_jobs() {
+ use std::time::Duration;
+ let policy = think_watch_common::resources::policy();
+ let jobs = policy.argon_jobs.min(policy.cpu_jobs);
+ let (started, mut beginnings) = tokio::sync::mpsc::unbounded_channel();
+ let mut release = Vec::new();
+ let mut callers = Vec::new();
+ for _ in 0..jobs {
+ let (tx, rx) = std::sync::mpsc::channel::<()>();
+ release.push(tx);
+ let started = started.clone();
+ callers.push(tokio::spawn(on_blocking_pool(move || {
+ started.send(()).unwrap();
+ let _ = rx.recv_timeout(Duration::from_secs(10));
+ })));
+ }
+ for _ in 0..jobs {
+ tokio::time::timeout(Duration::from_secs(5), beginnings.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ }
+ for caller in callers {
+ caller.abort();
+ assert!(caller.await.unwrap_err().is_cancelled());
+ }
+ let next = tokio::spawn(on_blocking_pool(move || started.send(()).unwrap()));
+ assert!(
+ tokio::time::timeout(Duration::from_millis(50), beginnings.recv())
+ .await
+ .is_err()
+ );
+ drop(release);
+ tokio::time::timeout(Duration::from_secs(5), next)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert_eq!(beginnings.recv().await, Some(()));
+ }
+
#[tokio::test]
async fn the_off_runtime_versions_agree_with_the_blocking_ones() {
let hash = hash_password_off_runtime("pw".into()).await.unwrap();
@@ -133,7 +161,9 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
static RUNNING: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
- let permits = std::thread::available_parallelism().map_or(1, |n| n.get());
+ let permits = think_watch_common::resources::policy()
+ .argon_jobs
+ .min(think_watch_common::resources::policy().cpu_jobs);
let tasks: Vec<_> = (0..permits * 4)
.map(|_| {
tokio::spawn(on_blocking_pool(|| {
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
index b196e8bc..ca37e675 100644
--- a/crates/common/Cargo.toml
+++ b/crates/common/Cargo.toml
@@ -48,3 +48,11 @@ aws-sigv4 = { workspace = true }
aws-credential-types = { workspace = true }
http_1x = { package = "http", version = "1" }
async-trait = "0.1"
+http-body-util = { workspace = true }
+http-body = "1"
+sysinfo = { version = "0.36", default-features = false, features = ["system"] }
+
+[dev-dependencies]
+async-stream = { workspace = true }
+tempfile = "3"
+tower = { workspace = true, features = ["util"] }
diff --git a/crates/common/src/audit/forwarders.rs b/crates/common/src/audit/forwarders.rs
index 90023b4e..31a2dd14 100644
--- a/crates/common/src/audit/forwarders.rs
+++ b/crates/common/src/audit/forwarders.rs
@@ -202,7 +202,10 @@ pub(super) async fn send_kafka(
if resp.status().is_success() {
Ok(())
} else {
- let body = resp.text().await.unwrap_or_default();
+ let bytes = crate::resources::read_response(resp, 64 * 1024)
+ .await
+ .map_err(|e| e.to_string())?;
+ let body = String::from_utf8_lossy(&bytes);
Err(format!("Kafka REST proxy returned error: {body}"))
}
}
@@ -300,7 +303,10 @@ pub(super) async fn send_webhook(
Ok(resp) if resp.status().is_success() => Ok(()),
Ok(resp) => {
let status = resp.status();
- let rtext = resp.text().await.unwrap_or_default();
+ let bytes = crate::resources::read_response(resp, 64 * 1024)
+ .await
+ .map_err(|e| e.to_string())?;
+ let rtext = String::from_utf8_lossy(&bytes);
Err(format!("Webhook returned {status}: {rtext}"))
}
Err(e) => Err(format!("Webhook request failed: {e}")),
diff --git a/crates/common/src/audit/logger.rs b/crates/common/src/audit/logger.rs
index a0a194cd..df17df10 100644
--- a/crates/common/src/audit/logger.rs
+++ b/crates/common/src/audit/logger.rs
@@ -59,17 +59,15 @@ pub struct AuditLogger {
/// Bounded audit channel capacity.
///
-/// At ~80 bytes per entry that's about 8 MB of in-memory backlog,
-/// which is fine for any reasonable host. 100k entries means a
-/// 30-second ClickHouse outage at 3k req/s still survives; drops
-/// are surfaced via a metric.
+/// Secondary count limit. Entries include captured bodies, so a separate
+/// process-wide byte budget is the primary memory bound, including retries.
const AUDIT_CHANNEL_CAPACITY: usize = 100_000;
/// Throttle window for the structured "audit drop" log line. The
/// metric still increments on every drop, so dashboards see the
/// real rate; the log is just for human inspection and one line
/// per second is plenty.
-fn log_audit_drop_throttled(err: &tokio::sync::mpsc::error::TrySendError) {
+fn log_audit_drop_throttled(err: &impl std::fmt::Display) {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static LAST_LOG_SECS: AtomicU64 = AtomicU64::new(0);
@@ -272,6 +270,8 @@ impl AuditLogger {
// the REMAINING capacity, so depth = total - capacity.
let depth = AUDIT_CHANNEL_CAPACITY.saturating_sub(tx_for_gauge.capacity());
metrics::gauge!("audit_log_queue_depth").set(depth as f64);
+ metrics::gauge!("audit_log_buffered_bytes")
+ .set(crate::resources::audit_budget().used() as f64);
}
}
});
@@ -297,7 +297,7 @@ impl AuditLogger {
.store(bps, std::sync::atomic::Ordering::Relaxed);
}
- pub fn log(&self, entry: AuditEntry) {
+ pub fn log(&self, mut entry: AuditEntry) {
// Sampling: consult the atomic rate and skip the entry when a
// uniform draw falls outside the keep window. A full keep
// (10000) short-circuits the RNG so the hot path pays nothing
@@ -309,6 +309,12 @@ impl AuditLogger {
metrics::counter!("audit_log_sampled_out_total").increment(1);
return;
}
+ if !entry.reserve_memory(crate::resources::audit_budget()) {
+ metrics::counter!("audit_log_dropped_total").increment(1);
+ metrics::counter!("audit_log_memory_dropped_total").increment(1);
+ log_audit_drop_throttled(&"audit byte budget occupied (queue and retained batches)");
+ return;
+ }
if let Err(e) = self.tx.try_send(entry) {
// Compliance signal: dropped audit entries are a real
// operational incident, not a debug warning. Bump the
diff --git a/crates/common/src/audit/types.rs b/crates/common/src/audit/types.rs
index 61202580..212a6094 100644
--- a/crates/common/src/audit/types.rs
+++ b/crates/common/src/audit/types.rs
@@ -132,6 +132,10 @@ impl std::fmt::Display for BodyCaptureStatus {
/// back out and feeds them into `send_webhook` again.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
+ /// Account for queued and retained data through its final clone. Never part
+ /// of ClickHouse rows or the durable webhook JSON contract.
+ #[serde(skip)]
+ memory: Option>,
pub id: String,
#[serde(skip, default = "default_log_type")]
pub log_type: LogType,
@@ -407,6 +411,7 @@ impl AuditEntry {
)]
pub fn new(action: impl Into) -> Self {
Self {
+ memory: None,
id: Uuid::new_v4().to_string(),
log_type: LogType::Audit,
user_id: None,
@@ -430,6 +435,61 @@ impl AuditEntry {
}
}
+ pub(super) fn reserve_memory(
+ &mut self,
+ budget: &std::sync::Arc,
+ ) -> bool {
+ fn value_bytes(value: &serde_json::Value) -> usize {
+ use serde_json::Value;
+ match value {
+ Value::String(s) => s.capacity(),
+ Value::Array(values) => values
+ .capacity()
+ .saturating_mul(std::mem::size_of::())
+ .saturating_add(values.iter().map(value_bytes).sum::()),
+ Value::Object(values) => values.iter().fold(0usize, |total, (key, value)| {
+ total
+ .saturating_add(key.capacity())
+ .saturating_add(std::mem::size_of::<(String, Value)>() * 2)
+ .saturating_add(value_bytes(value))
+ }),
+ _ => 0,
+ }
+ }
+ let strings = [
+ Some(&self.id),
+ Some(&self.action),
+ Some(&self.created_at),
+ self.user_id.as_ref(),
+ self.user_email.as_ref(),
+ self.api_key_id.as_ref(),
+ self.api_key_lineage_id.as_ref(),
+ self.resource.as_ref(),
+ self.resource_id.as_ref(),
+ self.ip_address.as_ref(),
+ self.user_agent.as_ref(),
+ self.trace_id.as_ref(),
+ self.session_id.as_ref(),
+ self.request_body.as_ref(),
+ self.response_body.as_ref(),
+ self.body_capture_status.as_ref(),
+ ];
+ let bytes = strings
+ .into_iter()
+ .flatten()
+ .fold(std::mem::size_of::(), |n, s| {
+ n.saturating_add(s.capacity())
+ })
+ .saturating_add(self.detail.as_ref().map_or(0, value_bytes));
+ // Flush mappers and forwarders temporarily clone/serialize one entry.
+ let mut lease = budget.reserve();
+ if !lease.grow(bytes.saturating_mul(2)) {
+ return false;
+ }
+ self.memory = Some(std::sync::Arc::new(lease));
+ true
+ }
+
/// Create entry for gateway request logs.
///
/// Bare constructor — same deprecation reasoning as `::new`. Use
@@ -680,3 +740,39 @@ mod tests {
assert_eq!(decoded.detail, original.detail);
}
}
+
+#[cfg(test)]
+#[allow(deprecated)]
+mod memory_tests {
+ use super::*;
+ use crate::resources::ByteBudget;
+
+ #[test]
+ fn queue_and_retry_clones_retain_body_admission_without_changing_wire_json() {
+ let budget = ByteBudget::new(32 * 1024);
+ let mut entry = AuditEntry::new("tools.call").request_body("x".repeat(4096));
+ let before = serde_json::to_value(&entry).unwrap();
+ assert!(entry.reserve_memory(&budget));
+ assert!(budget.used() >= 8192);
+ assert_eq!(before, serde_json::to_value(&entry).unwrap());
+ let retained = entry.clone();
+ drop(entry);
+ assert!(budget.used() >= 8192);
+ drop(retained);
+ assert_eq!(budget.used(), 0);
+ }
+
+ #[test]
+ fn a_few_large_entries_hit_the_byte_limit_before_the_count_limit() {
+ let budget = ByteBudget::new(16 * 1024);
+ let mut first = AuditEntry::new("gateway").response_body("x".repeat(4096));
+ let mut second = first.clone();
+ assert!(first.reserve_memory(&budget));
+ let used = budget.used();
+ assert!(!second.reserve_memory(&budget));
+ assert_eq!(used, budget.used());
+ drop(first);
+ assert_eq!(budget.used(), 0);
+ assert!(second.reserve_memory(&budget));
+ }
+}
diff --git a/crates/common/src/blob_store.rs b/crates/common/src/blob_store.rs
index 5211d7a8..8a057190 100644
--- a/crates/common/src/blob_store.rs
+++ b/crates/common/src/blob_store.rs
@@ -378,8 +378,12 @@ impl S3Store {
.await
.map_err(|e| BlobError::Transport(e.to_string()))?;
let status = resp.status();
- let bytes = resp
- .bytes()
+ let max = if status.is_success() {
+ crate::resources::policy().max_body_bytes
+ } else {
+ 64 * 1024
+ };
+ let bytes = crate::resources::read_response(resp, max)
.await
.map_err(|e| BlobError::Transport(e.to_string()))?;
if !status.is_success() {
diff --git a/crates/common/src/dto/mod.rs b/crates/common/src/dto/mod.rs
index 6e897009..e72c64a3 100644
--- a/crates/common/src/dto/mod.rs
+++ b/crates/common/src/dto/mod.rs
@@ -257,6 +257,15 @@ pub struct CreateProviderRequest {
#[serde(default)]
pub headers: Vec,
pub config: Option,
+ /// Cut a stream that generates fewer tokens per second than this
+ /// (1–1000), averaged over `min_speed_window_secs`. Absent or `null`
+ /// ⇒ off.
+ #[serde(default)]
+ pub min_speed_tokens_per_sec: Option,
+ /// The window the speed is averaged over, in seconds (5–120).
+ /// Absent ⇒ 20.
+ #[serde(default)]
+ pub min_speed_window_secs: Option,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
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/legacy.rs b/crates/common/src/guard_policy/legacy.rs
index 3480a2a8..d5a15156 100644
--- a/crates/common/src/guard_policy/legacy.rs
+++ b/crates/common/src/guard_policy/legacy.rs
@@ -508,11 +508,13 @@ pub fn redact(patterns: Option<&Value>) -> RedactPolicy {
}
}
}
- // Personal data is redacted only where the list asked for it. The
- // credential rules are not personal data and stay on.
+ // The personal data the old list could express (the seeded patterns'
+ // rules) is redacted only where the list asked for it. Credential
+ // rules are not personal data and stay on; personal rules the old
+ // list never knew run as they ship.
for b in tw_guard::redact::rules::BUILTINS
.iter()
- .filter(|b| b.kind == tw_guard::redact::rules::Kind::Personal)
+ .filter(|b| b.kind.is_personal() && SEEDED_PII.iter().any(|(_, _, id)| *id == b.id))
{
match (covered.contains(b.id), b.on_by_default) {
(true, false) => policy.enable.push(b.id.to_string()),
diff --git a/crates/common/src/guard_policy/mod.rs b/crates/common/src/guard_policy/mod.rs
index e3f933a8..876ebd7c 100644
--- a/crates/common/src/guard_policy/mod.rs
+++ b/crates/common/src/guard_policy/mod.rs
@@ -29,7 +29,9 @@ use tw_guard::redact::rules::RuleSet;
use crate::dynamic_config::DynamicConfig;
+pub mod exceptions;
pub mod legacy;
+pub mod saved;
/// The settings key a guard's policy is stored under.
pub fn key(guard: Guard) -> &'static str {
diff --git a/crates/common/src/guard_policy/saved.rs b/crates/common/src/guard_policy/saved.rs
new file mode 100644
index 00000000..c7886bdb
--- /dev/null
+++ b/crates/common/src/guard_policy/saved.rs
@@ -0,0 +1,323 @@
+//! Saved secrets: the credentials this gateway holds for its providers,
+//! which outbound redaction recognises by value (the built-in rule
+//! `thinkwatch-saved-secret`).
+//!
+//! Many providers' keys have no prefix to recognise them by — a 32-digit
+//! hex string, a UUID — yet the gateway knows each one exactly, because it
+//! sends them. A caller pasting one into a prompt is caught whatever its
+//! shape. The security log names the provider whose key it was, never any
+//! part of the value.
+//!
+//! The list is collected from the providers whenever they are loaded
+//! (startup, every provider CRUD, and the cross-instance reload), and the
+//! guards are recompiled with it; see `think_watch_server::app`.
+
+use std::fmt;
+use std::sync::Arc;
+
+use tw_guard::redact::rules::{
+ RuleSet, SAVED_MIN_CHARS, Saved, SavedSecret, SavedSource, SavedSourceKind,
+};
+
+/// Values shorter than this are not matched: a short value is too likely
+/// to occur by chance. thinkwatch-core's floor.
+pub const MIN_LEN: usize = SAVED_MIN_CHARS;
+
+/// Header names whose value is a credential: `authorization`, `x-api-key`,
+/// `api-key`, `x-goog-api-key`, and any custom header that says it carries
+/// a key, token, secret or password. Other headers a provider sends
+/// (`anthropic-version`, `content-type`) are configuration, not secrets,
+/// and matching their values would flag every request that mentions them.
+fn credential_header(name: &str) -> bool {
+ let name = name.to_ascii_lowercase();
+ ["auth", "key", "token", "secret", "password"]
+ .iter()
+ .any(|w| name.contains(w))
+}
+
+/// The credential in a header value: the token after an auth scheme
+/// (`Bearer sk-…` → `sk-…`), or the whole value.
+fn credential_value(value: &str) -> &str {
+ let value = value.trim();
+ match value.split_once(' ') {
+ Some((scheme, token))
+ if !scheme.is_empty()
+ && scheme.chars().all(|c| c.is_ascii_alphabetic())
+ && !token.trim().is_empty()
+ && !token.trim().contains(' ') =>
+ {
+ token.trim()
+ }
+ _ => value,
+ }
+}
+
+/// One provider credential.
+#[derive(Clone, PartialEq, Eq)]
+pub struct UpstreamSecret {
+ /// The provider's name, as the security log names the source.
+ pub provider: String,
+ pub value: String,
+ /// The host of the provider's base URL: the credential's own
+ /// destination, which dangerous commands' "credential sent to an
+ /// unknown host" does not count as sending it out. `None` when the
+ /// provider has no base URL to read one from.
+ pub host: Option,
+}
+
+impl fmt::Debug for UpstreamSecret {
+ /// Never the value: this ends up in logs.
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("UpstreamSecret")
+ .field("provider", &self.provider)
+ .field("host", &self.host)
+ .finish_non_exhaustive()
+ }
+}
+
+/// What a provider holds, decrypted, for [`SavedSecrets::of_provider`].
+#[derive(Debug, Clone, Copy)]
+pub struct ProviderCredentials<'a> {
+ pub name: &'a str,
+ pub base_url: &'a str,
+ /// The headers sent upstream, `(name, value)`.
+ pub headers: &'a [(String, String)],
+ /// A Bedrock provider's stored secret access key.
+ pub aws_secret_access_key: Option<&'a str>,
+}
+
+/// Every provider credential, each value once, and the same list compiled
+/// for matching (thinkwatch-core's [`Saved`]), shared by outbound
+/// redaction, the at-rest redactor, dangerous commands and the console's
+/// trial.
+#[derive(Clone, Default)]
+pub struct SavedSecrets {
+ list: Vec,
+ compiled: Option>,
+}
+
+impl PartialEq for SavedSecrets {
+ /// The same credentials: the compiled form follows from the list.
+ fn eq(&self, other: &Self) -> bool {
+ self.list == other.list
+ }
+}
+
+impl Eq for SavedSecrets {}
+
+impl fmt::Debug for SavedSecrets {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("SavedSecrets")
+ .field("list", &self.list)
+ .finish_non_exhaustive()
+ }
+}
+
+impl SavedSecrets {
+ /// The credentials of these providers. A value shorter than
+ /// [`MIN_LEN`] is left out; a value two providers share is kept once,
+ /// under the first.
+ pub fn collect<'a>(providers: impl IntoIterator- >) -> Self {
+ let mut list: Vec = Vec::new();
+ for p in providers {
+ let host = url::Url::parse(p.base_url)
+ .ok()
+ .and_then(|u| u.host_str().map(str::to_ascii_lowercase));
+ let values = p
+ .headers
+ .iter()
+ .filter(|(name, _)| credential_header(name))
+ .map(|(_, value)| credential_value(value))
+ .chain(p.aws_secret_access_key.map(str::trim));
+ for value in values {
+ if value.chars().count() < MIN_LEN || list.iter().any(|s| s.value == value) {
+ continue;
+ }
+ list.push(UpstreamSecret {
+ provider: p.name.to_string(),
+ value: value.to_string(),
+ host: host.clone(),
+ });
+ }
+ }
+ let compiled = Saved::new(
+ list.iter()
+ .map(|s| SavedSecret {
+ value: s.value.clone(),
+ source: SavedSource {
+ kind: SavedSourceKind::UpstreamKey,
+ name: s.provider.clone(),
+ },
+ hosts: s.host.iter().cloned().collect(),
+ })
+ .collect(),
+ )
+ .map(Arc::new);
+ Self { list, compiled }
+ }
+
+ /// The list compiled for matching; `None` when there is none.
+ pub fn compiled(&self) -> Option> {
+ self.compiled.clone()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.list.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.list.len()
+ }
+
+ pub fn iter(&self) -> impl Iterator
- {
+ self.list.iter()
+ }
+}
+
+/// `rules` with the saved secrets added, recognised by value. **The one
+/// place the saved secrets reach the rule set.** Every rule set gets the
+/// same compiled list rather than compiling its own.
+pub fn with_saved(rules: RuleSet, saved: &SavedSecrets) -> RuleSet {
+ rules.with_saved_set(saved.compiled())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn h(name: &str, value: &str) -> (String, String) {
+ (name.to_string(), value.to_string())
+ }
+
+ fn provider<'a>(
+ name: &'a str,
+ base_url: &'a str,
+ headers: &'a [(String, String)],
+ ) -> ProviderCredentials<'a> {
+ ProviderCredentials {
+ name,
+ base_url,
+ headers,
+ aws_secret_access_key: None,
+ }
+ }
+
+ #[test]
+ fn credential_headers_are_collected_and_configuration_headers_are_not() {
+ let headers = [
+ h("x-api-key", "test-anthropic-key-0123456789"),
+ h("anthropic-version", "2023-06-01-long-enough"),
+ h("Content-Type", "application/json"),
+ ];
+ let s =
+ SavedSecrets::collect([provider("claude", "https://API.anthropic.com/v1", &headers)]);
+ let got: Vec<(&str, &str, Option<&str>)> = s
+ .iter()
+ .map(|s| (s.provider.as_str(), s.value.as_str(), s.host.as_deref()))
+ .collect();
+ assert_eq!(
+ got,
+ [(
+ "claude",
+ "test-anthropic-key-0123456789",
+ Some("api.anthropic.com")
+ )]
+ );
+ }
+
+ #[test]
+ fn the_auth_scheme_is_not_part_of_the_value() {
+ let headers = [
+ h("Authorization", "Bearer test-deepseek-key-abcdef"),
+ h("X-Custom-Token", " test-custom-token-123456 "),
+ h("api-key", "test-azure-key-0123456789"),
+ h("x-goog-api-key", "test-google-key-0123456789"),
+ ];
+ let s = SavedSecrets::collect([provider("deepseek", "https://api.deepseek.com", &headers)]);
+ let values: Vec<&str> = s.iter().map(|s| s.value.as_str()).collect();
+ assert_eq!(
+ values,
+ [
+ "test-deepseek-key-abcdef",
+ "test-custom-token-123456",
+ "test-azure-key-0123456789",
+ "test-google-key-0123456789",
+ ]
+ );
+ }
+
+ #[test]
+ fn short_values_empty_values_and_duplicates_are_left_out() {
+ let a = [
+ h("Authorization", "Bearer "),
+ h("x-api-key", "short-key"),
+ h("x-api-key", "test-shared-key-0123456789"),
+ ];
+ let b = [h("Authorization", "Bearer test-shared-key-0123456789")];
+ let s = SavedSecrets::collect([
+ provider("first", "https://one.example.com", &a),
+ provider("second", "https://two.example.com", &b),
+ ]);
+ assert_eq!(s.len(), 1);
+ let only = s.iter().next().unwrap();
+ assert_eq!(only.provider, "first", "kept under the first provider");
+ }
+
+ #[test]
+ fn a_bedrock_secret_access_key_is_collected() {
+ let s = SavedSecrets::collect([ProviderCredentials {
+ name: "bedrock",
+ base_url: "",
+ headers: &[],
+ aws_secret_access_key: Some("test/secret+access/key0123456789"),
+ }]);
+ let only = s.iter().next().unwrap();
+ assert_eq!(only.value, "test/secret+access/key0123456789");
+ assert_eq!(only.host, None, "no base URL, no host");
+ }
+
+ #[test]
+ fn debug_never_shows_the_value() {
+ let headers = [h("x-api-key", "test-hidden-key-0123456789")];
+ let s = SavedSecrets::collect([provider("p", "https://p.example.com", &headers)]);
+ let shown = format!("{s:?}");
+ assert!(!shown.contains("test-hidden-key"), "{shown}");
+ assert!(shown.contains("p.example.com"), "{shown}");
+ }
+
+ #[test]
+ fn the_rules_find_a_saved_key_and_name_its_provider() {
+ let a = [h("x-api-key", "test-alpha-key-0123456789")];
+ let b = [h("x-api-key", "test\"beta\\key-0123456789")];
+ let s = SavedSecrets::collect([
+ provider("alpha", "https://a.example.com", &a),
+ provider("beta", "https://b.example.com", &b),
+ ]);
+ let rules = with_saved(RuleSet::only(&["thinkwatch-saved-secret"]), &s);
+ // As written, and JSON-escaped in a request body.
+ let text = r#"{"content":"use test-alpha-key-0123456789 and test\"beta\\key-0123456789"}"#;
+ let hits = tw_guard::redact::rules::scan(text, &rules);
+ let found = tw_guard::redact::rules::findings_in(text, &hits, &rules);
+ let named: Vec<(&str, &str)> = found
+ .iter()
+ .map(|f| {
+ let src = f.source.as_ref().expect("a saved key names its source");
+ (f.rule.id(), src.name.as_str())
+ })
+ .collect();
+ assert_eq!(
+ named,
+ [
+ ("thinkwatch-saved-secret", "alpha"),
+ ("thinkwatch-saved-secret", "beta")
+ ]
+ );
+ assert!(found.iter().all(|f| f.masked.is_empty()), "{found:?}");
+ }
+
+ #[test]
+ fn no_saved_secrets_leaves_the_rules_as_they_are() {
+ let rules = with_saved(RuleSet::only(&["jwt"]), &SavedSecrets::default());
+ assert!(rules.is_on("jwt") && !rules.is_on("anthropic-api-key"));
+ }
+}
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
index 334950bd..495963c5 100644
--- a/crates/common/src/lib.rs
+++ b/crates/common/src/lib.rs
@@ -43,6 +43,7 @@ pub mod clickhouse_client;
pub mod cost_decimal; // Decimal ↔ raw i64/i128 helpers for CH Decimal(18, 10) columns
pub mod lifecycle; // Surface-agnostic request pipeline (see lifecycle::mod docs)
pub mod limits; // rate-limit & budget evaluation
+pub mod resources; // automatic CPU admission and process-wide byte budgets
// --- Utilities ---
pub mod crypto; // AES-256-GCM envelope for secrets at rest
diff --git a/crates/common/src/models/provider.rs b/crates/common/src/models/provider.rs
index cc02a0e7..7b087269 100644
--- a/crates/common/src/models/provider.rs
+++ b/crates/common/src/models/provider.rs
@@ -15,6 +15,12 @@ pub struct Provider {
pub config_json: serde_json::Value,
pub created_at: DateTime,
pub deleted_at: Option>,
+ /// The slowest generation a stream from this upstream may keep up,
+ /// in tokens per second (1–1000), averaged over the last
+ /// `min_speed_window_secs` seconds (5–120). `None` ⇒ off. See
+ /// `think_watch_gateway::proxy::min_speed`.
+ pub min_speed_tokens_per_sec: Option,
+ pub min_speed_window_secs: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
diff --git a/crates/common/src/pii.rs b/crates/common/src/pii.rs
index b80d79e3..c222fbca 100644
--- a/crates/common/src/pii.rs
+++ b/crates/common/src/pii.rs
@@ -20,6 +20,8 @@ use std::sync::Arc;
use tw_guard::policy::RedactPolicy;
use tw_guard::redact::rules::RuleSet;
+use crate::guard_policy::saved::SavedSecrets;
+
/// Replace every match in `input` with `{{REDACTED_}}` — the
/// built-in rule's id or the custom rule's name. Nothing is kept to restore
/// them: the result is write-only audit data.
@@ -104,7 +106,7 @@ pub struct BlobRedactor {
impl Default for BlobRedactor {
/// The factory policy's rules.
fn default() -> Self {
- Self::from_policy(&RedactPolicy::default())
+ Self::from_policy(&RedactPolicy::default(), &SavedSecrets::default())
}
}
@@ -115,9 +117,14 @@ impl std::fmt::Debug for BlobRedactor {
}
impl BlobRedactor {
- pub fn from_policy(policy: &RedactPolicy) -> Self {
+ /// The policy's rules, and the providers' credentials recognised by
+ /// value.
+ pub fn from_policy(policy: &RedactPolicy, saved: &SavedSecrets) -> Self {
Self {
- rules: Arc::new(crate::guard_policy::redact_rules(policy)),
+ rules: Arc::new(crate::guard_policy::saved::with_saved(
+ crate::guard_policy::redact_rules(policy),
+ saved,
+ )),
}
}
@@ -220,22 +227,25 @@ mod tests {
"key {{REDACTED_anthropic-api-key}}"
);
- let r = BlobRedactor::from_policy(&RedactPolicy {
- disable: tw_guard::redact::rules::BUILTINS
- .iter()
- .map(|b| b.id.to_string())
- .collect(),
- custom: vec![CustomRedactRule {
- name: "project".into(),
- pattern: r"PRJ-\d{6}".into(),
- matching: tw_guard::policy::ContentMatch::Regex,
- case_sensitive: true,
- label: None,
- action: Default::default(),
- disabled: false,
- }],
- ..Default::default()
- });
+ let r = BlobRedactor::from_policy(
+ &RedactPolicy {
+ disable: tw_guard::redact::rules::BUILTINS
+ .iter()
+ .map(|b| b.id.to_string())
+ .collect(),
+ custom: vec![CustomRedactRule {
+ name: "project".into(),
+ pattern: r"PRJ-\d{6}".into(),
+ matching: tw_guard::policy::ContentMatch::Regex,
+ case_sensitive: true,
+ label: None,
+ action: Default::default(),
+ disabled: false,
+ }],
+ ..Default::default()
+ },
+ &SavedSecrets::default(),
+ );
assert_eq!(
r.redact_blob(&format!("{key} PRJ-123456")),
format!("{key} {{{{REDACTED_project}}}}")
diff --git a/crates/common/src/resources.rs b/crates/common/src/resources.rs
new file mode 100644
index 00000000..5f8b3987
--- /dev/null
+++ b/crates/common/src/resources.rs
@@ -0,0 +1,475 @@
+//! Resource admission shared by AI, MCP, authentication and audit dispatch.
+//! Limits describe this process (including cgroups), never the host alone.
+//! Byte reservations survive cancellation, shared buffers and audit retries.
+
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::Duration;
+
+use bytes::Bytes;
+use tokio::sync::{OwnedSemaphorePermit, Semaphore};
+
+#[cfg(any(target_os = "linux", test))]
+mod cgroup;
+pub mod http;
+
+const MIB: usize = 1024 * 1024;
+// Compact JSON arrays can use many times their wire size as Value nodes. This
+// also covers conversion/capture copies rather than budgeting raw bytes alone.
+pub const BODY_HEADROOM: usize = 32;
+pub const IDLE_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[derive(Debug)]
+pub struct Policy {
+ pub io_workers: usize,
+ pub cpu_jobs: usize,
+ pub argon_jobs: usize,
+ pub body_bytes: usize,
+ pub audit_bytes: usize,
+ pub max_body_bytes: usize,
+}
+
+impl Policy {
+ fn for_machine(cpus: usize, memory: u64) -> Self {
+ let memory = usize::try_from(memory).unwrap_or(usize::MAX);
+ let cpus = cpus.max(1);
+ let body_bytes = (memory / 4).clamp(8 * MIB, 8 * 1024 * MIB);
+ Self {
+ io_workers: cpus.min(8),
+ cpu_jobs: cpus.min((memory / (128 * MIB)).max(1)),
+ // Argon2's 19 MiB workspace, allocator headroom, and at most 1/8
+ // of the process allowance. CPU admission is shared with scanning.
+ argon_jobs: cpus.min((memory / 8 / (32 * MIB)).max(1)),
+ body_bytes,
+ audit_bytes: (memory / 16).clamp(2 * MIB, 128 * MIB),
+ max_body_bytes: (body_bytes / BODY_HEADROOM).min(64 * MIB),
+ }
+ }
+}
+
+pub fn policy() -> &'static Policy {
+ static POLICY: OnceLock = OnceLock::new();
+ POLICY.get_or_init(|| {
+ let mut system = sysinfo::System::new();
+ system.refresh_memory();
+ let physical = match system.total_memory() {
+ 0 => 1 << 30,
+ memory => memory,
+ };
+ let limit = system.cgroup_limits().map(|l| l.total_memory);
+ #[cfg(target_os = "linux")]
+ let limit = cgroup::memory_limit().or(limit);
+ let memory = limit
+ .filter(|&n| n > 0)
+ .map_or(physical, |n| n.min(physical));
+ Policy::for_machine(
+ std::thread::available_parallelism().map_or(1, usize::from),
+ memory,
+ )
+ })
+}
+
+struct Scheduler {
+ slots: Arc,
+ inline: Semaphore,
+}
+
+impl Scheduler {
+ fn new(policy: &Policy) -> Self {
+ Self {
+ slots: Arc::new(Semaphore::new(policy.cpu_jobs)),
+ inline: Semaphore::new(policy.io_workers.div_ceil(2)),
+ }
+ }
+
+ async fn run(&self, bytes: usize, f: impl FnOnce() -> T) -> T {
+ let _slot = self.slots.clone().acquire_owned().await.unwrap();
+ let inline = (bytes < MIB)
+ .then(|| self.inline.try_acquire().ok())
+ .flatten();
+ let multi = tokio::runtime::Handle::current().runtime_flavor()
+ == tokio::runtime::RuntimeFlavor::MultiThread;
+ if inline.is_some() || !multi {
+ f()
+ } else {
+ tokio::task::block_in_place(f)
+ }
+ }
+}
+
+fn scheduler() -> &'static Scheduler {
+ static SCHEDULER: OnceLock = OnceLock::new();
+ SCHEDULER.get_or_init(|| Scheduler::new(policy()))
+}
+
+pub async fn cpu(bytes: usize, f: impl FnOnce() -> T) -> T {
+ scheduler().run(bytes, f).await
+}
+
+/// Acquire before spawning; cancellation of the caller must not release a
+/// running password operation's CPU or memory admission.
+pub async fn password_slot() -> (OwnedSemaphorePermit, OwnedSemaphorePermit) {
+ static PASSWORDS: OnceLock> = OnceLock::new();
+ let memory = PASSWORDS.get_or_init(|| Arc::new(Semaphore::new(policy().argon_jobs)));
+ let memory = memory.clone().acquire_owned().await.unwrap();
+ let cpu = scheduler().slots.clone().acquire_owned().await.unwrap();
+ (memory, cpu)
+}
+
+#[derive(Debug)]
+pub struct ByteBudget {
+ limit: usize,
+ used: Mutex,
+}
+
+impl ByteBudget {
+ pub fn new(limit: usize) -> Arc {
+ Arc::new(Self {
+ limit,
+ used: Mutex::new(0),
+ })
+ }
+
+ pub fn reserve(self: &Arc) -> Reservation {
+ Reservation {
+ budget: self.clone(),
+ charged: 0,
+ }
+ }
+
+ pub fn used(&self) -> usize {
+ *self.used.lock().unwrap_or_else(|e| e.into_inner())
+ }
+}
+
+#[derive(Debug)]
+pub struct Reservation {
+ budget: Arc,
+ charged: usize,
+}
+
+impl Reservation {
+ pub fn grow(&mut self, bytes: usize) -> bool {
+ let extra = bytes.saturating_sub(self.charged);
+ let mut used = self.budget.used.lock().unwrap_or_else(|e| e.into_inner());
+ if extra > self.budget.limit.saturating_sub(*used) {
+ return false;
+ }
+ *used += extra;
+ self.charged += extra;
+ true
+ }
+
+ pub fn wrap(self, data: Bytes) -> Bytes {
+ Bytes::from_owner(Buffered {
+ data,
+ reservation: Arc::new(self),
+ })
+ }
+}
+
+impl Drop for Reservation {
+ fn drop(&mut self) {
+ *self.budget.used.lock().unwrap_or_else(|e| e.into_inner()) -= self.charged;
+ }
+}
+
+struct Buffered {
+ data: Bytes,
+ reservation: Arc,
+}
+
+impl AsRef<[u8]> for Buffered {
+ fn as_ref(&self) -> &[u8] {
+ let _ = &self.reservation;
+ &self.data
+ }
+}
+
+pub fn body_budget() -> &'static Arc {
+ static BUDGET: OnceLock> = OnceLock::new();
+ BUDGET.get_or_init(|| ByteBudget::new(policy().body_bytes))
+}
+
+pub fn audit_budget() -> &'static Arc {
+ static BUDGET: OnceLock> = OnceLock::new();
+ BUDGET.get_or_init(|| ByteBudget::new(policy().audit_bytes))
+}
+
+/// A stream's retained timeline, shared with its detached post-invoke tail.
+pub struct StreamBudget {
+ reservation: Reservation,
+ received: usize,
+ max: usize,
+}
+
+impl StreamBudget {
+ pub fn new(budget: &Arc, max: usize) -> Self {
+ Self {
+ reservation: budget.reserve(),
+ received: 0,
+ max,
+ }
+ }
+
+ pub fn append(&mut self, bytes: usize) -> bool {
+ let received = self.received.saturating_add(bytes);
+ if received > self.max
+ || !self
+ .reservation
+ .grow(received.saturating_mul(BODY_HEADROOM))
+ {
+ return false;
+ }
+ self.received = received;
+ true
+ }
+}
+
+pub type SharedStreamBudget = Arc>;
+
+pub fn stream_budget() -> SharedStreamBudget {
+ Arc::new(Mutex::new(StreamBudget::new(
+ body_budget(),
+ policy().max_body_bytes,
+ )))
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum ReadError {
+ #[error("The response exceeds the gateway buffering limit")]
+ TooLarge,
+ #[error("The gateway buffering budget is occupied; retry shortly")]
+ Busy,
+ #[error(transparent)]
+ Transport(#[from] reqwest::Error),
+}
+
+/// Charge before appending, including chunked and decompressed responses. Keep
+/// room for JSON, conversion and allocator capacity; never wait holding a partial
+/// body, since concurrent readers could otherwise deadlock each other.
+pub async fn read_response(response: reqwest::Response, max: usize) -> Result {
+ let max = max.min(policy().max_body_bytes);
+ read_response_with_budget(response, max, body_budget()).await
+}
+
+/// The caller keeps the returned Bytes while it uses the decoded value.
+pub async fn read_json(
+ response: reqwest::Response,
+ max: usize,
+) -> anyhow::Result<(T, Bytes)> {
+ let bytes = read_response(response, max).await?;
+ let value = cpu(bytes.len(), || serde_json::from_slice(&bytes)).await?;
+ Ok((value, bytes))
+}
+
+async fn read_response_with_budget(
+ mut response: reqwest::Response,
+ max: usize,
+ budget: &Arc,
+) -> Result {
+ if response.content_length().is_some_and(|n| n > max as u64) {
+ return Err(ReadError::TooLarge);
+ }
+ let mut reservation = budget.reserve();
+ let mut data = Vec::new();
+ while let Some(chunk) = response.chunk().await? {
+ let size = data
+ .len()
+ .checked_add(chunk.len())
+ .ok_or(ReadError::TooLarge)?;
+ if size > max {
+ return Err(ReadError::TooLarge);
+ }
+ if !reservation.grow(size.saturating_mul(BODY_HEADROOM)) {
+ metrics::counter!("gateway_memory_busy_total").increment(1);
+ return Err(ReadError::Busy);
+ }
+ data.extend_from_slice(&chunk);
+ }
+ Ok(reservation.wrap(data.into()))
+}
+
+/// A transformed/captured body still owns the original read reservation.
+pub fn retain(data: Bytes, source: Bytes) -> Bytes {
+ struct Retained {
+ data: Bytes,
+ _source: Bytes,
+ }
+ impl AsRef<[u8]> for Retained {
+ fn as_ref(&self) -> &[u8] {
+ &self.data
+ }
+ }
+ Bytes::from_owner(Retained {
+ data,
+ _source: source,
+ })
+}
+
+/// Reserve before fetching a Redis cache entry. STRLEN followed by bounded
+/// GETRANGE also stays bounded if another writer replaces the key in between.
+pub async fn cached_body(redis: &fred::clients::Client, key: &str) -> Option {
+ use fred::interfaces::KeysInterface;
+ let length: usize = redis.strlen(key).await.ok()?;
+ if length == 0 || length > policy().max_body_bytes {
+ return None;
+ }
+ let mut lease = body_budget().reserve();
+ if !lease.grow(length.saturating_mul(BODY_HEADROOM)) {
+ return None;
+ }
+ let data: Vec = redis.getrange(key, 0, length).await.ok()?;
+ if data.len() > length {
+ return None;
+ }
+ Some(lease.wrap(data.into()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn memory_limits_allocation_without_capping_a_well_provisioned_eighty_core_server() {
+ let large = Policy::for_machine(80, 128 << 30);
+ assert_eq!(
+ (large.io_workers, large.cpu_jobs, large.argon_jobs),
+ (8, 80, 80)
+ );
+ let small = Policy::for_machine(80, 256 << 20);
+ assert_eq!((small.cpu_jobs, small.argon_jobs), (2, 1));
+ assert!(small.body_bytes + small.audit_bytes <= 256 * MIB / 2);
+ }
+
+ #[test]
+ fn reservations_follow_shared_slices_and_failed_growth_is_atomic() {
+ let budget = ByteBudget::new(10);
+ let mut lease = budget.reserve();
+ assert!(lease.grow(8));
+ assert!(!lease.grow(11));
+ let data = lease.wrap(Bytes::from_static(b"hello"));
+ let slice = data.slice(1..3);
+ drop(data);
+ assert_eq!(budget.used(), 8);
+ drop(slice);
+ assert_eq!(budget.used(), 0);
+ }
+
+ #[tokio::test]
+ async fn chunked_responses_obey_size_and_shared_memory_limits() {
+ async fn response() -> (reqwest::Response, tokio::task::JoinHandle<()>) {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let url = format!("http://{}/", listener.local_addr().unwrap());
+ let router = axum::Router::new().route(
+ "/",
+ axum::routing::get(|| async {
+ axum::body::Body::from_stream(futures::stream::iter([
+ Ok::<_, std::io::Error>(Bytes::from_static(b"12345678")),
+ Ok(Bytes::from_static(b"abcdefgh")),
+ ]))
+ }),
+ );
+ let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
+ (reqwest::get(url).await.unwrap(), server)
+ }
+ for (limit, max, expected) in [
+ (10 * BODY_HEADROOM, 32, "busy"),
+ (32 * BODY_HEADROOM, 12, "large"),
+ (32 * BODY_HEADROOM, 32, "ok"),
+ ] {
+ let budget = ByteBudget::new(limit);
+ let (response, server) = response().await;
+ assert!(response.content_length().is_none());
+ match read_response_with_budget(response, max, &budget).await {
+ Err(ReadError::Busy) => assert_eq!(expected, "busy"),
+ Err(ReadError::TooLarge) => assert_eq!(expected, "large"),
+ Ok(bytes) => {
+ assert_eq!(expected, "ok");
+ assert_eq!(&bytes[..], b"12345678abcdefgh");
+ assert_eq!(budget.used(), 16 * BODY_HEADROOM);
+ let copy = bytes.clone();
+ drop(bytes);
+ assert_eq!(budget.used(), 16 * BODY_HEADROOM);
+ drop(copy);
+ }
+ Err(error) => panic!("{error}"),
+ }
+ assert_eq!(budget.used(), 0);
+ server.abort();
+ }
+ }
+
+ #[test]
+ fn stream_timeline_reservations_cover_retained_history_and_reject_growth() {
+ let budget = ByteBudget::new(25 * BODY_HEADROOM);
+ let mut stream = StreamBudget::new(&budget, 64);
+ assert!(stream.append(20));
+ assert!(!stream.append(10));
+ assert_eq!(budget.used(), 20 * BODY_HEADROOM);
+ drop(stream);
+ assert_eq!(budget.used(), 0);
+ }
+
+ #[test]
+ fn eighty_cpu_jobs_expand_beyond_io_workers_then_threads_retire() {
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::time::Instant;
+ let alive = Arc::new(AtomicUsize::new(0));
+ let (started, stopped) = (alive.clone(), alive.clone());
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .thread_keep_alive(Duration::from_millis(50))
+ .on_thread_start(move || {
+ started.fetch_add(1, Ordering::SeqCst);
+ })
+ .on_thread_stop(move || {
+ stopped.fetch_sub(1, Ordering::SeqCst);
+ })
+ .enable_all()
+ .build()
+ .unwrap();
+ let scheduler = Arc::new(Scheduler::new(&Policy {
+ io_workers: 2,
+ ..Policy::for_machine(80, 128 << 30)
+ }));
+ runtime.block_on(async {
+ let release = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
+ let (began, mut beginnings) = tokio::sync::mpsc::unbounded_channel();
+ let mut jobs = Vec::new();
+ for _ in 0..80 {
+ let (scheduler, release, began) =
+ (scheduler.clone(), release.clone(), began.clone());
+ jobs.push(tokio::spawn(async move {
+ scheduler
+ .run(4096, || {
+ began.send(()).unwrap();
+ let guard = release.0.lock().unwrap();
+ let _guard = release
+ .1
+ .wait_timeout_while(guard, Duration::from_secs(10), |done| !*done)
+ .unwrap();
+ })
+ .await;
+ }));
+ }
+ for _ in 0..80 {
+ tokio::time::timeout(Duration::from_secs(5), beginnings.recv())
+ .await
+ .expect("all 80 calls must start")
+ .unwrap();
+ }
+ assert_eq!(scheduler.slots.available_permits(), 0);
+ tokio::time::sleep(Duration::from_millis(1)).await;
+ *release.0.lock().unwrap() = true;
+ release.1.notify_all();
+ for job in jobs {
+ job.await.unwrap();
+ }
+ });
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while alive.load(Ordering::SeqCst) != 2 && Instant::now() < deadline {
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ assert_eq!(alive.load(Ordering::SeqCst), 2);
+ }
+}
diff --git a/crates/common/src/resources/cgroup.rs b/crates/common/src/resources/cgroup.rs
new file mode 100644
index 00000000..72ae6aa1
--- /dev/null
+++ b/crates/common/src/resources/cgroup.rs
@@ -0,0 +1,89 @@
+//! 同时看当前 cgroup 和它的祖先,systemd 的子组也不能越过父组的内存限额。
+
+use std::path::{Component, Path};
+
+#[cfg(target_os = "linux")]
+pub(super) fn memory_limit() -> Option {
+ let membership = std::fs::read_to_string("/proc/self/cgroup").ok()?;
+ membership
+ .lines()
+ .filter_map(|line| {
+ let mut fields = line.splitn(3, ':');
+ let hierarchy = fields.next()?;
+ let controllers = fields.next()?;
+ let path = fields.next()?;
+ if hierarchy == "0" && controllers.is_empty() {
+ lowest_limit(Path::new("/sys/fs/cgroup"), Path::new(path), "memory.max")
+ } else if controllers.split(',').any(|c| c == "memory") {
+ lowest_limit(
+ Path::new("/sys/fs/cgroup/memory"),
+ Path::new(path),
+ "memory.limit_in_bytes",
+ )
+ } else {
+ None
+ }
+ })
+ .min()
+}
+
+fn lowest_limit(root: &Path, membership: &Path, file: &str) -> Option {
+ // cgroup namespace 可以把当前组挂成根目录。深路径不存在时,仍然检查挂载根的限额。
+ if membership
+ .components()
+ .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
+ {
+ return None;
+ }
+ let relative = membership.strip_prefix("/").unwrap_or(membership);
+ let current = root.join(relative);
+ current
+ .ancestors()
+ .take_while(|p| p.starts_with(root))
+ .filter_map(|p| {
+ std::fs::read_to_string(p.join(file))
+ .ok()?
+ .trim()
+ .parse::()
+ .ok()
+ .filter(|&n| n > 0)
+ })
+ .min()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn nested_groups_obey_the_smallest_ancestor_limit() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = dir.path();
+ std::fs::create_dir_all(root.join("service/child")).unwrap();
+ std::fs::write(root.join("memory.max"), "max\n").unwrap();
+ std::fs::write(root.join("service/memory.max"), "536870912\n").unwrap();
+ std::fs::write(root.join("service/child/memory.max"), "1073741824\n").unwrap();
+ assert_eq!(
+ lowest_limit(root, Path::new("/service/child"), "memory.max"),
+ Some(512 << 20)
+ );
+ }
+
+ #[test]
+ fn a_namespaced_mount_root_and_v1_limits_are_supported() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("memory.limit_in_bytes"), "268435456").unwrap();
+ assert_eq!(
+ lowest_limit(
+ dir.path(),
+ Path::new("/unavailable/host/path"),
+ "memory.limit_in_bytes"
+ ),
+ Some(256 << 20)
+ );
+ assert_eq!(
+ lowest_limit(dir.path(), Path::new("/../escape"), "memory.limit_in_bytes"),
+ None
+ );
+ }
+}
diff --git a/crates/common/src/resources/http.rs b/crates/common/src/resources/http.rs
new file mode 100644
index 00000000..e07f7eb0
--- /dev/null
+++ b/crates/common/src/resources/http.rs
@@ -0,0 +1,227 @@
+//! Body admission precedes extraction; checking in the handler is too late.
+
+use std::pin::Pin;
+use std::sync::Arc;
+use std::task::{Context, Poll};
+
+use axum::body::{Body, Bytes, HttpBody};
+use axum::extract::Request;
+use axum::middleware::Next;
+use axum::response::{IntoResponse, Response};
+use futures::StreamExt;
+
+use super::{ByteBudget, policy};
+
+/// Preserve Axum's JSON errors while moving large/concurrent decoding off I/O.
+pub struct ScheduledJson(pub T);
+
+impl
axum::extract::FromRequest for ScheduledJson
+where
+ S: Send + Sync,
+ T: serde::de::DeserializeOwned,
+{
+ type Rejection = Response;
+
+ async fn from_request(request: Request, state: &S) -> Result {
+ let json = request
+ .headers()
+ .get(axum::http::header::CONTENT_TYPE)
+ .and_then(|h| h.to_str().ok())
+ .and_then(|h| h.split(';').next())
+ .is_some_and(|h| {
+ let h = h.trim().to_ascii_lowercase();
+ h == "application/json" || (h.starts_with("application/") && h.ends_with("+json"))
+ });
+ if !json {
+ return Err((
+ axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
+ "Expected request with Content-Type: application/json",
+ )
+ .into_response());
+ }
+ let bytes = Bytes::from_request(request, state)
+ .await
+ .map_err(IntoResponse::into_response)?;
+ super::cpu(bytes.len(), || axum::Json::::from_bytes(&bytes))
+ .await
+ .map(|axum::Json(value)| Self(value))
+ .map_err(IntoResponse::into_response)
+ }
+}
+
+pub async fn admit(request: Request, next: Next) -> Response {
+ admit_with_budget(request, next, super::body_budget(), policy().max_body_bytes).await
+}
+
+async fn admit_with_budget(
+ request: Request,
+ next: Next,
+ budget: &Arc,
+ max: usize,
+) -> Response {
+ let (parts, body) = request.into_parts();
+ if body.size_hint().upper().is_some_and(|n| n > max as u64) {
+ return rejection(axum::http::StatusCode::PAYLOAD_TOO_LARGE);
+ }
+ let mut data = Vec::new();
+ let mut lease = budget.reserve();
+ let mut chunks = body.into_data_stream();
+ while let Some(chunk) = chunks.next().await {
+ let chunk = match chunk {
+ Ok(chunk) => chunk,
+ Err(error) => {
+ use std::error::Error;
+ let status = if error
+ .source()
+ .is_some_and(|e| e.is::())
+ {
+ axum::http::StatusCode::PAYLOAD_TOO_LARGE
+ } else {
+ axum::http::StatusCode::BAD_REQUEST
+ };
+ return rejection(status);
+ }
+ };
+ let size = data.len().saturating_add(chunk.len());
+ if size > max {
+ return rejection(axum::http::StatusCode::PAYLOAD_TOO_LARGE);
+ }
+ if !lease.grow(size.saturating_mul(super::BODY_HEADROOM)) {
+ return rejection(axum::http::StatusCode::TOO_MANY_REQUESTS);
+ }
+ data.extend_from_slice(&chunk);
+ }
+ let data = lease.wrap(data.into());
+ let mut response = next
+ .run(Request::from_parts(parts, Body::from(data.clone())))
+ .await;
+ let body = std::mem::replace(response.body_mut(), Body::empty());
+ *response.body_mut() = Body::new(HeldBody {
+ inner: body,
+ _request: data,
+ });
+ response
+}
+
+fn rejection(status: axum::http::StatusCode) -> Response {
+ let busy = status == axum::http::StatusCode::TOO_MANY_REQUESTS;
+ let message = if busy {
+ "The gateway is buffering other requests. Retry shortly."
+ } else if status == axum::http::StatusCode::PAYLOAD_TOO_LARGE {
+ "The request body exceeds the gateway buffering limit."
+ } else {
+ "The request body could not be read."
+ };
+ let mut response = (status, axum::Json(serde_json::json!({
+ "error": {"type": if busy { "rate_limit_error" } else { "invalid_request_error" }, "message": message}
+ }))).into_response();
+ if busy {
+ response
+ .headers_mut()
+ .insert(axum::http::header::RETRY_AFTER, "1".parse().unwrap());
+ metrics::counter!("gateway_memory_busy_total").increment(1);
+ }
+ response
+}
+
+/// A JSON extractor discards the input Bytes while its decoded object still
+/// exists. Retain admission through the response, including streaming and drop.
+struct HeldBody {
+ inner: Body,
+ _request: Bytes,
+}
+
+impl HttpBody for HeldBody {
+ type Data = Bytes;
+ type Error = axum::Error;
+
+ fn poll_frame(
+ mut self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ ) -> Poll
+
+ {full ? t('contentSecurity.exceptions.full', { max }) : t('contentSecurity.exceptions.lead')}
+
+ >
+ )}
+
+ {(query.isPending || query.isError || items.length > 0) && (
+
+ {query.isPending ? (
+
+ ) : query.isError ? (
+
+ {t('contentSecurity.exceptions.loadFailed')}: {query.error.message}
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ {t('contentSecurity.exceptions.col.value')}
+ {t('contentSecurity.exceptions.col.rule')}
+ {t('contentSecurity.exceptions.col.added')}
+
+ {t('common.actions')}
+
+
+
+
+ {items.map((e) => (
+
+
+ {e.masked || '—'}
+
+
+ {rule(e)}
+
+
+ {e.added_at ? new Date(e.added_at).toLocaleString() : '—'}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+ {adding && setAdding(false)} />}
+ !o && !remove.isPending && setRemoving(null)}
+ title={t('contentSecurity.exceptions.removeTitle', { value: removing?.masked ?? '' })}
+ description={t('contentSecurity.exceptions.removeDesc')}
+ confirmLabel={t('contentSecurity.exceptions.remove')}
+ loading={remove.isPending}
+ onConfirm={() => removing && remove.mutate(removing)}
+ />
+
+ );
+}
+
+/**
+ * Paste a value to except it: for a test value that has not turned up in
+ * the log yet. The value goes to the server once and is forgotten here as
+ * soon as the dialog closes.
+ */
+function AddExceptionDialog({ onClose }: { onClose: () => void }) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [value, setValue] = useState('');
+ const [error, setError] = useState(null);
+ const add = useMutation({
+ mutationFn: (v: string) => apiPost(PATH, { value: v }),
+ onSuccess: (e) => {
+ toast.success(t('contentSecurity.exceptions.added', { value: e.masked }));
+ void queryClient.invalidateQueries({ queryKey: EXCEPTIONS_KEY });
+ onClose();
+ },
+ onError: (err) =>
+ setError(
+ err instanceof ApiError && err.status === 409
+ ? t('contentSecurity.exceptions.duplicate')
+ : err instanceof Error
+ ? err.message
+ : t('common.error'),
+ ),
+ });
+ const empty = value.trim() === '';
+ return (
+
+ );
+}
diff --git a/web/src/routes/gateway/security/guard-panel.tsx b/web/src/routes/gateway/security/guard-panel.tsx
index 8a4b0f71..784780e7 100644
--- a/web/src/routes/gateway/security/guard-panel.tsx
+++ b/web/src/routes/gateway/security/guard-panel.tsx
@@ -25,8 +25,16 @@ import {
} from '@/components/ui/table';
import { Segmented } from '@/components/segmented';
import { cn } from '@/lib/utils';
-import type { Guard, GuardDetail, GuardMode, RuleAction, SecurityRuleView } from '@/lib/security-types';
-import { Code, Codepoints, MatcherText } from './matcher-text';
+import type {
+ Guard,
+ GuardDetail,
+ GuardMode,
+ RuleAction,
+ SecurityKindView,
+ SecurityRuleView,
+} from '@/lib/security-types';
+import { ExceptionsPanel } from './exceptions-panel';
+import { About, Code, Codepoints, MatcherText } from './matcher-text';
import {
actionName,
actionTone,
@@ -34,6 +42,7 @@ import {
kindName,
modeDot,
modeName,
+ ruleAbout,
ruleWhy,
scopeName,
viewName,
@@ -124,12 +133,14 @@ export function GuardPanel({
guard={guard}
mode={detail.mode}
rules={detail.rules}
+ kinds={detail.kinds}
query={query}
canWrite={canWrite}
actions={actions}
/>
+ {guard === 'redact' && }
);
}
@@ -216,6 +227,7 @@ function RuleTable({
guard,
mode,
rules,
+ kinds,
query,
canWrite,
actions,
@@ -223,17 +235,17 @@ function RuleTable({
guard: Guard;
mode: GuardMode;
rules: SecurityRuleView[];
+ kinds: SecurityKindView[];
query: string;
canWrite: boolean;
actions: RuleActions;
}) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
+ const lang = i18n.language;
const content = guard === 'content';
- const groupKey = (r: SecurityRuleView) =>
- r.custom ? 'custom' : guard === 'inspect_tools' ? 'builtin' : r.kind;
- const groupTitle = (key: string) =>
- key === 'builtin' ? t('contentSecurity.builtinGroup') : kindName(t, key);
- const shown = rules.filter((r) => matchesSearch(r, viewName(t, guard, r), query));
+ const groupKey = (r: SecurityRuleView) => (r.custom ? 'custom' : r.kind);
+ const groupTitle = (key: string) => kindName(lang, kinds, key);
+ const shown = rules.filter((r) => matchesSearch(r, viewName(lang, r), query));
const columns = content ? 6 : 5;
return (
@@ -305,7 +317,11 @@ function RuleTable({
}
function MatchCell({ guard, r }: { guard: Guard; r: SecurityRuleView }) {
+ const { i18n } = useTranslation();
const m = r.matcher;
+ // A built-in redaction rule says what it finds in its own words.
+ const about = ruleAbout(i18n.language, r);
+ if (about) return ;
if (guard === 'redact') return ;
// Tool and content rules show the pattern itself (how it treats case is
// said in the rule's dialog); a check implemented in code says what it
@@ -336,8 +352,8 @@ function ActionCell({
canWrite: boolean;
onAct: (action: RuleAction) => void;
}) {
- const { t } = useTranslation();
- const name = viewName(t, guard, r);
+ const { t, i18n } = useTranslation();
+ const name = viewName(i18n.language, r);
const action = r.action ?? 'record';
const scope = ruleScope(r);
const label = (a: RuleAction) =>
@@ -386,10 +402,11 @@ function RuleRow({
canWrite: boolean;
actions: RuleActions;
}) {
- const { t } = useTranslation();
- const name = viewName(t, guard, r);
- const why = ruleWhy(t, guard, r);
+ const { t, i18n } = useTranslation();
+ const name = viewName(i18n.language, r);
+ const why = ruleWhy(i18n.language, r);
const pattern = patternOf(r)?.pattern;
+ const about = ruleAbout(i18n.language, r).replaceAll('`', '');
const open = () => actions.open(r);
const onKeyDown = (e: KeyboardEvent) => {
if (e.target !== e.currentTarget) return;
@@ -428,7 +445,7 @@ function RuleRow({
)
)}
-
+
{guard === 'content' && (
diff --git a/web/src/routes/gateway/security/highlight.tsx b/web/src/routes/gateway/security/highlight.tsx
index 7ee2cecf..28fff7cc 100644
--- a/web/src/routes/gateway/security/highlight.tsx
+++ b/web/src/routes/gateway/security/highlight.tsx
@@ -5,13 +5,17 @@ import { cn } from '@/lib/utils';
export interface Mark {
start: number;
end: number;
- /** `bad`: the request or call would be stopped; `warn`: replaced, deleted or recorded. */
- tone: 'warn' | 'bad';
+ /**
+ * `bad`: the request or call would be stopped; `warn`: replaced, deleted
+ * or recorded; `muted`: recognised, but an exception, left as it is.
+ */
+ tone: 'warn' | 'bad' | 'muted';
}
const TONE: Record = {
warn: 'bg-amber-500/20 shadow-[inset_0_-1.5px_0_0_var(--color-amber-500)]',
bad: 'bg-destructive/15 shadow-[inset_0_-1.5px_0_0_var(--destructive)]',
+ muted: 'bg-muted shadow-[inset_0_-1.5px_0_0_var(--muted-foreground)]',
};
/**
diff --git a/web/src/routes/gateway/security/index.tsx b/web/src/routes/gateway/security/index.tsx
index 504b2bdc..cfbe64f1 100644
--- a/web/src/routes/gateway/security/index.tsx
+++ b/web/src/routes/gateway/security/index.tsx
@@ -90,7 +90,7 @@ function message(err: unknown, fallback: string): string {
* flight the view is read back from the server.
*/
export function GatewaySecurityPage() {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [tab, setTab] = useState(() => (mayReadLog() ? 'log' : 'redact'));
const [dialog, setDialog] = useState(null);
@@ -156,7 +156,7 @@ export function GatewaySecurityPage() {
function toggle(guard: Guard, rule: SecurityRuleView, enabled: boolean, undoable = true) {
const d = current(guard);
if (!d) return;
- const name = viewName(t, guard, rule);
+ const name = viewName(i18n.language, rule);
write(guard, withEnabled(d, rule, enabled), true).then(
() =>
toast.success(
@@ -199,7 +199,7 @@ export function GatewaySecurityPage() {
if (!d) return;
const before = d.rules.find((r) => r.custom === rule.custom && r.id === rule.id)?.action ?? 'record';
if (before === action) return;
- const name = viewName(t, guard, rule);
+ const name = viewName(i18n.language, rule);
write(guard, withAction(d, rule, action), true).then(
() =>
toast.success(
@@ -230,7 +230,7 @@ export function GatewaySecurityPage() {
if (!d) return;
await write(guard, withAction(d, rule, action), false);
setDialog(null);
- toast.success(t('contentSecurity.toast.ruleSaved', { name: viewName(t, guard, rule) }));
+ toast.success(t('contentSecurity.toast.ruleSaved', { name: viewName(i18n.language, rule) }));
}
async function removeCustom(guard: Guard, rule: SecurityRuleView) {
@@ -257,7 +257,7 @@ export function GatewaySecurityPage() {
guard,
editing: null,
seed: {
- name: viewName(t, guard, rule),
+ name: viewName(i18n.language, rule),
pattern: written.pattern,
match: written.match,
action: rule.action ?? null,
@@ -342,7 +342,7 @@ export function GatewaySecurityPage() {
{mayReadLog() && (
-
+
)}
{GUARDS.map((g) => (
@@ -378,6 +378,7 @@ export function GatewaySecurityPage() {
guard={dialog.guard}
mode={detail?.[dialog.guard].mode ?? 'observe'}
rule={dialog.rule}
+ kinds={detail?.[dialog.guard].kinds ?? []}
canWrite={mayWrite(dialog.guard)}
onClose={() => setDialog(null)}
onCopy={
@@ -388,7 +389,7 @@ export function GatewaySecurityPage() {
onSaveAction={(a) => saveAction(dialog.guard, dialog.rule, a)}
/>
)}
- {dialog?.kind === 'test' && setDialog(null)} />}
+ {dialog?.kind === 'test' && setDialog(null)} />}
!o && !deleting && setDialog(null)}
diff --git a/web/src/routes/gateway/security/log-events.ts b/web/src/routes/gateway/security/log-events.ts
index 4ded1547..0e694947 100644
--- a/web/src/routes/gateway/security/log-events.ts
+++ b/web/src/routes/gateway/security/log-events.ts
@@ -50,6 +50,29 @@ export interface GuardEventDetail {
count?: number;
values?: number;
tool?: string;
+ /**
+ * Redaction, the rule that recognises saved keys by value: whose key the
+ * request carried (`{ kind: 'upstream-key', name: }`). Never
+ * any part of the value.
+ */
+ sources?: SavedSource[];
+}
+
+/** Where a saved secret came from. */
+export interface SavedSource {
+ kind: string;
+ name: string;
+}
+
+/**
+ * The saved secrets an event's request carried, named by where they came
+ * from: "API key of provider DeepSeek". A kind this console does not know
+ * yet shows its name alone.
+ */
+export function sourcesOf(t: TFunction, d: GuardEventDetail): string[] {
+ return (d.sources ?? []).map((s) =>
+ t(`contentSecurity.log.source.${s.kind}`, { name: s.name, defaultValue: s.name }),
+ );
}
/** The guard an event belongs to. */
diff --git a/web/src/routes/gateway/security/log-panel.tsx b/web/src/routes/gateway/security/log-panel.tsx
index a3fd2474..a7cefbcc 100644
--- a/web/src/routes/gateway/security/log-panel.tsx
+++ b/web/src/routes/gateway/security/log-panel.tsx
@@ -18,8 +18,8 @@ import {
import { Segmented } from '@/components/segmented';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
-import { GUARDS, type Guard } from '@/lib/security-types';
-import { GUARD_ACTIONS, guardOf, outcomeOf, partOf, type GuardEvent } from './log-events';
+import { GUARDS, type Guard, type SecurityDetail } from '@/lib/security-types';
+import { GUARD_ACTIONS, guardOf, outcomeOf, partOf, sourcesOf, type GuardEvent } from './log-events';
import { actionTone, guardName, ruleName } from './names';
const PAGE_SIZE = 50;
@@ -30,9 +30,11 @@ type Filter = 'all' | Guard;
* The security log: what the three guards found, newest first, read from
* the audit log. Each row says the guard, the rule, where the hit was and
* what became of it; monitor-mode hits say so. The request's text is never
- * in the audit log, only the trace that leads to it.
+ * in the audit log, only the trace that leads to it. An event names its
+ * rule by id; the name shown is the rule view's (`detail`), in the
+ * console's language.
*/
-export function LogPanel() {
+export function LogPanel({ detail }: { detail: SecurityDetail | undefined }) {
const { t } = useTranslation();
const [filter, setFilter] = useState('all');
const [page, setPage] = useState(0);
@@ -126,7 +128,7 @@ export function LogPanel() {
{items.map((e) => (
-
+
))}
@@ -159,13 +161,14 @@ export function LogPanel() {
);
}
-function EventRow({ e }: { e: GuardEvent }) {
- const { t } = useTranslation();
+function EventRow({ e, detail }: { e: GuardEvent; detail: SecurityDetail | undefined }) {
+ const { t, i18n } = useTranslation();
const guard = guardOf(e.action);
const d = e.detail ?? {};
if (!guard) return null;
const outcome = outcomeOf(t, e.action, d);
- const rule = d.rule ? ruleName(t, guard, d.rule, d.custom === true, d.rule_name) : '—';
+ const rule = d.rule ? ruleName(i18n.language, detail?.[guard], d.rule, d.custom === true, d.rule_name) : '—';
+ const sources = new Intl.ListFormat(i18n.language, { type: 'conjunction' }).format(sourcesOf(t, d));
const when = new Date(e.created_at);
return (
@@ -175,6 +178,11 @@ function EventRow({ e }: { e: GuardEvent }) {
{guardName(t, guard)}
{rule}
+ {sources && (
+
+ {sources}
+
+ )}
{d.model && {d.model}
}
{partOf(t, guard, d)}
diff --git a/web/src/routes/gateway/security/matcher-text.test.tsx b/web/src/routes/gateway/security/matcher-text.test.tsx
new file mode 100644
index 00000000..bc783465
--- /dev/null
+++ b/web/src/routes/gateway/security/matcher-text.test.tsx
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { render } from '@testing-library/react'
+import { About, MatcherText } from './matcher-text'
+
+describe('About', () => {
+ it('shows backticked text as code, without the backticks', () => {
+ const { container } = render()
+ expect(container.textContent).toBe('Starts with sk-ant-, followed by at least 20 characters')
+ expect(container.querySelector('code')?.textContent).toBe('sk-ant-')
+ })
+})
+
+describe('MatcherText', () => {
+ it('describes a pattern, and a check in code as a built-in check', () => {
+ const text = (m: Parameters[0]['m']) => render().container.textContent
+ expect(text({ kind: 'regex', pattern: 'a+', case_sensitive: true })).toBe('Regex a+, case-sensitive')
+ expect(text({ kind: 'builtin', check: 'credential-to-network' })).toBe('Built-in check')
+ })
+})
diff --git a/web/src/routes/gateway/security/matcher-text.tsx b/web/src/routes/gateway/security/matcher-text.tsx
index cd557e40..7b0a13f9 100644
--- a/web/src/routes/gateway/security/matcher-text.tsx
+++ b/web/src/routes/gateway/security/matcher-text.tsx
@@ -17,14 +17,6 @@ function fill(template: string, parts: Record): ReactNode {
});
}
-/** Joins nodes the way the language joins a list (`a, b or c` / `a、b或c`). */
-function joined(lang: string, items: ReactNode[], type: 'conjunction' | 'disjunction'): ReactNode {
- const parts = new Intl.ListFormat(lang, { type }).formatToParts(items.map((_, i) => String(i)));
- return parts.map((p, i) =>
- p.type === 'element' ? {items[Number(p.value)]} : p.value,
- );
-}
-
/** A literal from a rule, in code type. Spaces are kept: ` dan ` differs from `dan`. */
export function Code({ children, className }: { children: ReactNode; className?: string }) {
return (
@@ -53,62 +45,29 @@ export function Codepoints({ ranges }: { ranges: string[] }) {
);
}
-/** What a rule matches, as one sentence. */
+/**
+ * A built-in rule's description as the server writes it: text in backticks
+ * is code, shown in code type without the backticks.
+ */
+export function About({ text }: { text: string }) {
+ return (
+ <>
+ {text.split('`').map((piece, i) =>
+ i % 2 === 1 ? {piece} : {piece},
+ )}
+ >
+ );
+}
+
+/**
+ * What a rule matches, as one sentence: a custom rule's pattern, a content
+ * rule's code points. A built-in redaction rule is described by the server
+ * (`about`, see {@link About}); a check implemented in code, or a matcher
+ * this console does not know, reads as a built-in check.
+ */
export function MatcherText({ m }: { m: Matcher }) {
- const { t, i18n } = useTranslation();
- const lang = i18n.language;
+ const { t } = useTranslation();
switch (m.kind) {
- case 'prefix':
- return fill(t('contentSecurity.matcher.prefix', RAW), {
- prefix: {m.prefix},
- n: m.min_tail,
- });
- case 'openai-legacy':
- return fill(t('contentSecurity.matcher.openaiLegacy', RAW), {
- prefix: sk-,
- n: m.min_len,
- });
- case 'pem':
- return fill(t('contentSecurity.matcher.pem', RAW), {
- begin: -----BEGIN … PRIVATE KEY-----,
- });
- case 'jwt':
- return fill(t('contentSecurity.matcher.jwt', RAW), { alg: "alg" });
- case 'conn-string':
- return fill(t('contentSecurity.matcher.connString', RAW), {
- uri: {t('contentSecurity.matcher.connStringShape')},
- });
- case 'private-ip':
- return fill(t('contentSecurity.matcher.privateIp', RAW), {
- ranges: joined(
- lang,
- ['10.', '172.16–31.', '192.168.'].map((p) => {p}),
- 'disjunction',
- ),
- loopback: 127.0.0.1,
- });
- case 'domain-suffix':
- return fill(t('contentSecurity.matcher.domainSuffix', RAW), {
- suffixes: joined(
- lang,
- m.suffixes.map((s) => {s}),
- 'disjunction',
- ),
- });
- case 'cn-resident-id':
- return fill(t('contentSecurity.matcher.cnResidentId', RAW), { year: m.born_since });
- case 'bank-card':
- return fill(t('contentSecurity.matcher.bankCard', RAW), {
- networks: joined(
- lang,
- m.networks.map((n) => t(`contentSecurity.cardNetwork.${n.name}`, { defaultValue: n.name })),
- 'disjunction',
- ),
- });
- case 'email':
- return fill(t('contentSecurity.matcher.email', RAW), { example: name@example.com });
- case 'cn-mobile-phone':
- return t('contentSecurity.matcher.cnMobilePhone');
case 'regex':
return fill(
t(m.case_sensitive ? 'contentSecurity.matcher.regexCaseSensitive' : 'contentSecurity.matcher.regex', RAW),
@@ -126,11 +85,7 @@ export function MatcherText({ m }: { m: Matcher }) {
return fill(t('contentSecurity.matcher.codepoints', RAW), {
ranges: ,
});
- case 'builtin':
- // A check implemented in code: say what it looks for, not how.
- return t(`contentSecurity.check.${m.check}`, { defaultValue: t('contentSecurity.matcher.other') });
default:
- // A kind this console does not know yet.
return t('contentSecurity.matcher.other');
}
}
diff --git a/web/src/routes/gateway/security/names.test.ts b/web/src/routes/gateway/security/names.test.ts
new file mode 100644
index 00000000..e588a538
--- /dev/null
+++ b/web/src/routes/gateway/security/names.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import type { GuardDetail, SecurityRuleView } from '@/lib/security-types'
+import { kindName, ruleAbout, ruleName, ruleWhy, viewName } from './names'
+
+const rule = (over: Partial): SecurityRuleView => ({
+ id: 'groq-key',
+ custom: false,
+ name: 'Groq API key',
+ name_zh: 'Groq API 密钥',
+ about: 'Starts with `gsk_`',
+ about_zh: '`gsk_` 开头',
+ kind: 'ai-services',
+ matcher: { kind: 'builtin', check: 'groq-key' },
+ enabled: true,
+ on_by_default: true,
+ ...over,
+})
+
+const detail: GuardDetail = {
+ mode: 'observe',
+ rules: [rule({})],
+ kinds: [{ slug: 'ai-services', name: 'AI services', name_zh: 'AI 服务' }],
+}
+
+describe('rule text from the view', () => {
+ it('is shown in the console language, English where no Chinese is given', () => {
+ expect(viewName('zh', rule({}))).toBe('Groq API 密钥')
+ expect(viewName('en', rule({}))).toBe('Groq API key')
+ expect(viewName('zh', rule({ name_zh: undefined }))).toBe('Groq API key')
+ expect(ruleAbout('zh', rule({}))).toBe('`gsk_` 开头')
+ expect(ruleWhy('zh', rule({ why: 'Because', why_zh: '因为' }))).toBe('因为')
+ expect(kindName('zh', detail.kinds, 'ai-services')).toBe('AI 服务')
+ expect(kindName('en', detail.kinds, 'unknown')).toBe('unknown')
+ })
+
+ it('names a custom rule by its own name, and a log row by id', () => {
+ expect(viewName('zh', rule({ id: 'Codename', custom: true, name: 'Codename' }))).toBe('Codename')
+ expect(ruleName('zh', detail, 'groq-key', false)).toBe('Groq API 密钥')
+ expect(ruleName('zh', detail, 'gone-rule', false, 'Gone rule')).toBe('Gone rule')
+ expect(ruleName('zh', undefined, 'gone-rule', false)).toBe('gone-rule')
+ })
+})
diff --git a/web/src/routes/gateway/security/names.ts b/web/src/routes/gateway/security/names.ts
index 5f39f928..de39b19c 100644
--- a/web/src/routes/gateway/security/names.ts
+++ b/web/src/routes/gateway/security/names.ts
@@ -1,9 +1,19 @@
import type { TFunction } from 'i18next';
-import type { Guard, GuardMode, RuleAction, RuleScope, SecurityRuleView } from '@/lib/security-types';
+import type {
+ Guard,
+ GuardDetail,
+ GuardMode,
+ RuleAction,
+ RuleScope,
+ SecurityKindView,
+ SecurityRuleView,
+} from '@/lib/security-types';
-// Names on the security page. Built-in rules are looked up by id; a rule
-// the server ships before the console has a name for it falls back to the
-// server's English. A custom rule's id is the name its author gave it.
+// Names on the security page. A built-in rule's name, reason and
+// description, and each group's name, come with the rule view in both
+// languages (thinkwatch-core writes them once for both products); the
+// console keeps no table of its own. A custom rule's id is the name its
+// author gave it.
export function guardName(t: TFunction, guard: Guard): string {
return t(`contentSecurity.guard.${guard}.name`);
@@ -19,45 +29,55 @@ export function scopeName(t: TFunction, scope: RuleScope): string {
return t(`contentSecurity.scope.${scope}`);
}
+/** Whether the console is in Chinese: rule text comes from the server in both languages. */
+function zh(lang: string): boolean {
+ return lang.startsWith('zh');
+}
+
+/** The Chinese text in Chinese when there is one, else the English. */
+function pick(lang: string, en: string | undefined, cn: string | undefined): string {
+ return (zh(lang) && cn ? cn : en) ?? '';
+}
+
+/** A rule's name as the view gives it. A custom rule's id is the name its author gave it. */
+export function viewName(lang: string, r: SecurityRuleView): string {
+ if (r.custom) return r.id;
+ return pick(lang, r.name, r.name_zh) || r.id;
+}
+
+/**
+ * A rule's name from its id, for a log row or a trial hit: looked up in the
+ * guard's view. A rule the view does not list (since removed) shows
+ * `fallback`, or its id.
+ */
export function ruleName(
- t: TFunction,
- guard: Guard,
+ lang: string,
+ detail: GuardDetail | undefined,
id: string,
custom: boolean,
fallback?: string,
): string {
if (custom) return id;
- const defaultValue = fallback ?? id;
- switch (guard) {
- case 'redact':
- return t(`contentSecurity.redactRule.${id}`, { defaultValue });
- case 'inspect_tools':
- return t(`contentSecurity.toolRule.${id}.name`, { defaultValue });
- case 'content':
- return t(`contentSecurity.contentRule.${id}`, { defaultValue });
- }
+ const r = detail?.rules.find((r) => !r.custom && r.id === id);
+ return r ? viewName(lang, r) : (fallback ?? id);
}
-export function viewName(t: TFunction, guard: Guard, r: SecurityRuleView): string {
- return ruleName(t, guard, r.id, r.custom, r.name);
+/** Why a built-in rule is worth a look. Empty for rules that carry no reason. */
+export function ruleWhy(lang: string, r: SecurityRuleView): string {
+ if (r.custom) return '';
+ return pick(lang, r.why, r.why_zh);
}
-/** Why a built-in rule is worth a look. Empty for rules that carry no reason. */
-export function ruleWhy(t: TFunction, guard: Guard, r: SecurityRuleView): string {
- if (r.custom || !r.why) return '';
- switch (guard) {
- case 'inspect_tools':
- return t(`contentSecurity.toolRule.${r.id}.why`, { defaultValue: r.why });
- case 'content':
- return t(`contentSecurity.contentWhy.${r.id}`, { defaultValue: r.why });
- default:
- return r.why;
- }
+/** What a built-in redaction rule finds, as a sentence; backticks mark code. Empty elsewhere. */
+export function ruleAbout(lang: string, r: SecurityRuleView): string {
+ if (r.custom) return '';
+ return pick(lang, r.about, r.about_zh);
}
-/** A rule group's heading. */
-export function kindName(t: TFunction, kind: string): string {
- return t(`contentSecurity.kind.${kind}`, { defaultValue: kind });
+/** A rule group's heading, from the guard's view. */
+export function kindName(lang: string, kinds: SecurityKindView[] | undefined, slug: string): string {
+ const k = kinds?.find((k) => k.slug === slug);
+ return k ? pick(lang, k.name, k.name_zh) : slug;
}
export function actionName(t: TFunction, action: RuleAction): string {
diff --git a/web/src/routes/gateway/security/policy.test.ts b/web/src/routes/gateway/security/policy.test.ts
index 21930648..b805d251 100644
--- a/web/src/routes/gateway/security/policy.test.ts
+++ b/web/src/routes/gateway/security/policy.test.ts
@@ -297,7 +297,7 @@ describe('the restructured guards', () => {
const d: GuardDetail = {
mode: 'enforce',
rules: [
- builtin({ id: 'email', kind: 'personal', matcher: { kind: 'email' }, action: 'record', default_action: 'placeholder' }),
+ builtin({ id: 'email', kind: 'contact', matcher: { kind: 'email' }, action: 'record', default_action: 'placeholder' }),
builtin({ id: 'jwt', kind: 'jwt', matcher: { kind: 'jwt' }, action: 'placeholder', default_action: 'placeholder' }),
],
}
diff --git a/web/src/routes/gateway/security/policy.ts b/web/src/routes/gateway/security/policy.ts
index 84dee9c2..b0f77897 100644
--- a/web/src/routes/gateway/security/policy.ts
+++ b/web/src/routes/gateway/security/policy.ts
@@ -289,15 +289,44 @@ export function groupState(rules: readonly SecurityRuleView[]): boolean | 'indet
return on === 0 ? false : on === rules.length ? true : 'indeterminate';
}
+/** The literals a built-in matcher shows: prefixes, name endings, field names, domain suffixes. */
+function literalsOf(m: Matcher): string[] {
+ switch (m.kind) {
+ case 'prefix':
+ return [m.prefix];
+ case 'prefixes':
+ return m.prefixes.map((p) => p.prefix);
+ case 'assignment':
+ return m.suffixes;
+ case 'labeled':
+ return m.names;
+ case 'domain-suffix':
+ return m.suffixes;
+ default:
+ return [];
+ }
+}
+
/**
- * Whether a rule matches a search: its name (as shown, or its id) or what it
- * matches, ignoring case. An empty search matches every rule.
+ * Whether a rule matches a search: its name (as shown, in either language,
+ * or its id) or what it matches — a custom rule's pattern, a built-in
+ * rule's description, prefixes or field names — ignoring case. An empty
+ * search matches every rule.
*/
export function matchesSearch(r: SecurityRuleView, shownName: string, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
const pattern = patternOf(r)?.pattern ?? '';
- return [shownName, r.id, r.name, pattern].some((s) => s.toLowerCase().includes(q));
+ return [
+ shownName,
+ r.id,
+ r.name,
+ r.name_zh ?? '',
+ r.about ?? '',
+ r.about_zh ?? '',
+ pattern,
+ ...literalsOf(r.matcher),
+ ].some((s) => s.toLowerCase().includes(q));
}
/**
diff --git a/web/src/routes/gateway/security/rule-dialogs.tsx b/web/src/routes/gateway/security/rule-dialogs.tsx
index 9d7d451c..f3b64058 100644
--- a/web/src/routes/gateway/security/rule-dialogs.tsx
+++ b/web/src/routes/gateway/security/rule-dialogs.tsx
@@ -30,16 +30,18 @@ import {
DEFAULT_REPLACEMENT,
type ContentMatch,
type Guard,
+ type GuardDetail,
type GuardMode,
type RuleAction,
type RuleScope,
+ type SecurityKindView,
type SecurityRuleView,
type SecurityTestHit,
type SecurityTestRequest,
} from '@/lib/security-types';
import { Highlight, type Mark } from './highlight';
-import { Code, MatcherText } from './matcher-text';
-import { actionName, actionTone, kindName, ruleName, ruleWhy, scopeName, viewName } from './names';
+import { About, Code, MatcherText } from './matcher-text';
+import { actionName, actionTone, kindName, ruleAbout, ruleName, ruleWhy, scopeName, viewName } from './names';
import {
ACTIONS,
DEFAULT_LABEL,
@@ -179,8 +181,11 @@ function TrialBox({ trial }: { trial: Trial }) {
return {trial.error}
;
}
const result = trial.state === 'done' ? trial.result : trial.last;
- const hits = result?.hits ?? [];
- const tone = (h: SecurityTestHit) => toneOf(h.action);
+ const all = result?.hits ?? [];
+ // An exception value is marked, but it is not a hit: nothing is done to it.
+ const hits = all.filter((h) => !h.excepted);
+ const excepted = all.length - hits.length;
+ const tone = (h: SecurityTestHit): Mark['tone'] => (h.excepted ? 'muted' : toneOf(h.action));
const bad = hits.some((h) => tone(h) === 'bad');
// Dangerous commands send nothing and refuse nothing; the server
// answers null and false there.
@@ -203,12 +208,17 @@ function TrialBox({ trial }: { trial: Trial }) {
? t('contentSecurity.dialog.hits', { count: hits.length })
: t('contentSecurity.dialog.noHit'))}
- {result && hits.length > 0 && (
+ {result && all.length > 0 && (
({ start: h.start, end: h.end, tone: tone(h) }))}
+ marks={all.map((h) => ({ start: h.start, end: h.end, tone: tone(h) }))}
/>
)}
+ {excepted > 0 && (
+
+ {t('contentSecurity.exceptions.inTrial', { count: excepted })}
+
+ )}
{refused && {t('contentSecurity.dialog.refused')}
}
{!refused && result && result.output !== null && (
@@ -653,6 +663,7 @@ export function BuiltinRuleDialog({
guard,
mode,
rule,
+ kinds,
canWrite,
onClose,
onCopy,
@@ -661,20 +672,24 @@ export function BuiltinRuleDialog({
guard: Guard;
mode: GuardMode;
rule: SecurityRuleView;
+ /** The guard's groups, to name the rule's. */
+ kinds: SecurityKindView[];
canWrite: boolean;
onClose: () => void;
/** Absent where no equivalent custom rule can be written: checks implemented in code. */
onCopy?: () => void;
onSaveAction: (action: RuleAction) => Promise
;
}) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
+ const lang = i18n.language;
const initial = rule.action ?? 'record';
const [action, setAction] = useState(initial);
const [sample, setSample] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const trial = useTrial(guard, { sample, rule: rule.id, action });
- const why = ruleWhy(t, guard, rule);
+ const why = ruleWhy(lang, rule);
+ const about = ruleAbout(lang, rule);
const changed = action !== initial;
async function save() {
@@ -693,18 +708,18 @@ export function BuiltinRuleDialog({
- {viewName(t, guard, rule)}
+ {viewName(lang, rule)}
{t('contentSecurity.builtinGroup')}
- {why || t('contentSecurity.dialog.category', { kind: kindName(t, rule.kind) })}
+ {why || t('contentSecurity.dialog.category', { kind: kindName(lang, kinds, rule.kind) })}
@@ -797,8 +812,18 @@ export function BuiltinRuleDialog({
* processing, and for the content filter whether the request would be
* refused. A content sample is tried as a request or as an answer.
*/
-export function TestDialog({ guard, onClose }: { guard: Guard; onClose: () => void }) {
- const { t } = useTranslation();
+export function TestDialog({
+ guard,
+ detail,
+ onClose,
+}: {
+ guard: Guard;
+ /** The guard's view, to name the rules that matched. */
+ detail: GuardDetail | undefined;
+ onClose: () => void;
+}) {
+ const { t, i18n } = useTranslation();
+ const lang = i18n.language;
const [sample, setSample] = useState('');
const [side, setSide] = useState
('request');
const content = guard === 'content';
@@ -848,19 +873,25 @@ export function TestDialog({ guard, onClose }: { guard: Guard; onClose: () => vo
{hits.map((h, i) => (
- {ruleName(t, guard, h.rule, h.custom)}
+ {ruleName(lang, detail, h.rule, h.custom)}
{h.custom && (
- {kindName(t, 'custom')}
+ {kindName(lang, detail?.kinds, 'custom')}
)}
{h.excerpt}
-
- {actionName(t, h.action ?? 'record')}
-
+ {h.excepted ? (
+
+ {t('contentSecurity.exceptions.excepted')}
+
+ ) : (
+
+ {actionName(t, h.action ?? 'record')}
+
+ )}
))}
diff --git a/web/src/routes/gateway/security/security.test.tsx b/web/src/routes/gateway/security/security.test.tsx
index 87bc0baa..45991673 100644
--- a/web/src/routes/gateway/security/security.test.tsx
+++ b/web/src/routes/gateway/security/security.test.tsx
@@ -9,24 +9,58 @@ import { GatewaySecurityPage } from '.'
vi.mock('@/lib/api', () => ({
api: vi.fn(),
apiPatch: vi.fn(),
+ apiPost: vi.fn(),
+ apiDelete: vi.fn(),
hasPermission: vi.fn(() => true),
+ ApiError: class ApiError extends Error {
+ status: number
+ constructor(message: string, status: number) {
+ super(message)
+ this.status = status
+ }
+ },
}))
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: ReactNode }) => {children},
}))
-import { api, apiPatch, hasPermission } from '@/lib/api'
+import { api, apiDelete, apiPatch, apiPost, hasPermission } from '@/lib/api'
+
+const exception = {
+ sha256: 'a'.repeat(64),
+ masked: 'sk-an…AAAA',
+ rule: 'anthropic-api-key',
+ added_at: Date.UTC(2026, 9, 29, 8),
+}
+let exceptions: { items: (typeof exception)[]; max: number } = { items: [], max: 500 }
const detail = (): SecurityDetail => ({
redact: {
mode: 'observe',
rules: [
+ {
+ id: 'thinkwatch-saved-secret',
+ custom: false,
+ name: 'Key saved in ThinkWatch',
+ name_zh: 'ThinkWatch 中保存的密钥',
+ about: 'The provider API keys saved in ThinkWatch, by value, whatever their format',
+ about_zh: '按值识别 ThinkWatch 中保存的供应商 API 密钥,与格式无关',
+ kind: 'saved',
+ matcher: { kind: 'saved' },
+ enabled: true,
+ on_by_default: true,
+ action: 'placeholder',
+ default_action: 'placeholder',
+ label: 'SECRET',
+ },
{
id: 'anthropic-api-key',
custom: false,
name: 'Anthropic API key',
- kind: 'api-keys',
+ name_zh: 'Anthropic API 密钥',
+ about: 'Starts with `sk-ant-`, followed by at least 20 characters',
+ kind: 'ai-services',
matcher: { kind: 'prefix', prefix: 'sk-ant-', min_tail: 20 },
enabled: true,
on_by_default: true,
@@ -38,7 +72,9 @@ const detail = (): SecurityDetail => ({
id: 'email',
custom: false,
name: 'Email address',
- kind: 'personal',
+ name_zh: '邮箱地址',
+ about: 'Email addresses, such as `name@example.com`',
+ kind: 'contact',
matcher: { kind: 'email' },
enabled: false,
on_by_default: false,
@@ -47,6 +83,12 @@ const detail = (): SecurityDetail => ({
label: 'EMAIL',
},
],
+ kinds: [
+ { slug: 'saved', name: 'Keys saved in ThinkWatch', name_zh: 'ThinkWatch 中保存的密钥' },
+ { slug: 'ai-services', name: 'AI services', name_zh: 'AI 服务' },
+ { slug: 'contact', name: 'Contact details', name_zh: '联系方式' },
+ { slug: 'custom', name: 'Custom', name_zh: '自定义' },
+ ],
},
inspect_tools: {
mode: 'enforce',
@@ -68,6 +110,7 @@ const detail = (): SecurityDetail => ({
custom: false,
name: 'Send a credential to an unknown host',
why: "Sends a credential to a host that is neither local nor the credential's own provider",
+ why_zh: '将凭据发往既非本机、也不是该凭据服务商的主机',
kind: 'command',
matcher: { kind: 'builtin', check: 'credential-to-network' },
enabled: true,
@@ -76,6 +119,10 @@ const detail = (): SecurityDetail => ({
default_action: 'cut',
},
],
+ kinds: [
+ { slug: 'command', name: 'Built-in', name_zh: '内置' },
+ { slug: 'custom', name: 'Custom', name_zh: '自定义' },
+ ],
},
content: {
mode: 'observe',
@@ -94,11 +141,15 @@ const detail = (): SecurityDetail => ({
scope: ['request'],
},
],
+ kinds: [
+ { slug: 'invisible', name: 'Hidden characters', name_zh: '隐藏字符' },
+ { slug: 'custom', name: 'Custom', name_zh: '自定义' },
+ ],
},
})
const events = {
- total: 2,
+ total: 3,
items: [
{
id: 'e1',
@@ -123,6 +174,20 @@ const events = {
created_at: '2026-10-12T07:00:00Z',
detail: { trace_id: 't2', rule: 'anthropic-api-key', custom: false, outcome: 'recorded', mode: 'observe' },
},
+ {
+ id: 'e3',
+ action: 'gateway.redaction_replaced',
+ api_key_id: 'k1',
+ created_at: '2026-10-12T06:00:00Z',
+ detail: {
+ trace_id: 't3',
+ rule: 'thinkwatch-saved-secret',
+ custom: false,
+ outcome: 'replaced',
+ mode: 'enforce',
+ sources: [{ kind: 'upstream-key', name: 'DeepSeek' }],
+ },
+ },
],
}
@@ -131,11 +196,13 @@ beforeEach(() => {
vi.mocked(hasPermission).mockReturnValue(true)
vi.mocked(api).mockImplementation(async (url: string) => {
if (url === '/api/admin/security') return detail()
+ if (url === '/api/admin/security/redact/exceptions') return exceptions
if (url.startsWith('/api/audit/logs?')) return events
if (url.endsWith('/test')) return { hits: [], output: null, refused: false }
throw new Error(`unexpected request: ${url}`)
})
vi.mocked(apiPatch).mockResolvedValue({})
+ exceptions = { items: [], max: 500 }
})
describe('GatewaySecurityPage', () => {
@@ -145,16 +212,22 @@ describe('GatewaySecurityPage', () => {
await user.click(await screen.findByRole('tab', { name: /Secrets & privacy/ }))
expect(await screen.findByText('Anthropic API key')).toBeInTheDocument()
- expect(screen.getByText('API keys')).toBeInTheDocument()
- expect(screen.getByText('Personal information')).toBeInTheDocument()
+ // Groups in the server's order, saved keys first.
+ const groups = ['Keys saved in ThinkWatch', 'AI services', 'Contact details']
+ expect(groups.map((g) => screen.getByText(g).compareDocumentPosition(screen.getByText('Anthropic API key')))).toEqual([
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ Node.DOCUMENT_POSITION_PRECEDING,
+ ])
+ expect(screen.getByText('The provider API keys saved in ThinkWatch, by value, whatever their format')).toBeInTheDocument()
expect(screen.getByText('<>')).toBeInTheDocument()
// The same three states on every guard.
const modes = screen.getByRole('radiogroup', { name: 'Secrets & privacy mode' })
expect(within(modes).getAllByRole('radio').map((r) => r.textContent)).toEqual(['On', 'Monitor', 'Off'])
expect(within(modes).getByRole('radio', { name: 'Monitor' })).toBeChecked()
- expect(screen.getByText('1 of 2 rules on.')).toBeInTheDocument()
+ expect(screen.getByText('2 of 3 rules on.')).toBeInTheDocument()
// Monitoring: the action stays as set, and says that only recording happens.
- expect(screen.getAllByText('Monitoring')).toHaveLength(1)
+ expect(screen.getAllByText('Monitoring')).toHaveLength(2)
})
it('opens on the log, which reads the guard events from the audit log', async () => {
@@ -166,6 +239,9 @@ describe('GatewaySecurityPage', () => {
// A built-in rule is named; a monitor-mode hit says so.
expect(screen.getByText('Anthropic API key')).toBeInTheDocument()
expect(screen.getByText('Monitoring')).toBeInTheDocument()
+ // A saved key is named by where it came from, never by its value.
+ expect(screen.getByText('Key saved in ThinkWatch')).toBeInTheDocument()
+ expect(screen.getByText('API key of provider DeepSeek')).toBeInTheDocument()
const call = vi.mocked(api).mock.calls.find(([url]) => String(url).startsWith('/api/audit/logs?'))
const params = new URLSearchParams(String(call?.[0]).split('?')[1])
expect(params.get('action')?.split(',')).toContain('gateway.content_replaced')
@@ -185,7 +261,7 @@ describe('GatewaySecurityPage', () => {
renderWithQueryClient()
await user.click(await screen.findByRole('tab', { name: /Secrets & privacy/ }))
- await user.click(await screen.findByRole('checkbox', { name: 'Turn every rule in "Personal information" on or off' }))
+ await user.click(await screen.findByRole('checkbox', { name: 'Turn every rule in "Contact details" on or off' }))
await waitFor(() =>
expect(apiPatch).toHaveBeenCalledWith('/api/admin/settings', {
settings: { 'security.redact': { mode: 'observe', enable: ['email'] } },
@@ -349,12 +425,15 @@ describe('GatewaySecurityPage', () => {
renderWithQueryClient()
await user.click(await screen.findByRole('tab', { name: /Dangerous commands/ }))
- const check = "A credential sent to a host other than this machine and the credential's own provider"
- expect(await screen.findByText(check)).toBeInTheDocument()
+ // What it is for is the rule's reason; what it matches, a built-in check.
+ const why = "Sends a credential to a host that is neither local nor the credential's own provider"
+ expect(await screen.findByText(why)).toBeInTheDocument()
+ expect(screen.getAllByText('Built-in check').length).toBeGreaterThan(0)
await user.click(screen.getByText('Send a credential to an unknown host'))
const dialog = await screen.findByRole('dialog')
- expect(within(dialog).getByText(check)).toBeInTheDocument()
+ expect(within(dialog).getByText(why)).toBeInTheDocument()
+ expect(within(dialog).getByText('Built-in check')).toBeInTheDocument()
expect(within(dialog).queryByRole('button', { name: 'Copy as a custom rule' })).not.toBeInTheDocument()
await user.keyboard('{Escape}')
@@ -473,9 +552,79 @@ describe('GatewaySecurityPage', () => {
expect(await screen.findByRole('switch', { name: 'Turn on "Email address"' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'New rule' })).toBeDisabled()
expect(screen.getByRole('radio', { name: 'On' })).toBeDisabled()
- expect(screen.getByRole('checkbox', { name: 'Turn every rule in "API keys" on or off' })).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: 'Turn every rule in "AI services" on or off' })).toBeDisabled()
// The action is said, not offered.
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Test…' })).toBeEnabled()
})
+ it('lists the exception values under the redaction rules, and adds and removes one', async () => {
+ const user = userEvent.setup()
+ vi.mocked(apiPost).mockResolvedValue({ ...exception, sha256: 'b'.repeat(64), masked: 'ghp_B…BBBB' })
+ vi.mocked(apiDelete).mockResolvedValue(exception)
+ exceptions = { items: [exception], max: 500 }
+ renderWithQueryClient()
+
+ await user.click(await screen.findByRole('tab', { name: /Secrets & privacy/ }))
+ expect(await screen.findByText('Exceptions · 1')).toBeInTheDocument()
+ expect(screen.getByText('sk-an…AAAA')).toBeInTheDocument()
+ // The rule is named from the rule view.
+ expect(screen.getAllByText('Anthropic API key').length).toBeGreaterThan(1)
+
+ await user.click(screen.getByRole('button', { name: 'Add exception' }))
+ const dialog = await screen.findByRole('dialog')
+ expect(within(dialog).getByText(/will no longer be replaced, nor recorded/)).toBeInTheDocument()
+ await user.type(within(dialog).getByLabelText('Value'), 'ghp_test')
+ await user.click(within(dialog).getByRole('button', { name: 'Add exception' }))
+ await waitFor(() =>
+ expect(apiPost).toHaveBeenCalledWith('/api/admin/security/redact/exceptions', { value: 'ghp_test' }),
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Remove' }))
+ const confirm = await screen.findByRole('dialog')
+ await user.click(within(confirm).getByRole('button', { name: 'Remove' }))
+ await waitFor(() =>
+ expect(apiDelete).toHaveBeenCalledWith(`/api/admin/security/redact/exceptions/${'a'.repeat(64)}`),
+ )
+ })
+
+ it('offers no adding at the cap, and one line when there are none', async () => {
+ const user = userEvent.setup()
+ exceptions = { items: [exception], max: 1 }
+ const { unmount } = renderWithQueryClient()
+ await user.click(await screen.findByRole('tab', { name: /Secrets & privacy/ }))
+ expect(await screen.findByText(/There are 1 exceptions, the most there may be/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Add exception' })).toBeDisabled()
+ unmount()
+
+ exceptions = { items: [], max: 500 }
+ renderWithQueryClient()
+ await user.click(await screen.findByRole('tab', { name: /Secrets & privacy/ }))
+ expect(await screen.findByText(/^No exceptions\./)).toBeInTheDocument()
+ expect(screen.queryByText(/^Exceptions ·/)).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Add exception' })).toBeEnabled()
+ })
+
+ it('says in the trial that an exception value is left as is', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api).mockImplementation(async (url: string) => {
+ if (url === '/api/admin/security') return detail()
+ if (url === '/api/admin/security/redact/exceptions') return exceptions
+ if (url.startsWith('/api/audit/logs?')) return events
+ if (url.endsWith('/test'))
+ return {
+ hits: [{ rule: 'anthropic-api-key', custom: false, start: 4, end: 14, excerpt: 'sk-an…AAAA', excepted: true }],
+ output: null,
+ refused: false,
+ }
+ throw new Error(`unexpected request: ${url}`)
+ })
+ renderWithQueryClient()
+ await user.click(await screen.findByRole('tab', { name: /Secrets & privacy/ }))
+ await user.click(await screen.findByRole('button', { name: 'Test…' }))
+ const dialog = await screen.findByRole('dialog')
+ await user.type(within(dialog).getByLabelText('Test text'), 'key sk-ant-xyz')
+ expect(await within(dialog).findByText('Exception, left as is')).toBeInTheDocument()
+ expect(within(dialog).getByText('Exceptions left as is: 1')).toBeInTheDocument()
+ expect(within(dialog).getByText('No match')).toBeInTheDocument()
+ })
})