diff --git a/internal/runtime/loop/tool_policy.go b/internal/runtime/loop/tool_policy.go index b87bb82..4979e6a 100644 --- a/internal/runtime/loop/tool_policy.go +++ b/internal/runtime/loop/tool_policy.go @@ -3,6 +3,7 @@ package loop import ( "context" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "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" @@ -128,6 +129,37 @@ func notAllowedReason(name string) string { return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation" } +// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门 +// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate +// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne +// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的) +// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。 +// +// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。 +// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed), +// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。 +func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) { + if !l.voiceEnabled() { + return + } + env := sessionEnvFrom(ctx) + if env.ActorEnv == restrict.ActorOwner { + return + } + for i := range calls { + if !actions[i].run || calls[i].Name != speakToolName { + continue + } + reason := notAllowedReason(calls[i].Name) + actions[i] = toolCallAction{run: false, reason: reason} + l.emitAudit(AuditEvent{ + Who: string(env.ActorEnv), + What: auditToolRejectNotAllowed, + Result: reason, + }) + } +} + // emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线 // sink 时为空操作,因此在未挂审计 sink 的叶子测试中 // 安全门仍能工作(软 not_allowed 结果)。 diff --git a/internal/runtime/loop/tool_policy_gating_test.go b/internal/runtime/loop/tool_policy_gating_test.go index 4b20da2..824eb0d 100644 --- a/internal/runtime/loop/tool_policy_gating_test.go +++ b/internal/runtime/loop/tool_policy_gating_test.go @@ -8,7 +8,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) @@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) { } } +// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道 +// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的 +// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 —— +// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计, +// 主人环境照常放行。 +func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) { + t.Parallel() + var mu sync.Mutex + var audited []AuditEvent + l := New(Options{ + VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"}, + VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}}, + OnAudit: func(ev AuditEvent) { + mu.Lock() + audited = append(audited, ev) + mu.Unlock() + }, + }) + + cases := []struct { + env restrict.ActorEnv + allow bool + }{ + {restrict.ActorGroupMember, false}, + {restrict.ActorStranger, false}, + {restrict.ActorOwner, true}, + } + for _, tc := range cases { + mu.Lock() + audited = nil + mu.Unlock() + + calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}} + actions := []toolCallAction{{run: true}} + l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions) + + if actions[0].run != tc.allow { + t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason) + } + mu.Lock() + got := append([]AuditEvent(nil), audited...) + mu.Unlock() + if tc.allow { + if len(got) != 0 { + t.Fatalf("env %s audited %+v, want none", tc.env, got) + } + continue + } + if !strings.Contains(actions[0].reason, "not_allowed") { + t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason) + } + if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) { + t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env) + } + } + + // speak 未接线时没有可执行的旁路,门不该动任何调用。 + actions := []toolCallAction{{run: true}} + New(Options{}).applyToolAdmissions( + WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}), + nil, + []agent.ToolCall{{Name: speakToolName}}, + actions, + ) + if !actions[0].run { + t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason) + } +} + // mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。 func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) { t.Helper() diff --git a/internal/runtime/loop/tools.go b/internal/runtime/loop/tools.go index f966d98..27dce24 100644 --- a/internal/runtime/loop/tools.go +++ b/internal/runtime/loop/tools.go @@ -236,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct // 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计; // 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。② // BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit -// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证 -// 此时整批以错误回灌)。 +// 逐调用否决。 +// +// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎), +// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但 +// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的 +// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate +// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3 +// 的旁路。 func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) { if d != nil { l.applyToolSecurityGate(ctx, d, calls, actions) + } else { + l.applyDispatcherlessVoiceGate(ctx, calls, actions) } if l.hooks.BatchAdmit != nil { if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok { diff --git a/internal/runtime/loop/voice_deps.go b/internal/runtime/loop/voice_deps.go index 9f88044..97933f6 100644 --- a/internal/runtime/loop/voice_deps.go +++ b/internal/runtime/loop/voice_deps.go @@ -89,10 +89,21 @@ func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec { // applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。 // 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」 // 发生在哪一批都算数。 +// +// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的 +// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次 +// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮 +// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回 +// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖: +// 后到的成功就是这条消息最终的语音。 func applyBatchVoice(res *Result, batch *toolRunResult) { - if batch.voice != nil { - res.Voice = batch.voice + if batch.voice == nil { + return } + if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" { + return + } + res.Voice = batch.voice } // captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil diff --git a/internal/runtime/loop/voice_result_test.go b/internal/runtime/loop/voice_result_test.go index 54ae839..c8ce02c 100644 --- a/internal/runtime/loop/voice_result_test.go +++ b/internal/runtime/loop/voice_result_test.go @@ -238,6 +238,39 @@ func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) { } } +// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间 +// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成 +// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。 +// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。 +func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) { + t.Parallel() + delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3} + degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed} + + res := &Result{} + applyBatchVoice(res, &toolRunResult{voice: delivered}) + applyBatchVoice(res, &toolRunResult{voice: degraded}) + if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" { + t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice) + } + + // 先降级后成功:后到的成功就是这条消息最终的语音。 + res = &Result{} + applyBatchVoice(res, &toolRunResult{voice: degraded}) + applyBatchVoice(res, &toolRunResult{voice: delivered}) + if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" { + t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice) + } + + // 后一批没有 speak:前面那批的结论照旧算数。 + res = &Result{} + applyBatchVoice(res, &toolRunResult{voice: delivered}) + applyBatchVoice(res, &toolRunResult{}) + if res.Voice == nil || res.Voice.Name != "abc.wav" { + t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice) + } +} + // TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且 // 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上, // 而不让循环反向 import documents)。