From 3e4bb6d4b3c329fd8bc5a5999def9f2a017e60f9 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:09:28 +0800 Subject: [PATCH 1/2] Tool inspection: decide on settled input, record whole calls Follows the core change to tw-guard's streaming tool-call wall: - The stream wall is told whether this mode can cut (`cut_early`). In observe nothing can, so every rule waits for the call to complete and the audit row carries the whole call instead of the piece that had arrived when the rule first matched. - When a stream runs to its end, `StreamInspector::finish` checks Chat calls that never got a `finish_reason` or `[DONE]`; one that stops the response keeps the tail back. - Tests: a recorded hit waits for the whole call; `-T` outside curl is not an upload. Needs the core release that adds `Wall::cut_early` / `Wall::finish`; the tw-* pins move to it before this merges. Co-Authored-By: Claude Opus 5.5 --- crates/gateway/src/lifecycle/mod.rs | 7 ++- crates/gateway/src/tool_inspection.rs | 86 ++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/gateway/src/lifecycle/mod.rs b/crates/gateway/src/lifecycle/mod.rs index 14462342..3013224e 100644 --- a/crates/gateway/src/lifecycle/mod.rs +++ b/crates/gateway/src/lifecycle/mod.rs @@ -334,7 +334,12 @@ pub(crate) fn build_chat_pump( out.extend(shaper.finish()); // The converter's last bytes can complete a tool call (the block's // stop), so they are inspected too. - let stop = inspector.as_mut().and_then(|i| i.check(&out)); + // A Chat call with no `finish_reason` or `[DONE]` completes only + // as the stream closes; one that stops it keeps the tail back. + let stop = inspector.as_mut().and_then(|i| { + i.check(&out) + .or_else(|| i.finish().map(|err| (err, 0))) + }); if let Some((err, safe)) = stop { let out = cut(&shaper, None, client, &out[..safe], &err); tell(&mut done_tx, StreamOutcome::UpstreamError { diff --git a/crates/gateway/src/tool_inspection.rs b/crates/gateway/src/tool_inspection.rs index 1deb4bf5..1235de2c 100644 --- a/crates/gateway/src/tool_inspection.rs +++ b/crates/gateway/src/tool_inspection.rs @@ -59,8 +59,16 @@ impl ToolInspection { } /// A wall for an SSE stream in the client's format. `None` when off. + /// + /// Only the rules that can stop the response are checked while a call + /// is still arriving, and only on what has settled (a host cut off at + /// `http://127` is not yet an external one). Everything else waits for + /// the call to complete, so a recorded hit carries the whole call. In + /// observe nothing can stop it, so every rule waits. pub fn stream(&self) -> Option { - self.mode.detects().then(|| Wall::new(self.rules.clone())) + self.mode + .detects() + .then(|| Wall::new(self.rules.clone()).cut_early(self.mode.acts())) } /// Inspect a whole response. Empty when off. @@ -128,6 +136,29 @@ impl StreamInspector { } None } + + /// The stream ran to its end. A Chat call that never got its + /// `finish_reason` or `[DONE]` is complete only now: the client runs it + /// when the stream closes. Returns the refusal when one stops it; the + /// rest of the stream should not go out then. + pub fn finish(&mut self) -> Option { + for v in self.wall.finish() { + let blocked = self.inspection.blocks(&v); + record( + &self.audit, + &self.caller, + &self.provider, + &v, + blocked, + self.inspection.mode, + &self.mask, + ); + if blocked { + return Some(refusal(&v)); + } + } + None + } } /// What the caller is told when a response is cut. Names the tool and the @@ -364,6 +395,59 @@ mod tests { assert_eq!(found[0].rule, "upload-file-to-host"); } + /// A hit that is only recorded waits for the call to complete: the + /// audit row carries the command the client got, not the piece that had + /// arrived when the rule first matched. + #[test] + fn a_recorded_hit_waits_for_the_whole_call() { + let t = ToolInspection::default(); + let mut wall = t.stream().expect("on by default"); + let ev = |name: &str, d: serde_json::Value| format!("event: {name}\ndata: {d}\n\n"); + let delta = |part: &str| { + ev( + "content_block_delta", + serde_json::json!({"type": "content_block_delta", "index": 0, + "delta": {"type": "input_json_delta", "partial_json": part}}), + ) + }; + wall.feed( + ev( + "content_block_start", + serde_json::json!({"type": "content_block_start", "index": 0, + "content_block": {"type": "tool_use", "id": "t1", "name": "bash", "input": {}}}), + ) + .as_bytes(), + ); + assert!( + wall.feed(delta(r#"{"command":"rm -rf / "#).as_bytes()) + .is_empty() + ); + assert!(wall.feed(delta(r#"&& echo done"}"#).as_bytes()).is_empty()); + let found = wall.feed( + ev( + "content_block_stop", + serde_json::json!({"type": "content_block_stop", "index": 0}), + ) + .as_bytes(), + ); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].rule, "rm-rf-root"); + assert_eq!(found[0].arguments, r#"{"command":"rm -rf / && echo done"}"#); + } + + /// `-T` is an upload only in curl: `docker compose exec -T` turns off + /// the terminal. + #[test] + fn a_dash_t_outside_curl_is_not_an_upload() { + let t = ToolInspection::default(); + assert!( + t.whole(&call( + "docker compose exec -T postgres psql -c 'select 1'; open https://docs.example.com" + )) + .is_empty() + ); + } + #[test] fn the_excerpt_a_hit_reports_can_be_masked() { let key = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA"; From 8b140320824563f4dd25539be726b447ae9a5224 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:54:38 +0800 Subject: [PATCH 2/2] Pin thinkwatch-core v0.77.0; changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 +++++++++++++++++++++ Cargo.lock | 16 ++++++++-------- Cargo.toml | 8 ++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a07bc47..e42eb9bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ target. ## [Unreleased] +### Changed + +- The thinkwatch-core crates move from v0.75.0 to v0.77.0. + +### Fixed + +- **Tool-call inspection no longer judges arguments that are still + arriving.** A streamed tool call was checked against its arguments as + far as they had come in. A host cut off in the middle of its name + (`http://127` before `.0.0.1`) looked external, and a pattern anchored + at the end could match the half-received tail. A hit now counts only + once it is settled; rules that cut still act as early as possible. +- **A recorded hit carries the whole call.** A hit that is only recorded, + and every hit in observe mode, was recorded with the arguments as they + stood at the first match. It is now recorded once the call is complete. + A Chat Completions call with no `finish_reason` or `[DONE]` is checked + when the stream ends. +- **`-T` is an upload only in curl.** The upload rule took any `-T` for + `curl -T`, so `docker compose exec -T` next to a URL was recorded as a + file upload. + ## [3.8.2] — 2026-10-16 Tool-call inspection now checks every choice of an answer, not only the diff --git a/Cargo.lock b/Cargo.lock index 662d81c5..b0967cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4837,8 +4837,8 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.77.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -4852,16 +4852,16 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.77.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" dependencies = [ "serde", ] [[package]] name = "tw-dialect" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.77.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" dependencies = [ "memchr", "serde", @@ -4870,8 +4870,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.77.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" dependencies = [ "aho-corasick", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 6d0a2ee8..48ffbf79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,10 +70,10 @@ opt-level = 3 # never re-exported through a local shim. And the reverse: something only # this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error) # lives here, not in core. -tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.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" } +tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } +tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } +tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } # Web framework axum = { version = "0.8", features = ["macros", "ws"] }