From 27c324884435c375f549d33e17ab515a18ec524a Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:48:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(constraints):=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E6=8A=BD=E5=8F=96=E2=86=92=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=20planner/worker=20=E6=8F=90=E7=A4=BA=E2=86=92=E6=80=BB?= =?UTF-8?q?=E8=A7=88=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 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 追加新默认版本 --- agent/constraints.go | 49 ++++ agent/goals.go | 18 +- agent/mainagent.go | 1 + agent/planner.go | 17 +- agent/toolcatalog.go | 6 +- agent/tools.go | 74 ++++++ agent/tools_insert.go | 2 + agent/worker.go | 19 +- db/constraints.go | 85 +++++++ db/schema.sql | 15 ++ server/constraints_api.go | 177 +++++++++++++++ server/dto.go | 9 + server/orchestration.go | 31 +++ server/server.go | 30 ++- server/task_llm.go | 2 + .../tasks/detail/_tabs/overview-tab.tsx | 210 +++++++++++++++++- web/src/app/(main)/system/settings/page.tsx | 63 +++++- web/src/lib/api.ts | 14 ++ web/src/lib/types.ts | 13 ++ 19 files changed, 820 insertions(+), 15 deletions(-) create mode 100644 agent/constraints.go create mode 100644 db/constraints.go create mode 100644 server/constraints_api.go diff --git a/agent/constraints.go b/agent/constraints.go new file mode 100644 index 0000000..66093d4 --- /dev/null +++ b/agent/constraints.go @@ -0,0 +1,49 @@ +package agent + +import ( + "strings" + + "github.com/Autumn-27/artex/db" +) + +// constraintBlock renders this task's operation constraints (task_constraints) as a +// high-priority block appended to the planner/worker system prompt. allow/deny are +// grouped; empty string when there are no constraints (or ts is nil). The framing +// deliberately puts these ABOVE the exploration/expansion heuristics so a declared +// boundary wins the tug-of-war against "chase another entry surface". +func constraintBlock(ts *db.ExplorationStore) string { + if ts == nil { + return "" + } + rows, err := ts.ListConstraints() + if err != nil || len(rows) == 0 { + return "" + } + var allow, deny []string + for _, c := range rows { + text := strings.TrimSpace(c.Text) + if text == "" { + continue + } + if c.Kind == "allow" { + allow = append(allow, "- "+text) + } else { + deny = append(deny, "- "+text) + } + } + if len(allow) == 0 && len(deny) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n\n【操作约束(最高优先级,凌驾于下方一切探索/拓面启发式;每生成一条意图、每执行一个动作前都必须先自检是否违反,违反即不得进行)】:") + if len(allow) > 0 { + b.WriteString("\n允许的操作:\n") + b.WriteString(strings.Join(allow, "\n")) + } + if len(deny) > 0 { + b.WriteString("\n禁止的操作:\n") + b.WriteString(strings.Join(deny, "\n")) + } + b.WriteString("\n(发现约束之外的新目标/新端口/新主机,不等于获得授权:除非它落在上述允许范围内,否则记为 out-of-scope 事实并跳过,不得为其派生意图或执行动作。)") + return b.String() +} diff --git a/agent/goals.go b/agent/goals.go index 3501157..388515a 100644 --- a/agent/goals.go +++ b/agent/goals.go @@ -16,6 +16,17 @@ import ( // prompt, seeded into agent_prompts. No template vars are used today. const goalsDefaultTmpl = `你是渗透测试目标分解器。你的职责是从用户输入中识别出**最终要达成的结果**,而不是规划攻击步骤。 +**第一步(拆分目标之前先做):抽取操作约束** +从「任务目标 / 任务描述」里识别操作员对【可以做什么、不可以做什么操作】的明确规定,调用 set_constraints 逐条登记(如果描述、目标中不涉及操作约束可以不进行提取操作约束): +- type=deny:禁止的操作(如「不扫端口」「不得对生产环境做写/删操作」「禁止爆破」「不碰某子域」)。 +- type=allow:明确允许/限定的操作范围(如「只允许被动侦察」「仅针对某域名」)。 +- 约束 ≠ 目标,也 ≠ 攻击步骤:它是对操作行为边界的规定。 +- **约束必须【自包含、写死具体目标】**:把「当前目标/当前端口/当前IP/当前域名/本站」这类**指代词**替换成任务目标/描述里的**具体值**。约束会被单独注入到执行阶段的提示里,脱离上下文后指代词无法判断指谁。 + 例:目标是 https://abc.example.net → 写「只允许测试 abc.example.net」而不是「只允许测试当前目标」;「仅测目标端口 443,不扫其他端口」而不是「只测当前端口」。若原文只说「当前目标」但目标地址已明确,就把地址填进去。 +- **只登记目标/描述里【明确写出或强调】的约束,严禁臆造**;拿不准类型时用 deny(更保守)。 +- 若目标/描述里确实没有任何操作约束,则**不要**调用 set_constraints。 +登记完约束(如有)后,再进行下面的目标拆分。 + **目标 = 最终可交付/可核验的结果** **不是目标的内容(禁止列为子目标)**: @@ -105,7 +116,9 @@ func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, // {{.EngagementDescription}} template var — else a prompt that references the var // would inject the description twice. System prompt stays pure static instructions. sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{DataDir: dataDir, Now: nowStr()}) - tools := []actool.CoreTool{tsx.setGoals()} + // set_constraints 始终可用(不依赖 asset store):正文已含「先抽操作约束再拆目标」这步 + // (可在 agent 编辑页改措辞),这里只需接上工具。 + tools := []actool.CoreTool{tsx.setGoals(), tsx.setConstraints()} // Wire add_task_scope only when we have a real asset store + task to write to. // The scope-extraction tail is appended in lockstep so the prompt never asks for // a tool that isn't present. @@ -131,7 +144,8 @@ func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, Tools: tools, PermissionMode: acperm.ModeBypass, DisableBackgroundTasks: true, - MaxTurns: 6, + // 3 步(抽约束 → 登记范围 → 拆目标)各需一次工具调用,给足回合避免收尾前漏调 set_goals。 + MaxTurns: 8, }, userMsg, captureEmit) // set_goals persisted the goals directly; read them back so the caller sees what // was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back). diff --git a/agent/mainagent.go b/agent/mainagent.go index 55cd134..be9e573 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -65,6 +65,7 @@ const mainAgentDefaultTmpl = `你是一个授权渗透测试系统的"主 agent" - 人想"立刻测某个具体目标" → 用 add_intent 直接注入一条高优先级意图(priority 8-10)。 - 人想"对某条正在运行的意图(work)实时纠偏(别再走 X、聚焦 Y)" → 用 steer_work(不打断、不丢已有进展,worker 下一步动作前生效);先用 get_worker_output 看它在干嘛。方向整个错了则改用 add_intent 另下新意图。 - 人想"新增一个要达成的最终目标" → 用 set_goals 增补目标。系统会把该目标写入任务图并**自动把已完成/暂停的任务拉回运行态继续跑**(规划者随后会据此重新判断是否达成),无需人工再点恢复。 + - 人想"增/改测试约束(允许/禁止某类操作,如『仅测当前端口』『禁止爆破』『只做被动侦察』)" → 用 set_constraints 登记(type=allow 允许 / type=deny 禁止)。约束会在下一轮规划时注入 planner/worker 的提示词以框定探索边界;也可在总览「约束管理」里增删改。 3. 用人话简洁回复,说明你做了什么。 当前任务目标:{{.Goal}} diff --git a/agent/planner.go b/agent/planner.go index e08e544..dfacc91 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -32,6 +32,7 @@ type Planner struct { proxyCACert string // recording proxy's CA cert path (HTTPS verify) webSearch WebSearchOpts // web_search tool backend selection (off by default) workDir string // shared work dir (surfaced in prompt as artifact-output target) + injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes) // todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the // planner's multi-step plan survives across wake-ups — each Plan() is a fresh @@ -61,6 +62,14 @@ func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = a // SetWebSearch selects the web_search backend for the planner (off by default). func (p *Planner) SetWebSearch(o WebSearchOpts) { p.webSearch = o } +// SetConstraintInject wires a resolver deciding whether this task's operation +// constraints get injected into the planner system prompt. Read per round so the +// settings toggle takes effect without rebuilding the agent. nil = inject (default). +func (p *Planner) SetConstraintInject(fn func() bool) { p.injectConstraints = fn } + +// wantConstraints reports whether constraint injection is enabled (default yes). +func (p *Planner) wantConstraints() bool { return p.injectConstraints == nil || p.injectConstraints() } + // todoFor returns the task's persistent planning todo store, creating it on first // use. Shared across all of this task's planner wake-ups. func (p *Planner) todoFor(expID int64) *actool.TodoStore { @@ -245,7 +254,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" - 该方向在 recent_done_intents 里已尝试过(即使没产出)→ **不要原样重试**。把它当作【已封锁路线(blocked)】:仅当出现【材料性的新机理】——新事实、新资产、新参数、或一种明显不同的打法/构造——才重新派,且新意图的 summary 里要写清"这次和上次不同在哪"。**只是换个措辞、或"再试一次说不定行"都不算新机理,禁止重试。** 反之,若某条否定结论是 confidence=inferred 的弱证据、且该方向对目标关键,用【一条复核意图】去证实或推翻它是正当的(这属于新理由)。 - 仅对【当前完全没有任何意图覆盖的全新方向】生成。 - 所有已知方向都已被现有意图覆盖 → **不生成任何意图,直接结束本轮。** - - **保持路线多样、别过早收敛到一条**:当目标尚未达成时,若现有意图全都挤在【同一条攻击路线/同一类入口】上,而还存在【本质不同】的未覆盖方向(如另一种入口面、另一类资产、另一条利用链),优先补一条那样的分歧方向,而不是在同一条线上再加同义意图。理想状态是让 2–3 条彼此独立、机理不同的路线同时存活(如"从上传链打"与"从认证绕过打");只有当某条路线已交出【目标逼近】的证据时,才值得把资源集中过去。判断多样性看方向的实质差异,不看措辞。(这不与"0 意图正常"冲突:只有确存在本质不同且未覆盖的方向才补;空白路线若已被现有意图覆盖,仍旧 0 意图。) + - **保持路线多样、别过早收敛到一条**:当目标尚未达成时,若现有意图全都挤在【同一条攻击路线/同一类入口】上,而还存在【本质不同】的未覆盖方向(如另一种入口面、另一类资产、另一条利用链),优先补一条那样的分歧方向,而不是在同一条线上再加同义意图。**但这条【多样性】永远服从于顶部的【操作约束】**:只在不违反约束的前提下才拓展方向——被约束排除的入口面/端口/主机/操作,即使"本质不同"也绝不生成意图。理想状态是让 2–3 条彼此独立、机理不同的路线同时存活(如"从上传链打"与"从认证绕过打");只有当某条路线已交出【目标逼近】的证据时,才值得把资源集中过去。判断多样性看方向的实质差异,不看措辞。(这不与"0 意图正常"冲突:只有确存在本质不同且未覆盖的方向才补;空白路线若已被现有意图覆盖,仍旧 0 意图。) 3.5. **串行利用链:分步派,别一次拆成并行。** 很多利用是一条**强依赖串行链**(如:①→ ② → ③)——后一步依赖前一步的**实际产出**。这种情况: - **不要**把 ①②③ 一次性作为多个并行意图下发(下游 worker 拿不到还不存在的前置产出,只会重复/空转); - **用 TodoWrite 把整条链记成待办**(每步一条),然后**本轮只派"前置已满足"的那一步**(通常是第一步); @@ -303,7 +312,11 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts } // 本任务的工作目录 /tasks/,先建好。 taskDir := ensureRunDir(p.workDir, taskID, 0) - system, boundary := deferredSystem(plannerSystem(goal, p.workDir, taskDir), def) + sysBody := plannerSystem(goal, p.workDir, taskDir) + if p.wantConstraints() { + sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,框定探索边界 + } + system, boundary := deferredSystem(sysBody, def) // planner 无自身墙钟预算;有 deadline 时把 MaxDuration 夹逼到剩余,让在跑的规划轮在 // 任务到点时进收尾(因超时→任务超时词,因步数→per-run 词)。 maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, 0) diff --git a/agent/toolcatalog.go b/agent/toolcatalog.go index d564efe..77b7cb2 100644 --- a/agent/toolcatalog.go +++ b/agent/toolcatalog.go @@ -37,9 +37,9 @@ func builtinToolsByAgent() map[string][]actool.CoreTool { "mainagent": ts.MainAgentTools(), "planner": ts.PlannerTools(), "worker": ts.WorkerTools(), - // goals(目标拆解器)默认绑 set_goals:它靠这个工具把拆出的目标写进图。 - // 与 mainagent 共用同一受管工具,web 端可改描述/schema、按 agent 勾选。 - "goals": {ts.setGoals()}, + // goals(目标拆解器)默认绑 set_goals + set_constraints:靠它们把拆出的目标、 + // 抽出的操作约束写进库。与 mainagent 共用同一受管工具,web 端可改描述/schema、按 agent 勾选。 + "goals": {ts.setGoals(), ts.setConstraints()}, // auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。 // 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。 "auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()}, diff --git a/agent/tools.go b/agent/tools.go index 53073d0..4002889 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -1402,6 +1402,80 @@ func (t *ToolSet) setGoals() actool.CoreTool { }) } +type constraintItem struct { + Text string `json:"text"` + Type string `json:"type"` // allow | deny +} + +// addOneConstraint 落一条操作约束到 task_constraints。origin 取 t.worker(缺省 system): +// 拆解器写 "goals"、主 agent 写 "human"。 +func (t *ToolSet) addOneConstraint(it constraintItem) (int64, error) { + text := strings.TrimSpace(it.Text) + if text == "" { + return 0, fmt.Errorf("text 不能为空") + } + kind := strings.TrimSpace(strings.ToLower(it.Type)) + if kind == "" { + kind = "deny" // 默认按禁止处理:未标注类型时更保守 + } + if kind != "allow" && kind != "deny" { + return 0, fmt.Errorf("type 必须是 allow 或 deny") + } + return t.ts.AddConstraint(kind, text, t.worker) +} + +// setConstraints 给【本任务】新增操作约束(allow=允许做什么 / deny=禁止做什么)。既是目标 +// 拆解器 round-0 抽约束的提交工具,也是主 agent 运行时补约束的工具——同一受管工具,可在 web +// 端改描述/schema、按 agent 绑定。约束会被注入 planner/worker 的系统提示以约束探索边界。 +func (t *ToolSet) setConstraints() actool.CoreTool { + return writeTool("set_constraints", + "给【本任务】新增操作约束,用来框定探索边界:type=allow(允许做的操作)或 deny(禁止做的操作)。\n"+ + "约束=对『可以/不可以做哪些操作』的规定(如『仅测当前端口,不扫其他端口』『禁止对生产库做写操作』『只允许被动侦察』),不是目标、也不是攻击步骤。\n"+ + "★优先批量:多条放进 constraints 数组一次提交,返回 ids 与之等长同序(失败项 id=0,详情见 errors)。单条则省略 constraints 直接给顶层 text/type。\n"+ + "只登记任务目标/描述里【明确写出】的约束,不要臆造;拿不准类型时用 deny(更保守)。", + obj(map[string]any{ + "constraints": map[string]any{"type": "array", "description": "【优先用这个】要新增的约束数组,按顺序处理。每个元素:text(必填,一条约束)+ type(allow|deny)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}}, + "text": str("[单条] 一条操作约束的内容"), + "type": str("[单条] allow(允许)或 deny(禁止);缺省按 deny 处理"), + }), + func(_ context.Context, in json.RawMessage) (actool.Result, error) { + if t.ts == nil { + return actool.Errorf("set_constraints 未启用: ExplorationStore 未初始化"), nil + } + var a struct { + Constraints []constraintItem `json:"constraints"` + constraintItem // 单条模式:顶层 text/type + } + _ = json.Unmarshal(in, &a) + batch := len(a.Constraints) > 0 + items := a.Constraints + if !batch { + items = []constraintItem{a.constraintItem} + } + ids := make([]int64, len(items)) + errs := map[string]string{} + for i, it := range items { + id, err := t.addOneConstraint(it) + if err != nil { + errs[strconv.Itoa(i)] = err.Error() + continue + } + ids[i] = id + } + if !batch { // 单条:保持简单返回 + if e, bad := errs["0"]; bad { + return actool.Errorf(e), nil + } + return actool.Text(fmt.Sprintf("constraint added: %d", ids[0])), nil + } + out := map[string]any{"ids": ids} + if len(errs) > 0 { + out["errors"] = errs + } + return jsonResult(out) + }) +} + func (t *ToolSet) addHint() actool.CoreTool { return writeTool("add_hint", "把人类/主 agent 的战略提示挂到探索图,规划者下次生成意图时会读到它。\n"+ "★优先批量:多条提示放进 hints 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 hints 等长同序(失败项 id=0,详情见 errors)。单条则省略 hints 直接给顶层 text。", diff --git a/agent/tools_insert.go b/agent/tools_insert.go index f8c3943..e0b194a 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -615,6 +615,8 @@ func (t *ToolSet) MainAgentTools() []actool.CoreTool { t.steerWorkTool(), // set_goals:人可在运行时给本任务补一个新的最终目标(规划者据此重判是否达成)。 t.setGoals(), + // set_constraints:人可在运行时给本任务补/改操作约束(allow/deny),约束 planner/worker 的探索边界。 + t.setConstraints(), // asset management (handlers guard nil store internally) t.insertAssets(), t.addCompanyScope(), t.listAssets(), t.addFinding(), t.recordFact(), diff --git a/agent/worker.go b/agent/worker.go index 99125c9..f2e5ce8 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -58,8 +58,19 @@ type Worker struct { // extraTools are host-provided tools (e.g. traffic query, oast) appended to // the worker's graph write-back tools. extraTools []actool.CoreTool + // injectConstraints resolves whether this task's operation constraints get + // injected into the worker system prompt. Read per run so the settings toggle + // takes effect without rebuilding the agent. nil = inject (default). + injectConstraints func() bool } +// SetConstraintInject wires a resolver deciding whether this task's operation +// constraints get injected into the worker system prompt. nil = inject (default). +func (w *Worker) SetConstraintInject(fn func() bool) { w.injectConstraints = fn } + +// wantConstraints reports whether constraint injection is enabled (default yes). +func (w *Worker) wantConstraints() bool { return w.injectConstraints == nil || w.injectConstraints() } + // SetRunTimeout configures the per-intent wall-clock budget for the main // exploration (0 = unlimited). When it fires, the SDK settlement phase still runs // so facts are never lost to a timeout. Safe to call before Execute. @@ -151,7 +162,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo - report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。 - list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 -只在授权范围内操作。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。` +只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。` // workerTrafficBlock is 段 [B]: the traffic-tool note, code-injected only when // traffic capture is on (proxyAddr set). Not stored, not editable. @@ -278,7 +289,11 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db. // 压缩。本次意图的专属工作目录 /tasks//i,引擎侧先建好。 runDir := ensureRunDir(w.workDir, taskID, intent.ID) overview := renderWorkerGraphOverview(tsx.graphOverviewData()) - system, boundary := deferredSystem(workerSystem(w.proxyAddr, w.workDir, runDir), def) + sysBody := workerSystem(w.proxyAddr, w.workDir, runDir) + if w.wantConstraints() { + sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,worker 执行时严格遵守 + } + system, boundary := deferredSystem(sysBody, def) // 任务级 deadline(经 ctx 注入)夹逼本 run 的墙钟预算 + 决定收尾词(见 taskclock.go)。 tc := taskClockFrom(ctx) maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, w.runTimeout) diff --git a/db/constraints.go b/db/constraints.go new file mode 100644 index 0000000..6ffc788 --- /dev/null +++ b/db/constraints.go @@ -0,0 +1,85 @@ +package db + +import ( + "fmt" + "time" +) + +// Constraint is one operator-authored operation constraint for a task: kind=allow +// (permitted operations) or kind=deny (forbidden operations), free-text. Stored in +// task_constraints, keyed by exploration_id (cascades with the exploration). +type Constraint struct { + ID int64 `json:"id"` + Kind string `json:"kind"` // allow | deny + Text string `json:"text"` + Origin string `json:"origin,omitempty"` // goals | human | system + CreatedAt time.Time `json:"created_at"` +} + +// ListConstraints returns this exploration's constraints, allow before deny, oldest +// first within each group (stable render order for the prompt block + UI). +func (s *ExplorationStore) ListConstraints() ([]Constraint, error) { + rows, err := s.db.Query(` +SELECT id, kind, text, COALESCE(origin,''), created_at +FROM task_constraints WHERE exploration_id=$1 +ORDER BY (kind='deny'), id`, s.expID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Constraint + for rows.Next() { + var c Constraint + if err := rows.Scan(&c.ID, &c.Kind, &c.Text, &c.Origin, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// AddConstraint inserts one constraint (kind must be allow|deny) and returns its id. +func (s *ExplorationStore) AddConstraint(kind, text, origin string) (int64, error) { + if kind != "allow" && kind != "deny" { + return 0, fmt.Errorf("kind 必须是 allow 或 deny") + } + if origin == "" { + origin = "system" + } + var id int64 + err := s.db.QueryRow(` +INSERT INTO task_constraints(exploration_id, kind, text, origin) +VALUES ($1, $2, $3, $4) RETURNING id`, s.expID, kind, text, origin).Scan(&id) + return id, err +} + +// UpdateConstraint rewrites a constraint's kind + text; scoped to this exploration. +// Returns an error if no such constraint exists. +func (s *ExplorationStore) UpdateConstraint(id int64, kind, text string) error { + if kind != "allow" && kind != "deny" { + return fmt.Errorf("kind 必须是 allow 或 deny") + } + res, err := s.db.Exec(` +UPDATE task_constraints SET kind=$1, text=$2, updated_at=now() +WHERE id=$3 AND exploration_id=$4`, kind, text, id, s.expID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("约束不存在") + } + return nil +} + +// DeleteConstraint removes a constraint; scoped to this exploration. Returns an +// error if no such constraint exists. +func (s *ExplorationStore) DeleteConstraint(id int64) error { + res, err := s.db.Exec(`DELETE FROM task_constraints WHERE id=$1 AND exploration_id=$2`, id, s.expID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("约束不存在") + } + return nil +} diff --git a/db/schema.sql b/db/schema.sql index 5c0afac..5c517b6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -220,6 +220,21 @@ CREATE TABLE IF NOT EXISTS exploration_anchors ( ); CREATE INDEX IF NOT EXISTS idx_anchor_asset ON exploration_anchors(asset_id); +-- task_constraints: operator-authored operation constraints (allow/deny) for a task. +-- Extracted by the goals decomposer at round 0 (from goal/description), editable at +-- runtime by the main agent + 总览「约束管理」. Injected into the planner/worker system +-- prompt each round (config-gated) to keep exploration within the operator's boundary. +CREATE TABLE IF NOT EXISTS task_constraints ( + id BIGSERIAL PRIMARY KEY, + exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('allow','deny')), + text TEXT NOT NULL, + origin TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_task_constraints_exp ON task_constraints(exploration_id); + CREATE TABLE IF NOT EXISTS activity ( id BIGSERIAL PRIMARY KEY, exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, diff --git a/server/constraints_api.go b/server/constraints_api.go new file mode 100644 index 0000000..bb36b0c --- /dev/null +++ b/server/constraints_api.go @@ -0,0 +1,177 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/Autumn-27/artex/db" +) + +// 总览「约束管理」的人工 CRUD 接口 + 注入范围开关的解析。操作约束(allow/deny)与 agent 侧的 +// set_constraints 工具写同一张 task_constraints 表;这里是人类在 UI 上直接增删改。约束仅是 +// 提示上下文——增删改后【不】通知 planner,下一轮规划自然读库生效(按产品决策)。每个变更 handler +// 都走 beginTaskOperation/decInflight,避免与任务删除竞态(与目标/意图 CRUD 一致)。 + +// 注入范围开关的 settings key,默认都开(GetBool 第二参数 = true)。 +const ( + settingConstraintsInjectPlanner = "constraints_inject_planner" + settingConstraintsInjectWorker = "constraints_inject_worker" +) + +// constraintInjectPlanner / constraintInjectWorker 报告是否把操作约束注入对应 agent 的 +// 系统提示(默认开)。作为 resolver 传给 planner/worker,每轮读 → 改开关即时生效。 +func (s *Server) constraintInjectPlanner() bool { + return s.m.pg.GetBool(settingConstraintsInjectPlanner, true) +} + +func (s *Server) constraintInjectWorker() bool { + return s.m.pg.GetBool(settingConstraintsInjectWorker, true) +} + +// listConstraints 返回本任务的全部操作约束(allow 在前、deny 在后)。 +func (s *Server) listConstraints(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + rows, err := t.Store.ListConstraints() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"constraints": constraintDTOs(rows)}) +} + +// addConstraint 人工新增一条操作约束(kind=allow|deny)。不通知 planner。 +func (s *Server) addConstraint(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法新增约束") + return + } + defer s.engine.decInflight(t.ID) + + var body struct { + Text string `json:"text"` + Kind string `json:"kind"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + text := strings.TrimSpace(body.Text) + if text == "" { + writeErr(w, 400, "约束内容不能为空") + return + } + kind := normalizeConstraintKind(body.Kind) + if kind == "" { + writeErr(w, 400, "kind 必须是 allow 或 deny") + return + } + id, err := t.Store.AddConstraint(kind, text, "human") + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, ConstraintDTO{ID: strconv.FormatInt(id, 10), Kind: kind, Text: text, Origin: "human"}) +} + +// editConstraint 人工修改一条约束(kind + text)。不通知 planner。 +func (s *Server) editConstraint(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法修改约束") + return + } + defer s.engine.decInflight(t.ID) + + cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64) + if err != nil || cid <= 0 { + writeErr(w, 400, "bad constraint id") + return + } + var body struct { + Text string `json:"text"` + Kind string `json:"kind"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + text := strings.TrimSpace(body.Text) + if text == "" { + writeErr(w, 400, "约束内容不能为空") + return + } + kind := normalizeConstraintKind(body.Kind) + if kind == "" { + writeErr(w, 400, "kind 必须是 allow 或 deny") + return + } + if err := t.Store.UpdateConstraint(cid, kind, text); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, ConstraintDTO{ID: strconv.FormatInt(cid, 10), Kind: kind, Text: text}) +} + +// deleteConstraint 人工删除一条约束。不通知 planner。 +func (s *Server) deleteConstraint(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法删除约束") + return + } + defer s.engine.decInflight(t.ID) + + cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64) + if err != nil || cid <= 0 { + writeErr(w, 400, "bad constraint id") + return + } + if err := t.Store.DeleteConstraint(cid); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +// normalizeConstraintKind lowercases + validates the kind; "" on invalid. +func normalizeConstraintKind(k string) string { + k = strings.TrimSpace(strings.ToLower(k)) + if k == "allow" || k == "deny" { + return k + } + return "" +} + +// constraintDTOs converts db rows to the frontend shape. +func constraintDTOs(in []db.Constraint) []ConstraintDTO { + out := make([]ConstraintDTO, 0, len(in)) + for _, c := range in { + out = append(out, ConstraintDTO{ + ID: strconv.FormatInt(c.ID, 10), + Kind: c.Kind, + Text: c.Text, + Origin: c.Origin, + TS: rfc3339(c.CreatedAt), + }) + } + return out +} diff --git a/server/dto.go b/server/dto.go index 5e9ade9..f30881c 100644 --- a/server/dto.go +++ b/server/dto.go @@ -208,6 +208,15 @@ func goalDTOs(in []*db.Node) []GoalDTO { return out } +// ConstraintDTO is one operation constraint (allow/deny) for the 总览「约束管理」UI. +type ConstraintDTO struct { + ID string `json:"id"` + Kind string `json:"kind"` // allow | deny + Text string `json:"text"` + Origin string `json:"origin,omitempty"` + TS string `json:"ts,omitempty"` +} + func taskNodeDTOs(in []*db.Node) []TaskNodeDTO { out := make([]TaskNodeDTO, 0, len(in)) for _, n := range in { diff --git a/server/orchestration.go b/server/orchestration.go index 5ee78f0..3d4b104 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -450,6 +450,7 @@ func (s *Server) seedOrchestrationTools() { s.seedPlannerDefaultBindings() s.seedAutoReportFindingBinding() s.unbindGoalMetDefault() + s.reseedGoalsPrompt() // goals 提示词加入「抽操作约束」步 → 旧库追加一版新默认(一次性) s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性) // 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把 // list_assets/insert_assets/report_finding/list_findings/list_companies 连同 @@ -509,6 +510,36 @@ func (s *Server) unbindGoalMetDefault() { _ = s.m.pg.SetSetting(flag, "true") } +// reseedGoalsPrompt 把 goals 目标拆解器的提示词刷成【当前代码默认】——因为默认正文新增了 +// 「先抽操作约束(set_constraints)再拆目标」这一步,而 SeedPromptIfEmpty 首插入only,旧库 +// 已有的 version 1 收不到这步。这里用版本管理【追加一个新版本】并切过去(ResetPromptToDefault), +// 旧的版本仍保留在历史里,用户若自定义过可从版本记录找回。settings flag 守卫 → 只做一次; +// 以后默认再变就 bump 这个 flag。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。 +func (s *Server) reseedGoalsPrompt() { + const flag = "goals_prompt_constraint_step_v1" + if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { + return + } + defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次 + a, err := s.m.pg.GetAgentByKey("goals") + if err != nil || a == nil { + return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移 + } + tmpl := agent.BuiltinPromptSeeds()["goals"] + if tmpl == "" { + return + } + // 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。 + if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl { + return + } + if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil { + log.Printf("[prompts] goals 提示词重刷为新默认失败: %v", err) + return + } + log.Printf("[prompts] goals 提示词已追加新默认版本(加入抽操作约束步,一次性)") +} + // seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除): // 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的 // 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。 diff --git a/server/server.go b/server/server.go index 6b9c20d..4ee4864 100644 --- a/server/server.go +++ b/server/server.go @@ -387,12 +387,14 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory"))) wk.SetWebSearch(s.webSearchFor("worker")) + wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读) pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg) pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner")) - pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work - pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction - pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy + pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work + pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction + pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy pl.SetWebSearch(s.webSearchFor("planner")) + pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读) return pl, wk } @@ -669,6 +671,10 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务) mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务) mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活) + mux.HandleFunc("GET /api/tasks/{id}/constraints", s.listConstraints) // 约束管理:列出本任务操作约束 + mux.HandleFunc("POST /api/tasks/{id}/constraints", s.addConstraint) // 约束管理:新增约束(不通知 planner) + mux.HandleFunc("PATCH /api/tasks/{id}/constraints/{cid}", s.editConstraint) // 约束管理:修改约束 + mux.HandleFunc("DELETE /api/tasks/{id}/constraints/{cid}", s.deleteConstraint) // 约束管理:删除约束 mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler) @@ -3005,6 +3011,9 @@ func (s *Server) settingsPayload() map[string]any { // 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。 "llm_pool_enabled": s.m.LLMPoolEnabled(), "llm_pool_bind_fallback": s.m.LLMPoolBindFallback(), + // 操作约束注入范围(默认都开):把本任务的 allow/deny 约束拼进对应 agent 的系统提示。 + "constraints_inject_planner": s.constraintInjectPlanner(), + "constraints_inject_worker": s.constraintInjectWorker(), } } @@ -3045,11 +3054,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { // 重建 provider 链才生效,走下面的 changed → applyLLM 路径。 LLMPoolEnabled *bool `json:"llm_pool_enabled"` LLMPoolBindFallback *bool `json:"llm_pool_bind_fallback"` + // 操作约束注入范围开关(默认都开);即时生效(planner/worker 每轮读),无需重建 agent。 + ConstraintsInjectPlanner *bool `json:"constraints_inject_planner"` + ConstraintsInjectWorker *bool `json:"constraints_inject_worker"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) return } + if req.ConstraintsInjectPlanner != nil { + if err := s.m.pg.SetBool(settingConstraintsInjectPlanner, *req.ConstraintsInjectPlanner); err != nil { + writeErr(w, 500, err.Error()) + return + } + } + if req.ConstraintsInjectWorker != nil { + if err := s.m.pg.SetBool(settingConstraintsInjectWorker, *req.ConstraintsInjectWorker); err != nil { + writeErr(w, 500, err.Error()) + return + } + } if req.Workers != nil { if err := s.m.SetWorkers(*req.Workers); err != nil { writeErr(w, 400, err.Error()) diff --git a/server/task_llm.go b/server/task_llm.go index 9fbf5b4..a078c11 100644 --- a/server/task_llm.go +++ b/server/task_llm.go @@ -378,12 +378,14 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle { wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory"))) wk.SetWebSearch(s.webSearchFor("worker")) + wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开) pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner")) pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow) pl.SetKillWork(s.engine.KillWork) pl.SetSteerWork(s.engine.SteerWork) pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) pl.SetWebSearch(s.webSearchFor("planner")) + pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开) main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent")) main.SetCompactionWindowResolver(mainRuntime.CompactionWindow) main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 985b32c..01ba370 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -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(null); const [editText, setEditText] = React.useState(""); const [editVuln, setEditVuln] = React.useState(""); + // 约束管理:约束列表 + 新增表单 + 行内编辑状态。 + const [constraints, setConstraints] = React.useState([]); + const [conText, setConText] = React.useState(""); + const [conKind, setConKind] = React.useState("deny"); + const [conBusy, setConBusy] = React.useState(false); + const [conErr, setConErr] = React.useState(""); + const [editingConId, setEditingConId] = React.useState(null); + const [editConText, setEditConText] = React.useState(""); + const [editConKind, setEditConKind] = React.useState("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 }) { )} + {/* 操作约束管理:allow=允许 / deny=禁止。约束会在下一轮规划时注入 planner/worker 的系统 + 提示以框定探索边界(注入范围可在 系统设置 里按 planner/worker 开关)。改动不即时打断, + 下一轮规划自然读到。 */} + + + + 操作约束 + + (框定 planner/worker 的探索边界,共 {constraints.length} 条;改动下一轮规划生效) + + + + + {/* 新增表单 */} +
+ setConKind(e.target.value as TaskConstraint["kind"])} + > + 禁止 + 允许 + + setConText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addConstraint(); + }} + disabled={conBusy} + /> + + {conErr && {conErr}} +
+ {/* 约束列表 */} + {constraints.length > 0 ? ( +
+ {constraints.map((c) => + editingConId === c.id ? ( +
+ setEditConKind(e.target.value as TaskConstraint["kind"])} + > + 禁止 + 允许 + + setEditConText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void saveEditConstraint(c); + if (e.key === "Escape") cancelEditConstraint(); + }} + disabled={conBusy} + autoFocus + /> + + +
+ ) : ( +
+ + {c.kind === "allow" ? "允许" : "禁止"} + + {c.text} + + +
+ ), + )} +
+ ) : ( +

+ 暂无操作约束。建任务时会自动从描述/目标抽取;也可在此手动增删改,用来框定「允许/禁止做哪些操作」。 +

+ )} +
+
{coverage && coverage.enabled && coverage.scope_rows > 0 && ( diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index dece7ce..ec9d734 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -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) => { setSaving(true); @@ -220,6 +241,46 @@ export default function SystemSettingsPage() { + + + + + 操作约束注入 + + + 开启后,把每个任务的操作约束(在任务总览「操作约束」里维护的 allow/deny 条目)拼进对应 Agent + 的系统提示,用来框定探索边界(如「仅测当前端口」「禁止爆破」)。 +
+ 可分别控制注入到 规划者(planner)与 执行者(worker);默认都开。切换即时生效(下一轮读取),无需重建 + Agent。关闭后该 Agent 不再看到约束。 +
+
+ +
+ + +
+
+ + +
+
+
+ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1fe9234..cce2b74 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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(`/tasks/${id}/constraints`, { text, kind }), + // 修改约束(文本 + allow/deny)。 + updateConstraint: (id: string, constraintId: string, text: string, kind: TaskConstraint["kind"]) => + patch(`/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)。 diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6cdd232..78234de 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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 ----