Files
HomeAgent/internal/security/principal_test.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

66 lines
2.5 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 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 策略应仍然生效")
}
}