Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
7 changes: 6 additions & 1 deletion crates/gateway/src/lifecycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
86 changes: 85 additions & 1 deletion crates/gateway/src/tool_inspection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Wall> {
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.
Expand Down Expand Up @@ -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<crate::error::GatewayError> {
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
Expand Down Expand Up @@ -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";
Expand Down
Loading