First Commit
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
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
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user