diff --git a/README.md b/README.md
index 8716b1c..8ebd416 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,20 @@ AI 自主渗透测试系统(Go 后端 + Next.js 前端)
---
+## 审批记录详情
+
+全局「审批记录」和任务内「拦截审批」支持点击行或展开按钮查看详情。展示结构参考
+[AegisHook 的审批详情组件](https://github.com/RuoJi6/AegisHook/blob/main/web/src/components/CallDetail.vue),沿用 ARTEX 的组件和主题:
+
+- 工具请求与审批裁决左右分栏,手机端上下排列;待审批记录可在详情中允许或拒绝。
+- 展开上下文可查看当前轮输入、会话记录片段、模型或规则初判、执行输出、调用 ID 与参数/审查配置的 SHA-256 指纹。
+- 审批状态和执行结果分别保存。模型直接允许也会记录;重复提交已处理审批返回 HTTP 409。
+- `GET /api/intercept/history/{id}` 按需读取详情,原列表接口不返回上下文和输出。数据库启动迁移自动补列;旧记录显示未记录,旧任务归档可正常恢复。
+
+上下文是工具请求产生时保存的会话记录片段,最多 24 条、每条 8 KiB;当前轮输入最多 32 KiB,执行输出最多 64 KiB,截断会明确标注。
+当前裁判模型仍只接收工具名称和参数,展示的会话上下文不代表模型已使用这些内容作出判断。
+当前 Norma SDK 的审批钩子不包含调用 ID,因此仅在当前运行中能够唯一匹配工具请求事件时关联结果;相同参数的并发调用无法唯一匹配时,会显示未关联,避免错误归属。
+
## 资产同步(ScopeSentry)
支持从 [ScopeSentry](https://github.com/Autumn-27/ScopeSentry) 直接同步资产数据,免去重复收集:
diff --git a/agent/capture.go b/agent/capture.go
index 965a9f3..feb8f1c 100644
--- a/agent/capture.go
+++ b/agent/capture.go
@@ -7,6 +7,7 @@ import (
"unicode/utf8"
"github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
@@ -33,8 +34,13 @@ func captureRun(ctx context.Context, opts agentcore.Options, input string, emit
// multiple prompts on the SAME conversation (e.g. a settlement round that reuses
// the worker's accumulated context after the main run hit max_turns).
func captureRunSession(ctx context.Context, s *agentcore.Session, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
+ ctx, auditTrace := intercept.WithTrace(ctx, input, approvalHistory(s.Messages()))
+ defer auditTrace.Finish()
var reason harness.TerminalReason
rec := func(r db.Activity) {
+ if r.Kind == "text" || r.Kind == "tool_result" {
+ auditTrace.Append(db.InterceptContextEntry{Kind: r.Kind, Tool: r.Tool, ToolUseID: r.ToolUseID, Text: r.Detail, IsError: r.IsError})
+ }
if emit != nil {
emit(r)
}
@@ -90,6 +96,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
in := string(ev.ToolUse.Input)
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
+ auditTrace.Start(ev.ToolUse.ID, ev.ToolUse.Name, ev.ToolUse.Input)
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
case harness.KindToolResult:
@@ -99,6 +106,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
flush()
out := blocksText(ev.ToolResult.Content)
lastTool.done(ev.ToolResult.ToolUseID)
+ auditTrace.Complete(ev.ToolResult.ToolUseID, out, ev.ToolResult.IsError)
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
case harness.KindText:
@@ -184,3 +192,26 @@ func firstLine(s string, max int) string {
}
return s
}
+
+// Preserve the recorded session's visible messages, excluding thinking blocks.
+// This is audit context; it is not presented as input to the existing tool-only judge.
+func approvalHistory(messages []llm.Message) []db.InterceptContextEntry {
+ var entries []db.InterceptContextEntry
+ for _, message := range messages {
+ for _, block := range message.Content {
+ entry := db.InterceptContextEntry{Kind: string(message.Role)}
+ switch block.Type {
+ case llm.BlockText:
+ entry.Text = block.Text
+ case llm.BlockToolUse:
+ entry.Kind, entry.Tool, entry.ToolUseID, entry.Text = "tool_use", block.Name, block.ID, string(block.Input)
+ case llm.BlockToolResult:
+ entry.Kind, entry.ToolUseID, entry.Text, entry.IsError = "tool_result", block.ToolUseID, blocksText(block.Content), block.IsError
+ default:
+ continue
+ }
+ entries = append(entries, entry)
+ }
+ }
+ return entries
+}
diff --git a/agent/capture_approval_test.go b/agent/capture_approval_test.go
new file mode 100644
index 0000000..ac2d447
--- /dev/null
+++ b/agent/capture_approval_test.go
@@ -0,0 +1,131 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/artex/guard"
+ "github.com/Autumn-27/artex/intercept"
+ "github.com/Autumn-27/norma/agentcore"
+ "github.com/Autumn-27/norma/llm"
+ "github.com/Autumn-27/norma/permission"
+ "github.com/Autumn-27/norma/tool"
+)
+
+// Exercise the actual SDK event -> hook -> execution -> result path. No real
+// model or command is used; the probe tool only returns a fixed string.
+func TestCaptureApprovalLifecycle(t *testing.T) {
+ dsn, _, err := db.DSN()
+ if err != nil {
+ t.Skip("no test database configured")
+ }
+ d, err := db.Open(dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = d.Close() })
+ ic := intercept.New(d)
+ priorTools, err := ic.GetEnabledTools()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priorConfig := ic.GetJudgeConfig()
+ t.Cleanup(func() { _ = ic.SetEnabledTools(priorTools); _ = ic.SetJudgeConfig(priorConfig) })
+ if err := ic.SetEnabledTools([]string{"ApprovalAuditProbe"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := ic.SetJudgeConfig(intercept.JudgeConfig{Enabled: true, AskTimeoutSeconds: 1, AskTimeoutAction: "allow"}); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, tc := range []struct {
+ name, action, status, execution string
+ manual, approve, toolError bool
+ }{
+ {"model_fallback", "invalid", "allowed", "succeeded", false, false, false},
+ {"model_allow", "allow", "allowed", "succeeded", false, false, false},
+ {"model_deny", "deny", "denied", "not_executed", false, false, false},
+ {"human_allow_tool_error", "ask", "allowed", "failed", true, true, true},
+ {"human_deny", "ask", "denied", "not_executed", true, false, false},
+ {"timeout_allow", "ask", "timeout", "succeeded", false, false, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ic.SetReviewer(func(context.Context, int64, string, string, string) (intercept.Decision, error) {
+ return intercept.Decision{Action: tc.action, Message: "probe review", ProfileID: 7}, nil
+ })
+ g := guard.NewWithInterceptor(ic)
+ taskID := "approval-lifecycle-" + tc.name
+ t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, taskID) })
+ ctx := intercept.WithTaskContext(t.Context(), taskID, "test-agent", func(a db.Activity) {
+ if a.Kind != "intercept_request" || !tc.manual {
+ return
+ }
+ var detail struct {
+ ID int64 `json:"pending_id"`
+ }
+ if err := json.Unmarshal([]byte(a.Detail), &detail); err != nil {
+ t.Error(err)
+ return
+ }
+ if err := ic.Decide(detail.ID, tc.approve); err != nil {
+ t.Error(err)
+ }
+ })
+ turn, executions := 0, 0
+ provider := captureUsageProvider{stream: func(_ context.Context, yield func(llm.StreamEvent, error) bool) {
+ turn++
+ events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
+ if turn == 1 {
+ events = []llm.StreamEvent{
+ {Type: llm.SEToolUseStart, ToolID: "probe-call", ToolName: "ApprovalAuditProbe"},
+ {Type: llm.SEToolInputJSON, Text: `{}`}, {Type: llm.SEMessageDelta, StopReason: "tool_use"},
+ }
+ }
+ for _, event := range events {
+ if !yield(event, nil) {
+ return
+ }
+ }
+ }}
+ probe := tool.Build(tool.Spec{Name: "ApprovalAuditProbe", Schema: map[string]any{"type": "object"},
+ Run: func(context.Context, json.RawMessage, *tool.ToolContext) (tool.Result, error) {
+ executions++
+ if tc.toolError {
+ return tool.Errorf("probe failed"), nil
+ }
+ return tool.Text("probe succeeded"), nil
+ },
+ })
+ _, _, err := captureRun(ctx, agentcore.Options{Provider: provider, Tools: []tool.CoreTool{probe}, Hooks: g.Hooks(),
+ PermissionMode: permission.ModeBypass, WorkingDir: t.TempDir(), MaxTurns: 2}, "record this review", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows, err := d.ListTaskIntercepts(taskID)
+ if err != nil || len(rows) != 1 {
+ t.Fatalf("rows=%d err=%v", len(rows), err)
+ }
+ detail, err := d.GetInterceptDetail(rows[0].ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := detail.Audit
+ initialAction := tc.action
+ if tc.action == "invalid" {
+ initialAction = "allow"
+ }
+ if detail.Status != tc.status || detail.DecisionSource != "model" || a == nil || a.ExecutionStatus != tc.execution || a.ToolUseID != "probe-call" || a.UserMessage != "record this review" || a.InitialAction != initialAction || a.ModelFallback != (tc.action == "invalid") || a.ProfileID != 7 {
+ t.Fatalf("unexpected review: row=%+v audit=%+v", detail.InterceptApprovalRow, a)
+ }
+ wantCalls := 1
+ if tc.execution == "not_executed" {
+ wantCalls = 0
+ }
+ if executions != wantCalls {
+ t.Fatalf("tool ran %d times, wanted %d", executions, wantCalls)
+ }
+ })
+ }
+}
diff --git a/db/intercept.go b/db/intercept.go
index 8a07868..b2d4ee6 100644
--- a/db/intercept.go
+++ b/db/intercept.go
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"encoding/json"
+ "strings"
"time"
)
@@ -34,6 +35,7 @@ type InterceptPending struct {
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
Status string `json:"status"`
+ DecisionSource string `json:"decision_source"`
Reason string `json:"reason"` // 规则 message 或模型判定理由(前缀 [模型])
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
@@ -106,7 +108,7 @@ func (d *DB) ToggleInterceptRule(id int64, enabled bool) error {
// CreateInterceptPending inserts a pending approval record and returns its ID.
// convID == 0 → conversation_id stored as NULL (background task).
// taskID == "" → task_id stored as NULL.
-func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, toolName string, input []byte, reason string) (int64, error) {
+func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, toolName string, input []byte, reason string, audits ...*InterceptAudit) (int64, error) {
raw := json.RawMessage(input)
if len(raw) == 0 {
raw = json.RawMessage("{}")
@@ -126,9 +128,9 @@ func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, too
}
var id int64
err := d.QueryRow(`
-INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, reason)
-VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
- ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, reason).Scan(&id)
+INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, reason, decision_source, audit)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
+ ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
return id, err
}
@@ -142,7 +144,7 @@ func (d *DB) DecideInterceptPending(id int64, status string) error {
// (status = 'allowed' | 'denied'), decided_at stamped now. Used to log allow/deny
// rule matches for observability — they don't block and need no user action, so unlike
// CreateInterceptPending (which starts 'pending') this records the outcome directly.
-func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, toolName string, input []byte, status, reason string) (int64, error) {
+func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, toolName string, input []byte, status, reason string, audits ...*InterceptAudit) (int64, error) {
raw := json.RawMessage(input)
if len(raw) == 0 {
raw = json.RawMessage("{}")
@@ -162,17 +164,17 @@ func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, too
}
var id int64
err := d.QueryRow(`
-INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at)
-VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) RETURNING id`,
- ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason).Scan(&id)
+INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10) RETURNING id`,
+ ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
return id, err
}
-const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at`
+const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source`
func scanInterceptPending(s interface{ Scan(...any) error }, p *InterceptPending) error {
return s.Scan(&p.ID, &p.RuleID, &p.ConversationID, &p.TaskID, &p.AgentName,
- &p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt)
+ &p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource)
}
// ListPendingIntercepts returns all unresolved approval requests, newest first.
@@ -218,20 +220,45 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
return rows.Scan(
&r.ID, &r.RuleID, &r.ConversationID, &r.TaskID, &r.AgentName,
&r.ToolName, &r.ToolInput, &r.Status, &r.Reason, &r.DecidedAt, &r.CreatedAt,
- &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
+ &r.DecisionSource, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
)
}
-const approvalRowSelect = `
-SELECT ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
- ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at,
+const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
+ ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ip.decision_source,
COALESCE(c.title,'') AS conv_title,
COALESCE(c.agent_key,'') AS conv_agent_key,
- COALESCE(ir.name,'') AS rule_name
+ COALESCE(ir.name,'') AS rule_name`
+
+const approvalRowJoins = `
FROM intercept_pending ip
LEFT JOIN conversations c ON c.id = ip.conversation_id
LEFT JOIN intercept_rules ir ON ir.id = ip.rule_id`
+const approvalRowSelect = `SELECT ` + approvalRowColumns + approvalRowJoins
+const approvalRowSelectWithAudit = `SELECT ` + approvalRowColumns + `, ip.audit` + approvalRowJoins
+
+func interceptSource(ruleID int64, reason string) string {
+ if ruleID != 0 {
+ return "rule"
+ }
+ if strings.HasPrefix(reason, "[模型]") {
+ return "model"
+ }
+ return "unknown"
+}
+
+func firstAudit(audits []*InterceptAudit) any {
+ if len(audits) == 0 || audits[0] == nil {
+ return nil
+ }
+ raw, err := json.Marshal(audits[0])
+ if err != nil {
+ return nil
+ }
+ return raw
+}
+
// ListAllIntercepts returns up to limit intercept_pending rows (newest first)
// joined with conversation and rule info.
func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
diff --git a/db/intercept_detail.go b/db/intercept_detail.go
new file mode 100644
index 0000000..68d8997
--- /dev/null
+++ b/db/intercept_detail.go
@@ -0,0 +1,112 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "time"
+)
+
+// InterceptContextEntry is a bounded, recorded session event, not model reasoning.
+type InterceptContextEntry struct {
+ Kind string `json:"kind"`
+ Tool string `json:"tool,omitempty"`
+ ToolUseID string `json:"tool_use_id,omitempty"`
+ Text string `json:"text"`
+ IsError bool `json:"is_error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// InterceptAudit is captured at review time. It is deliberately excluded from
+// polling/list responses; old rows have no audit instead of reconstructed data.
+type InterceptAudit struct {
+ RunID string `json:"run_id,omitempty"`
+ ToolUseID string `json:"tool_use_id,omitempty"`
+ Correlation string `json:"correlation"` // exact | ambiguous | unavailable
+ InputDigest string `json:"input_digest"`
+ UserMessage string `json:"user_message"`
+ UserTruncated bool `json:"user_truncated,omitempty"`
+ Context []InterceptContextEntry `json:"context"`
+ ContextTruncated bool `json:"context_truncated,omitempty"`
+ CapturedAt time.Time `json:"captured_at"`
+ ModelFallback bool `json:"model_fallback,omitempty"`
+ InitialAction string `json:"initial_action"`
+ InitialReason string `json:"initial_reason"`
+ EffectiveAction string `json:"effective_action,omitempty"`
+ DecisionReason string `json:"decision_reason,omitempty"`
+ RuleName string `json:"rule_name,omitempty"`
+ ConfigDigest string `json:"config_digest,omitempty"`
+ ProfileID int64 `json:"profile_id,omitempty"`
+ ExecutionStatus string `json:"execution_status"`
+ Output string `json:"output,omitempty"`
+ OutputTruncated bool `json:"output_truncated,omitempty"`
+ ExecutionEndedAt *time.Time `json:"execution_ended_at,omitempty"`
+}
+
+type InterceptDetail struct {
+ InterceptApprovalRow
+ Audit *InterceptAudit `json:"audit"`
+}
+
+func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
+ var out InterceptDetail
+ var raw []byte
+ err := d.QueryRow(approvalRowSelectWithAudit+` WHERE ip.id=$1`, id).Scan(
+ &out.ID, &out.RuleID, &out.ConversationID, &out.TaskID, &out.AgentName,
+ &out.ToolName, &out.ToolInput, &out.Status, &out.Reason, &out.DecidedAt, &out.CreatedAt,
+ &out.DecisionSource, &out.ConvTitle, &out.ConvAgentKey, &out.RuleName, &raw,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, &out.Audit); err != nil {
+ return nil, err
+ }
+ }
+ return &out, nil
+}
+
+// ResolveIntercept atomically settles a pending request. A timeout cannot
+// overwrite a human decision and repeat decisions cannot rewrite history.
+func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, error) {
+ execution := "not_executed"
+ if action == "allow" {
+ execution = "awaiting_result"
+ }
+ patch, err := json.Marshal(map[string]any{
+ "effective_action": action, "decision_reason": reason, "execution_status": execution,
+ })
+ if err != nil {
+ return false, err
+ }
+ r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(),
+ audit=CASE WHEN audit IS NULL THEN NULL ELSE audit || $3::jsonb ||
+ CASE WHEN $4='allow' AND audit->>'correlation' IS DISTINCT FROM 'exact'
+ THEN '{"execution_status":"unknown"}'::jsonb ELSE '{}'::jsonb END END
+ WHERE id=$1 AND status='pending'`, id, status, patch, action)
+ if err != nil {
+ return false, err
+ }
+ n, err := r.RowsAffected()
+ return n == 1, err
+}
+
+// CompleteIntercept only updates the exact recorded call after it was allowed.
+// A blocked tool_result must never be presented as a failed execution.
+func (d *DB) CompleteIntercept(id int64, runID, toolUseID, status, output string, truncated bool) error {
+ patch, err := json.Marshal(map[string]any{
+ "execution_status": status, "output": output, "output_truncated": truncated,
+ "execution_ended_at": time.Now().UTC(),
+ })
+ if err != nil {
+ return err
+ }
+ _, err = d.Exec(`UPDATE intercept_pending SET audit=audit || $4::jsonb
+ WHERE id=$1 AND audit->>'run_id'=$2 AND audit->>'tool_use_id'=$3
+ AND audit->>'effective_action'='allow' AND audit->>'execution_status'='awaiting_result'`,
+ id, runID, toolUseID, patch)
+ return err
+}
diff --git a/db/intercept_detail_test.go b/db/intercept_detail_test.go
new file mode 100644
index 0000000..934e38c
--- /dev/null
+++ b/db/intercept_detail_test.go
@@ -0,0 +1,167 @@
+package db
+
+import (
+ "encoding/json"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+func TestInterceptDetails(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = d.Close() })
+ create := func(t *testing.T, audit *InterceptAudit) int64 {
+ t.Helper()
+ id, err := d.CreateInterceptPending(0, 0, "approval-detail-test", "test", "Write", []byte(`{"path":"report.md"}`), "[模型] 请确认", audit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE id=$1`, id) })
+ return id
+ }
+ t.Run("legacy and lazy payload", func(t *testing.T) {
+ id := create(t, nil)
+ got, err := d.GetInterceptDetail(id)
+ if err != nil || got == nil || got.Audit != nil {
+ t.Fatalf("legacy: %+v %v", got, err)
+ }
+ create(t, &InterceptAudit{UserMessage: "snapshot-only-marker", InitialAction: "ask"})
+ items, err := d.ListTaskIntercepts("approval-detail-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(items)
+ if strings.Contains(string(raw), "snapshot-only-marker") || strings.Contains(string(raw), `"audit"`) {
+ t.Fatal("snapshot leaked into list response")
+ }
+ missing, err := d.GetInterceptDetail(-1)
+ if err != nil || missing != nil {
+ t.Fatal("missing record not reported")
+ }
+ })
+ t.Run("decision race and exact output", func(t *testing.T) {
+ id := create(t, &InterceptAudit{RunID: "run", ToolUseID: "call", Correlation: "exact", InitialAction: "ask", ExecutionStatus: "not_started"})
+ var wins atomic.Int32
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Go(func() {
+ ok, err := d.ResolveIntercept(id, "allowed", "allow", "人工允许执行")
+ if err != nil {
+ t.Error(err)
+ }
+ if ok {
+ wins.Add(1)
+ }
+ })
+ }
+ wg.Wait()
+ if wins.Load() != 1 {
+ t.Fatalf("%d decisions won", wins.Load())
+ }
+ ok, err := d.ResolveIntercept(id, "timeout", "deny", "late timeout")
+ if err != nil || ok {
+ t.Fatal("timeout overwrote decision")
+ }
+ if err := d.CompleteIntercept(id, "different-run", "call", "succeeded", "WRONG", false); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := d.GetInterceptDetail(id)
+ if got.Audit.Output != "" {
+ t.Fatal("cross-run result attached")
+ }
+ if err := d.CompleteIntercept(id, "run", "call", "failed", "permission denied", true); err != nil {
+ t.Fatal(err)
+ }
+ got, err = d.GetInterceptDetail(id)
+ if err != nil || got.Status != "allowed" || got.Audit.InitialAction != "ask" || got.Audit.ExecutionStatus != "failed" || !got.Audit.OutputTruncated {
+ t.Fatalf("wrong details: %+v %v", got, err)
+ }
+ if err := d.CompleteIntercept(id, "run", "call", "succeeded", "late duplicate", false); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = d.GetInterceptDetail(id)
+ if got.Audit.Output != "permission denied" {
+ t.Fatal("duplicate result rewrote output")
+ }
+ })
+ t.Run("denied output and timeout allow", func(t *testing.T) {
+ id := create(t, &InterceptAudit{RunID: "run", ToolUseID: "call", ExecutionStatus: "not_started"})
+ if _, err := d.ResolveIntercept(id, "denied", "deny", "人工拒绝"); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.CompleteIntercept(id, "run", "call", "failed", "Blocked by hook", false); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := d.GetInterceptDetail(id)
+ if got.Audit.ExecutionStatus != "not_executed" || got.Audit.Output != "" {
+ t.Fatal("denial presented as executed")
+ }
+ id = create(t, &InterceptAudit{RunID: "run2", ToolUseID: "call2", Correlation: "exact", InitialAction: "ask"})
+ if _, err := d.ResolveIntercept(id, "timeout", "allow", "超时允许"); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.CompleteIntercept(id, "run2", "call2", "succeeded", "ok", false); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = d.GetInterceptDetail(id)
+ if got.Status != "timeout" || got.Audit.EffectiveAction != "allow" || got.Audit.ExecutionStatus != "succeeded" {
+ t.Fatal("timeout action lost")
+ }
+ })
+ t.Run("archive compatibility", func(t *testing.T) {
+ for _, legacy := range []bool{true, false} {
+ id := create(t, &InterceptAudit{InitialAction: "ask", ExecutionStatus: "not_started"})
+ var raw []byte
+ if err := d.QueryRow(`SELECT row_to_json(ip) FROM intercept_pending ip WHERE id=$1`, id).Scan(&raw); err != nil {
+ t.Fatal(err)
+ }
+ var row map[string]any
+ if err := json.Unmarshal(raw, &row); err != nil {
+ t.Fatal(err)
+ }
+ if legacy {
+ delete(row, "audit")
+ delete(row, "decision_source")
+ }
+ archived, _ := json.Marshal([]map[string]any{row})
+ tx, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`DELETE FROM intercept_pending WHERE id=$1`, id); err != nil {
+ t.Fatal(err)
+ }
+ if err := restoreInterceptRows(tx, archived); err != nil {
+ t.Fatalf("legacy=%t: %v", legacy, err)
+ }
+ var status, source string
+ var auditJSON []byte
+ if err := tx.QueryRow(`SELECT status, decision_source, audit FROM intercept_pending WHERE id=$1`, id).Scan(&status, &source, &auditJSON); err != nil {
+ t.Fatal(err)
+ }
+ if status != "timeout" || source != "model" {
+ t.Fatalf("restored %s/%s", status, source)
+ }
+ if legacy && len(auditJSON) > 0 {
+ t.Fatal("fabricated legacy audit")
+ }
+ if !legacy {
+ var a InterceptAudit
+ if err := json.Unmarshal(auditJSON, &a); err != nil {
+ t.Fatal(err)
+ }
+ if a.InitialAction != "ask" || a.EffectiveAction != "deny" || a.ExecutionStatus != "not_executed" {
+ t.Fatalf("restored audit: %+v", a)
+ }
+ }
+ if err := tx.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+}
diff --git a/db/schema.sql b/db/schema.sql
index 4248569..ff34e38 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -1024,6 +1024,11 @@ CREATE INDEX IF NOT EXISTS idx_intercept_pending_status ON intercept_pending(sta
CREATE INDEX IF NOT EXISTS idx_intercept_pending_task ON intercept_pending(task_id, created_at DESC);
-- 补旧库:reason 列(已发版,加列要带 IF NOT EXISTS)。
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT '';
+-- Detail payloads are lazy-loaded; NULL preserves the meaning of legacy history.
+ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
+ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
+UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
+ WHEN reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
-- =====================================================================
-- L. 漏洞发现持久化
diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go
index 71e916a..acf0d3d 100644
--- a/db/task_archives_restore.go
+++ b/db/task_archives_restore.go
@@ -634,10 +634,28 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
return err
}
for _, row := range rows {
+ // Archives predating approval snapshots lack this NOT NULL column.
+ if source, _ := row["decision_source"].(string); source == "" {
+ source = "unknown"
+ if row["rule_id"] != nil {
+ source = "rule"
+ } else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[模型]") {
+ source = "model"
+ }
+ row["decision_source"] = source
+ }
if status, _ := row["status"].(string); status == "pending" {
row["status"] = "timeout"
row["reason"] = "任务归档期间审批已超时"
row["decided_at"] = time.Now().UTC()
+ if audit, ok := row["audit"].(map[string]any); ok {
+ audit["effective_action"] = "deny"
+ audit["decision_reason"] = "任务归档期间审批已超时"
+ audit["execution_status"] = "not_executed"
+ }
+ } else if audit, ok := row["audit"].(map[string]any); ok && audit["execution_status"] == "awaiting_result" {
+ // An archived run cannot resume its former result callback.
+ audit["execution_status"] = "unknown"
}
}
if len(rows) == 0 {
diff --git a/guard/guard.go b/guard/guard.go
index 9e13f9c..b2d67c4 100644
--- a/guard/guard.go
+++ b/guard/guard.go
@@ -88,6 +88,7 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event, cmd string) h
if !g.interceptor.IsToolEnabled(ev.ToolName) {
return hook.Result{}
}
+ ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input)
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
if !matched {
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
@@ -104,11 +105,8 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event, cmd string) h
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied")
return g.block(ev.ToolName, dec.Message, "")
case "allow":
- // 观测:显式 allow 规则命中记一条 allowed(无规则命中的放行不记,避免全量刷屏)。
- // 模型兜底判 allow(RuleID==0)同样不落库,与「未命中直接放行」一致。
- if dec.RuleID != 0 {
- g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
- }
+ // Record explicit rule and model approvals so review details remain auditable.
+ g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
return hook.Result{}
case "ask":
// If the worker context is already cancelled (task stopped / killed), block
diff --git a/intercept/intercept.go b/intercept/intercept.go
index 0d718cc..0fb7b9a 100644
--- a/intercept/intercept.go
+++ b/intercept/intercept.go
@@ -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
}
diff --git a/intercept/trace.go b/intercept/trace.go
new file mode 100644
index 0000000..ea5caae
--- /dev/null
+++ b/intercept/trace.go
@@ -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
+}
diff --git a/intercept/trace_test.go b/intercept/trace_test.go
new file mode 100644
index 0000000..b736164
--- /dev/null
+++ b/intercept/trace_test.go
@@ -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()
+}
diff --git a/server/intercept.go b/server/intercept.go
index 5300aaf..76a34dc 100644
--- a/server/intercept.go
+++ b/server/intercept.go
@@ -2,6 +2,7 @@ package server
import (
"context"
+ "errors"
"fmt"
"net/http"
"regexp"
@@ -35,15 +36,15 @@ func (s *Server) wireInterceptReviewer() {
}
prov, _, ok := s.providerForProfile(profileID)
if !ok {
- return intercept.Decision{}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
+ return intercept.Decision{ProfileID: profileID}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
}
user := fmt.Sprintf("Tool: %s\nArguments:\n%s", tool, command)
text, err := streamCollectText(ctx, prov, prompt, user)
if err != nil {
- return intercept.Decision{}, err
+ return intercept.Decision{ProfileID: profileID}, err
}
v := intercept.ParseVerdict(text)
- return intercept.Decision{Action: v.Action, Message: v.Reason}, nil
+ return intercept.Decision{Action: v.Action, Message: v.Reason, ProfileID: profileID}, nil
})
}
@@ -278,6 +279,10 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.m.interceptor.Decide(id, req.Decision == "allowed"); err != nil {
+ if errors.Is(err, intercept.ErrAlreadyDecided) {
+ writeErr(w, 409, err.Error())
+ return
+ }
writeErr(w, 500, err.Error())
return
}
@@ -396,3 +401,25 @@ func validateInterceptRuleReq(req interceptRuleReq) error {
}
return nil
}
+
+func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) {
+ pg := s.pg(w)
+ if pg == nil {
+ return
+ }
+ id, ok := pathInt(r, "id")
+ if !ok || id <= 0 {
+ writeErr(w, 400, "bad approval id")
+ return
+ }
+ detail, err := pg.GetInterceptDetail(id)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ if detail == nil {
+ writeErr(w, 404, "not found")
+ return
+ }
+ writeJSON(w, 200, detail)
+}
diff --git a/server/intercept_detail_test.go b/server/intercept_detail_test.go
new file mode 100644
index 0000000..7ba0881
--- /dev/null
+++ b/server/intercept_detail_test.go
@@ -0,0 +1,72 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/artex/intercept"
+)
+
+func TestInterceptDetailHTTP(t *testing.T) {
+ dsn, _, err := db.DSN()
+ if err != nil {
+ t.Skip("no test database configured")
+ }
+ d, err := db.Open(dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = d.Close() })
+ // Exercise the authenticated HTTP surface without starting unrelated task
+ // schedulers or mutating the process-global tool assembly used by other tests.
+ m := &Manager{pg: d, interceptor: intercept.New(d)}
+ s := &Server{m: m, jwtKey: []byte("approval-http-test-signing-key")}
+ h := s.Handler()
+ token, err := signJWT(s.jwtKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := m.pg.CreateInterceptPending(0, 0, "approval-http", "test", "Write", []byte(`{}`), "[模型] 确认", &db.InterceptAudit{InitialAction: "ask", UserMessage: "snapshot", ExecutionStatus: "not_started"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = m.pg.Exec(`DELETE FROM intercept_pending WHERE id=$1`, id) })
+ do := func(method, path, body string, authenticated bool) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ if authenticated {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+ }
+ path := fmt.Sprintf("/api/intercept/history/%d", id)
+ if r := do(http.MethodGet, path, "", false); r.Code != 401 {
+ t.Fatalf("unprotected detail: %d", r.Code)
+ }
+ r := do(http.MethodGet, path, "", true)
+ var detail db.InterceptDetail
+ if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &detail) != nil || detail.Audit == nil || detail.Audit.UserMessage != "snapshot" {
+ t.Fatalf("detail: %d %s", r.Code, r.Body.String())
+ }
+ for _, tc := range []struct {
+ path string
+ code int
+ }{{"/api/intercept/history/not-a-number", 400}, {"/api/intercept/history/0", 400}, {"/api/intercept/history/9223372036854775807", 404}} {
+ if r := do(http.MethodGet, tc.path, "", true); r.Code != tc.code {
+ t.Fatalf("%s: %d", tc.path, r.Code)
+ }
+ }
+ decisionPath := fmt.Sprintf("/api/intercept/pending/%d/decide", id)
+ if r := do(http.MethodPost, decisionPath, `{"decision":"denied"}`, true); r.Code != 200 {
+ t.Fatalf("decide: %d %s", r.Code, r.Body.String())
+ }
+ if r := do(http.MethodPost, decisionPath, `{"decision":"allowed"}`, true); r.Code != 409 {
+ t.Fatalf("duplicate decision: %d", r.Code)
+ }
+}
diff --git a/server/server.go b/server/server.go
index 3f31803..0e0b30c 100644
--- a/server/server.go
+++ b/server/server.go
@@ -949,6 +949,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
mux.HandleFunc("GET /api/intercept/history", s.interceptHistory)
+ mux.HandleFunc("GET /api/intercept/history/{id}", s.interceptDetail)
mux.HandleFunc("GET /api/intercept/task/{taskID}", s.interceptListTaskItems)
mux.HandleFunc("GET /api/intercept/tool-config", s.interceptGetToolConfig)
mux.HandleFunc("PUT /api/intercept/tool-config", s.interceptSetToolConfig)
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx
index f566d52..e2a4ee4 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx
@@ -1,241 +1,7 @@
"use client";
-import * as React from "react";
-import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
-import { toast } from "sonner";
-import { api } from "@/lib/api";
-import type { InterceptApprovalRow } from "@/lib/types";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { ApprovalRecords } from "@/components/approval-records";
-function fmtTime(s: string) {
- if (!s) return "—";
- return new Date(s).toLocaleString("zh-CN", {
- month: "2-digit", day: "2-digit",
- hour: "2-digit", minute: "2-digit", second: "2-digit",
- });
-}
-
-function AgentChip({ name }: { name: string }) {
- if (!name) return —;
- const color =
- name === "planner" ? "bg-amber-500" :
- name === "mainagent" ? "bg-primary" :
- "bg-sky-600";
- return (
-
- {name}
-
- );
-}
-
-function StatusBadge({ status }: { status: string }) {
- if (status === "pending") return
- {reason} -
- )} -{short}
- {full}
- 此任务暂无拦截审批记录
-- {reason} -
- )} -
- {short}
-
- {full}
- + {reason || "未记录理由"} +
+ ) : null} +
+ {text || "未记录"}
+
+ {truncated ? 内容已截断,以上为保存的片段。
: null} ++ 执行结果:{detail ? execution : "加载中…"} +
++ {current.reason?.replace(/^\[模型\]\s*/, "") || "未记录审批理由"} +
+ {audit?.decision_reason ?{audit.decision_reason}
: null} + {audit?.effective_action ?最终动作:{actionLabels[audit.effective_action]}
: null} ++ 保存于 {fmtTime(audit.captured_at)} 的会话记录片段。模型审批目前仅接收工具名称和参数。 +
+ {audit.context_truncated ? ( +仅保存最近的上下文,部分内容已截断。
+ ) : null} + {audit.context?.length ? ( + audit.context.map((entry, index) => ( +未记录可关联的上下文
+ )} +展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。
+ {error ? ( +