open-kritt 漏洞扫描 Agent 控制面
open-kritt 是一个开源代码漏洞发现平台:前端创建 scan,后端锁定 workflow/post-script/agent skill 配置,Python engine 把仓库复制到隔离 workspace,再通过 Codex、Claude Code、Cursor 等 harness 执行多步扫描和后处理。
它解决什么,凭什么值得拆
open-kritt 最值得拆的点不是“让模型找漏洞”这句口号,而是它把扫描生命周期拆成可审计的状态机。入口侧的 backend/src/routes/scans.js:296 先验证模型、workflow、post-script 和 agent skill;运行侧的 backend/src/routes/scans.js:430 创建 scan 后交给 engine;界面侧的 frontend/src/pages/ScanDetail.jsx:66 和 frontend/src/pages/ScanDetail.jsx:74 持续轮询 scan 与 findings,形成用户能观察的闭环。
源码里更有价值的是约束边界。backend/src/routes/scans.js:356 用事务锁定配置资源,backend/src/routes/scans.js:405 从 workflow 与 post-script 推导必填 extra,frontend/src/pages/ScanDetail.jsx:137 到 frontend/src/pages/ScanDetail.jsx:160 用 per-record mutation queue 处理快速复核点击;这些设计说明它在做漏洞平台,而不是一次性 prompt demo。
▸核心数据
深度阅读 → 入口展开。
三层控制面如何把漏洞扫描跑稳
系统拓扑可以从三个入口看:后端创建 scan,engine 消费 scan,前端展示 scan。backend/src/routes/scans.js:296 是创建入口,backend/src/routes/scans.js:468 是运行时状态修改入口,frontend/src/pages/ScanDetail.jsx:103 和 frontend/src/pages/ScanDetail.jsx:116 把这些能力映射成暂停、恢复、保存运行设置。
▸系统拓扑
▸模块清单
四个模块最关键:API 层负责参数和资源锁,queue 层根据 workflow depth 生成 pending jobs,workspace 层复制隔离仓库和依赖,post-processing 层把模型输出变成可复核漏洞清单。backend/src/routes/scans.js:103、backend/src/routes/scans.js:120、frontend/src/lib/scanPresentation.js:29 分别说明配置锁、运行时更新和复核摘要不是 UI 附属品。
▸关键数据结构
76def build_pending_jobs( 77 *, 78 scan: dict[str, Any], 79 workflow: Workflow, 80 completed: set[tuple[int, int, str | None, int]], 81 step_results: dict[tuple[int, int, str | None, int], list[StepResultRow]], 82 claimed: set[tuple[int, int, str | None, int]] | None = None, 83) -> list[Job]: 84 pending: list[Job] = [] 85 if claimed is None: 86 claimed = completed 87 runs = repeat_runs(scan) 88 states = [State(prev_id=0, prev_table=None, repeat_run=1, context=scan_context(scan))] 89 previous_depth_complete = True 90 91 for depth in workflow.depths: 92 steps = workflow.steps_at_depth(depth) 93 next_states: list[State] = [] 94 depth_complete = previous_depth_complete 95 96 if _depth_consumes_all(steps, depth): 97 # Do not start a batch until every branch in the previous depth has 98 # completed. Otherwise a concurrent worker could run it over a 99 # partial result set. 100 input_states = [_batch_state(scan, states, depth - 1)] if previous_depth_complete and states else [] 101 else: 102 input_states = states 103 104 for state in input_states: 105 for step in steps: 106 task_complete = True 107 task_next_states: list[State] = [] 108 for repeat_run in range(1, runs + 1): 109 repeated_state = _state_for_repeat(state, repeat_run) 110 key = metadata_key(step.id, repeated_state) 111 if key not in completed: 112 task_complete = False 113 depth_complete = False 114 if key not in claimed: 115 pending.append(Job(step=step, state=repeated_state)) 116 # The next repeat needs the output from this one, and the 117 # next depth needs the complete accumulated task output. 118 break 119 task_next_states.extend(_next_states(step, repeated_state, step_results)) 120 if task_complete: 121 next_states.extend(task_next_states) 122 123 states = next_states 124 previous_depth_complete = depth_complete 125 126 return sorted( 127 pending, 128 key=lambda job: (-job.depth, job.state.repeat_run, job.step.order, job.state.prev_id), 129 )
扫描队列、workspace 与 harness 的核心模块
▸扫描创建路径:先锁配置,再落库
后端创建 scan 时不是直接 insert。backend/src/routes/scans.js:296 做入参验证,backend/src/routes/scans.js:356 开启事务,backend/src/routes/scans.js:397 到 backend/src/routes/scans.js:421 从 workflow/post-script 反推 extra 必填项,最后 backend/src/routes/scans.js:430 才创建 scan。这个顺序能避免扫描启动后才发现 prompt 变量缺失。
296router.post('/', async (req, res, next) => { 297 try { 298 const valid = validateScan(req.body, { localNames: localRepoNames() }); 299 await assertModelSelectionAvailable(valid); 300 const activeScanCount = await prisma.scan.count({ where: { status: { in: ACTIVE_SCAN_STATUSES } } }); 301 const launchDecision = scanLaunchDecision(req.body, activeScanCount); 302 if (launchDecision.kind === 'choice-required') { 303 return res.status(409).json({ 304 error: 'Another scan is running. Choose whether to start immediately or queue this scan.', 305 code: 'scan_launch_policy_required', 306 errors: [ 307 { 308 field: 'launchPolicy', 309 message: 'Choose whether to start immediately or queue this scan.', 310 }, 311 ], 312 }); 313 } 314 315 const configurationObject = 316 valid.configuration && typeof valid.configuration === 'object' && !Array.isArray(valid.configuration) 317 ? valid.configuration 318 : {}; 319 const configuredPostScriptIds = [ 320 ...new Set( 321 [ 322 `${valid.postScriptId}`, 323 ...(Array.isArray(configurationObject.post_script_ids) 324 ? configurationObject.post_script_ids 325 : Array.isArray(configurationObject.post_scripts) 326 ? configurationObject.post_scripts 327 : [] 328 ).map((id) => `${id}`), 329 ].filter((id) => id.trim() !== '') 330 ), 331 ]; 332 const invalidPostScriptIds = configuredPostScriptIds.filter((id) => !/^\d+$/.test(id)); 333 const queryPostScriptIds = configuredPostScriptIds.filter((id) => /^\d+$/.test(id)); 334 const requestedAgentSkills = 335 req.body?.agentSkillIds ?? 336 req.body?.agent_skill_ids ?? 337 configurationObject.agent_skill_ids ?? 338 configurationObject.agent_skills ?? 339 []; 340 const configuredAgentSkillIds = [ 341 ...new Set( 342 (Array.isArray(requestedAgentSkills) 343 ? requestedAgentSkills 344 : typeof requestedAgentSkills === 'string' 345 ? requestedAgentSkills.split(',') 346 : [] 347 ) 348 .map((item) => (item && typeof item === 'object' ? item.id : item)) 349 .map((id) => `${id}`.trim()) 350 .filter(Boolean) 351 ), 352 ]; 353 const invalidAgentSkillIds = configuredAgentSkillIds.filter((id) => !/^\d+$/.test(id)); 354 const queryAgentSkillIds = configuredAgentSkillIds.filter((id) => /^\d+$/.test(id)); 355 356 const created = await prisma.$transaction(async (tx) => { 357 await lockScanConfigurationResources(tx, { 358 workflowId: BigInt(valid.workflowId), 359 postScriptIds: queryPostScriptIds.map((id) => BigInt(id)), 360 agentSkillIds: queryAgentSkillIds.map((id) => BigInt(id)), 361 }); 362 const [workflow, postScripts, agentSkills] = await Promise.all([ 363 tx.workflow.findUnique({ where: { id: BigInt(valid.workflowId) } }), 364 tx.postScript.findMany({ where: { id: { in: queryPostScriptIds.map((id) => BigInt(id)) } } }), 365 tx.agentSkill.findMany({ where: { id: { in: queryAgentSkillIds.map((id) => BigInt(id)) } } }),
▸运行路径:pending job 与 harness 隔离执行
engine 在 backend/src/routes/scans.js:451 写入 pending 或 queued 后接管。运行时先用 workflow depth 生成 job,再用 workspace 和 harness 执行;前端 frontend/src/pages/ScanDetail.jsx:1174 到 frontend/src/pages/ScanDetail.jsx:1230 把 active jobs、attempts、failed attempts 展示给用户,避免长任务黑盒化。
821 def process_scan(self, scan: dict[str, Any], worker_id: int = 1) -> bool: 822 scan_id = int(scan["id"]) 823 did_work = False 824 with self.db.connect() as conn: 825 workflow = self.db.load_workflow(conn, int(scan["workflow_id"])) 826 827 while True: 828 with self.db.connect() as conn: 829 current = self.db.load_scan(conn, scan_id) 830 if not current or current["status"] in NON_RUNNABLE_SCAN_STATUSES: 831 return did_work 832 if not self._worker_can_pick_job(worker_id): 833 return did_work 834 if current["status"] == PREWARMING_SCAN_STATUS: 835 conn.commit() 836 jobs = None 837 completed = claimed = set() 838 elif current["status"] == "post_processing": 839 conn.commit() 840 jobs = None 841 completed = claimed = set() 842 else: 843 completed = self.db.load_completed_metadata(conn, scan_id) 844 claimed = self.db.load_claimed_metadata(conn, scan_id) 845 step_results = self.db.load_step_results(conn, scan_id) 846 jobs = build_pending_jobs( 847 scan=current, 848 workflow=workflow, 849 completed=completed, 850 claimed=claimed, 851 step_results=step_results, 852 ) 853 conn.commit() 854 855 needs_new_container = current["status"] == "post_processing" or bool(jobs) 856 if needs_new_container and not self._new_scan_container_allowed(scan_id): 857 return did_work 858 859 harness = harness_for( 860 normalize_harness_name(current["harness"]), 861 timeout_seconds=self.runtime_harness_timeout_seconds(), 862 model_provider=scan_model_provider(current), 863 codex_model_provider=getattr(self.config, "codex_model_provider", None), 864 codex_cli_gate=self.codex_cli_gate, 865 ) 866 867 if current["status"] == PREWARMING_SCAN_STATUS: 868 if self._ensure_scan_cache_prewarmed(current, restore_status="running"): 869 did_work = True 870 continue 871 872 if current["status"] != "post_processing" and jobs: 873 if self._ensure_scan_cache_prewarmed(current, restore_status="running"): 874 did_work = True 875 continue 876 877 if current["status"] == "post_processing": 878 if self._ensure_scan_cache_prewarmed(current, restore_status="post_processing"): 879 did_work = True 880 continue 881 if not self._worker_can_pick_job(worker_id): 882 return did_work 883 did_post_work = self.post_processor.process_once(current, harness) 884 if did_post_work: 885 return True 886 return did_work 887 888 if not jobs: 889 if claimed != completed: 890 return did_work 891 with self.db.connect() as conn: 892 transitioned = self.db.set_scan_status_if_current(conn, scan_id, "running", "post_processing") 893 conn.commit() 894 if not transitioned: 895 return did_work 896 LOGGER.info("scan %s workflow completed; starting post-processing", scan_id) 897 did_work = True 898 continue 899 900 did_claim = False 901 for job in jobs: 902 if not self._worker_can_pick_job(worker_id): 903 return did_work 904 did_claim = self.execute_job( 905 scan=current, 906 workflow_id=workflow.id, 907 job=job, 908 harness=harness, 909 ) 910 if did_claim:
从创建 scan 到 post-processing 的关键流
关键流里有两个容易被忽视的边界:scan 何时从 running 进入 post_processing,以及用户复核如何在轮询中保持最新写入。前者由 engine 的 job 完成情况驱动,后者由 frontend/src/pages/ScanDetail.jsx:137、frontend/src/pages/ScanDetail.jsx:145、frontend/src/pages/ScanDetail.jsx:150 到 frontend/src/pages/ScanDetail.jsx:160 的 mutation queue 覆盖处理。
▸扫描生命周期状态机
▸复核与后处理流水线
哪些测试在守住工作流边界
测试覆盖能看出作者关注的是边界条件,而不是只测 happy path。backend/test/validation.test.js:46 统一 generation 与 scan 的模型选择规范,backend/test/validation.test.js:125 拒绝非字符串 CLI 参数,frontend/src/pages/WorkflowBuilder.test.js:78 和 frontend/src/pages/WorkflowBuilder.test.js:155 专门测 batch depth 引用重编号。
▸测试覆盖
这些测试覆盖模型选择、workflow output schema、批处理变量迁移和 scan presentation,能防止 Agent workflow 编辑器生成看似可保存、实际运行时变量错位的配置。frontend/src/pages/WorkflowBuilder.jsx:64 与 frontend/src/pages/WorkflowBuilder.jsx:78 对应插入深度时的变量迁移,frontend/src/pages/WorkflowBuilder.jsx:117 对应删除步骤时的迁移。
46test('generation and scan model selections share normalization rules', () => { 47 const selection = validateModelSelection({ 48 model: ' gpt-5.6 ', 49 model_provider: 'CODEX', 50 harness: 'codex-cli', 51 thinking_effort: 'high', 52 }); 53 assert.deepEqual(selection, { 54 model: 'gpt-5.6', 55 modelProvider: 'codex', 56 harness: 'codex', 57 thinkingEffort: 'high', 58 }); 59 assert.deepEqual( 60 validateGeneration({ 61 kind: 'post_script', 62 request: ' Generate a triage summary. ', 63 model: 'gpt-5.6', 64 model_provider: 'codex', 65 harness: 'codex', 66 thinking_effort: 'high', 67 }), 68 { 69 kind: 'post_script', 70 request: 'Generate a triage summary.', 71 ...selection, 72 } 73 ); 74 75 assert.throws( 76 () => validateGeneration({ kind: 'unknown', request: '', model: '', harness: '' }), 77 (error) => 78 error instanceof ValidationError && 79 ['kind', 'request', 'model', 'harness'].every((field) => error.errors.some((item) => item.field === field)) 80 ); 81 82 assert.throws( 83 () => 84 validateGeneration({ 85 kind: 'workflow', 86 request: 'x'.repeat(20_001), 87 model: 'gpt-5.6', 88 model_provider: 'codex', 89 harness: 'codex', 90 }), 91 (error) => 92 error instanceof ValidationError && 93 error.errors.some((item) => item.field === 'request' && item.message.includes('20,000')) 94 ); 95}); 96 97test('model selection accepts the provider effort union', () => { 98 for (const thinkingEffort of ['default', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']) { 99 assert.equal( 100 validateModelSelection({ 101 model: 'custom-model', 102 model_provider: 'codex', 103 harness: 'codex', 104 thinking_effort: thinkingEffort, 105 }).thinkingEffort, 106 thinkingEffort 107 ); 108 } 109 assert.throws( 110 () => 111 validateModelSelection({ 112 model: 'claude-fable-5', 113 model_provider: 'claude', 114 harness: 'claude-code', 115 thinking_effort: 'ultra', 116 }), 117 (error) => 118 error instanceof ValidationError && 119 error.errors.some( 120 (item) => item.field === 'thinking_effort' && item.message.includes('not supported by harness "claude-code"') 121 ) 122 ); 123});
▸已知边界 / 失败模式
- 并发 scan:
backend/src/routes/scans.js:300根据 active scan 数决定立即启动或要求用户选择 queue。 - 配置漂移:
backend/src/routes/scans.js:103在事务内锁住 workflow/post-script/agent skill,降低扫描中途配置被改的风险。 - 快速复核点击:
frontend/src/pages/ScanDetail.jsx:145按 vulnerability id 入队,后台轮询不会直接覆盖 pending overlay。
真正值得借鉴的工程亮点
scan 创建从 backend/src/routes/scans.js:296 开始,到 backend/src/routes/scans.js:356 进入事务,再通过 backend/src/routes/scans.js:397、backend/src/routes/scans.js:405、backend/src/routes/scans.js:430 串起配置锁、extra 校验和落库。这段设计的重点是把不可变运行配置一次性固化。
前端在 frontend/src/pages/ScanDetail.jsx:66 与 frontend/src/pages/ScanDetail.jsx:74 双轮询 scan 和 findings,frontend/src/pages/ScanDetail.jsx:101 分页,frontend/src/lib/scanPresentation.js:29 提炼 post-script 摘要,让长时扫描有即时反馈。
18function resultSources(vulnerability) { 19 const sources = []; 20 const primary = vulnerability?.postScriptAnswer; 21 if (primary && typeof primary === 'object' && Object.keys(primary).length) sources.push(primary); 22 for (const enrichment of vulnerability?.enrichments || []) { 23 const result = enrichment?.result; 24 if (result && typeof result === 'object' && Object.keys(result).length) sources.push(result); 25 } 26 return sources; 27} 28 29export function postOutputSummary(vulnerability) { 30 const sources = resultSources(vulnerability); 31 const preferred = ['severity', 'ease_of_exploitability', 'patched', 'resource_exhaustion']; 32 for (const key of preferred) { 33 for (const result of sources) { 34 const value = result[key]; 35 if (['string', 'number', 'boolean'].includes(typeof value)) return { label: key, value: String(value) }; 36 } 37 } 38 for (const result of sources) { 39 for (const [key, value] of Object.entries(result)) { 40 if (key.startsWith('_reserved_') || key.startsWith('_chip_')) continue; 41 if (['string', 'number', 'boolean'].includes(typeof value)) return { label: key, value: String(value) }; 42 } 43 } 44 return sources.length 45 ? { label: 'post-script', value: `${sources.length} output${sources.length === 1 ? '' : 's'}` } 46 : null;
这段展示层逻辑把 post-script 和 enrichment 输出合并成单个摘要,frontend/src/lib/scanPresentation.js:18 收集来源,frontend/src/lib/scanPresentation.js:31 优先 severity/ease/patched 等字段,frontend/src/lib/scanPresentation.js:39 再回退到任意可显示字段。对安全扫描平台来说,这是把模型附加判断变成可扫读 UI 的关键小抽象。
技术亮点之外,别忽略这些
open-kritt 的风险主要来自三处:模型 harness 是外部进程,workspace 会复制用户仓库,post-processing 又会继续调用模型做去重和排序。backend/src/routes/vulnerabilities.js:34 的人工复核接口很克制,但 engine 的运行成本、凭据隔离和错误恢复仍是上线时要重点压测的部分。
workspace 准备会创建 job home、复制凭据、设置 HOME/CODEX_HOME/CLAUDE_HOME。虽然 backend/src/routes/scans.js:423 记录 dependenciesDetail,frontend/src/pages/ScanDetail.jsx:253 展示 commit,但真正的安全边界在 engine 侧,需要部署者认真配置容器和 token 权限。
post-processing 不是免费整理,它会继续做 dedupe、ranker、post-script。前端 frontend/src/pages/ScanDetail.jsx:206 展示 rate limit,frontend/src/pages/ScanDetail.jsx:1176 标记 rate_limited,但团队仍需要设置 worker cap 和重试策略。
64function shiftBatchDepthReferences(content, depthMap) { 65 if (typeof content !== 'string' || depthMap.size === 0) return content; 66 return content.replace(BATCH_DEPTH_REFERENCE, (reference, opening, rawDepth, closing) => { 67 const nextDepth = depthMap.get(Number(rawDepth)); 68 return nextDepth === undefined ? reference : `${opening}${multiOutputDepthKey(nextDepth)}${closing}`; 69 }); 70} 71 72/** 73 * Insert a blank workflow level immediately before an existing depth. Existing 74 * levels at and below that point move down together. Batch variables contain 75 * their source depth in the key, so valid references to moved batch boundaries 76 * are renumbered at the same time. 77 */ 78export function insertWorkflowDepthBefore(builder, depth, newStepId) { 79 if (!builder || !Array.isArray(builder.levels)) throw new TypeError('A workflow builder is required.'); 80 if (!Number.isInteger(depth) || !builder.levels.some((level) => level.depth === depth)) { 81 throw new RangeError('Choose an existing workflow depth.'); 82 } 83 if (!newStepId) throw new TypeError('A new step id is required.'); 84 85 const next = JSON.parse(JSON.stringify(builder)); 86 const batchDepthMap = new Map( 87 next.levels 88 .filter((level) => level.depth > 0 && level.consumesAll && level.depth >= depth) 89 .map((level) => [level.depth - 1, level.depth]) 90 ); 91 92 next.levels.forEach((level) => { 93 level.steps.forEach((step) => { 94 step.content = shiftBatchDepthReferences(step.content, batchDepthMap); 95 }); 96 if (level.depth >= depth) level.depth += 1; 97 }); 98 next.levels.push({ 99 depth, 100 multiOutput: true, 101 consumesAll: false, 102 schema: [], 103 steps: [{ id: newStepId, name: 'New step', content: '' }], 104 }); 105 next.levels.sort((left, right) => left.depth - right.depth); 106 next.selStepId = newStepId; 107 return next;
WorkflowBuilder 的批处理变量迁移说明作者知道 workflow 是可编辑图,而不是静态 prompt。frontend/src/pages/WorkflowBuilder.jsx:64 只在 depth map 存在时替换,frontend/src/pages/WorkflowBuilder.jsx:78 插入新层,frontend/src/pages/WorkflowBuilder.jsx:92 遍历每个 step 内容,frontend/src/pages/WorkflowBuilder.jsx:98 再插入新 step;这个路径仍需要更多端到端测试覆盖真实复杂 workflow。
源码证据,串起来看
▸API 到 UI 的证据链
从接口到界面是一条连续链:backend/src/routes/scans.js:204 列表 API 提供 runningCount,backend/src/routes/scans.js:243 读取单个 scan,backend/src/routes/scans.js:254 拉取 ranked findings,frontend/src/pages/ScanDetail.jsx:66 和 frontend/src/pages/ScanDetail.jsx:74 持续轮询,frontend/src/pages/ScanDetail.jsx:101 做 findings 分页,frontend/src/pages/ScanDetail.jsx:121 负责删除确认。它说明 open-kritt 把长任务的进度、结果和人工复核都放在同一个可观察页面。
运行设置也不是前端随便拼 JSON:frontend/src/pages/ScanDetail.jsx:1070 生成 draft,frontend/src/pages/ScanDetail.jsx:1086 合并 patch,frontend/src/pages/ScanDetail.jsx:1103 只提交变化字段,后端 backend/src/routes/scans.js:120 重新验证运行时设置,backend/src/routes/scans.js:143 上锁后 patch scan。这个设计让用户能修改模型和 harness,但不会绕过服务端可用性检查。
人工复核的最小写接口也很清晰:backend/src/routes/vulnerabilities.js:34 接收 PATCH,backend/src/routes/vulnerabilities.js:42 校验 interesting 只能是 1、0 或 null,backend/src/routes/vulnerabilities.js:51 处理 comments,backend/src/routes/vulnerabilities.js:58 只返回被允许的字段。前端 frontend/src/pages/ScanDetail.jsx:139 到 frontend/src/pages/ScanDetail.jsx:160 负责乐观更新和失败回滚。
32// PATCH /api/vulnerabilities/:id — user review: interesting flag and/or comments. 33// interesting: 1 (interesting), 0 (not interesting), or null (unmarked). 34router.patch('/:id', async (req, res, next) => { 35 try { 36 const id = BigInt(req.params.id); 37 const existing = await prisma.vulnerability.findUnique({ where: { id }, select: { id: true } }); 38 if (!existing) return res.status(404).json({ error: 'Vulnerability not found.' }); 39 40 const body = req.body || {}; 41 const data = {}; 42 if ('interesting' in body) { 43 const val = body.interesting; 44 if (val === null) data.interesting = null; 45 else if (val === 0 || val === 1 || val === '0' || val === '1') data.interesting = BigInt(Number(val)); 46 else 47 return res 48 .status(422) 49 .json({ errors: [{ field: 'interesting', message: 'interesting must be 0, 1, or null.' }] }); 50 } 51 if ('comments' in body) { 52 data.comments = body.comments === null || body.comments === '' ? null : String(body.comments); 53 } 54 if (Object.keys(data).length === 0) { 55 return res.status(422).json({ errors: [{ field: 'body', message: 'Provide interesting and/or comments.' }] }); 56 } 57 58 const updated = await prisma.vulnerability.update({ 59 where: { id }, 60 data, 61 select: { id: true, interesting: true, comments: true }, 62 }); 63 res.json({ 64 id: updated.id.toString(), 65 interesting: 66 updated.interesting === null || updated.interesting === undefined ? null : Number(updated.interesting), 67 comments: updated.comments ?? null, 68 });
▸工作流编辑器的变量安全
workflow 编辑器要守住 batch depth 变量,否则模型输入会在深层 workflow 中错位。实现里 frontend/src/pages/WorkflowBuilder.jsx:62 定义 batch 引用正则,frontend/src/pages/WorkflowBuilder.jsx:64 迁移引用,frontend/src/pages/WorkflowBuilder.jsx:117 删除 step,frontend/src/pages/WorkflowBuilder.jsx:131 判断是否删除整层,frontend/src/pages/WorkflowBuilder.jsx:132 到 frontend/src/pages/WorkflowBuilder.jsx:139 重建 depth map。对应测试 frontend/src/pages/WorkflowBuilder.test.js:78 和 frontend/src/pages/WorkflowBuilder.test.js:155 覆盖插入和删除两条路径。
这些证据也暴露一个限制:backend/test/validation.test.js:173 验证 terminal vulnerability 字段,backend/test/validation.test.js:177 枚举非法类型,backend/test/validation.test.js:46 确认 generation 与 scan 共享模型选择规范;但 engine 级别的真实 harness 失败、workspace 权限和 post-processing retry 更依赖集成测试。