Files
HomeAgent/internal/agent/core/security_profile.go
JianFeeeee e78a38b3d8 fix(security): 父为子声明工具策略 —— 修正上一版「子继承父权限」的错误模型
## 上一版错在哪

上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。

那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。

区别是实质的:

	错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
	对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大

驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。

## 这不是新机制,是没接线的旧机制

`security.Runtime` 里早就有:

	CheckToolFor(p Principal, tool)              // 按主体判定
	PrincipalPolicy{AllowTools, DenyTools,       // 主体策略
	                HardPrivateTools}
	DeclarePrincipalPolicy(scope, key, policy)   // 声明入口

而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。

## 改动

① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
   **撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
   子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
   这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
   全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
   等于任何执行体销毁时都能抹掉全站策略。

② `agent/core`:`securityDecisionFor` 分两条路径

	父(parentID == "")    → CheckTool(tool)                          // 内核自身
	子(parentID != "")    → CheckToolFor(Principal{self, "resident"},
	                                        tool)                      // 叠加父的声明

   CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
   主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
   (那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。

③ 创建点声明、销毁点撤销(生命周期对称)

	SpawnResident      → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
	SpawnRTChild       → 同上
	teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy

   声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
   「子已跑工具、策略还没进内核」的窗口。
   声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。

④ 工具面:`resident_agents create` 与 `rt_children create` 新增
   `allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
   因为模型很容易把白名单误读成额外授予。

## 判据(4 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |

`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。

`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。

## 实测(真实整机装配 + 真执行)

	① 父 ExecuteTool(cmd_run)            → 走到分派(未被拦)
	② 父声明 deny=[cmd_run] 后,子的判定  → Allowed=false
	   reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
	③ 父自己的判定                        → 仍 Allowed(主体隔离)
	④ 撤销后子的判定                      → 恢复 Allowed

build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。

## 仍未接线

- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
2026-10-31 06:35:32 +08:00

215 lines
8.9 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
// 插件声明限式的**生效点**:inputch 输入进来时激活,轮次内据此判定。
//
// ## 为什么在这里(而不是在工具调用时)
//
// 插件在注入时带上 `security_profile`,那一刻是**唯一**能确定
// "这条输入属于哪个身份档位"的时刻。等 LLM 决定调工具时,插件早已
// 不在调用栈上(异步通道 + 多轮工具循环),档位信息就断了。
//
// 所以:输入到达 → 激活档位(带 TTL)→ 该轮的工具/内容判定都读它。
//
// ## 缺省语义
//
// 没有携带 `security_profile` = **不使用档位**(沿用内核默认策略)。
// 这是可选参数:不声明档位的插件行为与之前完全一致。
//
// 携带了**未声明**的档位名 ⇒ 记日志并回退到默认策略,但**不改写输入**:
// 注入是 fire-and-forget,插件看不到内核错误;至少要在日志里留下
// 「你声明的档位没生效」的痕迹(与 cleaner_name 同一纪律)。
import (
"log"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// activateSecurityProfile 按输入 payload 激活插件声明的安全档位。
//
// 返回被激活的档位名(空 = 未使用档位),仅供日志/诊断。
func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
if a == nil || a.security == nil || payload == nil {
return ""
}
name, _ := payload["security_profile"].(string)
if name == "" {
return ""
}
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
}
// securityDecisionFor 判定某工具是否可用。
//
// ## 两条路径(为什么不能只有一条)
//
// 普通 agent(主 agent) → Runtime.CheckTool(tool)
// 全局策略 + 活跃档位 + 轮次作用域。它代表**内核自己**,不受“某个主体”约束。
//
// 子 agent(resident/rtchild)→ Runtime.CheckToolFor(Principal{self}, tool)
// 额外叠加**父在创建它时为它声明的主体策略**(见 SpawnResident /
// SpawnRTChild 里的 DeclarePrincipalPolicy)。
//
// ★ 为什么子不能只走 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 的整机判据)与**创建时的策略声明**,
// 不是本函数被手动调用后的行为。
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)
}
// declareChildPolicy 让**父**为刚创建的子 agent 声明主体策略。
//
// ## 为什么必须在创建点、由父做(用户裁定 2026-10-30)
//
// 子 agent 没有配置中心句柄(轻量内核刻意不接),也没有“自己决定权限”的
// 合理性 —— 权限只能来自委派者。这与 `PreferredProvider` 同一道理:
// “哪个源/多少权限”是**委派策略**,本来就该在创建点决定。
//
// ## 收窄的方向
//
// 声明只能**收窄**:
//
// DenyTools 追加禁止(父自己能用、但不给这个子用)
// AllowTools 白名单(非空时,子只能用这些)
// HardPrivateTools 由主体模型处理(内部主体不被它拦)
//
// 它**不能扩大**:CheckToolFor 内部先跑 CheckTool(全局 deny 优先),
// 所以父的黑名单对子依然有效。
//
// childID 未声明策略时返回 nil(合法:父选择不给额外限制)。
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,
})
}
// revokeChildPolicy 在子 agent 销毁时撤销父为它声明的策略。
//
// ★ 不撤销的后果(与今天修的 rtchild 留档串线同型):
// 主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作,
// 而旧策略留在表里 => 新一代用上一代父留的约束运行。
// 且表会随 create/destroy 单调增长。
func (a *Agent) revokeChildPolicy(childID string) {
if a == nil || a.security == nil || childID == "" {
return
}
_ = a.security.RevokePrincipalPolicy("id", childID)
}
// SetSecurity 注入安全运行时(**仅限测试与独立工具**)。
//
// ## 不要在生产装配里调它
//
// 生产路径是 `AgentConfig.Security`(构造期落位),因为那一条由依赖图
// 保证 `ks.Security` 已就绪。用 SetSecurity 事后补会造出一个图看不见的
// 隐式依赖 —— 正是本仓反复踩的那个坑。
//
// 保留它是因为 4 条进程内判据需要一个"只换安全运行时"的开关,
// 而它们不跑整机装配。
//
// ## 为什么允许 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 按**本 agent 的身份**(是否子 agent)判定工具可用性(判据用)。
//
// 它走的就是生产路径(securityDecisionFor),不是另写一份逻辑。
func (a *Agent) ProbeSecurityDecision(tool string) security.Decision {
return a.securityDecisionFor(tool)
}
// 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)
}