fix(qq): 权限身份改为绑帧,修中断抢占/运行中到达导致的串权与失效

问题(都是插件全局 p.auth 一份状态引起):
- 中断抢占当前轮并把现场压栈,中断轮收尾 afterOutput 清空全局身份;外层
  恢复(resumeTask 复用同帧、不重跑 StageOnInput)后 auth.active=false,
  beforeToolcall 在 !active 处直接返回 —— 该轮剩余工具调用**完全不受门**。
- 运行中到达的新消息会调 activateAuthContext 改写全局身份,把正在跑的那一轮
  换成另一方的身份:换高即越权,换低即误拒。

改法:身份在 StageOnInput 绑定到本帧的 StageContext.Extra 上,beforeToolcall
以帧上身份为准(无绑定时才回退插件全局,兼容单测)。帧随中断栈一起压栈/恢复,
身份自然跟着走。

顺带:合并中断正文里的整批 message_id 现在全部消费(原来只清第一个,其余要等
generation 回收),新增 qqMessageIDsRe 支持 message_id=100,101,102 连写。
新增 4 条测试覆盖:中断恢复、运行中到达、整批 id 消费、非 QQ 轮不受门。
This commit is contained in:
JianFeeeee
2026-09-14 16:45:09 +08:00
parent fe1c4cdb09
commit cfa72df3e9
2 changed files with 176 additions and 23 deletions

View File

@ -1029,6 +1029,40 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
return true, ""
}
// qqAuthExtraKey 是本轮QQ 身份挂在 StageContext.Extra 上的键。
//
// 身份必须**绑帧**,不能只存插件全局:
// - 中断会抢占当前轮并把现场压栈scheduler 的 suspendStack中断轮收尾时
// afterOutput 把插件全局身份清空随后外层被恢复resumeTask 复用同一帧、
// 不重跑 StageOnInput若身份只存全局恢复后的外层就是"无身份"——
// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。
// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮**
// 换成另一方的身份(换高=越权,换低=误拒)。
//
// 帧上的 Extra 随帧一起压栈/恢复,正好是"这一轮的身份"。
const qqAuthExtraKey = "qq_auth"
// authOnFrame 读取本帧绑定的身份ok=false 表示本帧未绑定过 QQ 身份。
// 调用方需持有 ctx 的读(或写)锁。
func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) {
if ctx == nil || ctx.Extra == nil {
return qqAuthContext{}, false
}
auth, ok := ctx.Extra[qqAuthExtraKey].(qqAuthContext)
return auth, ok
}
// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。
func bindAuthOnFrame(ctx *sdk.StageContext, auth qqAuthContext) {
if ctx == nil {
return
}
if ctx.Extra == nil {
ctx.Extra = make(map[string]interface{})
}
ctx.Extra[qqAuthExtraKey] = auth
}
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
@ -1079,13 +1113,28 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
p.auth.generation = next.generation
}
func messageIDFromInput(raw string) int64 {
match := qqMessageIDRe.FindStringSubmatch(raw)
if len(match) != 2 {
return 0
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
// messageIDsFromInput 取出一段输入里出现的全部 message_id。
//
// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理;
// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。
func messageIDsFromInput(raw string) []int64 {
matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1)
ids := make([]int64, 0, len(matches))
for _, match := range matches {
if len(match) != 2 {
continue
}
for _, part := range strings.Split(match[1], ",") {
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || id == 0 {
continue
}
ids = append(ids, id)
}
}
id, _ := strconv.ParseInt(match[1], 10, 64)
return id
return ids
}
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
@ -1093,24 +1142,27 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
source, _ := ctx.Extra["input_source"].(string)
raw := ctx.RawMessage
ctx.RUnlock()
p.authMu.Lock()
defer p.authMu.Unlock()
if source != p.name {
p.auth = qqAuthContext{}
p.resetTurnGuardLocked()
return nil
}
if messageID := messageIDFromInput(raw); messageID != 0 {
if auth, ok := p.authByMessageID[messageID]; ok {
p.auth = auth
delete(p.authByMessageID, messageID)
p.resetTurnGuardLocked()
return nil
// 默认降权QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
next := qqAuthContext{active: source == p.name}
ids := messageIDsFromInput(raw)
if source == p.name && len(ids) > 0 {
if auth, ok := p.authByMessageID[ids[0]]; ok {
next = auth
for _, id := range ids {
delete(p.authByMessageID, id)
}
}
}
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份
p.auth = qqAuthContext{active: true}
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上
p.auth = next
p.resetTurnGuardLocked()
p.authMu.Unlock()
ctx.Lock()
bindAuthOnFrame(ctx, next)
ctx.Unlock()
return nil
}
@ -1130,9 +1182,13 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
return nil
}
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) {
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
auth, onFrame := authOnFrame(ctx)
p.authMu.RLock()
auth := p.auth
if !onFrame {
auth = p.auth
}
var patterns []string
if auth.active && !auth.owner {
if auth.isGroup {
@ -1241,7 +1297,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
return nil
}
tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(tc.Name)
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
if !auth.active {
return nil
}

View File

@ -334,3 +334,100 @@ func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
t.Fatalf("普通人消息应为 L1实际 %q", joined)
}
}
// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后,
// 外层轮被恢复resumeTask 复用同一帧、不重跑 onInput时权限门不能整体失效。
func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
p := newPermissionTestPlugin(t)
// 中断轮Bot 所有者跑完afterOutput 会清掉插件全局身份。
inner := &sdk.StageContext{Extra: map[string]interface{}{
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
}}
if err := p.afterOutputAuthContext(inner); err != nil {
t.Fatal(err)
}
if p.auth.active {
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
}
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
frame := &sdk.StageContext{
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
if err := p.beforeToolcall(frame); err != nil {
t.Fatal(err)
}
if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") {
t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response)
}
}
// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。
func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
p := newPermissionTestPlugin(t)
frame := &sdk.StageContext{
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
// 路人的群消息在所有者轮运行中到达。
p.activateAuthContext(4242, 10001, 20002, true)
if p.auth.owner {
t.Fatal("到达事件应改写全局身份(复现场景)")
}
if err := p.beforeToolcall(frame); err != nil {
t.Fatal(err)
}
if frame.Response != nil {
t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response)
}
}
// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
p := newPermissionTestPlugin(t)
p.authByMessageID = map[int64]qqAuthContext{
100: {active: true, owner: true, userID: 2198972886},
101: {active: true, owner: true, userID: 2198972886},
}
ctx := &sdk.StageContext{
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.owner {
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
}
if len(p.authByMessageID) != 0 {
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
}
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
}
}
// 非 QQ 来源webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "webui 里的提问",
Extra: map[string]interface{}{"input_source": "webui"},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
}
}