mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-07 01:37:34 +00:00
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 消费者
This commit is contained in:
@ -62,6 +62,22 @@ type ResidentOptions struct {
|
||||
// 但**绝不能**把人(父的模型)建的子算进去或拿去复用 ——
|
||||
// 那会把人工安排的工作负载搬到一个本来在做别的事的 agent 上。
|
||||
OffloadOwned bool
|
||||
// AllowedTools 是**父为这个子声明**的工具白名单(空 = 不作额外限制)。
|
||||
//
|
||||
// ## 为什么由父在创建时给(用户裁定 2026-10-30)
|
||||
//
|
||||
// 子 agent 没有配置中心句柄(轻量内核刻意不接),也**没有资格自己决定权限**
|
||||
// —— 权限只能来自委派者。这与 PreferredProvider 同一道理:
|
||||
// “用哪个源/给多少权限”是**委派策略**,本来就该在创建点决定。
|
||||
//
|
||||
// 内核持有这份声明(`security.Runtime` 的主体表),子崩了/被替换也不会丢:
|
||||
// 否则“挂掉子就能绕过”就是现成的提权口子。
|
||||
AllowedTools []string
|
||||
// DenyTools 是**父为这个子声明**的追加禁止(父自己能用、但不给这个子用)。
|
||||
//
|
||||
// 与 AllowedTools 同时给时:两者**同时生效**(先 deny 后 allow,deny 优先)。
|
||||
// 典型用法:不给白名单(沿用父的),只禁掉几个高危工具。
|
||||
DenyTools []string
|
||||
}
|
||||
|
||||
// ResidentInfo 是父对某个驻留子的可查询状态(登记表条目 + 状态面摘要)。
|
||||
@ -216,6 +232,23 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
offloadOwned: opts.OffloadOwned,
|
||||
}
|
||||
|
||||
// ★ 父为这个子**声明主体策略**(用户裁定 2026-10-30)。
|
||||
//
|
||||
// 位置必须在“子开始跑”之前:子一旦接到第一条输入就会执行工具,
|
||||
// 那个时刻策略必须已经在内核里 —— 否则存在一个“无约束执行”的窗口。
|
||||
//
|
||||
// 声明失败要**回滚整个创建**:一个“本该受限却无限”的执行体
|
||||
// 比创建失败危险得多(而创建失败是可重试的)。
|
||||
if err := a.declareChildPolicy(opts.ID, opts.AllowedTools, opts.DenyTools); err != nil {
|
||||
child.Stop()
|
||||
if light != nil {
|
||||
_ = light.Close()
|
||||
}
|
||||
_ = mainRO.Close()
|
||||
_ = os.RemoveAll(filepath.Dir(opts.TempPath))
|
||||
return ResidentInfo{}, fmt.Errorf("为驻留子 %s 声明安全策略失败(拒绝创建一个无约束的子执行体): %w", opts.ID, err)
|
||||
}
|
||||
|
||||
// ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。
|
||||
parentInCh := a.residentInboundChannel(opts.ID)
|
||||
// 登记了入站 inputch 就必须有回滚路径:注册点与 residents 登记之间
|
||||
@ -570,6 +603,13 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
rc.state = "stopped"
|
||||
rc.mu.Unlock()
|
||||
|
||||
// ★ 撤销父为它声明的主体策略(与 teardown 同时,早于一切资源回收)。
|
||||
//
|
||||
// 不撤销的后果:主体表以**调用方给的名字**为键,回收后同名重建是常规
|
||||
// 操作 ⇒ 新一代用上一代父留的约束运行;且表随 create/destroy 单调增长。
|
||||
// 与 rtchild 留档串线(rtClosedTranscripts)同一形态。
|
||||
a.revokeChildPolicy(rc.id)
|
||||
|
||||
rc.agent.Stop() // 停子的调度器(取消其运行中的任务)
|
||||
if rc.light != nil {
|
||||
_ = rc.light.Close() // 关掉 temp 实例
|
||||
|
||||
Reference in New Issue
Block a user