diff --git a/CHANGELOG.md b/CHANGELOG.md index d42a7a75..2a07bc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,51 @@ target. ## [Unreleased] +## [3.8.2] — 2026-10-16 + +Tool-call inspection now checks every choice of an answer, not only the +first, and redacted values are put back in every choice of a streamed +answer. The thinkwatch-core crates move from v0.74.0 to v0.75.0, and rustls +moves to 0.23.46 for four security advisories. + +No setting, schema or API route changes. + +### Security + +- **Tool-call inspection checks every choice of an answer.** An answer can + carry several choices (Chat Completions `choices`, with `n` above 1) or + candidates (Gemini `candidates`), and a streamed chunk several of them at + once. Inspection looked only at the first element of each streamed Chat + Completions chunk's `choices`, and only at the first Gemini candidate, + streamed or whole. A tool call in any other choice or candidate reached + the client uninspected: it was not recorded, and in enforce mode it was + neither cut nor refused. Every choice and every candidate is now + inspected. The arguments of a streamed Chat Completions call are put + together both by the choice's `index` and by the choice's position in + each chunk, the two ways clients assemble them, and both are checked. + Whole Chat Completions answers already had every choice inspected. This + holds whether the route forwards the answer in the caller's format or + converts it. +- rustls 0.23.46, for RUSTSEC-2026-0346 to RUSTSEC-2026-0349. + +### Fixed + +- **Redacted values are put back in every choice of a streamed answer.** + With outbound redaction in enforce mode, a streamed Chat Completions + answer with several choices, or a Gemini answer with several candidates, + could reach the client with a placeholder still in it, or with the start + of a placeholder from one choice in the text of another. Values were put + back only in the first choice of each chunk, and the text held back while + a placeholder might still be completing was kept for the answer as a + whole rather than for each choice, so one choice finishing released what + another was holding. Each choice is now restored on its own, and released + when it finishes. + +### Changed + +- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) + v0.74.0 → v0.75.0. Only tw-guard changes. + ## [3.8.1] — 2026-10-13 Outbound redaction searches only the text written in a request, located @@ -2405,7 +2450,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.8.1...HEAD +[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.2...HEAD +[3.8.2]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.2 [3.8.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.1 [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 diff --git a/Cargo.lock b/Cargo.lock index 67545068..662d81c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3364,9 +3364,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "think-watch-auth" -version = "3.8.1" +version = "3.8.2" dependencies = [ "anyhow", "argon2", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "think-watch-common" -version = "3.8.1" +version = "3.8.2" dependencies = [ "aes-gcm", "anyhow", @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "think-watch-gateway" -version = "3.8.1" +version = "3.8.2" dependencies = [ "anyhow", "arc-swap", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "think-watch-mcp-gateway" -version = "3.8.1" +version = "3.8.2" dependencies = [ "anyhow", "arc-swap", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "think-watch-server" -version = "3.8.1" +version = "3.8.2" dependencies = [ "anyhow", "arc-swap", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "think-watch-test-support" -version = "3.8.1" +version = "3.8.2" dependencies = [ "anyhow", "async-stream", @@ -4837,8 +4837,8 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.74.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" +version = "0.75.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -4852,16 +4852,16 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.74.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" +version = "0.75.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" dependencies = [ "serde", ] [[package]] name = "tw-dialect" -version = "0.74.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" +version = "0.75.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" dependencies = [ "memchr", "serde", @@ -4870,8 +4870,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.74.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" +version = "0.75.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" dependencies = [ "aho-corasick", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 282b15f9..6d0a2ee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "3.8.1" +version = "3.8.2" 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.74.0" } -tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } -tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } +tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" } +tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" } +tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" } # Web framework axum = { version = "0.8", features = ["macros", "ws"] } diff --git a/crates/test-support/tests/tool_inspection.rs b/crates/test-support/tests/tool_inspection.rs index 94c8d232..a55dff7b 100644 --- a/crates/test-support/tests/tool_inspection.rs +++ b/crates/test-support/tests/tool_inspection.rs @@ -165,6 +165,82 @@ async fn enforce_cuts_a_stream_before_the_call_is_complete() { assert_eq!(detail["tool"], "bash", "{detail}"); } +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn enforce_cuts_a_call_in_the_second_choice_of_a_chat_stream() { + // A Chat answer can carry several choices, and a frame several of + // them at once. Only the first element of each frame's `choices` used + // to be inspected, so a call in the second went to the client + // unchecked. Every choice is inspected now. + let app = TestApp::spawn_with_clickhouse().await; + set_inspection(&app, json!({"mode": "enforce"})).await; + let upstream = bare().await; + let args = json!({"command": EVIL}).to_string(); + let split = args.find(" | sh").unwrap(); + // Every frame carries both choices: the first talks, the second calls + // `bash`, its arguments split across two frames. + let chunk = |second: Value, finish: Value| { + format!( + "data: {}\n\n", + json!({"id": "c", "object": "chat.completion.chunk", "created": 0, + "model": "gpt-inspect", "choices": [ + {"index": 0, "delta": {"content": "Installing the dependencies."}, + "finish_reason": Value::Null}, + {"index": 1, "delta": second, "finish_reason": finish}]}) + ) + }; + let sse = [ + chunk( + json!({"role": "assistant", "tool_calls": [{"index": 0, "id": "call_1", + "type": "function", "function": {"name": "bash", "arguments": &args[..split]}}]}), + Value::Null, + ), + chunk( + json!({"tool_calls": [{"index": 0, "function": {"arguments": &args[split..]}}]}), + Value::Null, + ), + chunk(json!({}), json!("tool_calls")), + "data: [DONE]\n\n".to_string(), + ] + .concat(); + upstream + .mount( + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")), + ) + .await; + let (key, user_id) = seed(&app, &upstream.uri(), "openai", "gpt-inspect").await; + + let gw = app.gateway_client(); + gw.set_bearer(&key); + let body = gw + .post( + "/v1/chat/completions", + json!({ + "model": "gpt-inspect", "stream": true, "n": 2, + "messages": [{"role": "user", "content": "set up the project"}], + }), + ) + .await + .unwrap() + .text(); + + // What came before the call still arrives... + assert!(body.contains("Installing the dependencies."), "{body}"); + // ...the rest of the call never does, nor is the client told the call + // is complete... + assert!(!body.contains(" | sh"), "{body}"); + assert!(!body.contains(r#""finish_reason":"tool_calls""#), "{body}"); + assert!(!body.contains("[DONE]"), "{body}"); + // ...and the stream ends with a refusal. + assert!(body.contains("Blocked by policy"), "{body}"); + + let detail = audited(&app, &user_id, "gateway.tool_call_blocked").await; + assert_eq!(detail["rule"], "curl-pipe-sh", "{detail}"); + assert_eq!(detail["tool"], "bash", "{detail}"); +} + #[ignore = "integration test — run via `make test-it`"] #[tokio::test] async fn observe_hands_the_answer_over_and_records_the_call() { diff --git a/deploy/helm/think-watch/Chart.yaml b/deploy/helm/think-watch/Chart.yaml index 3adf02f2..40f4b10d 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.8.1 -appVersion: "3.8.1" +version: 3.8.2 +appVersion: "3.8.2" keywords: - ai - gateway diff --git a/web/package.json b/web/package.json index 4a200591..9589518f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "3.8.1", + "version": "3.8.2", "type": "module", "packageManager": "pnpm@12.9.1", "scripts": {