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