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:
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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
Reference in new issue
Block a user