feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写) - 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name); 新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存 (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在 - 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、 restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截), 组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧 - 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源 复用 audit.Approval.Explain()),附纯 node 逻辑测试 - 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart 与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
This commit is contained in:
1 parent
ed5bd92e1b
commit
63236c9b00
20 files changed
+1709
-24
No files matched your search
@@ -0,0 +1,168 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
||||
)
|
||||
|
||||
// 本文件是 design/11 §4「敏感操作代审」的准入侧实现(裁决 13.4.3):
|
||||
// loop 的工具钩子(ToolLoopHooks.CallAdmit / BatchAdmit)调用 Gate.Admit,
|
||||
// 敏感调用经主代理代审、非敏感调用自主放行。落库与前端小字广播不在本包
|
||||
// ——Gate 只产出记录(Decision.Approval),由组合根交给 gateway 落 audit_logs
|
||||
// 并广播 approval_notice 帧。
|
||||
|
||||
// Reviewer 执行一次主代理代审(design/11 §4)。
|
||||
//
|
||||
// 生产实现适配 *audit.Approver(组合根注入,见 cmd/cyrene/wire_runtime.go);
|
||||
// 用窄接口是为了本包能用桩测试,不必起编排层。
|
||||
type Reviewer interface {
|
||||
// ReviewAndApprove 审核归属 actor 的角色想做的 action,返回是否放行。
|
||||
// describe 是给审查者看的动作说明。空白 actor/action/describe 或 ctx 取消
|
||||
// 由实现返回错误;返回错误时门按 fail-closed 拦截。
|
||||
ReviewAndApprove(ctx context.Context, actor, action, describe string) (allowed bool, err error)
|
||||
}
|
||||
|
||||
// Resolver 从一次 Run 的上下文解析代审归属(design/11 §4 小字里的「xxx」):
|
||||
// sessionID = 该 Run 的会话(前端小字的投递目标,可为空表示不定位会话),
|
||||
// actor = 想动手的角色名。组合根用 loop.SessionFromContext + 该会话的角色解析。
|
||||
type Resolver func(ctx context.Context) (sessionID, actor string)
|
||||
|
||||
// ApprovalEvent 是一次代审的完整记录(design/11 §4 §8:who / what / when /
|
||||
// result + 前端小字)。组合根把它落进 audit_logs 并广播给前端。
|
||||
type ApprovalEvent struct {
|
||||
// SessionID 是该次 Run 的会话 id(可为空)。
|
||||
SessionID string
|
||||
// Actor 是想要动手的角色名(小字里的 xxx)。
|
||||
Actor string
|
||||
// Tool 是敏感工具名(如 terminal)。
|
||||
Tool string
|
||||
// Op 是敏感操作类别(执行命令 / 修改文件 / 网络访问)。
|
||||
Op SensitiveOp
|
||||
// Detail 是动作摘要(工具 + 关键参数,已截断)。
|
||||
Detail string
|
||||
// Explain 是前端小字文案(design/11 §4 的固定句式)。
|
||||
Explain string
|
||||
// Allowed 报告代审是否放行。
|
||||
Allowed bool
|
||||
// At 是代审发生时间。
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Decision 是一次工具调用准入的结论。
|
||||
type Decision struct {
|
||||
// Allow 报告该调用是否放行。
|
||||
Allow bool
|
||||
// Reason 是拦截时回灌给模型的软提示(放行时为空),体例与既有安全门的
|
||||
// "not_allowed: ..." 一致,使模型能重新评估而不是硬崩。
|
||||
Reason string
|
||||
// Approval 是本次代审的记录;非敏感调用为 nil。调用方负责记录它
|
||||
// (审计落库 + 前端小字),放行与拦截都要记(design/11 §8)。
|
||||
Approval *ApprovalEvent
|
||||
}
|
||||
|
||||
// Gate 是 loop 的工具准入门(design/11 §4;裁决 13.4.3):敏感操作走主代理
|
||||
// 代审,其余自主放行。它对并发使用安全(无可变状态)。
|
||||
type Gate struct {
|
||||
reviewer Reviewer
|
||||
resolve Resolver
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// GateOption 配置一个 Gate。
|
||||
type GateOption func(*Gate)
|
||||
|
||||
// WithGateLogger 让门把每次代审结论与拦截原因记到 l(来源标
|
||||
// capability/restrict)。为 nil 时不记日志。
|
||||
func WithGateLogger(l *slog.Logger) GateOption {
|
||||
return func(g *Gate) { g.log = logger.WithSource(l, "capability/restrict") }
|
||||
}
|
||||
|
||||
// NewGate 构建代审门。reviewer 为 nil 时门是 fail-closed 的:所有敏感调用
|
||||
// 一律拦截并留下一条「未获代审」的拒绝记录——绝不因为没有代审者就静默放行。
|
||||
// resolve 为 nil 时归属解析为空(actor 为空会让小字退化,但判定不受影响)。
|
||||
func NewGate(reviewer Reviewer, resolve Resolver, opts ...GateOption) *Gate {
|
||||
g := &Gate{reviewer: reviewer, resolve: resolve}
|
||||
for _, opt := range opts {
|
||||
opt(g)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Admit 判定一次工具调用:非敏感立即放行(不产生代审记录),敏感调用先做
|
||||
// 主代理代审再决定放行或拦截。返回的 Decision.Approval 非 nil 时必须由调用方
|
||||
// 记录(design/11 §8:代审动作批准/拒绝各落一行)。
|
||||
func (g *Gate) Admit(ctx context.Context, name string, args map[string]any) Decision {
|
||||
op, detail, sensitive := SensitiveCall(name, args)
|
||||
if !sensitive {
|
||||
return Decision{Allow: true}
|
||||
}
|
||||
sessionID, actor := g.resolveCtx(ctx)
|
||||
action := string(op)
|
||||
if detail != "" {
|
||||
action += ":" + detail
|
||||
}
|
||||
ev := &ApprovalEvent{
|
||||
SessionID: sessionID,
|
||||
Actor: actor,
|
||||
Tool: name,
|
||||
Op: op,
|
||||
Detail: detail,
|
||||
At: time.Now(),
|
||||
}
|
||||
if g == nil || g.reviewer == nil {
|
||||
ev.Explain = approvalExplain(actor, false)
|
||||
g.logf("敏感操作无代审者,按 fail-closed 拦截", name, actor, nil)
|
||||
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
|
||||
}
|
||||
allowed, err := g.reviewer.ReviewAndApprove(ctx, actor, action, detail)
|
||||
if err != nil {
|
||||
ev.Explain = approvalExplain(actor, false)
|
||||
g.logf("代审失败,按 fail-closed 拦截", name, actor, err)
|
||||
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
|
||||
}
|
||||
ev.Allowed = allowed
|
||||
ev.Explain = approvalExplain(actor, allowed)
|
||||
g.logf("敏感操作代审", name, actor, nil)
|
||||
if !allowed {
|
||||
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
|
||||
}
|
||||
return Decision{Allow: true, Approval: ev}
|
||||
}
|
||||
|
||||
// resolveCtx 解析归属;Resolver 为 nil 时返回两个空串(判定不依赖它们)。
|
||||
func (g *Gate) resolveCtx(ctx context.Context) (string, string) {
|
||||
if g == nil || g.resolve == nil {
|
||||
return "", ""
|
||||
}
|
||||
return g.resolve(ctx)
|
||||
}
|
||||
|
||||
// logf 记一条带来源的代审日志:只记工具名与角色名,**不记动作摘要**
|
||||
// (参数里可能有秘密;摘要只进本包的 ApprovalEvent 与审计 who/what/result 链)。
|
||||
func (g *Gate) logf(msg, tool, actor string, err error) {
|
||||
if g == nil || g.log == nil {
|
||||
return
|
||||
}
|
||||
args := []any{"tool", tool, "actor", actor}
|
||||
if err != nil {
|
||||
args = append(args, "error", err)
|
||||
}
|
||||
g.log.Info(msg, args...)
|
||||
}
|
||||
|
||||
// approvalDeniedReason 是敏感操作未获代审放行时回灌给模型的软提示,
|
||||
// 与 loop 安全门文案(not_allowed: ...)同一体例。
|
||||
func approvalDeniedReason(name string) string {
|
||||
return "not_approved: sensitive tool " + name + " requires the main agent's approval"
|
||||
}
|
||||
|
||||
// approvalExplain 产出前端小字文案。它复用 orchestration/audit 的固定句式
|
||||
// (design/11 §4:「xxx 想要干什么,我审查后批准了」),使代审成功与
|
||||
// 「无代审者/代审报错」两条路径的小字完全一致,不出现两份文案。
|
||||
func approvalExplain(actor string, allowed bool) string {
|
||||
return audit.Approval{Actor: actor, Allowed: allowed}.Explain()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// reviewStub 是 Reviewer 的桩:记录调用、按配置返回结论或错误。
|
||||
type reviewStub struct {
|
||||
allowed bool
|
||||
err error
|
||||
calls []reviewCall
|
||||
}
|
||||
|
||||
type reviewCall struct{ actor, action, describe string }
|
||||
|
||||
func (s *reviewStub) ReviewAndApprove(_ context.Context, actor, action, describe string) (bool, error) {
|
||||
s.calls = append(s.calls, reviewCall{actor: actor, action: action, describe: describe})
|
||||
if s.err != nil {
|
||||
return false, s.err
|
||||
}
|
||||
return s.allowed, nil
|
||||
}
|
||||
|
||||
// staticResolver 固定返回一遍归属。
|
||||
func staticResolver(sessionID, actor string) Resolver {
|
||||
return func(context.Context) (string, string) { return sessionID, actor }
|
||||
}
|
||||
|
||||
// TestGate_NonSensitivePassesWithoutApproval 锁定「非敏感自主」:读文件/检索
|
||||
// 不进代审、不产生记录。
|
||||
func TestGate_NonSensitivePassesWithoutApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
rv := &reviewStub{allowed: true}
|
||||
g := NewGate(rv, staticResolver("web", "昔涟"))
|
||||
d := g.Admit(context.Background(), "file", map[string]any{"action": "read", "path": "/data/a.md"})
|
||||
if !d.Allow {
|
||||
t.Fatalf("读文件应自主放行,得到 %+v", d)
|
||||
}
|
||||
if d.Approval != nil {
|
||||
t.Fatalf("非敏感调用不应产生代审记录: %+v", d.Approval)
|
||||
}
|
||||
if len(rv.calls) != 0 {
|
||||
t.Fatalf("非敏感调用不应触发代审,代审者被调用 %d 次", len(rv.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_SensitiveToolTriggersApproval 是裁决 13.4.3 的反向用例(loop 侧):
|
||||
// terminal 不再自主执行,而是触发主代理代审,并把记录交给调用方落审计。
|
||||
func TestGate_SensitiveToolTriggersApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
rv := &reviewStub{allowed: true}
|
||||
g := NewGate(rv, staticResolver("web", "昔涟"))
|
||||
d := g.Admit(context.Background(), "terminal", map[string]any{"command": "echo hi"})
|
||||
if !d.Allow {
|
||||
t.Fatalf("代审批准后应放行,得到 %+v", d)
|
||||
}
|
||||
if len(rv.calls) != 1 {
|
||||
t.Fatalf("代审者调用次数 = %d, want 1", len(rv.calls))
|
||||
}
|
||||
if rv.calls[0].actor != "昔涟" {
|
||||
t.Fatalf("代审归属 actor = %q, want 昔涟", rv.calls[0].actor)
|
||||
}
|
||||
if rv.calls[0].action != "执行命令:terminal echo hi" {
|
||||
t.Fatalf("代审动作 = %q, want 执行命令:terminal echo hi", rv.calls[0].action)
|
||||
}
|
||||
ev := d.Approval
|
||||
if ev == nil {
|
||||
t.Fatal("敏感调用放行也必须留下代审记录(design/11 §8:批准/拒绝各落一行)")
|
||||
}
|
||||
if !ev.Allowed || ev.SessionID != "web" || ev.Actor != "昔涟" || ev.Tool != "terminal" || ev.Op != SensitiveExecCommand {
|
||||
t.Fatalf("代审记录 = %+v", ev)
|
||||
}
|
||||
if ev.Explain != "「昔涟 想要干什么,我审查后批准了」" {
|
||||
t.Fatalf("前端小字 = %q, want design/11 §4 的固定句式", ev.Explain)
|
||||
}
|
||||
if ev.At.IsZero() {
|
||||
t.Fatal("代审记录应带时间戳")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_DeniedSensitiveCallIsBlocked 锁定拒绝路径:代审不放行 → 调用被拦截、
|
||||
// 回灌软提示、记录里 Allowed=false 且小字换成「拒绝了」。
|
||||
func TestGate_DeniedSensitiveCallIsBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
rv := &reviewStub{allowed: false}
|
||||
g := NewGate(rv, staticResolver("web", "昔涟"))
|
||||
d := g.Admit(context.Background(), "terminal", map[string]any{"command": "rm -rf /"})
|
||||
if d.Allow {
|
||||
t.Fatalf("代审未放行却被放行: %+v", d)
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Fatal("拦截必须给出回灌给模型的说明")
|
||||
}
|
||||
if d.Approval == nil || d.Approval.Allowed {
|
||||
t.Fatalf("拒绝也要落记录且 Allowed=false: %+v", d.Approval)
|
||||
}
|
||||
if d.Approval.Explain != "「昔涟 想要干什么,我审查后拒绝了」" {
|
||||
t.Fatalf("拒绝小字 = %q", d.Approval.Explain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_ReviewerFailureIsFailClosed 锁定 fail-closed:代审者报错(或压根
|
||||
// 没配)时敏感操作一律拦截并留下拒绝记录,绝不静默放行。
|
||||
func TestGate_ReviewerFailureIsFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
for name, g := range map[string]*Gate{
|
||||
"reviewer error": NewGate(&reviewStub{err: errors.New("approver down")}, staticResolver("web", "昔涟")),
|
||||
"no reviewer": NewGate(nil, staticResolver("web", "昔涟")),
|
||||
} {
|
||||
d := g.Admit(ctx, "terminal", map[string]any{"command": "echo hi"})
|
||||
if d.Allow {
|
||||
t.Fatalf("%s: 敏感调用被放行,应 fail-closed 拦截", name)
|
||||
}
|
||||
if d.Approval == nil || d.Approval.Allowed {
|
||||
t.Fatalf("%s: 应留下 Allowed=false 的代审记录: %+v", name, d.Approval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_NilGateDeniesSensitive 锁定零值安全:未装配的门(nil 接收者)碰到
|
||||
// 敏感调用按拦截处理,而不是 panic 或放行。
|
||||
func TestGate_NilGateDeniesSensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
var g *Gate
|
||||
if d := g.Admit(context.Background(), "terminal", map[string]any{"command": "echo hi"}); d.Allow {
|
||||
t.Fatalf("nil 门应拦截敏感调用: %+v", d)
|
||||
}
|
||||
if d := g.Admit(context.Background(), "calculator", nil); !d.Allow {
|
||||
t.Fatalf("nil 门的非敏感调用仍应放行: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
|
||||
// 小字(「xxx 想要干什么,我审查后批准了」),不参与身份/权限判定——身份始终
|
||||
// 由 channel 层 actor 元数据 + 白名单决定(design/11 §0 红线)。
|
||||
type SensitiveOp string
|
||||
|
||||
const (
|
||||
// SensitiveExecCommand 执行命令(terminal)。
|
||||
SensitiveExecCommand SensitiveOp = "执行命令"
|
||||
// SensitiveWriteFile 修改文件(file 工具的 write 动作)。
|
||||
SensitiveWriteFile SensitiveOp = "修改文件"
|
||||
// SensitiveNetwork 网络访问。只有会改变外部状态的写方法算敏感:
|
||||
// GET/HEAD/OPTIONS 属「检索」,可自主(design/11 §4)。
|
||||
SensitiveNetwork SensitiveOp = "网络访问"
|
||||
)
|
||||
|
||||
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
||||
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
||||
const sensitiveSummaryRunes = 80
|
||||
|
||||
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
||||
//
|
||||
// 判定完全结构化:只看工具名 + 参数,绝不由模型文本推断(design/11 §0 红线)。
|
||||
// 返回操作类别、动作摘要与是否敏感。清单与项目实际的工具面对应如下:
|
||||
//
|
||||
// terminal → 执行命令(命令原文进摘要)
|
||||
// file (action=write) → 修改文件(路径进摘要)
|
||||
// http (method=POST) → 网络访问(URL 进摘要)
|
||||
//
|
||||
// 其余情况一律**不敏感**:file 的 read/list/search、http 的 GET/HEAD/OPTIONS、
|
||||
// knowledge / web-search / web-fetch(检索)、calculator / datetime / text /
|
||||
// json / markdown / random(纯计算)、vision / ocr / session_read(只读)——
|
||||
// 与 design/11 §4「非敏感(读文件 / 检索 / 查记忆)可自主」一致。
|
||||
//
|
||||
// 未知工具(含 MCP 工具)同样不敏感:工具面已由 registry 的策略闸门管理,
|
||||
// 这里不新增「未知即拦截」的硬边界,否则任何新工具都会在主人会话里被拦死。
|
||||
func SensitiveCall(name string, args map[string]any) (SensitiveOp, string, bool) {
|
||||
switch name {
|
||||
case "terminal":
|
||||
return SensitiveExecCommand, summarize("terminal", argString(args, "command")), true
|
||||
case "file":
|
||||
if argString(args, "action") != "write" {
|
||||
return "", "", false
|
||||
}
|
||||
return SensitiveWriteFile, summarize("file write", argString(args, "path")), true
|
||||
case "http":
|
||||
method := strings.ToUpper(strings.TrimSpace(argString(args, "method")))
|
||||
if method == "" || method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
||||
return "", "", false
|
||||
}
|
||||
return SensitiveNetwork, summarize("http "+method, argString(args, "url")), true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
||||
// 多字节字符);目标为空时只返回动作。
|
||||
func summarize(action, target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return action
|
||||
}
|
||||
return action + " " + clipRunes(target, sensitiveSummaryRunes)
|
||||
}
|
||||
|
||||
// clipRunes 把 s 截到至多 max 个 rune,超出时追加省略号;max 非正时原样返回。
|
||||
func clipRunes(s string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:max]) + "…"
|
||||
}
|
||||
|
||||
// argString 从工具参数里取一个字符串字段;参数表缺失或类型不符返回 ""。
|
||||
func argString(args map[string]any, key string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := args[key].(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSensitiveCall 是 design/11 §4 敏感清单的判定表:执行命令/修改文件/
|
||||
// 网络写方法敏感,读文件/检索/纯计算自主。
|
||||
func TestSensitiveCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
wantOp SensitiveOp
|
||||
wantIn string // 摘要应包含的片段(wantSensitive 时)
|
||||
sensitive bool
|
||||
}{
|
||||
{name: "terminal", args: map[string]any{"command": "rm -rf /tmp/x"}, wantOp: SensitiveExecCommand, wantIn: "rm -rf /tmp/x", sensitive: true},
|
||||
{name: "terminal", args: nil, wantOp: SensitiveExecCommand, wantIn: "terminal", sensitive: true},
|
||||
{name: "file", args: map[string]any{"action": "write", "path": "/data/a.md"}, wantOp: SensitiveWriteFile, wantIn: "/data/a.md", sensitive: true},
|
||||
{name: "file", args: map[string]any{"action": "read", "path": "/data/a.md"}},
|
||||
{name: "file", args: map[string]any{"action": "list"}},
|
||||
{name: "file", args: map[string]any{"action": "search", "query": "x"}},
|
||||
{name: "http", args: map[string]any{"method": "POST", "url": "https://example.com/hook"}, wantOp: SensitiveNetwork, wantIn: "example.com/hook", sensitive: true},
|
||||
{name: "http", args: map[string]any{"method": "post", "url": "https://example.com/hook"}, wantOp: SensitiveNetwork, sensitive: true},
|
||||
{name: "http", args: map[string]any{"url": "https://example.com"}}, // method 缺省 = GET
|
||||
{name: "http", args: map[string]any{"method": "GET", "url": "https://example.com"}},
|
||||
{name: "http", args: map[string]any{"method": "HEAD"}},
|
||||
{name: "knowledge", args: map[string]any{"query": "设计文档"}},
|
||||
{name: "web-search", args: map[string]any{"query": "x"}},
|
||||
{name: "web-fetch", args: map[string]any{"url": "https://example.com"}},
|
||||
{name: "session_read", args: map[string]any{"sessionId": "web"}},
|
||||
{name: "calculator", args: map[string]any{"expression": "1+1"}},
|
||||
{name: "mcp:some-server/tool", args: map[string]any{"anything": true}},
|
||||
{name: "unknown-tool", args: nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
op, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||
if sensitive != tc.sensitive {
|
||||
t.Fatalf("SensitiveCall(%s, %v) sensitive = %v, want %v", tc.name, tc.args, sensitive, tc.sensitive)
|
||||
}
|
||||
if !tc.sensitive {
|
||||
if op != "" || detail != "" {
|
||||
t.Fatalf("SensitiveCall(%s) 非敏感时不应给出类别/摘要,得到 (%q, %q)", tc.name, op, detail)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if op != tc.wantOp {
|
||||
t.Fatalf("SensitiveCall(%s) op = %q, want %q", tc.name, op, tc.wantOp)
|
||||
}
|
||||
if tc.wantIn != "" && !strings.Contains(detail, tc.wantIn) {
|
||||
t.Fatalf("SensitiveCall(%s) detail = %q, want it to contain %q", tc.name, detail, tc.wantIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveCall_SummaryIsClipped 锁定动作摘要的截断:长参数不许整段
|
||||
// 进入小字/审计(同时不切开多字节字符)。
|
||||
func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("昔", 200)
|
||||
_, detail, sensitive := SensitiveCall("terminal", map[string]any{"command": long})
|
||||
if !sensitive {
|
||||
t.Fatal("terminal 应判定为敏感")
|
||||
}
|
||||
if got := len([]rune(detail)); got > sensitiveSummaryRunes+len("terminal ")+1 {
|
||||
t.Fatalf("摘要 rune 数 = %d,未按 %d 截断: %q", got, sensitiveSummaryRunes, detail)
|
||||
}
|
||||
if !strings.HasSuffix(detail, "…") {
|
||||
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user