package loop import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // auditToolRejectNotAllowed 是「工具调用越出允许面」的审计 "what": // 群聊/陌生人环境中被 design/11 §3 安全门拒绝的调用,以及 // capacity 允许集之外的越界调用(design/14 §4,裁决 14.4.2)共用它, // 因此审计轨迹与前端小字两侧不必各认一套标签。 const auditToolRejectNotAllowed = "tool.reject.not_allowed" // AuditEvent 向审计 sink 上报被安全门拦截的工具拒绝 // (design/11 §3 §8)。循环在受限(群聊/陌生人)环境中拒绝 // 工具调用时发出一个,使该拒绝可审计,且 // 前端可以渲染「xx 工具在群聊被拒」的小字。它经 // Options.OnAudit 接到 gateway 审计轨迹。 // // capacity 允许集之外的越界调用也走同一通道(见 softJudgeAudit): // 区别只在 Result 文案 —— 安全门写的是拒绝,软判写的是被让行。 type AuditEvent struct { // Who 是实施行为的 actor 环境标签("group_member"/"stranger"/"owner")。 Who string // What 是操作,如 "tool.reject.not_allowed"。 What string // Result 是结果详情 —— 被拦调用的 not_allowed 提示,或越界调用 // 被让行时的 not_allowed 软提示。 Result string } // restrictSchemas 仅返回 ctx 上会话环境可调用的工具 schema, // 依据 design/11 §3 安全门:群聊/陌生人会话只能 // 调用其可编辑标记允许该环境的工具(默认 // 关闭),而 owner 环境保留全集。它是对 capacity // 允许集视图的最终裁决 —— 是安全红线而非便利 // 提示 —— 因此它最后运行,且只能收缩暴露面。 // // 它同时把**收缩前**的工具面(即 capacity 过滤后的本轮允许集) // 发布到该 Run 的允许集持有者上,供 Dispatcher.Invoke 的前置软判 // 使用(design/14 §4,裁决 14.4.2);env 安全门是硬红线,不在软判 // 范围内(它拦下的调用在 Invoke 之前就已跳过)。 func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) []model.ToolSpec { l.publishRoundAllowSet(ctx, d, schemas) policy := d.PolicyEngine(sessionEnvFrom(ctx).ActorEnv) kept := make([]model.ToolSpec, 0, len(schemas)) for _, s := range schemas { if policy.Allow(s.Name) { kept = append(kept, s) } } return kept } // publishRoundAllowSet 把本轮模型可见的工具面发布到该 Run 的允许集 // 持有者上,并把分发器的软判 sink 接进本循环的审计通道 // (design/14 §4,裁决 14.4.2)。发布的是 capacity 过滤后的 schema // 名单 —— 判定「这次调用是否越界」以模型实际看到的那份工具面为准, // 不另算第二套策略。ctx 未播种持有者(不经 capacity 过滤的内联流程) // 或 dispatcher 缺失时为空操作。 func (l *Loop) publishRoundAllowSet(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) { if d == nil { return } names := make([]string, 0, len(schemas)) for _, s := range schemas { names = append(names, s.Name) } tool.PublishRoundAllowSet(ctx, names, l.allowSetSource(ctx)) d.SetSoftJudgeSink(l.softJudgeAudit) } // allowSetSource 是本轮允许集的来源标签,只用于软判日志与提示文案, // 使越界调用看得出是哪个 capacity 态收窄了工具面;capacity 未启用时 // 标注 off(此时工具面没有被收敛,软判不会触发)。 func (l *Loop) allowSetSource(ctx context.Context) string { if l.capacity == nil || !l.capacity.Enabled() { return "capacity:off" } return "capacity:" + string(capState(ctx).state) } // softJudgeAudit 把 Dispatcher 的允许集软判记录接进既有审计通道 // (design/11 §3 §8 的 AuditEvent → Options.OnAudit):与群聊/陌生人 // 安全门复用同一个审计 "what" 与 sink,因此前端小字与审计轨迹都能 // 看到越界调用。who 从该次调用的会话环境(actor 元数据)取,与安全门 // 同一口径;result 写明调用是被让行而非被拒(文案由分发器给出)。 func (l *Loop) softJudgeAudit(ctx context.Context, ev tool.SoftJudgeEvent) { l.emitAudit(AuditEvent{ Who: string(sessionEnvFrom(ctx).ActorEnv), What: auditToolRejectNotAllowed, Result: ev.Reason, }) } // applyToolSecurityGate 拦截所有工具在 ctx 会话环境中 // 不被允许的调用(design/11 §3)。它就地把 actions 改写, // 把每个可运行的调用改为携带 not_allowed 提示的跳过, // 并为该拒绝发出一个 AuditEvent。nil 接收者或 nil dispatcher 为空操作。 func (l *Loop) applyToolSecurityGate(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) { env := sessionEnvFrom(ctx) policy := d.PolicyEngine(env.ActorEnv) for i := range calls { if !actions[i].run { continue } if policy.Allow(calls[i].Name) { continue } reason := notAllowedReason(calls[i].Name) actions[i] = toolCallAction{run: false, reason: reason} l.emitAudit(AuditEvent{ Who: string(env.ActorEnv), What: auditToolRejectNotAllowed, Result: reason, }) } } // notAllowedReason 是受限(群聊/陌生人)会话调用其可编辑 // 标记拒绝该环境的工具时回灌给模型的软提示 // (design/11 §3)。它是软结果 —— 注入对话, // 绝不 panic 或硬崩溃 —— 使模型能重新评估。 func notAllowedReason(name string) string { return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation" } // emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线 // sink 时为空操作,因此在未挂审计 sink 的叶子测试中 // 安全门仍能工作(软 not_allowed 结果)。 func (l *Loop) emitAudit(ev AuditEvent) { if l.onAudit == nil { return } l.onAudit(ev) }