Files
HomeAgent/internal/agent/core/security_profile.go
JianFeeeee 82932821c0 feat(security): 每 agent 一个策略实例 —— sessionProfile / longtimeProfile 两层
## 用户裁定

> 每个 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` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
2026-10-31 07:24:03 +08:00

222 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// agent 侧与安全模块的**接线点**。
//
// ## 边界(2026-10-31 收敛后的形态)
//
// security 是**外围内核到核心内核之间的防火墙**:它自己提供接口、自己做判定。
// agent 只负责两件事:
//
// ① 提供**事实**:谁在跑、有没有父、原始输入载荷
// ② 在输入开始/结束时各调一次 security 的轮次入口
//
// ★ 本文件**不得**再出现任何策略语义:
//
// ✗ 不该解析 `security_profile`(那是 security 的协议)
// ✗ 不该判断“我该走 CheckTool 还是 CheckToolFor”(那是策略决策)
// ✗ 不该声明自己是哪一类主体(Kind 由 security 自己推)
// ✗ 不该拼 `PrincipalPolicy{...}` 字面量(那是策略结构)
//
// 这些全部已收回到 `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"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// beginSecurityRound 在一条输入开始处理时调用:
// 把**原始事实**交给 security,拿回本轮句柄。
//
// 调用方必须在轮次结束时 `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
}
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)
}
return rc
}
// 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 判定某工具是否可用(**兼容入口**)。
//
// ## 它现在只做转发,不含任何策略
//
// 收敛前这里写着两条分支(父走 CheckTool、子走 CheckToolFor)并硬编码
// `Kind: "resident"`。现在那两条判断都在 security 内部
// (`classifyPrincipal`),本函数退化成一次裸转发。
//
// ★ 保留它的理由:工具执行链(`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 的**执行视图**(session 档 > longtime 档 > 全局基线)。
//
// 这是 2026-10-31「每个 agent 一个实例」的落地:视图由 security 按
// agentID 持有,本 agent 的 sessionProfile 不会污染别的 agent。
return a.securityScope().CheckTool(tool)
}
// securityScope 返回(并缓存)本 agent 的策略执行视图。
//
// 为什么要缓存:`ForAgent` 是幂等的,但每条输入/每次工具调用都取一次会
// 反复加锁;而视图本身在整个 agent 生命周期内不变。
func (a *Agent) securityScope() *security.AgentScope {
if a == nil || a.security == nil {
return nil
}
a.securityScopeOnce.Do(func() {
a.securityScopeView = a.security.ForAgent(string(a.id), a.parentID != "")
})
return a.securityScopeView
}
// ReleaseSecurityScope 在 agent 销毁时释放它的策略视图。
//
// ★ 必须调:视图里存着 **longtimeProfile**(跨轮保留的档位)。
// agent/子 agent 回收后同名重建是常规操作,残留的 long-time 档会让
// 新一代继承上一代父留的约束 —— 与 `RevokePrincipalPolicy` 同一纪律。
func (a *Agent) ReleaseSecurityScope() {
if a == nil || a.security == nil {
return
}
a.security.ReleaseAgent(string(a.id))
}
// declareChildPolicy 让父为刚创建的子执行体声明主体策略。
//
// 这里只做转发:策略结构的组装与作用域落点由 security 负责
// (`security.DeclareChildPolicy`)。
func (a *Agent) declareChildPolicy(childID string, allow, deny []string) error {
if a == nil || a.security == nil {
return nil
}
return a.security.DeclareChildPolicy(childID, allow, deny)
}
// revokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。
func (a *Agent) revokeChildPolicy(childID string) {
if a == nil || a.security == nil {
return
}
a.security.RevokeChildPolicy(childID)
}
// SetSecurity 注入安全运行时(**仅限测试与独立工具**)。
//
// ## 不要在生产装配里调它
//
// 生产路径是 `AgentConfig.Security`(构造期落位),因为那一条由依赖图
// 保证 `ks.Security` 已就绪(`kernelmod/agent` 的 `Requires()` 声明了
// `FacSecurity`)。用 SetSecurity 事后补会造出一个图看不见的隐式依赖 ——
// 正是本仓反复踩的那个坑。
//
// 保留它是因为进程内判据需要一个「只换安全运行时」的开关,
// 而它们不跑整机装配。
//
// ## 为什么允许 nil
//
// 测试与独立工具(memgc 等)不需要安全模块;传 nil 表示显式不启用。
func (a *Agent) SetSecurity(rt *security.Runtime) {
if a == nil {
return
}
a.security = rt
}
// ───────────────────── 判据专用探针(非生产接口) ─────────────────────
//
// 下面几个函数只为 `internal/kernelmod/all/security_wiring_test.go` 服务。
//
// ## 为什么不写在 _test.go 里
//
// 判据住在 `kernelmod/all` 包(那里才有真实登记表与整机装配),
// 而 Go 的 `_test.go` 符号不跨包导出 ⇒ 跨包判据必须有一个导出面。
//
// ## 为什么叫 Probe*
//
// 这是**测试专用**接口。生产代码不应调用它们 —— 需要判定时用
// `securityDecisionFor`(工具执行链上的那条)。带 Probe 前缀是为了让
// "有人在生产里调了它"在 code review 时一眼可见。
// ProbeSecurityRuntime 返回本 agent 持有的安全运行时(判据用)。
func (a *Agent) ProbeSecurityRuntime() *security.Runtime {
if a == nil {
return nil
}
return a.security
}
// 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)
}
// ProbeRevokeChildPolicy 是 revokeChildPolicy 的导出包装(判据用)。
func (a *Agent) ProbeRevokeChildPolicy(childID string) {
a.revokeChildPolicy(childID)
}