Files
artex/intercept/intercept.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

706 lines
23 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"
)
// 사용자에게 노출되는 가로채기 판정·승인 메시지(한국어). judge 판정 결과·규칙 메시지는
// 승인 기록(reason)·활동 스트림·409 응답으로 표시된다. 성능에 영향을 주는 에이전트 두뇌
// (판정 프롬프트 본문)는 번역하지 않으며, [模型] 센티넬(decision_source 분류용 · UI 에서
// 표시 전 제거 · db SQL LIKE·HasPrefix 소비처와 결합)도 원문 그대로 둔다.
const (
msgReviewContextIncomplete = "검토 맥락이 완전하지 않아 사람의 확인이 필요합니다: "
msgModelApprovalFailed = "모델 승인에 실패하여 실패 정책에 따라 처리합니다: "
msgModelOutputUnparsable = "모델 출력을 해석할 수 없어 실패 정책에 따라 처리합니다"
msgToolApprovalRequestFmt = "도구 %s 승인 요청 (#%d)"
reasonWorkCanceled = "작업이 취소되었습니다"
reasonWorkCanceledPreExec = "실행 전에 작업이 취소되었습니다"
reasonApprovalTimeout = "승인 시간이 초과되어 시간 초과 정책에 따라 처리합니다"
reasonManualDeny = "사람이 실행을 거부했습니다"
reasonManualAllow = "사람이 실행을 허용했습니다"
)
// 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: msgReviewContextIncomplete + 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: msgModelApprovalFailed + err.Error()}
}
switch out.Action {
case "allow", "ask", "deny":
// valid verdict
default:
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: msgModelOutputUnparsable}
}
// 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 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
}
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 "가로채기 규칙 [" + 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(msgToolApprovalRequestFmt, 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", reasonWorkCanceled)
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", reasonWorkCanceledPreExec, 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, reasonApprovalTimeout)
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", reasonWorkCanceled)
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", reasonWorkCanceledPreExec, 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", reasonManualDeny
if allowed {
action, reason = "allow", reasonManualAllow
}
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
}