mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Storage, flag and request path of the approval gate for asset targets. The
gate itself is not wired into the tool hook yet (steps 1.5 to 1.7): with this
change nothing is extracted, resolved or blocked, whatever the flag says.
Schema and store (1.2)
- tasks.target_gate, conversations.target_gate (default false).
- intercept_pending.target_key and a partial unique index: one open request
per scope (task or conversation) and host:port.
- target_pins (no foreign keys: a pin outlives its task, its conversation and
its request row) and target_gate_exemption_log. All DDL is idempotent; no
CHECK constraints; the cascade on intercept_pending.conversation_id stays.
- db/target_pins.go: pin lookup with port precedence, list, revoke,
find-or-create of a request, and ResolveTargetRequest, which settles the
row, supersedes the old pin and writes the new version in one transaction.
- Deleting a task or an archive stub revokes its pins and settles its open
target requests in the delete transaction. DeleteConversation and
DeleteConversations revoke the pins of the removed conversations in the
same transaction as the delete.
Opt-in flag (1.3)
- Per task through the create and metadata-patch paths, per conversation
through create and patch; exposed in the task DTO. A retest conversation
takes the flag of the finding's task, never its pins. A spawned task starts
with the gate off.
- The task flag is atomic and read on every call through TargetScope.Enabled.
TargetScope now carries the Interceptor that owns the gate.
Request, wait, decide (1.4)
- pendingManager keeps a list of waiters per request; resolve wakes all.
- RequestTargets: find-or-create per target, one card per request, wait on
channel, poll, context and wait bound. A plain request is never settled by
a call that leaves. A request merged with an ask is bound to its creating
call and a timeout never allows it.
- Decide records the approver (subject of the session token) and the remote
address for every human decision, classic asks included. On a target
request "allowed" pins the displayed addresses; that is the only writer of
pins.
- A request raised while the global egress proxy is set says the approval is
name-only and its pin is stored with name_only_effective.
- Routes: GET/POST .../target-pins and .../target-pins/{pid}/revoke under
/api/tasks/{id} and /api/conversations/{id}; decision_source=target filter.
- ValidateExemptionHost: an exemption written as an IP literal may be private
unicast, never loopback, link-local or metadata, unspecified or multicast.
773 lines
25 KiB
Go
773 lines
25 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
|
|
}
|
|
|
|
// 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()}
|
|
}
|
|
|
|
// 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
|
|
}
|