From 777c3a0ad397ee4d83e9e7e4047c2323930a07f3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 12 Oct 2026 08:28:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(proc,qq)!:=20shm=20=E6=A8=A1=E6=9D=BF=20v2?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20fExtraBag=20=E9=80=8F=E4=BC=A0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=87=AA=E5=BB=BA=20Extra=20=E9=94=AE=EF=BC=9Bqq=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=8F=AF=E5=BA=8F=E5=88=97=E5=8C=96=E7=BB=91?= =?UTF-8?q?=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核 shm 桥 v2 前(见主仓同步提交)只透传 4 个具名 Extra 键,插件自建键 (qq 的 qq_auth 绑帧身份)在 stage 之间静默丢失 → before_toolcall 永远回落 可变全局身份 → 权限门绑帧在 proc 插件下整体失效(owner 被误拒/非 owner 可能被误放,2026-10-12 生产实证)。 模板(proc_main.go.tmpl): - shmStageFieldCount 18→19、shmVersion 1→2(与内核镜像同步) - fExtraBag:具名 4 键之外的自建键整体 JSON 装袋,read/snapshot/dirty 全链接入 - ReadInto:bag 先打平、具名键后覆盖(具名键永远赢,防御袋污染) qq 插件: - qqAuthContext 字段导出 + json tag:非导出字段过 JSON 桥会静默丢空 (原根因链一环) - authOnFrame 双形态:值形态(进程内/单测)+ map 形态(proc 桥往返, 数字 float64);半截身份拒绝 —— 宁回落全局也不猜(owner 误判即提权) - authFromMap 按 json tag 重建,逐字段校验类型 判据(plugin_test.go):往返无损/降权保持/半截拒绝(逐键)/帧身份优先于 全局(含 owner 被误拒的原始故障场景反向)。变异 Q1-Q4 全杀。 --- example/qq/plugin.go | 147 ++++++++++++++------- example/qq/plugin_test.go | 152 +++++++++++++++++++--- tools/hmapdev/templates/proc_main.go.tmpl | 42 +++++- 3 files changed, 271 insertions(+), 70 deletions(-) diff --git a/example/qq/plugin.go b/example/qq/plugin.go index f7649d7..f004c0c 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -85,14 +85,58 @@ type DownloadTask struct { CreatedAt string `json:"created_at"` } +// qqAuthContext 是绑帧身份。字段导出 + json tag:proc 插件下身份要经 +// 共享段 JSON 往返(内核 shm 桥 v2 的 fExtraBag),非导出字段会在 +// marshal 时静默丢失 —— 那正是 2026-10-12 权限门失效的根因链一环。 type qqAuthContext struct { - active bool - owner bool - messageID int64 - userID int64 - groupID int64 - isGroup bool - generation uint64 + Active bool `json:"active"` + Owner bool `json:"owner"` + MessageID int64 `json:"message_id"` + UserID int64 `json:"user_id"` + GroupID int64 `json:"group_id"` + IsGroup bool `json:"is_group"` + Generation uint64 `json:"generation"` +} + +// authFromMap 按 JSON 往返约定重建身份(数字统一 float64)。 +// 解不出完整身份(键缺失/类型错)返回 false —— 宁回落全局也不猜: +// 半截的身份比没有更危险(owner 误判即提权)。 +func authFromMap(m map[string]interface{}) (qqAuthContext, bool) { + var a qqAuthContext + getBool := func(k string) (bool, bool) { + v, ok := m[k].(bool) + return v, ok + } + getInt := func(k string) (int64, bool) { + f, ok := m[k].(float64) + if !ok { + return 0, false + } + return int64(f), true + } + var ok bool + if a.Active, ok = getBool("active"); !ok { + return a, false + } + if a.Owner, ok = getBool("owner"); !ok { + return a, false + } + if a.MessageID, ok = getInt("message_id"); !ok { + return a, false + } + if a.UserID, ok = getInt("user_id"); !ok { + return a, false + } + if a.GroupID, ok = getInt("group_id"); !ok { + return a, false + } + if a.IsGroup, ok = getBool("is_group"); !ok { + return a, false + } + if g, present := m["generation"].(float64); present { + a.Generation = uint64(g) + } + return a, true } var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`) @@ -982,7 +1026,7 @@ func argInt64(args map[string]interface{}, key string) (int64, bool) { } func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) { - if !auth.active || auth.owner { + if !auth.Active || auth.Owner { return true, "" } // 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。 @@ -998,9 +1042,9 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{} return true, "" } - currentPeer := auth.userID - if auth.isGroup { - currentPeer = auth.groupID + currentPeer := auth.UserID + if auth.IsGroup { + currentPeer = auth.GroupID } if currentPeer == 0 { return false, "可信 QQ 会话身份不完整" @@ -1008,10 +1052,10 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{} matchCurrentPeer := func() bool { groupID, hasGroup := argInt64(args, "group_id") userID, hasUser := argInt64(args, "user_id") - if auth.isGroup { - return hasGroup && groupID == auth.groupID && !hasUser + if auth.IsGroup { + return hasGroup && groupID == auth.GroupID && !hasUser } - return hasUser && userID == auth.userID && !hasGroup + return hasUser && userID == auth.UserID && !hasGroup } switch name { @@ -1024,16 +1068,16 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{} if !ok { return false, "缺少有效 message_id" } - if messageID == auth.messageID { + if messageID == auth.MessageID { return true, "" } peerID, isGroup, _, found := p.lookupMsgRef(messageID) - if !found || isGroup != auth.isGroup || peerID != currentPeer { + if !found || isGroup != auth.IsGroup || peerID != currentPeer { return false, "message_id 不属于当前 QQ 会话" } case p.name + "_get_group_member_info", p.name + "_get_group_files": groupID, ok := argInt64(args, "group_id") - if !auth.isGroup || !ok || groupID != auth.groupID { + if !auth.IsGroup || !ok || groupID != auth.GroupID { return false, "只能访问当前 QQ 群的数据" } } @@ -1046,7 +1090,7 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{} // - 中断会抢占当前轮并把现场压栈(scheduler 的 suspendStack),中断轮收尾时 // afterOutput 把插件全局身份清空;随后外层被恢复(resumeTask 复用同一帧、 // 不重跑 StageOnInput),若身份只存全局,恢复后的外层就是"无身份"—— -// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。 +// beforeToolcall 会在 !auth.Active 处直接返回,权限门整体失效。 // - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮** // 换成另一方的身份(换高=越权,换低=误拒)。 // @@ -1054,13 +1098,22 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{} const qqAuthExtraKey = "qq_auth" // authOnFrame 读取本帧绑定的身份;ok=false 表示本帧未绑定过 QQ 身份。 -// 调用方需持有 ctx 的读(或写)锁。 +// +// 双形态兼容(proc 桥 JSON 往返约定): +// - 值形态(进程内 stage / 单测):类型断言直接命中; +// - map 形态(proc 插件:身份经共享段 JSON 往返):按 json tag 重建。 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 + switch v := ctx.Extra[qqAuthExtraKey].(type) { + case qqAuthContext: + return v, true + case map[string]interface{}: + return authFromMap(v) + default: + return qqAuthContext{}, false + } } // bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。 @@ -1085,43 +1138,43 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b } p.authGeneration++ next := qqAuthContext{ - active: true, owner: p.isOwner(userID), messageID: messageID, userID: userID, - groupID: groupID, isGroup: isGroup, generation: p.authGeneration, + Active: true, Owner: p.isOwner(userID), MessageID: messageID, UserID: userID, + GroupID: groupID, IsGroup: isGroup, Generation: p.authGeneration, } if messageID != 0 { p.authByMessageID[messageID] = next if len(p.authByMessageID) > 2048 { cutoff := p.authGeneration - 1024 for id, auth := range p.authByMessageID { - if auth.generation < cutoff { + if auth.Generation < cutoff { delete(p.authByMessageID, id) } } } } - if !p.auth.active { + if !p.auth.Active { p.auth = next return } - if p.auth.userID == userID && p.auth.groupID == groupID && p.auth.isGroup == isGroup { - p.auth.owner = p.auth.owner && next.owner - p.auth.messageID = next.messageID - p.auth.generation = next.generation + if p.auth.UserID == userID && p.auth.GroupID == groupID && p.auth.IsGroup == isGroup { + p.auth.Owner = p.auth.Owner && next.Owner + p.auth.MessageID = next.MessageID + p.auth.Generation = next.Generation return } // 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。 - bothOwners := p.auth.owner && next.owner - sameGroup := p.auth.isGroup && next.isGroup && p.auth.groupID == groupID - p.auth.owner = bothOwners - p.auth.messageID = 0 - p.auth.userID = 0 - p.auth.isGroup = sameGroup + bothOwners := p.auth.Owner && next.Owner + sameGroup := p.auth.IsGroup && next.IsGroup && p.auth.GroupID == groupID + p.auth.Owner = bothOwners + p.auth.MessageID = 0 + p.auth.UserID = 0 + p.auth.IsGroup = sameGroup if sameGroup { - p.auth.groupID = groupID + p.auth.GroupID = groupID } else { - p.auth.groupID = 0 + p.auth.GroupID = 0 } - p.auth.generation = next.generation + p.auth.Generation = next.Generation } var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`) @@ -1156,7 +1209,7 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error { p.authMu.Lock() // 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。 - next := qqAuthContext{active: source == p.name} + next := qqAuthContext{Active: source == p.name} ids := messageIDsFromInput(raw) if source == p.name && len(ids) > 0 { if auth, ok := p.authByMessageID[ids[0]]; ok { @@ -1201,9 +1254,9 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q auth = p.auth } var patterns []string - if auth.active && !auth.owner { - if auth.isGroup { - patterns, _ = p.groupToolAllowlists[auth.groupID] + if auth.Active && !auth.Owner { + if auth.IsGroup { + patterns, _ = p.groupToolAllowlists[auth.GroupID] if patterns == nil { patterns = p.groupToolAllowlists[0] } @@ -1212,7 +1265,7 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q } } p.authMu.RUnlock() - if !auth.active || auth.owner { + if !auth.Active || auth.Owner { return true, auth } return matchesToolAllowlist(name, patterns), auth @@ -1309,7 +1362,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error { } tc := &ctx.ToolCalls[0] allowed, auth := p.currentToolAllowed(ctx, tc.Name) - if !auth.active { + if !auth.Active { return nil } @@ -1352,7 +1405,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error { p.setDenial(ctx, msg) return nil } - if !auth.owner && isHardPrivateTool(tc.Name) { + if !auth.Owner && isHardPrivateTool(tc.Name) { p.authMu.Lock() p.denialLocked = true p.authMu.Unlock() @@ -1362,8 +1415,8 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error { } if !allowed { scope := "非所有者私聊" - if auth.isGroup { - scope = fmt.Sprintf("群聊 %d", auth.groupID) + if auth.IsGroup { + scope = fmt.Sprintf("群聊 %d", auth.GroupID) } msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope) p.setDenial(ctx, msg) diff --git a/example/qq/plugin_test.go b/example/qq/plugin_test.go index 4911591..a27d274 100644 --- a/example/qq/plugin_test.go +++ b/example/qq/plugin_test.go @@ -27,7 +27,7 @@ func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext func TestOwnerBypassesQQPermissionBoundary(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886} ctx := toolCallContext("calendar_list", nil) if err := p.beforeToolcall(ctx); err != nil { t.Fatal(err) @@ -40,7 +40,7 @@ func TestOwnerBypassesQQPermissionBoundary(t *testing.T) { func TestPrivateResourceCannotBeAllowlisted(t *testing.T) { p := newPermissionTestPlugin(t) p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*") - p.auth = qqAuthContext{active: true, userID: 10001} + p.auth = qqAuthContext{Active: true, UserID: 10001} ctx := toolCallContext("calendar_list", nil) if err := p.beforeToolcall(ctx); err != nil { t.Fatal(err) @@ -52,7 +52,7 @@ func TestPrivateResourceCannotBeAllowlisted(t *testing.T) { func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true} + 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 { @@ -73,7 +73,7 @@ func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) { func TestUnmatchedQQInputIsDowngraded(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886} ctx := &sdk.StageContext{ RawMessage: "来自未知事件(message_id=404)", Extra: map[string]interface{}{"input_source": "qq"}, @@ -81,7 +81,7 @@ func TestUnmatchedQQInputIsDowngraded(t *testing.T) { if err := p.onInputAuthContext(ctx); err != nil { t.Fatal(err) } - if !p.auth.active || p.auth.owner || p.auth.userID != 0 { + if !p.auth.Active || p.auth.Owner || p.auth.UserID != 0 { t.Fatalf("unmatched input reused prior privilege: %+v", p.auth) } } @@ -89,7 +89,7 @@ func TestUnmatchedQQInputIsDowngraded(t *testing.T) { func TestDuplicateQQOutputIsStopped(t *testing.T) { p := newPermissionTestPlugin(t) p.maxDuplicateSend = 1 - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + 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) @@ -151,7 +151,7 @@ func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) { // 现在只拦参数完全相同的重复调用。 func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + 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{}{ @@ -170,7 +170,7 @@ func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) { func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + 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)}) @@ -188,7 +188,7 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) { p.maxQQOutputCalls = 0 p.maxDuplicateSend = 0 p.maxQQToolCalls = 0 - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + 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", @@ -204,7 +204,7 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) { } } -// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时, +// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={Active:true}、无 peer、非 owner)时, // **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定, // 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去 // (报「可信 QQ 会话身份不完整」)。 @@ -212,7 +212,7 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) { // 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。 func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true} + 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"}} @@ -342,18 +342,18 @@ func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) { // 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。 inner := &sdk.StageContext{Extra: map[string]interface{}{ - qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}, + qqAuthExtraKey: qqAuthContext{Active: true, Owner: true, UserID: 2198972886}, }} if err := p.afterOutputAuthContext(inner); err != nil { t.Fatal(err) } - if p.auth.active { + if p.auth.Active { t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)") } // 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。 frame := &sdk.StageContext{ - Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}}, + 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 { @@ -369,12 +369,12 @@ func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) { p := newPermissionTestPlugin(t) frame := &sdk.StageContext{ - Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}}, + 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 { + if p.auth.Owner { t.Fatal("到达事件应改写全局身份(复现场景)") } @@ -390,8 +390,8 @@ func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) { 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}, + 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)。", @@ -400,13 +400,13 @@ func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) { if err := p.onInputAuthContext(ctx); err != nil { t.Fatal(err) } - if !p.auth.owner { + 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 { + if auth, ok := authOnFrame(ctx); !ok || !auth.Owner { t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok) } } @@ -414,7 +414,7 @@ func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) { // 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。 func TestNonQQFrameBindsInactiveAuth(t *testing.T) { p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} + p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886} ctx := &sdk.StageContext{ RawMessage: "webui 里的提问", @@ -431,3 +431,113 @@ func TestNonQQFrameBindsInactiveAuth(t *testing.T) { t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response) } } + +// ===== 绑帧跨 proc 桥的判据(2026-10-12 shm v2 fExtraBag)===== +// +// 背景:内核 shm 桥 v2 之前只透传 4 个具名 Extra 键,插件自建键(qq_auth) +// 在 stage 之间静默丢失 → before_toolcall 永远回落可变全局身份 → 绑帧失效。 +// v2 起自建键装 fExtraBag 以 JSON 往返;本组判据锁定插件侧解码契约: +// 数字统一 float64、解不出完整身份按未绑定处理(宁回落全局也不猜)。 + +// 判据 1:身份 struct → JSON → map(模拟 proc 桥往返)→ authOnFrame 重建无损。 +func TestAuthOnFrameMapRoundTripFullFidelity(t *testing.T) { + src := qqAuthContext{ + Active: true, Owner: true, MessageID: 1125391573, UserID: 2198972886, + GroupID: 0, IsGroup: false, Generation: 42, + } + raw, err := json.Marshal(src) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: m}} + got, ok := authOnFrame(ctx) + if !ok { + t.Fatal("map 形态身份应能重建(proc 桥语义),got !ok") + } + if got != src { + t.Fatalf("往返失真:\n got %+v\nwant %+v", got, src) + } +} + +// 判据 2:降权身份往返后必须保持降权(防“往返即提权”)。 +func TestAuthOnFrameMapRoundTripPreservesDemotion(t *testing.T) { + src := qqAuthContext{Active: true, Owner: false, UserID: 3162251442, IsGroup: true, GroupID: 258962824} + raw, _ := json.Marshal(src) + var m map[string]interface{} + _ = json.Unmarshal(raw, &m) + ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: m}} + got, ok := authOnFrame(ctx) + if !ok { + t.Fatal("降权身份应能重建") + } + if got.Owner { + t.Fatal("owner=false 经往返绝不能变 true(提权=安全漏洞)") + } + if !got.IsGroup || got.GroupID != 258962824 { + t.Fatalf("群身份字段失真: %+v", got) + } +} + +// 判据 3:半截身份(缺键)按未绑定处理 —— 宁回落全局也不猜。 +// 每个必填键都单独缺失一次:任何一个被放过,降权/身份锚定就有一条缝。 +func TestAuthOnFramePartialMapIsRejected(t *testing.T) { + full := map[string]interface{}{ + "active": true, "owner": true, "message_id": float64(1), + "user_id": float64(1), "group_id": float64(0), "is_group": false, + } + for _, missing := range []string{"active", "owner", "message_id", "user_id", "group_id", "is_group"} { + m := map[string]interface{}{} + for k, v := range full { + if k != missing { + m[k] = v + } + } + ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: m}} + if _, ok := authOnFrame(ctx); ok { + t.Fatalf("缺 %s: 半截身份应按未绑定处理(回落全局),却解析成功", missing) + } + } + // 类型错同样拒绝(float64 约定被破坏 = 旧桥/脏数据)。 + bad := map[string]interface{}{ + "active": true, "owner": true, "message_id": float64(1), + "user_id": "10001", "group_id": float64(0), "is_group": false, + } + ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: bad}} + if _, ok := authOnFrame(ctx); ok { + t.Fatal("user_id 类型错应拒绝") + } +} + +// 判据 4:帧上身份优先于全局身份 —— 绑帧存在时全局只作兜底。 +func TestFrameIdentityBeatsGlobalIdentity(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886} + ctx := &sdk.StageContext{ + Extra: map[string]interface{}{ + qqAuthExtraKey: qqAuthContext{Active: true, Owner: false, UserID: 3162251442, IsGroup: true, GroupID: 258962824}, + }, + ToolCalls: []sdk.ToolCall{{Name: "memory_commit"}}, + } + p.beforeToolcall(ctx) + if ctx.Response == nil || !strings.Contains(*ctx.Response, "拒绝") { + t.Fatalf("帧上非 owner 调硬私工具应被拒(全局 owner 不得放行): %v", ctx.Response) + } + + // 反向:全局非 owner,帧上 owner → 放行(owner 被误拒的原始故障)。 + p2 := newPermissionTestPlugin(t) + p2.auth = qqAuthContext{Active: true, Owner: false, UserID: 3162251442} + ctx2 := &sdk.StageContext{ + Extra: map[string]interface{}{ + qqAuthExtraKey: qqAuthContext{Active: true, Owner: true, UserID: 2198972886}, + }, + ToolCalls: []sdk.ToolCall{{Name: "memory_commit"}}, + } + p2.beforeToolcall(ctx2) + if ctx2.Response != nil { + t.Fatalf("帧上 owner 调硬私工具应放行(2026-10-12 owner 被误拒的故障场景): %s", *ctx2.Response) + } +} diff --git a/tools/hmapdev/templates/proc_main.go.tmpl b/tools/hmapdev/templates/proc_main.go.tmpl index f2b9808..adcf167 100644 --- a/tools/hmapdev/templates/proc_main.go.tmpl +++ b/tools/hmapdev/templates/proc_main.go.tmpl @@ -56,7 +56,7 @@ const ( // ---- StageContext 段内部布局(与内核 internal/plugin/proc/shm.go 一致)---- const ( - shmStageFieldCount = 18 + shmStageFieldCount = 19 shmSliceSize = 8 shmOffMagic = 0 @@ -68,7 +68,7 @@ const ( shmOffSeq = 24 shmMagic = 0x48415348 - shmVersion = 1 + shmVersion = 2 ) // 字段索引(顺序须与内核 stageField 枚举一致) @@ -91,8 +91,35 @@ const ( fExtraMediaType fExtraInputSource fExtraOutputChannel + // fExtraBag:Extra 里具名 4 键之外的全部插件自建键(整体 JSON)。 + // 插件可写:绑定帧态(如 qq_auth)就存这里;跨 stage 经内核回读存活。 + fExtraBag ) +// shmNamedExtraKeys 是被提升为具名字段的 Extra 键;bag 打平时必须排除, +// 否则同一键会被具名字段与 bag 各带一份。 +var shmNamedExtraKeys = map[string]bool{ + "media_blocks": true, "media_type": true, "input_source": true, "output_channel": true, +} + +// shmBagFromExtra 收集 Extra 里非具名键;无额外键返回 nil。 +func shmBagFromExtra(extra map[string]interface{}) map[string]interface{} { + if len(extra) == 0 { + return nil + } + var bag map[string]interface{} + for k, v := range extra { + if shmNamedExtraKeys[k] { + continue + } + if bag == nil { + bag = make(map[string]interface{}) + } + bag[k] = v + } + return bag +} + const ( flagNoMemory = 0 flagResponseSet = 1 @@ -518,6 +545,15 @@ func readStageContext() (*sdk.StageContext, error) { } extra := map[string]interface{}{} + // bag 先打平,具名键随后覆盖 → 具名键永远赢(与内核 ReadInto 同构; + // 防御 bag 里混入具名键时用陈旧值顶掉具名字段)。 + var bag map[string]interface{} + if err := unmarshalField(fExtraBag, &bag); err != nil { + return nil, err + } + for k, v := range bag { + extra[k] = v + } for _, pair := range []struct { field int key string @@ -580,6 +616,8 @@ func takeStageSnapshot(sc *sdk.StageContext) *stageSnapshot { sn.jsons[fMemory] = marshal(sc.Memory, len(sc.Memory)) sn.jsons[fTokenUsage] = marshal(sc.TokenUsage, len(sc.TokenUsage)) sn.jsons[fErrors] = marshal(sc.Errors, len(sc.Errors)) + bag := shmBagFromExtra(sc.Extra) + sn.jsons[fExtraBag] = marshal(bag, len(bag)) if sc.Response != nil { sn.response = *sc.Response