mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 用户裁定 > 每个 agent 一个,分为 sessionProfile 和 longtimeProfile ## 收敛前的缺陷 档位挂在 `Runtime.active`(**进程级单例**)上,多 agent 并发时串档: 主 agent 轮次 A:注入 qq.guest → active = "qq.guest" 驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖 A 主 agent 轮次 A 继续执行工具 → 判定按 owner 走 ← 串档 这不是理论风险:驻留子 / rtchild 与主 agent **同进程并发**(plan.md 十一)。 ## 切分原则:共享注册表 + per-agent 执行视图 **不能整份复制 Runtime** —— `profiles` / `principals` / `scopes` 是插件 声明出来的注册表,每个 agent 各持一份就会变成「插件往 A 声明了档位, B 看不见」,那是最难查的一类缺陷。 共享(进程级 1 份) per-agent(N 份) ──────────────────────── ─────────────────── gate / policy / content sessionProfile ← 轮次级 profiles / principals longtimeProfile ← agent 生命期 scopes / eventPolicy ## 两层档位语义(用户裁定) sessionProfile —— 属于**这一轮**。输入携带它,End() 即清。 严格 per-round:同一用户下一条消息要重新声明。 longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留, 直到显式改掉或 agent 销毁。 **优先级 session > longtime**,这个顺序本身是安全语义: session 是"这一轮的事实",必须能盖住 long-time 默认值 —— 否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。 反向(longtime 盖 session)= 提权:长期 owner 的 agent 上, 访客声明的 guest 被吞掉 ⇒ 访客拿到 owner 权限。 ## 改动 ① 新增 `internal/security/agentscope.go`: rt.ForAgent(agentID, isChild) *AgentScope // 幂等 sc.BeginRound(RoundInput) // security 自己解析档位 sc.SetLongtimeProfile(name) / LongtimeProfile() sc.EffectiveProfile() // session > longtime sc.CheckTool(tool) // 全局基线 + 档位 + 主体策略 rt.ReleaseAgent(agentID) // 含 longtime 档一起清 ② `Runtime` 新增 `scopesByAgent` 表;判定拆出 `checkToolWithProfile(p, profile, tool)` 与 `checkToolForWithProfile(...)` —— **档位由调用方显式传入**, 不再一律读全局 `r.active`(否则 per-agent 视图白做)。 ③ `agent/core`:`securityScope()` 用 `sync.Once` 缓存本 agent 的视图; `Stop()` 里 `ReleaseSecurityScope()`。 ④ `Round.End()`:per-agent 路径只清 **session**,**保留** longtime; 旧进程级路径仍恢复进入前的值。 ## 判据(3 个变异全部实测咬住) | 判据 | 变异 | |---|---| | `TestAgentScopesDoNotShareSessionProfile` | ForAgent 退化为共享视图 ⇒ 红 | | `TestLongtimeProfileSurvivesRounds` | 优先级写反 / longtime 不跨轮 ⇒ 红 | | `TestReleaseAgentDropsLongtimeProfile` | ReleaseAgent 不删视图 ⇒ 红 | | `TestLongtimeProfileRejectsUndeclared` | 未声明档位被接受 ⇒ 红 | 第一条同时钉:① 各自档位是自己的 ② **行为上**主 agent 仍被 guest 收窄、 子 agent 不受影响(只断言字符串相等会漏掉判定链没接上的情形)。 ## 实测 - 真实并发探针:3 个 agent(guest / owner / guest)各 200 轮并发, `-race` 干净,**零串档** - build / vet / go test ./...(62 ok / 0 fail)/ -race 全绿 ## 未变的约束 档位与主体策略**只能收窄**:`CheckToolFor` 内部先跑全局策略, 所以父的黑名单对子依然有效,子无法提权。
336 lines
12 KiB
Go
336 lines
12 KiB
Go
package security
|
||
|
||
// 主体(principal)授权模型:**声明式**能力授予。
|
||
//
|
||
// ## 为什么需要它(用户 2026-10-26 指出的现状)
|
||
//
|
||
// QQ 插件现在用 stage 管道自己实现了一套权限模型(`beforeToolcall`):
|
||
// 判身份 → 查用户白名单 → 查"硬私有"工具 → 内部委派放行 → 写拒绝消息。
|
||
// 那是**在插件里执行安全策略**,后果实测有三类:
|
||
//
|
||
// ① 每个插件各写一套,判定必然漂移(QQ 拦了、其他入口没拦)
|
||
// ② 策略与被约束者在同一个进程里 —— 插件崩了/被替换,策略就没了
|
||
// ③ 拒绝语义散落(写 denial / 返回 error 各有各的写法)
|
||
//
|
||
// 正确形态:插件**声明**策略(谁可以调什么),内核**执行**策略。
|
||
//
|
||
// ## 与本文件已有内容的关系
|
||
//
|
||
// Policy 全局静态策略(黑名单/白名单/沙箱)
|
||
// ScopeOptions 轮次作用域(临时收紧)
|
||
// PrincipalPolicy(本文件)**按主体**授予的能力集合
|
||
//
|
||
// 判定顺序始终是:全局 deny → 主体 deny → 轮次 deny → 白名单 → 放行。
|
||
|
||
import (
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
// PrincipalKind 是主体**类别**。内核侧固定枚举,不接受自由字符串。
|
||
//
|
||
// ## 为什么必须是枚举而不是 string(2026-10-31 收敛)
|
||
//
|
||
// 原先 `Principal.Kind` 是裸 `string`,而实际填充点只有一处:
|
||
// `agent/core/security_profile.go` 里写死的 `Kind: "resident"`。
|
||
// 那意味着:
|
||
//
|
||
// ① **判定逻辑散在两侧**:`IsInternal()` 在 security 里靠字符串比较,
|
||
// 而“我该算哪一类”由调用方(agent)决定 —— 调用方说自己是内部,
|
||
// 就享受内部主体的豁免(不过 HardPrivateTools)。那是提权口子。
|
||
// ② **拼错不报错**:写成 "residnet" ⇒ IsInternal 为 false ⇒
|
||
// 静默升级成用户主体语义(反方向)或静默降级。
|
||
//
|
||
// 收敛后:类别由**security 自己根据可信事实决定**(谁在调、有没有父),
|
||
// 调用方只能传事实,不能传结论。
|
||
type PrincipalKind string
|
||
|
||
const (
|
||
// KindUnset 表示未分类。**不要**用它做任何判定 —— 它是零值,
|
||
// 只用于“调用方没给”这一情形,由 security 自己补全。
|
||
KindUnset PrincipalKind = ""
|
||
// KindInternal 是内核自身发起的调用(模块、内置定时任务)。
|
||
KindInternal PrincipalKind = "internal"
|
||
// KindSubAgent 是内核派出的子执行体(驻留子 / rtchild / 工作式子)。
|
||
//
|
||
// ★ 与 KindInternal 的区别:子执行体**是**内部委派(不该被
|
||
// HardPrivateTools 拦),但它们是长期自主运行的 —— 父可以在创建时
|
||
// 为它们**收窄**权限(AllowTools / DenyTools),而内核自身不受收窄。
|
||
KindSubAgent PrincipalKind = "subagent"
|
||
// KindUser 是终端用户(通过插件通道进来的真实用户)。
|
||
KindUser PrincipalKind = "user"
|
||
// KindGroup 是群/频道类主体。
|
||
KindGroup PrincipalKind = "group"
|
||
)
|
||
|
||
// ParsePrincipalKind 把外部字符串解析成枚举(仅用于默认实现兼容)。
|
||
//
|
||
// 未识别的值返回 KindUnset 与 false:**不静默回退到某个默认类别** ——
|
||
// 那会让拼写错误变成权限语义漂移(与“未知档位名不静默放行”同一条纪律)。
|
||
func ParsePrincipalKind(s string) (PrincipalKind, bool) {
|
||
switch PrincipalKind(s) {
|
||
case KindInternal, KindSubAgent, KindUser, KindGroup:
|
||
return PrincipalKind(s), true
|
||
// 兼容旧调用方写过的 "resident":语义等价于 KindSubAgent。
|
||
case "resident":
|
||
return KindSubAgent, true
|
||
}
|
||
return KindUnset, false
|
||
}
|
||
|
||
// Principal 是一次调用的**主体**:谁在请求。
|
||
type Principal struct {
|
||
// ID 是主体标识(QQ 用户 id、群 id、驻留子 id、内部标记)。
|
||
ID string
|
||
// Kind 是主体类别(固定枚举,见上方常量)。
|
||
//
|
||
// ★ 为何不只看 ID:内部委派与用户请求的 ID 可能相同
|
||
//(驻留子继承父的输入事件),但**权限模型完全不同**。
|
||
Kind PrincipalKind
|
||
// Tags 是附加标签(如 "admin"、"trusted"),策略可按标签授予。
|
||
Tags []string
|
||
}
|
||
|
||
// IsInternal 报告是否内部主体(内核自身或内核派出的子执行体)。
|
||
//
|
||
// 语义:内部主体**不受** HardPrivateTools 限制 —— 那条规则防的是
|
||
// “用户偷看私人数据”,而内部委派本就有完整权限(QQ 的实测教训:
|
||
// 再拦一次等于“机器人自己派出去的活被自己拦住”)。
|
||
func (p Principal) IsInternal() bool {
|
||
return p.Kind == KindInternal || p.Kind == KindSubAgent
|
||
}
|
||
|
||
// IsSubAgent 报告是否内核派出的子执行体。
|
||
//
|
||
// 与 IsInternal 分开的理由:子执行体**可以**被父收窄(Allow/Deny),
|
||
// 而内核自身不受收窄。两者共用 HardPrivateTools 豁免,但不共用收窄语义。
|
||
func (p Principal) IsSubAgent() bool { return p.Kind == KindSubAgent }
|
||
|
||
// PrincipalPolicy 是某个主体的能力策略。
|
||
type PrincipalPolicy struct {
|
||
// AllowTools 是该主体**可用**的工具模式(空 = 不额外限制)。
|
||
AllowTools []string
|
||
// DenyTools 是该主体**禁用**的工具模式(优先于 AllowTools)。
|
||
DenyTools []string
|
||
// HardPrivateTools 是**永不外借**的工具模式(如 calendar_ / email_ / memory_)。
|
||
//
|
||
// ★ 与 DenyTools 的区别:DenyTools 是"这个主体不能用",
|
||
// HardPrivateTools 是"没有任何用户主体能用" —— 它防的是
|
||
// 用户偷看私人数据,而内部执行体(父的委派)本就有完整权限。
|
||
HardPrivateTools []string
|
||
}
|
||
|
||
// principalRegistry 持有各主体的策略(进程级,内核持有)。
|
||
type principalRegistry struct {
|
||
mu sync.RWMutex
|
||
byID map[string]PrincipalPolicy
|
||
byTag map[string]PrincipalPolicy
|
||
}
|
||
|
||
func newPrincipalRegistry() *principalRegistry {
|
||
return &principalRegistry{
|
||
byID: map[string]PrincipalPolicy{},
|
||
byTag: map[string]PrincipalPolicy{},
|
||
}
|
||
}
|
||
|
||
// SetByID 按主体 ID 声明策略。
|
||
func (r *principalRegistry) SetByID(id string, p PrincipalPolicy) {
|
||
if r == nil || strings.TrimSpace(id) == "" {
|
||
return
|
||
}
|
||
r.mu.Lock()
|
||
r.byID[id] = p
|
||
r.mu.Unlock()
|
||
}
|
||
|
||
// SetByTag 按标签声明策略(比逐 ID 声明更好维护)。
|
||
func (r *principalRegistry) SetByTag(tag string, p PrincipalPolicy) {
|
||
if r == nil || strings.TrimSpace(tag) == "" {
|
||
return
|
||
}
|
||
r.mu.Lock()
|
||
r.byTag[tag] = p
|
||
r.mu.Unlock()
|
||
}
|
||
|
||
// RemoveByID 撤销某个主体的策略声明。
|
||
//
|
||
// ## 为什么必须有它(2026-10-30)
|
||
//
|
||
// 主体 ID 对**子 agent** 来说是脚本给的(`ring_agents create id="bot1"`)。
|
||
// 子被回收后同名重建是常规操作,而若旧策略留在表里:
|
||
//
|
||
// 新一代用**上一代父当时留的约束**运行 —— 而那个父可能已经不在了。
|
||
//
|
||
// 这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)
|
||
// 是**同一种形态**:以调用方给的名称为键的表,必须在生命周期结束时清理。
|
||
//
|
||
// 不清理还会单调增长(每建一个不同名字的子就多一条永不释放的 Polciy)。
|
||
func (r *principalRegistry) RemoveByID(id string) {
|
||
if r == nil || strings.TrimSpace(id) == "" {
|
||
return
|
||
}
|
||
r.mu.Lock()
|
||
delete(r.byID, id)
|
||
r.mu.Unlock()
|
||
}
|
||
|
||
// lookup 取出与主体相关的全部策略(ID + 每个标签)。
|
||
func (r *principalRegistry) lookup(p Principal) []PrincipalPolicy {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
var out []PrincipalPolicy
|
||
if pol, ok := r.byID[p.ID]; ok {
|
||
out = append(out, pol)
|
||
}
|
||
for _, tag := range p.Tags {
|
||
if pol, ok := r.byTag[tag]; ok {
|
||
out = append(out, pol)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// DeclarePrincipalPolicy 是**插件声明主体策略**的入口(SDK 转发到这里)。
|
||
//
|
||
// scope 语义:
|
||
//
|
||
// "id" → 按主体 ID(一个具体的人/群/子 agent)
|
||
// "tag" → 按标签(一类主体,如 "external")
|
||
func (r *Runtime) DeclarePrincipalPolicy(scope, key string, p PrincipalPolicy) error {
|
||
if r == nil || r.principals == nil {
|
||
return errNoRuntime
|
||
}
|
||
switch scope {
|
||
case "id":
|
||
r.principals.SetByID(key, p)
|
||
case "tag":
|
||
r.principals.SetByTag(key, p)
|
||
default:
|
||
return &policyErr{"security: principal scope must be \"id\" or \"tag\", got " + scope}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RevokePrincipalPolicy 撤销一个按 ID 声明的主体策略(合规性:子 agent 销毁时调)。
|
||
//
|
||
// 只支持 scope="id":标签策略属于**部署级**声明(如 tag=external),
|
||
// 它们的生命周期不跟随某个具体执行体,撤销只能由声明方自己做。
|
||
func (r *Runtime) RevokePrincipalPolicy(scope, key string) error {
|
||
if r == nil || r.principals == nil {
|
||
return errNoRuntime
|
||
}
|
||
if scope != "id" {
|
||
return &policyErr{"security: revoke only supports scope=\"id\" (tag policies are deployment-level), got " + scope}
|
||
}
|
||
r.principals.RemoveByID(key)
|
||
return nil
|
||
}
|
||
|
||
// HasPrincipalPolicy 报告某 ID 是否已声明主体策略(诊断/状态面用)。
|
||
func (r *Runtime) HasPrincipalPolicy(id string) bool {
|
||
if r == nil || r.principals == nil {
|
||
return false
|
||
}
|
||
r.principals.mu.RLock()
|
||
defer r.principals.mu.RUnlock()
|
||
_, ok := r.principals.byID[id]
|
||
return ok
|
||
}
|
||
|
||
// CheckToolFor 按**主体**判定工具是否可用。
|
||
//
|
||
// ★ 这是替代"插件在 stage 里自己拦"的入口:插件只声明策略,
|
||
// 判定与拒绝文案由内核统一产出(模型看到一致的拒绝语义)。
|
||
func (r *Runtime) CheckToolFor(p Principal, tool string) Decision {
|
||
if r == nil {
|
||
return allowed()
|
||
}
|
||
// 用进程级档位(旧路径)。per-agent 视图走 checkToolForWithProfile。
|
||
return r.checkToolForWithProfile(p, r.ActiveProfile(), tool)
|
||
}
|
||
|
||
// checkToolForWithProfile 是**主体判定 + 指定档位**的组合实现。
|
||
//
|
||
// ★ 为什么必须与档位一起传(2026-10-31):
|
||
// per-agent 视图的档位是**它自己的**(session/longtime)。若这里回头
|
||
// 调 `r.CheckTool(tool)`(读进程级 active),per-agent 的档位就被丢了 ——
|
||
// 表现为「档位设了但不生效」,而全局 active 恰好为空时看不出问题。
|
||
func (r *Runtime) checkToolForWithProfile(p Principal, profileName, tool string) Decision {
|
||
if r == nil {
|
||
return allowed()
|
||
}
|
||
// ① 全局策略 + 档位(deny 优先)。
|
||
if d := r.checkToolWithProfile(p, profileName, tool); !d.Allowed {
|
||
return d
|
||
}
|
||
pols := r.principals.lookup(p)
|
||
if len(pols) == 0 {
|
||
return allowed()
|
||
}
|
||
|
||
// ② 硬私有:任何**非内部**主体都不能用。
|
||
// 内部执行体(父的委派)本就有完整权限 —— 再拦一次等于
|
||
// "机器人自己派出去的活被自己拦住"(QQ 的实测教训)。
|
||
for _, pol := range pols {
|
||
if p.IsInternal() {
|
||
break
|
||
}
|
||
for _, pat := range pol.HardPrivateTools {
|
||
if matchTool(pat, tool) {
|
||
return deny("tool %q is hard-private and unavailable to %s principals", tool, p.Kind)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ③ 主体 deny。
|
||
for _, pol := range pols {
|
||
for _, pat := range pol.DenyTools {
|
||
if matchTool(pat, tool) {
|
||
return deny("tool %q is not permitted for this principal (deny %q)", tool, pat)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ④ 主体 allow:只要求"至少有一个声明了 allow 的策略命中"。
|
||
// 若所有策略都没声明 allow,则不做额外限制(沿用全局)。
|
||
anyAllowDeclared := false
|
||
allowHit := false
|
||
for _, pol := range pols {
|
||
if len(pol.AllowTools) == 0 {
|
||
continue
|
||
}
|
||
anyAllowDeclared = true
|
||
for _, pat := range pol.AllowTools {
|
||
if matchTool(pat, tool) {
|
||
allowHit = true
|
||
}
|
||
}
|
||
}
|
||
if anyAllowDeclared && !allowHit {
|
||
return deny("tool %q is outside this principal's allowed set", tool)
|
||
}
|
||
return allowed()
|
||
}
|
||
|
||
// PrincipalPolicies 返回已声明策略数量(状态面用)。
|
||
func (r *Runtime) PrincipalPolicies() int {
|
||
if r == nil || r.principals == nil {
|
||
return 0
|
||
}
|
||
r.principals.mu.RLock()
|
||
defer r.principals.mu.RUnlock()
|
||
return len(r.principals.byID) + len(r.principals.byTag)
|
||
}
|
||
|
||
// errNoRuntime 表示安全运行时未装配。
|
||
var errNoRuntime = &policyErr{"security: runtime is not initialized"}
|
||
|
||
type policyErr struct{ msg string }
|
||
|
||
func (e *policyErr) Error() string { return e.msg }
|