feat(qq): 权限门 + 单轮循环保险 + 极简发送回执 (v1.4.0)

回应 problem.md:核心把「发送成功」的富回执喂给模型,模型读成
「这步成功,继续下一步」而重复调用 output_send__qq。

- handleChannelOutput 成功只返回 "ok",不回传 NapCat 原始响应(含 message_id)
- 全局权限门 on_input/before_toolcall/post_action:工具白名单、私人资源边界、
  高风险命令 confirm 校验;before_toolcall 的 Response 只用于拒绝单个工具,
  post_action 负责清除以免被内核误当作结束推理的最终响应
- 单轮循环保险:max_qq_tool_calls=200 / max_qq_output_calls=20 /
  max_duplicate_qq_send=1,只拦参数完全相同的重复调用,不误杀必需的多次调用
- plg.json 1.2.0 → 1.4.0
This commit is contained in:
JianFeeeee
2026-09-10 20:36:43 +08:00
parent 5175e7d6e0
commit fd5a291df1
3 changed files with 694 additions and 39 deletions

204
example/qq/plugin_test.go Normal file
View File

@ -0,0 +1,204 @@
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)
}
}
}