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。 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 }