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:
1 parent
dfc9d2fa2e
commit
d5359e1f04
14 files changed
+1234
No files matched your search
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user