Files
ARTEX-chixuweihu/intercept/intercept.go
T
nftpixels 4d66a84ac5 fix(intercept): match tool_input rules against decoded values
tool_input rules were only matched against the raw JSON of the tool call.
JSON escapes newlines and tabs, so a multi-line command reaches the rules as
`cd /tmp\nrm -rf /var/www`. There is no word boundary between the `n` of `\n`
and `rm`, so the built-in `\brm\b` / `\bmkfs\b` / `\bDROP ...` rules never
fire on a dangerous command that starts a new line (heredocs, small scripts).
Quotes (`\"`) and, for Go-encoded input, & < > (`&` etc.) have the same
problem.

Decode the input once per Match and also try each string value. The raw
JSON is still checked first, so existing rules written against the JSON
form keep matching; the change can only add matches.
2026-11-14 11:16:27 +13:00

793 lines
26 KiB
Go

// Package intercept implements the user-configurable tool-call interception layer.
// Rules are loaded from the database, cached in memory, and evaluated in priority
// order (highest first) on every PreToolUse event. Three actions are supported:
//
// - allow: immediately permits the call, skipping lower-priority rules.
// - deny: blocks the call and returns a message to the model.
// - ask: blocks the call, creates an intercept_pending record, writes an
// activity to the active conversation, then waits for the user to approve or
// deny via the /api/intercept/pending/{id}/decide endpoint.
//
// The timeout behaviour is configurable at runtime via SetTimeoutConfig.
package intercept
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/Autumn-27/artex/db"
)
// ctxKey is the unexported context key type to avoid collisions.
type ctxKey int
// ConvIDKey stores the active conversation ID in a context.Context so the
// interceptor can associate "ask" pending records with the right conversation.
const ConvIDKey ctxKey = 0
// WithConvID returns a child context carrying convID.
func WithConvID(ctx context.Context, convID int64) context.Context {
return context.WithValue(ctx, ConvIDKey, convID)
}
// ConvIDFromContext extracts the conversation ID (0 if absent).
func ConvIDFromContext(ctx context.Context) int64 {
v, _ := ctx.Value(ConvIDKey).(int64)
return v
}
// taskCtxKey is a separate unexported key type for task context values.
type taskCtxKey int
const (
taskInfoCtxKey taskCtxKey = 1
taskEmitCtxKey taskCtxKey = 2
)
type taskCtxInfo struct{ taskID, agentName string }
// WithTaskContext injects task metadata and an emit function into ctx so that
// HandleAsk can tag pending records and write intercept_request activities to
// the task's exploration stream (making them appear inline in session transcripts).
func WithTaskContext(ctx context.Context, taskID, agentName string, emit func(db.Activity)) context.Context {
ctx = context.WithValue(ctx, taskInfoCtxKey, taskCtxInfo{taskID, agentName})
if emit != nil {
ctx = context.WithValue(ctx, taskEmitCtxKey, emit)
}
return ctx
}
func taskInfoFromCtx(ctx context.Context) (taskID, agentName string) {
if v, ok := ctx.Value(taskInfoCtxKey).(taskCtxInfo); ok {
return v.taskID, v.agentName
}
return "", ""
}
func taskEmitFromCtx(ctx context.Context) func(db.Activity) {
f, _ := ctx.Value(taskEmitCtxKey).(func(db.Activity))
return f
}
// compiledRule is an InterceptRule with the regex pre-compiled (nil for string rules).
type compiledRule struct {
db.InterceptRule
re *regexp.Regexp
}
// pendingManager tracks in-flight "ask" requests via per-request channels.
type pendingManager struct {
mu sync.Mutex
ch map[int64]chan bool
}
func newPendingManager() *pendingManager { return &pendingManager{ch: map[int64]chan bool{}} }
func (p *pendingManager) add(id int64) chan bool {
ch := make(chan bool, 1)
p.mu.Lock()
p.ch[id] = ch
p.mu.Unlock()
return ch
}
func (p *pendingManager) resolve(id int64, allowed bool) {
p.mu.Lock()
ch, ok := p.ch[id]
delete(p.ch, id)
p.mu.Unlock()
if ok {
ch <- allowed
}
}
func (p *pendingManager) remove(id int64) {
p.mu.Lock()
delete(p.ch, id)
p.mu.Unlock()
}
// Reviewer runs the LLM fallback judge for one tool call and returns its verdict
// as a Decision (Action ∈ allow|ask|deny; empty Action means the reply could not
// be parsed). It is injected by the server layer so the intercept package stays
// free of any llm dependency. profileID == 0 means "use the active/default profile".
type Reviewer func(ctx context.Context, profileID int64, prompt string, input ReviewInput) (Decision, error)
// Interceptor loads intercept rules from the database and evaluates them on
// tool calls. It is safe for concurrent use.
type Interceptor struct {
db *db.DB
mu sync.RWMutex
cached []compiledRule // sorted by priority DESC; nil means not loaded yet
enabledTools map[string]bool // nil means not loaded yet
pending *pendingManager
reviewer Reviewer // nil = LLM fallback judge not wired
}
// SetReviewer installs the LLM fallback judge callback. Passing nil disables it.
func (i *Interceptor) SetReviewer(r Reviewer) {
i.mu.Lock()
i.reviewer = r
i.mu.Unlock()
}
// defaultEnabledTools is the hard-coded set of tools that enter the intercept
// rule system when no intercept_enabled_tools setting has been saved.
var defaultEnabledTools = []string{
"Bash", "WebFetch", "web_search",
"shell_open", "shell_send",
"Write", "Edit", "MultiEdit",
}
// New creates an Interceptor backed by d. The rule cache is lazy-loaded on
// first use.
func New(d *db.DB) *Interceptor {
return &Interceptor{db: d, pending: newPendingManager()}
}
// Invalidate clears the in-memory rule cache and the enabled-tools cache.
// The next call to Match or IsToolEnabled will reload from the database.
// Call this after any CRUD operation on rules or tool config.
func (i *Interceptor) Invalidate() {
i.mu.Lock()
i.cached = nil
i.enabledTools = nil
i.mu.Unlock()
}
func (i *Interceptor) loadLocked() error {
rules, err := i.db.ListInterceptRules()
if err != nil {
return err
}
var out []compiledRule
for _, r := range rules {
if !r.Enabled {
continue
}
cr := compiledRule{InterceptRule: r}
if r.MatchType == "regex" {
re, err := regexp.Compile(r.Pattern)
if err != nil {
continue // skip rules with bad regex rather than crashing
}
cr.re = re
}
out = append(out, cr)
}
i.cached = out
// Load enabled-tools set from settings, falling back to hard-coded defaults.
val, ok, _ := i.db.GetSetting("intercept_enabled_tools")
if !ok {
m := make(map[string]bool, len(defaultEnabledTools))
for _, n := range defaultEnabledTools {
m[n] = true
}
i.enabledTools = m
} else {
var names []string
if json.Unmarshal([]byte(val), &names) != nil {
i.enabledTools = map[string]bool{}
} else {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
i.enabledTools = m
}
}
return nil
}
func (i *Interceptor) rules() ([]compiledRule, error) {
i.mu.RLock()
if i.cached != nil {
out := i.cached
i.mu.RUnlock()
return out, nil
}
i.mu.RUnlock()
i.mu.Lock()
defer i.mu.Unlock()
if i.cached != nil {
return i.cached, nil
}
if err := i.loadLocked(); err != nil {
return nil, err
}
return i.cached, nil
}
// IsToolEnabled returns true if the named tool is in the intercept-enabled set
// (i.e. it should enter the rule-matching path). Uses the same double-check lock
// pattern as rules().
func (i *Interceptor) IsToolEnabled(name string) bool {
i.mu.RLock()
if i.enabledTools != nil {
v := i.enabledTools[name]
i.mu.RUnlock()
return v
}
i.mu.RUnlock()
i.mu.Lock()
defer i.mu.Unlock()
if i.enabledTools == nil {
_ = i.loadLocked()
}
return i.enabledTools[name]
}
// GetEnabledTools returns the ordered list of tool names that are currently
// configured to enter the intercept rule system. When the setting has never been
// saved the hard-coded default list is returned.
func (i *Interceptor) GetEnabledTools() ([]string, error) {
val, ok, err := i.db.GetSetting("intercept_enabled_tools")
if err != nil {
return nil, err
}
if !ok {
out := make([]string, len(defaultEnabledTools))
copy(out, defaultEnabledTools)
return out, nil
}
var names []string
if err := json.Unmarshal([]byte(val), &names); err != nil {
return []string{}, nil
}
return names, nil
}
// SetEnabledTools persists the list of tool names that should enter the intercept
// rule system, then invalidates the cache so the next call picks up the new list.
func (i *Interceptor) SetEnabledTools(tools []string) error {
b, err := json.Marshal(tools)
if err != nil {
return err
}
if err := i.db.SetSetting("intercept_enabled_tools", string(b)); err != nil {
return err
}
i.Invalidate()
return nil
}
// Decision is the outcome of a successful rule match.
type Decision struct {
ModelInput json.RawMessage
ModelInputDigest string
ModelFallback bool
RuleName string
ConfigDigest string
ProfileID int64
Action string // "allow" | "deny" | "ask"
Message string
RuleID int64
TimeoutEnabled bool
TimeoutSeconds int
TimeoutAction string // "deny" | "allow"
}
// --- LLM fallback judge ---
// Judge settings keys (stored in the settings KV table). See docs §3.
const (
settingJudgeEnabled = "llm_judge_enabled"
settingJudgeProfileID = "llm_judge_profile_id"
settingJudgePrompt = "llm_judge_prompt"
settingJudgeTimeoutSecs = "llm_judge_timeout_seconds"
settingJudgeRetryCount = "llm_judge_retry_count"
settingJudgeRetryIntervalMS = "llm_judge_retry_interval_ms"
settingJudgeFailAction = "llm_judge_fail_action"
settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds"
settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action"
settingJudgeIncompleteScript = "llm_judge_incomplete_script_action"
settingJudgeConfidence = "llm_judge_confidence_threshold"
)
// Judge default values.
const (
defaultJudgeTimeoutSecs = 15
defaultJudgeRetryCount = 3
defaultJudgeFailAction = "ask"
defaultJudgeAskTimeoutSecs = 300
defaultJudgeAskTimeoutAction = "deny"
defaultJudgeConfidence = 0.55
// DefaultIncompleteScriptAction keeps incomplete snapshots with the judge.
DefaultIncompleteScriptAction = "model"
)
// JudgeConfig is the resolved LLM-fallback-judge configuration. Prompt is always
// non-empty (falls back to DefaultJudgePrompt).
type JudgeConfig struct {
Enabled bool `json:"enabled"`
ProfileID int64 `json:"profile_id"` // 0 = follow active/default
Prompt string `json:"prompt"`
TimeoutSeconds int `json:"timeout_seconds"` // per attempt
RetryCount int `json:"retry_count"` // additional attempts; 0 = disabled
RetryIntervalMS int `json:"retry_interval_ms"`
FailAction string `json:"fail_action"` // allow|ask|deny
AskTimeoutSeconds int `json:"ask_timeout_seconds"`
AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny
IncompleteScriptAction string `json:"incomplete_script_action"` // ask|model
ConfidenceThreshold float64 `json:"confidence_threshold"` // Jev only; below this becomes ask
}
// judgeConfig reads the judge configuration from settings, applying defaults for
// missing/invalid keys. Read fresh on each fallback judgement — the LLM call that
// follows dwarfs a few KV reads, and freshness avoids a cache-invalidation path.
func (i *Interceptor) judgeConfig() JudgeConfig {
c := JudgeConfig{
Enabled: i.db.GetBool(settingJudgeEnabled, false),
ProfileID: int64(i.getSettingInt(settingJudgeProfileID, 0)),
TimeoutSeconds: i.getSettingInt(settingJudgeTimeoutSecs, defaultJudgeTimeoutSecs),
RetryCount: min(max(i.getSettingInt(settingJudgeRetryCount, defaultJudgeRetryCount), 0), MaxJudgeRetryCount),
RetryIntervalMS: min(max(i.getSettingInt(settingJudgeRetryIntervalMS, defaultJudgeRetryIntervalMS), 0), MaxJudgeRetryIntervalMS),
FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"),
AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs),
AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"),
IncompleteScriptAction: i.getSettingChoice(settingJudgeIncompleteScript, DefaultIncompleteScriptAction, "ask", "model"),
ConfidenceThreshold: i.getSettingFloat(settingJudgeConfidence, defaultJudgeConfidence),
}
// Prompt: stored value if non-empty, else the built-in template.
if v, ok, _ := i.db.GetSetting(settingJudgePrompt); ok && strings.TrimSpace(v) != "" {
c.Prompt = v
} else {
c.Prompt = DefaultJudgePrompt
}
if c.TimeoutSeconds <= 0 {
c.TimeoutSeconds = defaultJudgeTimeoutSecs
}
if c.AskTimeoutSeconds <= 0 {
c.AskTimeoutSeconds = defaultJudgeAskTimeoutSecs
}
if c.ConfidenceThreshold < 0 || c.ConfidenceThreshold > 1 {
c.ConfidenceThreshold = defaultJudgeConfidence
}
return c
}
func (i *Interceptor) getSettingFloat(key string, def float64) float64 {
v, ok, err := i.db.GetSetting(key)
if err != nil || !ok {
return def
}
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return def
}
return n
}
func (i *Interceptor) getSettingInt(key string, def int) int {
v, ok, err := i.db.GetSetting(key)
if err != nil || !ok {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
func (i *Interceptor) getSettingChoice(key, def string, allowed ...string) string {
v, ok, err := i.db.GetSetting(key)
if err != nil || !ok {
return def
}
v = strings.TrimSpace(v)
for _, a := range allowed {
if v == a {
return v
}
}
return def
}
// GetJudgeConfig returns the resolved judge configuration for the API/UI. Prompt
// is the effective prompt (built-in template when unset), so the UI can prefill.
func (i *Interceptor) GetJudgeConfig() JudgeConfig { return i.judgeConfig() }
// SetJudgeConfig persists the judge configuration. An empty Prompt clears the
// override (the built-in template is used again).
func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
if err := c.ValidateRetry(); err != nil {
return err
}
if c.ConfidenceThreshold < 0 || c.ConfidenceThreshold > 1 {
return fmt.Errorf("confidence_threshold 必须在 0 到 1 之间")
}
if err := i.db.SetBool(settingJudgeEnabled, c.Enabled); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeProfileID, strconv.FormatInt(c.ProfileID, 10)); err != nil {
return err
}
// Store the prompt only when it differs from the built-in template, so version
// updates to DefaultJudgePrompt flow through for users who never customized it.
promptToStore := ""
if strings.TrimSpace(c.Prompt) != "" && strings.TrimSpace(c.Prompt) != strings.TrimSpace(DefaultJudgePrompt) {
promptToStore = c.Prompt
}
if err := i.db.SetSetting(settingJudgePrompt, promptToStore); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeTimeoutSecs, strconv.Itoa(c.TimeoutSeconds)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeRetryCount, strconv.Itoa(c.RetryCount)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeRetryIntervalMS, strconv.Itoa(c.RetryIntervalMS)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeFailAction, c.FailAction); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeAskTimeoutSecs, strconv.Itoa(c.AskTimeoutSeconds)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeAskTimeoutAction, c.AskTimeoutAction); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeIncompleteScript, c.IncompleteScriptAction); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeConfidence, strconv.FormatFloat(c.ConfidenceThreshold, 'f', 4, 64)); err != nil {
return err
}
return nil
}
// Judge runs the LLM fallback judge for a tool call that matched no rule. It
// returns (Decision, true) when the judge produced a terminal verdict, or
// (Decision{}, false) when the fallback is disabled or not wired (caller then
// keeps the current behavior: allow). On model error or an unparseable reply it
// retries up to RetryCount times, then falls back to the configured FailAction.
// Ask verdicts carry the human-approval
// timeout so the existing HandleAsk consumes them unchanged.
func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.RawMessage) (Decision, bool) {
cfg := i.judgeConfig()
i.mu.RLock()
rv := i.reviewer
i.mu.RUnlock()
if !cfg.Enabled || rv == nil {
return Decision{}, false
}
cctx := ctx
cancel := func() {}
if cfg.TimeoutSeconds > 0 {
cctx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
}
input, contextErr := BuildReviewInput(cctx, tool, arguments)
cancel() // Capture once; each model attempt gets its own timeout below.
cfg.Prompt = EffectiveJudgePrompt(cfg.Prompt)
var out Decision
var err error
var modelInput []byte
if contextErr != nil {
// Invalid current arguments cannot be reviewed faithfully, regardless of
// the configured model-failure strategy. A human must resolve the input.
out = Decision{Action: "ask", ModelFallback: true, Message: "审查上下文不完整,需要人工确认:" + contextErr.Error()}
} else {
modelInput, _ = json.Marshal(input)
out, err = reviewWithRetry(ctx, cfg, rv, input)
}
if err != nil {
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
}
// By default the model receives snapshot read errors and its verdict is
// honored. Operators may explicitly require human review when an incomplete
// snapshot would otherwise be allowed.
out = applyIncompleteScriptPolicy(input, out, cfg.IncompleteScriptAction)
switch out.Action {
case "allow", "ask", "deny":
// valid verdict
default:
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 len(modelInput) > 0 {
out.ModelInput = modelInput
out.ModelInputDigest = digestInput(modelInput)
}
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, "[模型]") {
out.Message = "[模型] " + out.Message
}
if out.Action == "ask" {
out.TimeoutEnabled = true
out.TimeoutSeconds = cfg.AskTimeoutSeconds
out.TimeoutAction = cfg.AskTimeoutAction
}
return out, true
}
func applyIncompleteScriptPolicy(input ReviewInput, out Decision, policy string) Decision {
if input.ScriptCaptureStatus == "incomplete" && policy != "model" && out.Action == "allow" {
out.Action = "ask"
out.Message = "脚本未能完整附加,无法排除隐藏副作用,需要人工确认"
}
return out
}
func judgeActionLabel(action string) string {
switch action {
case "allow":
return "放行"
case "deny":
return "拦截"
case "ask":
return "转人工审批"
default:
return action
}
}
// Match evaluates the rule list (priority DESC) against a tool call.
// Returns (Decision, true) for the first matching enabled rule, or
// (Decision{}, false) if no rule matches.
func (i *Interceptor) Match(toolName string, input []byte) (Decision, bool) {
rules, err := i.rules()
if err != nil || len(rules) == 0 {
return Decision{}, false
}
values := inputStrings(input)
for _, r := range rules {
if ruleMatches(r, toolName, input, values) {
msg := r.Message
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,
TimeoutEnabled: r.TimeoutEnabled,
TimeoutSeconds: r.TimeoutSeconds,
TimeoutAction: r.TimeoutAction,
}, true
}
}
return Decision{}, false
}
// ruleMatches reports whether r applies to the call. tool_input rules are tried
// against the raw JSON first, then against each decoded string value in values.
// The raw form alone is not enough: JSON escapes newlines, tabs and quotes, and
// Go's encoder also escapes & < >, so a rule like `\brm\b` never fires on
// "cd /tmp\nrm -rf /var/www" (the raw bytes read `\nrm`, with no word boundary).
func ruleMatches(r compiledRule, toolName string, input []byte, values []string) bool {
switch r.MatchTarget {
case "tool_name":
return r.matchString(toolName)
case "tool_input":
if r.matchString(string(input)) {
return true
}
for _, v := range values {
if r.matchString(v) {
return true
}
}
}
return false
}
func (r compiledRule) matchString(s string) bool {
if r.MatchType == "regex" {
return r.re != nil && r.re.MatchString(s)
}
return strings.Contains(s, r.Pattern)
}
// inputStrings returns every string value in a JSON tool input, at any depth.
// Object keys are skipped. It returns nil if the input is not valid JSON.
func inputStrings(input []byte) []string {
var doc any
if json.Unmarshal(input, &doc) != nil {
return nil
}
var out []string
var walk func(any)
walk = func(v any) {
switch v := v.(type) {
case string:
out = append(out, v)
case []any:
for _, e := range v {
walk(e)
}
case map[string]any:
for _, e := range v {
walk(e)
}
}
}
walk(doc)
return out
}
func defaultMessage(action, name string) string {
switch action {
case "deny":
return "拦截规则 [" + name + "] 禁止执行此工具"
case "ask":
return "拦截规则 [" + name + "] 要求用户审批,请等待"
default:
return ""
}
}
// 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 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)
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
// (via /api/intercept/pending/{id}/decide) or the per-rule timeout elapses.
// Returns true if the user approved.
//
// convID == 0 means no active conversation (background pentest task). The
// pending record is still created (conversation_id = NULL) so the approvals
// page shows it and the sidebar badge lights up. The worker thread blocks just
// like in a chat session — the user must visit the approvals page to unblock it.
func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision, toolName string, input []byte) bool {
ruleID := dec.RuleID
taskID, agentName := taskInfoFromCtx(ctx)
taskEmit := taskEmitFromCtx(ctx)
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,
"input": json.RawMessage(input),
})
activity := db.Activity{
Kind: "intercept_request",
Summary: fmt.Sprintf("工具 %s 请求审批 (#%d)", toolName, pendingID),
Detail: string(detail),
}
if convID != 0 {
// Chat session: write inline card to the conversation stream.
_, _ = i.db.AppendConvActivity(convID, activity)
} else if taskEmit != nil {
// Task worker: emit to the exploration activity stream so it appears
// inline in the session transcript (the emit fn stamps NodeID + Worker).
taskEmit(activity)
}
if !dec.TimeoutEnabled {
// No timeout: wait indefinitely until user decides or worker stops.
select {
case allowed := <-ch:
return allowed
case <-ctx.Done():
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "工作已取消")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "执行前工作已取消", false)
return false
}
}
secs := dec.TimeoutSeconds
if secs <= 0 {
secs = 60
}
timer := time.NewTimer(time.Duration(secs) * time.Second)
defer timer.Stop()
select {
case allowed := <-ch:
return allowed
case <-timer.C:
allowed := dec.TimeoutAction == "allow"
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.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"
}
action, reason := "deny", "人工拒绝执行"
if allowed {
action, reason = "allow", "人工允许执行"
}
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason, "manual")
if err != nil {
return err
}
if !resolved {
return ErrAlreadyDecided
}
i.pending.resolve(pendingID, allowed)
return nil
}