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

+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})
}