feat(intercept): 增加审批记录详情与执行审计
This commit is contained in:
1 parent
8dae851b9b
commit
9ad323dc4c
22 files changed
+1687
-535
No files matched your search
+61
-13
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const (
|
||||
contextLimit = 24
|
||||
entryLimit = 8 * 1024
|
||||
promptLimit = 32 * 1024
|
||||
outputLimit = 64 * 1024
|
||||
)
|
||||
|
||||
type traceKey struct{}
|
||||
type callKey struct{}
|
||||
type completion func(status, output string, truncated bool)
|
||||
|
||||
type tracedCall struct {
|
||||
key string
|
||||
audit db.InterceptAudit
|
||||
claimed bool
|
||||
ambiguous bool
|
||||
complete completion
|
||||
}
|
||||
|
||||
// Trace belongs to ONE Prompt invocation. SDK v0.3.6 hooks omit the tool ID;
|
||||
// correlate only when exactly one outstanding event has matching input. Never
|
||||
// guess between simultaneous identical requests, even when results arrive FIFO.
|
||||
type Trace struct {
|
||||
mu sync.Mutex
|
||||
runID string
|
||||
user string
|
||||
userCut bool
|
||||
entries []db.InterceptContextEntry
|
||||
cut bool
|
||||
calls map[string]*tracedCall
|
||||
}
|
||||
|
||||
func WithTrace(ctx context.Context, user string, prior []db.InterceptContextEntry) (context.Context, *Trace) {
|
||||
t := &Trace{runID: rand.Text(), calls: make(map[string]*tracedCall)}
|
||||
t.user, t.userCut = bounded(user, promptLimit)
|
||||
for _, e := range prior {
|
||||
t.append(e)
|
||||
}
|
||||
return context.WithValue(ctx, traceKey{}, t), t
|
||||
}
|
||||
|
||||
func (t *Trace) append(e db.InterceptContextEntry) {
|
||||
var cut bool
|
||||
e.Text, cut = bounded(e.Text, entryLimit)
|
||||
e.Truncated = e.Truncated || cut
|
||||
t.cut = t.cut || e.Truncated
|
||||
t.entries = append(t.entries, e)
|
||||
if len(t.entries) > contextLimit {
|
||||
t.entries = append([]db.InterceptContextEntry(nil), t.entries[len(t.entries)-contextLimit:]...)
|
||||
t.cut = true
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Trace) Append(e db.InterceptContextEntry) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.append(e)
|
||||
}
|
||||
|
||||
func (t *Trace) Start(id, tool string, input []byte) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.calls[id] = &tracedCall{key: tool + ":" + digestInput(input), audit: db.InterceptAudit{
|
||||
RunID: t.runID, ToolUseID: id, Correlation: "exact", InputDigest: digestInput(input),
|
||||
UserMessage: t.user, UserTruncated: t.userCut, CapturedAt: time.Now().UTC(),
|
||||
Context: append([]db.InterceptContextEntry{}, t.entries...), ContextTruncated: t.cut,
|
||||
}}
|
||||
t.append(db.InterceptContextEntry{Kind: "tool_use", Tool: tool, ToolUseID: id, Text: string(input)})
|
||||
}
|
||||
|
||||
// WithCall claims the event before model review starts, so subsequent tools
|
||||
// cannot change this approval's context while the judge is running.
|
||||
func WithCall(ctx context.Context, tool string, input []byte) context.Context {
|
||||
t, _ := ctx.Value(traceKey{}).(*Trace)
|
||||
a := db.InterceptAudit{Correlation: "unavailable", InputDigest: digestInput(input), CapturedAt: time.Now().UTC()}
|
||||
if t != nil {
|
||||
t.mu.Lock()
|
||||
var candidates []*tracedCall
|
||||
for _, c := range t.calls {
|
||||
if !c.claimed && c.key == tool+":"+a.InputDigest {
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 1 && !candidates[0].ambiguous {
|
||||
candidates[0].claimed = true
|
||||
a = candidates[0].audit
|
||||
} else {
|
||||
a.RunID, a.UserMessage, a.UserTruncated = t.runID, t.user, t.userCut
|
||||
if len(candidates) > 0 {
|
||||
a.Correlation = "ambiguous"
|
||||
for _, c := range candidates {
|
||||
c.ambiguous = true
|
||||
}
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return context.WithValue(ctx, callKey{}, a)
|
||||
}
|
||||
|
||||
func (t *Trace) bind(id string, f completion) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c := t.calls[id]; c != nil {
|
||||
c.complete = f
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Trace) Complete(id, output string, isError bool) {
|
||||
t.mu.Lock()
|
||||
c := t.calls[id]
|
||||
delete(t.calls, id)
|
||||
t.mu.Unlock()
|
||||
if c == nil || c.complete == nil {
|
||||
return
|
||||
}
|
||||
status := "succeeded"
|
||||
if isError {
|
||||
status = "failed"
|
||||
}
|
||||
out, cut := bounded(output, outputLimit)
|
||||
c.complete(status, out, cut)
|
||||
}
|
||||
|
||||
// Finish marks missing results unknown, never successful. The tool may have
|
||||
// been interrupted or its final event lost; this is distinct from a tool error.
|
||||
func (t *Trace) Finish() {
|
||||
t.mu.Lock()
|
||||
calls := t.calls
|
||||
t.calls = make(map[string]*tracedCall)
|
||||
t.mu.Unlock()
|
||||
for _, c := range calls {
|
||||
if c.complete != nil {
|
||||
c.complete("unknown", "执行结束但未收到工具结果", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func auditFor(ctx context.Context, dec Decision, input []byte, status string) *db.InterceptAudit {
|
||||
a, ok := ctx.Value(callKey{}).(db.InterceptAudit)
|
||||
if !ok {
|
||||
a = db.InterceptAudit{Correlation: "unavailable", InputDigest: digestInput(input), CapturedAt: time.Now().UTC()}
|
||||
}
|
||||
a.InitialAction, a.InitialReason = dec.Action, dec.Message
|
||||
a.ModelFallback = dec.ModelFallback
|
||||
a.RuleName, a.ConfigDigest, a.ProfileID = dec.RuleName, dec.ConfigDigest, dec.ProfileID
|
||||
a.ExecutionStatus = "not_started"
|
||||
if status == "allowed" {
|
||||
a.EffectiveAction, a.ExecutionStatus = "allow", "awaiting_result"
|
||||
if a.Correlation != "exact" {
|
||||
a.ExecutionStatus = "unknown"
|
||||
}
|
||||
}
|
||||
if status == "denied" {
|
||||
a.EffectiveAction, a.ExecutionStatus = "deny", "not_executed"
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
func (i *Interceptor) bindResult(ctx context.Context, id int64, audit *db.InterceptAudit) {
|
||||
t, _ := ctx.Value(traceKey{}).(*Trace)
|
||||
if t == nil || audit.Correlation != "exact" || audit.ToolUseID == "" {
|
||||
return
|
||||
}
|
||||
t.bind(audit.ToolUseID, func(status, output string, cut bool) {
|
||||
_ = i.db.CompleteIntercept(id, audit.RunID, audit.ToolUseID, status, output, cut)
|
||||
})
|
||||
}
|
||||
|
||||
func digestInput(input []byte) string {
|
||||
var value any
|
||||
d := json.NewDecoder(bytes.NewReader(input))
|
||||
d.UseNumber()
|
||||
if d.Decode(&value) == nil {
|
||||
if canonical, err := json.Marshal(value); err == nil {
|
||||
input = canonical
|
||||
}
|
||||
}
|
||||
h := sha256.Sum256(input)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func bounded(s string, limit int) (string, bool) {
|
||||
if len(s) <= limit {
|
||||
return s, false
|
||||
}
|
||||
end := limit
|
||||
for end > 0 && !utf8.RuneStart(s[end]) {
|
||||
end--
|
||||
}
|
||||
return s[:end], true
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestTraceExactCorrelationAndSnapshot(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "save report", []db.InterceptContextEntry{{Kind: "user", Text: "prior message"}})
|
||||
trace.Start("call-a", "Write", []byte(`{"path":"a","n":12345678901234567890}`))
|
||||
trace.Append(db.InterceptContextEntry{Kind: "text", Text: "later context"})
|
||||
callCtx := WithCall(ctx, "Write", []byte(`{ "n":12345678901234567890, "path":"a" }`))
|
||||
a := auditFor(callCtx, Decision{Action: "allow"}, nil, "allowed")
|
||||
if a.Correlation != "exact" || a.ToolUseID != "call-a" || len(a.Context) != 1 || a.Context[0].Text != "prior message" {
|
||||
t.Fatalf("wrong snapshot: %+v", a)
|
||||
}
|
||||
var got string
|
||||
trace.bind("call-a", func(status, output string, cut bool) { got = status + ":" + output })
|
||||
trace.Complete("different-call", "wrong", false)
|
||||
if got != "" {
|
||||
t.Fatal("unrelated result attached")
|
||||
}
|
||||
trace.Complete("call-a", "written", false)
|
||||
trace.Finish()
|
||||
if got != "succeeded:written" {
|
||||
t.Fatalf("result %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceIdenticalParallelCallsAreNeverGuessed(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "test", nil)
|
||||
input := []byte(`{"command":"pwd"}`)
|
||||
trace.Start("first", "Bash", input)
|
||||
trace.Start("second", "Bash", input)
|
||||
for range 2 {
|
||||
a := auditFor(WithCall(ctx, "Bash", input), Decision{}, input, "pending")
|
||||
if a.Correlation != "ambiguous" || a.ToolUseID != "" {
|
||||
t.Fatalf("guessed identity: %+v", a)
|
||||
}
|
||||
}
|
||||
trace.Complete("first", "first-result", false)
|
||||
a := auditFor(WithCall(ctx, "Bash", input), Decision{}, input, "pending")
|
||||
if a.Correlation != "ambiguous" {
|
||||
t.Fatal("ambiguity must survive the other call completing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceSequentialIdenticalCallsAndRunIsolation(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "test", nil)
|
||||
input := []byte(`{}`)
|
||||
trace.Start("a", "Read", input)
|
||||
a := auditFor(WithCall(ctx, "Read", input), Decision{}, input, "pending")
|
||||
trace.Start("b", "Read", input)
|
||||
b := auditFor(WithCall(ctx, "Read", input), Decision{}, input, "pending")
|
||||
other, next := WithTrace(context.Background(), "next run", nil)
|
||||
next.Start("a", "Read", input)
|
||||
c := auditFor(WithCall(other, "Read", input), Decision{}, input, "pending")
|
||||
if a.ToolUseID != "a" || b.ToolUseID != "b" || a.RunID == c.RunID {
|
||||
t.Fatal("run or call identities overlap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceBoundsAndMissingResult(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), strings.Repeat("中文", promptLimit), nil)
|
||||
for range contextLimit + 5 {
|
||||
trace.Append(db.InterceptContextEntry{Kind: "text", Text: strings.Repeat("中", entryLimit)})
|
||||
}
|
||||
trace.Start("a", "Read", []byte(`{}`))
|
||||
a := auditFor(WithCall(ctx, "Read", []byte(`{}`)), Decision{}, nil, "pending")
|
||||
if !a.UserTruncated || !a.ContextTruncated || len(a.Context) != contextLimit || !utf8.ValidString(a.UserMessage) {
|
||||
t.Fatal("unbounded/invalid snapshot")
|
||||
}
|
||||
for _, e := range a.Context {
|
||||
if len(e.Text) > entryLimit || !utf8.ValidString(e.Text) {
|
||||
t.Fatal("invalid entry bound")
|
||||
}
|
||||
}
|
||||
got := ""
|
||||
trace.bind("a", func(status, _ string, _ bool) { got = status })
|
||||
trace.Finish()
|
||||
if got != "unknown" {
|
||||
t.Fatalf("missing result presented as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceConcurrentDistinctCalls(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "test", nil)
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range []string{"a", "b", "c", "d"} {
|
||||
wg.Go(func() {
|
||||
input := []byte(`{"path":"` + id + `"}`)
|
||||
trace.Start(id, "Read", input)
|
||||
a := auditFor(WithCall(ctx, "Read", input), Decision{}, input, "allowed")
|
||||
if a.ToolUseID != id {
|
||||
t.Errorf("got %s for %s", a.ToolUseID, id)
|
||||
}
|
||||
trace.Complete(id, "ok", false)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in new issue
Block a user