From 5160d8d4d15cdaaac700261f40d6c3aa10f96a79 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 16:03:28 +0800 Subject: [PATCH] =?UTF-8?q?chore(sdk-mirror):=20qq=20=E6=8F=92=E4=BB=B6=20?= =?UTF-8?q?1.4.1=EF=BC=88=E8=BE=93=E5=87=BA=E5=B7=A5=E5=85=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=97=E5=BD=93=E5=89=8D=E4=BC=9A=E8=AF=9D=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E9=99=90=E5=88=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 镜像 SDK 仓的插件修复:被子的中断唤醒的一轮里,父带齐 meta 调 output_send__qq 也被 「可信 QQ 会话身份不完整」拒掉;输出改为先放行(目标由 meta 决定),读取类工具仍限当前会话。 --- third_party/homeagent-sdk/example/qq/plg.json | 2 +- .../homeagent-sdk/example/qq/plugin.go | 13 + .../homeagent-sdk/example/qq/plugin_test.go | 237 ++++++++++++++++++ 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 third_party/homeagent-sdk/example/qq/plugin_test.go diff --git a/third_party/homeagent-sdk/example/qq/plg.json b/third_party/homeagent-sdk/example/qq/plg.json index 5141ab3..011b52b 100644 --- a/third_party/homeagent-sdk/example/qq/plg.json +++ b/third_party/homeagent-sdk/example/qq/plg.json @@ -2,7 +2,7 @@ "name": "qq", "name_zh": "QQ消息", "name_en": "qq", - "version": "1.4.0", + "version": "1.4.1", "description": "QQ 消息收发插件,通过 NapCat 协议桥接", "author": "HomeAgent", "entry": "plugin.so", diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go index 1dd7144..7e53be8 100644 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ b/third_party/homeagent-sdk/example/qq/plugin.go @@ -925,6 +925,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{} if !auth.active || auth.owner { return true, "" } + // 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。 + // + // 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta + // (group_id / user_id)决定 —— handleChannelOutput 会强制要求该字段存在, + // 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门, + // 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去, + // 报「可信 QQ 会话身份不完整」。 + // 「只能访问当前会话」这类限制只对**读取类**工具(get_history / mark_read / + // get_message)成立 —— 那才是真的不能跨会话读。 + if name == "output_send__"+p.name { + return true, "" + } + currentPeer := auth.userID if auth.isGroup { currentPeer = auth.groupID diff --git a/third_party/homeagent-sdk/example/qq/plugin_test.go b/third_party/homeagent-sdk/example/qq/plugin_test.go new file mode 100644 index 0000000..76e8cde --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/plugin_test.go @@ -0,0 +1,237 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +func newPermissionTestPlugin(t *testing.T) *Plugin { + t.Helper() + instance, err := NewPluginFactory("qq", nil) + if err != nil { + t.Fatal(err) + } + return instance.(*Plugin) +} + +func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext { + return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}} +} + +func TestOwnerBypassesQQPermissionBoundary(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + ctx := toolCallContext("calendar_list", nil) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("owner call rejected: %s", *ctx.Response) + } +} + +func TestPrivateResourceCannotBeAllowlisted(t *testing.T) { + p := newPermissionTestPlugin(t) + p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*") + p.auth = qqAuthContext{active: true, userID: 10001} + ctx := toolCallContext("calendar_list", nil) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") { + t.Fatalf("expected private-resource denial, got %#v", ctx.Response) + } +} + +func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true} + + ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)}) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") { + t.Fatalf("cross-group history not rejected: %#v", ctx.Response) + } + + ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)}) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("current-group history rejected: %s", *ctx.Response) + } +} + +func TestUnmatchedQQInputIsDowngraded(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + ctx := &sdk.StageContext{ + RawMessage: "来自未知事件(message_id=404)", + Extra: map[string]interface{}{"input_source": "qq"}, + } + if err := p.onInputAuthContext(ctx); err != nil { + t.Fatal(err) + } + if !p.auth.active || p.auth.owner || p.auth.userID != 0 { + t.Fatalf("unmatched input reused prior privilege: %+v", p.auth) + } +} + +func TestDuplicateQQOutputIsStopped(t *testing.T) { + p := newPermissionTestPlugin(t) + p.maxDuplicateSend = 1 + p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`} + + ctx := toolCallContext("output_send__qq", args) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("first send rejected: %s", *ctx.Response) + } + + ctx = toolCallContext("output_send__qq", args) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") { + t.Fatalf("duplicate send not stopped: %#v", ctx.Response) + } +} + +func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) { + var path string + var request map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`)) + })) + defer server.Close() + + p := newPermissionTestPlugin(t) + p.napcatURL = server.URL + p.httpClient = server.Client() + _, err := p.handleChannelOutput(map[string]interface{}{ + "payload": "hello", + "type": "text", + "meta": `{"group_id":20002,"user_id":10001}`, + }) + if err != nil { + t.Fatal(err) + } + if path != "/send_group_msg" { + t.Fatalf("path=%q, want /send_group_msg", path) + } + segments, ok := request["message"].([]interface{}) + if !ok || len(segments) < 2 { + t.Fatalf("message is not a segment array: %#v", request["message"]) + } + mention, _ := segments[0].(map[string]interface{}) + data, _ := mention["data"].(map[string]interface{}) + if mention["type"] != "at" || data["qq"] != "10001" { + t.Fatalf("leading mention=%#v", mention) + } +} + +// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。 +// 现在只拦参数完全相同的重复调用。 +func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + // maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。 + for i := 0; i < 5; i++ { + ctx := toolCallContext("output_send__qq", map[string]interface{}{ + "payload": fmt.Sprintf("message-%d", i), + "type": "text", + "meta": `{"user_id":123}`, + }) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response) + } + } +} + +func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + // 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。 + for i := 0; i < 50; i++ { + ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)}) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response) + } + } +} + +func TestZeroLimitsMeanUnlimited(t *testing.T) { + p := newPermissionTestPlugin(t) + p.maxQQOutputCalls = 0 + p.maxDuplicateSend = 0 + p.maxQQToolCalls = 0 + p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + for i := 0; i < 30; i++ { + ctx := toolCallContext("output_send__qq", map[string]interface{}{ + "payload": "same-content", + "type": "text", + "meta": `{"user_id":123}`, + }) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response) + } + } +} + +// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时, +// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定, +// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去 +// (报「可信 QQ 会话身份不完整」)。 +// +// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。 +func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{active: true} + p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"} + p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}} + + ctx := toolCallContext("output_send__qq", map[string]interface{}{ + "payload": "带齐 meta 的主动发送", + "type": "text", + "meta": `{"user_id":2198972886}`, + }) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("降权时输出被拒: %s", *ctx.Response) + } + + ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713}) + if err := p.beforeToolcall(ctx2); err != nil { + t.Fatal(err) + } + if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") { + t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response) + } +}