Files
ARTEX/server/intercept.go
T
BaaRai 78f92f44fc feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7)
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.
2026-11-11 00:34:55 +01:00

722 lines
20 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
}
if errors.Is(err, db.ErrTargetPinEmpty) {
// Refused, not failed: the request stays open and nothing was pinned.
writeErr(w, 422, 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)
}
// --- target-gate exemptions (hosts that need no target approval, global) ---
// targetGateGetExemptions returns the stored exemption list with its digest,
// and the exemptions derived from the configuration (LLM gateways, search
// backend, platform proxies), which are computed and cannot be edited.
func (s *Server) targetGateGetExemptions(w http.ResponseWriter, r *http.Request) {
list, digest, err := s.m.interceptor.Exemptions()
if err != nil {
writeErr(w, 500, err.Error())
return
}
if list == nil {
list = []db.TargetExemption{}
}
writeJSON(w, 200, map[string]any{"exemptions": list, "digest": digest, "derived": s.m.interceptor.DerivedExemptions()})
}
// targetGateSetExemptions replaces the stored exemption list. Entries are an
// exact host and port. The change is appended to the exemption log with the
// approver of the session.
func (s *Server) targetGateSetExemptions(w http.ResponseWriter, r *http.Request) {
var req struct {
Exemptions []db.TargetExemption `json:"exemptions"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
list, digest, err := s.m.interceptor.SetExemptions(req.Exemptions, s.approver(r))
if err != nil {
if errors.Is(err, intercept.ErrInvalidExemption) {
writeErr(w, 400, err.Error())
return
}
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"exemptions": list, "digest": digest})
}
// targetGateExemptionHistory returns the most recent changes of the exemption
// list, newest first.
func (s *Server) targetGateExemptionHistory(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
changes, err := pg.ListTargetExemptionLog(100)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"changes": changes})
}
// --- 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)})
}