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

@@ -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;