diff --git a/CHANGELOG.md b/CHANGELOG.md index d6444034..0b68c82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,196 @@ target. ## [Unreleased] +## [3.8.0] — 2026-10-13 + +The security page is organised by what each guard protects against: Secrets +& privacy (outbound redaction), Dangerous commands (tool-call inspection) +and Content filter, after a Log of what they found. Each guard is On, +Monitor or Off, and every rule has an action of its own. Content rules can +now check the text of answers as well as requests, and can replace what +they match with a text of their own. The thinkwatch-core crates move from +v0.72.0 to v0.73.0. + +### Read before upgrading + +- **Policies saved before 3.8.0 keep their behaviour.** Every new key is + optional, and a policy without it behaves as before: redaction rules + replace with placeholders, content rules check requests only and ignore + case, and answers are not changed. The console leaves each new key out + while it has its default, so a policy saved from the 3.8.0 console + without using a new option stays readable by 3.7. Two redaction fixes + apply to every policy (see Fixed). +- **Upgrade every replica before using the new options, and do not roll + back afterwards.** 3.7 refuses a policy with keys it does not know. A 3.7 + instance that reads a `security.redact` policy with `actions` or a custom + rule's `action`, or a `security.content` policy with a custom rule's + `scope`, `case_sensitive` or `replacement`, or the action `replace`, logs + `Unreadable guard policy` and runs that guard on its factory policy: + monitoring, built-in rules as shipped, no custom rules. During a rolling + upgrade, change a rule's action, what it checks, its case or its + replacement only once no 3.7 instance is left; to roll back after doing + so, first remove those keys. +- **New names in the console.** Outbound redaction is now Secrets & + privacy, and tool-call inspection is Dangerous commands; the content + filter keeps its name. Every guard's modes are On, Monitor and Off; they + were Off, Observe and a third named for the guard (Replace, Cut off, + Enforce). On the role pages the permission groups `pii_redactor` and + `content_filter` are shown as Secrets & privacy and as Content filter & + dangerous commands. The settings keys (`security.redact`, + `security.inspect_tools`, `security.content`), the mode values + (`enforce`, `observe`, `off`), the permissions and the existing audit + actions keep their names. +- **More guard events reach the audit log, and with it the log + forwarders.** Hits found while a guard monitors were already audit + events and still are. New in 3.8.0: + - `gateway.content_replaced`, a new action, for text a content rule + replaced. + - Rules that check answers write content events too, with `direction: + response`, `part: response_text` and the upstream in `resource` + (`provider:`); a replacement's event carries the rule's + `replacement`. A request's events have `direction: request`. + - A redaction rule whose action is to record writes + `gateway.redaction_flagged` while the guard is On, where that action + used to mean the guard was monitoring. Events are per rule, so one + request can have both a `gateway.redaction_replaced` and a + `gateway.redaction_flagged` event. + - Content, redaction and tool-call events carry the guard's `mode` + (`observe` for hits recorded while monitoring), and redaction events + the rule's `action` (`placeholder` or `record`). + - `gateway_content_matched_total` has a `direction` label. + + Check alerts and SIEM filters that key on these actions. +- **Filtering answers changes what clients receive, within these limits.** + Under Monitor, or when every rule that checks answers only records, + nothing is held back or changed. Under On, with a rule that deletes or + replaces: + - A streamed answer is never held back whole. Each text holds back only + the last characters that could still grow into a match: one fewer than + the longest keyword, or a regular expression's longest possible match, + at most 256 characters, one more for a regular expression that looks at + the next character (`\b`, `$`). Of a longer match the beginning may + already have been sent. When a rule deletes (`strip`, or `replace` with + an empty text), as many characters again are held back; deletions at + the very start of a text do not add to that. Text held back is sent + when the stream ends, breaks off or carries an error from the upstream. + - Deleting is repeated until no match is left: text that comes together + where something was deleted is checked again (`sesecretcret` becomes + empty, not `secret`). A replacement is never checked again. A whole + answer always comes out clean, and a streamed one comes out the same as + the whole one wherever it is split, as long as a match is nested no + more than one level deep. Of a match nested deeper the beginning may + already have been sent; the rest of it is deleted, so the client never + receives a whole match, except a regular expression that looks at the + next character whose match lies entirely in text already sent. After + 64 further passes, what is still changing is dropped from the answer. + - Regular expressions anchored to the start or end of a text or a line + (`^`, `$`, `\A`, `\z`) are checked once, on the model's own text, and + rules that only record see the model's own text, not what deletions + leave. + - The frames that repeat the whole text (`response.output_text.done`, + `response.output_item.done`, `response.completed`) say what the client + received. + - An answer that is not valid UTF-8 is read with replacement characters + in place of the invalid bytes, and sent that way where the filter + rewrites it; an element of a Gemini JSON-array stream that cannot be + parsed is dropped. +- **Logprobs are removed while an answer rule can change text.** Under On, + with a rule that checks answers and deletes or replaces, logprobs are + removed from every streamed answer, whether or not the rule matched, and + from a whole answer whose text changed, since they repeat the text token + by token: Chat Completions' `logprobs` becomes `null`, the Responses + API's `logprobs` an empty list, and Gemini's `logprobsResult` is left + out. +- **With a rule that checks answers, the log row stores the answer as the + client received it.** Its text has been through the content rules, and + the caller's redacted values are masked again: by the redaction rules, + and wherever a value the request's placeholders stand for appears as + written, so a value its rule recognises only in context (a connection + string's password) is masked even when the model repeats it alone. In a + body that is valid JSON this masking changes only the contents of strings + (also values written escaped inside them, up to six levels deep), never + numbers, literals or structure, so the stored body stays valid JSON. + Stored text that is not strictly valid JSON (an event stream, a + plain-text error) is masked wherever a value appears, numbers included. + Where values overlap, the longest one at each place is masked. Without + such a rule the row stores the answer as before, with the placeholders + the upstream returned. The response cache keeps the answer unfiltered, + in placeholder form, either way, and the rules in force run again on + every cache hit. + +### Added + +- **Answer text filtering.** A custom content rule says what it checks: + requests (the caller's messages and tool results), answers, or both + (`scope: [request]`, `[response]` or `[request, response]`; left out: + requests). On an answer the rule reads the text the client is shown and + the input of freeform tools such as Codex's `apply_patch`, not tool calls + and not reasoning. It applies to whole answers, streamed answers on every + API surface, answers served from the cache and the Responses WebSocket + (each turn filtered as a stream of its own), in the client's format, + after redacted values are restored, so a rule never cuts into a + placeholder, and before tool calls are inspected. A rule that checks + answers cannot refuse, and cannot match by code points; such a policy is + refused when saved. See Read before upgrading for how streamed answers + are held back and what the client and the log receive. +- **Replace.** A content rule can replace what it matches with a text of + its own (`action: replace`, `replacement`, `***` when left out, at most + 200 characters), in requests and in answers. A replacement is not put + back, and is not checked again. In a request, deleting and replacing are + repeated until no match is left (a keyword split by hidden characters is + caught once they are gone); a part still changing after 64 rounds is + emptied. +- **Case-sensitive content rules** (`case_sensitive: true`), as redaction + rules already were. +- **Record-only redaction rules.** A redaction rule's action is to replace + with a placeholder or to record only: built-in rules through + `security.redact.actions` (`{"email": "record"}`), custom rules through + `action`. A recorded value goes upstream unchanged, and the hit is + reported as `gateway.redaction_flagged`; as before, a custom rule's + event carries only counts and a built-in rule's event a few masked + values. With `audit.body_redact_pii` on, the value is still masked in + stored bodies. Where a record-only match overlaps a match that is + replaced, the value is replaced. +- **Console.** The security page has a Log tab (for holders of + `logs:read_all`) listing the guards' audit events, filtered by guard, + with the rule, where it matched (request, tool result, answer text, tool + call), what was done and a link to the trace. Each guard tab shows its + state with a dot (green On, amber Monitor, grey Off), one line on what + the state does, and how many rules are on. Rules are grouped, with a + checkbox per group that switches every rule shown in it in one save, and + a search over names and patterns; each row has its switch and its + action, chosen in place. While a guard monitors, each action is shown + with "Monitoring". The content rule dialog sets what the rule checks, + how it matches (with a Case-sensitive switch), its action and its + replacement text; options that do not apply to answers are shown but + unavailable, with the reason, and switch with a notice when the scope + changes. Samples can be tried as a request or as an answer + (`POST /api/admin/security/content/test` takes `scope` and + `replacement`). The redaction rule dialog's Case-sensitive is a switch, + and the dialog sets the rule's action. +- **`GET /api/audit/logs` takes several actions** in `action`, separated + by commas; the first 32 are used. + +### Fixed + +- **Overlapping redaction matches no longer leave part of a value in + clear.** Where two rules matched overlapping text, only the first match + was replaced, and the part of the other outside it went upstream as + written. Overlapping matches now become one placeholder. Matches that + do not count (on the gateway's own placeholders, in base64 payloads, a + custom rule's match in a protocol identifier) are set aside before + overlaps are resolved; before, a value that overlapped one of them was + dropped with it and sent as written. +- **A custom redaction rule written as a regular expression is matched + within one JSON string at a time.** A pattern such as `\S+` could start + on the request's structure, replace part of it and leave the request + that went upstream invalid JSON, missing the value it was written for. + +### Changed + +- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) + v0.72.0 → v0.73.0. + ## [3.7.1] — 2026-10-12 Tool-call inspection no longer crashes on a URL written right after a @@ -2165,7 +2355,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.7.1...HEAD +[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.0...HEAD +[3.8.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.0 [3.7.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.1 [3.7.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.0 [3.6.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.6.0 diff --git a/Cargo.lock b/Cargo.lock index d09aa948..ec440869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "think-watch-auth" -version = "3.7.1" +version = "3.8.0" dependencies = [ "anyhow", "argon2", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "think-watch-common" -version = "3.7.1" +version = "3.8.0" dependencies = [ "aes-gcm", "anyhow", @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "think-watch-gateway" -version = "3.7.1" +version = "3.8.0" dependencies = [ "anyhow", "arc-swap", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "think-watch-mcp-gateway" -version = "3.7.1" +version = "3.8.0" dependencies = [ "anyhow", "arc-swap", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "think-watch-server" -version = "3.7.1" +version = "3.8.0" dependencies = [ "anyhow", "arc-swap", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "think-watch-test-support" -version = "3.7.1" +version = "3.8.0" dependencies = [ "anyhow", "async-stream", @@ -4837,8 +4837,8 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -4852,16 +4852,16 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "serde", ] [[package]] name = "tw-dialect" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "memchr", "serde", @@ -4870,13 +4870,15 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ + "aho-corasick", "base64 0.22.1", "bytes", "memchr", "regex", + "regex-syntax", "serde", "serde_json", "serde_yaml_ng", diff --git a/Cargo.toml b/Cargo.toml index f77339a8..530b7634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "3.7.1" +version = "3.8.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.72.0" } -tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } +tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } # Web framework axum = { version = "0.8", features = ["macros", "ws"] } diff --git a/README.md b/README.md index 7e757b8c..bb81682d 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,12 @@ ## Highlights - **MCP tool calls run as the real user.** Each user connects their own GitHub, Notion, Linear, Slack or Atlassian account through OAuth or a personal token, so the upstream's own audit log shows who acted. Tokens are encrypted at rest, tool lists are cached per user, and each tool can be granted per role and per API key. -- **Security guards on every request.** Outbound redaction replaces credentials and personal data anywhere in a request with placeholders such as `<>` before it goes upstream, and restores them in the answer, streamed ones included. Tool-call inspection checks the tool calls in each response for dangerous commands, and the content filter looks for prompt-injection phrases and hidden characters in what the caller sent, then refuses the request, deletes them or records them. +- **Security guards on every request.** Secrets & privacy replaces credentials and personal data anywhere in a request with placeholders such as `<>` before it goes upstream, and restores them in the answer, streamed ones included. Dangerous commands checks the tool calls in each response, and the content filter looks for prompt-injection phrases, hidden characters and chosen words in what the caller sent and in the text of answers, then refuses the request, deletes or replaces them, or records them. - **Identity from the organization's directory.** Sign-in works through any OIDC provider (Zitadel, Okta, Azure AD and others), with optional TOTP. Five built-in roles, from Super Admin to Viewer, and custom roles decide who may use which models, tools and admin pages. - **One key for AI and MCP.** Users receive `tw-` virtual keys that can be scoped to the AI gateway, the MCP gateway or both. Keys are stored only as hashes and rotate with a grace period. - **Rate limits and budgets.** Sliding windows from one minute to one week limit requests or tokens, and daily, weekly or monthly budgets cap spending. Both attach to users, API keys or roles, and rate limits apply to MCP tool calls as well as model requests. - **Cost accounting that finance can use.** Spend is reported by model, user, provider and cost center, with CSV chargeback reports and a month-end forecast. Per-model weights make expensive models count for more against the same quota. -- **Audit trail in ClickHouse.** Every model request and tool call is recorded with user, parameters, response, latency and errors, and captured bodies can be redacted with the outbound redaction rules before storage (off by default). Events can be forwarded to a SIEM over Syslog, Kafka (through a REST proxy) or signed webhooks. +- **Audit trail in ClickHouse.** Every model request and tool call is recorded with user, parameters, response, latency and errors, and captured bodies can be redacted with the secrets & privacy rules before storage (off by default). Events can be forwarded to a SIEM over Syslog, Kafka (through a REST proxy) or signed webhooks. - **One endpoint for every client.** OpenAI Chat Completions, OpenAI Responses, Anthropic Messages and Gemini requests are served on one port and converted to whatever the upstream speaks. Routing spreads traffic by weight, latency or health, and a circuit breaker takes failing upstreams out of rotation. ## Quick start @@ -84,11 +84,11 @@ The gateway (port `3000`) is the only part that clients need to reach. The conso - Responses from servers that use per-user credentials are cached per user and account, never shared. **Security guards** -- There are three guards, each with three modes: off, observe, and one named for what it does — replace (outbound redaction), cut off (tool-call inspection) and enforce (content filter). A new installation starts all three in observe mode: hits go to the audit log and nothing is changed until a guard is switched to its third mode. -- Every rule is listed on the console's security page, built-in and custom. Built-in rules can be switched on or off, tool-call and content rules can take another action, custom rules can be added, and a sample can be tried against one rule or a whole guard first. -- Outbound redaction searches the whole request, system prompt and earlier answers included, but not field names or base64 payloads. Custom rules also skip the values a protocol needs exactly as they are, such as ids, call ids, types, roles, tool names and the model name; built-in rules still search them. A custom rule looks for its text as written or for a regular expression, ignoring case unless it is set to be case-sensitive. A match becomes `<>` — `SECRET` for credentials, `ID_NUMBER`, `CARD_NUMBER`, `EMAIL` and `PHONE` for personal data, a label of its own for a custom rule — and is restored in the answer. E-mail addresses and phone numbers ship switched off. -- A content rule matches a phrase, a regular expression or code points (`U+200B`, `U+E0000–U+E007F`), and either refuses the request, deletes what it matched from the caller's messages and tool results, or records only. Hidden characters are content rules: Unicode tag characters and bidirectional controls ship on, zero-width and private-use characters off. -- A tool-call rule cuts the response at the call or records it. Besides the dangerous-command rules, two built-in rules catch a credential sent to an unknown host and a local file uploaded to an external host. +- There are three guards — secrets & privacy, dangerous commands and the content filter — each on, monitoring or off. A new installation starts all three monitoring: hits go to the audit log, and from there to the log forwarders, and nothing is changed until a guard is switched on. Then each rule does what its own action says. +- Every rule is listed on the console's security page, built-in and custom, grouped, with a search; a group can be switched on or off at once. Every rule has an action of its own: replace with a placeholder or record (secrets & privacy), cut off or record (dangerous commands), refuse, delete, replace or record (content filter). Custom rules can be added, and a sample can be tried against one rule or a whole guard first. The page's log lists what the guards found, read from the audit log. +- Secrets & privacy searches the whole request, system prompt and earlier answers included, but not field names or base64 payloads. Custom rules also skip the values a protocol needs exactly as they are, such as ids, call ids, types, roles, tool names and the model name; built-in rules still search them. A custom rule looks for its text as written or for a regular expression, ignoring case unless it is set to be case-sensitive. A match becomes `<>` — `SECRET` for credentials, `ID_NUMBER`, `CARD_NUMBER`, `EMAIL` and `PHONE` for personal data, a label of its own for a custom rule — and is restored in the answer. E-mail addresses and phone numbers ship switched off. +- A content rule matches a phrase, a regular expression or code points (`U+200B`, `U+E0000–U+E007F`), case-sensitive or not, and checks requests (the caller's messages and tool results), the text of answers, or both. It refuses the request, deletes what it matched, replaces it with a text of its own (`***` unless it says otherwise), or records only. Answers are checked as they stream: only the tail that could still grow into a match is held back, and what a rule deletes or replaces never reaches the client. A rule that checks answers cannot refuse, nor match code points. Hidden characters are content rules: Unicode tag characters and bidirectional controls ship on, zero-width and private-use characters off. +- A dangerous-command rule cuts the response at the call or records it. Besides the dangerous-command rules, two built-in rules catch a credential sent to an unknown host and a local file uploaded to an external host. - A model's maximum output tokens, set on the Models page, caps `max_tokens` on every request to that model; it replaces the old output length guardrail. **Limits and budgets** @@ -115,7 +115,7 @@ Product page: **[thinkwat.ch/thinkwatch](https://thinkwat.ch/thinkwatch)** · Fu ## Built on ThinkWatch Core -ThinkWatch uses four crates from [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core) (MIT): `tw-dialect` for conversion between API formats and usage parsing, `tw-guard` for redaction, tool-call inspection and the other guards, `tw-breaker` for the circuit-breaker state machine, and `tw-bedrock` for Amazon Bedrock signing, event streams and the model catalog. +ThinkWatch uses four crates from [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core) (MIT): `tw-dialect` for conversion between API formats and usage parsing, `tw-guard` for the security guards, `tw-breaker` for the circuit-breaker state machine, and `tw-bedrock` for Amazon Bedrock signing, event streams and the model catalog. [ThinkWatch Lite](https://github.com/ThinkWatchProject/ThinkWatch-Lite) is the desktop edition for individual developers, a local gateway for Claude Code, Codex and other clients on macOS, Windows and Linux (MIT). diff --git a/README.zh-CN.md b/README.zh-CN.md index 9cc41b82..a983512e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,12 +40,12 @@ ## 主要特性 - **MCP 工具调用以真实用户身份执行。** 每位用户通过 OAuth 或个人令牌连接自己的 GitHub、Notion、Linear、Slack、Atlassian 等账号,上游自身的审计日志因此能记录到具体操作人。令牌加密存储,工具列表按用户分别缓存,每个工具可以按角色和按 API Key 授权。 -- **每个请求都经过安全防护。** 出站脱敏在请求发往上游前,把请求中任何位置的凭据和个人信息替换为 `<>` 这样的占位符,并在回答中还原,流式回答同样适用。工具调用审查按危险命令规则检查响应中的工具调用;内容过滤查找调用方发来内容中的提示词注入语句和隐藏字符,按规则拒绝请求、删除命中的文字或仅记录。 +- **每个请求都经过安全防护。** 「密钥与隐私」在请求发往上游前,把请求中任何位置的凭据和个人信息替换为 `<>` 这样的占位符,并在回答中还原,流式回答同样适用。「危险命令」检查响应中的工具调用;「内容过滤」在调用方发来的内容和回答正文中查找提示词注入语句、隐藏字符和指定的词语,按规则拒绝请求、删除或替换命中的文字,或仅记录。 - **身份来自组织目录。** 登录可对接任意 OIDC 提供商(Zitadel、Okta、Azure AD 等),并可启用 TOTP 两步验证。从超级管理员到观察者的五个内置角色以及自定义角色,决定每个人可用的模型、工具和管理页面。 - **AI 与 MCP 共用一把密钥。** 用户获得 `tw-` 虚拟密钥,可限定用于 AI 网关、MCP 网关或两者。密钥只以哈希形式保存,轮换时保留宽限期。 - **限流与预算。** 从一分钟到一周的滑动窗口可限制请求数或 Token 数,按日、周、月的预算控制总用量。两者均可设置在用户、API Key 或角色上,限流同样适用于 MCP 工具调用。 - **可用于财务核算的费用统计。** 费用可按模型、用户、上游和成本中心汇总,支持导出 CSV 分摊报表,并给出月末费用预测。按模型设置的权重使价格较高的模型在同一配额中计入更多用量。 -- **审计记录存入 ClickHouse。** 每一次模型请求和工具调用都记录用户、参数、响应、延迟与错误,留存的正文可在写入前按出站脱敏规则脱敏(默认关闭)。审计事件可通过 Syslog、Kafka(经 REST 代理)或签名 Webhook 转发至 SIEM。 +- **审计记录存入 ClickHouse。** 每一次模型请求和工具调用都记录用户、参数、响应、延迟与错误,留存的正文可在写入前按「密钥与隐私」的规则脱敏(默认关闭)。审计事件可通过 Syslog、Kafka(经 REST 代理)或签名 Webhook 转发至 SIEM。 - **所有客户端共用一个入口。** OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 与 Gemini 请求在同一端口提供,并转换为上游所用的格式。路由可按权重、延迟或健康状况分配流量,熔断器会将持续出错的上游移出轮转。 ## 快速开始 @@ -84,11 +84,11 @@ cd web && pnpm install && pnpm dev - 使用每用户凭据的服务器,其响应按用户和账号分别缓存,不会共用。 **安全防护** -- 三项防护各有三档:关闭、观察,以及按其作用命名的第三档——出站脱敏为「替换」,工具调用审查为「切断」,内容过滤为「处置」。新安装时三项都处于观察档:命中只写入审计日志,切换到第三档之前不改动任何请求。 -- 控制台的安全页列出每一条规则,内置规则和自定义规则都在其中。内置规则可以启用或停用,工具调用规则和内容规则可以改变处置,也可以添加自定义规则;切换之前可先用一段文本测试单条规则或整项防护。 -- 出站脱敏查找整个请求,包括系统提示和之前的回答,但不查字段名和 base64 载荷。自定义规则还会跳过协议要求原样保留的值,如各类 id、调用 id、类型、角色、工具名和模型名;内置规则照常查找这些值。自定义规则按原文或按正则表达式查找,默认不区分大小写,也可设为区分大小写。命中的内容替换为 `<>`(凭据为 `SECRET`,个人信息为 `ID_NUMBER`、`CARD_NUMBER`、`EMAIL`、`PHONE`,自定义规则使用自己的标签),并在回答中还原。邮箱地址和手机号规则出厂关闭。 -- 内容规则按关键词、正则表达式或码位(`U+200B`、`U+E0000–U+E007F`)匹配,命中后拒绝请求、从调用方消息和工具结果中删除命中的文字,或仅记录。隐藏字符属于内容规则:Unicode 标签字符和双向控制符出厂开启,零宽字符和私用区字符出厂关闭。 -- 工具调用规则在该调用处切断响应,或仅记录。除危险命令规则外,另有「发送凭据到陌生主机」和「上传本地文件到外部主机」两条内置规则。 +- 三个分项为「密钥与隐私」「危险命令」「内容过滤」,各有开启、监控、关闭三种状态。新安装时三项都处于监控状态:命中写入审计日志并经日志转发器输出,开启之前不改动任何请求;开启后每条规则按各自的动作处理。 +- 控制台的安全页按类别列出每一条规则,内置规则和自定义规则都在其中,可检索,并可按组批量启用或停用。每条规则有各自的动作:密钥与隐私为替换为占位符或仅记录,危险命令为切断或仅记录,内容过滤为拒绝、删除、替换或仅记录。可添加自定义规则;切换之前可先用一段文本测试单条规则或整个分项。安全页的日志列出各分项的命中记录,取自审计日志。 +- 「密钥与隐私」查找整个请求,包括系统提示和之前的回答,但不查字段名和 base64 载荷。自定义规则还会跳过协议要求原样保留的值,如各类 id、调用 id、类型、角色、工具名和模型名;内置规则照常查找这些值。自定义规则按原文或按正则表达式查找,默认不区分大小写,也可设为区分大小写。命中的内容替换为 `<>`(凭据为 `SECRET`,个人信息为 `ID_NUMBER`、`CARD_NUMBER`、`EMAIL`、`PHONE`,自定义规则使用自己的标签),并在回答中还原。邮箱地址和手机号规则出厂关闭。 +- 内容规则按关键词、正则表达式或码位(`U+200B`、`U+E0000–U+E007F`)匹配,可区分大小写;检查对象为请求(调用方消息与工具结果)、回答正文,或二者兼有。命中后拒绝请求、删除命中的文字、替换为规则指定的文本(默认 `***`),或仅记录。回答在流式输出时检查:仅暂缓可能构成命中的末尾片段,删除或替换的内容不会送达客户端。检查回答的规则不可设为拒绝,也不可按码位匹配。隐藏字符属于内容规则:Unicode 标签字符和双向控制符出厂开启,零宽字符和私用区字符出厂关闭。 +- 危险命令规则在该调用处切断响应,或仅记录。除危险命令规则外,另有「发送凭据到陌生主机」和「上传本地文件到外部主机」两条内置规则。 - 模型的「最大输出 token」在模型页设置,限制发往该模型的每个请求的 `max_tokens`,取代原来的输出长度护栏。 **限流与预算** @@ -115,7 +115,7 @@ cd web && pnpm install && pnpm dev ## 基于 ThinkWatch Core -ThinkWatch 使用 [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core)(MIT)中的四个 crate:`tw-dialect` 负责接口格式转换与用量解析,`tw-guard` 负责脱敏、工具调用审查及其他防护,`tw-breaker` 提供熔断状态机,`tw-bedrock` 负责 Amazon Bedrock 的请求签名、事件流解析与模型目录。 +ThinkWatch 使用 [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core)(MIT)中的四个 crate:`tw-dialect` 负责接口格式转换与用量解析,`tw-guard` 负责各项安全防护,`tw-breaker` 提供熔断状态机,`tw-bedrock` 负责 Amazon Bedrock 的请求签名、事件流解析与模型目录。 [ThinkWatch Lite](https://github.com/ThinkWatchProject/ThinkWatch-Lite) 是面向个人开发者的桌面版,在 macOS、Windows 和 Linux 上为 Claude Code、Codex 等客户端提供本地网关(MIT)。 diff --git a/crates/common/src/guard_policy/legacy.rs b/crates/common/src/guard_policy/legacy.rs index 5620cfd3..3480a2a8 100644 --- a/crates/common/src/guard_policy/legacy.rs +++ b/crates/common/src/guard_policy/legacy.rs @@ -302,8 +302,12 @@ fn custom_content(r: &UsableContentRule, name: String) -> CustomContentRule { CustomContentRule { name, pattern: r.pattern.clone(), + // The old filter read requests only, ignoring case. + scope: tw_guard::policy::request_only(), matching: r.matching, + case_sensitive: false, action: r.action, + replacement: tw_guard::policy::DEFAULT_REPLACEMENT.to_string(), disabled: false, } } @@ -498,6 +502,7 @@ pub fn redact(patterns: Option<&Value>) -> RedactPolicy { matching: ContentMatch::Regex, case_sensitive: true, label: label_of(&p.placeholder_prefix), + action: Default::default(), disabled: false, }); } diff --git a/crates/common/src/guard_policy/mod.rs b/crates/common/src/guard_policy/mod.rs index 7530c876..e3f933a8 100644 --- a/crates/common/src/guard_policy/mod.rs +++ b/crates/common/src/guard_policy/mod.rs @@ -17,7 +17,10 @@ //! //! An untouched policy is `{}`: every field left out is the factory value, //! and the factory mode is observe — every hit is recorded, nothing on the -//! wire changes. +//! wire changes. A policy saved before rules had an action of their own +//! (redaction), a scope, a case rule or a replacement (content) reads as +//! it always did: redaction rules replace with placeholders, content rules +//! check requests only and ignore case. use serde::de::DeserializeOwned; use serde_json::Value; @@ -42,6 +45,10 @@ pub fn guard_of(key: &str) -> Option { Guard::ALL.iter().copied().find(|g| self::key(*g) == key) } +/// The longest replacement text a content rule may write, in characters. +/// It goes into every request or answer the rule matches. +pub const REPLACEMENT_CHARS: usize = 200; + /// How many custom rules a policy may hold, and how long one's pattern /// may be, in characters. Every rule runs on every request: a policy past /// these is a mistake, or an attempt to slow the gateway down, rather than @@ -104,6 +111,19 @@ pub fn validate(guard: Guard, value: &Value) -> Result<(), String> { Guard::Content => { let p = parse::(guard, value)?; within_limits(guard, p.custom.iter().map(|c| (&c.name, &c.pattern)))?; + if let Some(c) = p + .custom + .iter() + .find(|c| c.replacement.chars().count() > REPLACEMENT_CHARS) + { + return Err(format!( + "{}: the replacement text of custom rule `{}` is {} characters long; at \ + most {REPLACEMENT_CHARS} are allowed", + key(guard), + c.name, + c.replacement.chars().count() + )); + } p.check() } }; @@ -345,6 +365,112 @@ mod tests { assert!(e.contains("name of a built-in rule"), "{e}"); } + #[test] + fn a_policy_saved_before_the_new_fields_reads_as_it_always_did() { + // As an earlier console wrote them: no actions for redaction, no + // scope, case rule or replacement for content. + let content = json!({"mode": "enforce", "custom": [ + {"name": "Code", "pattern": "project-x", "action": "strip"} + ]}); + assert_eq!(validate(Guard::Content, &content), Ok(())); + let p: ContentPolicy = serde_json::from_value(content.clone()).unwrap(); + let c = &p.custom[0]; + assert!(c.checks_requests() && !c.checks_answers()); + assert!(!c.case_sensitive); + assert_eq!(c.replacement, tw_guard::policy::DEFAULT_REPLACEMENT); + // Written back, the defaults stay out: the stored value is unchanged. + assert_eq!(serde_json::to_value(&p).unwrap(), content); + let rules = content_rules(&p); + let r = rules.rules.iter().find(|r| r.id == "Code").unwrap(); + assert!(r.sides.request && !r.sides.response); + assert!(!r.case_sensitive); + + let redact = json!({"mode": "enforce", "custom": [ + {"name": "Project", "pattern": "PRJ-\\d+", "match": "regex", "label": "PROJECT"} + ]}); + assert_eq!(validate(Guard::Redact, &redact), Ok(())); + let p: RedactPolicy = serde_json::from_value(redact.clone()).unwrap(); + assert_eq!( + p.custom[0].action, + tw_guard::policy::RedactAction::Placeholder + ); + assert!(p.actions.is_empty()); + assert_eq!(serde_json::to_value(&p).unwrap(), redact); + let hits = tw_guard::redact::rules::scan_plain("PRJ-12", &redact_rules(&p)); + assert!( + !redact_rules(&p).records(&hits[0].rule), + "replaced, as before" + ); + } + + #[test] + fn the_new_fields_are_read_and_checked() { + let content = json!({"mode": "enforce", "custom": [{ + "name": "Codename", "pattern": "Polaris", "scope": ["request", "response"], + "case_sensitive": true, "action": "replace", "replacement": "[hidden]" + }]}); + assert_eq!(validate(Guard::Content, &content), Ok(())); + let p: ContentPolicy = serde_json::from_value(content).unwrap(); + let r = &content_rules(&p).rules; + let r = r.iter().find(|r| r.id == "Codename").unwrap(); + assert!(r.sides.request && r.sides.response && r.case_sensitive); + assert_eq!(r.replacement, "[hidden]"); + + // An answer cannot be refused, nor matched by code points. + let e = validate( + Guard::Content, + &json!({"custom": [{"name": "a", "pattern": "x", "scope": ["response"], "action": "block"}]}), + ) + .unwrap_err(); + assert!(e.contains("checks answers and blocks"), "{e}"); + let e = validate( + Guard::Content, + &json!({"custom": [{"name": "a", "pattern": "U+200B", "match": "codepoints", + "scope": ["request", "response"]}]}), + ) + .unwrap_err(); + assert!(e.contains("code points") || e.contains("codepoints"), "{e}"); + let e = validate( + Guard::Content, + &json!({"custom": [{"name": "a", "pattern": "x", "scope": ["answer"]}]}), + ) + .unwrap_err(); + assert!(e.contains("answer"), "{e}"); + let e = validate( + Guard::Content, + &json!({"custom": [{"name": "a", "pattern": "x", "action": "replace", + "replacement": "r".repeat(REPLACEMENT_CHARS + 1)}]}), + ) + .unwrap_err(); + assert!(e.contains("replacement text"), "{e}"); + assert_eq!( + validate( + Guard::Content, + &json!({"custom": [{"name": "a", "pattern": "x", "action": "replace", + "replacement": "字".repeat(REPLACEMENT_CHARS)}]}), + ), + Ok(()) + ); + + // Redaction: built-in actions by id, and a custom rule that records. + let redact = json!({"mode": "enforce", "actions": {"email": "record"}, "custom": [ + {"name": "Project", "pattern": "PRJ-", "action": "record"} + ]}); + assert_eq!(validate(Guard::Redact, &redact), Ok(())); + let p: RedactPolicy = serde_json::from_value(redact).unwrap(); + let rules = redact_rules(&p); + let hits = tw_guard::redact::rules::scan_plain("PRJ-1", &rules); + assert!(rules.records(&hits[0].rule)); + let e = validate( + Guard::Redact, + &json!({"actions": {"no-such-rule": "record"}}), + ) + .unwrap_err(); + assert!(e.contains("no-such-rule"), "{e}"); + let e = validate(Guard::Redact, &json!({"actions": {"email": "replace"}})).unwrap_err(); + assert!(e.starts_with("security.redact: "), "{e}"); + } + #[test] fn a_custom_rule_that_does_not_compile_is_dropped_and_the_rest_run() { let content: ContentPolicy = serde_json::from_value(json!({"custom": [ diff --git a/crates/common/src/pii.rs b/crates/common/src/pii.rs index 7f01cce9..894c9336 100644 --- a/crates/common/src/pii.rs +++ b/crates/common/src/pii.rs @@ -228,6 +228,7 @@ mod tests { matching: tw_guard::policy::ContentMatch::Regex, case_sensitive: true, label: None, + action: Default::default(), disabled: false, }], ..Default::default() diff --git a/crates/gateway/src/content_filter.rs b/crates/gateway/src/content_filter.rs index 1fc06386..1127e6a7 100644 --- a/crates/gateway/src/content_filter.rs +++ b/crates/gateway/src/content_filter.rs @@ -1,29 +1,43 @@ -//! Content filter: what the caller sends, checked against the rules. +//! Content filter: what the caller sends, and the text of the answers, +//! checked against the rules. //! //! The rules, the engine and the verdict are thinkwatch-core's //! (`tw_guard::content`), shared with the desktop gateway: //! -//! - **Which text** — the caller's messages and the tool results inside -//! them, the place an injected instruction most often rides in: a page a -//! tool fetched, a file it read. Not the system prompt (the operator's) -//! and not the model's own turns. -//! - **How a rule matches** — a keyword (case-insensitive), a regex -//! (case-insensitive, size-bounded), or code points (`U+200B`, -//! `U+E0000–U+E007F`): the built-in hidden-character rules are the last -//! kind, characters an editor does not show and a model still reads. +//! - **Which text** — each rule says (`scope`). On the request: the +//! caller's messages and the tool results inside them, the place an +//! injected instruction most often rides in: a page a tool fetched, a +//! file it read; not the system prompt (the operator's) and not the +//! model's earlier turns. On the answer: the text the client is shown, +//! and the input of a freeform tool such as Codex's `apply_patch`; not +//! tool calls (dangerous commands look at those) and not thinking, whose +//! signature a changed word would break. Built-in rules check requests. +//! - **How a rule matches** — a keyword, a regex (size-bounded), both +//! ignoring case unless the rule says otherwise, or code points +//! (`U+200B`, `U+E0000–U+E007F`, requests only): the built-in +//! hidden-character rules are the last kind, characters an editor does +//! not show and a model still reads. //! - **What a hit does in enforce mode** — each rule its own: refuse the -//! request, strip the matched text (from the caller's text only) and send -//! the rest, or record only. Observe mode records every hit and changes -//! nothing. +//! request (requests only), delete the matched text, replace it with the +//! rule's text (`***` by default; not restored), or record only. Observe +//! mode records every hit and changes nothing. //! -//! A request with text stripped goes on as the stripped one: the gateway -//! decodes it again, and redaction, forwarding and the audit row all see -//! what was actually sent. +//! A request with text deleted or replaced goes on as the changed one: the +//! gateway decodes it again, and redaction, forwarding and the audit row +//! all see what was actually sent. +//! +//! **An answer is never held back whole.** It is filtered on its way to +//! the client, in the client's format, after redacted values are put back +//! and before tool calls are inspected: a stream frame by frame +//! (`proxy::shaper`), each text lane holding back only the tail that could +//! still grow into a match, and a whole answer in one pass ([`whole`]). +//! What a rule deletes or replaces never reaches the client. use std::sync::Arc; use think_watch_common::audit::AuditLogger; use tw_dialect::ir::Dialect; +use tw_guard::content::answer::{Answer, AnswerHit, FrameFilter}; use tw_guard::content::{self, Match, Outcome, Rules, ScreenHit, Screening}; use tw_guard::policy::{ContentPolicy, Mode}; @@ -36,16 +50,44 @@ use crate::redaction::Redaction; pub struct ContentFilter { pub mode: Mode, pub rules: Arc, + /// The rules that check answers, compiled for them. `None` when off or + /// when no rule checks answers: then answers are not touched at all. + pub answer: Option>, } impl ContentFilter { pub fn new(policy: &ContentPolicy) -> Self { + let rules = think_watch_common::guard_policy::content_rules(policy); + let answer = if policy.mode.detects() { + Answer::build(&rules) + } else { + None + }; Self { mode: policy.mode, - rules: Arc::new(think_watch_common::guard_policy::content_rules(policy)), + rules: Arc::new(rules), + answer, } } + /// A filter for one streamed answer, frame by frame in the client's + /// format. `None` when no rule checks answers. + pub fn stream(&self, client: Dialect) -> Option { + let answer = self.answer.as_ref()?; + Some(FrameFilter::new(answer.clone(), self.mode.acts(), client)) + } + + /// Filter a whole answer in the client's format: the answer as the + /// client receives it, and every rule that matched. The answer comes + /// back unchanged (and nothing is found) when no rule checks answers. + pub fn whole(&self, client: Dialect, body: Vec) -> (Vec, Vec) { + let Some(answer) = self.answer.as_ref() else { + return (body, Vec::new()); + }; + let (changed, hits) = content::answer::whole(answer, self.mode.acts(), client, &body); + (changed.unwrap_or(body), hits) + } + /// Check a request as the caller sent it, `dialect` being its format. /// Every rule that fires is in the result with what became of it; a /// refusal names the hit that decided it; a request with text @@ -85,34 +127,50 @@ pub fn refusal(hit: &ScreenHit, mask: &Redaction) -> GatewayError { GatewayError::PolicyBlocked(message) } -/// At most this many content filter events per request: the refusing hit -/// first, then what was stripped, then what was only recorded. +/// At most this many content filter events per request and side: the +/// refusing hit first, then what was replaced or deleted, then what was +/// only recorded. pub const RULE_EVENTS_MAX: usize = 20; +fn weight(o: Outcome) -> u8 { + match o { + Outcome::Blocked => 0, + Outcome::Replaced => 1, + Outcome::Stripped => 2, + Outcome::Recorded => 3, + } +} + +/// The audit action of an outcome. +pub fn action_of(o: Outcome) -> &'static str { + match o { + Outcome::Recorded => "gateway.content_flagged", + Outcome::Stripped => "gateway.content_stripped", + Outcome::Replaced => "gateway.content_replaced", + Outcome::Blocked => "gateway.content_blocked", + } +} + /// The hits of a screening in the order their events are written, the /// ones that changed the request first. pub fn by_weight(screening: &Screening) -> Vec<&ScreenHit> { - let weight = |o: Outcome| match o { - Outcome::Blocked => 0, - Outcome::Stripped => 1, - Outcome::Recorded => 2, - }; let mut hits: Vec<&ScreenHit> = screening.hits.iter().collect(); hits.sort_by_key(|h| weight(h.outcome)); hits } /// Record the hits of a screening: an audit event per rule -/// (`gateway.content_flagged`, `gateway.content_stripped` or -/// `gateway.content_blocked`), at most [`RULE_EVENTS_MAX`] of them, a -/// counter, and a log line. +/// (`gateway.content_flagged`, `gateway.content_stripped`, +/// `gateway.content_replaced` or `gateway.content_blocked`, with +/// `direction: request`), at most [`RULE_EVENTS_MAX`] of them, a counter, +/// and a log line. /// /// **No text of the request is written**, not even a masked excerpt: the /// matched text is part of the request body, and the body is for those who /// may read bodies (`logs:read_bodies`), not for everyone who reads the /// audit log. An event says which rule, what became of the request, how /// many matches and whether they were in a tool result. -pub fn record(audit: &AuditLogger, caller: &Caller, screening: &Screening) { +pub fn record(audit: &AuditLogger, caller: &Caller, mode: Mode, screening: &Screening) { let hits = by_weight(screening); if hits.len() > RULE_EVENTS_MAX { tracing::warn!( @@ -130,41 +188,148 @@ pub fn record(audit: &AuditLogger, caller: &Caller, screening: &Screening) { outcome = s.outcome.slug(), in_tool_result = h.in_tool_result, count = h.count, - "content rule matched (text withheld)" + "content rule matched in the request (text withheld)" ); metrics::counter!( "gateway_content_matched_total", "outcome" => s.outcome.slug(), "custom" => if h.custom { "true" } else { "false" }, + "direction" => "request", ) .increment(1); if i >= RULE_EVENTS_MAX { continue; } - let action = match s.outcome { - Outcome::Recorded => "gateway.content_flagged", - Outcome::Stripped => "gateway.content_stripped", - Outcome::Blocked => "gateway.content_blocked", - }; - audit.log(caller.audit(action).detail(serde_json::json!({ + audit.log( + caller + .audit(action_of(s.outcome)) + .detail(serde_json::json!({ + "trace_id": caller.trace_id, + "model": caller.model, + "rule": h.rule, + "rule_name": h.name, + "custom": h.custom, + "action": h.action.slug(), + "outcome": s.outcome.slug(), + "mode": mode.slug(), + "direction": "request", + "in_tool_result": h.in_tool_result, + "count": h.count, + "rules_in_request": hits.len(), + })), + ); + } +} + +/// Fold `more` into `into`: one entry per rule, counts added up. A stream's +/// hits are taken as it goes, so one rule can come back more than once. +pub fn merge_answer_hits(into: &mut Vec, more: Vec) { + for h in more { + match into + .iter_mut() + .find(|x| x.rule == h.rule && x.custom == h.custom) + { + Some(x) => x.count += h.count, + None => into.push(h), + } + } +} + +/// Record what the rules found in an answer: an audit event per rule +/// (`gateway.content_flagged`, `gateway.content_stripped` or +/// `gateway.content_replaced`, with `direction: response`), at most +/// [`RULE_EVENTS_MAX`] of them, a counter, and a log line. +/// +/// Like the request's events, **none carries the answer's text**: the +/// event names the rule, what became of the matches and how many there +/// were. The answer as the client received it is in the request's log row, +/// for those who may read bodies. +pub fn record_answer( + audit: &AuditLogger, + caller: &Caller, + provider: &str, + mode: Mode, + hits: &[AnswerHit], +) { + if hits.is_empty() { + return; + } + let mut ordered: Vec<&AnswerHit> = hits.iter().collect(); + ordered.sort_by_key(|h| weight(h.outcome)); + if ordered.len() > RULE_EVENTS_MAX { + tracing::warn!( + trace_id = %caller.trace_id, + rules = ordered.len(), + written = RULE_EVENTS_MAX, + "more content rules matched one answer than are written to the audit log" + ); + } + for (i, h) in ordered.iter().enumerate() { + tracing::info!( + trace_id = %caller.trace_id, + provider, + rule = %h.rule, + outcome = h.outcome.slug(), + count = h.count, + "content rule matched in the answer (text withheld)" + ); + metrics::counter!( + "gateway_content_matched_total", + "outcome" => h.outcome.slug(), + "custom" => if h.custom { "true" } else { "false" }, + "direction" => "response", + ) + .increment(1); + if i >= RULE_EVENTS_MAX { + continue; + } + let mut detail = serde_json::json!({ "trace_id": caller.trace_id, "model": caller.model, "rule": h.rule, "rule_name": h.name, "custom": h.custom, "action": h.action.slug(), - "outcome": s.outcome.slug(), - "in_tool_result": h.in_tool_result, + "outcome": h.outcome.slug(), + "mode": mode.slug(), + "direction": "response", + "part": "response_text", "count": h.count, - "rules_in_request": hits.len(), - }))); + "rules_in_answer": ordered.len(), + }); + if h.outcome == Outcome::Replaced { + detail["replacement"] = serde_json::json!(h.replacement); + } + audit.log( + caller + .audit(action_of(h.outcome)) + .resource(format!("provider:{provider}")) + .detail(detail), + ); } } +/// Filter a whole answer, as the client will receive it, and record what +/// was found. The answer comes back as the client is to receive it. +pub fn whole( + filter: &ContentFilter, + audit: &AuditLogger, + caller: &Caller, + provider: &str, + client: Dialect, + body: Vec, +) -> Vec { + let (body, hits) = filter.whole(client, body); + record_answer(audit, caller, provider, filter.mode, &hits); + body +} + #[cfg(test)] mod tests { use super::*; - use tw_guard::policy::{ContentAction, ContentMatch, CustomContentRule, RedactPolicy}; + use tw_guard::policy::{ + ContentAction, ContentMatch, ContentScope, CustomContentRule, RedactPolicy, + }; fn filter(mode: Mode, custom: Vec) -> ContentFilter { ContentFilter::new(&ContentPolicy { @@ -183,8 +348,11 @@ mod tests { CustomContentRule { name: name.into(), pattern: pattern.into(), + scope: tw_guard::policy::request_only(), matching, + case_sensitive: false, action, + replacement: tw_guard::policy::DEFAULT_REPLACEMENT.into(), disabled: false, } } @@ -356,4 +524,182 @@ mod tests { assert!(!e.contains(key), "{e}"); assert!(e.contains("sk-an…"), "{e}"); } + + fn answer_rule( + pattern: &str, + action: ContentAction, + scope: Vec, + ) -> CustomContentRule { + CustomContentRule { + scope, + ..rule("Codename", pattern, ContentMatch::Contains, action) + } + } + + #[test] + fn a_replacing_rule_writes_its_text_into_the_request() { + let f = filter( + Mode::Enforce, + vec![CustomContentRule { + replacement: "[hidden]".into(), + ..rule( + "Code", + "project-x", + ContentMatch::Contains, + ContentAction::Replace, + ) + }], + ); + let s = f.screen(Dialect::Chat, &chat("Project-X ships; ask project-x")); + assert!(s.refusal().is_none()); + assert_eq!(s.hits[0].outcome, Outcome::Replaced); + assert_eq!(action_of(s.hits[0].outcome), "gateway.content_replaced"); + let body: serde_json::Value = serde_json::from_slice(s.body.as_ref().unwrap()).unwrap(); + assert_eq!( + body["messages"][1]["content"], + "[hidden] ships; ask [hidden]" + ); + // The replacement goes before what was only recorded, after a refusal. + assert!(weight(Outcome::Blocked) < weight(Outcome::Replaced)); + assert!(weight(Outcome::Replaced) < weight(Outcome::Stripped)); + } + + #[test] + fn a_request_rule_leaves_answers_alone_and_an_answer_rule_leaves_requests_alone() { + let f = filter( + Mode::Enforce, + vec![answer_rule( + "Polaris", + ContentAction::Strip, + vec![ContentScope::Response], + )], + ); + assert!(f.screen(Dialect::Chat, &chat("Polaris")).hits.is_empty()); + let answer = serde_json::json!({"choices": [{"index": 0, + "message": {"role": "assistant", "content": "Polaris ships"}}]}) + .to_string() + .into_bytes(); + let (out, hits) = f.whole(Dialect::Chat, answer.clone()); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["choices"][0]["message"]["content"], " ships"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].outcome, Outcome::Stripped); + + let request_only = filter( + Mode::Enforce, + vec![rule( + "Codename", + "Polaris", + ContentMatch::Contains, + ContentAction::Strip, + )], + ); + assert!(request_only.answer.is_none() && request_only.stream(Dialect::Chat).is_none()); + let (out, hits) = request_only.whole(Dialect::Chat, answer.clone()); + assert_eq!(out, answer, "untouched, byte for byte"); + assert!(hits.is_empty()); + } + + #[test] + fn a_whole_answer_is_filtered_in_each_format_and_tool_calls_are_not() { + let f = filter( + Mode::Enforce, + vec![CustomContentRule { + replacement: "***".into(), + ..answer_rule( + "Polaris", + ContentAction::Replace, + vec![ContentScope::Request, ContentScope::Response], + ) + }], + ); + let anthropic = serde_json::json!({"type": "message", "role": "assistant", "content": [ + {"type": "thinking", "thinking": "Polaris", "signature": "sig"}, + {"type": "text", "text": "About POLARIS: soon."}, + {"type": "tool_use", "id": "t1", "name": "note", "input": {"text": "Polaris"}} + ]}); + let (out, hits) = f.whole(Dialect::Anthropic, anthropic.to_string().into_bytes()); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["content"][1]["text"], "About ***: soon."); + assert_eq!( + v["content"][0]["thinking"], "Polaris", + "thinking is not touched" + ); + assert_eq!( + v["content"][2]["input"]["text"], "Polaris", + "tool calls are not touched" + ); + assert_eq!((hits[0].outcome, hits[0].count), (Outcome::Replaced, 1)); + + let responses = serde_json::json!({"id": "r", "object": "response", "output": [ + {"type": "message", "role": "assistant", "content": [ + {"type": "output_text", "text": "Polaris and polaris"} + ]} + ]}); + let (out, hits) = f.whole(Dialect::Responses, responses.to_string().into_bytes()); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["output"][0]["content"][0]["text"], "*** and ***"); + assert_eq!(hits[0].count, 2); + } + + #[test] + fn observe_finds_the_same_in_answers_and_changes_nothing() { + let f = filter( + Mode::Observe, + vec![answer_rule( + "Polaris", + ContentAction::Strip, + vec![ContentScope::Response], + )], + ); + let answer = serde_json::json!({"choices": [{"index": 0, + "message": {"role": "assistant", "content": "Polaris ships"}}]}) + .to_string() + .into_bytes(); + let (out, hits) = f.whole(Dialect::Chat, answer.clone()); + assert_eq!(out, answer); + assert_eq!(hits[0].outcome, Outcome::Recorded); + assert_eq!(action_of(hits[0].outcome), "gateway.content_flagged"); + + let off = filter( + Mode::Off, + vec![answer_rule( + "Polaris", + ContentAction::Strip, + vec![ContentScope::Response], + )], + ); + assert!(off.answer.is_none()); + } + + #[test] + fn hits_taken_as_a_stream_goes_are_folded_into_one_per_rule() { + let f = filter( + Mode::Enforce, + vec![ + answer_rule( + "Polaris", + ContentAction::Strip, + vec![ContentScope::Response], + ), + CustomContentRule { + name: "Other".into(), + ..answer_rule("Vega", ContentAction::Record, vec![ContentScope::Response]) + }, + ], + ); + let one = |text: &str| { + let body = serde_json::json!({"choices": [{"index": 0, + "message": {"role": "assistant", "content": text}}]}) + .to_string() + .into_bytes(); + f.whole(Dialect::Chat, body).1 + }; + let mut all = Vec::new(); + merge_answer_hits(&mut all, one("Polaris Polaris")); + merge_answer_hits(&mut all, one("Polaris Vega")); + assert_eq!(all.len(), 2); + assert_eq!((all[0].rule.as_str(), all[0].count), ("Codename", 3)); + assert_eq!((all[1].rule.as_str(), all[1].count), ("Other", 1)); + } } diff --git a/crates/gateway/src/guards.rs b/crates/gateway/src/guards.rs index dbe1f41a..3986cabb 100644 --- a/crates/gateway/src/guards.rs +++ b/crates/gateway/src/guards.rs @@ -1,20 +1,27 @@ -//! The request guards in force: outbound redaction, the content filter and -//! tool-call inspection, compiled from their policies (`security.redact`, -//! `security.content`, `security.inspect_tools`; see +//! The request guards in force — secrets and privacy (outbound redaction), +//! the content filter (requests and answer text) and dangerous commands +//! (tool-call inspection) — compiled from their policies +//! (`security.redact`, `security.content`, `security.inspect_tools`; see //! `think_watch_common::guard_policy`). //! //! One snapshot, swapped whole when a policy changes. A request takes it //! once and runs every guard from it, so a change that lands mid-request //! cannot redact one hop with the old rules and restore with the new. //! -//! Every hit is written to the audit log, one event per hit: +//! Every hit is written to the audit log, one event per rule and request +//! (and, for the content filter, per side), so the log forwarders send it +//! on. A hit that is only recorded — the guard monitoring (`mode: +//! observe`), or the rule's action being to record — has an event of its +//! own: //! //! | guard | events | //! |---|---| -//! | content filter | `gateway.content_flagged` (recorded), `gateway.content_stripped`, `gateway.content_blocked` | -//! | outbound redaction | `gateway.redaction_flagged` (observe), `gateway.redaction_replaced` (enforce) | -//! | tool-call inspection | `gateway.tool_call_flagged` (recorded), `gateway.tool_call_blocked` (cut) | +//! | content filter | `gateway.content_flagged` (recorded), `gateway.content_stripped`, `gateway.content_replaced`, `gateway.content_blocked` (requests only); `direction` is `request` or `response` | +//! | secrets and privacy | `gateway.redaction_flagged` (recorded), `gateway.redaction_replaced` (replaced with placeholders) | +//! | dangerous commands | `gateway.tool_call_flagged` (recorded), `gateway.tool_call_blocked` (cut) | //! +//! Each event's detail carries the rule, its action, the outcome and the +//! guard's `mode`. //! **The audit log gets no text of the request.** It is forwarded and read //! far more widely than the request it came from (`logs:read_all`, against //! `logs:read_bodies` for bodies). A content event names the rule and diff --git a/crates/gateway/src/lifecycle/mod.rs b/crates/gateway/src/lifecycle/mod.rs index 2113ebbc..5e706d06 100644 --- a/crates/gateway/src/lifecycle/mod.rs +++ b/crates/gateway/src/lifecycle/mod.rs @@ -49,10 +49,16 @@ pub(crate) struct ChatCompletionSurface; /// A whole answer, in the caller's format. pub struct Completed { - /// As the caller will receive it, except that redaction placeholders - /// are still in place — this is also the form the cache stores, so a - /// later caller can paint in their own values. + /// As the upstream answered, in the caller's format, with redaction + /// placeholders still in place and the answer's text not yet through + /// the content rules — the form the cache stores, so a later caller + /// can paint in their own values and have the rules in force then run + /// on them. pub body: Vec, + /// What the request's log row stores: the answer as the client + /// received it (values restored, text through the content rules), with + /// the values masked again (see [`for_the_log`]). `None`: `body`. + pub logged: Option>, /// Read off the upstream's bytes, whatever format they were in, or /// estimated when they carried none. pub usage: tw_dialect::usage::Usage, @@ -75,10 +81,13 @@ pub struct ChatStreamCaptured { /// `usage` is at least partly an estimate. pub usage_estimated: bool, pub cost_usd: Decimal, - /// The stream assembled into a whole answer, for the cache and the - /// audit row. `None` when it produced nothing or could not be - /// assembled. + /// The stream assembled into a whole answer, in the form the cache + /// keeps (placeholders in place, text not through the content rules). + /// `None` when it produced nothing or could not be assembled. pub assembled: Option>, + /// The same answer as the client received it, masked again for the + /// log row (see [`for_the_log`]). + pub logged: Option>, } /// The in-flight request, captured once at the handler and read by every @@ -218,6 +227,14 @@ pub(crate) fn build_chat_pump( provider.to_string(), ); + // What the content rules find in the answer is recorded by the tail, + // whether the stream ran to its end or the client left. + let answer_hits = shaper.answer_hits(); + let content = guards.content.clone(); + let redaction = guards.redaction.clone(); + let caller = + crate::guards::Caller::of(&request.identity, &request.trace_id, &request.mapped_model); + let mut last_frame = LastFrame::new(client, client_sse); let provider_for_tail = provider.to_string(); let provider = provider.to_string(); @@ -245,6 +262,7 @@ pub(crate) fn build_chat_pump( r.sniffer = Some(tw_dialect::usage::Sniffer::new()); r.collector = Some(wire.collect.collector()); r.errors = Some(ErrorWatch::new(wire.dialect)); + r.ledger = Some(wire.ledger.clone()); } // The hop that answered can have numbered a value of its own. shaper.restore_with(&wire.ledger); @@ -378,18 +396,29 @@ pub(crate) fn build_chat_pump( ) .increment(1); + if let Ok(hits) = answer_hits.lock() { + crate::content_filter::record_answer( + &deps_state.audit, + &caller, + &provider_for_tail, + content.mode, + &hits, + ); + } + // The sniffer exists once the upstream answered. Without an // answer there is nothing to bill. - let (answered, reported, assembled) = match readers_for_tail.lock() { + let (answered, reported, assembled, ledger) = match readers_for_tail.lock() { Ok(mut r) => { let sniffer = r.sniffer.take(); ( sniffer.is_some(), sniffer.and_then(|s| s.finish()), r.collector.take().and_then(|c| c.finish().ok()), + r.ledger.take(), ) } - Err(_) => (false, None, None), + Err(_) => (false, None, None, None), }; // A stream that did not run to its end lost the upstream's final // count with it: the caller left, or the upstream broke off. @@ -410,13 +439,23 @@ pub(crate) fn build_chat_pump( .cost_tracker .calculate_cost(&mapped_model, &priced(&usage)) .await; + // A cache hit hands this back to a caller, so it carries the + // caller's model name like everything else they receive. It stays + // in placeholder form, unfiltered: the content rules run on the + // restored text of each hit. + let assembled = assembled.map(|b| rewrite_model(&b, &mapped_model)); + // The log row gets what the client was shown. (The hits were + // recorded above, from the stream itself.) + let logged = match (&assembled, &ledger) { + (Some(b), Some(l)) => Some(for_the_log(&content, &redaction, l, client, b)), + _ => None, + }; let captured = ChatStreamCaptured { usage, usage_estimated, cost_usd, - // A cache hit hands this back to a caller, so it carries the - // caller's model name like everything else they receive. - assembled: assembled.map(|b| rewrite_model(&b, &mapped_model)), + assembled, + logged, }; Invoked { client_ip: identity.ip_address.clone(), @@ -433,6 +472,29 @@ pub(crate) fn build_chat_pump( (response, tail) } +/// The answer as the client received it, for the request's log row: +/// `body` (placeholder form) with the caller's values restored and the +/// text through the content rules — **the rules always run on restored +/// text**, so a rule never cuts into a placeholder — and then the values +/// masked again with the same placeholders, by the rules and by the ledger +/// ([`crate::redaction::Redaction::mask_for_storage`]), so the row holds no +/// more of the caller's values than it did before answers were filtered. Nothing +/// is recorded here: the hits were recorded on the way to the client. +pub(crate) fn for_the_log( + content: &crate::content_filter::ContentFilter, + redaction: &crate::redaction::Redaction, + ledger: &tw_guard::redact::replace::Ledger, + client: Dialect, + body: &[u8], +) -> Vec { + if content.answer.is_none() { + return body.to_vec(); + } + let restored = crate::redaction::restore_body(ledger, body); + let (filtered, _) = content.whole(client, restored); + redaction.mask_for_storage(filtered, ledger) +} + /// What the pump reads off the upstream's bytes as they pass, for the /// tail. Each reader exists once the upstream answered. #[derive(Default)] @@ -441,6 +503,8 @@ struct Readers { collector: Option, /// `None` once it found an error. errors: Option, + /// The ledger the answering hop restores with, for the log row. + ledger: Option, /// The first error the upstream reported in its stream. said: Option, } @@ -950,7 +1014,7 @@ impl Surface for ChatCompletionSurface { d.insert("client_status".into(), 200.into()); } ( - captured.assembled.as_deref(), + captured.logged.as_deref().or(captured.assembled.as_deref()), captured.cost_usd, status, detail, @@ -962,7 +1026,12 @@ impl Surface for ChatCompletionSurface { .cost_tracker .calculate_cost(&deps.request.mapped_model, &priced(&usage)) .await; - (Some(c.body.as_slice()), cost, 200_i64, None) + ( + Some(c.logged.as_deref().unwrap_or(c.body.as_slice())), + cost, + 200_i64, + None, + ) } CapturedView::Buffered(ChatCompletionOutcome::ShortCircuit(e)) => ( None, @@ -1403,4 +1472,53 @@ mod tests { assert!(!LastFrame::new(Dialect::Gemini, sse).is_in(last)); } } + + /// An answer rule whose text is also in a placeholder's label: run on + /// the placeholder form it would cut `<>` apart. + #[test] + fn the_log_gets_the_restored_answer_filtered_and_masked_again() { + use tw_guard::policy::{ + ContentAction, ContentMatch, ContentPolicy, ContentScope, CustomContentRule, Mode, + RedactPolicy, + }; + let redaction = crate::redaction::Redaction::new(&RedactPolicy { + mode: Mode::Enforce, + enable: vec!["email".into()], + ..Default::default() + }); + let request = serde_json::json!({"messages": [ + {"role": "user", "content": "my email is alice@corp.example.org"} + ]}) + .to_string(); + let (_, ledger) = redaction.look(request.as_bytes()); + let content = crate::content_filter::ContentFilter::new(&ContentPolicy { + mode: Mode::Enforce, + custom: vec![CustomContentRule { + name: "Word".into(), + pattern: "email".into(), + scope: vec![ContentScope::Response], + matching: ContentMatch::Contains, + case_sensitive: false, + action: ContentAction::Strip, + replacement: "***".into(), + disabled: false, + }], + ..Default::default() + }); + let upstream = serde_json::json!({"choices": [{"index": 0, "message": { + "role": "assistant", "content": "my email is <>"}}]}) + .to_string(); + let logged = for_the_log( + &content, + &redaction, + &ledger, + Dialect::Chat, + upstream.as_bytes(), + ); + let v: serde_json::Value = serde_json::from_slice(&logged).unwrap(); + assert_eq!( + v["choices"][0]["message"]["content"], "my is <>", + "the rule ran on the address, the log keeps its placeholder" + ); + } } diff --git a/crates/gateway/src/proxy/generate.rs b/crates/gateway/src/proxy/generate.rs index d14c680d..a23a6675 100644 --- a/crates/gateway/src/proxy/generate.rs +++ b/crates/gateway/src/proxy/generate.rs @@ -24,8 +24,8 @@ //! # The guards, on the bytes the caller sent //! //! The content filter reads the caller's text where the request's own -//! format puts it, and a rule that strips text strips it there: the -//! request goes on as the stripped one, decoded again. Outbound redaction +//! format puts it, and a rule that deletes or replaces text does so there: +//! the request goes on as the changed one, decoded again. Outbound redaction //! then searches the whole request, numbers what it finds once, and every //! hop — forwarded or converted — goes out through it (see //! `crate::redaction`). Both are thinkwatch-core's, shared with the @@ -606,6 +606,7 @@ pub(crate) async fn read_whole( }; Ok(Completed { body: rewrite_model(&body, caller_model), + logged: None, usage, usage_estimated, }) @@ -729,21 +730,23 @@ async fn run( // 3. Content filter, on the caller's text where its own format puts // it. Every hit is an audit event (without the text); a refusal is - // the caller's 403, quoting their words (masked). + // the caller's 403, quoting their words (masked). The rules that + // check answers run on the way back (the shaper for a stream, below + // for a whole answer). let screening = guards.content.screen(surface.dialect, &body); - crate::content_filter::record(&state.audit, &caller, &screening); + crate::content_filter::record(&state.audit, &caller, guards.content.mode, &screening); if let Some(hit) = screening.refusal() { return Err(ctx .emit(crate::content_filter::refusal(hit, &guards.redaction)) .into()); } - // Text was stripped: from here on — decoding, redaction, every hop, - // the audit row — the request is the stripped one. + // Text was deleted or replaced: from here on — decoding, redaction, + // every hop, the audit row — the request is the changed one. let (body, raw) = match screening.body { Some(stripped) => { let raw = serde_json::from_slice(&stripped).map_err(|_| { ctx.emit(GatewayError::TransformError( - "The request could not be read after the content filter removed text from it." + "The request could not be read after the content filter changed text in it." .into(), )) })?; @@ -769,7 +772,7 @@ async fn run( // The audit row keeps what the caller wrote (their at-rest // redaction setting applies on top). let (findings, ledger) = guards.redaction.look(&body); - crate::redaction::record(&state.audit, &caller, guards.redaction.mode, &findings); + crate::redaction::record(&state.audit, &caller, &guards.redaction, &findings); let request_for_audit = body.to_vec(); let outbound_body = if ledger.is_empty() { raw @@ -818,10 +821,18 @@ async fn run( && let Some(cached) = state.cache.get(fp).await { metrics::counter!("gateway_cache_total", "result" => "hit").increment(1); - // With this caller's values in it: what they would run. + // With this caller's values in it: what they would run. A stored + // answer passed the content rules and the inspection in force when + // it was stored, not necessarily the ones in force now. let restored = crate::redaction::restore_body(&ledger, &cached.body); - // A stored answer passed the inspection in force when it was - // stored, not necessarily the one in force now. + let restored = crate::content_filter::whole( + &guards.content, + &state.audit, + &caller, + "cache", + surface.dialect, + restored, + ); if let Some(e) = crate::tool_inspection::check_whole( &guards.tools, &guards.redaction, @@ -853,14 +864,20 @@ async fn run( .await; // Same capture pipeline as a fresh request — redaction toggle, - // byte cap and offload all apply — with the status marked. + // byte cap and offload all apply — with the status marked. The row + // gets what this client was shown, its values masked again. + let logged = if guards.content.answer.is_some() { + guards.redaction.mask_for_storage(restored.clone(), &ledger) + } else { + cached.body.clone() + }; let mut capture = prepare_body_capture( &state.dynamic_config, &guards.redaction, &state.blob_store, &metadata.request_id, &request_for_audit, - Some(&cached.body), + Some(&logged), ) .await; capture.status = Some(BodyCaptureStatus::FromCache.as_str()); @@ -1012,7 +1029,8 @@ async fn run( let deps = snapshot(entry, sel_record); let shaper = StreamShaper::new(mapped_model.clone(), &ledger, surface.dialect) - .hiding_usage(hide_usage); + .hiding_usage(hide_usage) + .filtering(guards.content.stream(surface.dialect)); return Ok(launch_stream_pump( deps, open, @@ -1058,8 +1076,17 @@ async fn run( GatewayErrorResponse::from(e) })?; - // The answer as the caller will receive it, their values restored. + // The answer as the caller will receive it: their values restored, + // then its text through the content rules that check answers. let restored = crate::redaction::restore_body(&answer_ledger, &completed.body); + let restored = crate::content_filter::whole( + &guards.content, + &state.audit, + &caller, + &entry.provider_name, + surface.dialect, + restored, + ); // Tool calls, on the whole answer before any of it has gone out — in // the form the caller would run them. A refusal is the gateway's @@ -1078,7 +1105,17 @@ async fn run( } // Cache fill, audit, breaker and budget debit — the same hooks the - // stream runs in its tail. The cache keeps the placeholder form. + // stream runs in its tail. The cache keeps the placeholder form, + // unfiltered: the content rules run on the restored text of every hit. + // The log row gets what the client was shown, its values masked again. + let completed = Completed { + logged: guards.content.answer.is_some().then(|| { + guards + .redaction + .mask_for_storage(restored.clone(), &answer_ledger) + }), + ..completed + }; let deps = snapshot(entry, sel_record); run_buffered_post_invoke(&deps, completed).await; diff --git a/crates/gateway/src/proxy/shaper.rs b/crates/gateway/src/proxy/shaper.rs index 72121eaf..bb36572c 100644 --- a/crates/gateway/src/proxy/shaper.rs +++ b/crates/gateway/src/proxy/shaper.rs @@ -21,18 +21,35 @@ //! A value goes into text as it reads and into tool arguments, which are //! JSON, escaped. //! +//! **Answer text.** Content rules that check answers run next, on the +//! text with the caller's values back in it — what the client would read — +//! and before tool calls are inspected on the shaper's output. Each text +//! lane holds back only the tail that could still grow into a match, and +//! frames that repeat the whole text (Responses' `output_text.done`, +//! `output_item.done`, `response.completed`) are written as the client +//! received it — thinkwatch-core's `FrameFilter`. The rules found are +//! collected for the request's audit events ([`AnswerHits`]). +//! //! **Usage.** A Chat stream is always sent upstream asking for its usage //! chunk, or there would be nothing to bill. When the caller did not ask //! for it, the shaper takes it back out: the trailing chunk that carries //! only `usage`, and the `"usage": null` the upstream adds to every other //! chunk once asked. +use std::sync::{Arc, Mutex}; + use serde_json::Value; use tw_dialect::frame::{self, Decoder, Frame}; use tw_dialect::ir::Dialect; +use tw_guard::content::answer::{AnswerHit, FrameFilter}; use tw_guard::redact::replace::Ledger; use tw_guard::redact::sse::{FrameRestorer, Synth}; +/// What the content rules found in a streamed answer so far, one entry per +/// rule. Shared with the stream's tail, which writes the audit events +/// whether or not the client stayed to the end. +pub type AnswerHits = Arc>>; + /// Rewrite the model name in a whole (non-streaming) response. pub fn rewrite_model(body: &[u8], model: &str) -> Vec { let Ok(mut v) = serde_json::from_slice::(body) else { @@ -71,6 +88,8 @@ pub struct StreamShaper { model: String, client: Dialect, restorer: Option, + filter: Option, + hits: AnswerHits, hide_usage: bool, } @@ -81,6 +100,8 @@ impl StreamShaper { model, client, restorer: None, + filter: None, + hits: AnswerHits::default(), hide_usage: false, }; shaper.restore_with(redaction); @@ -95,6 +116,18 @@ impl StreamShaper { self.restorer = (!restorer.is_noop()).then_some(restorer); } + /// Filter the answer's text with the content rules that check answers. + /// `None`: no rule does. + pub fn filtering(mut self, filter: Option) -> Self { + self.filter = filter; + self + } + + /// What the content rules found so far, for the stream's tail. + pub fn answer_hits(&self) -> AnswerHits { + self.hits.clone() + } + /// Take the usage the caller did not ask for out of a Chat stream. pub fn hiding_usage(mut self, hide: bool) -> Self { self.hide_usage = hide; @@ -103,7 +136,9 @@ impl StreamShaper { pub fn process(&mut self, chunk: &[u8]) -> Vec { let frames = self.decoder.feed(chunk); - self.write(frames).into_bytes() + let out = self.write(frames).into_bytes(); + self.collect_hits(); + out } /// Frames the gateway writes itself (a refusal that ends the stream), @@ -136,9 +171,23 @@ impl StreamShaper { let frames = self.decoder.flush(); let mut out = self.write(frames); self.drain(&mut out); + self.collect_hits(); out.into_bytes() } + fn collect_hits(&mut self) { + let Some(f) = self.filter.as_mut() else { + return; + }; + let found = f.take_hits(); + if found.is_empty() { + return; + } + if let Ok(mut hits) = self.hits.lock() { + crate::content_filter::merge_answer_hits(&mut hits, found); + } + } + fn write(&mut self, frames: Vec) -> String { let mut out = String::new(); for f in frames { @@ -168,6 +217,11 @@ impl StreamShaper { } if let Some(r) = self.restorer.as_mut() { for s in r.frame(&mut v).before { + self.filtered(s, out); + } + } + if let Some(f) = self.filter.as_mut() { + for s in f.frame(&mut v).before { self.synth(s, out); } } @@ -178,15 +232,32 @@ impl StreamShaper { }); } + /// Every tail still held back, restored values first, then the + /// answer's text — both written before whatever ends the stream. fn drain(&mut self, out: &mut String) { - let Some(r) = self.restorer.as_mut() else { - return; - }; - for s in r.drain() { - self.synth(s, out); + if let Some(r) = self.restorer.as_mut() { + for s in r.drain() { + self.filtered(s, out); + } + } + if let Some(f) = self.filter.as_mut() { + for s in f.drain() { + self.synth(s, out); + } } } + /// A frame the restorer wrote, through the answer filter like any + /// frame of the stream. + fn filtered(&mut self, mut s: Synth, out: &mut String) { + if let Some(f) = self.filter.as_mut() { + for before in f.frame(&mut s.data).before { + self.synth(before, out); + } + } + self.synth(s, out); + } + fn synth(&self, mut s: Synth, out: &mut String) { set_model(&mut s.data, &self.model); out.push_str(&match &s.event { @@ -562,4 +633,196 @@ mod tests { let out = s.process(b"data: [DONE]\n\n"); assert_eq!(out, b"data: [DONE]\n\n"); } + + /// A content filter whose one rule checks answers for `pattern`. + fn answer_filter( + mode: tw_guard::policy::Mode, + pattern: &str, + action: tw_guard::policy::ContentAction, + client: Dialect, + ) -> Option { + crate::content_filter::ContentFilter::new(&tw_guard::policy::ContentPolicy { + mode, + custom: vec![tw_guard::policy::CustomContentRule { + name: "Codename".into(), + pattern: pattern.into(), + scope: vec![tw_guard::policy::ContentScope::Response], + matching: tw_guard::policy::ContentMatch::Contains, + case_sensitive: false, + action, + replacement: "***".into(), + disabled: false, + }], + ..Default::default() + }) + .stream(client) + } + + fn chat_text(out: &[u8]) -> String { + frames(out) + .iter() + .filter_map(|f| { + f["choices"][0]["delta"]["content"] + .as_str() + .map(str::to_string) + }) + .collect() + } + + #[test] + fn a_match_split_across_frames_never_reaches_the_client() { + use tw_guard::policy::{ContentAction, Mode}; + let mut s = + StreamShaper::new("m".into(), &ctx(None), Dialect::Chat).filtering(answer_filter( + Mode::Enforce, + "Polaris", + ContentAction::Replace, + Dialect::Chat, + )); + let hits = s.answer_hits(); + let mut out = Vec::new(); + for piece in ["The Pol", "aris release ", "is near; POLA", "RIS"] { + let bytes = s.process(chat_chunk(piece).as_bytes()); + // No frame that went out carries a piece of the codename. + let sent = chat_text(&bytes).to_lowercase(); + assert!(!sent.contains("pol") || sent.contains("***"), "{sent}"); + out.extend(bytes); + } + out.extend(s.process(b"data: [DONE]\n\n")); + out.extend(s.finish()); + assert_eq!(chat_text(&out), "The *** release is near; ***"); + assert!( + String::from_utf8(out) + .unwrap() + .ends_with("data: [DONE]\n\n"), + "the held tail goes out before the terminator" + ); + let hits = hits.lock().unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].count, 2); + assert_eq!(hits[0].outcome, tw_guard::content::Outcome::Replaced); + } + + #[test] + fn only_the_tail_that_could_still_match_is_held_back() { + use tw_guard::policy::{ContentAction, Mode}; + const TEXT: &str = "a long answer that goes on"; + // How many characters of TEXT reach the client at once, with one + // rule looking for the seven characters of "Polaris". + let sent_at_once = |action: ContentAction| { + let mut s = StreamShaper::new("m".into(), &ctx(None), Dialect::Chat).filtering( + answer_filter(Mode::Enforce, "Polaris", action, Dialect::Chat), + ); + let out = s.process(chat_chunk(TEXT).as_bytes()); + let sent = chat_text(&out); + let mut all = out; + all.extend(s.finish()); + assert_eq!( + chat_text(&all), + TEXT, + "{action:?}: all of it goes out in the end" + ); + sent.chars().count() + }; + let len = TEXT.chars().count(); + // A replacement with text: at most the keyword's length less one. + assert!(sent_at_once(ContentAction::Replace) >= len - 6); + // A deletion holds back as many again: text that comes together + // where something was deleted is checked again. + let strip = sent_at_once(ContentAction::Strip); + assert!(strip >= len - 12, "{strip}"); + assert!(strip <= len - 6, "{strip}"); + // Recording changes nothing, so nothing waits. + assert_eq!(sent_at_once(ContentAction::Record), len); + } + + #[test] + fn a_rule_sees_the_restored_value_and_the_whole_text_frames_agree() { + use tw_guard::policy::{ContentAction, Mode}; + // The caller's address was a placeholder upstream; the rule matches + // what the client would read. + let mut s = StreamShaper::new("m".into(), &ctx(Some("a@x.com")), Dialect::Responses) + .filtering(answer_filter( + Mode::Enforce, + "a@x.com", + ContentAction::Replace, + Dialect::Responses, + )); + let ev = |name: &str, d: Value| format!("event: {name}\ndata: {d}\n\n"); + let mut out = s.process( + ev( + "response.output_text.delta", + serde_json::json!({"type": "response.output_text.delta", "item_id": "m1", + "output_index": 0, "content_index": 0, "delta": "mail <> today"}), + ) + .as_bytes(), + ); + out.extend( + s.process( + ev( + "response.output_text.done", + serde_json::json!({"type": "response.output_text.done", "item_id": "m1", + "output_index": 0, "content_index": 0, "text": "mail <> today"}), + ) + .as_bytes(), + ), + ); + out.extend(s.finish()); + let fs = frames(&out); + let deltas: String = fs + .iter() + .filter(|f| f["type"] == "response.output_text.delta") + .filter_map(|f| f["delta"].as_str().map(str::to_string)) + .collect(); + assert_eq!(deltas, "mail *** today"); + let done = fs + .iter() + .find(|f| f["type"] == "response.output_text.done") + .unwrap(); + assert_eq!( + done["text"], "mail *** today", + "the repeat agrees with what was sent" + ); + } + + #[test] + fn monitoring_passes_the_answer_on_unchanged_and_still_finds() { + use tw_guard::policy::{ContentAction, Mode}; + let mut s = + StreamShaper::new("m".into(), &ctx(None), Dialect::Anthropic).filtering(answer_filter( + Mode::Observe, + "Polaris", + ContentAction::Strip, + Dialect::Anthropic, + )); + let hits = s.answer_hits(); + let ev = |t: &str| { + format!( + "event: content_block_delta\ndata: {}\n\n", + serde_json::json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":t}}) + ) + }; + let first = s.process(ev("Pola").as_bytes()); + // Nothing is held back when nothing would be changed. + assert_eq!(frames(&first)[0]["delta"]["text"], "Pola"); + let mut out = first; + out.extend(s.process(ev("ris").as_bytes())); + out.extend( + s.process( + format!( + "event: content_block_stop\ndata: {}\n\n", + serde_json::json!({"type":"content_block_stop","index":0}) + ) + .as_bytes(), + ), + ); + out.extend(s.finish()); + let text: String = frames(&out) + .iter() + .filter_map(|f| f["delta"]["text"].as_str().map(str::to_string)) + .collect(); + assert_eq!(text, "Polaris"); + let hits = hits.lock().unwrap(); + assert_eq!(hits[0].outcome, tw_guard::content::Outcome::Recorded); + } } diff --git a/crates/gateway/src/redaction.rs b/crates/gateway/src/redaction.rs index 997aec35..03eaa2fe 100644 --- a/crates/gateway/src/redaction.rs +++ b/crates/gateway/src/redaction.rs @@ -16,7 +16,9 @@ //! [`Redaction::replace`] with that same ledger — so a value has one //! placeholder in every hop, converted or not, and two callers sending //! the same structure redact to the same bytes and share a cache slot. -//! - Observe mode finds the same values and replaces none. +//! - Observe mode finds the same values and replaces none, and so does a +//! rule whose action is to record only: its values go out as they are, +//! and there is nothing of theirs to put back. //! //! What is restored on the way back: a whole answer in one pass //! ([`restore_body`]), a stream frame by frame (`proxy::shaper`), where a @@ -69,6 +71,29 @@ impl Redaction { (Vec::from(out), ledger) } + /// Restored text masked again for storage — a log row, never what is + /// sent: every value the rules find gets its placeholder from `ledger` + /// (or a new one), and then **every value `ledger` holds is put back + /// as its placeholder wherever it appears as written**, JSON-escaped + /// once or twice. The rules alone miss a value that needs its context + /// to be recognised — a connection string's password, a custom rule + /// written as `OPENAI_API_KEY=…` — when the model repeats it on its + /// own; the ledger knows the value itself. + pub fn mask_for_storage(&self, body: Vec, ledger: &Ledger) -> Vec { + let (body, ledger) = self.replace(body, ledger); + match std::str::from_utf8(&body) { + Ok(text) => match tw_guard::redact::replace::hide_values( + text, + &ledger, + tw_guard::redact::replace::Form::Json, + ) { + std::borrow::Cow::Borrowed(_) => body, + std::borrow::Cow::Owned(hidden) => hidden.into_bytes(), + }, + Err(_) => body, + } + } + /// `text` with every value the rules find masked (`sk-an…7f9c`, `…1234`), /// whatever the mode: for anything the gateway writes down that quotes /// a request or an answer — an audit event's excerpt, an error that @@ -173,26 +198,30 @@ pub fn per_rule(findings: &[Finding]) -> Vec { out } +/// Whether a rule's values were replaced or only recorded: replaced in +/// enforce mode, unless the rule's action is to record only. +fn replaced(redaction: &Redaction, rule: &Rule) -> bool { + redaction.mode.acts() && !redaction.rules.records(rule) +} + /// Record what was found in a request: an audit event per rule -/// (`gateway.redaction_replaced` in enforce mode, `gateway.redaction_flagged` -/// in observe mode), at most [`RULE_EVENTS_MAX`] of them, and a counter. -/// A value is never written as it is: a built-in rule's event carries a -/// few in masked form, a custom rule's only how many there were. -pub fn record(audit: &AuditLogger, caller: &Caller, mode: Mode, findings: &[Finding]) { +/// (`gateway.redaction_replaced` for values replaced with placeholders, +/// `gateway.redaction_flagged` for values only recorded — in observe mode, +/// or by a rule whose action is to record), at most [`RULE_EVENTS_MAX`] of +/// them, and a counter. A value is never written as it is: a built-in +/// rule's event carries a few in masked form, a custom rule's only how +/// many there were. +pub fn record(audit: &AuditLogger, caller: &Caller, redaction: &Redaction, findings: &[Finding]) { if findings.is_empty() { return; } - let (action, outcome) = if mode.acts() { - ("gateway.redaction_replaced", "replaced") - } else { - ("gateway.redaction_flagged", "recorded") - }; + let mode = redaction.mode; let rules = per_rule(findings); tracing::info!( trace_id = %caller.trace_id, rules = rules.len(), values = findings.len(), - outcome, + mode = mode.slug(), "outbound redaction found values in the request" ); if rules.len() > RULE_EVENTS_MAX { @@ -204,6 +233,11 @@ pub fn record(audit: &AuditLogger, caller: &Caller, mode: Mode, findings: &[Find ); } for r in &rules { + let outcome = if replaced(redaction, &r.rule) { + "replaced" + } else { + "recorded" + }; metrics::counter!( "gateway_redaction_found_total", "kind" => r.rule.kind().slug(), @@ -212,6 +246,11 @@ pub fn record(audit: &AuditLogger, caller: &Caller, mode: Mode, findings: &[Find .increment(r.values as u64); } for r in rules.iter().take(RULE_EVENTS_MAX) { + let (action, outcome) = if replaced(redaction, &r.rule) { + ("gateway.redaction_replaced", "replaced") + } else { + ("gateway.redaction_flagged", "recorded") + }; let mut detail = serde_json::json!({ "trace_id": caller.trace_id, "model": caller.model, @@ -220,7 +259,9 @@ pub fn record(audit: &AuditLogger, caller: &Caller, mode: Mode, findings: &[Find "kind": r.rule.kind().slug(), "values": r.values, "count": r.count, + "action": if redaction.rules.records(&r.rule) { "record" } else { "placeholder" }, "outcome": outcome, + "mode": mode.slug(), "rules_in_request": rules.len(), }); if !r.rule.custom() { @@ -392,6 +433,7 @@ mod tests { matching: tw_guard::policy::ContentMatch::Regex, case_sensitive: true, label: None, + action: Default::default(), disabled: false, }], ..Default::default() @@ -446,4 +488,79 @@ mod tests { let stored = r.redact_blob(&format!(r#"{{"k":"{KEY}"}}"#)); assert_eq!(stored, r#"{"k":"{{REDACTED_anthropic-api-key}}"}"#); } + + #[test] + fn a_rule_that_records_only_is_found_and_reported_but_not_replaced() { + let r = Redaction::new(&RedactPolicy { + mode: Mode::Enforce, + actions: [( + "anthropic-api-key".to_string(), + tw_guard::policy::RedactAction::Record, + )] + .into(), + custom: vec![tw_guard::policy::CustomRedactRule { + name: "project".into(), + pattern: "PRJ-7".into(), + matching: tw_guard::policy::ContentMatch::Contains, + case_sensitive: false, + label: Some("PROJECT".into()), + action: tw_guard::policy::RedactAction::Placeholder, + disabled: false, + }], + ..Default::default() + }); + let body = + serde_json::json!({"messages": [{"role": "user", "content": format!("{KEY} PRJ-7")}]}) + .to_string(); + let (found, ledger) = r.look(body.as_bytes()); + let rules = per_rule(&found); + assert_eq!(rules.len(), 2, "both are found and reported"); + let key = rules + .iter() + .find(|x| x.rule.id() == "anthropic-api-key") + .unwrap(); + let project = rules.iter().find(|x| x.rule.id() == "project").unwrap(); + assert!(!replaced(&r, &key.rule), "the key is only recorded"); + assert!(replaced(&r, &project.rule)); + let (sent, _) = r.replace(body.into_bytes(), &ledger); + let sent = String::from_utf8(sent).unwrap(); + assert!(sent.contains(KEY), "{sent}"); + assert!( + sent.contains("<>") && !sent.contains("PRJ-7"), + "{sent}" + ); + + // Observe mode records every rule. + let observing = Redaction { + mode: Mode::Observe, + ..r.clone() + }; + assert!(!replaced(&observing, &project.rule)); + } + + #[test] + fn a_value_repeated_without_its_context_is_masked_for_storage() { + let r = redaction(Mode::Enforce); + let body = serde_json::json!({"messages": [{"role": "user", + "content": "connect with postgres://app:hunter2@db.internal:5432/orders"}]}) + .to_string(); + let (_, ledger) = r.look(body.as_bytes()); + assert_eq!(ledger.len(), 1, "the password is numbered"); + // The model repeats the password alone: no rule recognises it there. + let answer = + br#"{"content":[{"type":"text","text":"The password <> is wrong."}]}"#; + let restored = restore_body(&ledger, answer); + assert!(String::from_utf8_lossy(&restored).contains("hunter2")); + let (rescanned, _) = r.replace(restored.clone(), &ledger); + assert!( + String::from_utf8_lossy(&rescanned).contains("hunter2"), + "rules alone miss it" + ); + let stored = String::from_utf8(r.mask_for_storage(restored, &ledger)).unwrap(); + assert!(!stored.contains("hunter2"), "{stored}"); + assert!( + stored.contains("The password <> is wrong."), + "{stored}" + ); + } } diff --git a/crates/gateway/src/tool_inspection.rs b/crates/gateway/src/tool_inspection.rs index fb3f86b9..1deb4bf5 100644 --- a/crates/gateway/src/tool_inspection.rs +++ b/crates/gateway/src/tool_inspection.rs @@ -119,6 +119,7 @@ impl StreamInspector { &self.provider, &v, blocked, + self.inspection.mode, &self.mask, ); if blocked { @@ -151,7 +152,7 @@ pub fn check_whole( ) -> Option { for v in inspection.whole(body) { let blocked = inspection.blocks(&v); - record(audit, caller, provider, &v, blocked, mask); + record(audit, caller, provider, &v, blocked, inspection.mode, mask); if blocked { return Some(refusal(&v)); } @@ -173,6 +174,7 @@ pub fn record( provider: &str, v: &Verdict, blocked: bool, + mode: Mode, mask: &Redaction, ) { tracing::warn!( @@ -204,6 +206,7 @@ pub fn record( "why": v.why, "action": if v.cut { "cut" } else { "record" }, "outcome": if blocked { "cut" } else { "recorded" }, + "mode": mode.slug(), "excerpt": mask.mask(&v.excerpt), })), ); diff --git a/crates/server/src/handlers/admin/security.rs b/crates/server/src/handlers/admin/security.rs index 6d63abc5..47bd5b6f 100644 --- a/crates/server/src/handlers/admin/security.rs +++ b/crates/server/src/handlers/admin/security.rs @@ -1,5 +1,7 @@ -//! The request guards in the console: every rule of each guard, and a -//! sample tried against them. +//! The request guards in the console — secrets and privacy (outbound +//! redaction), dangerous commands (tool-call inspection) and the content +//! filter, which checks requests and the text of answers: every rule of +//! each guard, and a sample tried against them. //! //! Both answers are thinkwatch-core's (`tw_guard::view`, `tw_guard::trial`), //! the same JSON the desktop app's control API returns for the same @@ -41,9 +43,11 @@ fn read_permission(guard: Guard) -> &'static str { } } -/// GET /api/admin/security — the three guards: mode and every rule -/// (built-in and custom) with whether it is on, what it does in the third -/// mode and what it does out of the box. +/// GET /api/admin/security — the three guards: mode (on, monitor, off) and +/// every rule (built-in and custom) with whether it is on, what it does +/// while the guard is on and what it does out of the box; a content rule +/// also says what it checks (requests, answers) and what a hit is replaced +/// with. #[utoipa::path( get, path = "/api/admin/security", @@ -67,8 +71,10 @@ pub async fn get_security( /// POST /api/admin/security/{guard}/test — try a sample against one /// guard: its enabled rules, one built-in rule (`rule`, switched off or -/// not), or a rule still being written (`pattern`). Answers every hit, and -/// what the third mode would send (`output`) or whether it would refuse. +/// not), or a rule still being written (`pattern`). A content sample is +/// tried as a request or as an answer (`scope`). Answers every hit, and +/// what the guard would send or pass on while on (`output`), or whether it +/// would refuse. #[utoipa::path( post, path = "/api/admin/security/{guard}/test", @@ -78,7 +84,7 @@ pub async fn get_security( ), request_body( content = inline(serde_json::Value), - description = "sample: string; pattern?, match?, case_sensitive?, rule?, label?, action? (thinkwatch-core's SecurityTestRequest)", + description = "sample: string; pattern?, match?, case_sensitive?, rule?, label?, action?, scope? (`request` or `response`), replacement? (thinkwatch-core's SecurityTestRequest)", ), responses( (status = 200, description = "Hits, output and refusal (thinkwatch-core's SecurityTestResult)", body = serde_json::Value), diff --git a/crates/server/src/handlers/audit.rs b/crates/server/src/handlers/audit.rs index 97ff1ae8..7256f27f 100644 --- a/crates/server/src/handlers/audit.rs +++ b/crates/server/src/handlers/audit.rs @@ -15,6 +15,9 @@ use super::clickhouse_util::*; pub struct AuditLogQuery { pub user_id: Option, pub api_key_id: Option, + /// One action, or several separated by commas (an event matches any + /// of them): the console's security log asks for the guards' events + /// this way. pub action: Option, pub resource: Option, pub ip_address: Option, @@ -45,6 +48,25 @@ struct ChAuditRow { created_at: String, } +/// At most this many actions in one `action` filter. +const ACTIONS_MAX: usize = 32; + +/// The actions an `action` filter names: separated by commas, blanks and +/// repeats left out, at most [`ACTIONS_MAX`]. No action has a comma in its +/// name, so one action is a list of one. +fn actions_of(raw: &str) -> Vec { + let mut out: Vec = Vec::new(); + for a in raw.split(',').map(str::trim).filter(|a| !a.is_empty()) { + if out.len() == ACTIONS_MAX { + break; + } + if !out.iter().any(|o| o == a) { + out.push(a.to_string()); + } + } + out +} + #[utoipa::path( get, path = "/api/audit/logs", @@ -86,8 +108,14 @@ pub async fn list_audit_logs( binds.push(v.clone()); } if let Some(ref v) = query.action { - conditions.push("action = ?".into()); - binds.push(v.clone()); + let actions = actions_of(v); + if !actions.is_empty() { + conditions.push(format!( + "action IN ({})", + vec!["?"; actions.len()].join(", ") + )); + binds.extend(actions); + } } if let Some(ref v) = query.resource { conditions.push("resource = ?".into()); @@ -177,3 +205,25 @@ pub async fn list_audit_logs( Ok(Json(AuditLogResponse { total, items })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_action_filter_names_one_action_or_several() { + assert_eq!(actions_of("settings.update"), ["settings.update"]); + assert_eq!( + actions_of( + "gateway.content_flagged, gateway.content_replaced,,gateway.content_flagged" + ), + ["gateway.content_flagged", "gateway.content_replaced"] + ); + assert!(actions_of(" , ").is_empty()); + let many = (0..40) + .map(|i| format!("a{i}")) + .collect::>() + .join(","); + assert_eq!(actions_of(&many).len(), ACTIONS_MAX); + } +} diff --git a/crates/test-support/tests/content_filter_pii.rs b/crates/test-support/tests/content_filter_pii.rs index 004e1a13..23a20435 100644 --- a/crates/test-support/tests/content_filter_pii.rs +++ b/crates/test-support/tests/content_filter_pii.rs @@ -692,6 +692,28 @@ async fn saving_a_policy_the_gateway_cannot_run_is_refused() { json!({"disable": ["no-such-rule"]}), "no-such-rule", ), + ( + "security.content", + json!({"custom": [{"name": "a", "pattern": "x", "scope": ["response"], "action": "block"}]}), + "checks answers and blocks", + ), + ( + "security.content", + json!({"custom": [{"name": "a", "pattern": "U+200B", "match": "codepoints", + "scope": ["request", "response"]}]}), + "checks answers", + ), + ( + "security.content", + json!({"custom": [{"name": "a", "pattern": "x", "action": "replace", + "replacement": "r".repeat(201)}]}), + "replacement text", + ), + ( + "security.redact", + json!({"actions": {"email": "strip"}}), + "security.redact", + ), ] { let r = con .patch("/api/admin/settings", json!({"settings": {key: value}})) @@ -874,3 +896,540 @@ async fn a_policy_past_the_limits_is_refused() { assert_eq!(r.status.as_u16(), 400, "{}", r.text()); assert!(r.text().contains("built-in rule"), "{}", r.text()); } + +// ---------------------------------------------------------------- answers + +/// A content rule that checks answers, in enforce mode. +fn answer_rule(pattern: &str, action: &str) -> Value { + json!({"mode": "enforce", "custom": [ + {"name": "Codename", "pattern": pattern, "scope": ["response"], "action": action} + ]}) +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_answer_rule_replaces_the_text_on_every_surface_streamed_or_whole() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting("security.content", answer_rule("Polaris", "replace")) + .await; + let upstream = echo_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "cf-answer").await; + + for stream in [false, true] { + for (path, body) in every_surface("cf-answer", "Polaris ships, polaris lands", stream) { + let (status, text) = post_as(&app, &key, &path, &body).await; + assert_eq!(status, 200, "{path} stream={stream}: {text}"); + // The echo answers with the caller's words; the client reads + // them with the codename replaced. + assert!( + !text.to_lowercase().contains("polaris"), + "{path} stream={stream}: {text}" + ); + // A stream may carry it over several frames (and Responses + // repeats it whole at the end); a whole answer in one piece. + assert!(text.contains("***"), "{path} stream={stream}: {text}"); + if !stream { + assert!(text.contains("*** ships, *** lands"), "{path}: {text}"); + } + } + } + // The request is not the rule's: it went out as written. + for r in upstream.received_requests().await { + assert!(String::from_utf8_lossy(&r.body).contains("Polaris ships")); + } + let events = audited(&app, &user_id, "gateway.content_replaced").await; + let e = &events[0]; + assert_eq!(e["rule"], "Codename", "{e}"); + assert_eq!(e["direction"], "response", "{e}"); + assert_eq!(e["part"], "response_text", "{e}"); + assert_eq!(e["outcome"], "replaced"); + assert_eq!(e["mode"], "enforce"); + assert_eq!(e["count"], 2, "{e}"); + assert!( + !e.to_string().contains("ships"), + "no text of the answer: {e}" + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn the_stored_answer_is_the_one_the_client_received() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting("security.content", answer_rule("Polaris", "strip")) + .await; + let upstream = echo_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "cf-stored").await; + for stream in [false, true] { + let (status, text) = post_as( + &app, + &key, + "/v1/chat/completions", + &json!({"model": "cf-stored", "stream": stream, "temperature": 0.5, + "messages": [{"role": "user", "content": "the Polaris plan"}]}), + ) + .await; + assert_eq!(status, 200, "{text}"); + assert!(!text.contains("Polaris"), "{text}"); + } + let ch = app.state.clickhouse.as_ref().expect("ClickHouse wired up"); + let mut bodies: Vec = Vec::new(); + for _ in 0..200 { + bodies = ch + .query("SELECT ifNull(response_body, '') FROM gateway_logs WHERE user_id = ?") + .bind(&user_id) + .fetch_all() + .await + .expect("CH query"); + if bodies.len() >= 2 && bodies.iter().all(|b| !b.is_empty()) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(bodies.len(), 2, "{bodies:?}"); + for b in bodies { + assert!(!b.contains("Polaris"), "{b}"); + assert!(b.contains("the plan"), "{b}"); + } +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn monitoring_an_answer_rule_records_and_changes_nothing() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting( + "security.content", + json!({"mode": "observe", "custom": [ + {"name": "Codename", "pattern": "Polaris", "scope": ["request", "response"], "action": "replace"} + ]}), + ) + .await; + let upstream = echo_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "cf-answer-observe").await; + let (status, text) = post_as( + &app, + &key, + "/v1/chat/completions", + &json!({"model": "cf-answer-observe", "stream": true, + "messages": [{"role": "user", "content": "Polaris ships"}]}), + ) + .await; + assert_eq!(status, 200, "{text}"); + assert!(text.contains("Polaris ships"), "{text}"); + let events = audited(&app, &user_id, "gateway.content_flagged").await; + let mut directions: Vec<&str> = events + .iter() + .map(|e| e["direction"].as_str().unwrap_or("")) + .collect(); + // The events land in batches: wait for both sides. + if directions.len() < 2 { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + let events = audited(&app, &user_id, "gateway.content_flagged").await; + directions = events + .iter() + .map(|e| e["direction"].as_str().unwrap_or("")) + .collect(); + directions.sort_unstable(); + assert_eq!(directions, ["request", "response"], "{events:?}"); + assert!( + events + .iter() + .all(|e| e["mode"] == "observe" && e["outcome"] == "recorded") + ); + assert!( + events.iter().all(|e| e["action"] == "replace"), + "what On would do" + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_replacing_rule_on_requests_writes_its_text_before_the_request_goes_out() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting( + "security.content", + json!({"mode": "enforce", "custom": [ + {"name": "Codename", "pattern": "Polaris", "case_sensitive": true, + "action": "replace", "replacement": "[codename]"} + ]}), + ) + .await; + let upstream = echo_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "cf-request-replace").await; + for stream in [false, true] { + for (path, body) in every_surface("cf-request-replace", "Polaris and polaris", stream) { + let (status, text) = post_as(&app, &key, &path, &body).await; + assert_eq!(status, 200, "{path} stream={stream}: {text}"); + } + } + for r in upstream.received_requests().await { + let body = String::from_utf8_lossy(&r.body); + // Case-sensitive: only the capitalised one. + assert!(body.contains("[codename] and polaris"), "{body}"); + } + let events = audited(&app, &user_id, "gateway.content_replaced").await; + assert_eq!(events[0]["direction"], "request", "{events:?}"); + assert_eq!( + events[0]["replacement"], + Value::Null, + "the request side names the rule only" + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_redaction_rule_that_records_sends_the_value_and_records_it() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting( + "security.redact", + json!({"mode": "enforce", "actions": {"anthropic-api-key": "record"}, "custom": [ + {"name": "ssn", "pattern": "\\d{3}-\\d{2}-\\d{4}", "match": "regex", "label": "SSN"} + ]}), + ) + .await; + let upstream = echo_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "redact-record").await; + let (status, text) = post_as( + &app, + &key, + "/v1/chat/completions", + &json!({"model": "redact-record", "messages": [ + {"role": "user", "content": format!("{KEY} and 123-45-6789")} + ]}), + ) + .await; + assert_eq!(status, 200, "{text}"); + let body = String::from_utf8_lossy(&upstream.received_requests().await[0].body).into_owned(); + assert!(body.contains(KEY), "the key only recorded: {body}"); + assert!( + body.contains("<>") && !body.contains("123-45-6789"), + "{body}" + ); + + let flagged = audited(&app, &user_id, "gateway.redaction_flagged").await; + assert_eq!(flagged[0]["rule"], "anthropic-api-key", "{flagged:?}"); + assert_eq!(flagged[0]["action"], "record"); + assert_eq!(flagged[0]["mode"], "enforce"); + let replaced = audited(&app, &user_id, "gateway.redaction_replaced").await; + assert_eq!(replaced[0]["rule"], "ssn", "{replaced:?}"); + assert_eq!(replaced[0]["action"], "placeholder"); + + // The console's log reads both back in one query. + let con = admin_session(&app).await; + let mut found = Vec::new(); + for _ in 0..100 { + let r: Value = con + .get(&format!( + "/api/audit/logs?user_id={user_id}&action=gateway.redaction_flagged,gateway.redaction_replaced" + )) + .await + .unwrap() + .json() + .unwrap(); + found = r["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["action"].as_str().unwrap().to_string()) + .collect(); + if found.len() >= 2 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + found.sort(); + assert_eq!( + found, + ["gateway.redaction_flagged", "gateway.redaction_replaced"] + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn the_view_and_the_trial_know_what_a_content_rule_checks() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + con.patch( + "/api/admin/settings", + json!({"settings": {"security.content": {"mode": "enforce", "custom": [ + {"name": "Codename", "pattern": "Polaris", "scope": ["request", "response"], + "action": "replace", "replacement": "[x]"} + ]}}}), + ) + .await + .unwrap() + .assert_ok(); + let view: Value = con + .get("/api/admin/security") + .await + .unwrap() + .json() + .unwrap(); + let rules = view["content"]["rules"].as_array().unwrap(); + let mine = rules.iter().find(|r| r["id"] == "Codename").unwrap(); + assert_eq!(mine["scope"], json!(["request", "response"]), "{mine}"); + assert_eq!(mine["replacement"], "[x]", "{mine}"); + assert_eq!(mine["action"], "replace"); + let tags = rules.iter().find(|r| r["id"] == "unicode-tags").unwrap(); + assert_eq!( + tags["scope"], + json!(["request"]), + "built-in rules check requests: {tags}" + ); + let email = view["redact"]["rules"] + .as_array() + .unwrap() + .iter() + .find(|r| r["id"] == "email") + .unwrap(); + assert_eq!(email["default_action"], "placeholder", "{email}"); + + let r: Value = con + .post( + "/api/admin/security/content/test", + json!({"sample": "the Polaris plan", "pattern": "polaris", "action": "replace", + "replacement": "[y]", "scope": "response"}), + ) + .await + .unwrap() + .json() + .unwrap(); + assert_eq!(r["output"], "the [y] plan", "{r}"); +} + +/// An answer rule whose text is also part of a placeholder's label +/// (`email` in `<>`) runs on the restored answer every time: +/// the first answer, every cache hit, and the copy in the log row. The +/// cache keeps the placeholder form, unfiltered; the log row keeps what the +/// client was shown, with the caller's value masked again. +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_answer_rule_runs_on_restored_text_on_a_miss_a_cache_hit_and_in_the_log() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting( + "security.redact", + json!({"mode": "enforce", "enable": ["email"]}), + ) + .await; + app.set_setting("security.content", answer_rule("email", "strip")) + .await; + let upstream = echo_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "cf-cache").await; + // A request each, different once redacted: the cache does not tell a + // streamed request from a whole one, and each is to start with a miss. + let address = |stream: bool| format!("alice{}@corp.example.org", u8::from(stream)); + let said = |stream: bool| if stream { "streamed" } else { "whole" }; + + for stream in [false, true] { + let shown_expected = format!("my is {} ({})", address(stream), said(stream)); + let body = json!({"model": "cf-cache", "stream": stream, "messages": [{"role": "user", + "content": format!("my email is {} ({})", address(stream), said(stream))}]}); + let mut seen = Vec::new(); + // The first call is a miss; the cache is written once the answer + // is done, so a few calls may pass before the first hit. + for _ in 0..20 { + let resp = reqwest::Client::new() + .post(format!("{}/v1/chat/completions", app.gateway_url)) + .bearer_auth(&key) + .json(&body) + .send() + .await + .unwrap(); + let cache = resp + .headers() + .get("x-cache") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = resp.text().await.unwrap(); + let shown: String = if stream { + // A miss streams chunks; a hit replays the whole answer. + text.lines() + .filter_map(|l| l.strip_prefix("data: ")) + .filter_map(|d| serde_json::from_str::(d).ok()) + .map(|v| { + let c = &v["choices"][0]; + c["delta"]["content"] + .as_str() + .or(c["message"]["content"].as_str()) + .unwrap_or("") + .to_string() + }) + .collect() + } else { + let v: Value = serde_json::from_str(&text).unwrap(); + v["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .to_string() + }; + assert_eq!( + shown, shown_expected, + "stream={stream} cache={cache}: {text}" + ); + assert!( + !text.contains("< = Vec::new(); + for _ in 0..200 { + bodies = ch + .query("SELECT ifNull(response_body, '') FROM gateway_logs WHERE user_id = ?") + .bind(&user_id) + .fetch_all() + .await + .expect("CH query"); + if bodies.len() >= 4 && bodies.iter().all(|b| !b.is_empty()) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!(bodies.len() >= 4, "{bodies:?}"); + for b in &bodies { + assert!(b.contains("my is <>"), "{b}"); + assert!(!b.contains("<> is wrong.` +async fn repeating_upstream() -> MockProvider { + let upstream = MockProvider { + server: wiremock::MockServer::start().await, + }; + upstream + .mount( + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(|req: &Request| { + let body: Value = serde_json::from_slice(&req.body).unwrap_or_default(); + let text = String::from_utf8_lossy(&req.body).into_owned(); + let placeholder = text + .find("<>") + .map(|end| text[at..at + end + 2].to_string()) + }) + .unwrap_or_default(); + let said = format!("The password {placeholder} is wrong."); + let mut fake = Value::Null; + fake["messages"] = json!([{"role": "user", "content": said}]); + fake["stream"] = body["stream"].clone(); + let fake_req = Request { + body: fake.to_string().into_bytes(), + ..req.clone() + }; + answer(&fake_req) + }), + ) + .await; + upstream +} + +/// A value its rule recognises only in context — a connection string's +/// password — repeated alone by the model is masked in the log row with +/// its placeholder, on a miss (whole and streamed) and on a cache hit, +/// while the client reads it restored. +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_value_repeated_without_its_context_is_masked_in_the_log_row() { + let app = TestApp::spawn_with_clickhouse().await; + app.set_setting("security.redact", json!({"mode": "enforce"})) + .await; + // Any rule that checks answers: the log row is then the client's copy. + app.set_setting("security.content", answer_rule("unrelated-word", "strip")) + .await; + let upstream = repeating_upstream().await; + let (key, user_id) = seed_route(&app, &upstream.uri(), "cf-context").await; + + let mut calls = 0; + for stream in [false, true] { + let body = json!({"model": "cf-context", "stream": stream, "messages": [{"role": "user", + "content": format!("connect with postgres://app:hunter2@db.internal:5432/orders ({stream})")}]}); + let mut hit = false; + for _ in 0..20 { + let resp = reqwest::Client::new() + .post(format!("{}/v1/chat/completions", app.gateway_url)) + .bearer_auth(&key) + .json(&body) + .send() + .await + .unwrap(); + hit = resp.headers().get("x-cache").and_then(|v| v.to_str().ok()) == Some("HIT"); + let text = resp.text().await.unwrap(); + calls += 1; + // What the client reads: the deltas of a streamed miss, the + // message of a whole answer or of a replayed hit. + let shown: String = text + .lines() + .map(|l| l.strip_prefix("data: ").unwrap_or(l)) + .filter_map(|d| serde_json::from_str::(d).ok()) + .map(|v| { + let c = &v["choices"][0]; + c["delta"]["content"] + .as_str() + .or(c["message"]["content"].as_str()) + .unwrap_or("") + .to_string() + }) + .collect(); + assert_eq!( + shown, "The password hunter2 is wrong.", + "stream={stream} hit={hit}: {text}" + ); + assert!(!text.contains("< = Vec::new(); + for _ in 0..200 { + bodies = ch + .query("SELECT ifNull(response_body, '') FROM gateway_logs WHERE user_id = ?") + .bind(&user_id) + .fetch_all() + .await + .expect("CH query"); + if bodies.len() >= calls && bodies.iter().all(|b| !b.is_empty()) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(bodies.len(), calls, "{bodies:?}"); + for b in &bodies { + assert!(!b.contains("hunter2"), "{b}"); + assert!(b.contains("<>"), "{b}"); + } +} diff --git a/crates/test-support/tests/gateway_responses_ws.rs b/crates/test-support/tests/gateway_responses_ws.rs index f6786b02..4373e722 100644 --- a/crates/test-support/tests/gateway_responses_ws.rs +++ b/crates/test-support/tests/gateway_responses_ws.rs @@ -314,3 +314,46 @@ async fn a_turn_ends_at_its_last_event_while_the_upstream_stream_stays_open() { } assert_eq!(rows, vec![(23, 5, 200), (23, 5, 200)], "gateway_logs rows"); } + +/// A content rule that checks answers works on the socket as on the HTTP +/// stream: the text frames, the whole-text repeats and the conversation +/// the connection keeps all carry the answer as the client received it. +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_answer_rule_filters_each_turn_across_frames() { + let app = TestApp::spawn().await; + app.set_setting( + "security.content", + json!({"mode": "enforce", "custom": [ + {"name": "Greeting", "pattern": "there", "scope": ["response"], + "action": "replace", "replacement": "[x]"} + ]}), + ) + .await; + let upstream = MockProvider::openai_chat_stream_ok("ws-filtered").await; + let (_, key) = seed(&app, &upstream.uri(), "ws-filtered").await; + + let mut socket = connect(&app, &key).await; + for _ in 0..2 { + socket.send(create("ws-filtered", "hi")).await.unwrap(); + let events = turn(&mut socket).await; + assert_eq!(text_of(&events), "hi [x]", "{events:?}"); + for e in &events { + assert!(!e.to_string().contains("there"), "{e}"); + } + let done = events + .iter() + .find(|e| e["type"] == "response.output_text.done") + .expect("output_text.done"); + assert_eq!(done["text"], "hi [x]", "{done}"); + let completed = events.last().unwrap(); + assert_eq!(completed["type"], "response.completed", "{events:?}"); + assert!( + completed["response"]["output"] + .to_string() + .contains("hi [x]"), + "{completed}" + ); + } + socket.close(None).await.unwrap(); +} diff --git a/deploy/helm/think-watch/Chart.yaml b/deploy/helm/think-watch/Chart.yaml index 2d374844..1fd88ef9 100644 --- a/deploy/helm/think-watch/Chart.yaml +++ b/deploy/helm/think-watch/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: think-watch description: Enterprise AI API Gateway & MCP Management Platform type: application -version: 3.7.1 -appVersion: "3.7.1" +version: 3.8.0 +appVersion: "3.8.0" keywords: - ai - gateway diff --git a/web/package.json b/web/package.json index 6af04bbb..29a1097c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "3.7.1", + "version": "3.8.0", "type": "module", "packageManager": "pnpm@12.9.1", "scripts": { diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index ab957254..1faab331 100644 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -37,10 +37,10 @@ const zhPath = join(webRoot, 'src/i18n/zh.json'); // server's English. const GUARD_IDS = ['redact', 'inspect_tools', 'content']; const GUARD_COPY = [ - 'name', 'enforce', 'lead', 'nowObserve', 'nowEnforce', 'effect', 'risk', - 'testTitle', 'createTitle', 'editTitle', 'nameHint', 'namePlaceholder', - 'samplePlaceholder', + 'name', 'lead', 'nowEnforce', 'testTitle', 'createTitle', 'editTitle', + 'nameHint', 'namePlaceholder', 'samplePlaceholder', ]; +const RULE_ACTIONS = ['placeholder', 'cut', 'block', 'strip', 'replace', 'record']; const REDACT_RULE_IDS = [ 'anthropic-api-key', 'openai-project-key', 'openai-api-key', 'github-personal-token', 'github-oauth-token', 'github-server-token', @@ -79,7 +79,13 @@ const DYNAMIC_ENUMS = { 'api-keys', 'private-keys', 'jwt', 'conn-strings', 'personal', 'internal', 'command', 'injection', 'persona', 'chinese', 'invisible', 'custom', ], - 'contentSecurity.ruleAction.${_}': ['cut', 'block', 'strip', 'record'], + 'contentSecurity.ruleAction.${_}': RULE_ACTIONS, + 'contentSecurity.mode.${_}': ['enforce', 'observe', 'off'], + 'contentSecurity.now.${_}': ['observe', 'off'], + 'contentSecurity.scope.${_}': ['request', 'response'], + 'contentSecurity.scopeChoice.${_}': ['request', 'response', 'both'], + 'contentSecurity.dialog.testAsSide.${_}': ['request', 'response'], + 'contentSecurity.dialog.modeNote.${_}': ['observe', 'off'], 'contentSecurity.redactRule.${_}': REDACT_RULE_IDS, 'contentSecurity.toolRule.${_}.name': TOOL_RULE_IDS, 'contentSecurity.toolRule.${_}.why': TOOL_RULE_IDS, @@ -90,8 +96,7 @@ const DYNAMIC_ENUMS = { 'contentSecurity.check.${_}': ['credential-to-network', 'file-to-network', 'thinkwatch-data'], 'contentSecurity.dialog.match.${_}': ['contains', 'regex', 'codepoints'], 'contentSecurity.dialog.regexHint.${_}': GUARD_IDS, - 'contentSecurity.dialog.actionWhat.${_}': ['cut', 'block', 'strip'], - 'contentSecurity.dialog.recordWhat.${_}': ['inspect_tools', 'content'], + 'contentSecurity.dialog.actionWhat.${_}': RULE_ACTIONS, 'limits.surfaceShort_${_}': ['ai_gateway', 'mcp_gateway', 'console'], 'limits.surface_${_}': ['ai_gateway', 'mcp_gateway', 'console'], 'limits.period_${_}': ['daily', 'weekly', 'monthly'], diff --git a/web/src/components/segmented.tsx b/web/src/components/segmented.tsx index 01bef78a..4136ee05 100644 --- a/web/src/components/segmented.tsx +++ b/web/src/components/segmented.tsx @@ -4,6 +4,8 @@ import { cn } from '@/lib/utils'; export interface SegmentedOption { value: T; label: ReactNode; + /** Shown but not choosable; the arrow keys skip it. */ + disabled?: boolean; } /** @@ -38,7 +40,12 @@ export function Segmented({ const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0; if (step === 0 || disabled || options.length === 0) return; e.preventDefault(); - const next = (Math.max(at, 0) + step + options.length) % options.length; + let next = Math.max(at, 0); + for (let tried = 0; tried < options.length; tried++) { + next = (next + step + options.length) % options.length; + if (!options[next].disabled) break; + } + if (options[next].disabled || next === at) return; refs.current[next]?.focus(); onChange(options[next].value); }; @@ -67,7 +74,7 @@ export function Segmented({ role="radio" aria-checked={checked} tabIndex={checked || (at < 0 && i === 0) ? 0 : -1} - disabled={disabled} + disabled={disabled || o.disabled} onClick={() => !checked && onChange(o.value)} className={cn( 'rounded px-2.5 font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50', diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx index 12ed6dc0..dfba4ee4 100644 --- a/web/src/components/ui/checkbox.tsx +++ b/web/src/components/ui/checkbox.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { CheckIcon } from "lucide-react" +import { CheckIcon, MinusIcon } from "lucide-react" import { Checkbox as CheckboxPrimitive } from "radix-ui" import { cn } from "@/lib/utils" @@ -14,7 +14,7 @@ function Checkbox({ - + {props.checked === "indeterminate" ? ( + + ) : ( + + )} ) diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 404fa3b6..6c93da2b 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -118,75 +118,74 @@ "unknown": "Status unknown" }, "contentSecurity": { - "subtitle": "Outbound redaction, tool-call inspection and the content filter, applied to every request through the AI gateway.", + "subtitle": "Keeps credentials and personal information from leaving, stops dangerous commands and filters chosen content, for every request and answer through the AI gateway.", "loadFailed": "The security settings could not be loaded", "summary": { - "enforce": "{{count}} enforcing", - "observe": "{{count}} observing", + "enforce": "{{count}} on", + "observe": "{{count}} monitoring", "off": "{{count}} off" }, - "modeOff": "Off", - "modeObserve": "Observe", + "mode": { + "enforce": "On", + "observe": "Monitor", + "off": "Off" + }, "modeFor": "{{guard}} mode", - "nowOff": "Currently: nothing is checked or recorded.", - "ifEnforced": "After switching to {{mode}}: {{effect}} {{risk}}", + "now": { + "observe": "Monitor: hits are recorded in the audit log; requests and answers are not changed.", + "off": "Off: nothing is checked or recorded." + }, "guard": { "redact": { - "name": "Outbound redaction", - "enforce": "Replace", - "lead": "Before a request is sent, it is searched for credentials and personal information using the rules below.", - "nowObserve": "Currently: what is found is recorded in the audit log, and the request is sent unchanged.", - "nowEnforce": "Currently: what is found is replaced with placeholders before the request is sent, and the placeholders in the response are restored to the original values.", - "effect": "what is found is replaced with placeholders before the request is sent, and the placeholders in the response are restored to the original values.", - "risk": "Once the request content changes, the upstream cache may no longer be hit.", - "testTitle": "Test outbound redaction", - "createTitle": "New redaction rule", - "editTitle": "Edit redaction rule", + "name": "Secrets & privacy", + "lead": "Before a request is sent, these rules look for credentials and personal information so that they are not sent to the model provider.", + "nowEnforce": "On: each hit is handled as its rule says, replaced with a placeholder before the request is sent or recorded only; placeholders in the answer are restored to the original values.", + "testTitle": "Test secrets & privacy rules", + "createTitle": "New secrets & privacy rule", + "editTitle": "Edit secrets & privacy rule", "nameHint": "Shown in the audit log.", "namePlaceholder": "Internal project number", "samplePlaceholder": "Paste some text, such as environment variables that contain a key" }, "inspect_tools": { - "name": "Tool-call inspection", - "enforce": "Cut off", - "lead": "Tool-call arguments returned by the upstream are checked.", - "nowObserve": "Currently: matching calls are recorded in the audit log and returned as usual.", - "nowEnforce": "Currently: calls that match a \"Cut off\" rule never fully reach the client, so they cannot run; calls that match a \"Record only\" rule are returned as usual. Both are recorded in the audit log.", - "effect": "calls that match a \"Cut off\" rule never fully reach the client, so they cannot run.", - "risk": "On a false match, the answer stops at that call.", - "testTitle": "Test tool-call inspection", - "createTitle": "New inspection rule", - "editTitle": "Edit inspection rule", + "name": "Dangerous commands", + "lead": "The tool calls an upstream returns are checked, so that agents do not run high-risk commands.", + "nowEnforce": "On: a call that matches a \"Cut off\" rule never fully reaches the client, so it cannot run; a call that matches a \"Record only\" rule is returned as usual.", + "testTitle": "Test dangerous command rules", + "createTitle": "New dangerous command rule", + "editTitle": "Edit dangerous command rule", "nameHint": "Shown in the audit log.", "namePlaceholder": "Delete cluster resources", "samplePlaceholder": "Paste the arguments of a tool call, such as a command to run" }, "content": { "name": "Content filter", - "enforce": "Enforce", - "lead": "User messages and tool results in each request are checked against the rules below.", - "nowObserve": "Currently: matches are recorded in the audit log, and the request is sent unchanged.", - "nowEnforce": "Currently: a request that matches a \"Refuse\" rule is not sent, and the client is told why; text that matches a \"Delete\" rule is deleted before the request is sent; a request that matches a \"Record only\" rule is sent as usual. All three are recorded in the audit log.", - "effect": "a request that matches a \"Refuse\" rule is not sent, and the client is told why; text that matches a \"Delete\" rule is deleted before the request is sent; a request that matches a \"Record only\" rule is sent as usual. All three are recorded in the audit log.", - "risk": "On a false match, an ordinary request is refused, or ordinary text is deleted.", - "testTitle": "Test the content filter", + "lead": "Requests and the text of answers are checked against these rules, and the words or content they name are refused, deleted or replaced.", + "nowEnforce": "On: each hit is handled as its rule says: refused, deleted, replaced or recorded only.", + "testTitle": "Test content filter rules", "createTitle": "New content rule", "editTitle": "Edit content rule", "nameHint": "Shown in the audit log and in the reason given for a refusal.", "namePlaceholder": "Project codename", - "samplePlaceholder": "Paste a user message or a tool result" + "samplePlaceholder": "Paste a user message, a tool result or a model answer" } }, - "rules": "Rules", - "ruleCount": "{{on}} of {{total}} on", + "ruleCount": "{{on}} of {{total}} rules on.", + "groupCount": "{{on}} of {{total}} on", + "groupToggle": "Turn every rule in \"{{name}}\" on or off", + "search": "Search rules", + "noMatch": "No rule matches the search.", + "monitoring": "Monitoring", + "placeholderIs": "Becomes", + "replaceWith": "Replace with {{text}}", "test": "Test…", "newRule": "New rule", "builtinGroup": "Built-in", "col": { "rule": "Rule", "match": "Match", + "scope": "Checks", "action": "Action", - "replaceWith": "Replaced with", "enabled": "On" }, "kind": { @@ -203,10 +202,21 @@ "invisible": "Hidden characters", "custom": "Custom" }, + "scope": { + "request": "Request", + "response": "Answer" + }, + "scopeChoice": { + "request": "Requests", + "response": "Answers", + "both": "Requests and answers" + }, "ruleAction": { + "placeholder": "Replace with placeholder", "cut": "Cut off", "block": "Refuse", "strip": "Delete", + "replace": "Replace", "record": "Record only" }, "menu": { @@ -216,11 +226,15 @@ "copyAsCustom": "Copy as a custom rule" }, "actionsFor": "Actions for {{name}}", + "actionFor": "Action of \"{{name}}\"", "toggleFor": "Turn on \"{{name}}\"", "toast": { "modeSet": "{{guard}} set to {{mode}}", "ruleOn": "\"{{name}}\" turned on", "ruleOff": "\"{{name}}\" turned off", + "groupOn": "{{count}} rules in \"{{name}}\" turned on", + "groupOff": "{{count}} rules in \"{{name}}\" turned off", + "actionSet": "\"{{name}}\" now does \"{{action}}\"", "ruleCreated": "Rule \"{{name}}\" created", "ruleSaved": "Rule \"{{name}}\" saved", "ruleDeleted": "Rule \"{{name}}\" deleted", @@ -229,6 +243,9 @@ }, "dialog": { "name": "Name", + "scope": "Checks", + "scopeHint": "Requests: user messages and tool results. Answers: the text the model writes, without tool calls or thinking.", + "builtinScope": "Built-in rules check requests only.", "matchKind": "Match by", "match": { "contains": "Contains", @@ -236,40 +253,58 @@ "codepoints": "Code points" }, "caseSensitive": "Case-sensitive", + "caseHintOn": " Case-sensitive.", + "caseHintOff": " Case is ignored.", "patternContains": "Text to find", "patternRegex": "Regular expression", "patternCodepoints": "Code points", - "containsHint": "Searched for in user messages and tool results, ignoring case. Leading and trailing spaces count.", - "redactContainsHint": "Every occurrence of this text is replaced with a placeholder. It is found as written: characters such as . and * have no special meaning, and leading and trailing spaces count.", - "codepointsHint": "A code point or a range of them; separate several with commas, as in U+200B, U+E0000–U+E007F", + "containsHint": "Found as written: characters such as . and * have no special meaning, and leading and trailing spaces count.", + "codepointsHint": "A code point or a range of them; separate several with commas, as in U+200B, U+E0000–U+E007F.", + "codepointsReason": "Code points apply to requests only.", + "regexAnswerHint": "In answers, a regex rule holds back at most 256 characters; of a longer match, the beginning may already have reached the client.", "regexHint": { - "redact": "Everything the pattern matches is replaced with a placeholder.", - "inspect_tools": "Matched against the arguments of each tool call.", - "content": "Matched against user messages and tool results, ignoring case." + "redact": "Matched as a regular expression.", + "inspect_tools": "Matched against the arguments of each tool call, case-sensitive.", + "content": "Matched as a regular expression." }, + "matchSwitched": "The rule checks answers, so matching changed to \"Contains\".", "label": "Placeholder name", - "labelHint": "Replaced with <>. Only capital letters, digits and underscores.", + "labelHint": "A hit becomes <>. Only capital letters, digits and underscores.", "becomes": "Becomes", + "placeholderField": "Placeholder", "action": "Action", "actionWhat": { - "cut": "In {{mode}} mode, a matching call never fully reaches the client, so it cannot run.", - "block": "In {{mode}} mode, a matching request is not sent, and the client is told why.", - "strip": "In {{mode}} mode, the matching text is deleted from user messages and tool results, and the request is sent." - }, - "recordWhat": { - "inspect_tools": "A matching call is returned as usual and recorded in the audit log.", - "content": "A matching request is sent as usual and recorded in the audit log." + "placeholder": "A hit is replaced with a placeholder before the request is sent; the placeholder is restored to the original value where it appears in the answer.", + "cut": "A matching call never fully reaches the client, so it cannot run.", + "block": "The request is not sent upstream, and the client is told why.", + "strip": "A hit is deleted, leaving no mark.", + "replace": "A hit is replaced with the given text, which cannot be undone.", + "record": "Nothing is changed; the hit is recorded in the audit log." }, + "blockReason": "Refusing applies to requests only: an answer streams, and what has reached the client cannot be taken back.", + "actionSwitched": "The rule checks answers, so its action changed to \"Delete\".", + "replacement": "Replacement text", + "replacementHint": "A hit is replaced with this text, which cannot be undone.", + "replacementRequired": "Enter the replacement text", "withFactory": "{{what}} The factory setting is \"{{action}}\".", + "modeNote": { + "observe": "This section is monitoring: rules only record hits and change nothing.", + "off": "This section is off: no rule applies." + }, "state": "State", "on": "On", "off": "Off", "category": "Category: {{kind}}", "sample": "Test text", + "testAs": "Test as", + "testAsSide": { + "request": "Test as a request", + "response": "Test as an answer" + }, "hits": "Matches: {{count}}", "noHit": "No match", - "sent": "Sent in {{mode}} mode", - "refused": "In {{mode}} mode, this request would not be sent.", + "output": "After processing:", + "refused": "This request would not be sent upstream.", "testDesc": "Checks a piece of text against the rules that are on. No request is sent.", "content": "Content", "nameRequired": "Enter a name", @@ -282,6 +317,37 @@ "deleteTitle": "Delete the rule \"{{name}}\"", "deleteDesc": "The rule is no longer checked. Entries already in the audit log are kept." }, + "log": { + "tab": "Log", + "lead": "What the three sections found, read from the audit log; the log forwarders send the same events on. Entries do not contain the text of requests or answers.", + "filter": "Filter by section", + "all": "All", + "total": "{{count}} entries", + "refresh": "Refresh", + "empty": "Nothing has been found yet.", + "loadFailed": "The log could not be loaded", + "page": "Page {{page}} of {{pages}}", + "previous": "Previous page", + "next": "Next page", + "openTrace": "View trace", + "stripped": "Deleted in {{count}} places", + "replaced": "Replaced in {{count}} places", + "col": { + "time": "Time", + "guard": "Section", + "part": "Where", + "outcome": "Handling", + "caller": "Caller", + "trace": "Trace" + }, + "part": { + "request": "Request", + "toolResult": "Request · tool result", + "answerText": "Answer · text", + "toolCall": "Answer · tool call", + "toolCallNamed": "Answer · tool call {{tool}}" + } + }, "matcher": { "prefix": "Starts with {{prefix}}, followed by at least {{n}} characters", "openaiLegacy": "Starts with {{prefix}}, at least {{n}} characters long, with both letters and digits", @@ -1548,8 +1614,8 @@ "logs": "Logs", "log_forwarders": "Log forwarders", "webhooks": "Webhooks", - "content_filter": "Content filter", - "pii_redactor": "PII redactor", + "content_filter": "Content filter & dangerous commands", + "pii_redactor": "Secrets & privacy", "rate_limits": "Rate limits", "settings": "Settings", "system": "System" diff --git a/web/src/i18n/terminology.md b/web/src/i18n/terminology.md index 675ffc36..489d720c 100644 --- a/web/src/i18n/terminology.md +++ b/web/src/i18n/terminology.md @@ -26,12 +26,12 @@ forces a future cleanup. | Rate limit | 限流 | Sliding-window limits in `rate_limit_rules`. | | Webhook outbox | Webhook 出箱 | Durable retry queue. | | Forwarder | 转发器 | Audit log forwarder (syslog / Kafka / webhook). | -| Outbound redaction | 出站脱敏 | Request guard: credentials and personal information become placeholders. Permission resource stays `pii_redactor`. | -| Tool-call inspection | 工具调用审查 | Request guard on the tool calls an upstream returns. | -| Content filter | 内容过滤 | Request guard on user messages and tool results. | -| Off / Observe | 关闭 / 观察 | The first two guard modes. Observe only records hits. | -| Replace / Cut off / Enforce | 替换 / 切断 / 处置 | Third guard mode, named for what it does: redaction, tool-call inspection, content filter. Not 拦截. | -| Refuse / Delete / Record only | 拒绝 / 删除 / 仅记录 | What a content rule does in Enforce. Tool rules: Cut off / Record only. | +| Secrets & privacy | 密钥与隐私 | Guard (config key `security.redact`, formerly "outbound redaction"): credentials and personal information in requests. Permission resource stays `pii_redactor`. | +| Dangerous commands | 危险命令 | Guard (`security.inspect_tools`, formerly "tool-call inspection") on the tool calls an upstream returns. | +| Content filter | 内容过滤 | Guard (`security.content`) on user messages, tool results and, per rule, the text of answers. | +| On / Monitor / Off | 开启 / 监控 / 关闭 | A guard's three states (`enforce` / `observe` / `off`), the same on every guard. Monitor only records hits. | +| Replace with placeholder / Cut off / Refuse / Delete / Replace / Record only | 替换为占位符 / 切断 / 拒绝 / 删除 / 替换 / 仅记录 | Rule actions. Redaction: placeholder or record; dangerous commands: cut off or record; content: refuse (requests only), delete, replace or record. Not 拦截. | +| Checks: Request / Answer | 检查对象:请求 / 回答 | What a content rule checks. Answer = the model's text, not its tool calls or thinking. | | Placeholder | 占位符 | What redaction puts in place of a value: `<>`. | ## Style diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index 294a1fb6..8cbd5555 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -118,75 +118,74 @@ "unknown": "状态未知" }, "contentSecurity": { - "subtitle": "出站脱敏、工具调用审查和内容过滤,作用于经过 AI 网关的全部请求。", + "subtitle": "防止凭据与个人信息外发、拦截危险命令、过滤指定内容,作用于经过 AI 网关的全部请求与回答。", "loadFailed": "安全设置读取失败", "summary": { - "enforce": "{{count}} 项处置", - "observe": "{{count}} 项观察", + "enforce": "{{count}} 项开启", + "observe": "{{count}} 项监控", "off": "{{count}} 项关闭" }, - "modeOff": "关闭", - "modeObserve": "观察", - "modeFor": "{{guard}}的档位", - "nowOff": "当前:不检查,不记录。", - "ifEnforced": "切换到「{{mode}}」后:{{effect}}{{risk}}", + "mode": { + "enforce": "开启", + "observe": "监控", + "off": "关闭" + }, + "modeFor": "{{guard}}的状态", + "now": { + "observe": "监控:命中内容记入审计日志,请求与回答不作修改。", + "off": "关闭:不检查,不记录。" + }, "guard": { "redact": { - "name": "出站脱敏", - "enforce": "替换", - "lead": "请求发出前,按以下规则查找凭据和个人信息。", - "nowObserve": "当前:检出的内容记入审计日志,请求原样发出。", - "nowEnforce": "当前:检出的内容替换为占位符后发出,响应中的占位符还原为原值。", - "effect": "检出的内容替换为占位符后发出,响应中的占位符还原为原值。", - "risk": "请求内容改变后,上游缓存可能无法命中。", - "testTitle": "测试出站脱敏", - "createTitle": "新建脱敏规则", - "editTitle": "编辑脱敏规则", + "name": "密钥与隐私", + "lead": "请求发出前按以下规则查找凭据与个人信息,防止其随请求发送至模型服务商。", + "nowEnforce": "开启:命中内容按各条规则的动作处理,替换为占位符后发出,或仅记录;回答中出现的占位符还原为原值。", + "testTitle": "测试密钥与隐私规则", + "createTitle": "新建密钥与隐私规则", + "editTitle": "编辑密钥与隐私规则", "nameHint": "记入审计日志时显示此名称。", "namePlaceholder": "内部项目号", - "samplePlaceholder": "粘贴一段文本,例如一段含有密钥的环境变量" + "samplePlaceholder": "粘贴一段文本,例如含有密钥的环境变量" }, "inspect_tools": { - "name": "工具调用审查", - "enforce": "切断", - "lead": "检查上游返回的工具调用参数。", - "nowObserve": "当前:命中的调用记入审计日志,照常返回。", - "nowEnforce": "当前:命中「切断」规则的调用不会完整到达客户端,因而无法执行;命中「仅记录」规则的调用照常返回。两类都记入审计日志。", - "effect": "命中「切断」规则的调用不会完整到达客户端,因而无法执行。", - "risk": "误判时,回答会在该调用处中断。", - "testTitle": "测试工具调用审查", - "createTitle": "新建审查规则", - "editTitle": "编辑审查规则", + "name": "危险命令", + "lead": "检查上游返回的工具调用,防止代理执行高风险命令。", + "nowEnforce": "开启:命中「切断」规则的调用不完整送达客户端,客户端无法执行该调用;命中「仅记录」规则的调用照常返回。", + "testTitle": "测试危险命令规则", + "createTitle": "新建危险命令规则", + "editTitle": "编辑危险命令规则", "nameHint": "记入审计日志时显示此名称。", "namePlaceholder": "删除集群资源", - "samplePlaceholder": "粘贴一段工具调用的参数,例如一条要执行的命令" + "samplePlaceholder": "粘贴一段工具调用的参数,例如一条待执行的命令" }, "content": { "name": "内容过滤", - "enforce": "处置", - "lead": "按以下规则检查请求中的用户消息和工具结果。", - "nowObserve": "当前:命中的内容记入审计日志,请求原样发出。", - "nowEnforce": "当前:命中「拒绝」规则的请求不发出,客户端收到拒绝的原因;命中「删除」规则的内容删除后发出;命中「仅记录」规则的请求照常发出。三类都记入审计日志。", - "effect": "命中「拒绝」规则的请求不发出,客户端收到拒绝的原因;命中「删除」规则的内容删除后发出;命中「仅记录」规则的请求照常发出。三类都记入审计日志。", - "risk": "误判时,正常的请求会被拒绝,或正常的文字被删除。", - "testTitle": "测试内容过滤", + "lead": "按以下规则检查请求与回答正文,对指定的词语或内容进行拦截、删除或替换。", + "nowEnforce": "开启:命中内容按各条规则的动作处理:拒绝、删除、替换或仅记录。", + "testTitle": "测试内容过滤规则", "createTitle": "新建内容规则", "editTitle": "编辑内容规则", - "nameHint": "记入审计日志和拒绝原因时显示此名称。", + "nameHint": "记入审计日志及拒绝原因时显示此名称。", "namePlaceholder": "项目代号", - "samplePlaceholder": "粘贴一段用户消息或工具结果" + "samplePlaceholder": "粘贴一段用户消息、工具结果或模型回答" } }, - "rules": "规则", - "ruleCount": "已启用 {{on}} 条,共 {{total}} 条", + "ruleCount": "已启用 {{on}} 条,共 {{total}} 条。", + "groupCount": "已启用 {{on}} 条,共 {{total}} 条", + "groupToggle": "启用或停用「{{name}}」组的全部规则", + "search": "检索规则", + "noMatch": "没有符合条件的规则。", + "monitoring": "监控中", + "placeholderIs": "替换为", + "replaceWith": "替换为 {{text}}", "test": "测试…", "newRule": "新建规则", "builtinGroup": "内置", "col": { "rule": "规则", - "match": "匹配", - "action": "处置", - "replaceWith": "替换为", + "match": "匹配内容", + "scope": "检查对象", + "action": "动作", "enabled": "启用" }, "kind": { @@ -203,10 +202,21 @@ "invisible": "隐藏字符", "custom": "自定义" }, + "scope": { + "request": "请求", + "response": "回答" + }, + "scopeChoice": { + "request": "请求", + "response": "回答", + "both": "请求和回答" + }, "ruleAction": { + "placeholder": "替换为占位符", "cut": "切断", "block": "拒绝", "strip": "删除", + "replace": "替换", "record": "仅记录" }, "menu": { @@ -216,11 +226,15 @@ "copyAsCustom": "复制为自定义规则" }, "actionsFor": "「{{name}}」的操作", + "actionFor": "「{{name}}」的动作", "toggleFor": "启用「{{name}}」", "toast": { - "modeSet": "{{guard}}已切换到「{{mode}}」", + "modeSet": "{{guard}}已切换为「{{mode}}」", "ruleOn": "已启用「{{name}}」", "ruleOff": "已停用「{{name}}」", + "groupOn": "已启用「{{name}}」组的 {{count}} 条规则", + "groupOff": "已停用「{{name}}」组的 {{count}} 条规则", + "actionSet": "「{{name}}」的动作已改为「{{action}}」", "ruleCreated": "已创建规则「{{name}}」", "ruleSaved": "已保存规则「{{name}}」", "ruleDeleted": "已删除规则「{{name}}」", @@ -229,6 +243,9 @@ }, "dialog": { "name": "名称", + "scope": "检查对象", + "scopeHint": "请求:用户消息与工具结果。回答:模型输出的正文,不含工具调用与思考过程。", + "builtinScope": "内置规则只检查请求。", "matchKind": "匹配方式", "match": { "contains": "包含", @@ -236,52 +253,101 @@ "codepoints": "码位" }, "caseSensitive": "区分大小写", + "caseHintOn": "区分大小写。", + "caseHintOff": "不区分大小写。", "patternContains": "匹配内容", "patternRegex": "正则表达式", "patternCodepoints": "码位", - "containsHint": "在用户消息和工具结果中查找这段文字,不区分大小写,首尾空格也参与匹配。", - "redactContainsHint": "这段文字出现的每一处都替换为占位符。按原样查找:. 和 * 等字符没有特殊含义,首尾空格也参与匹配。", - "codepointsHint": "码位或码位范围,多个之间用逗号分隔,如 U+200B, U+E0000–U+E007F", + "containsHint": "按原文查找,「.」「*」等字符不作特殊处理,首尾空格也参与匹配;", + "codepointsHint": "码位或码位范围,多项之间以逗号分隔,如 U+200B, U+E0000–U+E007F。", + "codepointsReason": "码位仅适用于请求。", + "regexAnswerHint": "检查回答时,正则规则至多暂缓 256 个字符;长于此的命中,其起始部分可能已送达客户端。", "regexHint": { - "redact": "匹配到的整段内容替换为占位符。", - "inspect_tools": "按工具调用的参数匹配。", - "content": "在用户消息和工具结果中匹配,不区分大小写。" + "redact": "按正则表达式匹配;", + "inspect_tools": "按工具调用的参数匹配,区分大小写。", + "content": "按正则表达式匹配;" }, + "matchSwitched": "检查对象含回答,匹配方式已改为「包含」。", "label": "占位符名称", - "labelHint": "替换为 <>。只能使用大写字母、数字和下划线。", + "labelHint": "命中内容替换为 <>。仅可使用大写字母、数字和下划线。", "becomes": "替换后为", - "action": "处置", + "placeholderField": "占位符", + "action": "动作", "actionWhat": { - "cut": "在「{{mode}}」档下,命中的调用不会完整到达客户端,因而无法执行。", - "block": "在「{{mode}}」档下,命中的请求不发出,客户端收到拒绝的原因。", - "strip": "在「{{mode}}」档下,命中的文字从用户消息和工具结果中删除后,请求照常发出。" - }, - "recordWhat": { - "inspect_tools": "命中的调用照常返回,只记入审计日志。", - "content": "命中的请求照常发出,只记入审计日志。" + "placeholder": "命中内容替换为占位符后发出;回答中出现该占位符时还原为原值。", + "cut": "命中的调用不完整送达客户端,客户端无法执行该调用。", + "block": "请求不发送至上游,客户端收到拒绝原因。", + "strip": "删除命中内容,不保留任何标记。", + "replace": "命中内容替换为指定的文本,不可还原。", + "record": "不修改内容,命中记入审计日志。" }, + "blockReason": "拒绝仅适用于请求:回答以流式输出,已送达的内容无法撤回。", + "actionSwitched": "检查对象含回答,动作已改为「删除」。", + "replacement": "替换文本", + "replacementHint": "命中内容替换为此文本,不可还原。", + "replacementRequired": "请填写替换文本", "withFactory": "{{what}}出厂设置为「{{action}}」。", + "modeNote": { + "observe": "当前分项处于监控状态,规则仅记录命中,不修改内容。", + "off": "当前分项已关闭,规则不生效。" + }, "state": "状态", "on": "已启用", "off": "已停用", "category": "类别:{{kind}}", "sample": "测试文本", + "testAs": "测试方式", + "testAsSide": { + "request": "按请求测试", + "response": "按回答测试" + }, "hits": "命中 {{count}} 处", "noHit": "未命中", - "sent": "「{{mode}}」档下发出的内容", - "refused": "在「{{mode}}」档下,这个请求不会发出。", + "output": "处理后:", + "refused": "该请求不会发送至上游。", "testDesc": "按当前启用的规则检查一段文本,不发出任何请求。", "content": "内容", "nameRequired": "请填写名称", "nameTaken": "已有同名规则", "patternRequired": "请填写匹配内容", - "badCodepoint": "码位写法不对:{{item}}", - "tooManyCodepoints": "最多 {{max}} 项", - "badLabel": "须以大写字母开头,只含大写字母、数字和下划线,最长 24 个字符。", + "badCodepoint": "码位格式有误:{{item}}", + "tooManyCodepoints": "至多 {{max}} 项", + "badLabel": "须以大写字母开头,仅含大写字母、数字和下划线,至多 24 个字符。", "saveFailed": "未能保存", "deleteTitle": "删除规则「{{name}}」", "deleteDesc": "删除后不再按此规则检查。审计日志中已有的记录保留。" }, + "log": { + "tab": "日志", + "lead": "三个分项的命中记录,取自审计日志;日志转发器同时将其输出。记录不含请求与回答的原文。", + "filter": "按分项筛选", + "all": "全部", + "total": "共 {{count}} 条", + "refresh": "刷新", + "empty": "暂无命中记录。", + "loadFailed": "日志读取失败", + "page": "第 {{page}} 页,共 {{pages}} 页", + "previous": "上一页", + "next": "下一页", + "openTrace": "查看追踪", + "stripped": "删除 {{count}} 处", + "replaced": "替换 {{count}} 处", + "col": { + "time": "时间", + "guard": "分项", + "part": "位置", + "outcome": "处理", + "caller": "调用方", + "trace": "追踪" + }, + "part": { + "request": "请求", + "toolResult": "请求 · 工具结果", + "answerText": "回答 · 正文", + "toolCall": "回答 · 工具调用", + "toolCallNamed": "回答 · 工具调用 {{tool}}" + } + }, "matcher": { "prefix": "{{prefix}} 开头,其后至少 {{n}} 个字符", "openaiLegacy": "{{prefix}} 开头,全长至少 {{n}} 个字符,同时含字母和数字", @@ -1548,8 +1614,8 @@ "logs": "日志", "log_forwarders": "日志转发器", "webhooks": "Webhook", - "content_filter": "内容过滤", - "pii_redactor": "PII 脱敏", + "content_filter": "内容过滤与危险命令", + "pii_redactor": "密钥与隐私", "rate_limits": "速率限制", "settings": "设置", "system": "系统" diff --git a/web/src/lib/security-types.ts b/web/src/lib/security-types.ts index bac65286..50b2d6fa 100644 --- a/web/src/lib/security-types.ts +++ b/web/src/lib/security-types.ts @@ -26,13 +26,24 @@ export const SETTING_KEYS: Record = { export type GuardMode = 'off' | 'observe' | 'enforce'; /** - * What a rule does in the third mode. Tool-call inspection: `cut` / - * `record`; content filter: `block` / `strip` / `record`. Outbound - * redaction has no per-rule action — a hit is always replaced. + * What a rule does when its guard is on (`enforce`); a guard that monitors + * (`observe`) only records, whatever the rule says. Redaction: + * `placeholder` / `record`; tool calls: `cut` / `record`; content: + * `block` / `strip` / `replace` / `record`. */ -export type RuleAction = 'cut' | 'block' | 'strip' | 'record'; +export type RuleAction = 'placeholder' | 'cut' | 'block' | 'strip' | 'replace' | 'record'; +export type RedactAction = 'placeholder' | 'record'; export type ToolAction = 'cut' | 'record'; -export type ContentAction = 'block' | 'strip' | 'record'; +export type ContentAction = 'block' | 'strip' | 'replace' | 'record'; + +/** + * What a content rule checks: the request (the caller's messages and tool + * results) and/or the answer's text (not its tool calls, not its thinking). + */ +export type RuleScope = 'request' | 'response'; + +/** The text a `replace` content rule writes in place of a hit when it names none. */ +export const DEFAULT_REPLACEMENT = '***'; /** How a content rule matches. */ export type ContentMatch = 'contains' | 'regex' | 'codepoints'; @@ -55,8 +66,8 @@ export interface CardNetwork { /** * What a rule matches, for display. Built-in rules use the specific kinds; * custom rules are `regex`, `contains` or `codepoints`. `case_sensitive` - * is left out when false: tool-call regexes are case-sensitive, content - * rules are not, a custom redaction rule is when it says so. A built-in rule's + * is left out when false: tool-call regexes are case-sensitive, a custom + * redaction or content rule is when it says so. A built-in rule's * `codepoints.ranges` is the canonical spelling (`U+200B`, * `U+E0000–U+E007F`); a custom rule's is the items as written, so joining * them with `, ` gives back what is stored. `builtin` is a check @@ -99,12 +110,16 @@ export interface SecurityRuleView { enabled: boolean; /** Whether it is on out of the box. `true` for custom rules. */ on_by_default: boolean; - /** Tool calls and content: what it does in the third mode. */ + /** What it does when the guard is on. */ action?: RuleAction | null; - /** Built-in tool and content rules: the factory action. */ + /** Built-in rules: the factory action. */ default_action?: RuleAction | null; /** Redaction: the placeholder label (`SECRET`, `EMAIL` …), built-in and custom. */ label?: string | null; + /** Content rules: what they check. Built-in rules check requests only. */ + scope?: RuleScope[] | null; + /** Content rules whose action is `replace`: the text a hit becomes. */ + replacement?: string | null; } export interface GuardDetail { @@ -132,18 +147,20 @@ export interface SecurityTestRequest { pattern?: string | null; /** Left out: `contains`. Redaction takes `contains` or `regex` only. */ match?: ContentMatch | null; - /** Redaction only. Left out: ignoring case. */ + /** Redaction and content. Left out: ignoring case. */ case_sensitive?: boolean | null; rule?: string | null; label?: string | null; /** - * What the rule tried (`pattern` or `rule`) does in the third mode; - * `output` and `refused` follow it. Tool calls: `cut` / `record`; - * content: `block` / `strip` / `record`. Never for redaction — the - * server refuses it there. Left out: the saved action (`record` for a - * pattern). + * What the rule tried (`pattern` or `rule`) does when the guard is on; + * `output` and `refused` follow it. Left out: the saved action + * (`placeholder` for a redaction pattern, `record` for the others). */ action?: RuleAction | null; + /** Content: the side the sample is tried as. Left out: the request. */ + scope?: RuleScope | null; + /** Content, `action: replace`: the text a hit becomes. Left out: `***`. */ + replacement?: string | null; } export interface SecurityTestHit { @@ -158,16 +175,17 @@ export interface SecurityTestHit { * characters are drawn as `‹U+200B›`, a run as `‹U+E0049 ×12›`. */ excerpt: string; - /** Tool calls and content: what the rule does in the third mode. */ + /** What the rule does when the guard is on. */ action?: RuleAction | null; } export interface SecurityTestResult { hits: SecurityTestHit[]; /** - * The sample as it would be sent in the third mode: redaction replaced, - * content stripped. `null` when nothing changes, or when the content - * filter would refuse the request. + * The sample as it would be sent (or, tried as an answer, received) with + * the guard on: redaction replaced, content stripped or replaced. `null` + * when nothing changes, or when the content filter would refuse the + * request. */ output: string | null; /** Content filter: a `block` rule matched, so the request would not be sent. */ @@ -190,6 +208,8 @@ export interface CustomRedactRule { case_sensitive?: boolean; /** Placeholder label, `^[A-Z][A-Z0-9_]{0,23}$`. Left out: `SECRET`. */ label?: string; + /** Left out: `placeholder`. */ + action?: RedactAction; disabled?: boolean; } @@ -199,6 +219,8 @@ export interface RedactPolicy { enable?: string[]; /** Built-in rules turned off that are on out of the box. */ disable?: string[]; + /** Built-in rules whose action differs from the factory one. */ + actions?: Record; custom?: CustomRedactRule[]; } @@ -223,10 +245,16 @@ export interface ToolPolicy { export interface CustomContentRule { name: string; pattern: string; + /** Left out: `request`. With `response`, `block` and `codepoints` are refused. */ + scope?: RuleScope[]; /** Left out: `contains`. */ match?: ContentMatch; + /** Left out: ignoring case. */ + case_sensitive?: boolean; /** Left out: `record`. */ action?: ContentAction; + /** `action: replace` only. Left out: `***`. */ + replacement?: string; disabled?: boolean; } diff --git a/web/src/routes/gateway/security/guard-panel.tsx b/web/src/routes/gateway/security/guard-panel.tsx index 6b3da4be..8a4b0f71 100644 --- a/web/src/routes/gateway/security/guard-panel.tsx +++ b/web/src/routes/gateway/security/guard-panel.tsx @@ -1,8 +1,10 @@ -import { Fragment, type KeyboardEvent } from 'react'; +import { Fragment, useState, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { Copy, Eye, FlaskConical, MoreHorizontal, Pencil, Plus, Power, Trash2 } from 'lucide-react'; +import { Copy, Eye, FlaskConical, MoreHorizontal, Pencil, Plus, Power, Search, Trash2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, @@ -10,6 +12,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Table, @@ -21,7 +25,7 @@ import { } from '@/components/ui/table'; import { Segmented } from '@/components/segmented'; import { cn } from '@/lib/utils'; -import type { Guard, GuardDetail, GuardMode, SecurityRuleView } from '@/lib/security-types'; +import type { Guard, GuardDetail, GuardMode, RuleAction, SecurityRuleView } from '@/lib/security-types'; import { Code, Codepoints, MatcherText } from './matcher-text'; import { actionName, @@ -31,19 +35,34 @@ import { modeDot, modeName, ruleWhy, + scopeName, viewName, } from './names'; -import { hasAction, patternOf, placeholder } from './policy'; +import { + ACTIONS, + actionAllowed, + enabledCount, + groupState, + matchesSearch, + patternOf, + placeholder, + ruleScope, +} from './policy'; -const MODES: readonly GuardMode[] = ['off', 'observe', 'enforce']; +/** In the order the spec gives them: on, monitor, off. */ +const MODES: readonly GuardMode[] = ['enforce', 'observe', 'off']; /** What the rule table can do. The page carries all of it out: each one writes or opens a dialog. */ export interface RuleActions { mode: (mode: GuardMode) => void; toggle: (r: SecurityRuleView, enabled: boolean) => void; + /** A group's checkbox: switch every rule shown in it. */ + toggleMany: (rules: SecurityRuleView[], enabled: boolean, group: string) => void; + /** A rule's action, chosen in its row. */ + act: (r: SecurityRuleView, action: RuleAction) => void; /** A built-in rule: view it (and set its action); a custom rule: edit it. */ open: (r: SecurityRuleView) => void; - /** Copy a built-in tool or content rule as a custom rule. */ + /** Copy a built-in rule that has a pattern as a custom rule. */ copy: (r: SecurityRuleView) => void; remove: (r: SecurityRuleView) => void; create: () => void; @@ -51,8 +70,8 @@ export interface RuleActions { } /** - * One guard: its mode, with what the current mode does and what the third - * mode would do, and below it every rule. + * One guard: its state (On, Monitor, Off) with one line on what that state + * does and how many rules are on, then every rule, with a search. */ export function GuardPanel({ guard, @@ -70,18 +89,23 @@ export function GuardPanel({ actions: RuleActions; }) { const { t } = useTranslation(); - const on = detail.rules.filter((r) => r.enabled).length; + const [query, setQuery] = useState(''); return (
- + -
-
- {t('contentSecurity.rules')} -

- {t('contentSecurity.ruleCount', { on, total: detail.rules.length })} -

+
+
+ + setQuery(e.target.value)} + placeholder={t('contentSecurity.search')} + aria-label={t('contentSecurity.search')} + />
- +
@@ -104,30 +135,26 @@ export function GuardPanel({ } /** - * The mode: three joined buttons, then one line on what the current mode - * does — the dot is the same colour as on the tab — and the cost of the - * third mode, said before anyone switches to it. + * The state: three joined buttons, then one line on what the current state + * does — the dot is the same colour as on the tab — and how many rules are + * on. */ function ModeCard({ guard, - mode, + detail, canWrite, onMode, }: { guard: Guard; - mode: GuardMode; + detail: GuardDetail; canWrite: boolean; onMode: (mode: GuardMode) => void; }) { const { t } = useTranslation(); const name = guardName(t, guard); + const mode = detail.mode; const now = - mode === 'off' - ? t('contentSecurity.nowOff') - : mode === 'observe' - ? t(`contentSecurity.guard.${guard}.nowObserve`) - : t(`contentSecurity.guard.${guard}.nowEnforce`); - const risk = t(`contentSecurity.guard.${guard}.risk`); + mode === 'enforce' ? t(`contentSecurity.guard.${guard}.nowEnforce`) : t(`contentSecurity.now.${mode}`); return ( @@ -139,26 +166,20 @@ function ModeCard({ ({ value: m, label: modeName(t, guard, m) }))} + options={MODES.map((m) => ({ value: m, label: modeName(t, m) }))} onChange={onMode} disabled={!canWrite} />
-
-

- - {now} -

-

- {mode === 'enforce' - ? risk - : t('contentSecurity.ifEnforced', { - mode: modeName(t, guard, 'enforce'), - effect: t(`contentSecurity.guard.${guard}.effect`), - risk, - })} -

-
+

+ + + {now} + + {t('contentSecurity.ruleCount', { on: enabledCount(detail.rules), total: detail.rules.length })} + + +

); @@ -176,77 +197,108 @@ function groups(rules: SecurityRuleView[], key: (r: SecurityRuleView) => string) return out; } -/** Clicks on the switch and the menu are not clicks on the row. */ +/** Clicks on the switch, the action and the menu are not clicks on the row. */ const stop = { onClick: (e: { stopPropagation: () => void }) => e.stopPropagation() }; /** - * The rules, grouped: redaction by what the rule finds, tool calls built-in - * then custom, content by kind (hidden characters, instruction override, - * identity and prompts, Chinese phrasing) then custom. + * The rules, grouped: redaction by what the rule finds, dangerous commands + * built-in then custom, content by kind (hidden characters, instruction + * override, identity and prompts, Chinese phrasing) then custom. Each + * group's checkbox switches every rule shown in it; it is a shortcut, not + * a switch of its own. * - * Columns: the rule, what it matches, what it does in the third mode (for - * redaction: the placeholder a hit becomes), whether it is on. A row opens - * the rule; the menu at its end has the rest. + * Columns: whether it is on, the rule, what it matches, what it checks + * (content), and its action, chosen in place. While the guard monitors, the + * action stays as set and says that only recording happens for now. A row + * opens the rule; the menu at its end has the rest. */ function RuleTable({ guard, + mode, rules, + query, canWrite, actions, }: { guard: Guard; + mode: GuardMode; rules: SecurityRuleView[]; + query: string; canWrite: boolean; actions: RuleActions; }) { const { t } = useTranslation(); + 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 columns = content ? 6 : 5; return ( - +
- + + - - + {content && } + + {t('contentSecurity.col.enabled')} {t('contentSecurity.col.rule')} {t('contentSecurity.col.match')} - - {guard === 'redact' ? t('contentSecurity.col.replaceWith') : t('contentSecurity.col.action')} - - {t('contentSecurity.col.enabled')} + {content && {t('contentSecurity.col.scope')}} + {t('contentSecurity.col.action')} {t('common.actions')} - {groups(rules, groupKey).map((g) => ( - - - - {groupTitle(g.key)} - {g.rules.length} - - - {g.rules.map((r) => ( - - ))} - - ))} + {shown.length === 0 && ( + + + {t('contentSecurity.noMatch')} + + + )} + {groups(shown, groupKey).map((g) => { + const title = groupTitle(g.key); + return ( + + + + actions.toggleMany(g.rules, groupState(g.rules) !== true, title)} + disabled={!canWrite} + aria-label={t('contentSecurity.groupToggle', { name: title })} + /> + + + {title} + + {t('contentSecurity.groupCount', { on: enabledCount(g.rules), total: g.rules.length })} + + + + {g.rules.map((r) => ( + + ))} + + ); + })}
); @@ -255,9 +307,9 @@ function RuleTable({ function MatchCell({ guard, r }: { guard: Guard; r: SecurityRuleView }) { const m = r.matcher; if (guard === 'redact') return ; - // Tool and content rules show the pattern itself (that it ignores case - // holds for every rule and is said in the rule's dialog); a check - // implemented in code says what it looks for. + // 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 + // looks for. switch (m.kind) { case 'regex': return {m.pattern}; @@ -270,13 +322,66 @@ function MatchCell({ guard, r }: { guard: Guard; r: SecurityRuleView }) { } } +/** The action a rule takes, chosen in place; plain text without the permission to change it. */ +function ActionCell({ + guard, + mode, + r, + canWrite, + onAct, +}: { + guard: Guard; + mode: GuardMode; + r: SecurityRuleView; + canWrite: boolean; + onAct: (action: RuleAction) => void; +}) { + const { t } = useTranslation(); + const name = viewName(t, guard, r); + const action = r.action ?? 'record'; + const scope = ruleScope(r); + const label = (a: RuleAction) => + a === 'replace' && r.custom + ? t('contentSecurity.replaceWith', { text: r.replacement ?? '***' }) + : actionName(t, a); + return ( +
+ {canWrite ? ( + + ) : ( + {label(action)} + )} + {mode === 'observe' && r.enabled && ( + {t('contentSecurity.monitoring')} + )} +
+ ); +} + function RuleRow({ guard, + mode, r, canWrite, actions, }: { guard: Guard; + mode: GuardMode; r: SecurityRuleView; canWrite: boolean; actions: RuleActions; @@ -293,9 +398,6 @@ function RuleRow({ open(); } }; - // Built-in redaction rules can only be switched; tool and content rules - // also take an action, so their dialog edits. - const editable = canWrite && (r.custom || hasAction(guard)); return ( + + actions.toggle(r, v)} + disabled={!canWrite} + aria-label={t('contentSecurity.toggleFor', { name })} + /> +
{name}
- {why && ( -
- {why} + {guard === 'redact' ? ( +
+ {t('contentSecurity.placeholderIs')} {placeholder(r.label)}
+ ) : ( + why && ( +
+ {why} +
+ ) )} - - {guard === 'redact' ? ( - {placeholder(r.label)} - ) : ( - {actionName(t, r.action ?? 'record')} - )} - + {guard === 'content' && ( + +
+ {ruleScope(r).map((s) => ( + + {scopeName(t, s)} + + ))} +
+
+ )} - actions.toggle(r, v)} - disabled={!canWrite} - aria-label={t('contentSecurity.toggleFor', { name })} - /> + actions.act(r, a)} /> @@ -339,14 +454,14 @@ function RuleRow({ - {editable ? : } - {editable ? t('common.edit') : t('contentSecurity.menu.view')} + {canWrite ? : } + {canWrite ? t('common.edit') : t('contentSecurity.menu.view')} actions.toggle(r, !r.enabled)} disabled={!canWrite}> {r.enabled ? t('contentSecurity.menu.turnOff') : t('contentSecurity.menu.turnOn')} - {!r.custom && hasAction(guard) && pattern !== undefined && ( + {!r.custom && pattern !== undefined && ( <> actions.copy(r)} disabled={!canWrite}> diff --git a/web/src/routes/gateway/security/index.tsx b/web/src/routes/gateway/security/index.tsx index 993aadee..504b2bdc 100644 --- a/web/src/routes/gateway/security/index.tsx +++ b/web/src/routes/gateway/security/index.tsx @@ -21,15 +21,16 @@ import { type SecurityRuleView, } from '@/lib/security-types'; import { GuardPanel, type RuleActions } from './guard-panel'; -import { guardName, modeDot, modeName, viewName } from './names'; +import { LogPanel } from './log-panel'; +import { actionName, guardName, modeDot, modeName, viewName } from './names'; import { customRuleView, - hasAction, patternOf, policyOf, withAction, withCustom, withEnabled, + withEnabledMany, withMode, withoutCustom, type CustomRuleInput, @@ -53,6 +54,11 @@ const READ_PERMISSION: Record = { /** A policy is saved through the settings endpoint: that takes `settings:write` as well as the guard's own. */ const mayWrite = (g: Guard) => hasPermission('settings:write') && hasPermission(WRITE_PERMISSION[g]); +/** The log is read from the audit log, which takes `logs:read_all`. */ +const mayReadLog = () => hasPermission('logs:read_all'); + +type Tab = 'log' | Guard; + type DialogState = | null | { kind: 'custom'; guard: Guard; editing: SecurityRuleView | null; seed?: RuleSeed } @@ -65,13 +71,17 @@ function message(err: unknown, fallback: string): string { } /** - * Request guards: outbound redaction, tool-call inspection and the content - * filter, one tab each. All three apply to every request through the AI + * The security page: a log of what the guards found, then one tab per + * guard — secrets and privacy (outbound redaction), dangerous commands + * (tool-call inspection) and the content filter, which checks requests and + * the text of answers. All three apply to every request through the AI * gateway, whatever the key, model or provider. * - * Each guard has a mode — off, observe, and a third mode named for what it - * does there — and a rule table: built-in rules that can be switched (tool - * and content rules can also take another action) and custom rules. + * Each guard is On, Monitor or Off, and has a rule table: built-in rules, + * grouped, that can be switched one by one or a group at a time, and + * custom rules; every rule has an action of its own, which applies while + * the guard is on. A monitoring guard only records, whatever the actions + * say. Every hit is an audit event, so the log forwarders send it on. * * **Every change is written at once, as the guard's whole policy object** * under its `security.*` settings key, built from the view on screen. @@ -82,7 +92,7 @@ function message(err: unknown, fallback: string): string { export function GatewaySecurityPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); - const [tab, setTab] = useState('redact'); + const [tab, setTab] = useState(() => (mayReadLog() ? 'log' : 'redact')); const [dialog, setDialog] = useState(null); const [deleting, setDeleting] = useState(false); @@ -134,7 +144,7 @@ export function GatewaySecurityPage() { write(guard, withMode(d, mode), true).then( () => toast.success( - t('contentSecurity.toast.modeSet', { guard: guardName(t, guard), mode: modeName(t, guard, mode) }), + t('contentSecurity.toast.modeSet', { guard: guardName(t, guard), mode: modeName(t, mode) }), undoable ? { action: { label: t('contentSecurity.toast.undo'), onClick: () => setMode(guard, before, false) } } : undefined, @@ -159,6 +169,49 @@ export function GatewaySecurityPage() { ); } + /** A group's checkbox: every rule shown in it, in one write. */ + function toggleMany(guard: Guard, rules: SecurityRuleView[], enabled: boolean, group: string, undoable = true) { + const d = current(guard); + if (!d || rules.length === 0) return; + const before = rules.filter((r) => r.enabled !== enabled); + write(guard, withEnabledMany(d, rules, enabled), true).then( + () => + toast.success( + enabled + ? t('contentSecurity.toast.groupOn', { name: group, count: rules.length }) + : t('contentSecurity.toast.groupOff', { name: group, count: rules.length }), + undoable && before.length > 0 + ? { + action: { + label: t('contentSecurity.toast.undo'), + onClick: () => toggleMany(guard, before, !enabled, group, false), + }, + } + : undefined, + ), + failed, + ); + } + + /** A rule's action, chosen in its row. */ + function act(guard: Guard, rule: SecurityRuleView, action: RuleAction, undoable = true) { + const d = current(guard); + 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); + write(guard, withAction(d, rule, action), true).then( + () => + toast.success( + t('contentSecurity.toast.actionSet', { name, action: actionName(t, action) }), + undoable + ? { action: { label: t('contentSecurity.toast.undo'), onClick: () => act(guard, rule, before, false) } } + : undefined, + ), + failed, + ); + } + /** Custom rule dialog: a refusal stays in the dialog, with the server's reason. */ async function saveCustom(guard: Guard, editing: SecurityRuleView | null, input: CustomRuleInput) { const d = current(guard); @@ -175,7 +228,7 @@ export function GatewaySecurityPage() { async function saveAction(guard: Guard, rule: SecurityRuleView, action: RuleAction) { const d = current(guard); if (!d) return; - await write(guard, withAction(d, rule.id, action), false); + await write(guard, withAction(d, rule, action), false); setDialog(null); toast.success(t('contentSecurity.toast.ruleSaved', { name: viewName(t, guard, rule) })); } @@ -215,6 +268,8 @@ export function GatewaySecurityPage() { const actions = (guard: Guard): RuleActions => ({ mode: (mode) => setMode(guard, mode), toggle: (r, enabled) => toggle(guard, r, enabled), + toggleMany: (rules, enabled, group) => toggleMany(guard, rules, enabled, group), + act: (r, action) => act(guard, r, action), open: (r) => setDialog(r.custom ? { kind: 'custom', guard, editing: r } : { kind: 'builtin', guard, rule: r }), copy: (r) => copyAsCustom(guard, r), @@ -270,16 +325,26 @@ export function GatewaySecurityPage() { ) ) : ( - setTab(v as Guard)}> + setTab(v as Tab)}> + {mayReadLog() && ( + + {t('contentSecurity.log.tab')} + + )} {GUARDS.map((g) => ( - {guardName(t, g)} - {modeName(t, g, detail[g].mode)} + {guardName(t, g)} + {modeName(t, detail[g].mode)} ))} + {mayReadLog() && ( + + + + )} {GUARDS.map((g) => ( setDialog(null)} onCopy={ - hasAction(dialog.guard) && patternOf(dialog.rule) + patternOf(dialog.rule) ? () => copyAsCustom(dialog.guard, dialog.rule) : undefined } diff --git a/web/src/routes/gateway/security/log-events.ts b/web/src/routes/gateway/security/log-events.ts new file mode 100644 index 00000000..4ded1547 --- /dev/null +++ b/web/src/routes/gateway/security/log-events.ts @@ -0,0 +1,93 @@ +// The guard events of the audit log, read back for the security page's log. + +import type { TFunction } from 'i18next'; +import { GUARDS, type Guard, type RuleAction } from '@/lib/security-types'; +import { actionName } from './names'; + +/** + * The audit events each guard writes, one per rule that matched in a + * request (see the gateway's `guards` module). The log forwarders push the + * same events out; this is where the console reads them back. + */ +export const GUARD_ACTIONS: Record = { + redact: ['gateway.redaction_flagged', 'gateway.redaction_replaced'], + inspect_tools: ['gateway.tool_call_flagged', 'gateway.tool_call_blocked'], + content: [ + 'gateway.content_flagged', + 'gateway.content_stripped', + 'gateway.content_replaced', + 'gateway.content_blocked', + ], +}; + +/** One row of `GET /api/audit/logs`, as far as the guard events go. */ +export interface GuardEvent { + id: string; + action: string; + user_email?: string | null; + user_id?: string | null; + api_key_id?: string | null; + created_at: string; + detail?: GuardEventDetail | null; +} + +/** What a guard event's `detail` carries. Every field is optional: older events lack the newer ones. */ +export interface GuardEventDetail { + trace_id?: string; + model?: string; + rule?: string; + rule_name?: string; + custom?: boolean; + /** The rule's own action. */ + action?: string; + /** What happened: `recorded`, `replaced`, `stripped`, `blocked`, `cut`. */ + outcome?: string; + /** The guard's state: `observe` (monitor) or `enforce`. */ + mode?: string; + /** Content: `request` or `response`. */ + direction?: string; + in_tool_result?: boolean; + count?: number; + values?: number; + tool?: string; +} + +/** The guard an event belongs to. */ +export function guardOf(action: string): Guard | null { + return GUARDS.find((g) => GUARD_ACTIONS[g].includes(action)) ?? null; +} + +/** Where in the request or the answer an event's hit was. */ +export function partOf(t: TFunction, guard: Guard, d: GuardEventDetail): string { + if (guard === 'inspect_tools') { + return d.tool + ? t('contentSecurity.log.part.toolCallNamed', { tool: d.tool }) + : t('contentSecurity.log.part.toolCall'); + } + if (guard === 'content' && d.direction === 'response') return t('contentSecurity.log.part.answerText'); + if (guard === 'content' && d.in_tool_result) return t('contentSecurity.log.part.toolResult'); + return t('contentSecurity.log.part.request'); +} + +/** + * What became of a hit, in the words of the rule actions: replaced with a + * placeholder, cut off, refused, deleted or replaced (with how many places), + * or recorded only. + */ +export function outcomeOf(t: TFunction, action: string, d: GuardEventDetail): { text: string; tone: RuleAction } { + const n = d.count ?? d.values ?? 0; + switch (action) { + case 'gateway.redaction_replaced': + return { text: actionName(t, 'placeholder'), tone: 'placeholder' }; + case 'gateway.tool_call_blocked': + return { text: actionName(t, 'cut'), tone: 'cut' }; + case 'gateway.content_blocked': + return { text: actionName(t, 'block'), tone: 'block' }; + case 'gateway.content_stripped': + return { text: t('contentSecurity.log.stripped', { count: n }), tone: 'strip' }; + case 'gateway.content_replaced': + return { text: t('contentSecurity.log.replaced', { count: n }), tone: 'replace' }; + default: + return { text: actionName(t, 'record'), tone: 'record' }; + } +} diff --git a/web/src/routes/gateway/security/log-panel.tsx b/web/src/routes/gateway/security/log-panel.tsx new file mode 100644 index 00000000..a3fd2474 --- /dev/null +++ b/web/src/routes/gateway/security/log-panel.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from '@tanstack/react-router'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { AlertCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +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 { actionTone, guardName, ruleName } from './names'; + +const PAGE_SIZE = 50; + +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. + */ +export function LogPanel() { + const { t } = useTranslation(); + const [filter, setFilter] = useState('all'); + const [page, setPage] = useState(0); + + const actions = filter === 'all' ? GUARDS.flatMap((g) => GUARD_ACTIONS[g]) : GUARD_ACTIONS[filter]; + const query = useQuery({ + queryKey: ['audit', 'logs', 'guards', { filter, page }], + queryFn: ({ signal }) => { + const params = new URLSearchParams({ + action: actions.join(','), + limit: String(PAGE_SIZE), + offset: String(page * PAGE_SIZE), + }); + return api<{ items: GuardEvent[]; total: number }>(`/api/audit/logs?${params}`, { signal }); + }, + placeholderData: keepPreviousData, + }); + const items = query.data?.items ?? []; + const total = query.data?.total ?? 0; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( + + +
+ ({ value: g as Filter, label: guardName(t, g) })), + ]} + onChange={(f) => { + setFilter(f); + setPage(0); + }} + /> +
+ {t('contentSecurity.log.total', { count: total })} + +
+
+

{t('contentSecurity.log.lead')}

+
+ + {query.isError && !query.data ? ( + + + + {t('contentSecurity.log.loadFailed')}: {query.error.message} + + + ) : query.isPending ? ( +
+ + + +
+ ) : items.length === 0 ? ( +

{t('contentSecurity.log.empty')}

+ ) : ( + + + + + + + + + + + + + {t('contentSecurity.log.col.time')} + {t('contentSecurity.log.col.guard')} + {t('contentSecurity.col.rule')} + {t('contentSecurity.log.col.part')} + {t('contentSecurity.log.col.outcome')} + {t('contentSecurity.log.col.caller')} + {t('contentSecurity.log.col.trace')} + + + + {items.map((e) => ( + + ))} + +
+ )} + {pages > 1 && ( +
+ {t('contentSecurity.log.page', { page: page + 1, pages })} + + +
+ )} +
+
+ ); +} + +function EventRow({ e }: { e: GuardEvent }) { + const { t } = 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 when = new Date(e.created_at); + return ( + + + {Number.isNaN(when.getTime()) ? e.created_at : when.toLocaleString()} + + {guardName(t, guard)} + + {rule} + {d.model &&
{d.model}
} +
+ {partOf(t, guard, d)} + + {outcome.text} + {d.mode === 'observe' && ( + {t('contentSecurity.monitoring')} + )} + + + {e.user_email ?? e.api_key_id ?? '—'} + + + {d.trace_id ? ( + + {t('contentSecurity.log.openTrace')} + + ) : ( + '—' + )} + +
+ ); +} diff --git a/web/src/routes/gateway/security/names.ts b/web/src/routes/gateway/security/names.ts index 41aa257d..5f39f928 100644 --- a/web/src/routes/gateway/security/names.ts +++ b/web/src/routes/gateway/security/names.ts @@ -1,5 +1,5 @@ import type { TFunction } from 'i18next'; -import type { Guard, GuardMode, RuleAction, SecurityRuleView } from '@/lib/security-types'; +import type { Guard, GuardMode, RuleAction, RuleScope, 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 @@ -9,14 +9,14 @@ export function guardName(t: TFunction, guard: Guard): string { return t(`contentSecurity.guard.${guard}.name`); } -/** - * A mode's name on one guard. The third mode is named for what it does - * there: Replace (redaction), Cut off (tool calls), Enforce (content). - */ -export function modeName(t: TFunction, guard: Guard, mode: GuardMode): string { - if (mode === 'off') return t('contentSecurity.modeOff'); - if (mode === 'observe') return t('contentSecurity.modeObserve'); - return t(`contentSecurity.guard.${guard}.enforce`); +/** A mode's name, the same on every guard: On, Monitor, Off. */ +export function modeName(t: TFunction, mode: GuardMode): string { + return t(`contentSecurity.mode.${mode}`); +} + +/** What a content rule checks, as a tag: Request, Answer. */ +export function scopeName(t: TFunction, scope: RuleScope): string { + return t(`contentSecurity.scope.${scope}`); } export function ruleName( @@ -64,13 +64,18 @@ export function actionName(t: TFunction, action: RuleAction): string { return t(`contentSecurity.ruleAction.${action}`); } -/** Text colour for an action: the ones that change a request's outcome stand out. */ +/** + * Text colour for an action: stopping a request or a call is red, changing + * text is amber, recording is muted. + */ export function actionTone(action: RuleAction | null | undefined): string { switch (action) { case 'block': case 'cut': return 'text-destructive'; case 'strip': + case 'replace': + case 'placeholder': return 'text-amber-600 dark:text-amber-400'; default: return 'text-muted-foreground'; diff --git a/web/src/routes/gateway/security/policy.test.ts b/web/src/routes/gateway/security/policy.test.ts index c399ab29..21930648 100644 --- a/web/src/routes/gateway/security/policy.test.ts +++ b/web/src/routes/gateway/security/policy.test.ts @@ -1,15 +1,23 @@ import { describe, expect, it } from 'vitest' import type { GuardDetail, SecurityRuleView } from '@/lib/security-types' import { + actionAllowed, checkCodepoints, + choiceOfScope, customRuleView, + groupState, isLabel, + matchAllowed, + matchesSearch, patternOf, placeholder, policyOf, + ruleScope, + scopeOfChoice, withAction, withCustom, withEnabled, + withEnabledMany, withMode, withoutCustom, } from './policy' @@ -91,9 +99,9 @@ describe('policyOf', () => { rules: [ builtin({ id: 'internal-ip', kind: 'internal', matcher: { kind: 'private-ip' }, on_by_default: false, label: 'SECRET' }), builtin({ id: 'jwt', kind: 'jwt', matcher: { kind: 'jwt' }, enabled: false, label: 'SECRET' }), - customRuleView({ name: 'Project', pattern: 'PRJ-\\d{6}', match: 'regex', caseSensitive: true, action: null, label: 'PROJECT', enabled: true }), - { ...customRuleView({ name: 'Ticket', pattern: 'T-\\d+', match: 'regex', caseSensitive: false, action: null, label: null, enabled: true }), label: null }, - customRuleView({ name: 'Codename', pattern: 'blue.fox', match: 'contains', caseSensitive: false, action: null, label: null, enabled: false }), + customRuleView({ name: 'Project', pattern: 'PRJ-\\d{6}', match: 'regex', caseSensitive: true, action: 'placeholder', label: 'PROJECT', enabled: true }), + { ...customRuleView({ name: 'Ticket', pattern: 'T-\\d+', match: 'regex', caseSensitive: false, action: 'record', label: null, enabled: true }), label: null }, + customRuleView({ name: 'Codename', pattern: 'blue.fox', match: 'contains', caseSensitive: false, action: 'placeholder', label: null, enabled: false }), ], } expect(policyOf('redact', d)).toEqual({ @@ -102,7 +110,8 @@ describe('policyOf', () => { disable: ['jwt'], custom: [ { name: 'Project', pattern: 'PRJ-\\d{6}', match: 'regex', case_sensitive: true, label: 'PROJECT' }, - { name: 'Ticket', pattern: 'T-\\d+', match: 'regex', label: 'SECRET' }, + // Only a rule that records says so: replacing is the default. + { name: 'Ticket', pattern: 'T-\\d+', match: 'regex', label: 'SECRET', action: 'record' }, { name: 'Codename', pattern: 'blue.fox', match: 'contains', label: 'SECRET', disabled: true }, ], }) @@ -110,7 +119,7 @@ describe('policyOf', () => { it('reads how a custom redaction rule matches back from the view', () => { const view = (matcher: SecurityRuleView['matcher']): SecurityRuleView => ({ - ...customRuleView({ name: 'r', pattern: '', match: 'contains', caseSensitive: false, action: null, label: null, enabled: true }), + ...customRuleView({ name: 'r', pattern: '', match: 'contains', caseSensitive: false, action: 'placeholder', label: null, enabled: true }), matcher, }) expect(patternOf(view({ kind: 'contains', text: 'a.b' }))).toEqual({ pattern: 'a.b', match: 'contains', caseSensitive: false }) @@ -119,7 +128,7 @@ describe('policyOf', () => { match: 'regex', caseSensitive: true, }) - expect(customRuleView({ name: 'r', pattern: 'x', match: 'regex', caseSensitive: true, action: null, label: null, enabled: true }).matcher).toEqual({ + expect(customRuleView({ name: 'r', pattern: 'x', match: 'regex', caseSensitive: true, action: 'placeholder', label: null, enabled: true }).matcher).toEqual({ kind: 'regex', pattern: 'x', case_sensitive: true, @@ -147,7 +156,7 @@ describe('edits', () => { it('switch, act and set the mode on the view', () => { let d = withMode(content, 'enforce') d = withEnabled(d, { id: 'jailbreak', custom: false }, true) - d = withAction(d, 'unicode-tags', 'block') + d = withAction(d, { id: 'unicode-tags', custom: false }, 'block') expect(policyOf('content', d)).toMatchObject({ mode: 'enforce', enable: ['zero-width', 'jailbreak'], @@ -224,3 +233,115 @@ describe('placeholder labels', () => { expect(placeholder(null)).toBe('<>') }) }) + +describe('the restructured guards', () => { + it('reads a stored policy without the new fields as before: requests only, ignoring case, placeholders', () => { + const old = customRuleView({ + name: 'Old', + pattern: 'x', + match: 'contains', + caseSensitive: false, + action: 'strip', + label: null, + enabled: true, + }) + expect(ruleScope({})).toEqual(['request']) + expect(ruleScope(old)).toEqual(['request']) + expect(policyOf('content', { mode: 'observe', rules: [old] })).toEqual({ + mode: 'observe', + custom: [{ name: 'Old', pattern: 'x', match: 'contains', action: 'strip' }], + }) + }) + + it('writes what a content rule checks, its case rule and its replacement, defaults left out', () => { + const rule = (over: Partial[0]>) => + customRuleView({ + name: 'Codename', + pattern: 'Polaris', + match: 'contains', + caseSensitive: false, + action: 'replace', + label: null, + scope: ['request', 'response'], + replacement: '***', + enabled: true, + ...over, + }) + expect(policyOf('content', { mode: 'enforce', rules: [rule({})] })).toEqual({ + mode: 'enforce', + custom: [{ name: 'Codename', pattern: 'Polaris', scope: ['request', 'response'], match: 'contains', action: 'replace' }], + }) + expect( + policyOf('content', { + mode: 'enforce', + rules: [rule({ scope: ['response'], caseSensitive: true, replacement: '[hidden]' })], + }).custom, + ).toEqual([ + { + name: 'Codename', + pattern: 'Polaris', + scope: ['response'], + match: 'contains', + case_sensitive: true, + action: 'replace', + replacement: '[hidden]', + }, + ]) + // A replacement only goes with the replace action. + expect( + policyOf('content', { mode: 'enforce', rules: [rule({ action: 'strip', replacement: '[hidden]' })] }).custom?.[0], + ).not.toHaveProperty('replacement') + }) + + it('writes the redaction actions that differ from the factory', () => { + const d: GuardDetail = { + mode: 'enforce', + rules: [ + builtin({ id: 'email', kind: 'personal', matcher: { kind: 'email' }, action: 'record', default_action: 'placeholder' }), + builtin({ id: 'jwt', kind: 'jwt', matcher: { kind: 'jwt' }, action: 'placeholder', default_action: 'placeholder' }), + ], + } + expect(policyOf('redact', d)).toEqual({ mode: 'enforce', actions: { email: 'record' } }) + expect(policyOf('redact', withAction(d, { id: 'email', custom: false }, 'placeholder'))).toEqual({ mode: 'enforce' }) + }) + + it('switches a group at once and says when it is partly on', () => { + const invisible = content.rules.filter((r) => r.kind === 'invisible') + expect(groupState(invisible)).toBe(true) + const off = withEnabledMany(content, invisible, false) + expect(groupState(off.rules.filter((r) => r.kind === 'invisible'))).toBe(false) + // zero-width ships off and was on: now it is simply not enabled. + expect(policyOf('content', off)).toMatchObject({ disable: ['unicode-tags', 'ignore-previous-instructions'] }) + expect(policyOf('content', off).enable).toBeUndefined() + expect(groupState(content.rules.filter((r) => r.kind === 'injection'))).toBe(false) + expect(groupState(content.rules.filter((r) => r.custom))).toBe('indeterminate') + }) + + it('gives a custom rule switched to replace the default text', () => { + const d = withAction(content, { id: 'Project X', custom: true }, 'replace') + expect(d.rules.find((r) => r.id === 'Project X')).toMatchObject({ action: 'replace', replacement: '***' }) + }) + + it('rules out a refusal and code points for answers', () => { + expect(actionAllowed('block', ['request'])).toBe(true) + expect(actionAllowed('block', ['response'])).toBe(false) + expect(actionAllowed('block', ['request', 'response'])).toBe(false) + expect(actionAllowed('replace', ['response'])).toBe(true) + expect(matchAllowed('codepoints', ['request'])).toBe(true) + expect(matchAllowed('codepoints', ['request', 'response'])).toBe(false) + expect(matchAllowed('regex', ['response'])).toBe(true) + expect(scopeOfChoice('both')).toEqual(['request', 'response']) + expect(choiceOfScope(['response', 'request'])).toBe('both') + expect(choiceOfScope(['response'])).toBe('response') + }) + + it('searches by name and by what a rule matches', () => { + const tags = content.rules[0] + expect(matchesSearch(tags, 'Unicode tag characters', 'tag')).toBe(true) + expect(matchesSearch(tags, 'Unicode 标签字符', '标签')).toBe(true) + const custom = content.rules.find((r) => r.id === 'Project X')! + expect(matchesSearch(custom, 'Project X', 'PROJECT-x')).toBe(true) + expect(matchesSearch(custom, 'Project X', 'nothing')).toBe(false) + expect(matchesSearch(custom, 'Project X', ' ')).toBe(true) + }) +}) diff --git a/web/src/routes/gateway/security/policy.ts b/web/src/routes/gateway/security/policy.ts index 6d6881c0..84dee9c2 100644 --- a/web/src/routes/gateway/security/policy.ts +++ b/web/src/routes/gateway/security/policy.ts @@ -6,26 +6,32 @@ // rule goes into `enable` when it is on but off out of the box, into // `disable` the other way round, and into `actions` when its action is not // the factory one; custom rules are written back from their matcher. What -// the table shows is therefore exactly what gets saved. - -import type { - ContentAction, - ContentMatch, - ContentPolicy, - CustomContentRule, - CustomRedactRule, - CustomToolRule, - Guard, - GuardDetail, - GuardMode, - GuardPolicies, - Matcher, - RedactMatch, - RedactPolicy, - RuleAction, - SecurityRuleView, - ToolAction, - ToolPolicy, +// the table shows is therefore exactly what gets saved. A field left at its +// default (a content rule checking requests only, a replacement of `***`, +// a redaction rule replacing with a placeholder) is left out, so a policy +// saved by an earlier console reads the same. + +import { + DEFAULT_REPLACEMENT, + type ContentAction, + type ContentMatch, + type ContentPolicy, + type CustomContentRule, + type CustomRedactRule, + type CustomToolRule, + type Guard, + type GuardDetail, + type GuardMode, + type GuardPolicies, + type Matcher, + type RedactAction, + type RedactMatch, + type RedactPolicy, + type RuleAction, + type RuleScope, + type SecurityRuleView, + type ToolAction, + type ToolPolicy, } from '@/lib/security-types'; /** Placeholder label a redaction rule gets when none is written. */ @@ -43,25 +49,67 @@ export function placeholder(label: string | null | undefined, n = 1): string { return `<>`; } -/** The guards whose rules carry an action of their own. */ -export type ActionGuard = 'inspect_tools' | 'content'; - -export function hasAction(guard: Guard): guard is ActionGuard { - return guard !== 'redact'; -} - /** The actions a rule can take, strongest first. */ -export const ACTIONS: Record = { +export const ACTIONS: Record = { + redact: ['placeholder', 'record'], inspect_tools: ['cut', 'record'], - content: ['block', 'strip', 'record'], + content: ['block', 'strip', 'replace', 'record'], }; -/** What a new custom rule does: a rule written on purpose is usually meant to act. */ -export const NEW_RULE_ACTION: Record = { +/** + * What a new custom rule does. A redaction or tool-call rule written on + * purpose is usually meant to act; a content rule starts by only + * recording, to see what it would catch. + */ +export const NEW_RULE_ACTION: Record = { + redact: 'placeholder', inspect_tools: 'cut', - content: 'block', + content: 'record', }; +// --------------------------------------------------------------------------- +// What a content rule checks +// --------------------------------------------------------------------------- + +/** The three choices the content rule dialog offers. */ +export type ScopeChoice = 'request' | 'response' | 'both'; + +export const SCOPE_CHOICES: readonly ScopeChoice[] = ['request', 'response', 'both']; + +export function scopeOfChoice(c: ScopeChoice): RuleScope[] { + return c === 'both' ? ['request', 'response'] : [c]; +} + +/** What a rule checks. Rules that say nothing (built-in ones, other guards) check requests. */ +export function ruleScope(r: Pick): RuleScope[] { + const s = r.scope?.filter((x) => x === 'request' || x === 'response') ?? []; + return s.length ? (['request', 'response'] as const).filter((x) => s.includes(x)) : ['request']; +} + +export function choiceOfScope(scope: readonly RuleScope[]): ScopeChoice { + const req = scope.includes('request'); + const res = scope.includes('response'); + return req && res ? 'both' : res ? 'response' : 'request'; +} + +/** Whether a rule with this scope checks the model's answers. */ +export function checksAnswers(scope: readonly RuleScope[]): boolean { + return scope.includes('response'); +} + +/** + * Whether a content rule may take `action`: an answer that streams has + * partly reached the client when a rule matches, so it cannot be refused. + */ +export function actionAllowed(action: RuleAction, scope: readonly RuleScope[]): boolean { + return !(action === 'block' && checksAnswers(scope)); +} + +/** Code points are a request check: the answer is checked by text and regex. */ +export function matchAllowed(match: ContentMatch, scope: readonly RuleScope[]): boolean { + return !(match === 'codepoints' && checksAnswers(scope)); +} + /** Built-in and custom rules may share a name. */ export function sameRule(a: Pick, b: Pick) { return a.id === b.id && a.custom === b.custom; @@ -116,7 +164,7 @@ export function checkCodepoints(text: string): CodepointProblem | null { export interface WrittenPattern { pattern: string; match: ContentMatch; - /** Only redaction rules choose; the view says it for every regex and text. */ + /** Redaction and content rules choose; the view says it for every regex and text. */ caseSensitive: boolean; } @@ -160,12 +208,15 @@ export interface CustomRuleInput { pattern: string; /** Content and redaction rules choose; tool rules are regexes. */ match: ContentMatch; - /** Redaction only; the other guards' rules have a fixed case rule. */ + /** Redaction and content; tool rules have a fixed case rule. */ caseSensitive: boolean; - /** Tool and content rules only. */ - action: RuleAction | null; + action: RuleAction; /** Redaction only. */ label: string | null; + /** Content only. */ + scope?: RuleScope[]; + /** Content rules that replace. */ + replacement?: string | null; enabled: boolean; } @@ -182,6 +233,8 @@ export function customRuleView(input: CustomRuleInput): SecurityRuleView { action: input.action, default_action: null, label: input.label, + ...(input.scope ? { scope: input.scope } : {}), + ...(input.action === 'replace' ? { replacement: input.replacement ?? DEFAULT_REPLACEMENT } : {}), }; } @@ -197,14 +250,56 @@ export function withEnabled(d: GuardDetail, rule: Pick (sameRule(r, rule) ? { ...r, enabled } : r)) }; } -/** A built-in rule's action. */ -export function withAction(d: GuardDetail, id: string, action: RuleAction): GuardDetail { +/** Switches several rules at once: a group's checkbox. */ +export function withEnabledMany( + d: GuardDetail, + rules: readonly Pick[], + enabled: boolean, +): GuardDetail { return { ...d, - rules: d.rules.map((r) => (!r.custom && r.id === id ? { ...r, action } : r)), + rules: d.rules.map((r) => (rules.some((x) => sameRule(r, x)) ? { ...r, enabled } : r)), }; } +/** + * A rule's action, built-in or custom. A content rule that replaces keeps + * the text it had, or gets the default one. + */ +export function withAction(d: GuardDetail, rule: Pick, action: RuleAction): GuardDetail { + return { + ...d, + rules: d.rules.map((r) => { + if (!sameRule(r, rule)) return r; + const next = { ...r, action }; + if (r.custom && action === 'replace' && !r.replacement) next.replacement = DEFAULT_REPLACEMENT; + return next; + }), + }; +} + +/** How many of `rules` are on. */ +export function enabledCount(rules: readonly SecurityRuleView[]): number { + return rules.filter((r) => r.enabled).length; +} + +/** A group checkbox: on, off or partly on. */ +export function groupState(rules: readonly SecurityRuleView[]): boolean | 'indeterminate' { + const on = enabledCount(rules); + return on === 0 ? false : on === rules.length ? true : 'indeterminate'; +} + +/** + * 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. + */ +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)); +} + /** * Saves a custom rule: replaces the one named `previous` in place (an edit, * possibly a rename), or adds it after the others when `previous` is null. @@ -248,8 +343,11 @@ function customPattern(r: SecurityRuleView): WrittenPattern { return patternOf(r) ?? { pattern: '', match: 'regex', caseSensitive: false }; } +const REDACT_ACTIONS: readonly RedactAction[] = ['placeholder', 'record']; + function redactPolicy(d: GuardDetail): RedactPolicy { - const { enable, disable } = builtinSwitches(d); + const { builtins, enable, disable } = builtinSwitches(d); + const actions = changedActions(builtins, REDACT_ACTIONS); const custom: CustomRedactRule[] = d.rules .filter((r) => r.custom) .map((r) => { @@ -262,6 +360,7 @@ function redactPolicy(d: GuardDetail): RedactPolicy { match: how, ...(caseSensitive ? { case_sensitive: true } : {}), label: r.label || DEFAULT_LABEL, + ...(r.action === 'record' ? { action: 'record' as const } : {}), ...(r.enabled ? {} : { disabled: true }), }; }); @@ -269,12 +368,13 @@ function redactPolicy(d: GuardDetail): RedactPolicy { mode: d.mode, ...(enable.length ? { enable } : {}), ...(disable.length ? { disable } : {}), + ...(Object.keys(actions).length ? { actions } : {}), ...(custom.length ? { custom } : {}), }; } const TOOL_ACTIONS: readonly ToolAction[] = ['cut', 'record']; -const CONTENT_ACTIONS: readonly ContentAction[] = ['block', 'strip', 'record']; +const CONTENT_ACTIONS: readonly ContentAction[] = ['block', 'strip', 'replace', 'record']; function toolPolicy(d: GuardDetail): ToolPolicy { const { builtins, enable, disable } = builtinSwitches(d); @@ -302,9 +402,21 @@ function contentPolicy(d: GuardDetail): ContentPolicy { const custom: CustomContentRule[] = d.rules .filter((r) => r.custom) .map((r) => { - const { pattern, match } = customPattern(r); - const action: ContentAction = r.action === 'block' || r.action === 'strip' ? r.action : 'record'; - return { name: r.id, pattern, match, action, ...(r.enabled ? {} : { disabled: true }) }; + const { pattern, match, caseSensitive } = customPattern(r); + const action: ContentAction = + r.action === 'block' || r.action === 'strip' || r.action === 'replace' ? r.action : 'record'; + const scope = ruleScope(r); + const replacement = r.replacement ?? DEFAULT_REPLACEMENT; + return { + name: r.id, + pattern, + ...(scope.length === 1 && scope[0] === 'request' ? {} : { scope }), + match, + ...(caseSensitive && match !== 'codepoints' ? { case_sensitive: true } : {}), + action, + ...(action === 'replace' && replacement !== DEFAULT_REPLACEMENT ? { replacement } : {}), + ...(r.enabled ? {} : { disabled: true }), + }; }); return { mode: d.mode, diff --git a/web/src/routes/gateway/security/rule-dialogs.tsx b/web/src/routes/gateway/security/rule-dialogs.tsx index 4ba4277b..9d7d451c 100644 --- a/web/src/routes/gateway/security/rule-dialogs.tsx +++ b/web/src/routes/gateway/security/rule-dialogs.tsx @@ -4,7 +4,6 @@ import { AlertCircle, Copy, Loader2 } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -15,6 +14,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Table, TableBody, @@ -26,38 +26,39 @@ import { import { Textarea } from '@/components/ui/textarea'; import { Segmented } from '@/components/segmented'; import { cn } from '@/lib/utils'; -import type { - ContentMatch, - Guard, - RuleAction, - SecurityRuleView, - SecurityTestHit, - SecurityTestRequest, +import { + DEFAULT_REPLACEMENT, + type ContentMatch, + type Guard, + type GuardMode, + type RuleAction, + type RuleScope, + 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, - modeName, - ruleName, - ruleWhy, - viewName, -} from './names'; +import { actionName, actionTone, kindName, ruleName, ruleWhy, scopeName, viewName } from './names'; import { ACTIONS, DEFAULT_LABEL, MATCHES, MAX_CODEPOINT_ITEMS, NEW_RULE_ACTION, + SCOPE_CHOICES, + actionAllowed, checkCodepoints, - hasAction, + checksAnswers, + choiceOfScope, isLabel, + matchAllowed, patternOf, placeholder, - type ActionGuard, + ruleScope, + scopeOfChoice, type CustomRuleInput, + type ScopeChoice, } from './policy'; import { useTrial, type Trial } from './use-trial'; @@ -90,49 +91,65 @@ function Field({
{children} - {hint &&

{hint}

} + {hint &&
{hint}
} {error &&

{error}

}
); } /** - * What a rule does in the third mode. Built-in and custom rules choose the - * same way: a built-in rule supplies the pattern, the operator decides what - * a hit does. A changed built-in rule says what the factory setting was. + * What a rule does when its guard is on. Built-in and custom rules choose + * the same way: a built-in rule supplies the pattern, the operator decides + * what a hit does. An action a content rule cannot take on what it checks + * stays visible, unavailable, with the reason under it. A changed built-in + * rule says what the factory setting was. */ function ActionField({ guard, value, onChange, + scope, factory, + notice, disabled, }: { - guard: ActionGuard; + guard: Guard; value: RuleAction; onChange: (a: RuleAction) => void; + /** Content rules: what the rule checks. */ + scope?: RuleScope[]; factory?: RuleAction | null; + /** Said once the action was changed for the operator. */ + notice?: string | null; disabled: boolean; }) { const { t } = useTranslation(); - const what = - value === 'record' - ? t(`contentSecurity.dialog.recordWhat.${guard}`) - : t(`contentSecurity.dialog.actionWhat.${value}`, { mode: modeName(t, guard, 'enforce') }); + const what = t(`contentSecurity.dialog.actionWhat.${value}`); + const unavailable = ACTIONS[guard].some((a) => scope && !actionAllowed(a, scope)); return ( +

+ {factory && factory !== value + ? t('contentSecurity.dialog.withFactory', { what, action: actionName(t, factory) }) + : what} +

+ {unavailable &&

{t('contentSecurity.dialog.blockReason')}

} + {notice &&

{notice}

} + } >
({ value: a, label: actionName(t, a) }))} + options={ACTIONS[guard].map((a) => ({ + value: a, + label: actionName(t, a), + disabled: scope ? !actionAllowed(a, scope) : false, + }))} onChange={onChange} disabled={disabled} /> @@ -147,21 +164,15 @@ function toneOf(action: RuleAction | null | undefined): Mark['tone'] { } /** - * What the server found: how many, marked in the sample; for redaction and - * deletion, the text as the third mode would send it; for the content - * filter, whether that mode would refuse the request. + * What the server found: how many, marked in the sample; the text as it + * would be sent or received with the guard on; for the content filter, + * whether the request would be refused. * * Every hit carries the action it takes there. A single rule is tried with - * the action chosen in its dialog, so the colours, the text sent and the - * refusal all follow that choice. + * the action chosen in its dialog, so the colours, the processed text and + * the refusal all follow that choice. */ -function TrialBox({ - guard, - trial, -}: { - guard: Guard; - trial: Trial; -}) { +function TrialBox({ trial }: { trial: Trial }) { const { t } = useTranslation(); if (trial.state === 'idle') return null; if (trial.state === 'failed') { @@ -171,7 +182,7 @@ function TrialBox({ const hits = result?.hits ?? []; const tone = (h: SecurityTestHit) => toneOf(h.action); const bad = hits.some((h) => tone(h) === 'bad'); - // Tool-call inspection sends nothing and refuses nothing; the server + // Dangerous commands send nothing and refuse nothing; the server // answers null and false there. const refused = result?.refused === true; return ( @@ -198,16 +209,10 @@ function TrialBox({ marks={hits.map((h) => ({ start: h.start, end: h.end, tone: tone(h) }))} /> )} - {refused && ( -

- {t('contentSecurity.dialog.refused', { mode: modeName(t, guard, 'enforce') })} -

- )} + {refused &&

{t('contentSecurity.dialog.refused')}

} {!refused && result && result.output !== null && (
-

- {t('contentSecurity.dialog.sent', { mode: modeName(t, guard, 'enforce') })} -

+

{t('contentSecurity.dialog.output')}

{result.output}
)} @@ -215,17 +220,57 @@ function TrialBox({ ); } +/** Which side a content sample is tried as: a request or an answer. */ +function TestSide({ + value, + onChange, + scope, +}: { + value: RuleScope; + onChange: (s: RuleScope) => void; + /** The sides the rule checks; the other is unavailable. Left out: both. */ + scope?: RuleScope[]; +}) { + const { t } = useTranslation(); + return ( + ({ + value: s, + label: t(`contentSecurity.dialog.testAsSide.${s}`), + disabled: scope ? !scope.includes(s) : false, + }))} + onChange={onChange} + /> + ); +} + +/** What the guard's state means for the rule being edited, said by the buttons. */ +function ModeNote({ mode }: { mode: GuardMode }) { + const { t } = useTranslation(); + if (mode === 'enforce') return null; + return {t(`contentSecurity.dialog.modeNote.${mode}`)}; +} + /** * Create or edit a custom rule. * - * Content rules match by text, regex or code points; redaction rules match - * by text or regex, case-sensitive or not, and name the placeholder a hit - * becomes; tool and content rules choose what a hit does. Code points and - * the placeholder name are checked as they are typed; a regex is checked by + * Content rules say what they check — requests, answers or both — match by + * text, regex or code points (requests only), case-sensitive or not, and + * refuse (requests only), delete, replace with a text of their own, or + * record. Redaction rules match by text or regex, case-sensitive or not, + * name the placeholder a hit becomes, and replace or record. Tool rules + * are regexes that cut the call off or record it. A choice the scope rules + * out stays visible and unavailable; changing the scope so that the current + * choice no longer fits switches it and says so. Code points and the + * placeholder name are checked as they are typed; a regex is checked by * the server, which marks the sample as it changes. */ export function CustomRuleDialog({ guard, + mode, editing, seed, taken, @@ -234,6 +279,8 @@ export function CustomRuleDialog({ onSave, }: { guard: Guard; + /** The guard's state, for the note by the buttons. */ + mode: GuardMode; /** The rule being edited; null to create one. */ editing: SecurityRuleView | null; seed?: RuleSeed; @@ -246,26 +293,55 @@ export function CustomRuleDialog({ const { t } = useTranslation(); const content = guard === 'content'; const redact = guard === 'redact'; - const acts = hasAction(guard) ? guard : null; const was = editing ? patternOf(editing) : null; const [name, setName] = useState(editing?.id ?? seed?.name ?? ''); + const [scopeChoice, setScopeChoice] = useState( + content && editing ? choiceOfScope(ruleScope(editing)) : 'request', + ); + const scope = scopeOfChoice(scopeChoice); // Content and redaction rules choose; tool rules are regexes. const matches = MATCHES[guard]; const [match, setMatch] = useState(() => { const written = was?.match ?? seed?.match; return written && matches.includes(written) ? written : matches[0]; }); - const [caseSensitive, setCaseSensitive] = useState(redact && (was?.caseSensitive ?? false)); + const choosesCase = redact || content; + const [caseSensitive, setCaseSensitive] = useState(choosesCase && (was?.caseSensitive ?? false)); const [pattern, setPattern] = useState(was?.pattern ?? seed?.pattern ?? ''); const [label, setLabel] = useState(editing?.label || DEFAULT_LABEL); - const [action, setAction] = useState( - editing?.action ?? seed?.action ?? (acts ? NEW_RULE_ACTION[acts] : 'record'), - ); + const [action, setAction] = useState(() => { + const a = editing?.action ?? seed?.action; + return a && ACTIONS[guard].includes(a) ? a : NEW_RULE_ACTION[guard]; + }); + const [replacement, setReplacement] = useState(editing?.replacement || DEFAULT_REPLACEMENT); + const [matchNotice, setMatchNotice] = useState(null); + const [actionNotice, setActionNotice] = useState(null); + const [testSide, setTestSide] = useState(scope[0]); const [sample, setSample] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + /** A scope that checks answers takes neither code points nor a refusal: those switch, with a notice. */ + function chooseScope(next: ScopeChoice) { + setError(null); + setScopeChoice(next); + const s = scopeOfChoice(next); + if (!s.includes(testSide)) setTestSide(s[0]); + if (!matchAllowed(match, s)) { + setMatch('contains'); + setMatchNotice(t('contentSecurity.dialog.matchSwitched')); + } else { + setMatchNotice(null); + } + if (!actionAllowed(action, s)) { + setAction('strip'); + setActionNotice(t('contentSecurity.dialog.actionSwitched')); + } else { + setActionNotice(null); + } + } + const trimmed = name.trim(); const clash = taken.includes(trimmed); const codepoints = content && match === 'codepoints' && pattern.trim() !== '' ? checkCodepoints(pattern) : null; @@ -276,6 +352,7 @@ export function CustomRuleDialog({ ? t('contentSecurity.dialog.tooManyCodepoints', { max: MAX_CODEPOINT_ITEMS }) : null; const labelError = redact && !isLabel(label) ? t('contentSecurity.dialog.badLabel') : null; + const replaces = content && action === 'replace'; const empty = match === 'contains' ? pattern.length === 0 : pattern.trim().length === 0; // What is still missing, said next to the buttons; a wrong value is // already said under its field. @@ -283,8 +360,11 @@ export function CustomRuleDialog({ ? t('contentSecurity.dialog.nameRequired') : empty ? t('contentSecurity.dialog.patternRequired') - : null; + : replaces && replacement.length === 0 + ? t('contentSecurity.dialog.replacementRequired') + : null; const blocked = missing !== null || clash || codepointError !== null || labelError !== null; + const exact = choosesCase && match !== 'codepoints' && caseSensitive; const request: SecurityTestRequest | null = !empty && !codepointError && !labelError @@ -292,10 +372,11 @@ export function CustomRuleDialog({ sample, pattern, match: matches.length > 1 ? match : undefined, - case_sensitive: redact ? caseSensitive : undefined, + case_sensitive: choosesCase && match !== 'codepoints' ? caseSensitive : undefined, label: redact ? label : undefined, - // Redaction rules have no action; the server refuses one there. - action: acts ? action : undefined, + action, + ...(content ? { scope: testSide } : {}), + ...(replaces ? { replacement } : {}), } : null; const trial = useTrial(guard, request); @@ -306,13 +387,14 @@ export function CustomRuleDialog({ : match === 'regex' ? t('contentSecurity.dialog.patternRegex') : t('contentSecurity.dialog.patternCodepoints'); - const patternHint = + const caseHint = exact ? t('contentSecurity.dialog.caseHintOn') : t('contentSecurity.dialog.caseHintOff'); + const matchHint = match === 'contains' - ? redact - ? t('contentSecurity.dialog.redactContainsHint') - : t('contentSecurity.dialog.containsHint') + ? `${t('contentSecurity.dialog.containsHint')}${caseHint}` : match === 'regex' - ? t(`contentSecurity.dialog.regexHint.${guard}`) + ? guard === 'inspect_tools' + ? t('contentSecurity.dialog.regexHint.inspect_tools') + : `${t(`contentSecurity.dialog.regexHint.${guard}`)}${caseHint}` : t('contentSecurity.dialog.codepointsHint'); async function save() { @@ -323,9 +405,10 @@ export function CustomRuleDialog({ name: trimmed, pattern, match, - caseSensitive: redact && caseSensitive, - action: acts ? action : null, + caseSensitive: exact, + action, label: redact ? label : null, + ...(content ? { scope, replacement: replaces ? replacement : null } : {}), enabled: editing?.enabled ?? true, }); } catch (err) { @@ -346,7 +429,7 @@ export function CustomRuleDialog({ -
+
+ {content && ( + +
+ ({ + value: c, + label: t(`contentSecurity.scopeChoice.${c}`), + }))} + onChange={chooseScope} + disabled={readOnly} + /> +
+
+ )} + {matches.length > 1 && ( - + +

{matchHint}

+ {content && checksAnswers(scope) && match === 'regex' && ( +

{t('contentSecurity.dialog.regexAnswerHint')}

+ )} + {content && checksAnswers(scope) &&

{t('contentSecurity.dialog.codepointsReason')}

} + {matchNotice &&

{matchNotice}

} + + } + >
({ value: m, label: t(`contentSecurity.dialog.match.${m}`), + disabled: content ? !matchAllowed(m, scope) : false, }))} onChange={(m) => { setError(null); + setMatchNotice(null); setMatch(m); }} disabled={readOnly} /> - {redact && ( - @@ -399,7 +514,12 @@ export function CustomRuleDialog({ )} - + 1 ? undefined : matchHint} + error={codepointError} + > + { + setActionNotice(null); + setAction(a); + }} + scope={content ? scope : undefined} + notice={actionNotice} + disabled={readOnly} + /> + + {replaces && ( + + { + setError(null); + setReplacement(e.target.value); + }} + disabled={readOnly} + /> + + )} + {redact && ( )} - {acts && ( - - )} - + {content && }