mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 00:48:12 +00:00
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:
@ -1029,6 +1029,40 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
return true, ""
|
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。多个中断在同一推理轮合并时
|
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
|
||||||
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
|
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
|
||||||
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
|
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
|
||||||
@ -1079,13 +1113,28 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
|
|||||||
p.auth.generation = next.generation
|
p.auth.generation = next.generation
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageIDFromInput(raw string) int64 {
|
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
|
||||||
match := qqMessageIDRe.FindStringSubmatch(raw)
|
|
||||||
if len(match) != 2 {
|
// messageIDsFromInput 取出一段输入里出现的全部 message_id。
|
||||||
return 0
|
//
|
||||||
|
// 合并中继的正文是 `(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 ids
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
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)
|
source, _ := ctx.Extra["input_source"].(string)
|
||||||
raw := ctx.RawMessage
|
raw := ctx.RawMessage
|
||||||
ctx.RUnlock()
|
ctx.RUnlock()
|
||||||
|
|
||||||
p.authMu.Lock()
|
p.authMu.Lock()
|
||||||
defer p.authMu.Unlock()
|
// 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
|
||||||
if source != p.name {
|
next := qqAuthContext{active: source == p.name}
|
||||||
p.auth = qqAuthContext{}
|
ids := messageIDsFromInput(raw)
|
||||||
p.resetTurnGuardLocked()
|
if source == p.name && len(ids) > 0 {
|
||||||
return nil
|
if auth, ok := p.authByMessageID[ids[0]]; ok {
|
||||||
}
|
next = auth
|
||||||
if messageID := messageIDFromInput(raw); messageID != 0 {
|
for _, id := range ids {
|
||||||
if auth, ok := p.authByMessageID[messageID]; ok {
|
delete(p.authByMessageID, id)
|
||||||
p.auth = auth
|
}
|
||||||
delete(p.authByMessageID, messageID)
|
|
||||||
p.resetTurnGuardLocked()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
|
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。
|
||||||
p.auth = qqAuthContext{active: true}
|
p.auth = next
|
||||||
p.resetTurnGuardLocked()
|
p.resetTurnGuardLocked()
|
||||||
|
p.authMu.Unlock()
|
||||||
|
|
||||||
|
ctx.Lock()
|
||||||
|
bindAuthOnFrame(ctx, next)
|
||||||
|
ctx.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1130,9 +1182,13 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
|
|||||||
return nil
|
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()
|
p.authMu.RLock()
|
||||||
auth := p.auth
|
if !onFrame {
|
||||||
|
auth = p.auth
|
||||||
|
}
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if auth.active && !auth.owner {
|
if auth.active && !auth.owner {
|
||||||
if auth.isGroup {
|
if auth.isGroup {
|
||||||
@ -1241,7 +1297,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tc := &ctx.ToolCalls[0]
|
tc := &ctx.ToolCalls[0]
|
||||||
allowed, auth := p.currentToolAllowed(tc.Name)
|
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||||
if !auth.active {
|
if !auth.active {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -334,3 +334,100 @@ func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
|
|||||||
t.Fatalf("普通人消息应为 L1,实际 %q", joined)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user