refactor(security): 安全策略全面收敛到 security 模块 —— agent 只提供事实,不再做策略决策

## 架构定位(用户澄清)

security 是**外围内核到核心内核之间的防火墙**:自己提供接口、判定内聚在自己内部。
device 是设备抽象模块、持有 channel,两者不冲突。

现成的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` ——
总线只交事件,guard 自己判,总线一行策略都没有。本次把同样的形状套到输入/主体上。

## 收敛前的问题

`agent/core/security_profile.go` 214 行里绝大部分是**策略逻辑**:

	name, _ := payload["security_profile"].(string)   // ① 解析 security 的协议
	a.security.ActivateProfile(name, 0)
	if a.parentID != "" {                             // ② 选哪条判定链
	    return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
	}
	a.security.DeclarePrincipalPolicy("id", id, PrincipalPolicy{...})  // ③ 拼策略结构

三个后果:

① **协议解析外泄**:`security_profile` 是 security 的概念,解析却在外人手里
② **策略决策外泄**:判断"父走 CheckTool / 子走 CheckToolFor"是策略语义
③ **提权口子**:`Kind: "resident"` 是调用方**声明自己的类别** —— 说自己是
  内部主体就享受内部豁免(不过 HardPrivateTools)。类别必须从事实推出

且三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效。

## 收敛后的形态

security 新增 `round.go`(自己提供接口):

	RoundInput{AgentID, IsChild, Payload, Source}  ← 只有事实,**无 Kind**
	rt.BeginRound(in)  → security 解析档位 + 判定主体类别
	round.CheckTool(t) → 本轮判定(调用方不需要知道任何组合规则)
	round.End()        → 归位(防跨轮泄漏)
	rt.DeclareChildPolicy / RevokeChildPolicy      ← 子策略生命周期

agent 侧只剩:`beginSecurityRound(payload, source)` + `defer End()`。
`security_profile.go` 从 214 行策略逻辑退化为转发层。

顺带全局收敛 `Principal.Kind`:裸 string → 固定枚举
(KindInternal / KindSubAgent / KindUser / KindGroup),并新增
`ParsePrincipalKind` —— 拼错返回 false 而不是静默回退到某类别。

## 轮次边界接到**全部三条**输入路径

	task.go      runInputTask   ← 主 agent + 驻留子(各有自己的 Agent 实例)
	rtchild.go   rtRunOnce      ← rtchild 不走调度器,是独立的一条

★ 收敛把**判定逻辑**收到了一处,但"一轮何时开始/结束"仍得各路径自己声明 ——
只有该路径知道边界。所以判据必须钉"每条路径都声明了"。

## 判据(5 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| `TestSecurityProfileFromInputChangesToolDecision` | BeginRound 不解析 payload ⇒ 红 |
| `TestProfileRestoredAfterRoundEnd` | End 不归位 ⇒ 红 |
| `TestUnknownSecurityProfileFallsBackWithoutPanic` | 拼错沿用旧档 ⇒ 红 |
| `TestAgentCoreHoldsNoPolicySemantics` | 把 `Kind:` / `CheckToolFor(` 写回 agent ⇒ 红 |
| `TestPrincipalKindIsDerivedBySecurity` | 分类规则错位 / 枚举接受拼错值 ⇒ 红 |
| `TestEveryInputPathDeclaresRound` | rtchild 路径漏 beginSecurityRound ⇒ 红 |

`TestAgentCoreHoldsNoPolicySemantics` 逐行扫描(跳过注释),禁四类形态:
`payload["security_profile"]` / `Kind:` / `.CheckToolFor(` / `PrincipalPolicy{`。

## 实测(真实整机装配)

	① security 自己解析出的档位 = "qq.guest"
	② security 自己判定的主体     = {ID:main Kind:user}
	③ 本轮判定 cmd_run            = Allowed:false
	   reason="tool \"cmd_run\" is disabled by profile \"qq.guest\""

## 已知限制(已写进 round.go 与 future-plan)

`Round` 是 per-round 句柄,但 `Runtime.active` 仍是**进程级单例**。
多 agent 轮次**交错**时无法表达两个不同档位(串行场景完全正确)。
彻底修法是把档位挂到 TaskFrame 上按任务隔离,需先改 CheckTool 签名。

build / vet / go test ./...(62 ok / 0 fail)/ -race(security + agent/core +
kernelmod/all)全绿;make build 通过。
This commit is contained in:
JianFeeeee
2026-10-31 07:14:09 +08:00
parent b9b5ac0282
commit 5194a96560
7 changed files with 703 additions and 134 deletions

View File

@ -27,25 +27,85 @@ import (
"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 是主体类别("user" / "group" / "internal" / "resident" …)。
// Kind 是主体类别(固定枚举,见上方常量)。
//
// ★ 为什么不只看 ID:内部委派与用户请求的 ID 可能相同
//(驻留子继承父的输入事件),但**权限模型完全不同** ——
// 这正是 QQ 那条 `isInternalToolCall` 分支存在的原因。
Kind string
// ★ 为何不只看 ID:内部委派与用户请求的 ID 可能相同
//(驻留子继承父的输入事件),但**权限模型完全不同**。
Kind PrincipalKind
// Tags 是附加标签(如 "admin"、"trusted"),策略可按标签授予。
Tags []string
}
// IsInternal 报告是否内部主体(agent 体系自己发起)。
// IsInternal 报告是否内部主体(内核自身或内核派出的子执行体)。
//
// 语义:内部主体**不受** HardPrivateTools 限制 —— 那条规则防的是
// “用户偷看私人数据”,而内部委派本就有完整权限(QQ 的实测教训:
// 再拦一次等于“机器人自己派出去的活被自己拦住”)。
func (p Principal) IsInternal() bool {
return p.Kind == "internal" || p.Kind == "resident"
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 是该主体**可用**的工具模式(空 = 不额外限制)。

303
internal/security/round.go Normal file
View File

@ -0,0 +1,303 @@
package security
// 轮次上下文(RoundContext):**security 对外的输入接入点**。
//
// ## 为什么需要这一层(2026-10-31 收敛)
//
// 收敛前的形态:security 只暴露细粒度判定原语(`CheckTool` / `CheckToolFor` /
// `ActivateProfile`),于是**每个消费方都得自己理解策略语义**:
//
// agent/core/security_profile.go 里曾这样写:
//
// name, _ := payload["security_profile"].(string) // 自己抠字段
// a.security.ActivateProfile(name, 0) // 再告诉 security
// if a.parentID != "" { // 自己判断走哪条链
// return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
// }
// return a.security.CheckTool(tool)
//
// 三个问题全出在这段:
//
// ① `security_profile` 是 security 自己的协议概念,解析却在外人手里
// ② “我是父还是子”是策略语义,判断却在 agent 手里
// ③ `Kind: "resident"` 是调用方**声明自己的类别** ⇒ 说自己是内部就享受
// 内部豁免,那是提权口子
//
// ## 本文件确立的形态
//
// 调用方只提供**事实**(谁在跑、有没有父、原始载荷)
// security 自己做**结论**(类别、档位、走哪条判定链)
//
// 对应 EventBus 那个已经跑通的范例:总线只交事件,guard 自己判。
//
// events.Bus.Publish → Guard.AllowPublish(Publisher, EventType) → 判定在 guard 内
//
// ## 生命周期
//
// rc, err := rt.BeginRound(RoundInput{...}) // 输入到达
// defer rc.End() // 轮次结束(归位,防跨轮泄漏)
// rc.CheckTool(name) // 本轮的判定(只含本轮的档位/主体)
//
// `End` 必须调:档位在 `Runtime` 上是**进程级状态**,不归位就会泄漏到下一轮
// (guest 的收窄误伤后续输入;方向相反就是提权)。
import (
"fmt"
"log"
"strings"
)
// RoundInput 是一次「输入到达」时调用方能提供的**全部事实**。
//
// ★ 这里刻意**不含 Kind**:类别由 security 自己从 AgentID/IsChild 推。
// 调用方给不出“我是内部主体”这种结论 —— 它只能说自己是谁。
type RoundInput struct {
// AgentID 是执行本次输入的执行体标识(主 agent = "main",子 = 子的 id)。
AgentID string
// IsChild 表示本次输入由**某个父 agent 派出的子执行体**处理。
//
// 这是调用方能提供的事实(驻留子/rtchild 自己知道有父),
// 而“子属于哪一类主体、该不该豁免 HardPrivate”由 security 决定。
IsChild bool
// Payload 是**原始输入载荷**(`InputEvent.Payload`)。
//
// ★ security 自己从中解析 `security_profile` —— 那是 security 的协议,
// 不该让每个消费方各写一遍解析。字段名定义见 `profileKey`。
Payload map[string]interface{}
// Source 是来源通道名(诊断与审计用,不参与判定)。
Source string
}
// profileKey 是输入载荷里携带命名档位的键名。
//
// 与公开 SDK 的 `InjectOptions.SecurityProfile`(JSON 名 `security_profile`)
// **逐字一致** —— 那是插件与 security 之间的协议,解析权属于 security。
const profileKey = "security_profile"
// Round 是一轮的策略上下文句柄。
//
// 它把「本轮生效的档位」与「本轮的主体」绑在一起,使判定成为**纯函数式**的
// 一次调用,调用方不再需要知道任何组合规则。
type Round struct {
rt *Runtime
// principal 是 security **自己判定**出来的主体(不是调用方声明的)。
principal Principal
// profile 是本轮激活的档位名(空 = 未使用档位)。
profile string
// restored 记录 BeginRound 之前的档位,End 时恢复。
//
// 为什么恢复而不是清空:进程内可能有多条输入在跑(主 agent + 驻留子),
// 清空会把别处的档位抹掉。恢复到进入前的值是最小惊讶行为。
restored string
ended bool
}
// CheckToolForAgent 按**执行体身份**判定工具是否可用(无轮次句柄时的入口)。
//
// ## 什么时候用它
//
// 工具执行链(`executeToolCallInner`)只持有执行体身份(id + 有无父),
// 不持有本轮句柄。此时由 security **自己**从事实推出主体类别,
// 而不是让调用方声明 `Kind` —— 后者是提权口子。
//
// 与 `BeginRound` + `Round.CheckTool` 的区别:
//
// BeginRound 路线 —— 带档位(本轮激活的 profile 参与判定)
// 本函数 —— 不带档位处理,只看全局策略 + 主体策略
//
// ★ 为什么不用把档位也算进来:档位是**输入**携带的(`security_profile`),
// 它属于某一轮的事实。工具执行链上没有轮次上下文,硬塞一个“进程当前档”
// 才是不对的(那正是收敛前 `ActivateProfile` 全局单例的毛病)。
// 真正的修法是让执行链拿到本轮句柄 —— 见 future-plan。
func (r *Runtime) CheckToolForAgent(agentID string, isChild bool, tool string) Decision {
if r == nil {
return allowed()
}
return r.CheckToolFor(r.classifyPrincipal(RoundInput{
AgentID: agentID,
IsChild: isChild,
}), tool)
}
// BeginRound 在**一条输入开始处理时**调用,由 security 完成:
//
// ① 判定主体类别(据 AgentID + IsChild,不信调用方声明)
// ② 从原始 Payload 解析 `security_profile`
// ③ 激活档位(未声明/拼错时**响亮降级**,不静默沿用上一轮的档)
//
// 返回的 `Round` 必须 `End()`(defer 即可)。
//
// ★ 为什么档位解析在这里而不是在调用方:
// 档位名是 `security` 的协议(`Profile.Name` 约定 `<owner>.<档位>`)。
// 把它交给调用方解析,等于让 N 个调用方各写一遍,且每写一遍都可能漏。
func (r *Runtime) BeginRound(in RoundInput) (*Round, error) {
if r == nil {
return &Round{}, nil // 未装配 security:零成本句柄
}
rc := &Round{
rt: r,
principal: r.classifyPrincipal(in),
restored: r.ActiveProfile(),
}
name := strings.TrimSpace(roundProfileName(in.Payload))
if name == "" {
// 不带档位 = 显式不使用(沿用全局策略)。但**必须归位**:
// 上一条输入若激活了 guest 档,不清掉就会误伤这一条。
if r.active != nil {
r.active.Clear()
}
return rc, nil
}
if err := r.ActivateProfile(name, 0); err != nil {
// 响亮但不断流:注入是 fire-and-forget,插件看不到内核错误。
// 但这里必须**清掉上一轮的档**,否则拼错档位名会静默沿用旧档 ——
// “拼错却仍收窄”与“拼错却仍放行”都是安全事故形态。
if r.active != nil {
r.active.Clear()
}
log.Printf("[security] round profile %q rejected for agent %q: %v "+
"(falling back to default policy, NOT to the previous round's profile)", name, in.AgentID, err)
return rc, err
}
rc.profile = name
return rc, nil
}
// roundProfileName 从原始载荷里取档位名。
//
// 独立成小函数是为了让“解析规则只有一处”这件事在代码上可见:
// 任何调用方都不该再写 `payload["security_profile"]`。
func roundProfileName(payload map[string]interface{}) string {
if payload == nil {
return ""
}
s, _ := payload[profileKey].(string)
return s
}
// classifyPrincipal 由 security **自己**判定主体类别。
//
// ## 判定规则(唯一事实来源)
//
// IsChild ⇒ KindSubAgent (内核派出的子执行体)
// AgentID == "" ⇒ KindInternal (内核自身发起的自循环任务)
// 其他 ⇒ KindUser (经插件通道进来的终端用户)
//
// ★ 为什么"子"优先于"内核自身":
// 子执行体也是内部委派(共享 HardPrivate 豁免),但它**可以被父收窄**。
// 若先判成 KindInternal,父在创建时声明的 Allow/Deny 就落不到它头上 ——
// 那正是收敛前 `Kind: "resident"` 掩盖的问题(它同时意味着
// “我是内部”和“我不受收窄”)。
func (r *Runtime) classifyPrincipal(in RoundInput) Principal {
p := Principal{ID: in.AgentID}
switch {
case in.IsChild:
p.Kind = KindSubAgent
case strings.TrimSpace(in.AgentID) == "":
p.Kind = KindInternal
default:
p.Kind = KindUser
}
return p
}
// Principal 返回本轮的主体(security 判定出的,只读)。
func (rc *Round) Principal() Principal {
if rc == nil {
return Principal{}
}
return rc.principal
}
// Profile 返回本轮生效的档位名(空 = 未使用档位)。
func (rc *Round) Profile() string {
if rc == nil {
return ""
}
return rc.profile
}
// CheckTool 判定本轮的某个工具是否可用。
//
// 这就是工具执行链**唯一**需要的调用:调用方不再需要知道
// “我是父还是子、该走 CheckTool 还是 CheckToolFor”。
func (rc *Round) CheckTool(name string) Decision {
if rc == nil || rc.rt == nil {
return allowed()
}
return rc.rt.CheckToolFor(rc.principal, name)
}
// End 结束本轮:恢复进入前的档位。
//
// 幂等:重复调用无副作用(defer + 显式调用并存是安全的)。
func (rc *Round) End() {
if rc == nil || rc.rt == nil || rc.ended {
return
}
rc.ended = true
if rc.rt.active == nil {
return
}
if rc.restored != "" {
// 恢复到进入前的档位(可能是别的输入正在用的档)。
rc.rt.active.Activate(rc.restored, scopeTTLDefault)
return
}
rc.rt.active.Clear()
}
// DeclareChildPolicy 让**父**为刚创建的子执行体声明主体策略。
//
// ## 为什么这个入口也在 security 里(而不是 agent 组装结构体)
//
// 收敛前 `agent/core` 自己拼 `PrincipalPolicy{AllowTools: …, DenyTools: …}`
// 字面量 —— 那是策略结构,属于 security 的领域。调用方只该说
// “这个子允许这些、禁止那些”,由 security 负责把语义落到正确的作用域。
//
// 语义约束(security 保证):声明只能**收窄**。`CheckToolFor` 内部先跑
// `CheckTool`(全局 deny 优先),所以父的黑名单对子依然有效,子无法提权。
func (r *Runtime) DeclareChildPolicy(childID string, allow, deny []string) error {
if r == nil {
return nil // 未装配 security:不为子声明,保持无限制(与之前一致)
}
childID = strings.TrimSpace(childID)
if childID == "" {
return fmt.Errorf("security: declare child policy requires a non-empty child id")
}
if len(allow) == 0 && len(deny) == 0 {
return nil // 父选择不给额外限制
}
return r.DeclarePrincipalPolicy("id", childID, PrincipalPolicy{
AllowTools: allow,
DenyTools: deny,
})
}
// RevokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。
//
// ★ 必须撤销:主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作。
// 旧策略留着 ⇒ 新一代用上一代父留的约束运行(且那个父可能已不存在),
// 主体表还会随 create/destroy 单调增长。
// 这与 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)同一形态。
func (r *Runtime) RevokeChildPolicy(childID string) {
if r == nil || strings.TrimSpace(childID) == "" {
return
}
_ = r.RevokePrincipalPolicy("id", strings.TrimSpace(childID))
}
// ClassifyPrincipalForTest 暴露主体分类规则给跨包判据(**仅测试用**)。
//
// 为什么不写在 _test.go:判据住在 `kernelmod/all`(那里才有整机装配),
// 而 Go 的 _test.go 符号不跨包导出。带 ForTest 后缀是为了让
// "生产里调了它"在 code review 时一眼可见。
func (r *Runtime) ClassifyPrincipalForTest(in RoundInput) Principal {
return r.classifyPrincipal(in)
}