feat: 资产拦截接入执行链,新增任务级拦截/允许规则

在 agent 下发意图(add_intent)与插入资产(insert_assets)两处接入资产闸门;
并新增任务级规则(拦截 block + 允许 allow 白名单),创建任务时录入、任务详情
总览里可编辑。

- 匹配/执行(db/asset_intercept_match.go):MatchAssetInterceptRules 纯匹配
  (exact/fuzzy 的 域名·IP·URL + CIDR,URL host 会拆出归类);EvaluateAssetGate
  「先拦截后允许」闸门——命中拦截即禁止;未命中但存在启用的允许规则且都不命中→
  不允许测试;无启用允许规则则不启用白名单。附单元测试。
- 执行点:add_intent 命中即不下发意图;insert_assets 命中即跳过 Upsert 及
  AddAutoScope 副作用。均合并 全局∪任务级 规则。
- 任务级规则:表 task_intercept_rules(task_id FK CASCADE + action block/allow,
  幂等 ALTER 补旧库);db/task_intercept.go 提供 List/Split/CRUD/事务插入;
  创建任务随 intercept_rules 提交;CRUD 路由 /api/tasks/{id}/intercept-rules。
- 前端:新建任务抽屉多行录入(拦截/允许 + 类型,用 NativeSelect 避免 Sheet 内
  下拉 portal 导致误关抽屉);任务详情总览新增规则管理卡片(增删改+启用开关)。
This commit is contained in:
Autumn-27 committed 2026-09-19 09:06:22 -04:00
1 parent dfc9d2fa2e
commit d5359e1f04
14 files changed
+1234

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ type AssetInterceptRule struct {
Pattern string `json:"pattern"`
Note string `json:"note"`
Builtin bool `json:"builtin"`
// Action 仅用于任务级规则:'block'=拦截 'allow'=允许(白名单)。
// 全局规则(asset_intercept_rules)不带此列,恒为空,视为拦截。
Action string `json:"action,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+250
View File
@@ -0,0 +1,250 @@
package db
import (
"fmt"
"net"
"net/url"
"strings"
)
// 资产拦截规则的匹配/执行层。asset_intercept.go 只负责规则存储,这里负责把
// 「目标资产」的域名/IP/URL 与启用中的规则做匹配。供 agent 工具(add_intent、
// insert_assets)在下发意图 / 插入资产前调用,命中则拒绝。
// AssetInterceptKindLabel 返回 kind 的中文标签,用于给 agent 的说明消息。
func AssetInterceptKindLabel(kind string) string {
switch kind {
case "exact_domain":
return "域名(全等)"
case "exact_ip":
return "IP(全等)"
case "exact_url":
return "URL(全等)"
case "fuzzy_domain":
return "域名(模糊)"
case "fuzzy_ip":
return "IP(模糊)"
case "fuzzy_url":
return "URL(模糊)"
case "cidr":
return "CIDR 网段"
}
return kind
}
// Reason 返回一条可读的命中原因,形如:命中资产拦截规则 [域名(模糊): .gov.cn](备注)。
func (r AssetInterceptRule) Reason() string {
s := fmt.Sprintf("命中资产拦截规则 [%s: %s]", AssetInterceptKindLabel(r.Kind), r.Pattern)
if note := strings.TrimSpace(r.Note); note != "" {
s += "(" + note + ")"
}
return s
}
// matchOne 判断单条启用规则是否命中给定的域名/IP/URL 候选串,返回命中的具体值。
func matchOne(r AssetInterceptRule, domains, ips, urls []string) (string, bool) {
p := strings.TrimSpace(r.Pattern)
if p == "" {
return "", false
}
switch r.Kind {
case "exact_domain":
for _, d := range domains {
if strings.EqualFold(strings.TrimSpace(d), p) {
return d, true
}
}
case "exact_ip":
for _, ip := range ips {
if strings.TrimSpace(ip) == p {
return ip, true
}
}
case "exact_url":
for _, u := range urls {
if strings.TrimSpace(u) == p {
return u, true
}
}
case "fuzzy_domain":
lp := strings.ToLower(p)
for _, d := range domains {
if d != "" && strings.Contains(strings.ToLower(d), lp) {
return d, true
}
}
case "fuzzy_ip":
for _, ip := range ips {
if ip != "" && strings.Contains(ip, p) {
return ip, true
}
}
case "fuzzy_url":
lp := strings.ToLower(p)
for _, u := range urls {
if u != "" && strings.Contains(strings.ToLower(u), lp) {
return u, true
}
}
case "cidr":
_, ipnet, err := net.ParseCIDR(p)
if err != nil {
return "", false
}
for _, ip := range ips {
if pip := net.ParseIP(strings.TrimSpace(ip)); pip != nil && ipnet.Contains(pip) {
return ip, true
}
}
}
return "", false
}
// MatchAssetInterceptRules 返回第一条命中给定 域名/IP/URL 候选串的启用规则,及命中的具体值。
// 供 insert_assets 用原始输入(尚未落库的 assetInputItem)匹配。
func MatchAssetInterceptRules(rules []AssetInterceptRule, domains, ips, urls []string) (AssetInterceptRule, string, bool) {
for _, r := range rules {
if !r.Enabled {
continue
}
if v, ok := matchOne(r, domains, ips, urls); ok {
return r, v, true
}
}
return AssetInterceptRule{}, "", false
}
// interceptCandidates 提取一个已落库资产用于拦截匹配的 域名/IP/URL 候选串。
// URL 的 host 会被拆出并归类,使「只带 URL」的服务类资产也能被 域名/IP 规则命中。
func (a *Asset) interceptCandidates() (domains, ips, urls []string) {
add := func(dst *[]string, s string) {
if s = strings.TrimSpace(s); s != "" {
*dst = append(*dst, s)
}
}
add(&domains, a.Domain)
add(&domains, a.RootDomain)
for _, d := range a.BoundDomains {
add(&domains, d)
}
add(&ips, a.IP)
add(&urls, a.URL)
if a.URL != "" {
if u, err := url.Parse(a.URL); err == nil {
if h := u.Hostname(); h != "" {
if net.ParseIP(h) != nil {
add(&ips, h)
} else {
add(&domains, h)
}
}
}
}
return domains, ips, urls
}
// InterceptLabel 返回资产的简短标识,用于给 agent 的说明消息。
func (a *Asset) InterceptLabel() string {
var target string
switch {
case a.Domain != "":
target = a.Domain
case a.URL != "":
target = a.URL
case a.IP != "":
target = a.IP
default:
target = fmt.Sprintf("#%d", a.ID)
}
return fmt.Sprintf("资产#%d[%s] %s", a.ID, a.Type, target)
}
// hasEnabledRule 判断规则集里是否存在任一启用规则。
func hasEnabledRule(rules []AssetInterceptRule) bool {
for _, r := range rules {
if r.Enabled {
return true
}
}
return false
}
// AssetGateDecision 是「先拦截后允许」闸门对一组候选串的判定结果。
type AssetGateDecision struct {
Allowed bool
Reason string // 被拒原因(不含资产标识);Allowed=true 时为空
}
// EvaluateAssetGate 执行任务级闸门判定:
// 1. 命中任一启用的 blockRules → 拒绝(拦截原因)。
// 2. 否则若 allowRules 存在启用项且都不命中 → 拒绝(不在允许范围)。
// 3. 否则放行。
//
// allowRules 为空/无启用项时,允许闸门不生效(即不启用白名单,全部放行),
// 避免「未配置允许规则」把所有资产挡掉。
func EvaluateAssetGate(blockRules, allowRules []AssetInterceptRule, domains, ips, urls []string) AssetGateDecision {
if rule, _, ok := MatchAssetInterceptRules(blockRules, domains, ips, urls); ok {
return AssetGateDecision{Allowed: false, Reason: rule.Reason()}
}
if hasEnabledRule(allowRules) {
if _, _, ok := MatchAssetInterceptRules(allowRules, domains, ips, urls); !ok {
return AssetGateDecision{Allowed: false, Reason: "不在任务允许(白名单)范围内,不允许测试"}
}
}
return AssetGateDecision{Allowed: true}
}
// AssetInterceptHit 描述一个被闸门拒绝的资产(拦截命中 或 不在允许范围)。
type AssetInterceptHit struct {
Asset *Asset
Reason string // 可读原因
}
// Describe 返回一条可读的说明:资产信息 + 原因。
func (h AssetInterceptHit) Describe() string {
return fmt.Sprintf("%s → %s", h.Asset.InterceptLabel(), h.Reason)
}
// ListAssetInterceptRules 是 *DB 同名方法的透传,让只持有 AssetStore 的调用方
// (如 agent 工具)也能读取规则。
func (s *AssetStore) ListAssetInterceptRules() ([]AssetInterceptRule, error) {
return s.db.ListAssetInterceptRules()
}
// CheckAssetsIntercept 按 id 载入资产,逐个执行「先拦截后允许」闸门判定,返回所有
// 被拒的资产。拦截规则 = 全局 ∪ 任务级 block;允许规则 = 任务级 allow(仅本任务)。
// 无 id 时快速返回。用全局 GetByIDs(不受任务范围过滤)以保证拦截不被 scope 削弱。
func (s *AssetStore) CheckAssetsIntercept(taskID int64, ids []int64) ([]AssetInterceptHit, error) {
if len(ids) == 0 {
return nil, nil
}
blockRules, err := s.db.ListAssetInterceptRules()
if err != nil {
return nil, err
}
var allowRules []AssetInterceptRule
if taskID > 0 {
tb, ta, err := s.TaskInterceptRulesSplit(taskID)
if err != nil {
return nil, err
}
blockRules = append(blockRules, tb...)
allowRules = ta
}
// 既无拦截规则、也无启用的允许规则 → 无需判定,全部放行。
if len(blockRules) == 0 && !hasEnabledRule(allowRules) {
return nil, nil
}
assets, err := s.GetByIDs(ids)
if err != nil {
return nil, err
}
var hits []AssetInterceptHit
for _, a := range assets {
domains, ips, urls := a.interceptCandidates()
if d := EvaluateAssetGate(blockRules, allowRules, domains, ips, urls); !d.Allowed {
hits = append(hits, AssetInterceptHit{Asset: a, Reason: d.Reason})
}
}
return hits, nil
}
+129
View File
@@ -0,0 +1,129 @@
package db
import "testing"
func rule(kind, pattern string, enabled bool) AssetInterceptRule {
return AssetInterceptRule{Kind: kind, Pattern: pattern, Enabled: enabled}
}
func TestMatchAssetInterceptRules(t *testing.T) {
cases := []struct {
name string
rules []AssetInterceptRule
domains []string
ips []string
urls []string
want bool
wantVal string
}{
{"内置模糊政府域名命中", []AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)},
[]string{"www.beijing.gov.cn"}, nil, nil, true, "www.beijing.gov.cn"},
{"模糊教育域名命中", []AssetInterceptRule{rule("fuzzy_domain", ".edu", true)},
[]string{"mit.edu"}, nil, nil, true, "mit.edu"},
{"全等域名命中大小写不敏感", []AssetInterceptRule{rule("exact_domain", "Example.com", true)},
[]string{"example.com"}, nil, nil, true, "example.com"},
{"全等域名不命中子域", []AssetInterceptRule{rule("exact_domain", "example.com", true)},
[]string{"a.example.com"}, nil, nil, false, ""},
{"全等IP命中", []AssetInterceptRule{rule("exact_ip", "203.0.113.5", true)},
nil, []string{"203.0.113.5"}, nil, true, "203.0.113.5"},
{"模糊IP前缀命中", []AssetInterceptRule{rule("fuzzy_ip", "203.0.113.", true)},
nil, []string{"203.0.113.99"}, nil, true, "203.0.113.99"},
{"CIDR 命中", []AssetInterceptRule{rule("cidr", "192.168.0.0/16", true)},
nil, []string{"192.168.5.20"}, nil, true, "192.168.5.20"},
{"CIDR 不命中", []AssetInterceptRule{rule("cidr", "192.168.0.0/16", true)},
nil, []string{"10.0.0.1"}, nil, false, ""},
{"全等URL命中", []AssetInterceptRule{rule("exact_url", "https://a.gov.cn/login", true)},
nil, nil, []string{"https://a.gov.cn/login"}, true, "https://a.gov.cn/login"},
{"模糊URL命中路径", []AssetInterceptRule{rule("fuzzy_url", "/admin", true)},
nil, nil, []string{"https://x.com/admin/panel"}, true, "https://x.com/admin/panel"},
{"禁用规则不命中", []AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", false)},
[]string{"www.gov.cn"}, nil, nil, false, ""},
{"无规则不命中", nil, []string{"www.gov.cn"}, nil, nil, false, ""},
{"空pattern不命中", []AssetInterceptRule{rule("fuzzy_domain", " ", true)},
[]string{"www.gov.cn"}, nil, nil, false, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r, val, ok := MatchAssetInterceptRules(c.rules, c.domains, c.ips, c.urls)
if ok != c.want {
t.Fatalf("命中 = %v, 期望 %v (rule=%+v)", ok, c.want, r)
}
if ok && val != c.wantVal {
t.Fatalf("命中值 = %q, 期望 %q", val, c.wantVal)
}
})
}
}
func TestEvaluateAssetGate(t *testing.T) {
block := []AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)}
allow := []AssetInterceptRule{rule("fuzzy_domain", "example.com", true)}
// 1. 命中拦截规则 → 拒绝(拦截原因优先)。
if d := EvaluateAssetGate(block, allow, []string{"www.gov.cn"}, nil, nil); d.Allowed {
t.Fatal("命中拦截规则应被拒绝")
}
// 2. 未命中拦截、有允许规则但不命中 → 拒绝(不允许)。
d := EvaluateAssetGate(block, allow, []string{"foo.other.com"}, nil, nil)
if d.Allowed {
t.Fatal("有白名单且不命中应被拒绝")
}
if d.Reason == "" {
t.Fatal("拒绝应带原因")
}
// 3. 未命中拦截、命中允许规则 → 放行。
if d := EvaluateAssetGate(block, allow, []string{"api.example.com"}, nil, nil); !d.Allowed {
t.Fatal("命中白名单应放行")
}
// 4. 无允许规则(白名单未启用)→ 未命中拦截即放行。
if d := EvaluateAssetGate(block, nil, []string{"foo.other.com"}, nil, nil); !d.Allowed {
t.Fatal("无白名单时未命中拦截应放行")
}
// 5. 允许规则全部禁用 → 视为白名单未启用,放行。
disabledAllow := []AssetInterceptRule{rule("fuzzy_domain", "example.com", false)}
if d := EvaluateAssetGate(nil, disabledAllow, []string{"foo.other.com"}, nil, nil); !d.Allowed {
t.Fatal("白名单全禁用时应放行")
}
// 6. 拦截优先于允许:同一目标既命中拦截又命中允许 → 拒绝。
if d := EvaluateAssetGate(
[]AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)},
[]AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)},
[]string{"www.gov.cn"}, nil, nil,
); d.Allowed {
t.Fatal("拦截应优先于允许")
}
}
func TestAssetInterceptCandidates(t *testing.T) {
// 只带 URL 的服务资产:host 应被拆出并归入域名候选,从而被 fuzzy_domain 命中。
a := &Asset{Type: "service", URL: "https://portal.beijing.gov.cn:8443/app"}
domains, _, urls := a.interceptCandidates()
if len(urls) != 1 || urls[0] != a.URL {
t.Fatalf("urls = %v", urls)
}
found := false
for _, d := range domains {
if d == "portal.beijing.gov.cn" {
found = true
}
}
if !found {
t.Fatalf("URL host 未拆入域名候选: %v", domains)
}
r, _, ok := MatchAssetInterceptRules([]AssetInterceptRule{rule("fuzzy_domain", ".gov.cn", true)}, domains, nil, urls)
if !ok {
t.Fatalf("仅带 URL 的政府服务资产应被 fuzzy_domain 命中, rule=%+v", r)
}
// URL host 是 IP 时应归入 IP 候选,可被 CIDR 命中。
b := &Asset{Type: "service", URL: "http://10.1.2.3/x"}
_, ips, _ := b.interceptCandidates()
if r, _, ok := MatchAssetInterceptRules([]AssetInterceptRule{rule("cidr", "10.0.0.0/8", true)}, nil, ips, nil); !ok {
t.Fatalf("URL 中的 IP 应被 CIDR 命中, ips=%v rule=%+v", ips, r)
}
}
+29
View File
@@ -1248,3 +1248,32 @@ CREATE INDEX IF NOT EXISTS idx_asset_intercept_enabled ON asset_intercept_rules(
DROP TRIGGER IF EXISTS trg_asset_intercept_rules_upd ON asset_intercept_rules;
CREATE TRIGGER trg_asset_intercept_rules_upd BEFORE UPDATE ON asset_intercept_rules
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =====================================================================
-- 任务级资产拦截/允许规则
-- 与全局 asset_intercept_rules 同构(kind/pattern/note/enabled),但按 task_id
-- 关联、随任务级联删除;创建任务时录入、任务详情里可编辑。
-- action: 'block'=拦截(禁止测试) 'allow'=允许(白名单)。
-- 执行判定:先按 拦截规则(全局 ∪ 任务block) 匹配,命中即禁止;未命中且该任务存在
-- 启用的 allow 规则时,须命中某条 allow 才放行,否则「不允许测试」。
-- =====================================================================
CREATE TABLE IF NOT EXISTS task_intercept_rules (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
action TEXT NOT NULL DEFAULT 'block' CHECK (action IN ('block','allow')),
kind TEXT NOT NULL CHECK (kind IN (
'exact_domain', 'exact_ip', 'exact_url',
'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url',
'cidr')),
pattern TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_task_intercept_task ON task_intercept_rules(task_id);
-- 补旧库(本会话早前建过该表、无 action 列):加列(带 IF NOT EXISTS)。
ALTER TABLE task_intercept_rules ADD COLUMN IF NOT EXISTS action TEXT NOT NULL DEFAULT 'block';
DROP TRIGGER IF EXISTS trg_task_intercept_rules_upd ON task_intercept_rules;
CREATE TRIGGER trg_task_intercept_rules_upd BEFORE UPDATE ON task_intercept_rules
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+122
View File
@@ -0,0 +1,122 @@
package db
import (
"database/sql"
"fmt"
)
// TaskInterceptRuleInput is one task-level rule supplied at task creation.
// Action: 'block'=拦截 'allow'=允许(白名单);空视为 'block'。
type TaskInterceptRuleInput struct {
Enabled bool `json:"enabled"`
Action string `json:"action"`
Kind string `json:"kind"`
Pattern string `json:"pattern"`
Note string `json:"note"`
}
const taskInterceptRuleCols = `id, enabled, action, kind, pattern, note, created_at, updated_at`
func scanTaskInterceptRule(row interface{ Scan(...any) error }) (AssetInterceptRule, error) {
var r AssetInterceptRule
err := row.Scan(&r.ID, &r.Enabled, &r.Action, &r.Kind, &r.Pattern, &r.Note, &r.CreatedAt, &r.UpdatedAt)
return r, err
}
// ListTaskInterceptRules returns a task's rules (both block and allow) as
// AssetInterceptRule (Builtin always false; Action carries block/allow). taskID
// <= 0 returns nothing.
func (s *AssetStore) ListTaskInterceptRules(taskID int64) ([]AssetInterceptRule, error) {
if taskID <= 0 {
return nil, nil
}
rows, err := s.db.Query(`SELECT `+taskInterceptRuleCols+` FROM task_intercept_rules WHERE task_id=$1 ORDER BY action, id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AssetInterceptRule
for rows.Next() {
r, err := scanTaskInterceptRule(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// TaskInterceptRulesSplit loads a task's rules and splits them into block and
// allow sets, for the enforcement gate.
func (s *AssetStore) TaskInterceptRulesSplit(taskID int64) (block, allow []AssetInterceptRule, err error) {
rules, err := s.ListTaskInterceptRules(taskID)
if err != nil {
return nil, nil, err
}
for _, r := range rules {
if r.Action == "allow" {
allow = append(allow, r)
} else {
block = append(block, r)
}
}
return block, allow, nil
}
func normalizeRuleAction(action string) string {
if action == "allow" {
return "allow"
}
return "block"
}
// CreateTaskInterceptRule inserts a rule under a task.
func (s *AssetStore) CreateTaskInterceptRule(taskID int64, action, kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
row := s.db.QueryRow(`
INSERT INTO task_intercept_rules(task_id, enabled, action, kind, pattern, note)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING `+taskInterceptRuleCols,
taskID, enabled, normalizeRuleAction(action), kind, pattern, note)
return scanTaskInterceptRule(row)
}
// UpdateTaskInterceptRule replaces the editable fields of a task's rule (scoped
// by task_id so a rule can only be edited through its owning task).
func (s *AssetStore) UpdateTaskInterceptRule(taskID, ruleID int64, action, kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
row := s.db.QueryRow(`
UPDATE task_intercept_rules
SET enabled=$3, action=$4, kind=$5, pattern=$6, note=$7
WHERE id=$1 AND task_id=$2
RETURNING `+taskInterceptRuleCols,
ruleID, taskID, enabled, normalizeRuleAction(action), kind, pattern, note)
return scanTaskInterceptRule(row)
}
// DeleteTaskInterceptRule removes a task's rule. Returns false if not found.
func (s *AssetStore) DeleteTaskInterceptRule(taskID, ruleID int64) (bool, error) {
res, err := s.db.Exec(`DELETE FROM task_intercept_rules WHERE id=$1 AND task_id=$2`, ruleID, taskID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ToggleTaskInterceptRule flips the enabled state of a task's rule.
func (s *AssetStore) ToggleTaskInterceptRule(taskID, ruleID int64, enabled bool) error {
_, err := s.db.Exec(`UPDATE task_intercept_rules SET enabled=$3 WHERE id=$1 AND task_id=$2`, ruleID, taskID, enabled)
return err
}
// insertTaskInterceptRules inserts task-level rules within the task-creation
// transaction (mirrors insertTaskCompanies).
func insertTaskInterceptRules(tx *sql.Tx, taskID int64, rules []TaskInterceptRuleInput) error {
for _, r := range rules {
if _, err := tx.Exec(`
INSERT INTO task_intercept_rules(task_id, enabled, action, kind, pattern, note)
VALUES ($1,$2,$3,$4,$5,$6)`, taskID, r.Enabled, normalizeRuleAction(r.Action), r.Kind, r.Pattern, r.Note); err != nil {
return fmt.Errorf("insert task intercept rule %q: %w", r.Pattern, err)
}
}
return nil
}
+5
View File
@@ -154,6 +154,8 @@ type TaskCreateOptions struct {
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
CoverageEnabled *bool
// InterceptRules 是任务级资产拦截规则,创建时随任务在同一事务内写入 task_intercept_rules。
InterceptRules []TaskInterceptRuleInput
}
// CreateTaskWithOptions creates an exploration, task, direct source relations,
@@ -238,6 +240,9 @@ RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, descripti
if err := insertTaskLLMProfiles(tx, t.ID, opts.LLMProfileIDs); err != nil {
return nil, err
}
if err := insertTaskInterceptRules(tx, t.ID, opts.InterceptRules); err != nil {
return nil, err
}
if len(opts.LLMProfileIDs) == 0 {
t.LLMFailoverState = "default"
} else {