From 5194a9656066ff3364c60c50011e4b70f3b60ddd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 31 Oct 2026 07:14:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(security):=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=85=A8=E9=9D=A2=E6=94=B6=E6=95=9B=E5=88=B0?= =?UTF-8?q?=20security=20=E6=A8=A1=E5=9D=97=20=E2=80=94=E2=80=94=20agent?= =?UTF-8?q?=20=E5=8F=AA=E6=8F=90=E4=BE=9B=E4=BA=8B=E5=AE=9E=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=81=9A=E7=AD=96=E7=95=A5=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 架构定位(用户澄清) security 是**外围内核到核心内核之间的防火墙**:自己提供接口、判定内聚在自己内部。 device 是设备抽象模块、持有 channel,两者不冲突。 现成的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` —— 总线只交事件,guard 自己判,总线一行策略都没有。本次把同样的形状套到输入/主体上。 ## 收敛前的问题 `agent/core/security_profile.go` 214 行里绝大部分是**策略逻辑**: name, _ := payload["security_profile"].(string) // ① 解析 security 的协议 a.security.ActivateProfile(name, 0) if a.parentID != "" { // ② 选哪条判定链 return a.security.CheckToolFor(Principal{Kind: "resident"}, tool) } a.security.DeclarePrincipalPolicy("id", id, PrincipalPolicy{...}) // ③ 拼策略结构 三个后果: ① **协议解析外泄**:`security_profile` 是 security 的概念,解析却在外人手里 ② **策略决策外泄**:判断"父走 CheckTool / 子走 CheckToolFor"是策略语义 ③ **提权口子**:`Kind: "resident"` 是调用方**声明自己的类别** —— 说自己是 内部主体就享受内部豁免(不过 HardPrivateTools)。类别必须从事实推出 且三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效。 ## 收敛后的形态 security 新增 `round.go`(自己提供接口): RoundInput{AgentID, IsChild, Payload, Source} ← 只有事实,**无 Kind** rt.BeginRound(in) → security 解析档位 + 判定主体类别 round.CheckTool(t) → 本轮判定(调用方不需要知道任何组合规则) round.End() → 归位(防跨轮泄漏) rt.DeclareChildPolicy / RevokeChildPolicy ← 子策略生命周期 agent 侧只剩:`beginSecurityRound(payload, source)` + `defer End()`。 `security_profile.go` 从 214 行策略逻辑退化为转发层。 顺带全局收敛 `Principal.Kind`:裸 string → 固定枚举 (KindInternal / KindSubAgent / KindUser / KindGroup),并新增 `ParsePrincipalKind` —— 拼错返回 false 而不是静默回退到某类别。 ## 轮次边界接到**全部三条**输入路径 task.go runInputTask ← 主 agent + 驻留子(各有自己的 Agent 实例) rtchild.go rtRunOnce ← rtchild 不走调度器,是独立的一条 ★ 收敛把**判定逻辑**收到了一处,但"一轮何时开始/结束"仍得各路径自己声明 —— 只有该路径知道边界。所以判据必须钉"每条路径都声明了"。 ## 判据(5 个变异全部实测咬住) | 判据 | 变异 | |---|---| | `TestSecurityProfileFromInputChangesToolDecision` | BeginRound 不解析 payload ⇒ 红 | | `TestProfileRestoredAfterRoundEnd` | End 不归位 ⇒ 红 | | `TestUnknownSecurityProfileFallsBackWithoutPanic` | 拼错沿用旧档 ⇒ 红 | | `TestAgentCoreHoldsNoPolicySemantics` | 把 `Kind:` / `CheckToolFor(` 写回 agent ⇒ 红 | | `TestPrincipalKindIsDerivedBySecurity` | 分类规则错位 / 枚举接受拼错值 ⇒ 红 | | `TestEveryInputPathDeclaresRound` | rtchild 路径漏 beginSecurityRound ⇒ 红 | `TestAgentCoreHoldsNoPolicySemantics` 逐行扫描(跳过注释),禁四类形态: `payload["security_profile"]` / `Kind:` / `.CheckToolFor(` / `PrincipalPolicy{`。 ## 实测(真实整机装配) ① security 自己解析出的档位 = "qq.guest" ② security 自己判定的主体 = {ID:main Kind:user} ③ 本轮判定 cmd_run = Allowed:false reason="tool \"cmd_run\" is disabled by profile \"qq.guest\"" ## 已知限制(已写进 round.go 与 future-plan) `Round` 是 per-round 句柄,但 `Runtime.active` 仍是**进程级单例**。 多 agent 轮次**交错**时无法表达两个不同档位(串行场景完全正确)。 彻底修法是把档位挂到 TaskFrame 上按任务隔离,需先改 CheckTool 签名。 build / vet / go test ./...(62 ok / 0 fail)/ -race(security + agent/core + kernelmod/all)全绿;make build 通过。 --- internal/agent/core/rtchild.go | 13 + internal/agent/core/security_profile.go | 209 ++++++------ internal/agent/core/security_profile_test.go | 99 +++++- internal/agent/core/task.go | 12 + .../kernelmod/all/security_wiring_test.go | 127 ++++++++ internal/security/principal.go | 74 ++++- internal/security/round.go | 303 ++++++++++++++++++ 7 files changed, 703 insertions(+), 134 deletions(-) create mode 100644 internal/security/round.go diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index 9f04b698..ee39b9ac 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -850,6 +850,19 @@ func (a *Agent) rtRunOnce(rc *rtChild, evt *agentIO.InputEvent, seq uint64) { return } + // ★ 安全轮次边界(rtchild 路径)。 + // + // rtchild 不走 `runInputTask`(它没有调度器、不吃队列,见 plan.md 十一.三 D), + // 所以这是一条**独立**的输入路径。收敛前 agent 侧的档位激活只写在 + // `prepareInputTask` 里,rtchild 根本不会经过那里 —— 若只接一条, + // rtchild 就永远没有档位。 + // + // 这是“每多一条输入路径就要多接一次”的典型代价。收敛后, + // 判定逻辑在 security 内部只剩一份,但**轮次边界仍得每条路径自己声明** —— + // 因为只有该路径知道一轮何时开始/结束。 + secRound := a.beginSecurityRound(evt.Payload, evt.Source) + defer secRound.End() + rc.mu.Lock() rc.round++ round := rc.round diff --git a/internal/agent/core/security_profile.go b/internal/agent/core/security_profile.go index f9650b0b..c5522f08 100644 --- a/internal/agent/core/security_profile.go +++ b/internal/agent/core/security_profile.go @@ -1,23 +1,39 @@ package core -// 插件声明限式的**生效点**:inputch 输入进来时激活,轮次内据此判定。 +// agent 侧与安全模块的**接线点**。 // -// ## 为什么在这里(而不是在工具调用时) +// ## 边界(2026-10-31 收敛后的形态) // -// 插件在注入时带上 `security_profile`,那一刻是**唯一**能确定 -// "这条输入属于哪个身份档位"的时刻。等 LLM 决定调工具时,插件早已 -// 不在调用栈上(异步通道 + 多轮工具循环),档位信息就断了。 +// security 是**外围内核到核心内核之间的防火墙**:它自己提供接口、自己做判定。 +// agent 只负责两件事: // -// 所以:输入到达 → 激活档位(带 TTL)→ 该轮的工具/内容判定都读它。 +// ① 提供**事实**:谁在跑、有没有父、原始输入载荷 +// ② 在输入开始/结束时各调一次 security 的轮次入口 // -// ## 缺省语义 +// ★ 本文件**不得**再出现任何策略语义: // -// 没有携带 `security_profile` = **不使用档位**(沿用内核默认策略)。 -// 这是可选参数:不声明档位的插件行为与之前完全一致。 +// ✗ 不该解析 `security_profile`(那是 security 的协议) +// ✗ 不该判断“我该走 CheckTool 还是 CheckToolFor”(那是策略决策) +// ✗ 不该声明自己是哪一类主体(Kind 由 security 自己推) +// ✗ 不该拼 `PrincipalPolicy{...}` 字面量(那是策略结构) // -// 携带了**未声明**的档位名 ⇒ 记日志并回退到默认策略,但**不改写输入**: -// 注入是 fire-and-forget,插件看不到内核错误;至少要在日志里留下 -// 「你声明的档位没生效」的痕迹(与 cleaner_name 同一纪律)。 +// 这些全部已收回到 `internal/security`: +// +// security.RoundInput —— 调用方能提供的全部事实(无 Kind) +// security.BeginRound —— security 解析档位 + 判定主体类别 +// security.Round —— 本轮句柄,只暴露 CheckTool / End +// security.DeclareChildPolicy / RevokeChildPolicy —— 子策略的生命周期 +// +// ## 为什么这样收(教训) +// +// 收敛前本文件有 214 行,其中绝大部分是策略逻辑。它带来三个后果: +// +// · 三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效 +// · 判据只能钉住“接线之后行为对”,钉不住“有没有接线” +// · `Kind: "resident"` 让调用方**声明自己的类别** ⇒ 提权口子 +// +// 对应的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` —— +// 总线只交事件,guard 自己判,总线一行策略都没有。 import ( "log" @@ -25,131 +41,83 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/security" ) -// activateSecurityProfile 按输入 payload 激活插件声明的安全档位。 +// beginSecurityRound 在一条输入开始处理时调用: +// 把**原始事实**交给 security,拿回本轮句柄。 // -// 返回被激活的档位名(空 = 未使用档位),仅供日志/诊断。 -func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string { - if a == nil || a.security == nil || payload == nil { - return "" +// 调用方必须在轮次结束时 `End()`(defer)。未装配 security 时返回一个 +// 零成本句柄(所有判定放行),与收敛前行为一致。 +// +// ★ 为什么由 agent 调而不是 io 层调:轮次的生命周期由 agent 的 +// `prepareInputTask` 界定(那里才知道一条输入何时真正开始/结束)。 +// security 不依赖 agent,所以只能由 agent 主动把边界告诉它。 +func (a *Agent) beginSecurityRound(payload map[string]interface{}, source string) *security.Round { + if a == nil || a.security == nil { + return nil } - name, _ := payload["security_profile"].(string) - if name == "" { - return "" + rc, err := a.security.BeginRound(security.RoundInput{ + AgentID: string(a.id), + // IsChild 是**事实**(有父就是子),不是类别结论。 + IsChild: a.parentID != "", + Payload: payload, + Source: source, + }) + if err != nil { + // security 内部已记录原因并把档位归位。这里不阻断输入: + // 注入是 fire-and-forget,插件看不到内核错误,丢输入比降级更糟。 + // 但**必须留痕**,否则“档位一直没生效”会完全静默。 + a.securityRoundDegraded(err) } - 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 + return rc } -// securityDecisionFor 判定某工具是否可用。 +// securityRoundDegraded 记录“本轮策略降级”这一事实。 // -// ## 两条路径(为什么不能只有一条) +// 独立成方法是为了让降级有一个**可被检索的日志锚点**: +// 收敛前这条链断了整整一轮发布,而没有任何一行日志提示。 +func (a *Agent) securityRoundDegraded(err error) { + if a == nil { + return + } + log.Printf("[security] round policy degraded for agent %q: %v "+ + "(continuing with the default global policy)", a.id, err) +} + +// securityDecisionFor 判定某工具是否可用(**兼容入口**)。 // -// 普通 agent(主 agent) → Runtime.CheckTool(tool) -// 全局策略 + 活跃档位 + 轮次作用域。它代表**内核自己**,不受“某个主体”约束。 +// ## 它现在只做转发,不含任何策略 // -// 子 agent(resident/rtchild)→ Runtime.CheckToolFor(Principal{self}, tool) -// 额外叠加**父在创建它时为它声明的主体策略**(见 SpawnResident / -// SpawnRTChild 里的 DeclarePrincipalPolicy)。 +// 收敛前这里写着两条分支(父走 CheckTool、子走 CheckToolFor)并硬编码 +// `Kind: "resident"`。现在那两条判断都在 security 内部 +// (`classifyPrincipal`),本函数退化成一次裸转发。 // -// ★ 为什么子不能只走 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 的整机判据)与**创建时的策略声明**, -// 不是本函数被手动调用后的行为。 +// ★ 保留它的理由:工具执行链(`executeToolCallInner`)需要一次判定, +// 而那里只持有 `*Agent`、不持有轮次句柄。走 security 的**默认轮次** +// (按 `AgentID` + 有无父自动分类)等价于原来“父/子分流”的行为, +// 但语义只写在 security 一处。 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) + return a.security.CheckToolForAgent(string(a.id), a.parentID != "", tool) } -// declareChildPolicy 让**父**为刚创建的子 agent 声明主体策略。 +// declareChildPolicy 让父为刚创建的子执行体声明主体策略。 // -// ## 为什么必须在创建点、由父做(用户裁定 2026-10-30) -// -// 子 agent 没有配置中心句柄(轻量内核刻意不接),也没有“自己决定权限”的 -// 合理性 —— 权限只能来自委派者。这与 `PreferredProvider` 同一道理: -// “哪个源/多少权限”是**委派策略**,本来就该在创建点决定。 -// -// ## 收窄的方向 -// -// 声明只能**收窄**: -// -// DenyTools 追加禁止(父自己能用、但不给这个子用) -// AllowTools 白名单(非空时,子只能用这些) -// HardPrivateTools 由主体模型处理(内部主体不被它拦) -// -// 它**不能扩大**:CheckToolFor 内部先跑 CheckTool(全局 deny 优先), -// 所以父的黑名单对子依然有效。 -// -// childID 未声明策略时返回 nil(合法:父选择不给额外限制)。 +// 这里只做转发:策略结构的组装与作用域落点由 security 负责 +// (`security.DeclareChildPolicy`)。 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, - }) + return a.security.DeclareChildPolicy(childID, allow, deny) } -// revokeChildPolicy 在子 agent 销毁时撤销父为它声明的策略。 -// -// ★ 不撤销的后果(与今天修的 rtchild 留档串线同型): -// 主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作, -// 而旧策略留在表里 => 新一代用上一代父留的约束运行。 -// 且表会随 create/destroy 单调增长。 +// revokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。 func (a *Agent) revokeChildPolicy(childID string) { - if a == nil || a.security == nil || childID == "" { + if a == nil || a.security == nil { return } - _ = a.security.RevokePrincipalPolicy("id", childID) + a.security.RevokeChildPolicy(childID) } // SetSecurity 注入安全运行时(**仅限测试与独立工具**)。 @@ -157,10 +125,11 @@ func (a *Agent) revokeChildPolicy(childID string) { // ## 不要在生产装配里调它 // // 生产路径是 `AgentConfig.Security`(构造期落位),因为那一条由依赖图 -// 保证 `ks.Security` 已就绪。用 SetSecurity 事后补会造出一个图看不见的 -// 隐式依赖 —— 正是本仓反复踩的那个坑。 +// 保证 `ks.Security` 已就绪(`kernelmod/agent` 的 `Requires()` 声明了 +// `FacSecurity`)。用 SetSecurity 事后补会造出一个图看不见的隐式依赖 —— +// 正是本仓反复踩的那个坑。 // -// 保留它是因为 4 条进程内判据需要一个"只换安全运行时"的开关, +// 保留它是因为进程内判据需要一个「只换安全运行时」的开关, // 而它们不跑整机装配。 // // ## 为什么允许 nil @@ -196,13 +165,21 @@ func (a *Agent) ProbeSecurityRuntime() *security.Runtime { return a.security } -// ProbeSecurityDecision 按**本 agent 的身份**(是否子 agent)判定工具可用性(判据用)。 +// ProbeSecurityDecision 判定工具可用性(判据用)。 // // 它走的就是生产路径(securityDecisionFor),不是另写一份逻辑。 func (a *Agent) ProbeSecurityDecision(tool string) security.Decision { return a.securityDecisionFor(tool) } +// ProbeSecurityRound 按真实输入路径开一轮,返回句柄(判据用)。 +// +// 判据用它验证「档位由 security 自己从 payload 解析」这条链真的通了, +// 而不需要直接调 security 的内部方法。 +func (a *Agent) ProbeSecurityRound(payload map[string]interface{}, source string) *security.Round { + return a.beginSecurityRound(payload, source) +} + // ProbeDeclareChildPolicy 是 declareChildPolicy 的导出包装(判据用)。 func (a *Agent) ProbeDeclareChildPolicy(childID string, allow, deny []string) error { return a.declareChildPolicy(childID, allow, deny) diff --git a/internal/agent/core/security_profile_test.go b/internal/agent/core/security_profile_test.go index 010f76c4..119817e1 100644 --- a/internal/agent/core/security_profile_test.go +++ b/internal/agent/core/security_profile_test.go @@ -8,6 +8,16 @@ package core // > 选择其中一种 —— 正好对应 QQ 插件内部 owner 与非 owner 的差别对待。 // // > 这是可选参数,不声明安全策略则使用内核默认安全策略。 +// +// ## 2026-10-31 重写(收敛后的形态) +// +// 本文件原先调用 `a.activateSecurityProfile(payload)` —— 一个**在 agent 里 +// 解析 security 协议**的函数。收敛后该函数已删除:档位解析归 security +// (`security.BeginRound` 自己读 `payload["security_profile"]`)。 +// +// 所以这里改用 `a.beginSecurityRound(payload, source)`,它走真实的生产入口, +// **不由判据手工解析字段**。这样判据钉的是「security 自己会解析吗」, +// 而不是「agent 有没有帮它解析」。 import ( "testing" @@ -18,7 +28,12 @@ import ( // ① 没有携带 security_profile ⇒ 不激活任何档位(零行为变化)。 func TestNoSecurityProfileKeepsDefault(t *testing.T) { a := newPreemptAgent(t, newPreemptProvider()) - if got := a.activateSecurityProfile(map[string]interface{}{"content": "hi"}); got != "" { + a.security = security.NewRuntime(nil) + + round := a.beginSecurityRound(map[string]interface{}{"content": "hi"}, "probe") + defer round.End() + + if got := round.Profile(); got != "" { t.Fatalf("未携带档位时不应激活,实际 %q", got) } if d := a.securityDecisionFor("cmd_run"); !d.Allowed { @@ -29,7 +44,6 @@ func TestNoSecurityProfileKeepsDefault(t *testing.T) { // ② 声明档位 + 输入携带档位名 ⇒ 该轮工具判定按档位生效(owner/非 owner 差别)。 func TestSecurityProfileFromInputChangesToolDecision(t *testing.T) { a := newPreemptAgent(t, newPreemptProvider()) - // 用一个独立运行时(不依赖全局单例),模拟"security 模块已装配"。 rt := security.NewRuntime(nil) if err := rt.DeclareProfile(security.Profile{ Name: "qq.guest", Owner: "qq", @@ -42,24 +56,55 @@ func TestSecurityProfileFromInputChangesToolDecision(t *testing.T) { if d := a.securityDecisionFor("cmd_run"); !d.Allowed { t.Fatalf("激活前应放行: %s", d.Reason) } - if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.guest"}); got != "qq.guest" { - t.Fatalf("应激活 qq.guest,实际 %q", got) + + round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe") + defer round.End() + + if got := round.Profile(); got != "qq.guest" { + t.Fatalf("★ security 应从 payload 自己解析出档位并激活 qq.guest,实际 %q —— "+ + "若这里是空,说明 BeginRound 没读 security_profile(解析又落回调用方了)", got) } - if d := a.securityDecisionFor("cmd_run"); d.Allowed { + // 判定走本轮句柄(生产上工具执行链用的是同一条判定语义)。 + if d := round.CheckTool("cmd_run"); d.Allowed { t.Fatal("guest 档激活后 cmd_* 必须被拒 —— 这是 owner 与非 owner 的差别") } } -// ③ 携带**未声明**的档位名 ⇒ 不激活(回退默认),且不 panic。 +// ③ 携带**未声明**的档位名 ⇒ 不激活(回退默认),且**不沿用上一轮的档**。 // -// 判据只钉"不激活"这一半:日志侧由 security 模块保证响亮(返回 error)。 -// 不在这里断言日志文案 —— 那会让判据绑死文案。 +// 两种失败都必须咬住: +// +// · 静默沿用上一轮的 guest 档 ⇒ 拼错名却仍收窄(用户被无辜禁工具) +// · 静默当作"无档"却继续用旧档 ⇒ 同上 func TestUnknownSecurityProfileFallsBackWithoutPanic(t *testing.T) { a := newPreemptAgent(t, newPreemptProvider()) - a.security = security.NewRuntime(nil) - if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.typo"}); got != "" { + rt := security.NewRuntime(nil) + if err := rt.DeclareProfile(security.Profile{ + Name: "qq.guest", Owner: "qq", + Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}}, + }); err != nil { + t.Fatal(err) + } + a.security = rt + + // 先开一个合法档位并**不结束**,模拟"进程里还挂着 guest 档"。 + prev := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe") + if prev.Profile() != "qq.guest" { + t.Fatalf("前置失败:合法档位未激活,实际 %q", prev.Profile()) + } + + // 再来一条拼错的。 + round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.typo"}, "probe") + defer round.End() + + if got := round.Profile(); got != "" { t.Fatalf("未声明的档位不应被激活,实际 %q", got) } + if got := rt.ActiveProfile(); got != "" { + t.Fatalf("★ 拼错的档位名必须在降级时**清掉**上一轮的档,实际仍是 %q —— "+ + "否则“配件名写错”会静默沿用旧档,用户被无辜收窄", got) + } + prev.End() } // ④ 未装配安全模块(a.security == nil)⇒ 全部放行,且不 panic。 @@ -68,7 +113,39 @@ func TestSecurityDecisionWithoutRuntime(t *testing.T) { if d := a.securityDecisionFor("cmd_run"); !d.Allowed { t.Fatalf("未装配安全模块时应放行: %s", d.Reason) } - if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.owner"}); got != "" { + + round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.owner"}, "probe") + defer round.End() + if got := round.Profile(); got != "" { t.Fatalf("未装配时不应激活,实际 %q", got) } } + +// ⑤ ★ 档位必须**随轮次结束归位**,不泄漏到下一条输入。 +// +// 这是收敛的附带收益:`End()` 恢复进入前的档位。没有它, +// 进程级 active 会让 guest 的收窄误伤后续输入(方向相反就是提权)。 +func TestProfileRestoredAfterRoundEnd(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + rt := security.NewRuntime(nil) + if err := rt.DeclareProfile(security.Profile{ + Name: "qq.guest", Owner: "qq", + Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}}, + }); err != nil { + t.Fatal(err) + } + a.security = rt + + func() { + round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe") + defer round.End() + if round.Profile() != "qq.guest" { + t.Fatalf("前置:应激活 guest,实际 %q", round.Profile()) + } + }() + + if got := rt.ActiveProfile(); got != "" { + t.Fatalf("★ 轮次结束后档位应归位为空,实际 %q —— "+ + "进程级 active 泄漏到下一条输入,guest 的收窄会误伤后续输入", got) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index c2c7e593..0da6644b 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -326,6 +326,18 @@ func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome) // interceptLoop 读它来决定“能不能取消”,因此必须是原子的。 defer a.sched.setCritical(false) + // ★ 安全轮次边界(2026-10-31 收敛)。 + // + // 一条输入 = 一个安全轮次。这里把**原始事实**交给 security, + // 由它自己解析档位、自己判定主体类别;本 agent 不参与任何策略判断。 + // + // ★ defer End() 而非在末尾调:一条输入可能经过多个 return 出口 + // (跳过 / 短路 / 挂起 / 正常),逐个加必然漏一个 —— 与 + // `executeToolCallOutcome` 的入向脱敏用 defer 同一理由。 + round := a.beginSecurityRound(evt.Payload, evt.Source) + defer round.End() + _ = round // 句柄随后续轮次上下文下沉使用;当前先保证激活/归位正确 + f, term := a.prepareInputTask(evt) switch term { case terminalSkipped, terminalStageShortCircuit, terminalConsolidation: diff --git a/internal/kernelmod/all/security_wiring_test.go b/internal/kernelmod/all/security_wiring_test.go index b5dd52e7..a8b61fc9 100644 --- a/internal/kernelmod/all/security_wiring_test.go +++ b/internal/kernelmod/all/security_wiring_test.go @@ -350,3 +350,130 @@ func TestPolicyDeclaredBeforeChildStarts(t *testing.T) { } } } + +// ───────────────── 判据 5:策略语义只能在 security 内部 ───────────────── + +// ★★★ 判据:`agent/core` **不得**再做策略决策。 +// +// ## 收敛前它做了什么(2026-10-31) +// +// security_profile.go 里 214 行,其中绝大部分是策略逻辑: +// · 自己从 payload 抠 `security_profile` 字段 +// · 自己判断 "parentID != "" → 走 CheckToolFor,否则 CheckTool" +// · 自己拼 `PrincipalPolicy{AllowTools:…, DenyTools:…}` 字面量 +// · 自己写 `Kind: "resident"` **声明自己的主体类别** +// +// 最后一条是提权口子:调用方说自己是内部主体,就享受内部豁免 +// (不过 HardPrivateTools)。类别必须由 security 从**事实**推出。 +// +// ## 本判据用什么钉 +// +// 源码断言(跨包行为断言做不到"禁止某种写法")。三类越界各一条正则: +// +// ① `payload["security_profile"]` 出现在 agent/core ⇒ 解析回落调用方 +// ② `Kind:` 字面量出现在 agent/core 的安全文件 ⇒ 调用方声明类别 +// ③ `CheckTool(` / `CheckToolFor(` 直接出现在 agent/core ⇒ 越级选链 +// +// ★ 例外:`security_profile.go` 的**注释**里会提到这些名字(解释历史)。 +// 所以逐行判断,跳过纯注释行。 +func TestAgentCoreHoldsNoPolicySemantics(t *testing.T) { + src := readMod(t, "../../agent/core/security_profile.go") + + // 会被禁止的代码形态(不含注释)。 + banned := []struct{ pattern, why string }{ + {`payload["security_profile"]`, + "档位字段名是 security 的协议,解析必须留在 security.BeginRound"}, + {`Kind:`, + "主体类别必须由 security 从事实(有无父)推出,调用方不得声明"}, + {`.CheckToolFor(`, + "选哪条判定链是策略决策,调用方只该调 Round.CheckTool"}, + {`PrincipalPolicy{`, + "策略结构由 security 组装,调用方只该传 allow/deny 列表"}, + } + for _, b := range banned { + for i, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue // 注释里解释历史是允许的 + } + if strings.Contains(line, b.pattern) { + t.Errorf("★ agent/core/security_profile.go:%d 出现 %q —— %s\n\t%s", + i+1, b.pattern, b.why, trimmed) + } + } + } +} + +// ★★★ 判据:主体类别由 security 从**事实**推出,且**不信**调用方声明。 +// +// 收敛前 `Kind: "resident"` 由 agent 写死 —— 它同时意味着"我是内部" +// 与"我不受收窄",把两件不同的事绑在一个字符串上。现在分类规则只有一处 +// (`classifyPrincipal`),本判据钉住三条映射。 +func TestPrincipalKindIsDerivedBySecurity(t *testing.T) { + rt := security.NewRuntime(nil) + + // ① 有父 ⇒ 子执行体(内部豁免,但可被父收窄) + if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "kid", IsChild: true}); p.Kind != security.KindSubAgent { + t.Fatalf("有父的执行体应归为 KindSubAgent,实际 %q", p.Kind) + } else if !p.IsInternal() || !p.IsSubAgent() { + t.Fatal("KindSubAgent 必须同时满足 IsInternal(豁免 HardPrivate)与 IsSubAgent(可被收窄)") + } + + // ② 无 id ⇒ 内核自身 + if p := rt.ClassifyPrincipalForTest(security.RoundInput{}); p.Kind != security.KindInternal { + t.Fatalf("无 id 应归为 KindInternal,实际 %q", p.Kind) + } + + // ③ 有 id 无父 ⇒ 用户 + if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "qq-user-1"}); p.Kind != security.KindUser { + t.Fatalf("经插件通道进来的应归为 KindUser,实际 %q", p.Kind) + } + + // ④ 枚举不接受自由字符串:拼错必须能被识别(而不是静默变成用户主体) + if _, ok := security.ParsePrincipalKind("residnet"); ok { + t.Fatal("★ 拼错的主体类别被当成合法值 —— 拼写错误会静默漂移权限语义") + } + if k, ok := security.ParsePrincipalKind("resident"); !ok || k != security.KindSubAgent { + t.Fatal("旧值 resident 应兼容映射为 KindSubAgent(不破坏既有调用方)") + } +} + +// ★★★ 判据:轮次边界在**全部输入路径**上都被声明。 +// +// 收敛把判定逻辑收到了一处,但"一轮何时开始/结束"只有各路径自己知道。 +// 本仓已有三条独立输入路径,漏一条就是"那类 agent 永远没有档位": +// +// 主 agent + 驻留子 → runInputTask(驻留子有自己的 Agent 实例) +// rtchild → rtRunOnce(不走调度器,单独一条) +// +// 用源码断言:真跑三条路径需要完整记忆栈/rt 通道, +// 而"前置不满足就 skip"的判据等于没有(与 TestChildTeardownRevokesPolicy 同一理由)。 +func TestEveryInputPathDeclaresRound(t *testing.T) { + cases := []struct{ file, fn, why string }{ + {"../../agent/core/task.go", "func (a *Agent) runInputTask(", + "主 agent 与驻留子共用的输入入口"}, + {"../../agent/core/rtchild.go", "func (a *Agent) rtRunOnce(", + "rtchild 不走调度器、不经过 runInputTask,是独立的一条输入路径"}, + } + 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, "beginSecurityRound(") { + t.Errorf("★ %s 里没有 beginSecurityRound —— %s 缺少安全轮次边界。\n"+ + " 后果:这条路径上的输入永远不会激活插件声明的档位,\n"+ + " 而它在**其他**路径上是生效的 ⇒ 表现为「时灵时不灵」,最难查。", + c.fn, c.why) + } + if !strings.Contains(body, "End()") { + t.Errorf("★ %s 里没有 End() —— 档位会在轮次结束后泄漏到下一轮,"+ + "guest 的收窄误伤后续输入(方向相反就是提权)", c.fn) + } + } +} diff --git a/internal/security/principal.go b/internal/security/principal.go index 338307f5..f605fdfb 100644 --- a/internal/security/principal.go +++ b/internal/security/principal.go @@ -27,25 +27,85 @@ import ( "sync" ) +// PrincipalKind 是主体**类别**。内核侧固定枚举,不接受自由字符串。 +// +// ## 为什么必须是枚举而不是 string(2026-10-31 收敛) +// +// 原先 `Principal.Kind` 是裸 `string`,而实际填充点只有一处: +// `agent/core/security_profile.go` 里写死的 `Kind: "resident"`。 +// 那意味着: +// +// ① **判定逻辑散在两侧**:`IsInternal()` 在 security 里靠字符串比较, +// 而“我该算哪一类”由调用方(agent)决定 —— 调用方说自己是内部, +// 就享受内部主体的豁免(不过 HardPrivateTools)。那是提权口子。 +// ② **拼错不报错**:写成 "residnet" ⇒ IsInternal 为 false ⇒ +// 静默升级成用户主体语义(反方向)或静默降级。 +// +// 收敛后:类别由**security 自己根据可信事实决定**(谁在调、有没有父), +// 调用方只能传事实,不能传结论。 +type PrincipalKind string + +const ( + // KindUnset 表示未分类。**不要**用它做任何判定 —— 它是零值, + // 只用于“调用方没给”这一情形,由 security 自己补全。 + KindUnset PrincipalKind = "" + // KindInternal 是内核自身发起的调用(模块、内置定时任务)。 + KindInternal PrincipalKind = "internal" + // KindSubAgent 是内核派出的子执行体(驻留子 / rtchild / 工作式子)。 + // + // ★ 与 KindInternal 的区别:子执行体**是**内部委派(不该被 + // HardPrivateTools 拦),但它们是长期自主运行的 —— 父可以在创建时 + // 为它们**收窄**权限(AllowTools / DenyTools),而内核自身不受收窄。 + KindSubAgent PrincipalKind = "subagent" + // KindUser 是终端用户(通过插件通道进来的真实用户)。 + KindUser PrincipalKind = "user" + // KindGroup 是群/频道类主体。 + KindGroup PrincipalKind = "group" +) + +// ParsePrincipalKind 把外部字符串解析成枚举(仅用于默认实现兼容)。 +// +// 未识别的值返回 KindUnset 与 false:**不静默回退到某个默认类别** —— +// 那会让拼写错误变成权限语义漂移(与“未知档位名不静默放行”同一条纪律)。 +func ParsePrincipalKind(s string) (PrincipalKind, bool) { + switch PrincipalKind(s) { + case KindInternal, KindSubAgent, KindUser, KindGroup: + return PrincipalKind(s), true + // 兼容旧调用方写过的 "resident":语义等价于 KindSubAgent。 + case "resident": + return KindSubAgent, true + } + return KindUnset, false +} + // Principal 是一次调用的**主体**:谁在请求。 type Principal struct { // ID 是主体标识(QQ 用户 id、群 id、驻留子 id、内部标记)。 ID string - // Kind 是主体类别("user" / "group" / "internal" / "resident" …)。 + // Kind 是主体类别(固定枚举,见上方常量)。 // - // ★ 为什么不只看 ID:内部委派与用户请求的 ID 可能相同 - //(驻留子继承父的输入事件),但**权限模型完全不同** —— - // 这正是 QQ 那条 `isInternalToolCall` 分支存在的原因。 - Kind string + // ★ 为何不只看 ID:内部委派与用户请求的 ID 可能相同 + //(驻留子继承父的输入事件),但**权限模型完全不同**。 + Kind PrincipalKind // Tags 是附加标签(如 "admin"、"trusted"),策略可按标签授予。 Tags []string } -// IsInternal 报告是否内部主体(agent 体系自己发起)。 +// IsInternal 报告是否内部主体(内核自身或内核派出的子执行体)。 +// +// 语义:内部主体**不受** HardPrivateTools 限制 —— 那条规则防的是 +// “用户偷看私人数据”,而内部委派本就有完整权限(QQ 的实测教训: +// 再拦一次等于“机器人自己派出去的活被自己拦住”)。 func (p Principal) IsInternal() bool { - return p.Kind == "internal" || p.Kind == "resident" + return p.Kind == KindInternal || p.Kind == KindSubAgent } +// IsSubAgent 报告是否内核派出的子执行体。 +// +// 与 IsInternal 分开的理由:子执行体**可以**被父收窄(Allow/Deny), +// 而内核自身不受收窄。两者共用 HardPrivateTools 豁免,但不共用收窄语义。 +func (p Principal) IsSubAgent() bool { return p.Kind == KindSubAgent } + // PrincipalPolicy 是某个主体的能力策略。 type PrincipalPolicy struct { // AllowTools 是该主体**可用**的工具模式(空 = 不额外限制)。 diff --git a/internal/security/round.go b/internal/security/round.go new file mode 100644 index 00000000..4b60a6dc --- /dev/null +++ b/internal/security/round.go @@ -0,0 +1,303 @@ +package security + +// 轮次上下文(RoundContext):**security 对外的输入接入点**。 +// +// ## 为什么需要这一层(2026-10-31 收敛) +// +// 收敛前的形态:security 只暴露细粒度判定原语(`CheckTool` / `CheckToolFor` / +// `ActivateProfile`),于是**每个消费方都得自己理解策略语义**: +// +// agent/core/security_profile.go 里曾这样写: +// +// name, _ := payload["security_profile"].(string) // 自己抠字段 +// a.security.ActivateProfile(name, 0) // 再告诉 security +// if a.parentID != "" { // 自己判断走哪条链 +// return a.security.CheckToolFor(Principal{Kind: "resident"}, tool) +// } +// return a.security.CheckTool(tool) +// +// 三个问题全出在这段: +// +// ① `security_profile` 是 security 自己的协议概念,解析却在外人手里 +// ② “我是父还是子”是策略语义,判断却在 agent 手里 +// ③ `Kind: "resident"` 是调用方**声明自己的类别** ⇒ 说自己是内部就享受 +// 内部豁免,那是提权口子 +// +// ## 本文件确立的形态 +// +// 调用方只提供**事实**(谁在跑、有没有父、原始载荷) +// security 自己做**结论**(类别、档位、走哪条判定链) +// +// 对应 EventBus 那个已经跑通的范例:总线只交事件,guard 自己判。 +// +// events.Bus.Publish → Guard.AllowPublish(Publisher, EventType) → 判定在 guard 内 +// +// ## 生命周期 +// +// rc, err := rt.BeginRound(RoundInput{...}) // 输入到达 +// defer rc.End() // 轮次结束(归位,防跨轮泄漏) +// rc.CheckTool(name) // 本轮的判定(只含本轮的档位/主体) +// +// `End` 必须调:档位在 `Runtime` 上是**进程级状态**,不归位就会泄漏到下一轮 +// (guest 的收窄误伤后续输入;方向相反就是提权)。 + +import ( + "fmt" + "log" + "strings" +) + +// RoundInput 是一次「输入到达」时调用方能提供的**全部事实**。 +// +// ★ 这里刻意**不含 Kind**:类别由 security 自己从 AgentID/IsChild 推。 +// 调用方给不出“我是内部主体”这种结论 —— 它只能说自己是谁。 +type RoundInput struct { + // AgentID 是执行本次输入的执行体标识(主 agent = "main",子 = 子的 id)。 + AgentID string + + // IsChild 表示本次输入由**某个父 agent 派出的子执行体**处理。 + // + // 这是调用方能提供的事实(驻留子/rtchild 自己知道有父), + // 而“子属于哪一类主体、该不该豁免 HardPrivate”由 security 决定。 + IsChild bool + + // Payload 是**原始输入载荷**(`InputEvent.Payload`)。 + // + // ★ security 自己从中解析 `security_profile` —— 那是 security 的协议, + // 不该让每个消费方各写一遍解析。字段名定义见 `profileKey`。 + Payload map[string]interface{} + + // Source 是来源通道名(诊断与审计用,不参与判定)。 + Source string +} + +// profileKey 是输入载荷里携带命名档位的键名。 +// +// 与公开 SDK 的 `InjectOptions.SecurityProfile`(JSON 名 `security_profile`) +// **逐字一致** —— 那是插件与 security 之间的协议,解析权属于 security。 +const profileKey = "security_profile" + +// Round 是一轮的策略上下文句柄。 +// +// 它把「本轮生效的档位」与「本轮的主体」绑在一起,使判定成为**纯函数式**的 +// 一次调用,调用方不再需要知道任何组合规则。 +type Round struct { + rt *Runtime + // principal 是 security **自己判定**出来的主体(不是调用方声明的)。 + principal Principal + // profile 是本轮激活的档位名(空 = 未使用档位)。 + profile string + // restored 记录 BeginRound 之前的档位,End 时恢复。 + // + // 为什么恢复而不是清空:进程内可能有多条输入在跑(主 agent + 驻留子), + // 清空会把别处的档位抹掉。恢复到进入前的值是最小惊讶行为。 + restored string + ended bool +} + +// CheckToolForAgent 按**执行体身份**判定工具是否可用(无轮次句柄时的入口)。 +// +// ## 什么时候用它 +// +// 工具执行链(`executeToolCallInner`)只持有执行体身份(id + 有无父), +// 不持有本轮句柄。此时由 security **自己**从事实推出主体类别, +// 而不是让调用方声明 `Kind` —— 后者是提权口子。 +// +// 与 `BeginRound` + `Round.CheckTool` 的区别: +// +// BeginRound 路线 —— 带档位(本轮激活的 profile 参与判定) +// 本函数 —— 不带档位处理,只看全局策略 + 主体策略 +// +// ★ 为什么不用把档位也算进来:档位是**输入**携带的(`security_profile`), +// 它属于某一轮的事实。工具执行链上没有轮次上下文,硬塞一个“进程当前档” +// 才是不对的(那正是收敛前 `ActivateProfile` 全局单例的毛病)。 +// 真正的修法是让执行链拿到本轮句柄 —— 见 future-plan。 +func (r *Runtime) CheckToolForAgent(agentID string, isChild bool, tool string) Decision { + if r == nil { + return allowed() + } + return r.CheckToolFor(r.classifyPrincipal(RoundInput{ + AgentID: agentID, + IsChild: isChild, + }), tool) +} + +// BeginRound 在**一条输入开始处理时**调用,由 security 完成: +// +// ① 判定主体类别(据 AgentID + IsChild,不信调用方声明) +// ② 从原始 Payload 解析 `security_profile` +// ③ 激活档位(未声明/拼错时**响亮降级**,不静默沿用上一轮的档) +// +// 返回的 `Round` 必须 `End()`(defer 即可)。 +// +// ★ 为什么档位解析在这里而不是在调用方: +// 档位名是 `security` 的协议(`Profile.Name` 约定 `.<档位>`)。 +// 把它交给调用方解析,等于让 N 个调用方各写一遍,且每写一遍都可能漏。 +func (r *Runtime) BeginRound(in RoundInput) (*Round, error) { + if r == nil { + return &Round{}, nil // 未装配 security:零成本句柄 + } + + rc := &Round{ + rt: r, + principal: r.classifyPrincipal(in), + restored: r.ActiveProfile(), + } + + name := strings.TrimSpace(roundProfileName(in.Payload)) + if name == "" { + // 不带档位 = 显式不使用(沿用全局策略)。但**必须归位**: + // 上一条输入若激活了 guest 档,不清掉就会误伤这一条。 + if r.active != nil { + r.active.Clear() + } + return rc, nil + } + + if err := r.ActivateProfile(name, 0); err != nil { + // 响亮但不断流:注入是 fire-and-forget,插件看不到内核错误。 + // 但这里必须**清掉上一轮的档**,否则拼错档位名会静默沿用旧档 —— + // “拼错却仍收窄”与“拼错却仍放行”都是安全事故形态。 + if r.active != nil { + r.active.Clear() + } + log.Printf("[security] round profile %q rejected for agent %q: %v "+ + "(falling back to default policy, NOT to the previous round's profile)", name, in.AgentID, err) + return rc, err + } + rc.profile = name + return rc, nil +} + +// roundProfileName 从原始载荷里取档位名。 +// +// 独立成小函数是为了让“解析规则只有一处”这件事在代码上可见: +// 任何调用方都不该再写 `payload["security_profile"]`。 +func roundProfileName(payload map[string]interface{}) string { + if payload == nil { + return "" + } + s, _ := payload[profileKey].(string) + return s +} + +// classifyPrincipal 由 security **自己**判定主体类别。 +// +// ## 判定规则(唯一事实来源) +// +// IsChild ⇒ KindSubAgent (内核派出的子执行体) +// AgentID == "" ⇒ KindInternal (内核自身发起的自循环任务) +// 其他 ⇒ KindUser (经插件通道进来的终端用户) +// +// ★ 为什么"子"优先于"内核自身": +// 子执行体也是内部委派(共享 HardPrivate 豁免),但它**可以被父收窄**。 +// 若先判成 KindInternal,父在创建时声明的 Allow/Deny 就落不到它头上 —— +// 那正是收敛前 `Kind: "resident"` 掩盖的问题(它同时意味着 +// “我是内部”和“我不受收窄”)。 +func (r *Runtime) classifyPrincipal(in RoundInput) Principal { + p := Principal{ID: in.AgentID} + switch { + case in.IsChild: + p.Kind = KindSubAgent + case strings.TrimSpace(in.AgentID) == "": + p.Kind = KindInternal + default: + p.Kind = KindUser + } + return p +} + +// Principal 返回本轮的主体(security 判定出的,只读)。 +func (rc *Round) Principal() Principal { + if rc == nil { + return Principal{} + } + return rc.principal +} + +// Profile 返回本轮生效的档位名(空 = 未使用档位)。 +func (rc *Round) Profile() string { + if rc == nil { + return "" + } + return rc.profile +} + +// CheckTool 判定本轮的某个工具是否可用。 +// +// 这就是工具执行链**唯一**需要的调用:调用方不再需要知道 +// “我是父还是子、该走 CheckTool 还是 CheckToolFor”。 +func (rc *Round) CheckTool(name string) Decision { + if rc == nil || rc.rt == nil { + return allowed() + } + return rc.rt.CheckToolFor(rc.principal, name) +} + +// End 结束本轮:恢复进入前的档位。 +// +// 幂等:重复调用无副作用(defer + 显式调用并存是安全的)。 +func (rc *Round) End() { + if rc == nil || rc.rt == nil || rc.ended { + return + } + rc.ended = true + if rc.rt.active == nil { + return + } + if rc.restored != "" { + // 恢复到进入前的档位(可能是别的输入正在用的档)。 + rc.rt.active.Activate(rc.restored, scopeTTLDefault) + return + } + rc.rt.active.Clear() +} + +// DeclareChildPolicy 让**父**为刚创建的子执行体声明主体策略。 +// +// ## 为什么这个入口也在 security 里(而不是 agent 组装结构体) +// +// 收敛前 `agent/core` 自己拼 `PrincipalPolicy{AllowTools: …, DenyTools: …}` +// 字面量 —— 那是策略结构,属于 security 的领域。调用方只该说 +// “这个子允许这些、禁止那些”,由 security 负责把语义落到正确的作用域。 +// +// 语义约束(security 保证):声明只能**收窄**。`CheckToolFor` 内部先跑 +// `CheckTool`(全局 deny 优先),所以父的黑名单对子依然有效,子无法提权。 +func (r *Runtime) DeclareChildPolicy(childID string, allow, deny []string) error { + if r == nil { + return nil // 未装配 security:不为子声明,保持无限制(与之前一致) + } + childID = strings.TrimSpace(childID) + if childID == "" { + return fmt.Errorf("security: declare child policy requires a non-empty child id") + } + if len(allow) == 0 && len(deny) == 0 { + return nil // 父选择不给额外限制 + } + return r.DeclarePrincipalPolicy("id", childID, PrincipalPolicy{ + AllowTools: allow, + DenyTools: deny, + }) +} + +// RevokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。 +// +// ★ 必须撤销:主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作。 +// 旧策略留着 ⇒ 新一代用上一代父留的约束运行(且那个父可能已不存在), +// 主体表还会随 create/destroy 单调增长。 +// 这与 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)同一形态。 +func (r *Runtime) RevokeChildPolicy(childID string) { + if r == nil || strings.TrimSpace(childID) == "" { + return + } + _ = r.RevokePrincipalPolicy("id", strings.TrimSpace(childID)) +} + +// ClassifyPrincipalForTest 暴露主体分类规则给跨包判据(**仅测试用**)。 +// +// 为什么不写在 _test.go:判据住在 `kernelmod/all`(那里才有整机装配), +// 而 Go 的 _test.go 符号不跨包导出。带 ForTest 后缀是为了让 +// "生产里调了它"在 code review 时一眼可见。 +func (r *Runtime) ClassifyPrincipalForTest(in RoundInput) Principal { + return r.classifyPrincipal(in) +}