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

+15
View File
@@ -1211,6 +1211,21 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
priority = 5
}
anchors := pidList(it.AssetIDs)
// 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。
if t.as != nil && len(anchors) > 0 {
hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors)
if err != nil {
return 0, fmt.Errorf("资产拦截校验失败:%w", err)
}
if len(hits) > 0 {
var b strings.Builder
fmt.Fprintf(&b, "意图「%s」绑定的资产未通过测试范围校验,请停止对相关资产进行测试:", it.Summary)
for _, h := range hits {
fmt.Fprintf(&b, "\n - %s", h.Describe())
}
return 0, fmt.Errorf("%s", b.String())
}
}
payload := map[string]any{"summary": it.Summary}
if len(anchors) > 0 {
payload["asset_ids"] = anchors
+74
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/url"
"strconv"
"strings"
@@ -11,6 +13,57 @@ import (
actool "github.com/Autumn-27/norma/tool"
)
// assetInterceptCandidates 提取一条待插入资产输入项的 域名/IP/URL 候选串,用于资产拦截匹配。
// URL 的 host 会拆出归类,使「只带 URL」的服务/端点资产也能被 域名/IP 规则命中。
func assetInterceptCandidates(item assetInputItem) (domains, ips, urls []string) {
add := func(dst *[]string, s string) {
if s = strings.TrimSpace(s); s != "" {
*dst = append(*dst, s)
}
}
add(&domains, item.Domain)
for _, d := range item.BoundDomains {
add(&domains, d)
}
add(&ips, item.IP)
add(&ips, item.ServiceIP)
add(&urls, item.URL)
if item.URL != "" {
if u, err := url.Parse(item.URL); err == nil {
if h := u.Hostname(); h != "" {
if net.ParseIP(h) != nil {
add(&ips, h)
} else {
add(&domains, h)
}
}
}
}
return domains, ips, urls
}
// assetInputLabel 返回一条待插入资产的简短标识,用于拦截说明消息。
func assetInputLabel(item assetInputItem) string {
typ := strings.TrimSpace(item.Type)
var target string
switch {
case strings.TrimSpace(item.Domain) != "":
target = strings.TrimSpace(item.Domain)
case strings.TrimSpace(item.URL) != "":
target = strings.TrimSpace(item.URL)
case strings.TrimSpace(item.IP) != "":
target = strings.TrimSpace(item.IP)
case strings.TrimSpace(item.ServiceIP) != "":
target = strings.TrimSpace(item.ServiceIP)
default:
target = "(未知)"
}
if typ != "" {
return fmt.Sprintf("[%s] %s", typ, target)
}
return target
}
// =====================================================================
// Unified asset insertion tools
// =====================================================================
@@ -174,7 +227,28 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
var results []result
var errs []errEntry
// 资产闸门规则一次性载入;读取失败则跳过判定(不阻断插入)。
// 拦截规则 = 全局 ∪ 任务级 block;允许规则 = 任务级 allow。
blockRules, _ := t.as.ListAssetInterceptRules()
var allowRules []db.AssetInterceptRule
if t.taskID > 0 {
if tb, ta, err := t.as.TaskInterceptRulesSplit(t.taskID); err == nil {
blockRules = append(blockRules, tb...)
allowRules = ta
}
}
for i, item := range a.Assets {
// 资产闸门:先拦截后允许,被拒的资产禁止插入(跳过 Upsert 及后续副作用)。
domains, ips, urls := assetInterceptCandidates(item)
if d := db.EvaluateAssetGate(blockRules, allowRules, domains, ips, urls); !d.Allowed {
errs = append(errs, errEntry{
Index: i,
Error: fmt.Sprintf("资产 %s %s,已禁止插入", assetInputLabel(item), d.Reason),
})
continue
}
typ := strings.TrimSpace(item.Type)
var id int64
var err error
+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 {
+14
View File
@@ -680,6 +680,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
mux.HandleFunc("PATCH /api/tasks/{id}", s.updateTaskMetadata)
mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory)
// 任务级资产拦截/允许规则
mux.HandleFunc("GET /api/tasks/{id}/intercept-rules", s.taskInterceptListRules)
mux.HandleFunc("POST /api/tasks/{id}/intercept-rules", s.taskInterceptCreateRule)
mux.HandleFunc("PUT /api/tasks/{id}/intercept-rules/{rid}", s.taskInterceptUpdateRule)
mux.HandleFunc("DELETE /api/tasks/{id}/intercept-rules/{rid}", s.taskInterceptDeleteRule)
mux.HandleFunc("POST /api/tasks/{id}/intercept-rules/{rid}/toggle", s.taskInterceptToggleRule)
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
mux.HandleFunc("GET /api/task-archives", s.listTaskArchives)
mux.HandleFunc("GET /api/task-archives/{id}", s.getTaskArchive)
@@ -1427,6 +1433,8 @@ type createTaskReq struct {
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
}
func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
@@ -1473,11 +1481,17 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
return
}
req.CompanyIDs = companyIDs
interceptRules, err := buildTaskInterceptRules(req.InterceptRules)
if err != nil {
writeErr(w, 400, "任务级拦截规则无效:"+err.Error())
return
}
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled,
InterceptRules: interceptRules,
})
if err != nil {
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
+189
View File
@@ -0,0 +1,189 @@
package server
import (
"fmt"
"net/http"
"github.com/Autumn-27/artex/db"
)
// 任务级资产拦截/允许规则的 CRUD。规则按 task_id 归属,仅对该任务生效:
// action=block 拦截(禁止测试),action=allow 允许(白名单)。执行判定见 db.EvaluateAssetGate。
type taskInterceptRuleReq struct {
Enabled bool `json:"enabled"`
Action string `json:"action"` // block | allow
Kind string `json:"kind"`
Pattern string `json:"pattern"`
Note string `json:"note"`
}
// validateTaskInterceptRuleReq 归一并校验;复用全局规则的 kind/pattern 校验器。
func validateTaskInterceptRuleReq(req *taskInterceptRuleReq) error {
if req.Action == "" {
req.Action = "block"
}
if req.Action != "block" && req.Action != "allow" {
return fmt.Errorf("action 必须是 block 或 allow")
}
v := assetInterceptRuleReq{Enabled: req.Enabled, Kind: req.Kind, Pattern: req.Pattern, Note: req.Note}
if err := validateAssetInterceptRuleReq(&v); err != nil {
return err
}
req.Pattern = v.Pattern // 已 trim
return nil
}
// buildTaskInterceptRules 校验创建任务时录入的任务级规则并转换为 db 输入形态。
func buildTaskInterceptRules(reqs []taskInterceptRuleReq) ([]db.TaskInterceptRuleInput, error) {
if len(reqs) == 0 {
return nil, nil
}
out := make([]db.TaskInterceptRuleInput, 0, len(reqs))
for i := range reqs {
rq := reqs[i]
if err := validateTaskInterceptRuleReq(&rq); err != nil {
return nil, err
}
out = append(out, db.TaskInterceptRuleInput{
Enabled: rq.Enabled,
Action: rq.Action,
Kind: rq.Kind,
Pattern: rq.Pattern,
Note: rq.Note,
})
}
return out, nil
}
func (s *Server) taskInterceptListRules(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
taskID, ok := pathInt(r, "id")
if !ok || taskID <= 0 {
writeErr(w, 400, "bad task id")
return
}
rules, err := pg.Assets().ListTaskInterceptRules(taskID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if rules == nil {
rules = []db.AssetInterceptRule{}
}
writeJSON(w, 200, map[string]any{"rules": rules})
}
func (s *Server) taskInterceptCreateRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
taskID, ok := pathInt(r, "id")
if !ok || taskID <= 0 {
writeErr(w, 400, "bad task id")
return
}
var req taskInterceptRuleReq
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := validateTaskInterceptRuleReq(&req); err != nil {
writeErr(w, 400, err.Error())
return
}
rule, err := pg.Assets().CreateTaskInterceptRule(taskID, req.Action, req.Kind, req.Pattern, req.Note, req.Enabled)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, rule)
}
func (s *Server) taskInterceptUpdateRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
taskID, ok := pathInt(r, "id")
if !ok || taskID <= 0 {
writeErr(w, 400, "bad task id")
return
}
ruleID, ok := pathInt(r, "rid")
if !ok || ruleID <= 0 {
writeErr(w, 400, "bad rule id")
return
}
var req taskInterceptRuleReq
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := validateTaskInterceptRuleReq(&req); err != nil {
writeErr(w, 400, err.Error())
return
}
rule, err := pg.Assets().UpdateTaskInterceptRule(taskID, ruleID, req.Action, req.Kind, req.Pattern, req.Note, req.Enabled)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, rule)
}
func (s *Server) taskInterceptDeleteRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
taskID, ok := pathInt(r, "id")
if !ok || taskID <= 0 {
writeErr(w, 400, "bad task id")
return
}
ruleID, ok := pathInt(r, "rid")
if !ok || ruleID <= 0 {
writeErr(w, 400, "bad rule id")
return
}
deleted, err := pg.Assets().DeleteTaskInterceptRule(taskID, ruleID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": deleted})
}
func (s *Server) taskInterceptToggleRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
taskID, ok := pathInt(r, "id")
if !ok || taskID <= 0 {
writeErr(w, 400, "bad task id")
return
}
ruleID, ok := pathInt(r, "rid")
if !ok || ruleID <= 0 {
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.Assets().ToggleTaskInterceptRule(taskID, ruleID, req.Enabled); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true, "enabled": req.Enabled})
}
@@ -26,8 +26,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import type {
AssetInterceptKind,
AssetInterceptRule,
Finding,
ModelTokenStat,
Stats,
@@ -697,6 +700,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
)}
</CardContent>
</Card>
<TaskInterceptRulesCard taskId={taskId} />
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
<Card>
<CardHeader>
@@ -1045,3 +1049,274 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div>
);
}
const TASK_RULE_KIND_OPTIONS: { value: AssetInterceptKind; label: string; placeholder: string }[] = [
{ value: "exact_domain", label: "域名(全等)", placeholder: "example.gov.cn" },
{ value: "exact_ip", label: "IP(全等)", placeholder: "203.0.113.10" },
{ value: "exact_url", label: "URL(全等)", placeholder: "https://example.com/login" },
{ value: "fuzzy_domain", label: "域名(模糊)", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", label: "IP(模糊)", placeholder: "203.0.113." },
{ value: "fuzzy_url", label: "URL(模糊)", placeholder: "/admin" },
{ value: "cidr", label: "CIDR 网段", placeholder: "192.168.0.0/16" },
];
const TASK_RULE_KIND_LABEL: Record<AssetInterceptKind, string> = Object.fromEntries(
TASK_RULE_KIND_OPTIONS.map((o) => [o.value, o.label]),
) as Record<AssetInterceptKind, string>;
// TaskInterceptRulesCard 在任务详情总览里管理「任务级资产拦截 / 允许规则」:
// 列表 + 新增 + 行内编辑 + 删除 + 启用开关。规则仅本任务生效,不进全局表。
function TaskInterceptRulesCard({ taskId }: { taskId: string }) {
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState("");
const [newAction, setNewAction] = React.useState<"block" | "allow">("block");
const [newKind, setNewKind] = React.useState<AssetInterceptKind>("fuzzy_domain");
const [newPattern, setNewPattern] = React.useState("");
const [newNote, setNewNote] = React.useState("");
const [editId, setEditId] = React.useState<number | null>(null);
const [editAction, setEditAction] = React.useState<"block" | "allow">("block");
const [editKind, setEditKind] = React.useState<AssetInterceptKind>("fuzzy_domain");
const [editPattern, setEditPattern] = React.useState("");
const [editNote, setEditNote] = React.useState("");
const load = React.useCallback(async () => {
try {
setRules(await api.taskInterceptRules(taskId));
} catch {
// 忽略瞬时错误
}
}, [taskId]);
React.useEffect(() => {
void load();
}, [load]);
async function add() {
if (!newPattern.trim()) return;
setBusy(true);
setErr("");
try {
await api.createTaskInterceptRule(taskId, {
action: newAction,
kind: newKind,
pattern: newPattern.trim(),
note: newNote.trim(),
enabled: true,
});
setNewPattern("");
setNewNote("");
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
function startEdit(r: AssetInterceptRule) {
setEditId(r.id);
setEditAction(r.action ?? "block");
setEditKind(r.kind);
setEditPattern(r.pattern);
setEditNote(r.note);
setErr("");
}
async function saveEdit(r: AssetInterceptRule) {
if (!editPattern.trim()) return;
setBusy(true);
setErr("");
try {
await api.updateTaskInterceptRule(taskId, r.id, {
action: editAction,
kind: editKind,
pattern: editPattern.trim(),
note: editNote.trim(),
enabled: r.enabled,
});
setEditId(null);
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
async function remove(r: AssetInterceptRule) {
setRules((prev) => prev.filter((x) => x.id !== r.id));
try {
await api.deleteTaskInterceptRule(taskId, r.id);
} catch {
await load();
}
}
async function toggle(r: AssetInterceptRule) {
setRules((prev) => prev.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)));
try {
await api.toggleTaskInterceptRule(taskId, r.id, !r.enabled);
} catch {
await load();
}
}
const placeholder = TASK_RULE_KIND_OPTIONS.find((o) => o.value === newKind)?.placeholder ?? "";
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheckIcon className="size-4 text-sky-500" /> 任务级资产拦截 / 允许
<span className="text-muted-foreground text-xs font-normal">
(仅本任务生效,不进全局;先拦截后允许,共 {rules.length} 条)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<NativeSelect size="sm" value={newAction} onChange={(e) => setNewAction(e.target.value as "block" | "allow")}>
<NativeSelectOption value="block">拦截</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<NativeSelect size="sm" value={newKind} onChange={(e) => setNewKind(e.target.value as AssetInterceptKind)}>
{TASK_RULE_KIND_OPTIONS.map((o) => (
<NativeSelectOption key={o.value} value={o.value}>
{o.label}
</NativeSelectOption>
))}
</NativeSelect>
<Input
className="h-7 min-w-56 flex-1 text-sm"
placeholder={placeholder}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void add();
}}
disabled={busy}
/>
<Input
className="h-7 w-36 text-sm"
placeholder="备注(可选)"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
disabled={busy}
/>
<Button size="sm" variant="outline" disabled={busy || !newPattern.trim()} onClick={() => void add()}>
<PlusIcon className="size-3.5" /> 添加
</Button>
{err && <span className="text-xs text-red-500">{err}</span>}
</div>
{/* 规则列表 */}
{rules.length > 0 ? (
<div className="flex flex-col gap-1.5">
{rules.map((r) =>
editId === r.id ? (
<div key={r.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
<NativeSelect
size="sm"
value={editAction}
onChange={(e) => setEditAction(e.target.value as "block" | "allow")}
>
<NativeSelectOption value="block">拦截</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<NativeSelect
size="sm"
value={editKind}
onChange={(e) => setEditKind(e.target.value as AssetInterceptKind)}
>
{TASK_RULE_KIND_OPTIONS.map((o) => (
<NativeSelectOption key={o.value} value={o.value}>
{o.label}
</NativeSelectOption>
))}
</NativeSelect>
<Input
className="h-7 min-w-56 flex-1 text-sm"
value={editPattern}
onChange={(e) => setEditPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEdit(r);
if (e.key === "Escape") setEditId(null);
}}
disabled={busy}
autoFocus
/>
<Input
className="h-7 w-36 text-sm"
placeholder="备注(可选)"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
disabled={busy}
/>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={busy || !editPattern.trim()}
onClick={() => void saveEdit(r)}
>
<CheckIcon className="size-3.5 text-emerald-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={busy}
onClick={() => setEditId(null)}
>
<XIcon className="size-3.5" />
</Button>
</div>
) : (
<div key={r.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-xs ${
r.action === "allow"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/15 text-red-600 dark:text-red-400"
}`}
>
{r.action === "allow" ? "允许" : "拦截"}
</span>
<span className="text-muted-foreground shrink-0 text-xs">{TASK_RULE_KIND_LABEL[r.kind]}</span>
<code className="bg-muted min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-xs">{r.pattern}</code>
{r.note && (
<span className="text-muted-foreground max-w-[120px] shrink-0 truncate text-xs">{r.note}</span>
)}
<Switch checked={r.enabled} onCheckedChange={() => void toggle(r)} />
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={busy}
onClick={() => startEdit(r)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={busy}
onClick={() => void remove(r)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
</div>
),
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
暂无任务级规则。「拦截」命中即禁止测试;「允许」为白名单——配置后本任务只允许命中允许规则的资产(未配置则不启用白名单)。
</p>
)}
</CardContent>
</Card>
);
}
+101
View File
@@ -98,6 +98,7 @@ import {
import { Input } from "@/components/ui/input";
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -121,6 +122,8 @@ import { api } from "@/lib/api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
import type {
AssetInterceptKind,
AssetInterceptRuleInput,
ChatAttachment,
Company,
DeleteTaskOptions,
@@ -3032,6 +3035,92 @@ function CategoryManagementSheet({
);
}
const TASK_INTERCEPT_KIND_OPTIONS: { value: AssetInterceptKind; label: string; placeholder: string }[] = [
{ value: "exact_domain", label: "域名(全等)", placeholder: "example.gov.cn" },
{ value: "exact_ip", label: "IP(全等)", placeholder: "203.0.113.10" },
{ value: "exact_url", label: "URL(全等)", placeholder: "https://example.com/login" },
{ value: "fuzzy_domain", label: "域名(模糊)", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", label: "IP(模糊)", placeholder: "203.0.113." },
{ value: "fuzzy_url", label: "URL(模糊)", placeholder: "/admin" },
{ value: "cidr", label: "CIDR 网段", placeholder: "192.168.0.0/16" },
];
// TaskInterceptRulesField 是创建任务时录入「任务级资产拦截规则」的多行编辑区。
// 规则只对本任务生效、不进全局表;与全局规则合并后在 agent 执行时匹配。
function TaskInterceptRulesField({
value,
onChange,
}: {
value: AssetInterceptRuleInput[];
onChange: (v: AssetInterceptRuleInput[]) => void;
}) {
function update(i: number, patch: Partial<AssetInterceptRuleInput>) {
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
function add() {
onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]);
}
return (
<div className="grid gap-2">
{value.map((r, i) => {
const ph = TASK_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
return (
<div key={i} className="flex items-center gap-2">
<NativeSelect
size="sm"
className="w-[84px] shrink-0"
value={r.action}
onChange={(e) => update(i, { action: e.target.value as "block" | "allow" })}
>
<NativeSelectOption value="block">拦截</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<NativeSelect
size="sm"
className="w-[120px] shrink-0"
value={r.kind}
onChange={(e) => update(i, { kind: e.target.value as AssetInterceptKind })}
>
{TASK_INTERCEPT_KIND_OPTIONS.map((o) => (
<NativeSelectOption key={o.value} value={o.value}>
{o.label}
</NativeSelectOption>
))}
</NativeSelect>
<Input
className="flex-1"
placeholder={ph}
value={r.pattern}
onChange={(e) => update(i, { pattern: e.target.value })}
/>
<Input
className="w-[120px] shrink-0"
placeholder="备注(可选)"
value={r.note}
onChange={(e) => update(i, { note: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8 shrink-0"
onClick={() => remove(i)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
);
})}
<Button type="button" size="sm" variant="outline" className="w-fit" onClick={add}>
<PlusIcon className="size-4" /> 添加一条
</Button>
</div>
);
}
function CreateTaskSheet({
tasks,
categories,
@@ -3059,6 +3148,7 @@ function CreateTaskSheet({
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // 任务级资产拦截规则(仅本任务生效,不进全局表)
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
@@ -3125,6 +3215,9 @@ function CreateTaskSheet({
seedFirstIntent,
planHeartbeatSeconds: heartbeatSec,
coverageEnabled,
interceptRules: interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""),
});
toast.success("任务已创建");
setName("");
@@ -3139,6 +3232,7 @@ function CreateTaskSheet({
setHeartbeatMin("10");
setSeedFirstIntent(false);
setCoverageEnabled(true);
setInterceptRules([]);
setUploadCount(0);
draftIdRef.current = "";
setOpen(false);
@@ -3274,6 +3368,13 @@ function CreateTaskSheet({
作为范围上下文;不会自动生成意图或强制改变执行目标。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="task-intercept-rules">任务级资产拦截 / 允许规则(可选)</FieldLabel>
<TaskInterceptRulesField value={interceptRules} onChange={setInterceptRules} />
<FieldDescription>
仅对本任务生效,不写入全局规则。判定顺序:先按「拦截」规则(含全局)匹配,命中即禁止测试;未命中且本任务配置了「允许」规则时,须命中某条允许规则才放行,否则同样不允许测试;未配置任何允许规则则不启用白名单。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="llm-profiles">LLM 配置链</FieldLabel>
<TaskLLMProfileChain
+15
View File
@@ -15,6 +15,7 @@ import type {
ArchiveBatchItem,
Asset,
AssetInterceptRule,
AssetInterceptRuleInput,
Audit,
BatchCategoryItem,
BatchControlItem,
@@ -243,6 +244,7 @@ export const api = {
seedFirstIntent?: boolean;
planHeartbeatSeconds?: number;
coverageEnabled?: boolean;
interceptRules?: AssetInterceptRuleInput[];
}) =>
post<Task>("/tasks", {
name: input.name ?? "",
@@ -256,6 +258,7 @@ export const api = {
seed_first_intent: input.seedFirstIntent ?? false,
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
@@ -388,6 +391,18 @@ export const api = {
deleteConstraint: (id: string, constraintId: string) =>
del<{ ok: boolean }>(`/tasks/${id}/constraints/${constraintId}`),
// ---- 任务级资产拦截/允许规则(总览)----
taskInterceptRules: (id: string) =>
get<{ rules: AssetInterceptRule[] | null }>(`/tasks/${id}/intercept-rules`).then((r) => arr(r.rules)),
createTaskInterceptRule: (id: string, rule: AssetInterceptRuleInput) =>
post<AssetInterceptRule>(`/tasks/${id}/intercept-rules`, rule),
updateTaskInterceptRule: (id: string, ruleId: number, rule: AssetInterceptRuleInput) =>
put<AssetInterceptRule>(`/tasks/${id}/intercept-rules/${ruleId}`, rule),
deleteTaskInterceptRule: (id: string, ruleId: number) =>
del<{ deleted: boolean }>(`/tasks/${id}/intercept-rules/${ruleId}`),
toggleTaskInterceptRule: (id: string, ruleId: number, enabled: boolean) =>
post<{ ok: boolean; enabled: boolean }>(`/tasks/${id}/intercept-rules/${ruleId}/toggle`, { enabled }),
// 本任务测试范围列表(含继承自来源任务的范围)。
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
+13
View File
@@ -1201,9 +1201,22 @@ export type AssetInterceptKind =
| "fuzzy_url"
| "cidr";
// action 仅用于任务级规则:block=拦截(禁止测试) allow=允许(白名单)。
export type AssetInterceptAction = "block" | "allow";
// 任务级资产拦截/允许规则的录入项(创建任务、任务详情编辑使用)。
export interface AssetInterceptRuleInput {
action: AssetInterceptAction;
kind: AssetInterceptKind;
pattern: string;
note: string;
enabled: boolean;
}
export interface AssetInterceptRule {
id: number;
enabled: boolean;
action?: AssetInterceptAction; // 全局规则不带此字段(恒为拦截);任务级规则区分 block/allow
kind: AssetInterceptKind;
pattern: string;
note: string;