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 is the audit "what" for a tool call denied by the // design/11 §3 security gate in a group/stranger environment. const auditToolRejectNotAllowed = "tool.reject.not_allowed" // AuditEvent reports a security-gated tool rejection to the audit sink // (design/11 §3 §8). The loop emits one when it denies a tool call in a // restricted (group/stranger) environment, so the rejection is auditable and the // frontend can render the small "xx 工具在群聊被拒" note. It is wired to the // gateway audit trail via Options.OnAudit. type AuditEvent struct { // Who is the acting actor environment label ("group_member"/"stranger"). Who string // What is the operation, e.g. "tool.reject.not_allowed". What string // Result is the outcome detail — the not_allowed hint naming the denied tool. Result string } // restrictSchemas returns only the tool schemas the session env on ctx may // invoke, per the design/11 §3 security gate: a group/stranger session may // invoke only tools whose editable marker allows that environment (default // closed), while the owner environment keeps the full set. It is the final word // over the capacity allowed-set view — a security red line, not a convenience // hint — so it runs last and can only shrink the surface. func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) []model.ToolSpec { 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 } // applyToolSecurityGate blocks every call whose tool is not allowed in the // session env on ctx (design/11 §3). It mutates actions in place, overriding // each runnable call to a skip carrying the not_allowed hint and emitting an // AuditEvent for the rejection. A nil receiver or nil dispatcher is a no-op. 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 is the soft hint fed back to the model when a restricted // (group/stranger) session invokes a tool whose editable marker denies that // environment (design/11 §3). It is a soft result — injected into the // conversation, never a panic or hard crash — so the model can reassess. func notAllowedReason(name string) string { return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation" } // emitAudit forwards an AuditEvent to the configured OnAudit sink. It is a no-op // when no sink is wired, so the security gate still works (soft not_allowed // result) in leaf tests that do not attach an audit sink. func (l *Loop) emitAudit(ev AuditEvent) { if l.onAudit == nil { return } l.onAudit(ev) }