Files
ARTEX/intercept/trace.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

215 lines
6.0 KiB
Go

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", "execution ended but no tool result was received", 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.ModelInput, a.ModelInputDigest = dec.ModelInput, dec.ModelInputDigest
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"
}
// Keep the exact model input for EVERY model verdict, including automatic
// allows. Raw audit history is not model input. Preserve the existing
// lightweight allow-retention policy; render the saved input directly.
a.UserMessage, a.UserTruncated = "", false
a.Context, a.ContextTruncated = nil, false
}
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
}