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:
1 parent
f63a451b87
commit
95d2309935
14 files changed
+860
-66
No files matched your search
+26
-6
@@ -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
|
||||
|
||||
Reference in new issue
Block a user