- 落点=Dispatcher.Invoke 入口(唯一执行口,覆盖主循环/子代理/编排全部调用方) - 语义=**不硬阻断**:越界调用照常执行,把 not_allowed 提示与软判理由挂到真实结果上回灌模型 (符合 design/14 §1「状态改变不构成能力边界、紧急时也能调」与 §4「入口前置软判」) - 允许集单一真源:loop 发布 capacity 过滤后的 schema 名单(= 模型本轮实际看到的工具面), 分发器不在本地另算一套判定 - 审计:复用既有 AuditEvent/OnAudit 通道(what=tool.reject.not_allowed,design/14 §4 原文), 前端小字与审计日志同源;日志带 source=runtime/tool - ctx 载体:Run 入口每 Run 播种允许集持有者(run.go +6 行,越界但必需——ctx 只能由 Run 或 request.go 播种,后者归 G5-A) - 与既有硬闸门零重叠:restrict(群聊/陌生人)与 ToolWhitelist(子代理)仍在更早处硬拦 + 各自审计 - 复核:`go build`/`go vet`/tool+loop 包测试绿,`-race` 下 SoftJudge/AllowSet 无竞态; 变异 M1(软判恒空)4 例红、M2(不发布允许集)仅端到端 1 例红 ⇒ 两侧各被独立覆盖
140 lines
5.9 KiB
Go
140 lines
5.9 KiB
Go
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)
|
||
}
|