fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner - session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝 - 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效) - 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测) - 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
This commit is contained in:
1 parent
7e7f53bb41
commit
af8591b78a
30 files changed
+1289
-144
No files matched your search
@@ -6,13 +6,14 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// ResolveActor turns a normalized inbound message into the strongly typed actor
|
||||
// metadata used by every downstream permission decision. It is the structural
|
||||
// owner-recognition rule from design/11 §0: the owner is decided by matching
|
||||
// channel-level identity (platform + account id) against the user.OwnerProfile
|
||||
// whitelist (or by honoring a channel-level owner tag), and never by reading the
|
||||
// message text. A message whose body says "I am the owner" is not the owner
|
||||
// unless it also matches the whitelist.
|
||||
// ResolveActor 把一条归一化入站消息解析成下游权限决策所用的强类型 actor 元数据。
|
||||
// 这是 design/11 §0 的结构化主人识别规则(裁决 06.4.2):**owner 车道只认白名单**,
|
||||
// 判主依据只有结构化来源——主人档案的账号白名单(owner.yaml,internal/user)与每渠道
|
||||
// 账号配置的 adminIds[](channels.json,design/08 §1.1)。
|
||||
//
|
||||
// **适配器上报的角色标记一律不得单独判主**:平台消息里的 role 字段(OneBot 的群主
|
||||
// owner / 群管理员 admin,或私聊帧里伪造的 role=owner)只能当作非主人侧提示,命中
|
||||
// owner 也会被降级为 RoleMember。判主永远不看消息正文——正文声称「我是主人」同样无效。
|
||||
func ResolveActor(msg adapter.UnifiedMessage, owner *user.OwnerProfile) adapter.Actor {
|
||||
actor := adapter.Actor{
|
||||
Role: msg.Sender.Role,
|
||||
@@ -23,19 +24,30 @@ func ResolveActor(msg adapter.UnifiedMessage, owner *user.OwnerProfile) adapter.
|
||||
GroupName: firstNonEmpty(msg.Sender.GroupName, msg.GroupName),
|
||||
}
|
||||
|
||||
// Structural owner resolution: the whitelist is authoritative, and a
|
||||
// channel-level owner tag (from authenticated login binding) is also
|
||||
// structural. Neither depends on msg.Content.
|
||||
if (owner != nil && owner.IsOwner(msg.Platform, msg.SenderID)) || actor.Role == adapter.RoleOwner {
|
||||
// 白名单命中即主人;未命中则不管适配器上报了什么角色,都留在非主人车道
|
||||
// (owner.IsOwner 对 nil 档案安全返回 false)。
|
||||
if owner.IsOwner(msg.Platform, msg.SenderID) {
|
||||
actor.Role = adapter.RoleOwner
|
||||
} else if !actor.Role.Valid() {
|
||||
// No channel-tagged role and not the owner: default to a member rather
|
||||
// than leaving the role ambiguous, which is the conservative choice.
|
||||
actor.Role = adapter.RoleMember
|
||||
return actor.Resolve()
|
||||
}
|
||||
actor.Role = nonOwnerRole(actor.Role)
|
||||
return actor.Resolve()
|
||||
}
|
||||
|
||||
// nonOwnerRole 把适配器上报的角色收口到非主人车道:owner(群主 / 群管理员 / 伪造
|
||||
// 字段)一律降级为 RoleMember;member / system / unknown 原样保留;非法值保守回落
|
||||
// RoleMember,避免留下含糊角色。
|
||||
func nonOwnerRole(role adapter.Role) adapter.Role {
|
||||
switch role {
|
||||
case adapter.RoleOwner:
|
||||
return adapter.RoleMember
|
||||
case adapter.RoleMember, adapter.RoleSystem, adapter.RoleUnknown:
|
||||
return role
|
||||
default:
|
||||
return adapter.RoleMember
|
||||
}
|
||||
}
|
||||
|
||||
// EnvFor maps a resolved actor to the restrict.ActorEnv used to gate tools.
|
||||
// Group chats always map to group_member (isolated, restricted) even when the
|
||||
// owner is the speaker (design/02 §3: owner group speech stays in the isolated
|
||||
@@ -51,15 +63,16 @@ func EnvFor(actor adapter.Actor) restrict.ActorEnv {
|
||||
return restrict.ActorStranger
|
||||
}
|
||||
|
||||
// IdentifyContext returns the restrict.ActorEnv for a message from the channel
|
||||
// metadata it carries (msg.Sender), inferring the group flag from the channel
|
||||
// type when the sender is not already tagged. This is the environment the policy
|
||||
// engine is bound to before any tool call.
|
||||
// IdentifyContext 仅凭消息自带的渠道元数据推断 restrict.ActorEnv:群聊一律
|
||||
// group_member,其余一律 stranger。它拿不到白名单,因此**不能授予 owner 车道**——
|
||||
// 适配器上报的 role=owner 在此被降级为 member(裁决 06.4.2,owner 只认白名单)。
|
||||
// 真正的判主走 ResolveActor(msg, owner) + EnvFor。
|
||||
func IdentifyContext(msg adapter.UnifiedMessage) restrict.ActorEnv {
|
||||
actor := msg.Sender.Resolve()
|
||||
if msg.IsGroup() {
|
||||
actor.IsGroup = true
|
||||
}
|
||||
actor.Role = nonOwnerRole(actor.Role)
|
||||
return EnvFor(actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -122,17 +122,23 @@ func TestResolveActorStranger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveActorPreservesChannelOwnerTag(t *testing.T) {
|
||||
func TestResolveActorIgnoresAdapterOwnerTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A channel-level owner tag (from authenticated login binding) is structural
|
||||
// metadata and must be honored even without a whitelist.
|
||||
msg := directMsg("login-owner")
|
||||
// 反向用例(裁决 06.4.2):适配器上报的 owner 标记**不得单独判主**。没有白名单时
|
||||
// 它只能拿到非主人车道,因此拿不到 MemWritable、也不可能镜像进主会话。
|
||||
msg := directMsg("forged-owner")
|
||||
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
actor := ResolveActor(msg, nil)
|
||||
|
||||
if !actor.IsOwner() {
|
||||
t.Fatal("channel-tagged owner lost during resolution")
|
||||
if actor.IsOwner() {
|
||||
t.Fatal("适配器上报的 role=owner 被判成了主人(应只认白名单)")
|
||||
}
|
||||
if actor.Role != adapter.RoleMember {
|
||||
t.Errorf("Role = %q, want member (owner 标记降级)", actor.Role)
|
||||
}
|
||||
if got := EnvFor(actor); got != restrict.ActorStranger {
|
||||
t.Errorf("EnvFor() = %q, want stranger", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +182,10 @@ func TestEnvFor(t *testing.T) {
|
||||
func TestIdentifyContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerMsg := directMsg("o-100")
|
||||
ownerMsg.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
// 适配器上报 role=owner 但没有白名单可查:IdentifyContext 不得授予 owner 车道
|
||||
// (裁决 06.4.2),因此它只能得到 stranger。
|
||||
ownerTaggedMsg := directMsg("o-100")
|
||||
ownerTaggedMsg.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
|
||||
groupMsgWithSender := groupMsg("g-1")
|
||||
groupMsgWithSender.Sender = adapter.Actor{Role: adapter.RoleMember}
|
||||
@@ -189,7 +197,7 @@ func TestIdentifyContext(t *testing.T) {
|
||||
msg adapter.UnifiedMessage
|
||||
want restrict.ActorEnv
|
||||
}{
|
||||
{name: "owner", msg: ownerMsg, want: restrict.ActorOwner},
|
||||
{name: "适配器 owner 标记(无白名单可查)", msg: ownerTaggedMsg, want: restrict.ActorStranger},
|
||||
{name: "group", msg: groupMsgWithSender, want: restrict.ActorGroupMember},
|
||||
{name: "stranger", msg: strangerMsg, want: restrict.ActorStranger},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package session
|
||||
|
||||
// 本文件覆盖裁决 06.4.2 在会话层的安全反向用例,以及第二处结构化判主来源
|
||||
// (每渠道账号配置的 adminIds[],channels.json / design/08 §1.1)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// TestResolveActorOwnerByChannelAdminIDs 覆盖每渠道管理员 id 列表:SetPlatformAdmins
|
||||
// 写入后命中即进 owner 车道(可多账户),不在列表里的账号——哪怕适配器上报 role=owner
|
||||
// ——仍是非主人。
|
||||
func TestResolveActorOwnerByChannelAdminIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}
|
||||
owner.SetPlatformAdmins("qq", []string{"admin-1", " admin-2 ", "admin-1", ""})
|
||||
|
||||
ids := owner.PlatformAdmins("qq")
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("PlatformAdmins = %v, want 去重并去空白后的两个 id", ids)
|
||||
}
|
||||
for _, id := range []string{"admin-1", "admin-2"} {
|
||||
if actor := ResolveActor(directMsg(id), owner); !actor.IsOwner() {
|
||||
t.Errorf("adminIds 命中的 %q 未进 owner 车道", id)
|
||||
}
|
||||
}
|
||||
|
||||
forged := directMsg("group-owner")
|
||||
forged.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
if actor := ResolveActor(forged, owner); actor.IsOwner() {
|
||||
t.Error("不在 adminIds 里的账号被判成了主人")
|
||||
}
|
||||
|
||||
// 整体替换语义:重新声明后旧 id 立刻失效,channels.json 热重载不会留下悬挂管理员。
|
||||
owner.SetPlatformAdmins("qq", []string{"admin-2"})
|
||||
if owner.IsOwner("qq", "admin-1") {
|
||||
t.Error("SetPlatformAdmins 未整体替换,旧管理员 id 仍有效")
|
||||
}
|
||||
owner.SetPlatformAdmins("qq", nil)
|
||||
if owner.IsOwner("qq", "admin-2") || owner.PlatformAdmins("qq") != nil {
|
||||
t.Error("空列表应清空该平台的管理员层")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActorNilOwnerIsFailClosed 覆盖没有主人档案的路径:任何入站帧都是非主人
|
||||
// (fail-closed),适配器上报的 owner 标记也不例外。
|
||||
func TestResolveActorNilOwnerIsFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := directMsg("whoever")
|
||||
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
actor := ResolveActor(msg, nil)
|
||||
if actor.IsOwner() {
|
||||
t.Fatal("nil 主人档案下仍被判成主人")
|
||||
}
|
||||
if actor.Role != adapter.RoleMember {
|
||||
t.Errorf("Role = %q, want member", actor.Role)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerFrameMemWritable 走真实 SessionManager 链路断言 MemWritable:伪造 owner 的
|
||||
// 私聊帧 Owner/MemWritable 全 false(不入主会话上下文、记忆只读);白名单命中的主人私聊
|
||||
// 为 true;群主(群聊)永远是隔离环境、MemWritable=false。
|
||||
func TestOwnerFrameMemWritable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := newTestManager(t)
|
||||
ctx := context.Background()
|
||||
owner := &user.OwnerProfile{
|
||||
ID: "own-1",
|
||||
DisplayName: "Owner",
|
||||
Accounts: []user.OwnerAccount{{Platform: "qq", AccountID: "o-100"}},
|
||||
}
|
||||
|
||||
forged := directMsg("forged-owner")
|
||||
forged.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
if actor := ResolveActor(forged, owner); actor.Role != adapter.RoleMember {
|
||||
t.Fatalf("伪造 owner 的入站帧 role = %q, want member", actor.Role)
|
||||
}
|
||||
forgedSess, err := m.GetOrCreate(ctx, forged, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreate(伪造 owner): %v", err)
|
||||
}
|
||||
if forgedSess.Owner || forgedSess.MemWritable {
|
||||
t.Errorf("伪造 owner 帧 Owner=%v MemWritable=%v, want false/false",
|
||||
forgedSess.Owner, forgedSess.MemWritable)
|
||||
}
|
||||
|
||||
whitelisted := directMsg("o-100")
|
||||
ownerSess, err := m.GetOrCreate(ctx, whitelisted, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreate(白名单): %v", err)
|
||||
}
|
||||
if !ownerSess.Owner || !ownerSess.MemWritable {
|
||||
t.Errorf("白名单主人帧 Owner=%v MemWritable=%v, want true/true",
|
||||
ownerSess.Owner, ownerSess.MemWritable)
|
||||
}
|
||||
|
||||
groupOwner := groupMsg("g-owner")
|
||||
groupOwner.Sender = adapter.Actor{Role: adapter.RoleOwner, GroupID: "g1", IsGroup: true}
|
||||
if actor := ResolveActor(groupOwner, owner); actor.Role != adapter.RoleMember || actor.IsOwner() {
|
||||
t.Errorf("群主 role=%q owner=%v, want member/false", actor.Role, actor.IsOwner())
|
||||
}
|
||||
groupSess, err := m.GetOrCreate(ctx, groupOwner, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreate(群主): %v", err)
|
||||
}
|
||||
if groupSess.Owner || groupSess.MemWritable || !groupSess.IsGroup {
|
||||
t.Errorf("群主会话 Owner=%v MemWritable=%v IsGroup=%v, want false/false/true",
|
||||
groupSess.Owner, groupSess.MemWritable, groupSess.IsGroup)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Proactive push per-platform defaults (design/08 §3): weixin-ilink allows push
|
||||
// only within 24h of the last inbound message; OneBot allows push but must be
|
||||
// rate-limited to avoid wind-control flags; telegram/webhook push freely.
|
||||
// 主动推送的每平台默认值(design/08 §3):weixin-ilink 只允许在最后一条入站消息的
|
||||
// 24 小时内推送(超 24h 推不过去);OneBot 可推送但要限频以避开风控;telegram/webhook
|
||||
// 推送强、不设限;**能力未声明的未知平台保守默认**(裁决 06.4.4)——严格限频,绝不给
|
||||
// 「无限制」。真正的限幅落在适配器实现,策略层只声明口径(design/08 §3)。
|
||||
const (
|
||||
// iLinkWindow is the weixin-ilink proactive push window.
|
||||
iLinkWindow = 24 * time.Hour
|
||||
@@ -20,6 +21,10 @@ const (
|
||||
oneBotBurstLimit = 3
|
||||
// oneBotBurstWindow is the OneBot rate-limit window.
|
||||
oneBotBurstWindow = 10 * time.Second
|
||||
// unknownBurstLimit / unknownBurstWindow 是未知平台的保守默认:每分钟最多 1 条主动
|
||||
// 推送(比 OneBot 的 3 条/10s 更紧),保证「未知 ≠ 无限制」。
|
||||
unknownBurstLimit = 1
|
||||
unknownBurstWindow = time.Minute
|
||||
)
|
||||
|
||||
// ProactiveOptions configures a ProactivePolicy.
|
||||
@@ -57,17 +62,20 @@ func NewProactivePolicy(opts ProactiveOptions) *ProactivePolicy {
|
||||
return p
|
||||
}
|
||||
|
||||
// ProactivePolicyForPlatform returns the default proactive policy for a known
|
||||
// platform identifier, following design/08 §3. Unknown platforms get an
|
||||
// unrestricted policy, which is the conservative-outcome of "strong" channels.
|
||||
// ProactivePolicyForPlatform 返回某平台标识对应的默认主动推送策略(design/08 §3)。
|
||||
// 已声明的平台按其规格:weixin-ilink 走 24h 窗口、OneBot 限频、telegram/webhook 推送强
|
||||
// 不设限。**未知(能力未声明)平台保守默认**(裁决 06.4.4):给最严格的限频
|
||||
// (1 条/分钟),不再返回「无限制」——限幅落在适配器实现,策略层只声明口径。
|
||||
func ProactivePolicyForPlatform(platform string) *ProactivePolicy {
|
||||
switch platform {
|
||||
case "weixin-ilink":
|
||||
return NewProactivePolicy(ProactiveOptions{Window: iLinkWindow})
|
||||
case "qq-obv11", "qq-onebot", "onebot":
|
||||
return NewProactivePolicy(ProactiveOptions{RateLimit: oneBotBurstLimit, RateWindow: oneBotBurstWindow})
|
||||
default:
|
||||
case "telegram", "webhook":
|
||||
return NewProactivePolicy(ProactiveOptions{})
|
||||
default:
|
||||
return NewProactivePolicy(ProactiveOptions{RateLimit: unknownBurstLimit, RateWindow: unknownBurstWindow})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,32 @@ func TestProactivePolicyForPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProactivePolicyForUnknownPlatformIsConservative 是裁决 06.4.4 的断言:未知平台
|
||||
// (能力未声明、不在平台清单里)**不得**拿到「无限制」——默认给最严格的限频。
|
||||
func TestProactivePolicyForUnknownPlatformIsConservative(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
for _, platform := range []string{"mystery-im", "discord", ""} {
|
||||
p := ProactivePolicyForPlatform(platform)
|
||||
if p == nil {
|
||||
t.Fatalf("ProactivePolicyForPlatform(%q) = nil", platform)
|
||||
}
|
||||
if p.limiter == nil {
|
||||
t.Fatalf("未知平台 %q 拿到了无限制策略,违反裁决 06.4.4", platform)
|
||||
}
|
||||
for i := 0; i < unknownBurstLimit; i++ {
|
||||
if !p.Allow(ctx, capsProactive(), now) {
|
||||
t.Fatalf("未知平台 %q 第 %d 条被拒,want allowed(限频内)", platform, i+1)
|
||||
}
|
||||
}
|
||||
if p.Allow(ctx, capsProactive(), now) {
|
||||
t.Errorf("未知平台 %q 超出保守限频仍被放行", platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProactivePolicyNilPolicyAndCanceledCtx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// matched against the user.OwnerProfile account whitelist. Nothing here reads
|
||||
// message text to infer identity — that is a project red line.
|
||||
// - Proactive push policy: per-platform rules from design/08 §3 (weixin-ilink
|
||||
// 24h window, OneBot burst rate limit, telegram/webhook unrestricted).
|
||||
// 24h window, OneBot burst rate limit, telegram/webhook unrestricted), with a
|
||||
// conservative strict limit as the default for unknown platforms (裁决 06.4.4).
|
||||
//
|
||||
// The package is concurrency-safe: the manager guards its cache with an
|
||||
// RWMutex and the rate limiter uses its own mutex, so multiple adapters may
|
||||
|
||||
Reference in new issue
Block a user