mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
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:
@ -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 是该主体**可用**的工具模式(空 = 不额外限制)。
|
||||
|
||||
Reference in New Issue
Block a user