Files
ARTEX/intercept/intercept.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

690 lines
22 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"
settingJudgeFailAction = "llm_judge_fail_action"
settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds"
settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action"
)
// Judge default values.
const (
defaultJudgeTimeoutSecs = 15
defaultJudgeFailAction = "allow"
defaultJudgeAskTimeoutSecs = 300
defaultJudgeAskTimeoutAction = "deny"
)
// 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"`
FailAction string `json:"fail_action"` // allow|ask|deny
AskTimeoutSeconds int `json:"ask_timeout_seconds"`
AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny
}
// 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),
FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"),
AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs),
AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"),
}
// 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
}
return c
}
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 := 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(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
}
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
// 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
if cfg.TimeoutSeconds > 0 {
var cancel context.CancelFunc
cctx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
defer cancel()
}
input, contextErr := BuildReviewInput(cctx, tool, arguments)
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: "review context incomplete, manual confirmation required: " + contextErr.Error()}
} else {
modelInput, _ = json.Marshal(input)
out, err = rv(cctx, cfg.ProfileID, cfg.Prompt, input)
}
if err != nil {
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "model review failed, applying failure policy: " + err.Error()}
}
switch out.Action {
case "allow", "ask", "deny":
// valid verdict
default:
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "model output unparsable, applying failure policy"}
}
// 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 = "[model] " + judgeActionLabel(out.Action)
} else if !strings.HasPrefix(out.Message, "[model]") {
out.Message = "[model] " + out.Message
}
if out.Action == "ask" {
out.TimeoutEnabled = true
out.TimeoutSeconds = cfg.AskTimeoutSeconds
out.TimeoutAction = cfg.AskTimeoutAction
}
return out, true
}
func judgeActionLabel(action string) string {
switch action {
case "allow":
return "allow"
case "deny":
return "block"
case "ask":
return "escalated to manual approval"
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
}
for _, r := range rules {
if ruleMatches(r, toolName, input) {
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
}
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 "intercept rule [" + name + "] forbids executing this tool"
case "ask":
return "intercept rule [" + name + "] requires user approval, please wait"
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("tool %s requests approval (#%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", "work cancelled")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "work cancelled before execution", 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, "approval timed out, applying timeout policy")
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", "work cancelled")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "work cancelled before execution", false)
return false
}
}
var ErrAlreadyDecided = errors.New("approval already decided or does not exist; please refresh the list")
// 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", "user rejected execution"
if allowed {
action, reason = "allow", "user approved execution"
}
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason)
if err != nil {
return err
}
if !resolved {
return ErrAlreadyDecided
}
i.pending.resolve(pendingID, allowed)
return nil
}