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"] } 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";