From cbf4fb3c5c92f37de52cce15916ca9545e493d8e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:09:23 +0800 Subject: [PATCH] Say which rule a cut tool call matched, not who produced it The tool-call guard judges the answer as the client would receive it, and a script plugin with the tool-call permission can produce or modify tool calls. "The call returned by upstream X" is no longer always true, so the sentences now say the answer contained the call. New message codes (the old ones are removed): - gw.toolcall.cut -> gw.toolcall.response_cut - gw.toolcall.blocked -> gw.toolcall.response_withheld - gw.ws.toolcall_cut -> gw.toolcall.connection_cut All three take the same arguments: upstream, tool, rule, name, why (the WebSocket code used to call the reason `detail`). The upstream stays an argument, as it stays in tool_call_flagged and in the log fields. A WebSocket connection cut for a tool call now tells the client the same coded sentence its ending records, as a frame the content filter refuses already does, instead of a separate hard-coded line. The log lines no longer name the upstream as the source either. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/msg-codes.txt | 6 +-- .../tw-gateway/src/server/pipeline/relay.rs | 20 ++++--- crates/tw-gateway/src/ws.rs | 29 ++++++----- crates/tw-gateway/tests/endings.rs | 52 ++++++++++++++++--- crates/tw-gateway/tests/m5_toolwall.rs | 28 +++++++++- crates/tw-gateway/tests/ws.rs | 6 +++ 6 files changed, 106 insertions(+), 35 deletions(-) diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index ab6f6b3..ea10200 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -274,8 +274,9 @@ gw.route.protocol_mismatch gw.route.rule_failed gw.route.selected_upstream_missing gw.route.upstream_missing -gw.toolcall.blocked -gw.toolcall.cut +gw.toolcall.connection_cut +gw.toolcall.response_cut +gw.toolcall.response_withheld gw.upstream.aws_profile_expired gw.upstream.aws_token_expired gw.upstream.bedrock_refused @@ -296,7 +297,6 @@ gw.ws.bad_url gw.ws.connect_failed gw.ws.proxy_unsupported gw.ws.send_failed -gw.ws.toolcall_cut gw.ws.upstream_broke l1.config.bad_url l1.config.no_host diff --git a/crates/tw-gateway/src/server/pipeline/relay.rs b/crates/tw-gateway/src/server/pipeline/relay.rs index 936e856..8c98e23 100644 --- a/crates/tw-gateway/src/server/pipeline/relay.rs +++ b/crates/tw-gateway/src/server/pipeline/relay.rs @@ -531,14 +531,17 @@ impl Relay { if blocked { tracing::warn!( provider = %self.provider, tool = %v.tool, rule = %v.rule, - "cut the response stream: the upstream returned a dangerous tool call" + "cut the response stream: a tool call in the answer matched a cut rule" ); + // **句子不说这个调用出自谁。**审查看的是最后交给客户端的那一份回答, + // 里面的工具调用不一定是上游给的 —— 有工具调用权限的插件也能造、能改。 + // 上游照样在 `upstream` 参数和事件里,只是不当成调用的出处 let err = GatewayError::denied(msg!( - "gw.toolcall.cut", + "gw.toolcall.response_cut", upstream = self.provider.clone(), tool = v.tool.clone(), rule = v.rule.clone(), name = v.name.clone(), why = v.why.clone() => - "The {tool} call returned by upstream `{upstream}` \ - matched rule “{name}”{}, so the response was cut off.", + "The answer contained a {tool} call that matched rule “{name}”{}, \ + so the response was cut off.", because(&v.why) )); // **命中那一帧之前的内容照常发。**模型在动手之前 @@ -638,14 +641,15 @@ impl Relay { if blocked { tracing::warn!( provider = %self.provider, tool = %v.tool, rule = %v.rule, - "withheld the response: the upstream returned a dangerous tool call" + "withheld the response: a tool call in the answer matched a cut rule" ); + // 和流式那句一样不说调用出自谁(见 `wall_cut`) let err = GatewayError::denied(msg!( - "gw.toolcall.blocked", + "gw.toolcall.response_withheld", upstream = self.provider.clone(), tool = v.tool.clone(), rule = v.rule.clone(), name = v.name.clone(), why = v.why.clone() => - "The {tool} call returned by upstream `{upstream}` matched rule \ - “{name}”{}, so the response was withheld.", + "The answer contained a {tool} call that matched rule “{name}”{}, \ + so the response was withheld.", because(&v.why) )); return (Vec::new(), Some(err)); diff --git a/crates/tw-gateway/src/ws.rs b/crates/tw-gateway/src/ws.rs index 52504fd..885f00e 100644 --- a/crates/tw-gateway/src/ws.rs +++ b/crates/tw-gateway/src/ws.rs @@ -310,7 +310,7 @@ enum End { Closed, /// 上游那边出错断了,或者写不过去了 Broke(Msg), - /// 被防护切断了:上游返回了高危工具调用,或者客户端发来的一帧被内容过滤拒了 + /// 被防护切断了:回答里的工具调用命中了切断规则,或者客户端发来的一帧被内容过滤拒了 Cut(Msg), } @@ -406,22 +406,22 @@ async fn pump( }; // **和主管线一模一样的判据**:规则是切断 + 拦截档 let acts = p.rules.inspect_mode.acts(); - let mut deadly = false; - let mut why: Option = None; + // 头一个真要切的命中:告诉客户端的、结局里记的都是这一句 + let mut refusal: Option = None; for h in &hits { let blocked = h.cut && acts; - if blocked && why.is_none() { - why = Some(msg!( - "gw.ws.toolcall_cut", + if blocked && refusal.is_none() { + // 和 HTTP 那条路一样不说调用出自谁(见 relay 的 `wall_cut`) + refusal = Some(msg!( + "gw.toolcall.connection_cut", upstream = p.provider.clone(), tool = h.tool.clone(), rule = h.rule.clone(), name = h.name.clone(), - detail = h.why.clone() => - "The {tool} call returned by upstream `{upstream}` matched \ - rule “{name}”{}, so the connection was cut.", + why = h.why.clone() => + "The answer contained a {tool} call that matched rule \ + “{name}”{}, so the connection was cut.", crate::server::because(&h.why) )); } - deadly |= blocked; // 命中的那一段是还原过的:报出去之前和留档一样打码 let redaction = crate::bodies::Redaction { rules: p.rules.redact.clone(), @@ -435,13 +435,14 @@ async fn pump( &redaction, )); } - if deadly { + if let Some(why) = refusal { // **命中那一帧不发。**和 SSE 那条路同一条纪律: - // 先判断再转发,而不是发完再说 + // 先判断再转发,而不是发完再说。告诉客户端的就是结局里 + // 那句带码的话,和内容过滤拒掉一帧时一样 let _ = c_tx.send(Message::Text( - "[ThinkWatch] the upstream returned a dangerous tool call; the connection was cut".into(), + format!("[ThinkWatch] {}", why.text).into(), )).await; - break End::Cut(why.expect("set on the same pass that set deadly")); + break End::Cut(why); } ending.count(restored.len()); Message::Text(restored.into()) diff --git a/crates/tw-gateway/tests/endings.rs b/crates/tw-gateway/tests/endings.rs index c24aa1a..42d60fa 100644 --- a/crates/tw-gateway/tests/endings.rs +++ b/crates/tw-gateway/tests/endings.rs @@ -456,8 +456,20 @@ async fn a_stream_the_tool_firewall_cuts_is_denied_and_keeps_its_usage() { .. } => { assert_eq!(source, "denied"); - assert_eq!(message.code, "gw.toolcall.cut", "{message}"); + assert_eq!(message.code, "gw.toolcall.response_cut", "{message}"); assert_eq!(message.arg("tool"), "Bash", "{message}"); + assert_eq!(message.arg("rule"), "curl-pipe-sh", "{message}"); + // **句子不说这个调用出自谁**:插件也能造工具调用。上游另作一个参数留着 + assert_eq!(message.arg("upstream"), "up", "{message}"); + let said = format!( + "The answer contained a Bash call that matched rule “{}” ({}), \ + so the response was cut off.", + message.arg("name"), + message.arg("why") + ); + assert!(message.text.ends_with(&said), "{message}"); + // 客户端在流里收到的是同一句 + assert!(text.contains(&format!("[ThinkWatch] {said}")), "{text}"); let u = usage.expect("切断之前的用量没有带上"); assert_eq!(u.input, 5000); } @@ -734,16 +746,40 @@ async fn a_websocket_cut_for_a_dangerous_tool_call_is_failed_as_denied() { .await .unwrap(); + let said = tokio::time::timeout(Duration::from_secs(3), client.next()) + .await + .expect("等说明超时") + .unwrap() + .unwrap() + .into_text() + .unwrap() + .to_string(); + let got = endings(&mut events).await; assert_eq!(got.len(), 1, "该恰好有一个结局:{got:?}"); - assert!( - matches!( - &got[0], - Event::RequestFailed { source, message, .. } - if source == "denied" && message.arg("tool") == "Bash" - ), - "该是一次带着工具名的拦截:{got:?}" + let Event::RequestFailed { + source, message, .. + } = &got[0] + else { + panic!("该是一次拦截,实际 {got:?}"); + }; + assert_eq!(source, "denied"); + assert_eq!(message.code, "gw.toolcall.connection_cut", "{message}"); + assert_eq!(message.arg("tool"), "Bash", "{message}"); + assert_eq!(message.arg("rule"), "curl-pipe-sh", "{message}"); + // **句子不说这个调用出自谁**:插件也能造工具调用。上游另作一个参数留着 + assert_eq!(message.arg("upstream"), "up", "{message}"); + assert_eq!( + message.text, + format!( + "The answer contained a Bash call that matched rule “{}” ({}), \ + so the connection was cut.", + message.arg("name"), + message.arg("why") + ) ); + // 告诉客户端的就是结局里那句带码的话,不是另写的一句 + assert_eq!(said, format!("[ThinkWatch] {}", message.text)); } /// 上游写到一半在流里报错:客户端照样收到上游的原话,**结局是一条失败**,不是成功 —— diff --git a/crates/tw-gateway/tests/m5_toolwall.rs b/crates/tw-gateway/tests/m5_toolwall.rs index 5a5242a..48c3988 100644 --- a/crates/tw-gateway/tests/m5_toolwall.rs +++ b/crates/tw-gateway/tests/m5_toolwall.rs @@ -302,9 +302,13 @@ async fn a_custom_rule_that_says_cut_cuts() { !body.contains("namespace prod"), "自定义的切断规则没切:{body}" ); - // 告诉客户端的那句话按规则名说;自定义规则没有「为什么」,不留一对空括号 + // 告诉客户端的那句话按规则名说;自定义规则没有「为什么」,不留一对空括号。 + // **不说这个调用出自谁**:审查看的是最后那一份回答,插件也能造工具调用 assert!( - body.contains("matched rule “删除集群资源”, so the response was cut off"), + body.contains( + "[ThinkWatch] The answer contained a Bash call that matched rule “删除集群资源”, \ + so the response was cut off." + ), "{body}" ); let (cut, blocked, tool, rule) = flagged(&mut rx).await.expect("没发告警事件"); @@ -447,6 +451,26 @@ async fn enforce_withholds_the_whole_non_streaming_response() { let (cut, blocked, tool, _) = flagged(&mut rx).await.expect("没发告警事件"); assert!(cut && blocked); assert_eq!(tool, "Bash"); + + // 结局里那句话:**不说这个调用出自谁**,上游另作一个参数留着 + let message = loop { + match tokio::time::timeout(Duration::from_secs(3), rx.recv()).await { + Ok(Ok(tw_api::Event::RequestFailed { message, .. })) => break message, + Ok(Ok(_)) => continue, + other => panic!("没等到失败的结局:{other:?}"), + } + }; + assert_eq!(message.code, "gw.toolcall.response_withheld", "{message}"); + assert_eq!(message.arg("upstream"), "relay", "{message}"); + let said = format!( + "The answer contained a Bash call that matched rule “{}” ({}), \ + so the response was withheld.", + message.arg("name"), + message.arg("why") + ); + assert!(message.text.ends_with(&said), "{message}"); + // 换上去的错误体说的是同一句 + assert!(body.contains(&format!("[ThinkWatch] {said}")), "{body}"); } #[tokio::test] diff --git a/crates/tw-gateway/tests/ws.rs b/crates/tw-gateway/tests/ws.rs index 24f9609..c624806 100644 --- a/crates/tw-gateway/tests/ws.rs +++ b/crates/tw-gateway/tests/ws.rs @@ -213,6 +213,12 @@ async fn a_dangerous_tool_call_cuts_the_connection() { !text.contains("evil.example.sh"), "**那条命令还是发给客户端了**:{text}" ); + // 说明按命中的规则说,**不说这个调用出自谁**:插件也能造工具调用 + assert!( + text.starts_with("[ThinkWatch] The answer contained a Bash call that matched rule “") + && text.ends_with(", so the connection was cut."), + "{text}" + ); } /// 审查关掉时不该切 —— **安全档位说了算**(三态)。