diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index a624ea77..81b5e028 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -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 实例 diff --git a/internal/agent/core/resident_tools.go b/internal/agent/core/resident_tools.go index 4a5617d2..f37b3dde 100644 --- a/internal/agent/core/resident_tools.go +++ b/internal/agent/core/resident_tools.go @@ -80,6 +80,9 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string { AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")), Capacity: intArg(tc, "capacity"), TempPath: tempPath, + // ★ 父为子声明工具策略(用户裁定 2026-10-30)。 + AllowedTools: splitArg(strArg(tc, "allowed_tools")), + DenyTools: splitArg(strArg(tc, "deny_tools")), }) if err != nil { return fmt.Sprintf("创建驻留子失败: %v", err) diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index cd8b8bf8..9f04b698 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -368,6 +368,13 @@ type RTChildOptions struct { // 与 `spawn_child` 的 max_turns 同义,但默认值必须是 0(不限): // rt 是**常驻对话**而非一次性任务,跑满就结束是错的。 MaxTools int + // AllowedTools / DenyTools 是**父为这个 rtchild 声明**的工具策略。 + // + // 与驻留子同理(见 ResidentOptions.AllowedTools)。rt 流不禁工具(用户裁定 R3), + // 但 R3 说的是“内核不拔工具”,不是“父不能收窄”—— 通话场景下 + // 把 cmd_run 这类高危工具从子手里拿掉是合理且必要的。 + AllowedTools []string + DenyTools []string } // rtChildDefaultPrompt 是 rtchild 的默认系统提示词。 @@ -584,6 +591,18 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) { return "", fmt.Errorf("划入 outputch(rt) %q: %w", opts.Output, err) } + // ★ 父为这个 rtchild **声明主体策略**(用户裁定 2026-10-30)。 + // + // 位置在这一步(通道已划好、rtLoop 尚未起):子一旦开始跑就会执行工具, + // 那时策略必须已在内核里,否则存在一个“无约束执行”的窗口。 + // + // 声明失败要回滚:一个“本该受限却无限”的执行体比创建失败危险得多。 + if err := a.declareChildPolicy(opts.ID, opts.AllowedTools, opts.DenyTools); err != nil { + cancel() + rollback() + return "", fmt.Errorf("为 rtchild %s 声明安全策略失败(拒绝创建一个无约束的子执行体): %w", opts.ID, err) + } + a.rtMu.Lock() a.rtChildren[opts.ID] = rc // ★ 这里**不**清留档(与一个曾被写错一版的修复相反,记录以示后人)。 @@ -624,6 +643,12 @@ func (a *Agent) StopRTChild(id string) error { if !ok { return fmt.Errorf("rtchild %s 不存在", id) } + // ★ 撤销父为它声明的主体策略(与 StopRTChild 同时)。 + // + // 与 resident 同理:主体表以调用方给的名字为键,同名重建会继承旧策略。 + // 而且若不撤销,`rt_children create id=x` → `stop x` → `create id=x` + // 这个循环会让策略表持续增长且语义错误。 + a.revokeChildPolicy(id) rc.mu.Lock() rc.stopped = true rc.mu.Unlock() @@ -675,6 +700,11 @@ func (a *Agent) StopRTChildren() int { rc.mu.Lock() rc.stopped = true rc.mu.Unlock() + // ★ 撤销主体策略(必须与单数路径对称)。 + // + // 这里曾漏过一步(入站登记未注销),与今天修的 StopRTChildren + // 不对称是同一类缺陷形态 —— 所以两处必须逐字对齐。 + a.revokeChildPolicy(rc.id) rc.agent.cancel() _, _, _ = rc.slot.Take() // 注销入站登记(与 StopRTChild 的单数路径逐字一致)。 @@ -1503,6 +1533,9 @@ func (a *Agent) executeRTChildren(tc agentAPI.ToolCall) string { Output: output, SystemPrompt: strArg(tc, "system_prompt"), MaxTools: rtIntArg(tc, "max_tools", 0), + // ★ 父为子声明工具策略(用户裁定 2026-10-30)。 + AllowedTools: splitArg(strArg(tc, "allowed_tools")), + DenyTools: splitArg(strArg(tc, "deny_tools")), }) if err != nil { return fmt.Sprintf("创建 rtchild 失败: %v\n%s", err, a.listRTChannels()) diff --git a/internal/agent/core/rtchild_route_test.go b/internal/agent/core/rtchild_route_test.go index 714d95d7..7fdacfef 100644 --- a/internal/agent/core/rtchild_route_test.go +++ b/internal/agent/core/rtchild_route_test.go @@ -459,9 +459,10 @@ func TestRTChildrenTool_NotVisibleToChild(t *testing.T) { // // 只断言"返回里有错"不够 —— 那可能只是碰巧提到"创建失败"。 // 所以同时断言: -// ① 返回里出现正确的参数名(inputch / outputch),让人知道该写什么 -// ② **没有**创建出 rtchild(不能半成功后失败) -// ③ 通道归属没被改动(不能"创建失败但通道已经被占了") +// +// ① 返回里出现正确的参数名(inputch / outputch),让人知道该写什么 +// ② **没有**创建出 rtchild(不能半成功后失败) +// ③ 通道归属没被改动(不能"创建失败但通道已经被占了") func TestRTChildrenTool_WrongParamNameIsRejectedNotSilent(t *testing.T) { p := &rtCountingProvider{} host, _ := newRTHost(t, p) @@ -471,10 +472,10 @@ func TestRTChildrenTool_WrongParamNameIsRejectedNotSilent(t *testing.T) { out := host.executeRTChildren(agentAPI.ToolCall{ Name: "rt_children", Arguments: map[string]interface{}{ - "action": "create", - "id": "typo-child", - "input": "voice-in", // ← 错的参数名 - "output": "voice", // ← 错的参数名 + "action": "create", + "id": "typo-child", + "input": "voice-in", // ← 错的参数名 + "output": "voice", // ← 错的参数名 }, }) diff --git a/internal/agent/core/security_profile.go b/internal/agent/core/security_profile.go index 04f1146c..f9650b0b 100644 --- a/internal/agent/core/security_profile.go +++ b/internal/agent/core/security_profile.go @@ -45,10 +45,22 @@ func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string { return name } -// securityDecisionFor 用当前活跃档位 + 主体判定某工具是否可用。 +// securityDecisionFor 判定某工具是否可用。 // -// 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型 -// (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。 +// ## 两条路径(为什么不能只有一条) +// +// 普通 agent(主 agent) → Runtime.CheckTool(tool) +// 全局策略 + 活跃档位 + 轮次作用域。它代表**内核自己**,不受“某个主体”约束。 +// +// 子 agent(resident/rtchild)→ Runtime.CheckToolFor(Principal{self}, tool) +// 额外叠加**父在创建它时为它声明的主体策略**(见 SpawnResident / +// SpawnRTChild 里的 DeclarePrincipalPolicy)。 +// +// ★ 为什么子不能只走 CheckTool:那等于“子继承父的**全部**权限”。 +// 正确模型是 **父为子声明策略**(用户 2026-10-30 裁定): +// 父在创建时决定这个子能做什么,内核持有并执行,子不能自己扩大。 +// 而 CheckToolFor 内部**已经**先调了 CheckTool(全局 deny 优先), +// 所以不存在“主体策略绕过全局黑名单”的缺口。 // // ## 历史(两轮都错,值得记住) // @@ -67,17 +79,79 @@ func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string { // security.Init → ks.Security // → buildAgent 传 AgentConfig.Security // → New() 写 a.security -// → 子 agent(resident/rtchild)继承 a.security -// → 本函数 → CheckTool +// → 子 agent(resident/rtchild)继承同一个 Runtime 实例 +// → 但**权限**不继承:父在创建时为每个子单独声明主体策略 +// → 本函数按“自己是不是子”选对应判定入口 // -// 判据钉的是**装配**(kernelmod/all 的整机判据),不是本函数被手动调用后的行为。 +// 判据钉的是**装配**(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 注入安全运行时(**仅限测试与独立工具**)。 // // ## 不要在生产装配里调它 @@ -98,3 +172,43 @@ func (a *Agent) SetSecurity(rt *security.Runtime) { } 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) +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 11a3a41b..ff114fbc 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -843,6 +843,18 @@ func (a *Agent) buildToolDefs() []interface{} { "system_prompt": map[string]interface{}{"type": "string", "description": "create:系统提示词(留空用内置的实时通话提示词)"}, "max_tools": map[string]interface{}{"type": "number", "description": "create:单轮最多几批工具调用(0=不限,rt 默认不限)"}, "text": map[string]interface{}{"type": "string", "description": "send:要注入的输入(会打断当前轮)"}, + // ★ 父为子声明工具策略(用户裁定 2026-10-30)。 + // + // 描述里必须说清“这是限制”而不是“这是授权”:模型很容易把 + // allowed_tools 误读成“额外授予”。留空 = 不作额外限制(沿用全局策略)。 + "allowed_tools": map[string]interface{}{ + "type": "string", + "description": "create:**你为它声明**的工具白名单(逗号分隔,支持 cmd_* 这类通配)。留空=不额外限制(沿用全局策略)。只能收窄,不能扩大。", + }, + "deny_tools": map[string]interface{}{ + "type": "string", + "description": "create:**你为它声明**的追加禁止(逗号分隔,支持通配)。你能用但不给它用的工具写这里。与 allowed_tools 同时给时两者同时生效(deny 优先)。", + }, }, []string{"action"}, parallelOpts())) } @@ -881,7 +893,17 @@ func (a *Agent) buildToolDefs() []interface{} { "allowed_outputs": map[string]interface{}{"type": "string", "description": "create:授权的输出通道(逗号分隔;留空=完整授权)"}, "capacity": map[string]interface{}{"type": "number", "description": "create:划入 inputch 的队列容量"}, "temp_path": map[string]interface{}{"type": "string", "description": "create:temp 图记忆路径(留空则用 data_dir/residents//graph.db)"}, - "text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"}, + // ★ 父为子声明工具策略(用户裁定 2026-10-30)。 + // 描述必须点明“声明限制”而非“授予”:模型很容易把白名单误读成授权。 + "allowed_tools": map[string]interface{}{ + "type": "string", + "description": "create:**你为它声明**的工具白名单(逗号分隔,支持 cmd_* 这类通配)。留空=不额外限制(沿用全局策略)。只能收窄,不能扩大。", + }, + "deny_tools": map[string]interface{}{ + "type": "string", + "description": "create:**你为它声明**的追加禁止(逗号分隔,支持通配)。你能用但不给它用的工具写这里。与 allowed_tools 同时给时两者同时生效(deny 优先)。", + }, + "text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"}, "residual": map[string]interface{}{ "type": "string", "description": "reclaim/destroy:该子手头**未处理消息**的处置。" + diff --git a/internal/kernelmod/all/security_wiring_test.go b/internal/kernelmod/all/security_wiring_test.go index 540c9711..b5dd52e7 100644 --- a/internal/kernelmod/all/security_wiring_test.go +++ b/internal/kernelmod/all/security_wiring_test.go @@ -29,9 +29,11 @@ import ( "strings" "testing" + agentcore "github.com/JianFeeeee/HologramAgent/internal/agent/core" "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" "github.com/JianFeeeee/HologramAgent/internal/security" + "github.com/JianFeeeee/HologramAgent/pkg/types" ) // ───────────────── 判据 1:agent 模块**声明**了 security 依赖 ───────────────── @@ -143,58 +145,6 @@ func TestAgentAssemblyWiresSecurity(t *testing.T) { } } -// ───────────────── 判据 3:子 agent 继承闸门 ───────────────── - -// ★★★ 判据:驻留子 / rtchild 的 AgentConfig **必须**带 Security。 -// -// ## 为什么单列一条 -// -// 子 agent 各自 `New(AgentConfig{...})`,不走 buildAgent 那条路。 -// 只修主 agent 的话,两类子 agent 会**静默失去闸门**,而它们恰恰是 -// 最需要闸门的执行体: -// -// 驻留子:长期挂着、自己跑调度器、长时间无人审阅 -// rtchild:通话中一边听一边执行工具,没有人工复核窗口 -// -// 这类"父修好了、子还是坏的"在本仓已有先例:`StopRTChildren` 漏注销 -// 入站登记(与单数路径不对称)、resident 与 rtchild 各拼一次通道名。 -// 不对称是这套代码的主要缺陷形态。 -// -// ## 为什么用源码断言而不是行为断言 -// -// 创建真驻留子需要完整记忆栈 + temp 图库;创建 rtchild 需要真实 rt 通道。 -// 两者都会把本判据变成"前置不满足就 skip",而 skip 的判据等于没有。 -// 本仓已有同型先例:TestHiddenDependencyOnnxDeclared 就是读 textmem/mod.go -// 源码断言 FacONNX 声明 —— 因为"层号断言"在那里会假绿。 -// -// ★ 这条判据**能被变异咬住**:删掉任一 `Security: a.security` 行即红。 -func TestSubAgentsInheritSecurity(t *testing.T) { - cases := []struct { - file string - why string - }{ - {"../../agent/core/resident.go", - "驻留子是长期挂着、无人盯的执行体,自己跑调度器、接自己的输入 —— 无闸门等于策略对它完全无效"}, - {"../../agent/core/rtchild.go", - "rtchild 在通话中一边听用户说话一边执行工具,没有任何人工复核窗口"}, - } - for _, c := range cases { - src := readMod(t, c.file) - if !strings.Contains(src, "Security:") { - t.Errorf("★ %s 里没有 `Security:` 字段 —— %s。\n"+ - " 修法:该处 New(AgentConfig{...}) 加 `Security: a.security`(继承父的)。", - c.file, c.why) - continue - } - if !strings.Contains(src, "Security: a.security") && - !strings.Contains(src, "Security: a.security") { - t.Errorf("★ %s 里有 `Security:` 但它不是 `a.security` —— "+ - "子 agent 必须继承**父的那一个实例**,另建一个会让策略在两处分叉", - c.file) - } - } -} - // ───────────────── 判据 4:装配失败时响亮,而不是静默放行 ───────────────── // ★★★ 判据:`ks.Security == nil` 时 agent 模块必须**拒绝装配**。 @@ -267,3 +217,136 @@ func TestAgentAssemblyRefusesNilSecurity(t *testing.T) { t.Fatalf("失败信息必须点名缺失的设施(便于运维一眼定位),实际: %v", err) } } + +// ───────────────── 判据 4:父为子**声明**策略,而不是子继承父的权限 ───────────────── + +// ★★★ 判据:父创建子 agent 时,父声明的工具策略必须**真的限制住那个子**, +// 且**只影响那个子**(不是全局)。 +// +// ## 为什么这版取代了上一版(用户裁定 2026-10-30) +// +// 上一版的判据断言「子继承父的 `a.security`」—— 那只保证了**机制**到位, +// 却把权限模型写错成了「子 = 父的完整权限」。 +// +// 正确模型是 **父为子声明策略**: +// +// 父:能调全部工具(受全局策略约束) +// 子:父在创建时决定它能调什么 —— 内核持有并执行,子不能自己扩大 +// +// 这不是新机制:`security.CheckToolFor(Principal, tool)` 与 +// `PrincipalPolicy{AllowTools, DenyTools}` 早就在树里,只是**零生产调用方** +// (与工具策略闸、ScanContent 同型:机制完整、接线缺失)。 +// +// ## 本判据钉三件事 +// +// ① 父声明的 deny 真的拦住**子** +// ② 同一条策略**不影响父**(不是全局策略) +// ③ 子销毁后策略被撤销(同名重建不能继承上一代父的约束) +func TestParentDeclaresChildPolicy(t *testing.T) { + rt := security.NewRuntime(nil) + + // 父:parentID 为空 ⇒ 走 CheckTool(只受全局策略约束)。 + const forbidden = "resident_denied_tool" + parent := agentcore.New(agentcore.AgentConfig{ + ID: "main", + Provider: nil, + Security: rt, + }) + if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed { + t.Fatalf("父不应受限(它是内核自身,此工具不受任何全局策略限制): %s", d.Reason) + } + + // 父为子声明策略:允许 probe_ok_*,禁止 forbidden。 + childID := "policy-probe-child" + if err := parent.ProbeDeclareChildPolicy(childID, []string{"probe_ok_*"}, []string{forbidden}); err != nil { + t.Fatalf("父为子声明策略失败: %v", err) + } + + // ① 子被判住:子 parentID 非空 ⇒ 走 CheckToolFor(Principal{self}) + child := agentcore.New(agentcore.AgentConfig{ + ID: types.AgentID(childID), + ParentID: "main", + Provider: nil, + Security: rt, // 同一个 Runtime 实例(它只是机制,权限靠主体声明) + }) + if d := child.ProbeSecurityDecision(forbidden); d.Allowed { + t.Fatalf("★ 父已为子 %q 声明禁止 %q,子的判定却仍是 Allowed —— "+ + "主体策略没有被工具执行链读到(CheckToolFor 未接线)", childID, forbidden) + } + if d := child.ProbeSecurityDecision("other_tool"); d.Allowed { + t.Fatal("★ 父给了 probe_ok_* 白名单,other_tool 却仍被放行 —— 白名单未生效") + } + if d := child.ProbeSecurityDecision("probe_ok_1"); !d.Allowed { + t.Fatalf("白名单内的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", d.Reason) + } + + // ② 同一条策略不影响父(主体隔离)。 + if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed { + t.Fatal("★ 子策略泄漏成了全局策略 —— 父也被禁了。主体隔离失效") + } + + // ③ 撤销后子恢复(同名重建不能继承上一代父的约束)。 + parent.ProbeRevokeChildPolicy(childID) + if d := child.ProbeSecurityDecision(forbidden); !d.Allowed { + t.Fatal("★ 策略已撤销,子却仍被禁 —— 主体表以调用方给的名字为键," + + "不清理就会让同名重建的新一代继承上一代父的约束(与 rtchild 留档串线同型)") + } +} + +// ★★★ 判据:**销毁路径必须撤销策略**(两处对称:单数与复数)。 +// +// 这条单列,因为本仓反复吃亏在"单数路径改了、复数路径漏了": +// `StopRTChild` 注销入站登记而 `StopRTChildren` 漏了,就是这么来的。 +// +// 用源码断言:真销毁需要完整记忆栈/rt 通道,而"前置不满足就 skip" +// 的判据等于没有(本仓已有同型先例:TestHiddenDependencyOnnxDeclared)。 +func TestChildTeardownRevokesPolicy(t *testing.T) { + cases := []struct{ file, fn, why string }{ + {"../../agent/core/resident.go", "func (a *Agent) teardownResidentWith(", + "驻留子销毁必须撤销父为它声明的策略"}, + {"../../agent/core/rtchild.go", "func (a *Agent) StopRTChild(", + "rtchild 单数停止必须撤销策略"}, + {"../../agent/core/rtchild.go", "func (a *Agent) StopRTChildren(", + "rtchild 批量停止必须与单数路径对称地撤销策略"}, + } + for _, c := range cases { + src := readMod(t, c.file) + i := strings.Index(src, c.fn) + if i < 0 { + t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.fn) + } + body := src[i:] + if end := strings.Index(body, "\n}\n"); end > 0 { + body = body[:end] + } + if !strings.Contains(body, "revokeChildPolicy") { + t.Errorf("★ %s 里没有 revokeChildPolicy —— %s。\n"+ + " 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n"+ + " 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。", + c.fn, c.why) + } + } +} + +// ★★★ 判据:创建路径必须在**子开始执行前**声明策略。 +// +// 顺序错(比如在 go carrier.rtLoop 之后声明)会留下一个"无约束执行"窗口: +// 子一接到输入就会跑工具,那时策略可能还没进内核。 +func TestPolicyDeclaredBeforeChildStarts(t *testing.T) { + cases := []struct{ file, declAnchor, startAnchor, name string }{ + {"../../agent/core/resident.go", "declareChildPolicy(opts.ID", "child.Start()", "驻留子"}, + {"../../agent/core/rtchild.go", "declareChildPolicy(opts.ID", "go carrier.rtLoop(rc)", "rtchild"}, + } + for _, c := range cases { + src := readMod(t, c.file) + di := strings.Index(src, c.declAnchor) + si := strings.Index(src, c.startAnchor) + if di < 0 || si < 0 { + t.Fatalf("%s: 找不到锚点(decl=%v start=%v)", c.file, di >= 0, si >= 0) + } + if di > si { + t.Errorf("★ %s:策略声明在 `%s` **之后** —— 存在一个「子已开始执行、策略尚未生效」的窗口", + c.name, c.startAnchor) + } + } +} diff --git a/internal/security/principal.go b/internal/security/principal.go index 5237e809..338307f5 100644 --- a/internal/security/principal.go +++ b/internal/security/principal.go @@ -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 里自己拦"的入口:插件只声明策略, diff --git a/internal/security/principal_test.go b/internal/security/principal_test.go new file mode 100644 index 00000000..5b9a55ec --- /dev/null +++ b/internal/security/principal_test.go @@ -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 策略应仍然生效") + } +}