Files
Cyrene_v2/internal/runtime/orchestration/audit/approval.go
T
AskaEth 20160c66d8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
2026-10-05 12:12:31 +08:00

162 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package audit
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Approver 返回的哨兵错误。用 errors.Is 判断它们。
var (
// ErrActionMissing 在 action 为空时返回。
ErrActionMissing = errors.New("audit: action is missing")
// ErrUnknownActor 在扮演角色的名字为空时返回。本包没有角色注册表,因此空名字是它
// 唯一能检出的「未知」;某个角色是否真的获授权由上游决定。
ErrUnknownActor = errors.New("audit: actor is unknown")
// ErrExplainMissing 在说明为空时返回。
ErrExplainMissing = errors.New("audit: explanation is missing")
)
// reviewerMain 是执行该审查的主 agent 所记录的名字。
const reviewerMain = "main"
// Approval 是对子代理敏感操作的一次主代理代审的不可变记录。
type Approval struct {
// Actor 是扮演角色的名字(角色名)。
Actor string
// Action 是被批准的敏感操作。
Action string
// Description 是对扮演者想做什么的说明(该操作意味着什么)。它命名为
// Description 而非 Explain,因为 Explain 方法(见下)返回的是前端提示短句。
Description string
// Subagent 报告这次调用是否由子代理发起(口径 A 的唯一判定对象)。false 表示
// 这是主会话自身的调用:不加判定、直接放行。
Subagent bool
// Task 是被判定时所依据的「派给该子代理的任务目标」(已截断);非子代理调用为空。
Task string
// Reason 是判定给出的可读理由:判「越界」或判不出来(fail-closed)时说明原因;
// 主会话调用与判定放行时为空。它会进审计与前端小字,绝不静默拒绝。
Reason string
// Allowed 报告该操作是否获准。
Allowed bool
// Reviewer 是执行该审查的主 agent。
Reviewer string
// At 是代审发生的时刻。
At time.Time
}
// Explain 返回描述审查结果的前端提示短句。获准时是
// "「<actor> 想要干什么,我审查后批准了」";被拒时用 "我审查后拒绝了"。<actor> 处会
// 代入 Actor。
func (a Approval) Explain() string {
if a.Allowed {
return "「" + a.Actor + " 想要干什么,我审查后批准了」"
}
return "「" + a.Actor + " 想要干什么,我审查后拒绝了」"
}
// Approver 记录主代理代审事件及其审计日志。
type Approver struct {
log *slog.Logger
// origin 回答「这次调用是否由子代理发起」(口径 A 的开关);nil 表示不做实质判定。
origin SubagentOrigin
// judge 做实质判定;nil 而 origin 说「是子代理发起」时一律拒绝(fail-closed)。
judge CallJudge
// judgeTimeout 是单次判定的时限(默认 judgeTimeout)。
judgeTimeout time.Duration
}
// NewApprover 构建一个经 log 写入审计日志的 Approver。该 logger 会被标注来源
// "orchestration/audit"。允许传入 nil logger,此时 approver 静默记录(无日志输出)。
//
// 组合根经 WithSubagentOrigin / WithCallJudge 装配口径 A 的实质判定;两者都不装配时
// 保持既有行为(每次代审都记一条放行记录)。
func NewApprover(log *slog.Logger, opts ...ApproverOption) *Approver {
a := &Approver{
log: logger.WithSource(log, "orchestration/audit"),
judgeTimeout: judgeTimeout,
}
for _, opt := range opts {
if opt != nil {
opt(a)
}
}
// 装配了判定器却没有来源解析 = 判定永不触发。这属于接线错误,必须显式告警,
// 否则「判定器已装配」会给人一个假的安心(实际一次都没跑)。
if a.judge != nil && a.origin == nil && a.log != nil {
a.log.Warn("代审判定器已装配但没有子代理来源解析,判定不会触发",
"error", ErrNoJudgeOrigin)
}
return a
}
// ReviewAndApprove 校验请求、检查 ctx、实施口径 A 的实质判定,写入结构化审计日志
// (谁 / 什么 / 何时 / 结果 / 理由),并返回记录下来的 approval。
//
// 判定的边界(主人 2026-10-05 裁决):
// - **只对子代理发起的敏感调用**做判定,判「这次调用是否与派给它的任务一致」;
// 主会话自身的调用不加判定、直接放行(否则主人自己显式要求的操作会被拦死)。
// - **fail-closed**:未装配判定器 / 判定失败 / 超时 / 判不出来 ⇒ 不放行,并在
// 返回的 Approval.Reason 里如实写明原因。
// - 判定只**收紧**、绝不**放宽**:某项操作是否真的敏感由上层策略(capability/restrict
// 的结构化判据)先决,本方法及其判定器都不判身份、不判权限。
//
// 入参为空时返回 ErrUnknownActor / ErrActionMissing / ErrExplainMissing,ctx 被取消时
// 包装 ctx.Err()。
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.judgeCall(ctx, ap)
a.logAudit(ap)
return ap, nil
}
// logAudit 写入结构化的「谁/什么/何时/结果/理由」审计记录。它只记判定理由与是否
// 子代理发起,绝不记判定问句里的参数摘要(参数里可能有秘密)。
func (a *Approver) logAudit(ap *Approval) {
if a == nil || 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("subagent", ap.Subagent),
slog.String("reason", ap.Reason),
slog.Bool("allowed", ap.Allowed),
slog.String("reviewer", ap.Reviewer),
slog.Time("at", ap.At),
)
}
// validateApproval 以对应的哨兵错误拒绝空入参。
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
}