feat(constraints): 操作约束抽取→注入 planner/worker 提示→总览管理

- 新表 task_constraints(allow/deny),CREATE TABLE IF NOT EXISTS 无需迁移
- 新工具 set_constraints:目标拆解器 round-0 先抽约束再拆目标、主 agent 运行时可补;
  拆解器提示词要求约束自包含(把「当前目标」等指代替换成具体域名/端口)
- constraintBlock 按最高优先级注入 planner+worker 系统提示;去偏:多样性/拓面显式服从约束
- 注入范围可配置(constraints_inject_planner/worker,默认都开,每轮读、即时生效)
- 总览「约束管理」卡片 + CRUD 接口(增删改不通知 planner,下轮规划自然读到)
- goals 提示词把抽约束步并入可编辑正文;旧库经 flag 守卫的一次性 reseed 追加新默认版本
This commit is contained in:
Autumn-27 committed 2026-08-23 12:48:01 -04:00
1 parent d8f34cc79d
commit 27c3248844
19 files changed
+820 -15

No files matched your search

@@ -13,6 +13,7 @@ import {
PencilIcon,
PlusIcon,
RefreshCwIcon,
ShieldAlertIcon,
ShieldCheckIcon,
TargetIcon,
Trash2Icon,
@@ -26,7 +27,16 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { api } from "@/lib/api";
import type { Finding, ModelTokenStat, Stats, Task, TaskGoal, TaskNode, TaskScopeRow } from "@/lib/types";
import type {
Finding,
ModelTokenStat,
Stats,
Task,
TaskConstraint,
TaskGoal,
TaskNode,
TaskScopeRow,
} from "@/lib/types";
// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
function fmtTokens(n: number): string {
@@ -119,6 +129,15 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const [editingGoalId, setEditingGoalId] = React.useState<string | null>(null);
const [editText, setEditText] = React.useState("");
const [editVuln, setEditVuln] = React.useState("");
// 约束管理:约束列表 + 新增表单 + 行内编辑状态。
const [constraints, setConstraints] = React.useState<TaskConstraint[]>([]);
const [conText, setConText] = React.useState("");
const [conKind, setConKind] = React.useState<TaskConstraint["kind"]>("deny");
const [conBusy, setConBusy] = React.useState(false);
const [conErr, setConErr] = React.useState("");
const [editingConId, setEditingConId] = React.useState<string | null>(null);
const [editConText, setEditConText] = React.useState("");
const [editConKind, setEditConKind] = React.useState<TaskConstraint["kind"]>("deny");
const loadTokens = React.useCallback(async () => {
try {
@@ -201,6 +220,68 @@ export function OverviewTab({ taskId }: { taskId: string }) {
}
};
const loadConstraints = React.useCallback(async () => {
try {
const resp = await api.taskConstraints(taskId);
setConstraints(resp.constraints);
} catch {
// 忽略:瞬时错误,下次轮询重试
}
}, [taskId]);
const addConstraint = async () => {
const text = conText.trim();
if (!text) return;
setConBusy(true);
setConErr("");
try {
await api.addConstraint(taskId, text, conKind);
setConText("");
await loadConstraints();
} catch (e) {
setConErr(e instanceof Error ? e.message : "添加失败");
} finally {
setConBusy(false);
}
};
const startEditConstraint = (c: TaskConstraint) => {
setEditingConId(c.id);
setEditConText(c.text);
setEditConKind(c.kind);
};
const cancelEditConstraint = () => {
setEditingConId(null);
setEditConText("");
setEditConKind("deny");
};
const saveEditConstraint = async (c: TaskConstraint) => {
const text = editConText.trim();
if (!text) return;
setConBusy(true);
setConErr("");
try {
await api.updateConstraint(taskId, c.id, text, editConKind);
cancelEditConstraint();
await loadConstraints();
} catch (e) {
setConErr(e instanceof Error ? e.message : "保存失败");
} finally {
setConBusy(false);
}
};
const removeConstraint = async (c: TaskConstraint) => {
setConstraints((prev) => prev.filter((x) => x.id !== c.id));
try {
await api.deleteConstraint(taskId, c.id);
} catch {
await loadConstraints(); // 删除失败:重新拉取还原
}
};
const addScope = async () => {
const value = scopeValueInput.trim();
if (!value) return;
@@ -293,16 +374,18 @@ export function OverviewTab({ taskId }: { taskId: string }) {
void loadScope();
void loadTokens();
void loadGoals();
void loadConstraints();
const timer = setInterval(() => {
void load();
void loadTokens();
void loadGoals();
void loadConstraints();
}, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId, loadScope, loadTokens, loadGoals]);
}, [taskId, loadScope, loadTokens, loadGoals, loadConstraints]);
const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open");
@@ -464,6 +547,129 @@ export function OverviewTab({ taskId }: { taskId: string }) {
)}
</CardContent>
</Card>
{/* 操作约束管理:allow=允许 / deny=禁止。约束会在下一轮规划时注入 planner/worker 的系统
提示以框定探索边界(注入范围可在 系统设置 里按 planner/worker 开关)。改动不即时打断,
下一轮规划自然读到。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlertIcon className="size-4 text-amber-500" /> 操作约束
<span className="text-muted-foreground text-xs font-normal">
(框定 planner/worker 的探索边界,共 {constraints.length} 条;改动下一轮规划生效)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
size="sm"
value={conKind}
onChange={(e) => setConKind(e.target.value as TaskConstraint["kind"])}
>
<NativeSelectOption value="deny">禁止</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 min-w-56 flex-1 text-sm"
placeholder="一条操作约束,如『仅测当前端口,不扫其他端口』"
value={conText}
onChange={(e) => setConText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addConstraint();
}}
disabled={conBusy}
/>
<Button size="sm" variant="outline" disabled={conBusy || !conText.trim()} onClick={() => void addConstraint()}>
<PlusIcon className="size-3.5" /> 添加
</Button>
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
</div>
{/* 约束列表 */}
{constraints.length > 0 ? (
<div className="flex flex-col gap-1.5">
{constraints.map((c) =>
editingConId === c.id ? (
<div key={c.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
<NativeSelect
size="sm"
value={editConKind}
onChange={(e) => setEditConKind(e.target.value as TaskConstraint["kind"])}
>
<NativeSelectOption value="deny">禁止</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 min-w-56 flex-1 text-sm"
value={editConText}
onChange={(e) => setEditConText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEditConstraint(c);
if (e.key === "Escape") cancelEditConstraint();
}}
disabled={conBusy}
autoFocus
/>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy || !editConText.trim()}
onClick={() => void saveEditConstraint(c)}
>
<CheckIcon className="size-3.5 text-emerald-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy}
onClick={cancelEditConstraint}
>
<XIcon className="size-3.5" />
</Button>
</div>
) : (
<div key={c.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 ${
c.kind === "allow"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/15 text-red-600 dark:text-red-400"
}`}
>
{c.kind === "allow" ? "允许" : "禁止"}
</span>
<span className="min-w-0 flex-1 break-words">{c.text}</span>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy}
onClick={() => startEditConstraint(c)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy}
onClick={() => void removeConstraint(c)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
</div>
),
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
暂无操作约束。建任务时会自动从描述/目标抽取;也可在此手动增删改,用来框定「允许/禁止做哪些操作」。
</p>
)}
</CardContent>
</Card>
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
<Card>
<CardHeader>
+62 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { CpuIcon, KeyboardIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
import { CpuIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -33,6 +33,9 @@ export default function SystemSettingsPage() {
const [pyInterp, setPyInterp] = React.useState("");
const [workers, setWorkers] = React.useState("3");
const [savingWorkers, setSavingWorkers] = React.useState(false);
// 操作约束注入范围(默认都开)。
const [injectPlanner, setInjectPlanner] = React.useState(true);
const [injectWorker, setInjectWorker] = React.useState(true);
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
const sendMode = useChatSendMode();
@@ -45,6 +48,8 @@ export default function SystemSettingsPage() {
setProxyInput(s.web_search_proxy || "");
setPyInterp(s.python_interpreter || "");
setWorkers(String(s.workers ?? 3));
setInjectPlanner(s.constraints_inject_planner !== false);
setInjectWorker(s.constraints_inject_worker !== false);
}, []);
const saveWorkers = () => {
@@ -102,6 +107,22 @@ export default function SystemSettingsPage() {
.finally(() => setSaving(false));
};
const toggleInjectPlanner = (v: boolean) => {
setInjectPlanner(v); // optimistic
api
.setSettings({ constraints_inject_planner: v })
.then(apply)
.catch(() => setInjectPlanner(!v)); // revert on failure
};
const toggleInjectWorker = (v: boolean) => {
setInjectWorker(v); // optimistic
api
.setSettings({ constraints_inject_worker: v })
.then(apply)
.catch(() => setInjectWorker(!v)); // revert on failure
};
// Persist a web-search patch (enable and/or backend). Optimistic with refetch.
const saveWebSearch = (patch: Partial<Settings>) => {
setSaving(true);
@@ -220,6 +241,46 @@ export default function SystemSettingsPage() {
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlertIcon className="size-4" />
操作约束注入
</CardTitle>
<CardDescription>
开启后,把每个任务的<b>操作约束</b>(在任务总览「操作约束」里维护的 allow/deny 条目)拼进对应 Agent
的系统提示,用来框定探索边界(如「仅测当前端口」「禁止爆破」)。
<br />
可分别控制注入到 <b>规划者(planner)</b>与 <b>执行者(worker)</b>;默认都开。切换即时生效(下一轮读取),无需重建
Agent。关闭后该 Agent 不再看到约束。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="inject-planner" className="text-sm font-normal text-muted-foreground">
注入规划者(planner){injectPlanner ? " · 已开启" : " · 已关闭"}
</Label>
<Switch
id="inject-planner"
checked={injectPlanner}
disabled={!loaded}
onCheckedChange={toggleInjectPlanner}
/>
</div>
<div className="flex items-center justify-between gap-4">
<Label htmlFor="inject-worker" className="text-sm font-normal text-muted-foreground">
注入执行者(worker){injectWorker ? " · 已开启" : " · 已关闭"}
</Label>
<Switch
id="inject-worker"
checked={injectWorker}
disabled={!loaded}
onCheckedChange={toggleInjectWorker}
/>
</div>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
+14
View File
@@ -56,6 +56,7 @@ import type {
SSTask,
Stats,
Task,
TaskConstraint,
TaskGoal,
TaskLLMResolutions,
TaskNode,
@@ -269,6 +270,19 @@ export const api = {
// 人工删除目标(硬删除):通知 planner,删除不复活任务。
deleteGoal: (id: string, goalId: string) => del<{ ok: boolean }>(`/tasks/${id}/goals/${goalId}`),
// ---- 操作约束管理(总览)----
// 本任务全部操作约束(allow/deny)。
taskConstraints: (id: string) => get<{ constraints: TaskConstraint[] }>(`/tasks/${id}/constraints`),
// 新增约束(不通知 planner,下一轮规划自然读到)。
addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) =>
post<TaskConstraint>(`/tasks/${id}/constraints`, { text, kind }),
// 修改约束(文本 + allow/deny)。
updateConstraint: (id: string, constraintId: string, text: string, kind: TaskConstraint["kind"]) =>
patch<TaskConstraint>(`/tasks/${id}/constraints/${constraintId}`, { text, kind }),
// 删除约束。
deleteConstraint: (id: string, constraintId: string) =>
del<{ ok: boolean }>(`/tasks/${id}/constraints/${constraintId}`),
// 本任务测试范围列表(含继承自来源任务的范围)。
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
+13
View File
@@ -309,6 +309,16 @@ export interface TaskGoal {
ts: string;
}
// 约束管理卡片用的操作约束(allow=允许 / deny=禁止)。
export type ConstraintKind = "allow" | "deny";
export interface TaskConstraint {
id: string;
kind: ConstraintKind;
text: string;
origin?: string;
ts?: string;
}
// ---- Findings ----
export type Severity = "critical" | "high" | "medium" | "low";
@@ -704,6 +714,9 @@ export interface Settings {
llm_pool_enabled?: boolean; // 默认 false
// 绑定了指定配置的 agent/任务失败时是否也回落到轮询链。默认 false = 绑定即独占。
llm_pool_bind_fallback?: boolean;
// 操作约束注入范围(默认都开):把任务的 allow/deny 约束拼进对应 agent 的系统提示。
constraints_inject_planner?: boolean;
constraints_inject_worker?: boolean;
}
// ---- LLM config ----