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` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
This commit is contained in:
JianFeeeee
2026-10-31 07:24:03 +08:00
parent 5194a96560
commit 82932821c0
8 changed files with 599 additions and 45 deletions

View File

@ -69,6 +69,20 @@ type Runtime struct {
//(QQ 的 beforeToolcall):插件只声明谁可以调什么,
// 判定与拒绝文案由内核统一产出。
principals *principalRegistry
// scopesByAgent 是**每 agent 一个**策略执行视图(见 agentscope.go)。
//
// ★ 为何需要(2026-10-31 用户裁定):每个 agent / 子 agent 一个实例,
// 因为轮次档位(sessionProfile)是**该 agent 独有**的状态。
// 放在 Runtime 单例上会让并发 agent 互相覆写(主 agent 的 guest
// 被驻留子的 owner 盖掉,或反之)。
//
// ★ 为何不把整个 Runtime 复制一份:`profiles` / `principals` / `scopes`
// 是**插件声明出来的注册表**,每个 agent 各持一份就会变成
// “插件往 A 声明了档位,B 看不见”—— 那是最难查的一类缺陷。
// 所以切分是「共享注册表 + per-agent 执行视图」。
scopesByAgent map[string]*AgentScope
scopeMu sync.Mutex
}
// NewRuntime 用已装好的变量库构造运行时。
@ -217,7 +231,30 @@ func (r *Runtime) DeclareTurnPolicy(opts ScopeOptions) error {
// 组合顺序(**deny 永远优先**,这是安全策略的基本直觉):
//
// 全局 deny → 作用域 deny → 全局 allow(非空则必须命中)→ 作用域 allow
//
// ## 与 per-agent 视图的关系(2026-10-31 用户裁定)
//
// 本函数用 `Runtime.active`(**进程级**)上的档位 —— 新代码不要依赖它。
// 新调用方应走 `Runtime.ForAgent(id, isChild)` 拿 `*AgentScope`:
// 那里的档位是**该 agent 独有**的,不会与其他 agent 串。
//
// 保留本函数是为了:独立工具(memgc 等,进程内只有一个执行体)、判据、
// 以及尚未按 agent 切分的旧路径。
func (r *Runtime) CheckTool(name string) Decision {
if r == nil {
return allowed()
}
return r.checkToolWithProfile(Principal{}, r.ActiveProfile(), name)
}
// checkToolWithProfile 是工具判定的**唯一实现**:档位由调用方显式给定。
//
// `profileName` 为空 = 不使用档位(零影响)。
//
// ★ 为什么档位作参数、而不是读全局 `r.active`:
// per-agent 视图必须传入**它自己的**档位(session 或 longtime)。
// 若这里读 `r.active`,per-agent 视图就白做了 —— 那正是本轮要修的缺陷。
func (r *Runtime) checkToolWithProfile(p Principal, profileName, name string) Decision {
if r == nil {
return allowed()
}
@ -227,7 +264,7 @@ func (r *Runtime) CheckTool(name string) Decision {
// ★ 活跃档位(可选):插件没声明/没激活时这里零影响。
// 档位只参与**收窄**:它的 deny / allow 与主体、全局同一条链,
// 并且不能绕过全局黑名单(上面已先判过)。
if prof, ok := r.profilePolicy(); ok {
if prof, ok := r.profilePolicyByName(profileName); ok {
for _, pat := range prof.Policy.DenyTools {
if matchTool(pat, name) {
return deny("tool %q is disabled by profile %q", name, prof.Name)