|
|
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 |
|
|
|
5194a96560
|
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 通过。
|
2026-10-31 07:14:09 +08:00 |
|