// 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" "fmt" "regexp" "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() } // 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 } // 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 { Action string // "allow" | "deny" | "ask" Message string RuleID int64 TimeoutEnabled bool TimeoutSeconds int TimeoutAction string // "deny" | "allow" } // 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 } for _, r := range rules { if ruleMatches(r, toolName, input) { msg := r.Message if msg == "" { msg = defaultMessage(r.Action, r.Name) } return Decision{ Action: r.Action, Message: msg, RuleID: r.ID, TimeoutEnabled: r.TimeoutEnabled, TimeoutSeconds: r.TimeoutSeconds, TimeoutAction: r.TimeoutAction, }, true } } return Decision{}, false } func ruleMatches(r compiledRule, toolName string, input []byte) bool { var subject string switch r.MatchTarget { case "tool_name": subject = toolName case "tool_input": subject = string(input) default: return false } if r.MatchType == "regex" { return r.re != nil && r.re.MatchString(subject) } return strings.Contains(subject, r.Pattern) } func defaultMessage(action, name string) string { switch action { case "deny": return "拦截规则 [" + name + "] 禁止执行此工具" case "ask": return "拦截规则 [" + name + "] 要求用户审批,请等待" default: return "" } } // 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. // // 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) pendingID, err := i.db.CreateInterceptPending(ruleID, convID, taskID, agentName, toolName, input) if err != nil { return false } 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) } ch := i.pending.add(pendingID) defer i.pending.remove(pendingID) if !dec.TimeoutEnabled { // No timeout: wait indefinitely until user decides or worker stops. select { case allowed := <-ch: return allowed case <-ctx.Done(): _ = i.db.DecideInterceptPending(pendingID, "denied") 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" _ = i.db.DecideInterceptPending(pendingID, "timeout") return allowed case <-ctx.Done(): _ = i.db.DecideInterceptPending(pendingID, "denied") return false } } // 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" } if err := i.db.DecideInterceptPending(pendingID, status); err != nil { return err } i.pending.resolve(pendingID, allowed) return nil }