feat(orchestration/audit): 子代理上下文可审计查询(全量/尾部/关键词定位)+ 主代理代审授权(审计日志+小字)

This commit is contained in:
Aska committed 2026-09-06 05:39:06 +08:00
1 parent 71697ca4f8
commit 8b723009f9
5 files changed
+658

No files matched your search

@@ -0,0 +1,124 @@
package audit
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Sentinel errors returned by Approver. Use errors.Is to test for them.
var (
// ErrActionMissing is returned when the action is blank.
ErrActionMissing = errors.New("audit: action is missing")
// ErrUnknownActor is returned when the acting role name is blank. This
// package has no role registry, so a blank name is the only "unknown" it
// can detect; whether a role is truly authorized is decided upstream.
ErrUnknownActor = errors.New("audit: actor is unknown")
// ErrExplainMissing is returned when the explanation is blank.
ErrExplainMissing = errors.New("audit: explanation is missing")
)
// reviewerMain is the name recorded for the main agent that performs the review.
const reviewerMain = "main"
// Approval is the immutable record of a single main-agent review-and-approve of
// a subagent's sensitive action.
type Approval struct {
// Actor is the acting role name (角色名).
Actor string
// Action is the sensitive action being approved.
Action string
// Description is the explanation of what the actor wants to do (what the
// action means). It is named Description rather than Explain because the
// Explain method (below) returns the frontend notice.
Description string
// Allowed reports whether the action was granted.
Allowed bool
// Reviewer is the main agent that performed the review.
Reviewer string
// At is when the review-and-approve happened.
At time.Time
}
// Explain returns the short frontend notice describing the review outcome. For
// an allowed action it is "「<actor> 想要干什么,我审查后批准了」"; for a denied
// action it uses "我审查后拒绝了". Actor is interpolated in place of <actor>.
func (a Approval) Explain() string {
if a.Allowed {
return "「" + a.Actor + " 想要干什么,我审查后批准了」"
}
return "「" + a.Actor + " 想要干什么,我审查后拒绝了」"
}
// Approver records main-agent review-and-approve events and their audit logs.
type Approver struct {
log *slog.Logger
}
// NewApprover builds an Approver that writes audit logs through log. The logger
// is tagged with source "orchestration/audit". A nil logger is allowed, making
// the approver record silently (no log output).
func NewApprover(log *slog.Logger) *Approver {
return &Approver{log: logger.WithSource(log, "orchestration/audit")}
}
// ReviewAndApprove validates the request, checks ctx, writes a structured audit
// log (who / what / when / result), and returns the recorded approval with
// Allowed set to true.
//
// Whether an action is actually sensitive and whether it should be granted is
// decided by the upper policy layer; this method only records, narrates and
// returns the result model. It returns ErrUnknownActor / ErrActionMissing /
// ErrExplainMissing for blank inputs and wraps ctx.Err() when ctx is canceled.
func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe string) (*Approval, error) {
if err := validateApproval(actor, action, describe); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("audit: review-and-approve: %w", err)
}
ap := &Approval{
Actor: actor,
Action: action,
Description: describe,
Allowed: true,
Reviewer: reviewerMain,
At: time.Now(),
}
a.logAudit(ap)
return ap, nil
}
// logAudit writes the structured who/what/when/result audit record.
func (a *Approver) logAudit(ap *Approval) {
if a.log == nil {
return
}
a.log.Info("audit: review-and-approve",
slog.String("actor", ap.Actor),
slog.String("action", ap.Action),
slog.String("explain", ap.Description),
slog.Bool("allowed", ap.Allowed),
slog.String("reviewer", ap.Reviewer),
slog.Time("at", ap.At),
)
}
// validateApproval rejects blank inputs with the matching sentinel.
func validateApproval(actor, action, describe string) error {
if strings.TrimSpace(actor) == "" {
return fmt.Errorf("%w: actor is blank", ErrUnknownActor)
}
if strings.TrimSpace(action) == "" {
return fmt.Errorf("%w: action is blank", ErrActionMissing)
}
if strings.TrimSpace(describe) == "" {
return fmt.Errorf("%w: explanation is blank", ErrExplainMissing)
}
return nil
}
@@ -0,0 +1,146 @@
package audit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// newSilentApprover builds an Approver whose audit log is discarded.
func newSilentApprover() *Approver {
return NewApprover(logger.New(logger.Options{Out: io.Discard}))
}
func TestReviewAndApprove_Records(t *testing.T) {
t.Parallel()
ap, err := newSilentApprover().ReviewAndApprove(context.Background(), "worker", "run ls", "list the project dir")
if err != nil {
t.Fatalf("ReviewAndApprove unexpected error: %v", err)
}
if ap.Actor != "worker" {
t.Fatalf("Actor = %q, want worker", ap.Actor)
}
if ap.Action != "run ls" {
t.Fatalf("Action = %q, want run ls", ap.Action)
}
if ap.Description != "list the project dir" {
t.Fatalf("Description = %q, want list the project dir", ap.Description)
}
if !ap.Allowed {
t.Fatal("Allowed = false, want true")
}
if ap.Reviewer != "main" {
t.Fatalf("Reviewer = %q, want main", ap.Reviewer)
}
if ap.At.IsZero() {
t.Fatal("At is zero, want a timestamp")
}
}
func TestApproval_Explain_Allowed(t *testing.T) {
t.Parallel()
ap := Approval{Actor: "worker", Allowed: true}
if got, want := ap.Explain(), "「worker 想要干什么,我审查后批准了」"; got != want {
t.Fatalf("Explain() = %q, want %q", got, want)
}
}
func TestApproval_Explain_Denied(t *testing.T) {
t.Parallel()
ap := Approval{Actor: "worker", Allowed: false}
if got, want := ap.Explain(), "「worker 想要干什么,我审查后拒绝了」"; got != want {
t.Fatalf("Explain() = %q, want %q", got, want)
}
}
func TestReviewAndApprove_Validation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
actor string
action string
desc string
wantErr error
}{
{name: "blank actor", actor: "", action: "run ls", desc: "list dir", wantErr: ErrUnknownActor},
{name: "space actor", actor: " ", action: "run ls", desc: "list dir", wantErr: ErrUnknownActor},
{name: "blank action", actor: "worker", action: "", desc: "list dir", wantErr: ErrActionMissing},
{name: "blank explain", actor: "worker", action: "run ls", desc: "", wantErr: ErrExplainMissing},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ap, err := newSilentApprover().ReviewAndApprove(context.Background(), tt.actor, tt.action, tt.desc)
if err == nil {
t.Fatalf("ReviewAndApprove returned nil error, want %v", tt.wantErr)
}
if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want Is(%v)", err, tt.wantErr)
}
if ap != nil {
t.Fatalf("Approval = %+v, want nil on validation failure", ap)
}
})
}
}
func TestReviewAndApprove_WritesAuditLog(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}))
_, err := ap.ReviewAndApprove(context.Background(), "worker", "run command", "delete prod db")
if err != nil {
t.Fatalf("ReviewAndApprove unexpected error: %v", err)
}
got := buf.String()
for _, want := range []string{
`"source":"orchestration/audit"`,
`"actor":"worker"`,
`"action":"run command"`,
`"explain":"delete prod db"`,
`"allowed":true`,
`"reviewer":"main"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("audit log missing %s, got: %s", want, got)
}
}
}
func TestReviewAndApprove_CanceledContext(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := newSilentApprover().ReviewAndApprove(ctx, "worker", "run ls", "list dir")
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func TestNewApprover_NilLogger(t *testing.T) {
t.Parallel()
ap, err := NewApprover(nil).ReviewAndApprove(context.Background(), "worker", "run ls", "list dir")
if err != nil {
t.Fatalf("ReviewAndApprove with nil logger returned error: %v", err)
}
if ap == nil || ap.Actor != "worker" || !ap.Allowed {
t.Fatalf("Approval with nil logger not recorded: %+v", ap)
}
}
@@ -0,0 +1,24 @@
// Package audit provides the subagent-auditing primitives specified by the
// Cyrene orchestration design (DevDocs/design/05-子代理.md §3 and §6) and the
// security/audit requirements in DevDocs/design/11-安全与权限.md §4 and §8.
//
// It has two halves:
//
// - ContextAuditor gives a main agent read-only, traceable access to a
// subagent's context (a []model.Message) so it can audit and pinpoint what
// the subagent did: the full context, a tail window, or a keyword hit with
// its surrounding lines. It is a pure value type over the message list the
// orchestral layer hands it; it holds no storage and no policy.
//
// - Approver records a main-agent review-and-approve (代审授权) of a
// subagent's sensitive action. It writes a structured audit log (who / what
// / when / result) and produces the short frontend notice "「xxx 想要干什么,
// 我审查后批准了」". Whether an action is actually sensitive and whether it is
// allowed is decided by the upper policy layer; this package only records,
// narrates, and returns the result model — it never judges permission.
//
// The package is a leaf: it depends only on the standard library,
// internal/runtime/model and internal/logger, and never imports the agent loop
// or other runtime packages, so it can sit inside runtime/orchestration without
// an import cycle.
package audit
@@ -0,0 +1,122 @@
package audit
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// ErrQueryEmpty is returned by ContextAuditor.Keyword when the query is empty
// or blank. Use errors.Is to test for it.
var ErrQueryEmpty = errors.New("audit: query is empty")
// contextRadius is how many messages on each side of a keyword hit are included
// in the surrounding window (one above and one below).
const contextRadius = 1
// Located describes a single keyword match within a subagent context.
type Located struct {
// Line is the 1-based index of the matched message in the full context.
Line int
// Surrounding is a window centered on the matched message: the message
// immediately before it, the matched message, and the message immediately
// after it, clamped at the context boundaries. It always contains the
// matched message.
Surrounding []model.Message
}
// ContextAuditor provides read-only, traceable access to a subagent's context.
// It snapshots the message list at construction so later mutation by the caller
// does not change what the auditor reports. It is a value type that holds no
// storage or policy; the orchestral layer decides which context to inspect.
type ContextAuditor struct {
messages []model.Message
}
// NewContextAuditor snapshots messages so subsequent caller mutations do not
// affect the auditor. A nil input is allowed and yields an empty context.
func NewContextAuditor(messages []model.Message) ContextAuditor {
cp := make([]model.Message, len(messages))
copy(cp, messages)
return ContextAuditor{messages: cp}
}
// Full returns a defensive copy of the entire context.
func (a ContextAuditor) Full() []model.Message {
cp := make([]model.Message, len(a.messages))
copy(cp, a.messages)
return cp
}
// Tail returns the last n messages of the context in their original order.
//
// n <= 0 yields an empty, non-nil slice; n >= len returns a copy of the full
// context. The result is a defensive copy.
func (a ContextAuditor) Tail(n int) []model.Message {
if n <= 0 {
return []model.Message{}
}
if n >= len(a.messages) {
return a.Full()
}
start := len(a.messages) - n
out := make([]model.Message, n)
copy(out, a.messages[start:])
return out
}
// Keyword locates the first message whose text contains query, using a
// case-insensitive substring match against the message's text parts (image
// payloads are ignored). It returns the message's 1-based index and a
// surrounding window, or nil, nil when there is no match.
//
// It returns ErrQueryEmpty for a blank query and wraps ctx.Err() when ctx is
// canceled. Only the earliest match is reported.
func (a ContextAuditor) Keyword(ctx context.Context, query string) (*Located, error) {
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("%w: query is blank", ErrQueryEmpty)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("audit: keyword: %w", err)
}
q := strings.ToLower(query)
for i, m := range a.messages {
if strings.Contains(strings.ToLower(messageText(m)), q) {
return &Located{Line: i + 1, Surrounding: a.window(i)}, nil
}
}
return nil, nil
}
// window returns up to 2*contextRadius+1 messages centered on index i, clamped
// to the context boundaries.
func (a ContextAuditor) window(i int) []model.Message {
lo := i - contextRadius
hi := i + contextRadius
if lo < 0 {
lo = 0
}
if hi >= len(a.messages) {
hi = len(a.messages) - 1
}
if lo > hi {
return nil
}
out := make([]model.Message, 0, hi-lo+1)
out = append(out, a.messages[lo:hi+1]...)
return out
}
// messageText returns the concatenated text of a message's text parts.
func messageText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
b.WriteString(c.Text)
}
}
return b.String()
}
@@ -0,0 +1,242 @@
package audit
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// texts builds a context of text-only messages with the given body texts.
func texts(role model.Role, bodies ...string) []model.Message {
out := make([]model.Message, 0, len(bodies))
for _, b := range bodies {
out = append(out, model.TextMessage(role, b))
}
return out
}
// sameMessage compares a message by role and concatenated text; the test
// messages are text-only, so this is sufficient for equality.
func sameMessage(a, b model.Message) bool {
return a.Role == b.Role && messageText(a) == messageText(b)
}
func TestNewContextAuditor_Snapshot(t *testing.T) {
t.Parallel()
msgs := texts(model.RoleUser, "alpha", "bravo", "charlie")
aud := NewContextAuditor(msgs)
// Mutating the caller's slice after construction must not affect the audit.
msgs[0].Role = model.RoleSystem
got := aud.Full()
if got[0].Role != model.RoleUser {
t.Fatalf("snapshot leaked caller mutation: role = %q, want %q", got[0].Role, model.RoleUser)
}
}
func TestFull_ReturnsDefensiveCopy(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
got := aud.Full()
got[0].Role = model.RoleSystem
got[1].Role = model.RoleSystem
fresh := aud.Full()
if fresh[0].Role != model.RoleUser || fresh[1].Role != model.RoleUser {
t.Fatalf("Full leaked mutation of its returned copy: %+v", fresh)
}
}
func TestTail(t *testing.T) {
t.Parallel()
full := texts(model.RoleUser, "alpha", "bravo", "charlie", "delta")
aud := NewContextAuditor(full)
tests := []struct {
name string
n int
want []string
}{
{name: "zero returns empty", n: 0, want: nil},
{name: "negative returns empty", n: -1, want: nil},
{name: "single", n: 1, want: []string{"delta"}},
{name: "pair", n: 2, want: []string{"charlie", "delta"}},
{name: "exact length", n: 4, want: []string{"alpha", "bravo", "charlie", "delta"}},
{name: "beyond length clamps to full", n: 9, want: []string{"alpha", "bravo", "charlie", "delta"}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := aud.Tail(tt.n)
if len(got) != len(tt.want) {
t.Fatalf("Tail(%d) length = %d, want %d", tt.n, len(got), len(tt.want))
}
for i := range got {
if got[i].Role != model.RoleUser || messageText(got[i]) != tt.want[i] {
t.Fatalf("Tail(%d)[%d] = %q/%s, want text %q", tt.n, i, messageText(got[i]), got[i].Role, tt.want[i])
}
}
})
}
}
func TestKeyword_LocateWithSurroundings(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo", "charlie"))
loc, err := aud.Keyword(context.Background(), "rav")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc == nil {
t.Fatal("Keyword returned nil for a present match")
}
if loc.Line != 2 {
t.Fatalf("Line = %d, want 2", loc.Line)
}
if len(loc.Surrounding) != 3 {
t.Fatalf("Surrounding length = %d, want 3", len(loc.Surrounding))
}
if !sameMessage(loc.Surrounding[1], model.TextMessage(model.RoleUser, "bravo")) {
t.Fatalf("matched message missing from window: %+v", loc.Surrounding)
}
if !sameMessage(loc.Surrounding[0], model.TextMessage(model.RoleUser, "alpha")) {
t.Fatalf("above-message missing from window: %+v", loc.Surrounding)
}
if !sameMessage(loc.Surrounding[2], model.TextMessage(model.RoleUser, "charlie")) {
t.Fatalf("below-message missing from window: %+v", loc.Surrounding)
}
}
func TestKeyword_Edges(t *testing.T) {
t.Parallel()
t.Run("first message has no above-neighbor", func(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
loc, err := aud.Keyword(context.Background(), "alp")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc.Line != 1 {
t.Fatalf("Line = %d, want 1", loc.Line)
}
if len(loc.Surrounding) != 2 {
t.Fatalf("Surrounding length = %d, want 2", len(loc.Surrounding))
}
})
t.Run("last message has no below-neighbor", func(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
loc, err := aud.Keyword(context.Background(), "bra")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc.Line != 2 {
t.Fatalf("Line = %d, want 2", loc.Line)
}
if len(loc.Surrounding) != 2 {
t.Fatalf("Surrounding length = %d, want 2", len(loc.Surrounding))
}
})
}
func TestKeyword_CaseInsensitive(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
loc, err := aud.Keyword(context.Background(), "BRAVO")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc == nil || loc.Line != 2 {
t.Fatalf("case-insensitive match failed: line = %d", loc.Line)
}
}
func TestKeyword_NoMatch(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
loc, err := aud.Keyword(context.Background(), "zzz")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc != nil {
t.Fatalf("Keyword = %+v, want nil for a missing match", loc)
}
}
func TestKeyword_EmptyAndBlankQuery(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha"))
for _, q := range []string{"", " "} {
_, err := aud.Keyword(context.Background(), q)
if !errors.Is(err, ErrQueryEmpty) {
t.Fatalf("Keyword(%q) error = %v, want ErrQueryEmpty", q, err)
}
}
}
func TestKeyword_CanceledContext(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "alpha"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := aud.Keyword(ctx, "alpha")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Keyword error = %v, want context.Canceled", err)
}
}
func TestKeyword_FirstMatchOnly(t *testing.T) {
t.Parallel()
aud := NewContextAuditor(texts(model.RoleUser, "foo", "bar", "foo"))
loc, err := aud.Keyword(context.Background(), "foo")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc.Line != 1 {
t.Fatalf("Line = %d, want 1 (earliest match)", loc.Line)
}
}
func TestKeyword_IgnoresImagePayload(t *testing.T) {
t.Parallel()
msgs := []model.Message{
model.TextMessage(model.RoleUser, "alpha"),
model.ImageMessage(model.RoleUser, "", "http://example.com/bravo.png"),
}
aud := NewContextAuditor(msgs)
loc, err := aud.Keyword(context.Background(), "bravo")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc != nil {
t.Fatalf("Keyword matched an image URL (ignored): %+v", loc)
}
loc, err = aud.Keyword(context.Background(), "alpha")
if err != nil {
t.Fatalf("Keyword unexpected error: %v", err)
}
if loc == nil || loc.Line != 1 {
t.Fatalf("text-part match failed: line = %d", loc.Line)
}
}