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}" + ); } /// 审查关掉时不该切 —— **安全档位说了算**(三态)。