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:
JianFeeeee
2026-10-31 06:35:32 +08:00
parent 1d007e1d49
commit e78a38b3d8
9 changed files with 475 additions and 66 deletions

View File

@ -94,6 +94,28 @@ func (r *principalRegistry) SetByTag(tag string, p PrincipalPolicy) {
r.mu.Unlock()
}
// RemoveByID 撤销某个主体的策略声明。
//
// ## 为什么必须有它(2026-10-30)
//
// 主体 ID 对**子 agent** 来说是脚本给的(`ring_agents create id="bot1"`)。
// 子被回收后同名重建是常规操作,而若旧策略留在表里:
//
// 新一代用**上一代父当时留的约束**运行 —— 而那个父可能已经不在了。
//
// 这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)
// 是**同一种形态**:以调用方给的名称为键的表,必须在生命周期结束时清理。
//
// 不清理还会单调增长(每建一个不同名字的子就多一条永不释放的 Polciy)。
func (r *principalRegistry) RemoveByID(id string) {
if r == nil || strings.TrimSpace(id) == "" {
return
}
r.mu.Lock()
delete(r.byID, id)
r.mu.Unlock()
}
// lookup 取出与主体相关的全部策略(ID + 每个标签)。
func (r *principalRegistry) lookup(p Principal) []PrincipalPolicy {
if r == nil {
@ -134,6 +156,32 @@ func (r *Runtime) DeclarePrincipalPolicy(scope, key string, p PrincipalPolicy) e
return nil
}
// RevokePrincipalPolicy 撤销一个按 ID 声明的主体策略(合规性:子 agent 销毁时调)。
//
// 只支持 scope="id":标签策略属于**部署级**声明(如 tag=external),
// 它们的生命周期不跟随某个具体执行体,撤销只能由声明方自己做。
func (r *Runtime) RevokePrincipalPolicy(scope, key string) error {
if r == nil || r.principals == nil {
return errNoRuntime
}
if scope != "id" {
return &policyErr{"security: revoke only supports scope=\"id\" (tag policies are deployment-level), got " + scope}
}
r.principals.RemoveByID(key)
return nil
}
// HasPrincipalPolicy 报告某 ID 是否已声明主体策略(诊断/状态面用)。
func (r *Runtime) HasPrincipalPolicy(id string) bool {
if r == nil || r.principals == nil {
return false
}
r.principals.mu.RLock()
defer r.principals.mu.RUnlock()
_, ok := r.principals.byID[id]
return ok
}
// CheckToolFor 按**主体**判定工具是否可用。
//
// ★ 这是替代"插件在 stage 里自己拦"的入口:插件只声明策略,

View File

@ -0,0 +1,65 @@
package security
import "testing"
// ★★★ 判据:主体策略可**撤销**,且撤销后不影响其他主体。
//
// ## 为什么必须能撤销(2026-10-30)
//
// 主体 ID 对子 agent 来说是**调用方给的名字**(`create id="bot1"`)。
// 子被回收后同名重建是常规操作,而若旧策略留在表里:
//
// 新一代用上一代父当时留的约束运行 —— 而那个父可能已经不在了。
//
// 这与 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
// 全仓 0 处删除)是**同一种形态**:以调用方给的名称为键的表,
// 必须在生命周期结束时清理,否则既是语义错误也是单调增长。
func TestPrincipalPolicyCanBeRevoked(t *testing.T) {
rt := NewRuntime(nil)
if err := rt.DeclarePrincipalPolicy("id", "child-x", PrincipalPolicy{
DenyTools: []string{"cmd_*"},
}); err != nil {
t.Fatal(err)
}
// 另声明一个,用来证明撤销不会误伤别人。
if err := rt.DeclarePrincipalPolicy("id", "child-y", PrincipalPolicy{
DenyTools: []string{"cmd_*"},
}); err != nil {
t.Fatal(err)
}
p := Principal{ID: "child-x", Kind: "resident"}
if d := rt.CheckToolFor(p, "cmd_run"); d.Allowed {
t.Fatal("前置:已声明的 deny 应生效")
}
if err := rt.RevokePrincipalPolicy("id", "child-x"); err != nil {
t.Fatalf("撤销失败: %v", err)
}
if d := rt.CheckToolFor(p, "cmd_run"); !d.Allowed {
t.Fatal("★ 撤销后仍被禁 —— 同名重建的子会继承上一代父留下的约束")
}
// 隔离:另一个主体的策略必须还在。
if d := rt.CheckToolFor(Principal{ID: "child-y", Kind: "resident"}, "cmd_run"); d.Allowed {
t.Fatal("★ 撤销 child-x 时误伤了 child-y —— 撤销必须只作用于指定 ID")
}
}
// ★★★ 判据:撤销只支持 scope="id"(标签策略是部署级声明)。
//
// 允许撤销 tag 会让一个执行体的销毁顺手抹掉全站策略 —— 那是提权口子。
func TestRevokeRejectsTagScope(t *testing.T) {
rt := NewRuntime(nil)
if err := rt.DeclarePrincipalPolicy("tag", "external", PrincipalPolicy{
HardPrivateTools: []string{"calendar_*"},
}); err != nil {
t.Fatal(err)
}
if err := rt.RevokePrincipalPolicy("tag", "external"); err == nil {
t.Fatal("★ 支持撤销 tag 策略 = 任何执行体销毁时都能抹掉全站策略")
}
// 原策略仍在。
if d := rt.CheckToolFor(Principal{ID: "u1", Kind: "user", Tags: []string{"external"}}, "calendar_list"); d.Allowed {
t.Fatal("tag 策略应仍然生效")
}
}