feat(intercept): 增加审批记录详情与执行审计

This commit is contained in:
ji ruo committed 2026-09-10 15:10:50 +08:00
1 parent 8dae851b9b
commit 9ad323dc4c
22 files changed
+1687 -535

No files matched your search

+61 -13
View File
@@ -14,6 +14,7 @@ package intercept
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
@@ -282,6 +283,10 @@ func (i *Interceptor) SetEnabledTools(tools []string) error {
// Decision is the outcome of a successful rule match.
type Decision struct {
ModelFallback bool
RuleName string
ConfigDigest string
ProfileID int64
Action string // "allow" | "deny" | "ask"
Message string
RuleID int64
@@ -437,16 +442,22 @@ func (i *Interceptor) Judge(ctx context.Context, tool, command string) (Decision
out, err := rv(cctx, cfg.ProfileID, cfg.Prompt, tool, command)
if err != nil {
out = Decision{Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
}
switch out.Action {
case "allow", "ask", "deny":
// valid verdict
default:
out = Decision{Action: cfg.FailAction, Message: "模型输出无法解析,按失败策略处理"}
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型输出无法解析,按失败策略处理"}
}
// A model verdict never carries a rule; keep RuleID 0 (→ NULL) for history.
out.RuleID = 0
if out.ProfileID != 0 {
cfg.ProfileID = out.ProfileID
}
out.ProfileID = cfg.ProfileID
configJSON, _ := json.Marshal(cfg)
out.ConfigDigest = digestInput(configJSON)
if out.Message == "" {
out.Message = "[模型] " + judgeActionLabel(out.Action)
} else if !strings.HasPrefix(out.Message, "[模型]") {
@@ -487,7 +498,9 @@ func (i *Interceptor) Match(toolName string, input []byte) (Decision, bool) {
if msg == "" {
msg = defaultMessage(r.Action, r.Name)
}
configJSON, _ := json.Marshal(r.InterceptRule)
return Decision{
RuleName: r.Name, ConfigDigest: digestInput(configJSON),
Action: r.Action,
Message: msg,
RuleID: r.ID,
@@ -527,15 +540,19 @@ func defaultMessage(action, name string) string {
}
}
// Log records an allow/deny rule match into intercept_pending as an ALREADY-decided
// Log records an allow/deny rule or model decision into intercept_pending as an ALREADY-decided
// row (status = "allowed" | "denied"), for observability. Unlike HandleAsk it does NOT
// block and needs no user action — it just makes every rule hit visible on the history
// block and needs no user action — it makes explicit review decisions visible on the history
// page (GET /api/intercept/history) and the task's intercept list. Best-effort: a DB
// error is swallowed so logging never changes the tool call's outcome. The pending list
// (status='pending') is unaffected, so it still shows only asks awaiting a decision.
func (i *Interceptor) Log(ctx context.Context, convID int64, dec Decision, toolName string, input []byte, status string) {
taskID, agentName := taskInfoFromCtx(ctx)
_, _ = i.db.CreateDecidedIntercept(dec.RuleID, convID, taskID, agentName, toolName, input, status, dec.Message)
audit := auditFor(ctx, dec, input, status)
id, err := i.db.CreateDecidedIntercept(dec.RuleID, convID, taskID, agentName, toolName, input, status, dec.Message, audit)
if err == nil {
i.bindResult(ctx, id, audit)
}
}
// HandleAsk creates a pending approval record and blocks until the user decides
@@ -551,11 +568,22 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
taskID, agentName := taskInfoFromCtx(ctx)
taskEmit := taskEmitFromCtx(ctx)
pendingID, err := i.db.CreateInterceptPending(ruleID, convID, taskID, agentName, toolName, input, dec.Message)
audit := auditFor(ctx, dec, input, "pending")
pendingID, err := i.db.CreateInterceptPending(ruleID, convID, taskID, agentName, toolName, input, dec.Message, audit)
if err != nil {
return false
}
i.bindResult(ctx, pendingID, audit)
ch := i.pending.add(pendingID)
defer i.pending.remove(pendingID)
// Polling clients can decide after INSERT commits but before the channel is
// registered. Re-read after registration so that decision cannot be lost;
// later decisions will be delivered through ch.
if saved, err := i.db.GetInterceptDetail(pendingID); err == nil && saved != nil && saved.Status != "pending" {
return saved.Status == "allowed" || (saved.Audit != nil && saved.Audit.EffectiveAction == "allow")
}
detail, _ := json.Marshal(map[string]any{
"pending_id": pendingID,
"tool": toolName,
@@ -576,16 +604,14 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
taskEmit(activity)
}
ch := i.pending.add(pendingID)
defer i.pending.remove(pendingID)
if !dec.TimeoutEnabled {
// No timeout: wait indefinitely until user decides or worker stops.
select {
case allowed := <-ch:
return allowed
case <-ctx.Done():
_ = i.db.DecideInterceptPending(pendingID, "denied")
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "工作已取消")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "执行前工作已取消", false)
return false
}
}
@@ -601,23 +627,45 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
return allowed
case <-timer.C:
allowed := dec.TimeoutAction == "allow"
_ = i.db.DecideInterceptPending(pendingID, "timeout")
action := "deny"
if allowed {
action = "allow"
}
resolved, err := i.db.ResolveIntercept(pendingID, "timeout", action, "审批超时,按超时策略处理")
if err != nil {
return false
}
if !resolved {
detail, err := i.db.GetInterceptDetail(pendingID)
return err == nil && detail != nil && (detail.Status == "allowed" || (detail.Audit != nil && detail.Audit.EffectiveAction == "allow"))
}
return allowed
case <-ctx.Done():
_ = i.db.DecideInterceptPending(pendingID, "denied")
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "工作已取消")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "执行前工作已取消", false)
return false
}
}
var ErrAlreadyDecided = errors.New("审批已处理或不存在,请刷新记录")
// Decide resolves a pending request. Called by the HTTP decide endpoint.
func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
status := "denied"
if allowed {
status = "allowed"
}
if err := i.db.DecideInterceptPending(pendingID, status); err != nil {
action, reason := "deny", "人工拒绝执行"
if allowed {
action, reason = "allow", "人工允许执行"
}
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason)
if err != nil {
return err
}
if !resolved {
return ErrAlreadyDecided
}
i.pending.resolve(pendingID, allowed)
return nil
}