feat(intercept): 未命中规则的命令接入 LLM 兜底审批

在正则/字符串规则之外新增一套独立的模型兜底层:规则命中直接按规则走;
一条规则都没命中时,才由模型做语义判定(ALLOW/ASK/DENY),人工审批兜底。

- intercept: DefaultJudgePrompt(三态判定提示词)+ ParseVerdict;Reviewer 回调注入;
  Judge() 读全局 llm_judge_* 配置、超时/失败回退、ask 填审批超时、模型判定加 [模型] 前缀
- guard: applyIntercept 未命中分支调 Judge;模型 allow 不落库,deny/ask 带 reason 入库
- db: intercept_pending 加 reason 列(带 ALTER IF NOT EXISTS 补旧库);
  create 函数支持 reason 与 ruleID 0→NULL;历史查询带出 reason
- server: 用 providerForProfile + 单次 Stream 收集注入裁判;GET/PUT /api/intercept/judge
- web: 拦截页拆「拦截规则 / 模型配置」两 tab;拦截范围提为共用信息条;
  模型配置双栏布局(提示词直接展开编辑);审批/任务拦截页标注「模型判定」+ 理由

配置默认值:失败回退 allow、审批超时动作 deny、模型 allow 不落库、
提示词默认即内置模板(清空回退模板)。无需 DB 结构性迁移(judge 走 settings KV)。

Claude-Session: https://claude.ai/code/session_019QXSCDmzimwnjntBNCgUD7
This commit is contained in:
Autumn-27 committed 2026-08-23 10:02:43 -04:00
1 parent f63a451b87
commit 95d2309935
14 files changed
+860 -66

No files matched your search

+26 -6
View File
@@ -14,8 +14,8 @@ import (
"sync"
"time"
"github.com/Autumn-27/norma/hook"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/hook"
)
// AuditEntry records one gated tool call.
@@ -75,12 +75,13 @@ func (g *Guard) preToolUse(ctx context.Context, ev hook.Event) hook.Result {
cmd = in.Text
}
g.record(ev.ToolName, "allow", "", cmd)
return g.applyIntercept(ctx, ev)
return g.applyIntercept(ctx, ev, cmd)
}
// applyIntercept evaluates user-configured intercept rules against the tool call.
// It is called after all built-in safety checks pass.
func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
// It is called after all built-in safety checks pass. cmd is the extracted shell
// surface (empty for non-shell tools), passed to the LLM fallback judge.
func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event, cmd string) hook.Result {
if g.interceptor == nil {
return hook.Result{}
}
@@ -89,7 +90,13 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
}
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
if !matched {
return hook.Result{}
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
// or unwired, keep current behavior and allow.
d, judged := g.interceptor.Judge(ctx, ev.ToolName, judgeSubject(cmd, ev.Input))
if !judged {
return hook.Result{}
}
dec = d
}
switch dec.Action {
case "deny":
@@ -98,7 +105,10 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
return g.block(ev.ToolName, dec.Message, "")
case "allow":
// 观测:显式 allow 规则命中记一条 allowed(无规则命中的放行不记,避免全量刷屏)。
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
// 模型兜底判 allow(RuleID==0)同样不落库,与「未命中直接放行」一致。
if dec.RuleID != 0 {
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
@@ -116,6 +126,16 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
return hook.Result{}
}
// judgeSubject builds the text the LLM fallback judge evaluates: the shell
// command for shell tools, or the raw tool input JSON for others (Write/Edit/…)
// so the model sees what is being written/requested.
func judgeSubject(cmd string, input []byte) string {
if cmd != "" {
return cmd
}
return string(input)
}
var reBlocked = regexp.MustCompile(`(?i)\b(403|forbidden|waf|blocked|rate.?limit|429|captcha|denied)\b`)
// postToolUse is the Observer failure-attribution hook (G5): it classifies tool