mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 16:27:28 +00:00
## 上一版错在哪
上一版(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 消费者
215 lines
8.9 KiB
Go
215 lines
8.9 KiB
Go
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)
|
||
}
|