diff --git a/db/intercept.go b/db/intercept.go index 0e6f2c7..cc7a902 100644 --- a/db/intercept.go +++ b/db/intercept.go @@ -132,6 +132,31 @@ func (d *DB) DecideInterceptPending(id int64, status string) error { return err } +// CreateDecidedIntercept inserts an intercept_pending row ALREADY in a final state +// (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 string) (int64, error) { + raw := json.RawMessage(input) + if len(raw) == 0 { + raw = json.RawMessage("{}") + } + var convIDPtr *int64 + if convID != 0 { + convIDPtr = &convID + } + var taskIDPtr *string + if taskID != "" { + taskIDPtr = &taskID + } + var id int64 + err := d.QueryRow(` +INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, decided_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id`, + ruleID, convIDPtr, taskIDPtr, agentName, toolName, raw, status).Scan(&id) + return id, err +} + const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, decided_at, created_at` func scanInterceptPending(s interface{ Scan(...any) error }, p *InterceptPending) error { diff --git a/guard/guard.go b/guard/guard.go index 6f3d1fd..b99ac19 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -93,8 +93,12 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result { } switch dec.Action { case "deny": + // 观测:deny 命中不阻塞审批,直接记一条 denied(历史/任务拦截页可见)。 + g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied") return g.block(ev.ToolName, dec.Message, "") case "allow": + // 观测:显式 allow 规则命中记一条 allowed(无规则命中的放行不记,避免全量刷屏)。 + 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 8e71c33..f69fea4 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -329,6 +329,17 @@ func defaultMessage(action, name string) string { } } +// Log records an allow/deny rule match 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 +// 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) +} + // HandleAsk creates a pending approval record and blocks until the user decides // (via /api/intercept/pending/{id}/decide) or the per-rule timeout elapses. // Returns true if the user approved.