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

@ -1,23 +1,39 @@
package core
// 插件声明限式的**生效点**:inputch 输入进来时激活,轮次内据此判定。
// agent 侧与安全模块的**接线点**。
//
// ## 为什么在这里(而不是在工具调用时)
// ## 边界(2026-10-31 收敛后的形态)
//
// 插件在注入时带上 `security_profile`,那一刻是**唯一**能确定
// "这条输入属于哪个身份档位"的时刻。等 LLM 决定调工具时,插件早已
// 不在调用栈上(异步通道 + 多轮工具循环),档位信息就断了。
// security 是**外围内核到核心内核之间的防火墙**:它自己提供接口、自己做判定。
// agent 只负责两件事:
//
// 所以:输入到达 → 激活档位(带 TTL)→ 该轮的工具/内容判定都读它。
// ① 提供**事实**:谁在跑、有没有父、原始输入载荷
// ② 在输入开始/结束时各调一次 security 的轮次入口
//
// ## 缺省语义
// ★ 本文件**不得**再出现任何策略语义:
//
// 没有携带 `security_profile` = **不使用档位**(沿用内核默认策略)。
// 这是可选参数:不声明档位的插件行为与之前完全一致。
// ✗ 不该解析 `security_profile`(那是 security 的协议)
// ✗ 不该判断“我该走 CheckTool 还是 CheckToolFor”(那是策略决策)
// ✗ 不该声明自己是哪一类主体(Kind 由 security 自己推)
// ✗ 不该拼 `PrincipalPolicy{...}` 字面量(那是策略结构)
//
// 携带了**未声明**的档位名 ⇒ 记日志并回退到默认策略,但**不改写输入**:
// 注入是 fire-and-forget,插件看不到内核错误;至少要在日志里留下
// 「你声明的档位没生效」的痕迹(与 cleaner_name 同一纪律)。
// 这些全部已收回到 `internal/security`:
//
// security.RoundInput —— 调用方能提供的全部事实(无 Kind)
// security.BeginRound —— security 解析档位 + 判定主体类别
// security.Round —— 本轮句柄,只暴露 CheckTool / End
// security.DeclareChildPolicy / RevokeChildPolicy —— 子策略的生命周期
//
// ## 为什么这样收(教训)
//
// 收敛前本文件有 214 行,其中绝大部分是策略逻辑。它带来三个后果:
//
// · 三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效
// · 判据只能钉住“接线之后行为对”,钉不住“有没有接线”
// · `Kind: "resident"` 让调用方**声明自己的类别** ⇒ 提权口子
//
// 对应的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` ——
// 总线只交事件,guard 自己判,总线一行策略都没有。
import (
"log"
@ -25,131 +41,83 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// activateSecurityProfile 按输入 payload 激活插件声明的安全档位。
// beginSecurityRound 在一条输入开始处理时调用:
// 把**原始事实**交给 security,拿回本轮句柄。
//
// 返回被激活的档位名(空 = 未使用档位),仅供日志/诊断。
func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
if a == nil || a.security == nil || payload == nil {
return ""
// 调用方必须在轮次结束时 `End()`(defer)。未装配 security 时返回一个
// 零成本句柄(所有判定放行),与收敛前行为一致。
//
// ★ 为什么由 agent 调而不是 io 层调:轮次的生命周期由 agent 的
// `prepareInputTask` 界定(那里才知道一条输入何时真正开始/结束)。
// security 不依赖 agent,所以只能由 agent 主动把边界告诉它。
func (a *Agent) beginSecurityRound(payload map[string]interface{}, source string) *security.Round {
if a == nil || a.security == nil {
return nil
}
name, _ := payload["security_profile"].(string)
if name == "" {
return ""
rc, err := a.security.BeginRound(security.RoundInput{
AgentID: string(a.id),
// IsChild 是**事实**(有父就是子),不是类别结论。
IsChild: a.parentID != "",
Payload: payload,
Source: source,
})
if err != nil {
// security 内部已记录原因并把档位归位。这里不阻断输入:
// 注入是 fire-and-forget,插件看不到内核错误,丢输入比降级更糟。
// 但**必须留痕**,否则“档位一直没生效”会完全静默。
a.securityRoundDegraded(err)
}
if err := a.security.ActivateProfile(name, 0); err != nil {
// 响亮但不断流:拼错档位名不该让整条输入丢失,
// 但也绝不能静默 —— 那等于悄悄降级到默认策略。
log.Printf("[agent] input declared security_profile=%q but activation failed: %v", name, err)
return ""
}
return name
return rc
}
// securityDecisionFor 判定某工具是否可用。
// securityRoundDegraded 记录“本轮策略降级”这一事实。
//
// ## 两条路径(为什么不能只有一条)
// 独立成方法是为了让降级有一个**可被检索的日志锚点**:
// 收敛前这条链断了整整一轮发布,而没有任何一行日志提示。
func (a *Agent) securityRoundDegraded(err error) {
if a == nil {
return
}
log.Printf("[security] round policy degraded for agent %q: %v "+
"(continuing with the default global policy)", a.id, err)
}
// securityDecisionFor 判定某工具是否可用(**兼容入口**)。
//
// 普通 agent(主 agent) → Runtime.CheckTool(tool)
// 全局策略 + 活跃档位 + 轮次作用域。它代表**内核自己**,不受“某个主体”约束。
// ## 它现在只做转发,不含任何策略
//
// 子 agent(resident/rtchild)→ Runtime.CheckToolFor(Principal{self}, tool)
// 额外叠加**父在创建它时为它声明的主体策略**(见 SpawnResident /
// SpawnRTChild 里的 DeclarePrincipalPolicy)。
// 收敛前这里写着两条分支(父走 CheckTool、子走 CheckToolFor)并硬编码
// `Kind: "resident"`。现在那两条判断都在 security 内部
// (`classifyPrincipal`),本函数退化成一次裸转发。
//
// ★ 为什么子不能只走 CheckTool:那等于“子继承父的**全部**权限”。
// 正确模型是 **父为子声明策略**(用户 2026-10-30 裁定):
// 父在创建时决定这个子能做什么,内核持有并执行,子不能自己扩大。
// 而 CheckToolFor 内部**已经**先调了 CheckTool(全局 deny 优先),
// 所以不存在“主体策略绕过全局黑名单”的缺口。
//
// ## 历史(两轮都错,值得记住)
//
// ★ 第一轮(2026-10-29)的诊断是「`a.security` 从未被赋值」,于是补了
// `SetSecurity()`。但**生产装配链从来没调它** —— 唯一的调用方是 4 条测试。
// 那 4 条判据全绿,因为它们在测试里**手动**调了 `a.SetSecurity(...)`:
// 判据自己把缺失的那根线补上了,所以永远绿。
//
// ★ 第二轮(2026-10-30)才看清根因:`kernelmod/agent` 的 `Requires()`
// **没有声明 `FacSecurity`**,依赖图就没有理由把 security 的产物交给
// agent(只有声明了的 device 拿到了)。所以这不是"少一行赋值",
// 而是"模块边界没连上"。
//
// 现在的链路:
//
// security.Init → ks.Security
// → buildAgent 传 AgentConfig.Security
// → New() 写 a.security
// → 子 agent(resident/rtchild)继承同一个 Runtime 实例
// → 但**权限**不继承:父在创建时为每个子单独声明主体策略
// → 本函数按“自己是不是子”选对应判定入口
//
// 判据钉的是**装配**(kernelmod/all 的整机判据)与**创建时的策略声明**,
// 不是本函数被手动调用后的行为。
// ★ 保留它的理由:工具执行链(`executeToolCallInner`)需要一次判定,
// 而那里只持有 `*Agent`、不持有轮次句柄。走 security 的**默认轮次**
// (按 `AgentID` + 有无父自动分类)等价于原来“父/子分流”的行为,
// 但语义只写在 security 一处。
func (a *Agent) securityDecisionFor(tool string) security.Decision {
if a == nil || a.security == nil {
return security.Decision{Allowed: true}
}
// 子 agent:叠加父为它声明的主体策略。
//
// ★ 主体 Kind 用 "resident"(IsInternal() 为真):
// · 它确实属于内部执行体(父的委派),所以 HardPrivateTools 不拦它
// (那条防的是**用户主体**偷看私人数据,内部委派本就有完整权限)
// · 但父声明的 DenyTools / AllowTools **仍然生效**(都是收窄方向)
// · 若父没为它声明任何策略(lookup 为空)⇒ 行为与 CheckTool 一致
if a.parentID != "" {
return a.security.CheckToolFor(security.Principal{
ID: string(a.id),
Kind: "resident",
}, tool)
}
return a.security.CheckTool(tool)
return a.security.CheckToolForAgent(string(a.id), a.parentID != "", tool)
}
// declareChildPolicy 让**父**为刚创建的子 agent 声明主体策略。
// declareChildPolicy 让父为刚创建的子执行体声明主体策略。
//
// ## 为什么必须在创建点、由父做(用户裁定 2026-10-30)
//
// 子 agent 没有配置中心句柄(轻量内核刻意不接),也没有“自己决定权限”的
// 合理性 —— 权限只能来自委派者。这与 `PreferredProvider` 同一道理:
// “哪个源/多少权限”是**委派策略**,本来就该在创建点决定。
//
// ## 收窄的方向
//
// 声明只能**收窄**:
//
// DenyTools 追加禁止(父自己能用、但不给这个子用)
// AllowTools 白名单(非空时,子只能用这些)
// HardPrivateTools 由主体模型处理(内部主体不被它拦)
//
// 它**不能扩大**:CheckToolFor 内部先跑 CheckTool(全局 deny 优先),
// 所以父的黑名单对子依然有效。
//
// childID 未声明策略时返回 nil(合法:父选择不给额外限制)。
// 这里只做转发:策略结构的组装与作用域落点由 security 负责
// (`security.DeclareChildPolicy`)。
func (a *Agent) declareChildPolicy(childID string, allow, deny []string) error {
if a == nil || a.security == nil {
// 父自己没有安全运行时(独立工具/单测)⇒ 不为子声明,保持无限制。
return nil
}
if len(allow) == 0 && len(deny) == 0 {
return nil
}
return a.security.DeclarePrincipalPolicy("id", childID, security.PrincipalPolicy{
AllowTools: allow,
DenyTools: deny,
})
return a.security.DeclareChildPolicy(childID, allow, deny)
}
// revokeChildPolicy 在子 agent 销毁时撤销父为它声明的策略。
//
// ★ 不撤销的后果(与今天修的 rtchild 留档串线同型):
// 主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作,
// 而旧策略留在表里 => 新一代用上一代父留的约束运行。
// 且表会随 create/destroy 单调增长。
// revokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。
func (a *Agent) revokeChildPolicy(childID string) {
if a == nil || a.security == nil || childID == "" {
if a == nil || a.security == nil {
return
}
_ = a.security.RevokePrincipalPolicy("id", childID)
a.security.RevokeChildPolicy(childID)
}
// SetSecurity 注入安全运行时(**仅限测试与独立工具**)。
@ -157,10 +125,11 @@ func (a *Agent) revokeChildPolicy(childID string) {
// ## 不要在生产装配里调它
//
// 生产路径是 `AgentConfig.Security`(构造期落位),因为那一条由依赖图
// 保证 `ks.Security` 已就绪。用 SetSecurity 事后补会造出一个图看不见的
// 隐式依赖 —— 正是本仓反复踩的那个坑。
// 保证 `ks.Security` 已就绪(`kernelmod/agent` 的 `Requires()` 声明了
// `FacSecurity`)。用 SetSecurity 事后补会造出一个图看不见的隐式依赖 ——
// 正是本仓反复踩的那个坑。
//
// 保留它是因为 4 条进程内判据需要一个"只换安全运行时"的开关,
// 保留它是因为进程内判据需要一个「只换安全运行时」的开关,
// 而它们不跑整机装配。
//
// ## 为什么允许 nil
@ -196,13 +165,21 @@ func (a *Agent) ProbeSecurityRuntime() *security.Runtime {
return a.security
}
// ProbeSecurityDecision 按**本 agent 的身份**(是否子 agent)判定工具可用性(判据用)。
// ProbeSecurityDecision 判定工具可用性(判据用)。
//
// 它走的就是生产路径(securityDecisionFor),不是另写一份逻辑。
func (a *Agent) ProbeSecurityDecision(tool string) security.Decision {
return a.securityDecisionFor(tool)
}
// ProbeSecurityRound 按真实输入路径开一轮,返回句柄(判据用)。
//
// 判据用它验证「档位由 security 自己从 payload 解析」这条链真的通了,
// 而不需要直接调 security 的内部方法。
func (a *Agent) ProbeSecurityRound(payload map[string]interface{}, source string) *security.Round {
return a.beginSecurityRound(payload, source)
}
// ProbeDeclareChildPolicy 是 declareChildPolicy 的导出包装(判据用)。
func (a *Agent) ProbeDeclareChildPolicy(childID string, allow, deny []string) error {
return a.declareChildPolicy(childID, allow, deny)