Files
Cyrene_v2/internal/runtime/orchestration/audit/approval.go
T

125 lines
4.2 KiB
Go

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
}