diff --git a/Cargo.lock b/Cargo.lock index 1f67ad2..b566731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3143,7 +3143,7 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.57.1" +version = "0.58.0" dependencies = [ "serde", "serde_json", @@ -3154,7 +3154,7 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.57.1" +version = "0.58.0" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.57.1" +version = "0.58.0" dependencies = [ "serde", "serde_json", @@ -3183,7 +3183,7 @@ dependencies = [ [[package]] name = "tw-config" -version = "0.57.1" +version = "0.58.0" dependencies = [ "blake3", "libc", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tw-control" -version = "0.57.1" +version = "0.58.0" dependencies = [ "axum", "base64", @@ -3249,7 +3249,7 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.57.1" +version = "0.58.0" dependencies = [ "serde", "serde_json", @@ -3257,7 +3257,7 @@ dependencies = [ [[package]] name = "tw-engine" -version = "0.57.1" +version = "0.58.0" dependencies = [ "serde", "serde_json", @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "tw-gateway" -version = "0.57.1" +version = "0.58.0" dependencies = [ "arc-swap", "async-stream", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.57.1" +version = "0.58.0" dependencies = [ "base64", "bytes", @@ -3334,7 +3334,7 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.57.1" +version = "0.58.0" dependencies = [ "serde", "serde_json", @@ -3348,7 +3348,7 @@ dependencies = [ [[package]] name = "tw-observe" -version = "0.57.1" +version = "0.58.0" dependencies = [ "tokio", "tracing", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "tw-plugin" -version = "0.57.1" +version = "0.58.0" dependencies = [ "libc", "rand 0.10.2", @@ -3372,7 +3372,7 @@ dependencies = [ [[package]] name = "tw-pricing" -version = "0.57.1" +version = "0.58.0" dependencies = [ "arc-swap", "flate2", @@ -3385,14 +3385,14 @@ dependencies = [ [[package]] name = "tw-secret" -version = "0.57.1" +version = "0.58.0" dependencies = [ "thiserror", ] [[package]] name = "tw-store" -version = "0.57.1" +version = "0.58.0" dependencies = [ "blake3", "bytes", @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.57.1" +version = "0.58.0" dependencies = [ "serde", "serde_json", @@ -3421,7 +3421,7 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.57.1" +version = "0.58.0" dependencies = [ "notify", "tempfile", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.57.1" +version = "0.58.0" dependencies = [ "saphyr-parser", "serde_yaml_ng", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "twcore" -version = "0.57.1" +version = "0.58.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index d46da33..1f715bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 -version = "0.57.1" +version = "0.58.0" [workspace.dependencies] # ── 内部 crate ─────────────────────────────────────────── diff --git a/release-notes/0.58.0.md b/release-notes/0.58.0.md new file mode 100644 index 0000000..ec0e8d6 --- /dev/null +++ b/release-notes/0.58.0.md @@ -0,0 +1,146 @@ +This release adds script plugins: JavaScript that changes requests before they go upstream and answers before they reach the client, run in a WebAssembly sandbox inside core. It also reads a session back as a conversation, stores request and answer bodies with recognized secrets already replaced, and records an error that an upstream answered as a failed request. The five security guards become three. Hidden characters are now content-filter rules, the output limit is gone, and tool-call inspection gains two rules against sending credentials and files to other hosts. + +**Upgrade notes** + +- The control-plane protocol version (`CONTROL_API_VERSION`) goes from 31 to 34. ThinkWatch Lite connects only to a core with the same protocol version. ThinkWatch Lite 2026.10.0 includes 0.57.1 (protocol 31) and does not connect to 0.58.0. A server used with it stays on 0.57.1 until the app is updated to a release that includes 0.58.0; `sudo twcore upgrade --version 0.57.1 --restart` switches a server back. +- The request store's schema goes from 23 to 25: the security log has two new columns, and plugin runs have a table of their own. **The request history is cleared** when the store is rebuilt on first start. +- **`security.hidden_text` and `security.output_limit` are removed.** A configuration that still has either key does not load: the app starts in safe mode, and `twcore serve` exits with the error. + - Only configurations where one of these settings was changed are affected. A new configuration has no `security` section, and the app writes only values that differ from the factory ones. In ThinkWatch Lite 2026.10.0, that means: + - Hidden characters set to Off or Enforce (factory: Observe), or one of its two rules (Unicode tag characters, bidirectional controls) turned off. + - Output limit set to Observe or Enforce (factory: Off), or its limit changed from 100,000 characters. + - Before upgrading, do one of the following: + - set these back to their factory values in the app; + - delete `hidden_text:` and `output_limit:`, with the lines indented under them, from the `security:` section of `config.yaml`. + - Hidden characters are now checked by content-filter rules that follow the content filter's mode (see *Three guards*). +- Changed in the protocol: + - Removed: + - `PUT /security/{guard}/limit` (`SetSecurityLimit`, `LimitSave`); + - `OutputLimitDetail`, `HiddenItem`, `HiddenKind`; + - the events `hidden_text_found` and `output_limited`; + - `hidden_text` and `output_limit` in `Guard`, `SecurityDetail`, `SecurityView` and `SecurityCounts`. + - `GET /sessions/{id}/transcript` (`SessionTranscript` → `Transcript`, with `TranscriptTurn`, `TranscriptMessage`, `TranscriptRole`, `TranscriptPart`, `TranscriptGap`). + - The plugin endpoints: + - `GET /plugins` (`Plugins` → `PluginView`), `POST /plugins/inspect` (`PluginInspect`: `PluginSource` → `PluginInspection`), `POST /plugins` (`CreatePlugin`, `PluginCreate`), `PUT /plugins/order` (`ReorderPlugins`, `PluginOrder`); + - `PUT /plugins/{id}` (`UpdatePlugin`, `PluginUpdate`), `PUT /plugins/{id}/confirmed` (`UpdatePluginConfirmed`), `DELETE /plugins/{id}` (`DeletePlugin`); + - `PUT /plugins/{id}/source` (`ReplacePluginSource`, `PluginSourceReplace`), `GET /plugins/{id}/source` (`PluginSourceDiff` → `PluginSourceView`), `POST /plugins/{id}/approve` (`ApprovePluginFile`, `PluginApprove`); + - `POST /plugins/{id}/trial` (`TrialPlugin`: `PluginTrial` → `PluginTrialResult`, `TrialSide`), `GET /plugins/{id}/logs` (`PluginLogs` → `PluginLogEntry`). + - Supporting types: `ManifestView`, `PluginStatus`, `PluginStats`, `PluginLastError`, `PluginLoadError`, `PluginScope`, `PluginHooks`, `PluginRunView`, `SettingSpecView`, `SettingValue`, `Permission`, `RequestKind`, `OnError`, `ReplyMode`, `SettingKind`, `PluginHook`, `PluginOutcome`, `PluginLogLevel`. + - `Event::PluginFailed`. `RequestDetail` has `plugins` and `request_after_plugins`, `HistoryRow` has `plugin_changed`, and `ConfigOrigin` has `defaults`. + - `TurnView` has `status`. + - The security types are now defined in tw-guard and keep their names. Changes: + - `SecurityRuleView` and `CustomRuleSave` have `label`. + - `Matcher` has `email`, `cn-mobile-phone` and `builtin`; `RuleAction` has `strip`; `ContentMatch` has `codepoints`. + - `SecurityTestRequest` has `label` and `action`; `SecurityTestResult` has `output` and `refused`. + - `SecurityOutcome` and `SecurityOutcomeCounts` have `stripped`; `SecurityEventView` has `match` and `revealed`. + - The security counts in `/summary` are `secrets`, `secrets_replaced`, `tool_calls`, `tool_calls_cut`, `content`, `content_blocked` and `content_stripped`. + - `content_matched` has `match`, `action`, `outcome` (`ContentOutcome`: `recorded`, `stripped`, `blocked`), `count` and `revealed`, and no longer `blocked`. +- Message codes, compared with 0.57.1: + - New: + - 52 for plugins (`config.plugin.*`, `control.plugin.*`, `gw.plugin.*`). `gw.plugin.reason` only passes on what the plugin wrote. + - `gw.upstream.status_message`. + - Nine for the guards: `config.rule_codepoints_bad`, `config.rule_label_bad`, `gw.content.refused_invisible_message`, `gw.content.refused_invisible_tool_result`, `security.bad_codepoints`, `security.bad_label`, `security.content_action_unknown`, `security.pattern_empty`, `security.unknown_guard`. + - Two test-only codes, which never reach a UI. + - Renamed: `gw.toolcall.cut` → `gw.toolcall.response_cut`, `gw.toolcall.blocked` → `gw.toolcall.response_withheld`, `gw.ws.toolcall_cut` → `gw.toolcall.connection_cut` (its `detail` argument is now `why`). The sentences now say that the answer contained the call, not that an upstream returned it. + - Removed: `config.output_limit_range`, `gw.hidden_text.refused_message`, `gw.hidden_text.refused_tool_result`, `gw.output_limit.cut`, `gw.output_limit.withheld`, `security.guard_unknown`, `security.limit_range`, `security.no_custom_rules`, `security.no_limit`, `security.nothing_to_test`, `security.unknown_content_action`. `security.unknown_guard` and `security.content_action_unknown` take the place of `security.guard_unknown` and `security.unknown_content_action`, with sentences that list the new guards and actions. +- **Building from source** needs an LLVM `clang` that can compile C to WebAssembly, and its `llvm-ar`. The plugin sandbox compiles QuickJS while core builds. Apple's clang cannot target WebAssembly; on macOS, `brew install llvm`. The README lists the other systems. The released binaries need nothing new. +- Behavior worth knowing: + - Compaction requests (`/v1/responses/compact`, `/backend-api/codex/responses/compact`) are screened by the content filter like generation requests. Token counts are still not screened. + - Tool-call excerpts are masked before they reach the `tool_call_flagged` event, the security log and system notifications. A secret restored from a placeholder used to appear there in the clear. + - Stored bodies no longer contain the secrets and personal numbers that the redaction rules recognize, whatever the redaction mode (see *Stored bodies*). + - The retention settings now take effect. tw-store ran its own hourly cleanup with fixed limits (7 days of bodies, 90 days of rows, 2 GiB of bodies) beside the one that reads `retention`, so any setting above those limits was cut back every hour. That cleanup is removed. `retention.body_max_bytes` now defaults to 5 GiB (it was 2 GiB). + - On first start, core adds its three default plugins to `config.yaml`, turned off, and writes their files to `plugins/` beside it. That is one configuration version, recorded in the history as `defaults`. + +**Session transcripts.** `GET /sessions/{id}/transcript` reads a session's stored bodies back as a conversation. For every turn it gives the messages that are new in that request, the answer, and what could not be shown. +- Clients resend the whole history on every turn, so a turn shows only what its request added after the previous readable one. +- Messages are compared by a fingerprint that ignores cache markers, reasoning signatures, key order and reasoning text. When the history was edited or compacted, the turn is marked `restart` and carries the whole history. +- It reads Anthropic Messages, OpenAI Chat Completions and Responses, Gemini and Bedrock, streamed and whole, including answers converted from another format. +- Tool-call ids in converted answers are matched to the ones the client recorded, so results pair with their calls. +- A message holds text, reasoning, tool calls, tool results, images (media type and size only), and other blocks by their type name. System and developer messages in the middle of a conversation appear as `system` messages. +- Gaps say what is missing: `request_missing`, `request_truncated`, `response_missing`, `response_truncated`, `response_unreadable`. +- Every string is masked after decoding. Image data and reasoning signatures are never returned. +- A sub-agent's work is in its own session. + +**Stored bodies.** Bodies used to be stored as the client sent them and masked only when read. They are now redacted when twcore hands them to the store, off the forwarding path. +- Every value the redaction rules recognize is taken out, whatever the mode, `off` included. + - Under `enforce`, a request is stored with the placeholders the upstream received. + - In the other modes, the values are masked. + - The whole text then goes through the same masking the read side applies. +- A value gets the same placeholder in every hop, in the stored request and in the stored answer. +- Up to 4 MiB of each answer is kept (it was 256 KiB), the same as for requests. A request over 4 MiB is stored cut, with its original length recorded, so a replay refuses it instead of sending half the JSON. +- Bodies waiting to be written are capped at 32 MiB as well as at 64 entries. +- Where the stored copy shows: + - The request detail shows the stored copy. + - Whole-history search no longer finds text that occurs only inside a secret. + - A replay sends the stored request, with placeholders or masked values where the secrets were. + - The fixture export redacts again with every built-in rule. + +**Failed upstream answers.** When an upstream answered 4xx (or 3xx) and the gateway passed that answer on to the client, the request ended as finished with an empty error. It counted as successful everywhere. It now ends with `RequestFailed`, from source `upstream`. A request failed if and only if its `error` is set, in the traffic list, history search, the overview, sessions and the upstream check-up. +- The recorded reason is what the upstream said: `gw.upstream.status_message` (`upstream`, `status`, `message`), read in the upstream's format, masked, and capped at 500 characters. An empty body or an HTML page gives `gw.upstream.status`. +- `TurnView.status` carries the upstream's status code, so a failed turn can say what the upstream answered. +- The client still gets the upstream's answer byte for byte, and a client error still does not count against the upstream. + +**Three guards.** Outbound redaction, tool-call inspection and the content filter now share one model in tw-guard: policy shape, built-in catalogs, validation, rule views, and the logic behind "Test…". The enterprise edition uses the same model. +- **Hidden characters** are built-in content rules matched by code point. `unicode-tags` and `bidi-controls` are on; `zero-width` and `private-use` are off. +- **Actions.** Content rules act with `block`, `strip` (new) or `record` when the filter is in `enforce`. The hidden-character rules strip, where the old guard refused the request. + - Stripped text is removed from user messages and tool results. + - The stripped body is what is redacted, recorded and sent on every hop. + - For tag characters, events and the security log also show the decoded text (`revealed`). +- **Custom rules.** Custom content rules can match code points (`U+200B, U+E0000–U+E007F`). Custom redaction rules can name their placeholder: `label: PROJECT` gives `<>`. +- **New redaction rules.** `email` and `cn-mobile-phone` (Chinese mainland mobile numbers) are built in, off by default. +- **What is screened.** + - Compaction requests are screened, and so are `response.create` frames on the Responses WebSocket. Other WebSocket frames go through the code-point rules only. + - Token counts, embeddings and legacy completions are not screened. + - A refused request still leaves a failed row. +- **Output limit.** It is removed. +- **Testing.** "Test…" takes an action, and shows what would be sent and whether the request would be refused. + +**Two tool-call rules.** Placeholders are restored in answers, so an upstream could write a tool call that sends a restored credential somewhere. Tool calls are judged as the client will execute them, after restoration. Two built-in rules cover this: +- `secret-to-unknown-host` (high: cut in `enforce`). It fires when a tool call makes an http(s) request with an API key or private key in its arguments that the redaction rules recognize, and the destination is neither local nor that credential's own provider (an Anthropic key going to anthropic.com is fine). JWTs and connection strings do not count here. +- `upload-file-to-host` (medium: recorded only). It fires when a tool call uploads a local file to a host that is not local: `curl -T`, `--data @file`, `-F field=@file`, `--upload-file`, `--post-file`. +- The excerpt shows only `scheme://host`, so it never carries the credential. +- Tool-call inspection starts in `observe`, where both rules only record. +- The cut messages name the rule, not who produced the call, since a plugin can produce one too. + +**Script plugins.** Plugins are JavaScript modules that change requests before they go upstream and answers before they reach the client. +- **Sandbox.** They run only inside core, in QuickJS compiled to WebAssembly and run by Wasmtime. They have no file system, network, environment, timers or module imports. A request hook gets a fresh instance for every call. An answer gets one instance, shared by its hooks and discarded when the answer ends. +- **Permissions.** `system`, `messages`, `tools`, `params`, `reply.text` and `reply.tool_calls`. A plugin sees only the parts it was granted. Every edit is checked: keys, read-only fields and permissions. A request no plugin changed reaches the upstream byte for byte. +- **Placeholders.** Plugins never see a real secret. Before a hook runs, values the redaction rules recognize are replaced with placeholders, in every redaction mode and with the same numbering as outbound redaction. Placeholders are put back afterwards, whoever wrote them. Tool-call inspection judges the call after restoration. +- **Request hooks.** + - They run after routing, once per upstream attempt, on the client's request as the content filter left it. + - Failover to another upstream starts again from that request, so an edit made for one upstream never reaches the next. Resends to the same upstream reuse the result. + - A changed request is screened again. Only what the plugins added is reported, and only that can refuse the request. + - `reject()`, an error under `on_error: reject`, or a refusal by the content filter refuses the whole request without failing over. + - A plugin's `params.model` renames what that upstream gets and never re-routes. The gateway key's model list still applies (`gw.plugin.model_not_allowed`). +- **Request kinds.** A plugin handles the kinds of request its manifest lists in `requests`, and conversations only when it lists none. + - Conversations are Anthropic Messages, OpenAI Chat Completions and Responses, and Gemini, with their token counts and compaction. + - Embeddings and legacy completions go only through plugins that declare them, and only their input text can change. + - Other endpoints pass without any plugin. +- **Reply hooks.** They run after format conversion and before tool-call inspection, so inspection sees what the plugin produced. They cover streamed, whole and converted answers, and the Responses WebSocket. Text comes in blocks or as a stream; tool calls come whole. +- **Management.** The `plugins:` section of `config.yaml` lists plugins in the order they run. + - A plugin runs only while the SHA-256 of `plugins/.js` matches its approved `sha256`. A changed file stops it within seconds, until it is approved again. + - Each plugin has `on_error` (`reject` or `skip`), a scope (clients, models sent, upstreams) and settings. +- **Confirmations.** These need the user's confirmation outside the web page: + - installing a plugin, replacing its source, or approving a changed file; + - turning on a plugin that can rewrite tool calls, or changing its settings or scope. + + `UpdatePlugin` refuses the second kind with 403 `control.plugin.needs_confirmation`. A client calls `CreatePlugin`, `ReplacePluginSource`, `ApprovePluginFile` and `UpdatePluginConfirmed` from native code after the user confirms, never from its web view. ThinkWatch Lite asks in a system dialog. +- **Default plugins.** Three ship with core and are added turned off: + - `reply-language` asks the model to answer in a chosen language. + - `wsl-paths` converts drive paths in tool-call arguments between their WSL and Windows forms. + - `deepseek-flags` replaces a flag emoji that DeepSeek's API rejects, and restores it in answers. Its scope is `deepseek*`. + + A default that was deleted is not added back, and one that was changed is left alone. A new version that asks for more permissions or request kinds comes back turned off. The sandbox (about 7 MB of memory) starts only once a plugin is turned on. +- **Limits.** + - A request hook gets 200 ms of CPU and 128 MiB of memory. An answer gets 20 ms per call, 2 s in total and 64 MiB. + - Output is capped at twice the input plus 1 MiB, and logs at 100 lines per call (longer lines are cut at 4 KiB). The source is capped at 1 MiB. + - At most 32 plugin instances run on answers at once. Past that, a plugin follows its `on_error`. +- **Recording.** Every run is recorded with its hook, outcome, CPU time and upstream attempt. + - The request detail shows the runs and the body sent after plugins, which is stored with secrets replaced. History rows say when a plugin changed the request. + - Failures raise `plugin_failed`. + - Each plugin keeps its last 500 log lines in memory. + - A plugin can be tried on a recorded request without contacting an upstream. + +**Security fixes.** +- rustls 0.23.45 fixes RUSTSEC-2026-0285: TLS 1.3 handshake messages were accepted across encryption-level boundaries. Every upstream request goes through rustls. +- The plugin sandbox ships with Wasmtime 49.0.2, which fixes RUSTSEC-2026-0325, -0326 and -0327. Those advisories were published against 49.0.1, which no release contained. +- CI now checks the dependencies against RustSec advisories.