feat: 拦截规则 allow/deny 命中也进审批记录(三种 action 全可观测)

此前只有 ask 命中会写 intercept_pending(审批记录);deny 直接拦下、allow 直接放行,
历史里查不到。现让三种 action 命中都留痕,便于观测:

- db: CreateDecidedIntercept —— 一条 insert 直接以最终状态(allowed/denied)落库并盖
  decided_at(复用 intercept_pending 现有合法状态,无需 schema 迁移)。
- intercept: Interceptor.Log —— 非阻塞、best-effort 记录一次规则命中(DB 出错不影响
  工具调用结果)。
- guard: deny 分支记 denied、显式 allow 规则命中记 allowed;无规则命中的放行不记,
  避免全量刷屏。

前端零改动:审批记录/历史页(interceptHistory=全状态、interceptTask=全状态)本就渲染
allowed/denied/pending;pending 过滤仅用于审批按钮区。
This commit is contained in:
Autumn-27 committed 2026-08-17 10:03:56 -04:00
1 parent 05eb7d2174
commit 6348ec9d21
3 files changed
+40

No files matched your search

+25
View File
@@ -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 {
+4
View File
@@ -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
+11
View File
@@ -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.