Files
Cyrene_v2/internal/runtime/loop/tool_policy.go
T
AskaEth 38b4527949
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tool,loop): 工具调用软判并回(裁决 14.4.2)
- 落点=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 例红 ⇒ 两侧各被独立覆盖
2026-10-04 12:45:48 +08:00

140 lines
5.9 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 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)
}