mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
The approval gate for asset targets now decides. It is opt-in per task and per conversation (target_gate, default off). With the flag off nothing is extracted, resolved, read or written, and every branch of the hook behaves as before. No UI. Extraction and normalisation (1.5) - intercept.NormalizeTarget: lower case, trailing dot, userinfo, IPv6 brackets, IPv4-mapped IPv6, decimal / hex / octal / short IPv4, percent- encoded host, punycode; port explicit, else the scheme default, else 0. - intercept.ExtractTargets: WebFetch url exactly; Bash, shell_open, Monitor (command) and shell_send (text) with the command-string rules (URL with a scheme, IP, IP:port, host:port, CIDR; a bare name is not a target); every string of the input for MCP tools, custom tools and any tool the gate does not know. ExecuteExtraTool is unwrapped to the inner tool. The platform's domain tools, the file tools and web_search have no target. - The fields are decoded as the tools decode them (key case, last duplicate), and a URL authority that reads differently quoted and unquoted yields both readings. Resolver, exemptions, gate decision (1.6) - Resolver interface, system implementation (A and AAAA), 3 s per lookup, positive answers cached 10 s per host with collapsed lookups, failures and empty answers never cached, always fresh when a request is raised. - Exemptions are exact host + port. Derived from the configuration (LLM gateways, search backend in use, platform proxies): computed on every call, never resolved, never voided. Stored (settings key target_gate_exempt_hosts, seeded once with the package sources): resolved, and void when any address is private, loopback, link-local, unspecified, unique-local, multicast or a metadata address. An entry in both origins counts as derived. A stored IP literal that can never be exempt does not apply. - GET / PUT /api/target-gate/exemptions and GET .../history. A change is validated (ValidateExemptionHost on the normalised host) and appended to target_gate_exemption_log in the transaction that stores it. - Interceptor.CheckTargets: asset block rules (global and of the task) on the name and its addresses, exemptions, resolution, pin lookup. A (host, 0) pin covers every port; a (host, 443) pin covers 443 only and never a target without a declared port; two pins are never merged. At most 5 unpinned targets per call. Codes: TARGET_RULE_BLOCKED, TARGET_RESOLVE_FAILED, TARGET_TOO_MANY, TARGET_GATE_UNAVAILABLE; none opens a request. - With the upstream egress proxy set every pin is name-only at call time, whatever name_only_effective says in its row. - An approval never pins zero addresses: the pin writer refuses a request that displays none (db.ErrTargetPinEmpty, 422 on the decide endpoint, the row stays pending) and RequestTargets does not open one. - Every block, and the first use per process of each exemption and of each pin version, is a decided row (decided_by system, decision_source target). The gate in the hook (1.7) - guard.applyIntercept: a deny by a rule or by the judge blocks first, before any target request exists; then CheckTargets; an allow is logged as before and does not satisfy the gate; an ask and an unpinned target share one request, and the classic ask is raised only when it was not merged. The call is checked again after the decisions, at most three passes. - The gate applies to every tool of a gated session, enabled for rules or not, and to the target-only guard of the planner and the main agent, which still records blocks only. - Target blocks have their own model-facing text, built from the code and the reason of the result. Not in this change: rendered targets of custom tools (1.8) and redirect targets (1.9). Subprocess paths stay advisory; the TestBypass_* tests document what the gate does not see.
783 lines
26 KiB
Go
783 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
|
|
}
|
|
|
|
// targetScopeCtxKey is a separate unexported key type for the target-gate scope.
|
|
type targetScopeCtxKey struct{}
|
|
|
|
// DefaultTargetWaitBound is how long a gated tool call may wait for a human
|
|
// decision on an unpinned target before it fails with the request left open.
|
|
const DefaultTargetWaitBound = 300 * time.Second
|
|
|
|
// TargetScope is what an approval for an asset target is bound to: one task
|
|
// (TaskID) or one conversation (ConversationID), never both. Enabled reports the
|
|
// scope's opt-in flag and is read on every gated call; nil means the gate is off.
|
|
// WaitBound caps the wait for a human decision; 0 means never wait.
|
|
// Interceptor is the one that owns the gate of this session, so that code
|
|
// holding only a context (an HTTP redirect callback) can reach it.
|
|
type TargetScope struct {
|
|
TaskID string
|
|
ConversationID int64
|
|
Enabled func() bool
|
|
WaitBound time.Duration
|
|
Interceptor *Interceptor
|
|
}
|
|
|
|
// WithTargetScope returns a child context carrying the target-gate scope of the
|
|
// session that runs on it.
|
|
func WithTargetScope(ctx context.Context, s TargetScope) context.Context {
|
|
return context.WithValue(ctx, targetScopeCtxKey{}, s)
|
|
}
|
|
|
|
// TargetScopeFromContext extracts the target-gate scope (ok is false if absent).
|
|
func TargetScopeFromContext(ctx context.Context) (s TargetScope, ok bool) {
|
|
s, ok = ctx.Value(targetScopeCtxKey{}).(TargetScope)
|
|
return s, ok
|
|
}
|
|
|
|
// 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 approval requests via per-waiter channels. A
|
|
// classic ask has one waiter; a target request can have several (every call
|
|
// that names the same host waits on the same row).
|
|
type pendingManager struct {
|
|
mu sync.Mutex
|
|
ch map[int64][]chan bool
|
|
}
|
|
|
|
func newPendingManager() *pendingManager { return &pendingManager{ch: map[int64][]chan bool{}} }
|
|
|
|
// add registers one more waiter for id and returns its channel.
|
|
func (p *pendingManager) add(id int64) chan bool {
|
|
ch := make(chan bool, 1)
|
|
p.mu.Lock()
|
|
p.ch[id] = append(p.ch[id], ch)
|
|
p.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
// resolve wakes every waiter of id with the decision.
|
|
func (p *pendingManager) resolve(id int64, allowed bool) {
|
|
p.mu.Lock()
|
|
waiters := p.ch[id]
|
|
delete(p.ch, id)
|
|
p.mu.Unlock()
|
|
for _, ch := range waiters {
|
|
ch <- allowed
|
|
}
|
|
}
|
|
|
|
// remove takes one waiter's channel out; the other waiters of id stay.
|
|
func (p *pendingManager) remove(id int64, ch chan bool) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
waiters := p.ch[id]
|
|
for n, w := range waiters {
|
|
if w == ch {
|
|
waiters = append(waiters[:n:n], waiters[n+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if len(waiters) == 0 {
|
|
delete(p.ch, id)
|
|
return
|
|
}
|
|
p.ch[id] = waiters
|
|
}
|
|
|
|
// 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
|
|
// upstreamProxy reports whether target traffic leaves through an upstream
|
|
// egress proxy (nil = no). Injected by the server layer.
|
|
upstreamProxy func() bool
|
|
// derivedExempt lists the endpoints of the platform's own services (nil =
|
|
// none). Injected by the server layer. See SetDerivedExemptions.
|
|
derivedExempt func() []string
|
|
// targets is what the target gate reads and writes; resolve resolves the
|
|
// names it checks. firstUse holds the exemptions and pin versions whose first
|
|
// use this process has logged.
|
|
targets targetStore
|
|
resolve *resolveCache
|
|
firstUse sync.Map
|
|
}
|
|
|
|
// SetUpstreamProxyCheck installs the function that reports whether an upstream
|
|
// egress proxy is configured. Behind one the proxy chooses the address it
|
|
// connects to, so a target approval given while it is set is name-only.
|
|
func (i *Interceptor) SetUpstreamProxyCheck(f func() bool) {
|
|
i.mu.Lock()
|
|
i.upstreamProxy = f
|
|
i.mu.Unlock()
|
|
}
|
|
|
|
// 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(),
|
|
targets: dbTargetStore{d}, resolve: newResolveCache(systemResolver{})}
|
|
}
|
|
|
|
// 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)
|
|
out.Message = JudgeReason(out.Action, out.Message)
|
|
if out.Action == "ask" {
|
|
out.TimeoutEnabled = true
|
|
out.TimeoutSeconds = cfg.AskTimeoutSeconds
|
|
out.TimeoutAction = cfg.AskTimeoutAction
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// JudgeReason is the reason stored for a judge verdict: the model's message, or
|
|
// a label for the action when it gave none, carrying db.ModelReasonPrefix so
|
|
// the db readers record decision_source='model'.
|
|
func JudgeReason(action, message string) string {
|
|
if message == "" {
|
|
return db.ModelReasonPrefix + " " + judgeActionLabel(action)
|
|
}
|
|
if !strings.HasPrefix(message, db.ModelReasonPrefix) {
|
|
return db.ModelReasonPrefix + " " + message
|
|
}
|
|
return message
|
|
}
|
|
|
|
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, ch)
|
|
// 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", "system")
|
|
_ = 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", "system")
|
|
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", "system")
|
|
_ = 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.
|
|
// approver (subject of the session token) and remoteAddr identify the human
|
|
// behind the decision; they are recorded in the row's audit. On a target
|
|
// request "allowed" also pins the addresses the request displayed, and both
|
|
// values go to the pin: a human decision is the only thing that creates a pin.
|
|
func (i *Interceptor) Decide(pendingID int64, allowed bool, approver, remoteAddr string) error {
|
|
status := "denied"
|
|
if allowed {
|
|
status = "allowed"
|
|
}
|
|
action, reason := "deny", "user rejected execution"
|
|
if allowed {
|
|
action, reason = "allow", "user approved execution"
|
|
}
|
|
targetKey, err := i.db.TargetRequestKey(pendingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resolved bool
|
|
if targetKey != "" {
|
|
_, resolved, err = i.db.ResolveTargetRequest(pendingID, allowed, approver, remoteAddr)
|
|
} else {
|
|
resolved, err = i.db.ResolveInterceptBy(pendingID, status, action, reason, "human", approver, remoteAddr)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resolved {
|
|
return ErrAlreadyDecided
|
|
}
|
|
i.pending.resolve(pendingID, allowed)
|
|
return nil
|
|
}
|