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
6 changes: 3 additions & 3 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 12 additions & 8 deletions crates/tw-gateway/src/server/pipeline/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
));
// **命中那一帧之前的内容照常发。**模型在动手之前
Expand Down Expand Up @@ -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));
Expand Down
29 changes: 15 additions & 14 deletions crates/tw-gateway/src/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ enum End {
Closed,
/// 上游那边出错断了,或者写不过去了
Broke(Msg),
/// 被防护切断了:上游返回了高危工具调用,或者客户端发来的一帧被内容过滤拒了
/// 被防护切断了:回答里的工具调用命中了切断规则,或者客户端发来的一帧被内容过滤拒了
Cut(Msg),
}

Expand Down Expand Up @@ -406,22 +406,22 @@ async fn pump(
};
// **和主管线一模一样的判据**:规则是切断 + 拦截档
let acts = p.rules.inspect_mode.acts();
let mut deadly = false;
let mut why: Option<Msg> = None;
// 头一个真要切的命中:告诉客户端的、结局里记的都是这一句
let mut refusal: Option<Msg> = 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(),
Expand All @@ -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())
Expand Down
52 changes: 44 additions & 8 deletions crates/tw-gateway/tests/endings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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));
}

/// 上游写到一半在流里报错:客户端照样收到上游的原话,**结局是一条失败**,不是成功 ——
Expand Down
28 changes: 26 additions & 2 deletions crates/tw-gateway/tests/m5_toolwall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("没发告警事件");
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions crates/tw-gateway/tests/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}

/// 审查关掉时不该切 —— **安全档位说了算**(三态)。
Expand Down
Loading