mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +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.
662 lines
18 KiB
Go
662 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/guard"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/artex/llmrec"
|
|
"github.com/Autumn-27/norma/llm"
|
|
)
|
|
|
|
// judgeWorkerLane is the usage-ledger "worker" label for intercept fallback-judge
|
|
// calls, so judge spend can be queried apart from the worker/planner/main lanes.
|
|
const judgeWorkerLane = "judge"
|
|
|
|
// chatGuard returns a guard wired with the manager's interceptor, used for chat
|
|
// conversations. Called once per applyLLM so a new LLM config always gets a fresh guard.
|
|
func (s *Server) chatGuard() *guard.Guard {
|
|
return guard.NewWithInterceptor(s.m.interceptor)
|
|
}
|
|
|
|
// wireInterceptReviewer installs the LLM fallback judge into the interceptor. The
|
|
// judge runs only on tool calls that matched no rule (see intercept.Judge). It
|
|
// resolves the configured judge profile (0 → active/default), builds a provider,
|
|
// runs a one-shot JSON classification with an explanation for every verdict.
|
|
func (s *Server) wireInterceptReviewer() {
|
|
s.m.interceptor.SetReviewer(func(ctx context.Context, profileID int64, prompt string, input intercept.ReviewInput) (intercept.Decision, error) {
|
|
if profileID == 0 {
|
|
if p, err := s.m.pg.ActiveProfile(); err == nil && p != nil {
|
|
profileID = p.ID
|
|
}
|
|
}
|
|
if profileID == 0 {
|
|
return intercept.Decision{}, fmt.Errorf("no usable judge model is configured")
|
|
}
|
|
prov, _, ok := s.providerForProfile(profileID)
|
|
if !ok {
|
|
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("judge model profile %d is unavailable", profileID)
|
|
}
|
|
// Tag this call's usage as the "judge" lane so the config page can report
|
|
// how much the fallback approval has spent, separate from model profiles.
|
|
ctx = llmrec.WithWorker(ctx, judgeWorkerLane)
|
|
text, err := reviewCompletion(ctx, prov, prompt, input)
|
|
if err != nil {
|
|
return intercept.Decision{ProfileID: profileID}, err
|
|
}
|
|
v := intercept.ParseVerdict(text)
|
|
if v.Action == "" {
|
|
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("invalid model verdict format: it must contain the decision, actual action, consequence if successful, and matched rule")
|
|
}
|
|
return intercept.Decision{Action: v.Action, Message: v.Reason, ProfileID: profileID}, nil
|
|
})
|
|
}
|
|
|
|
func reviewCompletion(ctx context.Context, prov llm.Provider, prompt string, input intercept.ReviewInput) (string, error) {
|
|
user, err := json.Marshal(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return streamCollectText(ctx, prov, prompt, string(user))
|
|
}
|
|
|
|
// streamCollectText runs a single non-streaming-style completion (thinking off,
|
|
// low temperature, bounded output) and returns the concatenated text. The
|
|
// budget includes the explanation and complete closing JSON delimiters.
|
|
func streamCollectText(ctx context.Context, prov llm.Provider, system, user string) (string, error) {
|
|
temp := 0.0
|
|
req := llm.CompletionRequest{
|
|
System: []string{system},
|
|
Messages: []llm.Message{llm.UserText(user)},
|
|
MaxTokens: 1024,
|
|
Temperature: &temp,
|
|
Thinking: "disabled",
|
|
}
|
|
var sb strings.Builder
|
|
for ev, err := range prov.Stream(ctx, req) {
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ev.Type == llm.SETextDelta {
|
|
sb.WriteString(ev.Text)
|
|
}
|
|
}
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// --- intercept rule CRUD ---
|
|
|
|
func (s *Server) interceptListRules(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
rules, err := pg.ListInterceptRules()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if rules == nil {
|
|
rules = []db.InterceptRule{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"rules": rules})
|
|
}
|
|
|
|
func (s *Server) interceptCreateRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var req interceptRuleReq
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := validateInterceptRuleReq(req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
rule, err := pg.CreateInterceptRule(req.Name, req.MatchTarget, req.MatchType, req.Pattern, req.Action, req.Message, req.Priority, req.Enabled, req.TimeoutEnabled, req.TimeoutSeconds, req.TimeoutAction)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, rule)
|
|
}
|
|
|
|
func (s *Server) interceptUpdateRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad rule id")
|
|
return
|
|
}
|
|
var req interceptRuleReq
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := validateInterceptRuleReq(req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
rule, err := pg.UpdateInterceptRule(id, req.Name, req.MatchTarget, req.MatchType, req.Pattern, req.Action, req.Message, req.Priority, req.Enabled, req.TimeoutEnabled, req.TimeoutSeconds, req.TimeoutAction)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, rule)
|
|
}
|
|
|
|
func (s *Server) interceptDeleteRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad rule id")
|
|
return
|
|
}
|
|
if err := pg.DeleteInterceptRule(id); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, map[string]any{"deleted": id})
|
|
}
|
|
|
|
func (s *Server) interceptToggleRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad rule id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.ToggleInterceptRule(id, req.Enabled); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, map[string]any{"ok": true, "enabled": req.Enabled})
|
|
}
|
|
|
|
// --- pending (ask) ---
|
|
|
|
func (s *Server) interceptListPending(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
pending, err := pg.ListPendingIntercepts()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if pending == nil {
|
|
pending = []db.InterceptPending{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"pending": pending})
|
|
}
|
|
|
|
func (s *Server) interceptGetOne(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad pending id")
|
|
return
|
|
}
|
|
p, err := pg.GetInterceptPending(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if p == nil {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, p)
|
|
}
|
|
|
|
func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
taskID := r.PathValue("taskID")
|
|
if taskID == "" {
|
|
writeErr(w, 400, "bad task id")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
filter, err := interceptFilterParams(q)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
|
|
items, err := pg.ListTaskIntercepts(taskID)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []db.InterceptApprovalRow{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items, "total": len(items)})
|
|
return
|
|
}
|
|
page, size := interceptPageParams(q)
|
|
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size, filter)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []db.InterceptApprovalRow{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
|
}
|
|
|
|
func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
filter, err := interceptFilterParams(q)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
|
|
items, err := pg.ListAllIntercepts(200)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []db.InterceptApprovalRow{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items, "total": len(items)})
|
|
return
|
|
}
|
|
page, size := interceptPageParams(q)
|
|
items, total, err := pg.ListAllInterceptsPage(page, size, filter)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []db.InterceptApprovalRow{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
|
}
|
|
|
|
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
|
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source"), DecidedBy: q.Get("decided_by")}
|
|
switch filter.Status {
|
|
case "", "pending", "allowed", "denied", "timeout":
|
|
default:
|
|
return filter, fmt.Errorf("status must be pending, allowed, denied or timeout")
|
|
}
|
|
switch filter.DecisionSource {
|
|
case "", "model", "rule", "target", "unknown":
|
|
default:
|
|
return filter, fmt.Errorf("decision_source must be model, rule, target or unknown")
|
|
}
|
|
switch filter.DecidedBy {
|
|
case "", "human", "rule", "model", "system":
|
|
default:
|
|
return filter, fmt.Errorf("decided_by must be human, rule, model or system")
|
|
}
|
|
return filter, nil
|
|
}
|
|
|
|
func interceptPageParams(q url.Values) (int, int) {
|
|
page := atoiDefault(q.Get("page"), 1)
|
|
size := atoiDefault(q.Get("size"), 20)
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if size < 1 {
|
|
size = 20
|
|
}
|
|
if size > 100 {
|
|
size = 100
|
|
}
|
|
return page, size
|
|
}
|
|
|
|
func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad pending id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Decision string `json:"decision"` // "allowed" | "denied"
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if req.Decision != "allowed" && req.Decision != "denied" {
|
|
writeErr(w, 400, "decision must be allowed or denied")
|
|
return
|
|
}
|
|
// On a target request "allowed" pins the addresses the request displayed.
|
|
// Every human decision records who took it and from where.
|
|
if err := s.m.interceptor.Decide(id, req.Decision == "allowed", s.approver(r), clientIP(r, s.trustedProxies)); err != nil {
|
|
if errors.Is(err, intercept.ErrAlreadyDecided) {
|
|
writeErr(w, 409, err.Error())
|
|
return
|
|
}
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// --- target pins (hosts a human approved for one task or one conversation) ---
|
|
|
|
// taskPinScope resolves {id} to the pin scope of an existing task.
|
|
func (s *Server) taskPinScope(w http.ResponseWriter, r *http.Request) (db.TargetScopeRef, bool) {
|
|
id, ok := pathInt(r, "id")
|
|
if !ok || id <= 0 {
|
|
writeErr(w, 400, "bad task id")
|
|
return db.TargetScopeRef{}, false
|
|
}
|
|
if _, ok := s.m.Task(strconv.FormatInt(id, 10)); !ok {
|
|
writeErr(w, 404, "task not found")
|
|
return db.TargetScopeRef{}, false
|
|
}
|
|
return db.TargetScopeRef{TaskID: id}, true
|
|
}
|
|
|
|
func (s *Server) taskListTargetPins(w http.ResponseWriter, r *http.Request) {
|
|
if scope, ok := s.taskPinScope(w, r); ok {
|
|
s.listTargetPins(w, scope)
|
|
}
|
|
}
|
|
|
|
func (s *Server) taskRevokeTargetPin(w http.ResponseWriter, r *http.Request) {
|
|
if scope, ok := s.taskPinScope(w, r); ok {
|
|
s.revokeTargetPin(w, r, scope)
|
|
}
|
|
}
|
|
|
|
func (s *Server) convListTargetPins(w http.ResponseWriter, r *http.Request) {
|
|
if _, c, ok := s.convByID(w, r); ok {
|
|
s.listTargetPins(w, db.TargetScopeRef{ConversationID: c.ID})
|
|
}
|
|
}
|
|
|
|
func (s *Server) convRevokeTargetPin(w http.ResponseWriter, r *http.Request) {
|
|
if _, c, ok := s.convByID(w, r); ok {
|
|
s.revokeTargetPin(w, r, db.TargetScopeRef{ConversationID: c.ID})
|
|
}
|
|
}
|
|
|
|
// listTargetPins returns every pin of the scope, in any state, newest first.
|
|
func (s *Server) listTargetPins(w http.ResponseWriter, scope db.TargetScopeRef) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
pins, err := pg.ListTargetPins(scope)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"pins": pins})
|
|
}
|
|
|
|
// revokeTargetPin revokes pin {pid} of the scope. The next call to that host
|
|
// asks again. Revoking a pin that is no longer active changes nothing.
|
|
func (s *Server) revokeTargetPin(w http.ResponseWriter, r *http.Request, scope db.TargetScopeRef) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
pid, ok := pathInt(r, "pid")
|
|
if !ok {
|
|
writeErr(w, 400, "bad pin id")
|
|
return
|
|
}
|
|
pin, err := pg.RevokeTargetPin(scope, pid)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if pin == nil {
|
|
writeErr(w, 404, "pin not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, pin)
|
|
}
|
|
|
|
// --- tool-config (global tool interception scope) ---
|
|
|
|
// interceptGetToolConfig returns the list of tool names that are currently
|
|
// configured to enter the intercept rule system.
|
|
func (s *Server) interceptGetToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
tools, err := s.m.interceptor.GetEnabledTools()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"enabled_tools": tools})
|
|
}
|
|
|
|
// interceptSetToolConfig replaces the list of tool names that should enter
|
|
// the intercept rule system.
|
|
func (s *Server) interceptSetToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
EnabledTools []string `json:"enabled_tools"`
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if req.EnabledTools == nil {
|
|
req.EnabledTools = []string{}
|
|
}
|
|
if err := s.m.interceptor.SetEnabledTools(req.EnabledTools); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// --- LLM fallback judge config (global model fallback) ---
|
|
|
|
// interceptGetJudgeConfig returns the resolved judge configuration. Prompt is the
|
|
// effective prompt (built-in template when unset), so the UI can prefill it.
|
|
func (s *Server) interceptGetJudgeConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, s.m.interceptor.GetJudgeConfig())
|
|
}
|
|
|
|
// interceptSetJudgeConfig persists the judge configuration.
|
|
func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req intercept.JudgeConfig
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
switch req.FailAction {
|
|
case "allow", "ask", "deny":
|
|
default:
|
|
writeErr(w, 400, "fail_action must be allow, ask or deny")
|
|
return
|
|
}
|
|
switch req.AskTimeoutAction {
|
|
case "allow", "deny":
|
|
default:
|
|
writeErr(w, 400, "ask_timeout_action must be allow or deny")
|
|
return
|
|
}
|
|
if err := s.m.interceptor.SetJudgeConfig(req); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// interceptJudgeUsage returns the fallback judge's cumulative token spend plus a
|
|
// recent daily series, for the config page. ?days bounds the daily series (default 30).
|
|
func (s *Server) interceptJudgeUsage(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.m.PG()
|
|
if pg == nil {
|
|
writeJSON(w, 200, db.JudgeUsage{Daily: []db.JudgeDayUsage{}})
|
|
return
|
|
}
|
|
days := atoiDefault(r.URL.Query().Get("days"), 30)
|
|
usage, err := pg.JudgeUsageStats(days)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, usage)
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
type interceptRuleReq struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Priority int `json:"priority"`
|
|
MatchTarget string `json:"match_target"`
|
|
MatchType string `json:"match_type"`
|
|
Pattern string `json:"pattern"`
|
|
Action string `json:"action"`
|
|
Message string `json:"message"`
|
|
TimeoutEnabled bool `json:"timeout_enabled"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
TimeoutAction string `json:"timeout_action"`
|
|
}
|
|
|
|
func validateInterceptRuleReq(req interceptRuleReq) error {
|
|
if req.Name == "" {
|
|
return fmt.Errorf("name cannot be empty")
|
|
}
|
|
switch req.MatchTarget {
|
|
case "tool_name", "tool_input":
|
|
default:
|
|
return fmt.Errorf("match_target must be tool_name or tool_input")
|
|
}
|
|
switch req.MatchType {
|
|
case "string", "regex":
|
|
default:
|
|
return fmt.Errorf("match_type must be string or regex")
|
|
}
|
|
if req.Pattern == "" {
|
|
return fmt.Errorf("pattern cannot be empty")
|
|
}
|
|
switch req.Action {
|
|
case "allow", "deny", "ask":
|
|
default:
|
|
return fmt.Errorf("action must be allow, deny or ask")
|
|
}
|
|
if req.MatchType == "regex" {
|
|
if _, err := regexp.Compile(req.Pattern); err != nil {
|
|
return fmt.Errorf("pattern is not a valid regular expression: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok || id <= 0 {
|
|
writeErr(w, 400, "bad approval id")
|
|
return
|
|
}
|
|
detail, err := pg.GetInterceptDetail(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if detail == nil {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, detail)
|
|
}
|
|
|
|
// The navigation endpoint returns only the original call and its paired result.
|
|
func (s *Server) interceptExecution(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok || id <= 0 {
|
|
writeErr(w, 400, "bad approval id")
|
|
return
|
|
}
|
|
target, err := pg.GetInterceptExecution(id)
|
|
if errors.Is(err, db.ErrInterceptTaskDeleted) || errors.Is(err, db.ErrInterceptSessionDeleted) {
|
|
writeErr(w, http.StatusGone, err.Error())
|
|
return
|
|
}
|
|
if errors.Is(err, db.ErrInterceptExecutionUnavailable) {
|
|
writeErr(w, 409, err.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if target == nil {
|
|
// Conversation deletion cascades approval rows. A stale source link still
|
|
// carries its conversation ID, allowing a precise message without retaining
|
|
// deleted conversations or changing their deletion semantics.
|
|
if convID, parseErr := strconv.ParseInt(r.URL.Query().Get("conversation"), 10, 64); parseErr == nil && convID > 0 {
|
|
conv, getErr := pg.GetConversation(convID)
|
|
if getErr != nil {
|
|
writeErr(w, 500, getErr.Error())
|
|
return
|
|
}
|
|
if conv == nil {
|
|
writeErr(w, http.StatusGone, "The conversation has been deleted")
|
|
return
|
|
}
|
|
}
|
|
writeErr(w, 404, "The approval record has been deleted or does not exist")
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"conversation_id": target.ConversationID, "task_id": target.TaskID, "session": target.Session, "seq": target.Seq, "items": activityDTOs(target.Items)})
|
|
}
|