dsh-pentester 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +39 -5
  2. package/agents/impact/profile.yml +19 -0
  3. package/agents/recon/profile.yml +23 -0
  4. package/agents/reporting/REPORT_TEMPLATE.md +333 -0
  5. package/agents/reporting/profile.yml +45 -0
  6. package/agents/threat-model/profile.yml +19 -0
  7. package/agents/validation/profile.yml +20 -0
  8. package/agents/vulnerability/profile.yml +19 -0
  9. package/agents/web/profile.yml +19 -0
  10. package/cordis.dev.patch.yml +16 -0
  11. package/cordis.patch.yml +2 -0
  12. package/docker/README.md +60 -0
  13. package/docker/kali/Dockerfile +881 -0
  14. package/docker/kali/README.md +104 -0
  15. package/docker/kali/REPORT_TEMPLATE.md +333 -0
  16. package/docker/kali/TOOL_PROMPT.md +362 -0
  17. package/docker/kali/bin/clone-kb +39 -0
  18. package/docker/kali/bin/entrypoint.sh +9 -0
  19. package/docker/kali/bin/gen-tools-json.sh +246 -0
  20. package/docker/kali/bin/record-traffic.sh +32 -0
  21. package/docker/kali/bin/tool-info +31 -0
  22. package/docker/kali/bin/tool-list +17 -0
  23. package/lib/catalog-BmeOyr6n.js +231 -0
  24. package/lib/catalog-BmeOyr6n.js.map +1 -0
  25. package/lib/catalog-DhE_r18k.js +231 -0
  26. package/lib/catalog-DhE_r18k.js.map +1 -0
  27. package/lib/client.js +15234 -0
  28. package/lib/client.js.map +7 -0
  29. package/lib/container-listing-BI6Xj8l2.js +56 -0
  30. package/lib/container-listing-BI6Xj8l2.js.map +1 -0
  31. package/lib/container-listing-BWZoBnN_.js +56 -0
  32. package/lib/container-listing-BWZoBnN_.js.map +1 -0
  33. package/lib/container-listing-CE5t-Y_H.js +56 -0
  34. package/lib/container-listing-CE5t-Y_H.js.map +1 -0
  35. package/lib/container-listing-DZPBvrLD.js +56 -0
  36. package/lib/container-listing-DZPBvrLD.js.map +1 -0
  37. package/lib/docker-tar-_wf8UrSj.js +66 -0
  38. package/lib/docker-tar-_wf8UrSj.js.map +1 -0
  39. package/lib/engagement-container-store-B9D8g0wq.js +641 -0
  40. package/lib/engagement-container-store-B9D8g0wq.js.map +1 -0
  41. package/lib/engagement-container-store-Cf-h0B4F.js +641 -0
  42. package/lib/engagement-container-store-Cf-h0B4F.js.map +1 -0
  43. package/lib/engagement-container-store-Cu1wuur1.js +639 -0
  44. package/lib/engagement-container-store-Cu1wuur1.js.map +1 -0
  45. package/lib/engagement-container-store-L_Gms-zi.js +632 -0
  46. package/lib/engagement-container-store-L_Gms-zi.js.map +1 -0
  47. package/lib/index.d.ts +41 -0
  48. package/lib/index.js +3200 -0
  49. package/lib/index.js.map +1 -0
  50. package/lib/model-CZoiogVs.js +149 -0
  51. package/lib/model-CZoiogVs.js.map +1 -0
  52. package/lib/model-DKZ5Rjik.js +145 -0
  53. package/lib/model-DKZ5Rjik.js.map +1 -0
  54. package/lib/worker-events-jFvaBp9x.js +351 -0
  55. package/lib/worker-events-jFvaBp9x.js.map +1 -0
  56. package/lib/worker-events-juSf0EDW.js +347 -0
  57. package/lib/worker-events-juSf0EDW.js.map +1 -0
  58. package/package.json +89 -8
  59. package/presets/pentester/agent.cordis.yml +194 -0
  60. package/presets/pentester/preset.yml +2 -0
  61. package/presets/pentester/run-state.mjs +210 -0
  62. package/index.js +0 -5
package/lib/index.js ADDED
@@ -0,0 +1,3200 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join, posix } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import Schema from "@deepseek-ai/schemastery";
5
+ import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { Readable } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+ import tar from "tar-stream";
11
+ import Dockerode from "dockerode";
12
+ import { Service } from "@deepseek-ai/cordis";
13
+ import { z } from "zod";
14
+ import { execFile } from "node:child_process";
15
+ //#region src/dsh.ts
16
+ const PRESET_ID = "pentester";
17
+ /** dsh-base 已注册的官方 in-process spawn provider 名。 */
18
+ const SUBAGENT_PROVIDER = "spawn";
19
+ /**
20
+ * Worker 工具面:deny Root 编排能力 + 用户交互。
21
+ *
22
+ * 使用 deny-list 而非 allow-list:Worker 继承 Parent 的 pentester preset
23
+ * standing scope。deny 只移除 Worker 不应拥有的工具,其余继承能力完整保留。
24
+ *
25
+ * 重要:deny 中只能出现当前 composition 中确实注册过的工具名。
26
+ * DSH tools.restrict() 对未知名字做严格存在性校验。
27
+ */
28
+ const WORKER_TOOL_FILTER = { deny: [
29
+ "pentester_delegate",
30
+ "pentester_cancel_delegation",
31
+ "pentester_advance_stage",
32
+ "pentester_rollback_stage",
33
+ "ask_user_question"
34
+ ] };
35
+ function isPentesterAgentPreset(preset) {
36
+ return preset === PRESET_ID;
37
+ }
38
+ function resolveSessionPreset(session) {
39
+ const events = session.events ?? [];
40
+ for (let index = events.length - 1; index >= 0; index -= 1) {
41
+ const event = events[index];
42
+ if (event?.type !== "agent-preset/selected") continue;
43
+ const data = event.data;
44
+ if (typeof data?.agentPreset === "string" && data.agentPreset.length > 0) return data.agentPreset;
45
+ }
46
+ return session.header.agentPreset;
47
+ }
48
+ function trustedCallerFromAgent(agent) {
49
+ if (agent === null || typeof agent !== "object") return void 0;
50
+ const record = agent;
51
+ const session = record.session;
52
+ if (session === null || typeof session !== "object" || session.header === null || typeof session.header !== "object") return void 0;
53
+ const sessionId = typeof record.id === "string" && record.id.length > 0 ? record.id : session.id;
54
+ if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
55
+ const agentPreset = resolveSessionPreset({
56
+ header: session.header,
57
+ events: session.events
58
+ });
59
+ const isSubagent = session.header.origin === "subagent" || typeof session.header.delegationDepth === "number" && session.header.delegationDepth >= 1;
60
+ return {
61
+ sessionId,
62
+ ...agentPreset === void 0 ? {} : { agentPreset },
63
+ isSubagent,
64
+ isPentesterRoot: !isSubagent && isPentesterAgentPreset(agentPreset),
65
+ ...typeof session.header.cwd === "string" && session.header.cwd.length > 0 ? { cwd: session.header.cwd } : {}
66
+ };
67
+ }
68
+ function legacySessionIdFromExec(exec) {
69
+ if (exec === null || typeof exec !== "object") return void 0;
70
+ const record = exec;
71
+ if (typeof record.sessionId === "string" && record.sessionId.length > 0) return record.sessionId;
72
+ const session = record.session;
73
+ if (session !== null && typeof session === "object") {
74
+ const id = session.id;
75
+ if (typeof id === "string") return id;
76
+ }
77
+ }
78
+ function trustedCallerFromExec(exec) {
79
+ const agent = exec === null || typeof exec !== "object" ? void 0 : exec.agent;
80
+ const fromAgent = agent !== null && typeof agent === "object" ? trustedCallerFromAgent(agent) : void 0;
81
+ if (fromAgent !== void 0) return fromAgent;
82
+ const sessionId = legacySessionIdFromExec(exec);
83
+ if (sessionId === void 0) return void 0;
84
+ return {
85
+ sessionId,
86
+ isSubagent: false,
87
+ isPentesterRoot: false
88
+ };
89
+ }
90
+ var DshClient = class {
91
+ ctx;
92
+ constructor(ctx) {
93
+ this.ctx = ctx;
94
+ }
95
+ get subagentAvailable() {
96
+ return this.ctx.subagents !== void 0 && this.ctx.subagents.getProvider("spawn") !== void 0;
97
+ }
98
+ /**
99
+ * 创建 continuable Worker:reserve durable child id → resolve provider
100
+ * creation spec → create child Agent → submit initial prompt。
101
+ * resolve 时 child 已接受 prompt 但尚未开始执行 —— 不等于 Worker 已完成。
102
+ */
103
+ async startContinuableWorker(input, parent, signal) {
104
+ if (!this.subagentAvailable) throw new Error(`subagent provider "${SUBAGENT_PROVIDER}" is unavailable`);
105
+ const spec = {
106
+ provider: SUBAGENT_PROVIDER,
107
+ label: input.label,
108
+ request: {
109
+ prompt: [{
110
+ type: "text",
111
+ text: input.prompt
112
+ }],
113
+ parent,
114
+ toolFilter: WORKER_TOOL_FILTER,
115
+ persona: input.persona,
116
+ maxDepth: 1,
117
+ ...input.model === void 0 ? {} : { agentOptions: { model: input.model } }
118
+ },
119
+ signal
120
+ };
121
+ const start = await this.ctx.subagents.startContinuable(spec);
122
+ return {
123
+ childId: start.childId,
124
+ messageId: start.messageId
125
+ };
126
+ }
127
+ /**
128
+ * 中断 continuable child 的当前轮次。fire-and-return:cancel signal 发出后
129
+ * 立即返回;child 观察到 signal 后当前轮结束,未消费的 inbox 消息保留。
130
+ * 不存在的 target 是 accepted no-op。
131
+ */
132
+ interruptWorker(targetSessionId, authority) {
133
+ this.ctx.subagents.interrupt(targetSessionId, authority);
134
+ }
135
+ };
136
+ //#endregion
137
+ //#region src/stages.ts
138
+ const STAGE_DEFINITIONS = [
139
+ {
140
+ id: "pre-engagement",
141
+ name: "Pre-engagement",
142
+ goal: "与用户确认目标、授权范围和交战规则,建立 PentestRun 基线。",
143
+ instructions: "收集并确认 objectives、targets、Rules of Engagement。所有输入必须经 ask_user_question 与用户确认。",
144
+ exitCriteria: [
145
+ "测试目标已经明确并经用户确认",
146
+ "授权范围(targets / 排除项)已经记录",
147
+ "Rules of Engagement 已确认(时间窗、强度、凭据模式)"
148
+ ],
149
+ deliverables: ["scope-summary"],
150
+ agentIds: []
151
+ },
152
+ {
153
+ id: "intelligence-gathering",
154
+ name: "Intelligence Gathering",
155
+ goal: "获得足够的目标资产、服务和技术栈信息。",
156
+ instructions: "尽可能建立目标攻击面认知。避免重复已经完成的侦察;复用 pentest/intelligence-gathering/ 下已有成果。",
157
+ exitCriteria: [
158
+ "目标范围已经明确",
159
+ "主要资产已经识别",
160
+ "重要开放服务已经识别",
161
+ "Web/API 技术栈已有基本认知"
162
+ ],
163
+ deliverables: [
164
+ "assets",
165
+ "services",
166
+ "technology-summary"
167
+ ],
168
+ agentIds: ["recon", "web"]
169
+ },
170
+ {
171
+ id: "threat-modeling",
172
+ name: "Threat Modeling",
173
+ goal: "从攻击者视角梳理攻击路径与优先级。",
174
+ instructions: "基于情报阶段成果建模:入口、信任边界、高价值资产、候选攻击路径。",
175
+ exitCriteria: [
176
+ "攻击面已梳理成结构化模型",
177
+ "候选攻击路径已按价值与可行性排序",
178
+ "明确了后续验证的优先级清单"
179
+ ],
180
+ deliverables: ["threat-model"],
181
+ agentIds: ["threat-model"]
182
+ },
183
+ {
184
+ id: "vulnerability-analysis",
185
+ name: "Vulnerability Analysis",
186
+ goal: "识别并评估候选安全问题。",
187
+ instructions: "针对威胁模型优先级验证候选问题,输出带证据的 findings。",
188
+ exitCriteria: [
189
+ "优先攻击路径均已分析",
190
+ "候选问题有可复验的证据支撑",
191
+ "每个问题标注了置信度与建议验证方式"
192
+ ],
193
+ deliverables: ["findings"],
194
+ agentIds: ["web", "vulnerability"]
195
+ },
196
+ {
197
+ id: "exploitation",
198
+ name: "Exploitation",
199
+ goal: "在授权范围内验证可利用性并确认影响。",
200
+ instructions: "只验证已确认的候选问题;控制强度与影响;保留过程证据。",
201
+ exitCriteria: [
202
+ "高价值候选问题已完成验证(可利用 / 不可利用)",
203
+ "每次验证有可复现步骤与证据",
204
+ "未验证项已记录原因"
205
+ ],
206
+ deliverables: ["validation-results"],
207
+ agentIds: ["validation"]
208
+ },
209
+ {
210
+ id: "post-exploitation",
211
+ name: "Post Exploitation",
212
+ goal: "评估横向移动、持久性与业务影响。",
213
+ instructions: "基于已验证的立足点评估影响范围;遵守 RoE 边界;不做超出授权的影响操作。",
214
+ exitCriteria: [
215
+ "影响范围已评估",
216
+ "业务影响已量化或明确描述",
217
+ "清理与恢复动作已记录"
218
+ ],
219
+ deliverables: ["impact-assessment"],
220
+ agentIds: ["impact"]
221
+ },
222
+ {
223
+ id: "reporting",
224
+ name: "Reporting",
225
+ goal: "产出最终交付报告。",
226
+ instructions: "reporter 读取整个 pentest/ 目录,整理最终报告写入 reporting/report.md。报告是普通 Stage:delegate reporter 即可。",
227
+ exitCriteria: [
228
+ "report.md 已生成且覆盖全部阶段成果",
229
+ "每个确认的问题有证据、影响与修复建议",
230
+ "用户已确认报告可交付"
231
+ ],
232
+ deliverables: ["report.md"],
233
+ agentIds: ["reporting"]
234
+ }
235
+ ];
236
+ function stageDefinition(stage) {
237
+ return STAGE_DEFINITIONS.find((definition) => definition.id === stage);
238
+ }
239
+ //#endregion
240
+ //#region src/model.ts
241
+ /**
242
+ * model.ts — the 6 domain concepts (see docs/plan.md §1 and CONTEXT.md).
243
+ * Deliverables are files; there are no store entities beyond Delegation records.
244
+ */
245
+ const STAGE_IDS = [
246
+ "pre-engagement",
247
+ "intelligence-gathering",
248
+ "threat-modeling",
249
+ "vulnerability-analysis",
250
+ "exploitation",
251
+ "post-exploitation",
252
+ "reporting"
253
+ ];
254
+ /** 阶段目录名:`01-pre-engagement` … `07-reporting`(与 STAGE_IDS 同序)。 */
255
+ function stageDir(stage) {
256
+ const index = STAGE_IDS.indexOf(stage);
257
+ return `${String(index + 1).padStart(2, "0")}-${stage}`;
258
+ }
259
+ function nextStage(stage) {
260
+ const index = STAGE_IDS.indexOf(stage);
261
+ if (index < 0 || index === STAGE_IDS.length - 1) return void 0;
262
+ return STAGE_IDS[index + 1];
263
+ }
264
+ function isStageId(value) {
265
+ return STAGE_IDS.includes(value);
266
+ }
267
+ //#endregion
268
+ //#region src/store.ts
269
+ /**
270
+ * store.ts — the single run.json snapshot per Project (docs/workspace.md).
271
+ * Atomic write via tmp+rename; no event log, no replay.
272
+ */
273
+ const RUN_DIR = ".dsh-pentester";
274
+ const RUN_FILE = "run.json";
275
+ /** 工作区交付树:`<project>/workspace` = 容器 `/workspace`(共享 volume 的宿主镜像)。 */
276
+ const WORKSPACE_DIR = "workspace";
277
+ var StoreError = class extends Error {};
278
+ function runFilePath(projectDir) {
279
+ return join(workspaceDir(projectDir), RUN_DIR, RUN_FILE);
280
+ }
281
+ function workspaceDir(projectDir) {
282
+ return join(projectDir, WORKSPACE_DIR);
283
+ }
284
+ /**
285
+ * 非异常版加载:文件不存在返回 null(不要用 try/catch 判存在性);
286
+ * 文件损坏仍抛 StoreError(不吞损坏,防止用新 Run 覆盖坏文件)。
287
+ */
288
+ /**
289
+ * 唯一 PentestRun 解析入口(docs/plan.md §6):文件不存在 → null;
290
+ * 文件损坏 → StoreError。插件侧所有消费者统一走这里。
291
+ */
292
+ async function resolvePentestRun(projectDir) {
293
+ return tryLoadRun(projectDir);
294
+ }
295
+ async function tryLoadRun(projectDir) {
296
+ const file = runFilePath(projectDir);
297
+ if (!existsSync(file)) return null;
298
+ let parsed;
299
+ try {
300
+ parsed = JSON.parse(await readFile(file, "utf8"));
301
+ } catch (error) {
302
+ throw new StoreError(`run.json is corrupted: ${error instanceof Error ? error.message : String(error)}`);
303
+ }
304
+ return validateRun(parsed, file);
305
+ }
306
+ async function saveRun(projectDir, run) {
307
+ const file = runFilePath(projectDir);
308
+ await mkdir(join(workspaceDir(projectDir), RUN_DIR), { recursive: true });
309
+ const tmp = `${file}.${crypto.randomUUID().slice(0, 8)}.tmp`;
310
+ await writeFile(tmp, `${JSON.stringify(run, null, 2)}\n`);
311
+ await rename(tmp, file);
312
+ }
313
+ /** Linear advance only; the host never judges information sufficiency. */
314
+ function advanceStage(run, reason) {
315
+ if (run.currentStage === null || run.status === "completed") throw new StoreError("run is already completed; cannot advance");
316
+ const current = run.currentStage;
317
+ const target = nextStage(current);
318
+ if (target === void 0) {
319
+ const statuses = { ...run.stageStatuses ?? {} };
320
+ statuses[current] = "completed";
321
+ run.stageStatuses = statuses;
322
+ run.status = "completed";
323
+ run.currentStage = null;
324
+ return null;
325
+ }
326
+ const statuses = { ...run.stageStatuses ?? {} };
327
+ statuses[current] = "completed";
328
+ statuses[target] = "active";
329
+ run.stageStatuses = statuses;
330
+ run.currentStage = target;
331
+ run.stageHistory.push({
332
+ stage: target,
333
+ enteredAt: (/* @__PURE__ */ new Date()).toISOString(),
334
+ reason
335
+ });
336
+ return target;
337
+ }
338
+ /** 当前 stage 状态(缺省:currentStage=active,其余=pending)。 */
339
+ /** 把当前 stage 标为 reviewing(最后一个 delegation 结算时调用)。 */
340
+ function markStageReviewing(run) {
341
+ if (run.currentStage === null) return;
342
+ run.stageStatuses = {
343
+ ...run.stageStatuses ?? {},
344
+ [run.currentStage]: "reviewing"
345
+ };
346
+ }
347
+ /** 当前 stage 的 delegation 目录:`workspace/stages/<NN>-<stage>/delegations/`。 */
348
+ function stageDelegationsDir(projectDir, stage) {
349
+ return join(workspaceDir(projectDir), "stages", stageDir(stage), "delegations");
350
+ }
351
+ /** 某 delegation 的工作区目录:`workspace/stages/<NN>-<stage>/delegations/D-00N`。 */
352
+ function delegationWorkspaceDir(projectDir, stage, id) {
353
+ return join(stageDelegationsDir(projectDir, stage), id);
354
+ }
355
+ function nextDelegationId(run) {
356
+ let max = 0;
357
+ for (const delegation of run.delegations) {
358
+ const match = /^D-(\d+)$/.exec(delegation.id);
359
+ if (match !== null) max = Math.max(max, Number(match[1]));
360
+ }
361
+ return `D-${String(max + 1).padStart(3, "0")}`;
362
+ }
363
+ function findDelegation(run, id) {
364
+ return run.delegations.find((delegation) => delegation.id === id);
365
+ }
366
+ function validateRun(value, file) {
367
+ if (value === null || typeof value !== "object") throw new StoreError(`${file}: run.json must be an object`);
368
+ const record = value;
369
+ if (record.schemaVersion !== 1) throw new StoreError(`${file}: unsupported schemaVersion ${String(record.schemaVersion)}`);
370
+ if (record.currentStage !== null && (typeof record.currentStage !== "string" || !isStageId(record.currentStage))) throw new StoreError(`${file}: invalid currentStage`);
371
+ if (!Array.isArray(record.delegations)) throw new StoreError(`${file}: delegations must be an array`);
372
+ return record;
373
+ }
374
+ //#endregion
375
+ //#region src/workspace.ts
376
+ /**
377
+ * workspace.ts — 工作区布局初始化与宿主侧产物。
378
+ *
379
+ * 固定骨架(每次 run 结构完全一致,见 docs/workspace.md):
380
+ *
381
+ * <project>/workspace/ = 容器 /workspace(共享 volume 的宿主镜像)
382
+ * ├── .gitignore # traffic/ *.pcap *.mitm tmp/ cache/ *.log
383
+ * ├── STATUS.md # 宿主自动生成的可读状态
384
+ * ├── target/ target.json scope/ roe/ inputs/
385
+ * ├── assets/ assets.json
386
+ * ├── stages/ <NN>-<stage>/ summary.md delegations/
387
+ * ├── findings/
388
+ * ├── report/
389
+ * └── traffic/
390
+ *
391
+ * 宿主侧状态(不随 volume 同步):<project>/.dsh-pentester/run.json、
392
+ * workspace.json、events.jsonl(append-only)。
393
+ */
394
+ function workspaceFilePath(projectDir) {
395
+ return join(workspaceDir(projectDir), RUN_DIR, "workspace.json");
396
+ }
397
+ function eventsFilePath(projectDir) {
398
+ return join(workspaceDir(projectDir), RUN_DIR, "events.jsonl");
399
+ }
400
+ /** 追加一条事件(失败不阻塞主流程)。 */
401
+ async function appendEvent(projectDir, event) {
402
+ try {
403
+ await appendFile(eventsFilePath(projectDir), `${JSON.stringify({
404
+ ts: event.ts,
405
+ type: event.type,
406
+ data: event.data
407
+ })}\n`, "utf8");
408
+ } catch {}
409
+ }
410
+ /**
411
+ * 初始化工作区固定骨架 + 宿主侧状态文件。幂等:已存在则只补齐缺失目录。
412
+ * @param target 初始目标(pre-engagement 确认后写入 target/target.json)。
413
+ * @param scope 用户确认后的 Scope 文本(写入 target/scope.md,不做机器 enforcement)。
414
+ * @param roe 用户确认后的 RoE / Authorization 事实(写入 target/roe.md,不做程序级语义解析)。
415
+ */
416
+ async function initWorkspace(projectDir, options = {}) {
417
+ const root = workspaceDir(projectDir);
418
+ const metaPath = workspaceFilePath(projectDir);
419
+ await mkdir(join(root, RUN_DIR), { recursive: true });
420
+ let meta;
421
+ if (existsSync(metaPath)) meta = JSON.parse(await readFile(metaPath, "utf8"));
422
+ else {
423
+ meta = {
424
+ schemaVersion: 1,
425
+ id: crypto.randomUUID(),
426
+ target: options.target,
427
+ language: options.language ?? "zh-CN",
428
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
429
+ gitBranch: "main"
430
+ };
431
+ await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
432
+ await appendEvent(projectDir, {
433
+ ts: meta.createdAt,
434
+ type: "run.created",
435
+ data: { target: meta.target }
436
+ });
437
+ }
438
+ await mkdir(root, { recursive: true });
439
+ const dirs = [
440
+ "target/inputs",
441
+ "assets",
442
+ "findings",
443
+ "report",
444
+ "traffic",
445
+ ...STAGE_IDS.map((stage) => join("stages", stageDir(stage), "delegations"))
446
+ ];
447
+ for (const dir of dirs) await mkdir(join(root, dir), { recursive: true });
448
+ const gitignore = join(root, ".gitignore");
449
+ if (!existsSync(gitignore)) await writeFile(gitignore, `tmp/\ncache/\n*.log\n`, "utf8");
450
+ if (options.target !== void 0 && meta.target === void 0) {
451
+ meta = {
452
+ ...meta,
453
+ target: options.target
454
+ };
455
+ await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
456
+ }
457
+ await writeTarget(projectDir, meta.target);
458
+ if (options.scope !== void 0) await writeFile(join(root, "target", "scope.md"), options.scope.endsWith("\n") ? options.scope : `${options.scope}\n`, "utf8");
459
+ if (options.roe !== void 0) await writeFile(join(root, "target", "roe.md"), options.roe.endsWith("\n") ? options.roe : `${options.roe}\n`, "utf8");
460
+ return meta;
461
+ }
462
+ /** 写 target/target.json(幂等;仅当文件不存在或 target 变化时更新)。 */
463
+ async function writeTarget(projectDir, target) {
464
+ if (target === void 0) return;
465
+ const file = join(workspaceDir(projectDir), "target", "target.json");
466
+ if ((existsSync(file) ? JSON.parse(await readFile(file, "utf8")) : void 0)?.target === target) return;
467
+ await writeFile(file, `${JSON.stringify({
468
+ target,
469
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
470
+ }, null, 2)}\n`, "utf8");
471
+ }
472
+ /**
473
+ * 写 stage 的 summary.md(Stage handoff)。内容由 Root 通过
474
+ * pentester_advance_stage(summary) 提供,Host 只负责写磁盘、不解析。
475
+ */
476
+ async function writeStageSummary(projectDir, stage, summary) {
477
+ const text = summary.trim();
478
+ if (text === "") return;
479
+ await writeFile(join(workspaceDir(projectDir), "stages", stageDir(stage), "summary.md"), `# ${stageDir(stage)}\n\n${text}\n`, "utf8");
480
+ }
481
+ /**
482
+ * 提升某 delegation 目录中的 canonical 产物(仅 threat-model.md)。
483
+ * assets.json 和 findings.md 现在由 promoteAssets / promoteFindings
484
+ * (src/pentest/)负责聚合提升,不再走简单 copyFile。
485
+ */
486
+ async function promoteDeliverables(projectDir, dir) {
487
+ const root = workspaceDir(projectDir);
488
+ for (const [name, relative] of [["threat-model.md", "findings/threat-model.md"]]) {
489
+ const source = join(dir, name);
490
+ if (!existsSync(source)) continue;
491
+ const target = join(root, relative);
492
+ await mkdir(dirname(target), { recursive: true });
493
+ await copyFile(source, target);
494
+ }
495
+ }
496
+ /**
497
+ * 将 Pre-engagement 用户确认的完整授权信息写入
498
+ * `target/inputs/pre-engagement.md` 作为原始输入 provenance。
499
+ * 在 bootstrap(首次 advance)时调用。
500
+ */
501
+ async function writePreEngagementInput(projectDir, options) {
502
+ const inputsDir = join(workspaceDir(projectDir), "target", "inputs");
503
+ await mkdir(inputsDir, { recursive: true });
504
+ const content = [
505
+ "# Pre-engagement Confirmation",
506
+ "",
507
+ `**Recorded**: ${(/* @__PURE__ */ new Date()).toISOString()}`,
508
+ "",
509
+ "## Target",
510
+ ...options.targets.map((t) => `- ${t}`),
511
+ "",
512
+ "## Scope",
513
+ options.scope,
514
+ "",
515
+ "## Rules of Engagement",
516
+ options.roe,
517
+ "",
518
+ "## Language",
519
+ options.language,
520
+ ""
521
+ ].join("\n");
522
+ await writeFile(join(inputsDir, "pre-engagement.md"), `${content}\n`, "utf8");
523
+ }
524
+ //#endregion
525
+ //#region src/delegations.ts
526
+ /**
527
+ * delegations.ts — Delegation lifecycle: one Delegation = one DSH continuable
528
+ * child Session (docs/plan.md §3, §26).
529
+ *
530
+ * Worker execution uses the DeepSeek Harness official Continuable Subagent seam
531
+ * (`ctx.subagents.startContinuable()` → provider `spawn`). The child is a
532
+ * durable Session that survives across turns and can accept followup messages
533
+ * from Root or user. The plugin only manages PTES domain state.
534
+ *
535
+ * 目录契约(docs/workspace.md):`workspace/stages/<NN>-<stage>/delegations/D-00N/`
536
+ * input/ work/ artifacts/ evidence/ result.md
537
+ * Worker 只写自己的 delegation 目录;宿主写 input/task_prompt.md 快照。
538
+ * result.md 是当前 Delegation 的"最新正式可交付结果",不是 child Session
539
+ * 终止标记。Worker 后续可以更新 result.md。
540
+ */
541
+ /** Profile → category 映射:决定 compact catalog 中哪些工具标记为 Recommended。 */
542
+ const PROFILE_CATEGORIES = {
543
+ recon: [
544
+ "network-discovery",
545
+ "dns-osint",
546
+ "web-discovery",
547
+ "network"
548
+ ],
549
+ web: ["web-discovery", "vulnerability"],
550
+ vulnerability: ["vulnerability", "web-discovery"],
551
+ validation: [
552
+ "vulnerability",
553
+ "password",
554
+ "smb-ad"
555
+ ],
556
+ impact: [
557
+ "smb-ad",
558
+ "network",
559
+ "misc"
560
+ ],
561
+ reporting: [],
562
+ "threat-model": []
563
+ };
564
+ var DelegationError = class extends Error {};
565
+ /**
566
+ * Worker persona(官方 ContinuableStartSpec.request.persona):常驻规则 +
567
+ * AgentProfile 角色。Worker 是长期存在的 continuable child,可能收到多轮
568
+ * 来自 Root 或用户的消息。
569
+ */
570
+ const WORKER_BASELINE = `You are a long-lived PTES execution worker — a continuable child agent.
571
+
572
+ You may receive multiple rounds of tasks from:
573
+ - The Root Agent (via send_message)
574
+ - A human user (direct chat in your session)
575
+
576
+ All messages belong to the same ongoing Delegation Session. You must retain
577
+ context from previous turns and build on earlier results.
578
+
579
+ Your responsibilities:
580
+ - Execute the assigned task using available tools.
581
+ - Stay inside the Primary Target, Scope, and Rules of Engagement.
582
+ - Produce reusable artifacts and evidence.
583
+ - Write or update result.md as the current formal deliverable of this Delegation.
584
+ - When an important finding affects the Root's next step, use the report tool
585
+ to notify the Root Agent proactively.
586
+
587
+ You must NOT:
588
+ - Advance or rollback PTES stages (Root-only).
589
+ - Create or cancel delegations (Root-only).
590
+ - Ask the user questions (Root-only).
591
+ - Create more subagents or fork yourself.
592
+ - Expand scope beyond what is authorized.
593
+
594
+ Standing rules:
595
+ - Operate only within the authorized assessment recorded for this run.
596
+ - Execute commands via available tools.
597
+ - NEVER run git commands. The host owns git checkpoints; .git is not present
598
+ in the container.
599
+ - ALL container network traffic is recorded (pcap + optional mitmproxy on
600
+ :8080). This is an audit trail, not a limitation.
601
+ - Write ALL output in the language of your task prompt: your final response
602
+ AND the content of every file you generate, including reports. Tool raw
603
+ output stays as-is.
604
+ - One round ending does NOT mean your Delegation is permanently over. You may
605
+ receive follow-up messages from Root or a user.
606
+ - Your final response each round must summarize: what you did, what you found,
607
+ which files you wrote, and what you recommend investigating next.`;
608
+ function buildWorkerPersona(profile, catalogText, toolPrompt) {
609
+ const parts = [WORKER_BASELINE];
610
+ if (toolPrompt !== void 0 && toolPrompt.length > 0) parts.push(toolPrompt);
611
+ parts.push(`# Agent Profile: ${profile.name}`, profile.systemPrompt);
612
+ if (catalogText !== void 0 && catalogText.length > 0) parts.push(catalogText);
613
+ return parts.join("\n\n");
614
+ }
615
+ /**
616
+ * Worker 首轮 user message(官方 ContinuableStartSpec.request.prompt):
617
+ * standalone task context。官方 continuable child 不继承 parent transcript,
618
+ * 所以这里必须自包含:Delegation ID / Stage / Target / Scope / RoE /
619
+ * 目录路径 / 完成契约 / task_prompt。
620
+ */
621
+ function buildWorkerTaskPrompt(options) {
622
+ const { run, delegation, projectDir } = options;
623
+ const stage = stageDefinition(delegation.stageId);
624
+ const containerDir = delegation.workspaceDir.startsWith(projectDir) ? delegation.workspaceDir.slice(projectDir.length).replace(/\\/g, "/") : `/workspace/stages/${stage === void 0 ? delegation.stageId : stage.id}`;
625
+ const metadata = [
626
+ `Delegation ID: ${delegation.id}`,
627
+ `Stage: ${delegation.stageId}${stage === void 0 ? "" : ` — ${stage.name}`}`,
628
+ `Project workspace root: /workspace (READ-ONLY outside your own directory)`,
629
+ `Your deliverable directory: ${containerDir}`,
630
+ ` work/ scratch working files (default container cwd)`,
631
+ ` artifacts/ final deliverables for later workers`,
632
+ ` evidence/ reproducible evidence (commands + outputs)`,
633
+ ` result.md current formal deliverable — write/update it as results accumulate`,
634
+ `Write policy: write files ONLY inside ${containerDir}; never modify`,
635
+ ` .git/, .dsh-pentester/, target/, other delegation directories or the workspace root.`,
636
+ run.targets.length > 0 ? `Targets: ${run.targets.join(", ")}` : "Targets: (not yet recorded)",
637
+ run.rulesOfEngagement === void 0 ? "" : `Rules of Engagement: ${run.rulesOfEngagement}`
638
+ ].filter((line) => line !== "");
639
+ return [
640
+ `# Task for ${delegation.id}`,
641
+ "",
642
+ ...metadata,
643
+ "",
644
+ `# Objective`,
645
+ delegation.objective,
646
+ "",
647
+ `# task_prompt (task context from the Root Agent)`,
648
+ delegation.taskPrompt,
649
+ "",
650
+ `# Completion contract`,
651
+ `- This is a continuable session. You may receive follow-up messages.`,
652
+ `- When you have results, write or update result.md in ${containerDir}`,
653
+ ` (summary + what you found). result.md is the current formal deliverable —`,
654
+ ` it does NOT terminate your session.`,
655
+ `- If you discover something important that affects the Root's next step,`,
656
+ ` use the report tool to notify the Root Agent.`
657
+ ].join("\n");
658
+ }
659
+ /**
660
+ * 把 tools.json 的 categories 渲染成 compact 文本,注入 Worker persona。
661
+ * 先输出 profile 匹配的 Recommended 工具,再输出 Other。
662
+ * raw 格式由 gen-tools-json.sh 固定(categories: { name, bin, description, examples }[])。
663
+ */
664
+ function renderToolCatalog(raw, profileId) {
665
+ const data = raw;
666
+ if (data === null || data === void 0) return "";
667
+ const categories = data.categories;
668
+ if (categories === void 0) return "";
669
+ const image = typeof data.image === "string" ? data.image : "dsh-pentester-kali";
670
+ const recommended = PROFILE_CATEGORIES[profileId];
671
+ const recommendedSet = recommended !== void 0 ? new Set(recommended) : /* @__PURE__ */ new Set();
672
+ const recLines = [];
673
+ const otherLines = [];
674
+ for (const [catName, tools] of Object.entries(categories)) {
675
+ if (!Array.isArray(tools)) continue;
676
+ const lines = recommendedSet.has(catName) ? recLines : otherLines;
677
+ for (const tool of tools) {
678
+ if (typeof tool.name !== "string" || typeof tool.description !== "string") continue;
679
+ lines.push(` ${tool.name} — ${tool.description}`);
680
+ }
681
+ }
682
+ const parts = [
683
+ "## Toolbox Capabilities",
684
+ "",
685
+ `Image: ${image} · All execution via \`pentester_container_exec\`.`,
686
+ "Discovery: `tool-list` for full catalog, `tool-info <name>` for single tool details.",
687
+ "If you need specific parameters, use `<tool> -h` or `<tool> --help`."
688
+ ];
689
+ if (recLines.length > 0) parts.push("", "### Recommended for this profile", ...recLines);
690
+ if (otherLines.length > 0) parts.push("", "### Other capabilities", ...otherLines);
691
+ parts.push("", "## Tool Selection", "", "1. Match tool to task — prefer narrow specialized tools over curl/bash scripts.", "2. Do NOT run multiple overlapping scanners without a specific reason.", "3. Broad scanners are discovery aids, not vulnerability proof.", "4. Validate important findings before reporting them.", "5. Stay strictly within Target / Scope / RoE.");
692
+ return parts.join("\n");
693
+ }
694
+ var DelegationService = class {
695
+ dsh;
696
+ docker;
697
+ constructor(dsh, docker) {
698
+ this.dsh = dsh;
699
+ this.docker = docker;
700
+ }
701
+ /**
702
+ * 创建一个或多个 continuable Worker。
703
+ *
704
+ * 流程:
705
+ * validate PentestRun → validate Stage → validate AgentProfile →
706
+ * allocate D-xxx → scaffold Delegation → build standalone initial prompt →
707
+ * persist Delegation → ctx.subagents.startContinuable() →
708
+ * 得到 childId + messageId → Delegation.childSessionId = childId →
709
+ * Delegation.status = active → persist run.json → return
710
+ *
711
+ * startContinuable() resolve 的含义只是初始 prompt 已进入 child inbox,
712
+ * 不是 Worker 已完成任务。
713
+ */
714
+ async delegate(deps, assignments) {
715
+ if (assignments.length === 0) throw new DelegationError("assignments must not be empty");
716
+ if (deps.run.status === "completed" || deps.run.currentStage === null) throw new DelegationError("PentestRun is completed; cannot delegate new work");
717
+ const stage = stageDefinition(deps.run.currentStage);
718
+ if (stage === void 0) throw new DelegationError(`unknown stage: ${deps.run.currentStage}`);
719
+ if (!this.dsh.subagentAvailable) throw new DelegationError("subagent infrastructure unavailable: ctx.subagents provider \"spawn\" is not registered");
720
+ const created = [];
721
+ for (const assignment of assignments) {
722
+ const profile = deps.profiles.get(assignment.agent);
723
+ if (profile === void 0) throw new DelegationError(`unknown AgentProfile: ${assignment.agent}`);
724
+ if (!stage.agentIds.includes(profile.id)) throw new DelegationError(`AgentProfile ${profile.id} is not attached to current stage ${deps.run.currentStage}. Allowed profiles: ${stage.agentIds.join(", ")}`);
725
+ if (assignment.objective.trim() === "" || assignment.taskPrompt.trim() === "") throw new DelegationError("objective and task_prompt must not be empty");
726
+ if (/delegations\/D-\d+/.test(assignment.taskPrompt)) throw new DelegationError("task_prompt must not contain concrete delegation paths (e.g. delegations/D-001). The Host manages D-ID allocation and directory structure. Reference deliverables by their logical names (e.g. \"the recon scan results from intelligence-gathering\").");
727
+ const id = nextDelegationId(deps.run);
728
+ const dir = delegationWorkspaceDir(deps.projectDir, deps.run.currentStage, id);
729
+ await scaffoldDelegationDir(dir, {
730
+ id,
731
+ stage: deps.run.currentStage,
732
+ agent: profile.id,
733
+ objective: assignment.objective,
734
+ taskPrompt: assignment.taskPrompt
735
+ });
736
+ const delegation = {
737
+ id,
738
+ stageId: deps.run.currentStage,
739
+ agentId: profile.id,
740
+ objective: assignment.objective,
741
+ taskPrompt: assignment.taskPrompt,
742
+ workspaceDir: dir,
743
+ status: "starting",
744
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
745
+ };
746
+ deps.run.delegations.push(delegation);
747
+ await saveRun(deps.projectDir, deps.run);
748
+ await appendEvent(deps.projectDir, {
749
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
750
+ type: "delegation.created",
751
+ data: {
752
+ id,
753
+ agent: profile.id,
754
+ stage: deps.run.currentStage
755
+ }
756
+ });
757
+ created.push(delegation);
758
+ }
759
+ await saveRun(deps.projectDir, deps.run);
760
+ await this.docker.pushWorkspace(deps.run.id, deps.projectDir).catch(() => void 0);
761
+ const toolbox = await this.docker.ensureImage("kali");
762
+ const containerId = await this.docker.ensureRunContainer({
763
+ runId: deps.run.id,
764
+ toolbox,
765
+ projectDir: deps.projectDir
766
+ });
767
+ for (const delegation of created) await this.dispatch(deps, delegation, containerId);
768
+ await saveRun(deps.projectDir, deps.run);
769
+ return created;
770
+ }
771
+ /**
772
+ * 派发一个 delegation:创建 continuable child。
773
+ * startContinuable() resolve 时 child 已接受 prompt 但尚未开始执行。
774
+ * 之后 child 作为 durable Session 持续存在,可接受 followup。
775
+ */
776
+ async dispatch(deps, delegation, containerId) {
777
+ const profile = deps.profiles.get(delegation.agentId);
778
+ let catalogText;
779
+ try {
780
+ catalogText = renderToolCatalog(await this.docker.readToolCatalog(containerId), delegation.agentId);
781
+ } catch {}
782
+ let toolPrompt;
783
+ try {
784
+ toolPrompt = (await this.docker.exec(containerId, { argv: ["cat", "/pentester/PROMPT.md"] })).stdout;
785
+ } catch {}
786
+ const input = {
787
+ label: `${delegation.id} · ${profile.name}`,
788
+ persona: buildWorkerPersona(profile, catalogText, toolPrompt),
789
+ prompt: buildWorkerTaskPrompt({
790
+ run: deps.run,
791
+ delegation,
792
+ projectDir: deps.projectDir
793
+ }),
794
+ ...profile.model === void 0 ? {} : { model: profile.model }
795
+ };
796
+ const start = await this.dsh.startContinuableWorker(input, deps.parent, deps.signal);
797
+ delegation.sessionId = start.childId;
798
+ delegation.status = "active";
799
+ await saveRun(deps.projectDir, deps.run);
800
+ await appendEvent(deps.projectDir, {
801
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
802
+ type: "delegation.dispatched",
803
+ data: {
804
+ id: delegation.id,
805
+ childId: start.childId,
806
+ messageId: start.messageId
807
+ }
808
+ });
809
+ }
810
+ /**
811
+ * 关闭 PTES Delegation。
812
+ *
813
+ * 流程:
814
+ * resolve D-ID → resolve childSessionId → 如有 live turn:interrupt child →
815
+ * Delegation.status = closed → persist。
816
+ *
817
+ * 不删除 durable Child Session transcript。用户以后仍然可以看到历史。
818
+ */
819
+ async cancel(deps, delegationId, reason) {
820
+ const delegation = findDelegation(deps.run, delegationId);
821
+ if (delegation === void 0) throw new DelegationError(`unknown delegation: ${delegationId}`);
822
+ if (delegation.sessionId !== void 0) try {
823
+ this.dsh.interruptWorker(delegation.sessionId, {
824
+ kind: "user",
825
+ parentSessionId: delegation.sessionId
826
+ });
827
+ } catch {}
828
+ delegation.status = "closed";
829
+ delegation.resultSummary = reason ?? "closed by Root";
830
+ delegation.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
831
+ await saveRun(deps.projectDir, deps.run);
832
+ await appendEvent(deps.projectDir, {
833
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
834
+ type: "delegation.closed",
835
+ data: {
836
+ id: delegationId,
837
+ reason
838
+ }
839
+ });
840
+ return delegation;
841
+ }
842
+ /**
843
+ * 当前 stage 全部 delegation 是否都已 settled(没有 active/starting)。
844
+ * 用于 advance 前的机械检查。
845
+ */
846
+ stageHasPendingWork(run, stageId) {
847
+ return run.delegations.some((d) => d.stageId === stageId && (d.status === "active" || d.status === "starting"));
848
+ }
849
+ /**
850
+ * 当前 stage 全部 delegation 中没有 active/starting → 标记 reviewing。
851
+ */
852
+ async maybeMarkReviewing(deps) {
853
+ if (deps.run.currentStage === null) return;
854
+ if (this.stageHasPendingWork(deps.run, deps.run.currentStage)) return;
855
+ markStageReviewing(deps.run);
856
+ await saveRun(deps.projectDir, deps.run);
857
+ await appendEvent(deps.projectDir, {
858
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
859
+ type: "stage.reviewing",
860
+ data: { stage: deps.run.currentStage }
861
+ });
862
+ }
863
+ };
864
+ /** 预建 delegation 目录骨架 + input/task_prompt 快照。 */
865
+ async function scaffoldDelegationDir(dir, info) {
866
+ for (const sub of [
867
+ "input",
868
+ "work",
869
+ "artifacts",
870
+ "evidence"
871
+ ]) await mkdir(join(dir, sub), { recursive: true });
872
+ await writeFile(join(dir, "input", "task_prompt.md"), info.taskPrompt, "utf8");
873
+ }
874
+ //#endregion
875
+ //#region src/pentest/assets.ts
876
+ /**
877
+ * assets.ts — PentestRun canonical asset inventory promotion.
878
+ *
879
+ * Workers produce local `artifacts/assets.json` and `artifacts/services.json`
880
+ * inside their delegation directories. On stage advance, the host aggregates
881
+ * every delegation's assets from the completed stage, deduplicates them, and
882
+ * writes the canonical `assets/assets.json` and `assets/services.json` at the
883
+ * workspace root. Worker-local artifacts are never moved or deleted.
884
+ *
885
+ * Dedup keys:
886
+ * asset: type + normalized value
887
+ * service: asset value + protocol + port
888
+ */
889
+ function assetKey(entry) {
890
+ const type = typeof entry.type === "string" ? entry.type : "unknown";
891
+ const value = typeof entry.value === "string" ? entry.value.trim().toLowerCase() : "";
892
+ if (value === "") return "";
893
+ return `${type}:${value}`;
894
+ }
895
+ function serviceKey(entry) {
896
+ const asset = typeof entry.asset === "string" ? entry.asset.trim().toLowerCase() : "";
897
+ const protocol = typeof entry.protocol === "string" ? entry.protocol.trim().toLowerCase() : "";
898
+ const port = typeof entry.port === "number" ? entry.port : 0;
899
+ if (asset === "" || protocol === "" || port === 0) return "";
900
+ return `${asset}:${protocol}:${port}`;
901
+ }
902
+ /** Normalize a worker's asset entry to canonical form. */
903
+ function normalizeAsset(entry, sourceDelegationId, sourceStage, updatedAt) {
904
+ return {
905
+ type: typeof entry.type === "string" && entry.type.length > 0 ? entry.type : "unknown",
906
+ value: typeof entry.value === "string" ? entry.value.trim() : "",
907
+ ...entry.meta !== void 0 && typeof entry.meta === "object" && entry.meta !== null ? { meta: entry.meta } : {},
908
+ sourceDelegationId,
909
+ sourceStage,
910
+ updatedAt
911
+ };
912
+ }
913
+ /** Normalize a worker's service entry to canonical form. */
914
+ function normalizeService(entry, sourceDelegationId, sourceStage, updatedAt) {
915
+ return {
916
+ asset: typeof entry.asset === "string" ? entry.asset.trim() : "",
917
+ protocol: typeof entry.protocol === "string" ? entry.protocol.trim() : "",
918
+ port: typeof entry.port === "number" ? entry.port : 0,
919
+ ...typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {},
920
+ ...typeof entry.version === "string" && entry.version.length > 0 ? { version: entry.version } : {},
921
+ ...typeof entry.banner === "string" && entry.banner.length > 0 ? { banner: entry.banner } : {},
922
+ ...entry.meta !== void 0 && typeof entry.meta === "object" && entry.meta !== null ? { meta: entry.meta } : {},
923
+ sourceDelegationId,
924
+ sourceStage,
925
+ updatedAt
926
+ };
927
+ }
928
+ function readJsonFile(file) {
929
+ if (!existsSync(file)) return void 0;
930
+ try {
931
+ return JSON.parse(readFileSync(file, "utf8"));
932
+ } catch {
933
+ return;
934
+ }
935
+ }
936
+ function readJsonArray(file) {
937
+ const parsed = readJsonFile(file);
938
+ if (Array.isArray(parsed)) return parsed.filter((entry) => entry !== null && typeof entry === "object");
939
+ return [];
940
+ }
941
+ /**
942
+ * Aggregate assets from every delegation directory in the completed stage,
943
+ * deduplicate, and write the canonical `assets/assets.json` and (if any
944
+ * services found) `assets/services.json`.
945
+ */
946
+ async function promoteAssets(projectDir, stage, delegationDirs, delegationIds) {
947
+ const root = workspaceDir(projectDir);
948
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
949
+ const assetMap = /* @__PURE__ */ new Map();
950
+ for (let index = 0; index < delegationDirs.length; index += 1) {
951
+ const delegationId = delegationIds[index] ?? `D-${String(index + 1).padStart(3, "0")}`;
952
+ const entries = readJsonArray(join(delegationDirs[index], "assets.json"));
953
+ for (const entry of entries) {
954
+ const key = assetKey(entry);
955
+ if (key === "") continue;
956
+ if (!assetMap.has(key)) assetMap.set(key, normalizeAsset(entry, delegationId, stage, updatedAt));
957
+ }
958
+ }
959
+ if (assetMap.size > 0) {
960
+ const assetsDir = join(root, "assets");
961
+ await mkdir(assetsDir, { recursive: true });
962
+ const canonical = [...assetMap.values()].sort((a, b) => a.value.localeCompare(b.value));
963
+ await writeFile(join(assetsDir, "assets.json"), `${JSON.stringify(canonical, null, 2)}\n`, "utf8");
964
+ }
965
+ const serviceMap = /* @__PURE__ */ new Map();
966
+ for (let index = 0; index < delegationDirs.length; index += 1) {
967
+ const delegationId = delegationIds[index] ?? `D-${String(index + 1).padStart(3, "0")}`;
968
+ const entries = readJsonArray(join(delegationDirs[index], "services.json"));
969
+ for (const entry of entries) {
970
+ const key = serviceKey(entry);
971
+ if (key === "") continue;
972
+ if (!serviceMap.has(key)) serviceMap.set(key, normalizeService(entry, delegationId, stage, updatedAt));
973
+ }
974
+ }
975
+ if (serviceMap.size > 0) {
976
+ const assetsDir = join(root, "assets");
977
+ await mkdir(assetsDir, { recursive: true });
978
+ const canonical = [...serviceMap.values()].sort((a, b) => `${a.asset}:${a.protocol}:${a.port}`.localeCompare(`${b.asset}:${b.protocol}:${b.port}`));
979
+ await writeFile(join(assetsDir, "services.json"), `${JSON.stringify(canonical, null, 2)}\n`, "utf8");
980
+ }
981
+ }
982
+ //#endregion
983
+ //#region src/pentest/findings.ts
984
+ /**
985
+ * findings.ts — PentestRun canonical finding registry promotion.
986
+ *
987
+ * Workers produce `artifacts/findings.md` (human-readable) and optionally
988
+ * `artifacts/findings.json` (structured) inside their delegation directories.
989
+ * On stage advance, the host aggregates every delegation's findings from the
990
+ * completed stage, indexes them in `findings/findings.json`, and writes each
991
+ * delegation's finding detail as `findings/<delegationId>-findings.md` with a
992
+ * provenance header. Worker-local artifacts are never moved.
993
+ *
994
+ * Dedup: within a single stage, findings from the same delegation are kept
995
+ * as one entry (the delegation's findings.md is the authoritative unit).
996
+ * Exploitation updates to an existing finding are reflected in the index
997
+ * when the exploitation stage advances.
998
+ */
999
+ /**
1000
+ * Aggregate findings from every delegation directory in the completed stage,
1001
+ * index them in `findings/findings.json`, and write each delegation's detail
1002
+ * as `findings/<delegationId>-findings.md`.
1003
+ *
1004
+ * If a delegation produces `artifacts/findings.md`, its content is copied
1005
+ * into the canonical detail file with a provenance header.
1006
+ */
1007
+ async function promoteFindings(projectDir, stage, delegationDirs, delegationIds) {
1008
+ const root = workspaceDir(projectDir);
1009
+ const findingsDir = join(root, "findings");
1010
+ await mkdir(findingsDir, { recursive: true });
1011
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1012
+ const entries = [];
1013
+ const existingIndex = loadExistingIndex(findingsDir);
1014
+ for (let index = 0; index < delegationDirs.length; index += 1) {
1015
+ const delegationId = delegationIds[index] ?? `D-${String(index + 1).padStart(3, "0")}`;
1016
+ const delegationDir = delegationDirs[index];
1017
+ const sourceFile = join(delegationDir, "findings.md");
1018
+ if (!existsSync(sourceFile)) continue;
1019
+ const content = readFileSync(sourceFile, "utf8").trim();
1020
+ if (content === "") continue;
1021
+ const detailFile = `${delegationId}-findings.md`;
1022
+ const detailPath = join(findingsDir, detailFile);
1023
+ await writeFile(detailPath, `# ${delegationId} Findings\n\n**Source Stage**: ${stage}\n**Updated**: ${updatedAt}\n\n---\n\n${content}\n`, "utf8");
1024
+ const existing = existingIndex.get(delegationId);
1025
+ if (existing !== void 0) existingIndex.set(delegationId, {
1026
+ ...existing,
1027
+ updatedAt,
1028
+ sourceStage: stage
1029
+ });
1030
+ else entries.push({
1031
+ sourceDelegationId: delegationId,
1032
+ sourceStage: stage,
1033
+ detailFile,
1034
+ updatedAt
1035
+ });
1036
+ }
1037
+ for (const [id, entry] of existingIndex) if (!delegationIds.includes(id)) entries.push(entry);
1038
+ entries.sort((a, b) => a.sourceDelegationId.localeCompare(b.sourceDelegationId));
1039
+ if (entries.length > 0) await writeFile(join(findingsDir, "findings.json"), `${JSON.stringify(entries, null, 2)}\n`, "utf8");
1040
+ }
1041
+ function loadExistingIndex(findingsDir) {
1042
+ const file = join(findingsDir, "findings.json");
1043
+ if (!existsSync(file)) return /* @__PURE__ */ new Map();
1044
+ try {
1045
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1046
+ if (!Array.isArray(parsed)) return /* @__PURE__ */ new Map();
1047
+ const map = /* @__PURE__ */ new Map();
1048
+ for (const entry of parsed) if (entry !== null && typeof entry === "object" && typeof entry.sourceDelegationId === "string") {
1049
+ const id = entry.sourceDelegationId;
1050
+ map.set(id, entry);
1051
+ }
1052
+ return map;
1053
+ } catch {
1054
+ return /* @__PURE__ */ new Map();
1055
+ }
1056
+ }
1057
+ //#endregion
1058
+ //#region src/tools.ts
1059
+ const ROOT_TOOL_SCHEMAS = {
1060
+ pentester_delegate: {
1061
+ type: "object",
1062
+ additionalProperties: false,
1063
+ required: ["assignments"],
1064
+ properties: { assignments: {
1065
+ type: "array",
1066
+ minItems: 1,
1067
+ description: "Parallel delegations; each spawns one continuable worker session with its own deliverable directory.",
1068
+ items: {
1069
+ type: "object",
1070
+ additionalProperties: false,
1071
+ required: [
1072
+ "agent",
1073
+ "objective",
1074
+ "task_prompt"
1075
+ ],
1076
+ properties: {
1077
+ agent: {
1078
+ type: "string",
1079
+ description: "AgentProfile id attached to the current Stage (see Available AgentProfiles in your run state)."
1080
+ },
1081
+ objective: {
1082
+ type: "string",
1083
+ description: "One-line objective (recorded in run.json)."
1084
+ },
1085
+ task_prompt: {
1086
+ type: "string",
1087
+ description: "Full task context for the worker: known facts, gaps to close, references to existing deliverables."
1088
+ }
1089
+ }
1090
+ }
1091
+ } }
1092
+ },
1093
+ pentester_cancel_delegation: {
1094
+ type: "object",
1095
+ additionalProperties: false,
1096
+ required: ["delegation_id"],
1097
+ properties: {
1098
+ delegation_id: { type: "string" },
1099
+ reason: { type: "string" }
1100
+ }
1101
+ },
1102
+ pentester_advance_stage: {
1103
+ type: "object",
1104
+ additionalProperties: false,
1105
+ required: ["summary"],
1106
+ properties: {
1107
+ summary: {
1108
+ type: "string",
1109
+ description: "Stage handoff summary (what was done, confirmed, key results, next-stage focus). The host writes it to stages/<stage>/summary.md verbatim and never parses it."
1110
+ },
1111
+ run: {
1112
+ type: "object",
1113
+ additionalProperties: false,
1114
+ description: "Only for the FIRST advance of a new run (bootstrap): records the user-confirmed targets/RoE and output language. The host creates the PentestRun and completes pre-engagement.",
1115
+ properties: {
1116
+ targets: {
1117
+ type: "array",
1118
+ items: { type: "string" }
1119
+ },
1120
+ rules_of_engagement: { type: "string" },
1121
+ language: {
1122
+ type: "string",
1123
+ description: "Output language for host-generated files (e.g. zh-CN, en)."
1124
+ }
1125
+ }
1126
+ }
1127
+ }
1128
+ },
1129
+ pentester_rollback_stage: {
1130
+ type: "object",
1131
+ additionalProperties: false,
1132
+ required: ["stage", "reason"],
1133
+ properties: {
1134
+ stage: {
1135
+ type: "string",
1136
+ description: "Domain StageId to roll back to (e.g. intelligence-gathering); its completed checkpoint becomes the new timeline root."
1137
+ },
1138
+ reason: {
1139
+ type: "string",
1140
+ description: "Why the stage result needs rework."
1141
+ }
1142
+ }
1143
+ }
1144
+ };
1145
+ function requireRootCaller(exec) {
1146
+ const caller = trustedCallerFromExec(exec);
1147
+ if (caller === void 0 || !caller.isPentesterRoot) throw new Error("this tool is reserved for the Pentester Root Agent");
1148
+ if (caller.cwd === void 0) throw new Error("Root session has no trusted workspace cwd");
1149
+ return caller;
1150
+ }
1151
+ function agentOf(exec) {
1152
+ if (exec === null || typeof exec !== "object") return void 0;
1153
+ const agent = exec.agent;
1154
+ return agent !== null && typeof agent === "object" ? agent : void 0;
1155
+ }
1156
+ function signalOf(exec) {
1157
+ if (exec !== null && typeof exec === "object") {
1158
+ const signal = exec.signal;
1159
+ if (signal instanceof AbortSignal) return signal;
1160
+ }
1161
+ return new AbortController().signal;
1162
+ }
1163
+ function registerRootTools(tools, deps) {
1164
+ const output = {
1165
+ schema: {
1166
+ type: "object",
1167
+ additionalProperties: true
1168
+ },
1169
+ render: (_args, value) => [{
1170
+ type: "text",
1171
+ text: JSON.stringify(value)
1172
+ }]
1173
+ };
1174
+ tools.register({
1175
+ name: "pentester_delegate",
1176
+ description: ROOT_TOOL_DESCRIPTIONS.pentester_delegate,
1177
+ parameters: ROOT_TOOL_SCHEMAS.pentester_delegate,
1178
+ output,
1179
+ execute: (args, exec) => delegate(deps, args, exec)
1180
+ });
1181
+ tools.register({
1182
+ name: "pentester_cancel_delegation",
1183
+ description: ROOT_TOOL_DESCRIPTIONS.pentester_cancel_delegation,
1184
+ parameters: ROOT_TOOL_SCHEMAS.pentester_cancel_delegation,
1185
+ output,
1186
+ execute: (args, exec) => cancel(deps, args, exec)
1187
+ });
1188
+ tools.register({
1189
+ name: "pentester_advance_stage",
1190
+ description: ROOT_TOOL_DESCRIPTIONS.pentester_advance_stage,
1191
+ parameters: ROOT_TOOL_SCHEMAS.pentester_advance_stage,
1192
+ output,
1193
+ execute: (args, exec) => advance(deps, args, exec)
1194
+ });
1195
+ tools.register({
1196
+ name: "pentester_rollback_stage",
1197
+ description: ROOT_TOOL_DESCRIPTIONS.pentester_rollback_stage,
1198
+ parameters: ROOT_TOOL_SCHEMAS.pentester_rollback_stage,
1199
+ output,
1200
+ execute: (args, exec) => rollback(deps, args, exec)
1201
+ });
1202
+ return [
1203
+ "pentester_delegate",
1204
+ "pentester_cancel_delegation",
1205
+ "pentester_advance_stage",
1206
+ "pentester_rollback_stage"
1207
+ ];
1208
+ }
1209
+ const ROOT_TOOL_DESCRIPTIONS = {
1210
+ pentester_delegate: "Creates a new long-lived continuable PTES worker. IMPORTANT: Before calling this tool, first tell the user which worker you are creating, what it will do, and why the current PTES stage needs it. Do not call this tool silently. Each call creates a NEW durable child Session. For follow-up work with an existing child, do not call pentester_delegate again; use send_message through the continuation interface.",
1211
+ pentester_cancel_delegation: "Close a PTES Delegation. Interrupts the live child turn if active, then marks the Delegation as closed. The child Session transcript is preserved for future reference. Use send_message/interrupt_agent for follow-up or interruption without closing.",
1212
+ pentester_advance_stage: "Advance to the next PTES stage after you judge the exit criteria satisfied. The host only verifies linear order and that no active/starting delegations remain. On the FIRST call (no PentestRun yet) pass run (targets / rules_of_engagement / language) + summary: the host bootstraps the workspace, run.json, target files, git and completes pre-engagement.",
1213
+ pentester_rollback_stage: "Roll the timeline back to a completed stage checkpoint and start a rework branch from the next stage. Closes active delegations, saves a WIP backup branch, never rewrites history."
1214
+ };
1215
+ async function delegate(deps, args, exec) {
1216
+ const caller = requireRootCaller(exec);
1217
+ const projectDir = caller.cwd;
1218
+ const parent = agentOf(exec);
1219
+ if (parent === void 0) throw new Error("pentester_delegate requires a calling agent (exec.agent was undefined)");
1220
+ const signal = signalOf(exec);
1221
+ const run = await requireRun(caller, deps);
1222
+ const assignments = parseAssignments(args.assignments);
1223
+ const created = await deps.delegations.delegate({
1224
+ projectDir,
1225
+ run,
1226
+ profiles: deps.profiles,
1227
+ rootSessionId: caller.sessionId,
1228
+ parent,
1229
+ signal
1230
+ }, assignments);
1231
+ return {
1232
+ ...rootState(run, deps.profiles),
1233
+ created: created.map((d) => ({
1234
+ id: d.id,
1235
+ agent: d.agentId,
1236
+ status: d.status,
1237
+ ...d.sessionId === void 0 ? {} : { childSessionId: d.sessionId }
1238
+ }))
1239
+ };
1240
+ }
1241
+ async function cancel(deps, args, exec) {
1242
+ const caller = requireRootCaller(exec);
1243
+ const run = await requireRun(caller, deps);
1244
+ const delegationId = typeof args.delegation_id === "string" ? args.delegation_id : "";
1245
+ const reason = typeof args.reason === "string" ? args.reason : void 0;
1246
+ const cancelled = await deps.delegations.cancel({
1247
+ projectDir: caller.cwd,
1248
+ run
1249
+ }, delegationId, reason);
1250
+ return {
1251
+ cancelled: {
1252
+ id: cancelled.id,
1253
+ status: cancelled.status
1254
+ },
1255
+ ...rootState(run, deps.profiles)
1256
+ };
1257
+ }
1258
+ async function advance(deps, args, exec) {
1259
+ const projectDir = requireRootCaller(exec).cwd;
1260
+ const summary = typeof args.summary === "string" ? args.summary : "";
1261
+ if (summary.trim() === "") throw new Error("pentester_advance_stage requires a non-empty summary (stage handoff text)");
1262
+ let run = await resolvePentestRun(projectDir);
1263
+ if (run === null) run = await bootstrapRun(deps, projectDir, parseRunInfo(args.run));
1264
+ else await initWorkspace(projectDir, { language: run.language });
1265
+ if (run.status === "completed" || run.currentStage === null) throw new Error("PentestRun is already completed; no stage to advance");
1266
+ const previous = run.currentStage;
1267
+ if (deps.delegations.stageHasPendingWork(run, previous)) throw new Error(`stage ${previous} still has active or starting delegations; close them with pentester_cancel_delegation before advancing`);
1268
+ await deps.syncWorkspace(projectDir, run).catch(() => void 0);
1269
+ const stageDelegations = run.delegations.filter((d) => d.stageId === previous);
1270
+ const delegationDirs = stageDelegations.map((d) => d.workspaceDir);
1271
+ const delegationIds = stageDelegations.map((d) => d.id);
1272
+ await promoteAssets(projectDir, previous, delegationDirs, delegationIds);
1273
+ await promoteFindings(projectDir, previous, delegationDirs, delegationIds);
1274
+ for (const delegation of stageDelegations) await promoteDeliverables(projectDir, delegation.workspaceDir);
1275
+ await writeStageSummary(projectDir, previous, summary);
1276
+ const target = advanceStage(run, summary);
1277
+ await saveRun(projectDir, run);
1278
+ const tag = await deps.git.checkpointStage(projectDir, previous);
1279
+ await appendEvent(projectDir, {
1280
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1281
+ type: "stage.completed",
1282
+ data: {
1283
+ stage: previous,
1284
+ tag,
1285
+ next: target
1286
+ }
1287
+ });
1288
+ await deps.pushWorkspace(projectDir, run).catch(() => void 0);
1289
+ return {
1290
+ previous,
1291
+ ...target === null ? { completed: true } : { next: target },
1292
+ tag,
1293
+ ...rootState(run, deps.profiles)
1294
+ };
1295
+ }
1296
+ async function rollback(deps, args, exec) {
1297
+ const caller = requireRootCaller(exec);
1298
+ const projectDir = caller.cwd;
1299
+ const run = await requireRun(caller, deps);
1300
+ const stageArg = typeof args.stage === "string" ? args.stage : "";
1301
+ const reason = typeof args.reason === "string" ? args.reason : "";
1302
+ if (!isStageId(stageArg)) throw new Error(`pentester_rollback_stage: unknown stage "${stageArg}"`);
1303
+ const next = nextStage(stageArg);
1304
+ if (next === void 0) throw new Error(`pentester_rollback_stage: cannot roll back to the final stage ${stageArg}`);
1305
+ if (!run.stageHistory.some((entry) => entry.stage === stageArg)) throw new Error(`pentester_rollback_stage: stage ${stageArg} has no completed checkpoint on this timeline`);
1306
+ const active = run.delegations.filter((d) => d.status === "active" || d.status === "starting");
1307
+ for (const delegation of active) await deps.delegations.cancel({
1308
+ projectDir,
1309
+ run
1310
+ }, delegation.id, "rolled back by Root");
1311
+ let backup;
1312
+ if (await deps.git.hasChanges(projectDir)) backup = await deps.git.createBackupBranch(projectDir, stageArg);
1313
+ const checkpoint = await deps.git.findStageCheckpoint(projectDir, stageArg);
1314
+ if (checkpoint === void 0) throw new Error(`pentester_rollback_stage: no checkpoint commit found for stage ${stageArg}`);
1315
+ const branch = await deps.git.createReworkBranch(projectDir, checkpoint, stageDir(next));
1316
+ const authoritativeRun = await resolvePentestRun(projectDir);
1317
+ if (authoritativeRun === null) throw new Error("pentester_rollback_stage: run.json disappeared after checkout — this is a bug");
1318
+ await appendEvent(projectDir, {
1319
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1320
+ type: "stage.rollback",
1321
+ data: {
1322
+ stage: stageArg,
1323
+ checkpoint,
1324
+ branch,
1325
+ backup,
1326
+ reason
1327
+ }
1328
+ });
1329
+ await deps.pushWorkspace(projectDir, authoritativeRun).catch(() => void 0);
1330
+ return {
1331
+ reopenedStage: stageArg,
1332
+ currentStage: stageArg,
1333
+ branch,
1334
+ ...backup === void 0 ? {} : { backupBranch: backup },
1335
+ reason,
1336
+ ...rootState(authoritativeRun, deps.profiles)
1337
+ };
1338
+ }
1339
+ const NO_RUN_MESSAGE = "No PentestRun exists. Complete pre-engagement and call pentester_advance_stage first — pass run (targets / rules_of_engagement / language) + summary to initialize the run.";
1340
+ async function requireRun(caller, _deps) {
1341
+ const projectDir = caller.cwd;
1342
+ await initWorkspace(projectDir, { language: void 0 });
1343
+ const run = await resolvePentestRun(projectDir);
1344
+ if (run === null) throw new Error(NO_RUN_MESSAGE);
1345
+ return run;
1346
+ }
1347
+ async function bootstrapRun(deps, projectDir, runInfo) {
1348
+ if (runInfo === void 0 || runInfo.targets === void 0 || runInfo.targets.length === 0) throw new Error(NO_RUN_MESSAGE);
1349
+ const primary = runInfo.targets[0];
1350
+ await initWorkspace(projectDir, {
1351
+ target: primary,
1352
+ language: runInfo.language,
1353
+ scope: renderScope(runInfo.targets),
1354
+ roe: runInfo.rulesOfEngagement
1355
+ });
1356
+ await writePreEngagementInput(projectDir, {
1357
+ targets: runInfo.targets,
1358
+ scope: renderScope(runInfo.targets),
1359
+ roe: runInfo.rulesOfEngagement ?? "Standard RoE",
1360
+ language: runInfo.language ?? "zh-CN"
1361
+ });
1362
+ const created = {
1363
+ schemaVersion: 1,
1364
+ id: crypto.randomUUID(),
1365
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1366
+ targets: [...runInfo.targets],
1367
+ ...runInfo.rulesOfEngagement === void 0 ? {} : { rulesOfEngagement: runInfo.rulesOfEngagement },
1368
+ ...runInfo.language === void 0 ? {} : { language: runInfo.language },
1369
+ status: "active",
1370
+ currentStage: "pre-engagement",
1371
+ stageHistory: [{
1372
+ stage: "pre-engagement",
1373
+ enteredAt: (/* @__PURE__ */ new Date()).toISOString()
1374
+ }],
1375
+ delegations: []
1376
+ };
1377
+ await saveRun(projectDir, created);
1378
+ await deps.git.init(projectDir, primary);
1379
+ return created;
1380
+ }
1381
+ function renderScope(targets) {
1382
+ return [
1383
+ "# Scope",
1384
+ "",
1385
+ `- Primary Target: ${targets[0] ?? ""}`,
1386
+ ...targets.slice(1).map((target) => `- ${target}`),
1387
+ ""
1388
+ ].join("\n");
1389
+ }
1390
+ function parseAssignments(value) {
1391
+ if (!Array.isArray(value) || value.length === 0) throw new Error("assignments must be a non-empty array");
1392
+ return value.map((entry) => {
1393
+ if (entry === null || typeof entry !== "object") throw new Error("invalid assignment");
1394
+ const record = entry;
1395
+ if (typeof record.agent !== "string" || typeof record.objective !== "string" || typeof record.task_prompt !== "string") throw new Error("assignment requires agent, objective and task_prompt");
1396
+ return {
1397
+ agent: record.agent,
1398
+ objective: record.objective,
1399
+ taskPrompt: record.task_prompt
1400
+ };
1401
+ });
1402
+ }
1403
+ function parseRunInfo(value) {
1404
+ if (value === void 0) return void 0;
1405
+ if (value === null || typeof value !== "object") throw new Error("invalid run info");
1406
+ const record = value;
1407
+ const targets = Array.isArray(record.targets) ? record.targets.map(String) : void 0;
1408
+ const roe = typeof record.rules_of_engagement === "string" ? record.rules_of_engagement : void 0;
1409
+ const language = typeof record.language === "string" && record.language.length > 0 ? record.language : void 0;
1410
+ return {
1411
+ ...targets === void 0 ? {} : { targets },
1412
+ ...roe === void 0 ? {} : { rulesOfEngagement: roe },
1413
+ ...language === void 0 ? {} : { language }
1414
+ };
1415
+ }
1416
+ function rootState(run, profiles) {
1417
+ const stage = run.currentStage === null ? void 0 : stageDefinition(run.currentStage);
1418
+ const allowedProfiles = (stage?.agentIds ?? []).map((id) => profiles.get(id)).filter((profile) => profile !== void 0).map((profile) => ({
1419
+ id: profile.id,
1420
+ name: profile.name
1421
+ }));
1422
+ return {
1423
+ run: {
1424
+ currentStage: run.currentStage,
1425
+ status: run.status ?? "active",
1426
+ targets: run.targets
1427
+ },
1428
+ stage: stage === void 0 ? null : {
1429
+ id: stage.id,
1430
+ name: stage.name,
1431
+ goal: stage.goal,
1432
+ exitCriteria: stage.exitCriteria,
1433
+ allowedProfiles
1434
+ },
1435
+ delegations: run.delegations.map((d) => ({
1436
+ id: d.id,
1437
+ stage: d.stageId,
1438
+ agent: d.agentId,
1439
+ status: d.status,
1440
+ ...d.sessionId === void 0 ? {} : { childSessionId: d.sessionId },
1441
+ ...d.finishedAt === void 0 ? {} : { finishedAt: d.finishedAt }
1442
+ }))
1443
+ };
1444
+ }
1445
+ //#endregion
1446
+ //#region src/profiles.ts
1447
+ /**
1448
+ * profiles.ts — AgentProfile registry loaded from agents/<id>/profile.yml.
1449
+ * The YAML subset is fixed by this schema: scalar keys, one block scalar
1450
+ * (`systemPrompt: |`), and two string lists. No external yaml dependency.
1451
+ */
1452
+ var ProfileError = class extends Error {};
1453
+ function parseProfileYaml(text, file) {
1454
+ const lines = text.split("\n");
1455
+ const scalars = /* @__PURE__ */ new Map();
1456
+ const lists = /* @__PURE__ */ new Map();
1457
+ let key;
1458
+ let block;
1459
+ let listKey;
1460
+ const closeBlock = () => {
1461
+ if (key !== void 0 && block !== void 0) scalars.set(key, block.join("\n").replace(/\n+$/, ""));
1462
+ key = void 0;
1463
+ block = void 0;
1464
+ };
1465
+ for (const raw of lines) {
1466
+ if (block !== void 0 && (raw.startsWith(" ") || raw.trim() === "")) {
1467
+ block.push(raw.replace(/^ {2}/, ""));
1468
+ continue;
1469
+ }
1470
+ closeBlock();
1471
+ const line = raw.trimEnd();
1472
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
1473
+ const listItem = /^\s*-\s+(.*)$/.exec(line);
1474
+ if (listItem !== null && listKey !== void 0) {
1475
+ lists.get(listKey).push(listItem[1].trim());
1476
+ continue;
1477
+ }
1478
+ const pair = /^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(line);
1479
+ if (pair === null) throw new ProfileError(`${file}: unparsable line: ${line}`);
1480
+ const [, name, value] = pair;
1481
+ if (value === "|" || value === "|-" || value === ">") {
1482
+ key = name;
1483
+ block = [];
1484
+ listKey = void 0;
1485
+ continue;
1486
+ }
1487
+ if (value === "") {
1488
+ listKey = name;
1489
+ lists.set(name, []);
1490
+ continue;
1491
+ }
1492
+ scalars.set(name, value.trim());
1493
+ listKey = void 0;
1494
+ }
1495
+ closeBlock();
1496
+ const id = scalars.get("id");
1497
+ const name = scalars.get("name");
1498
+ const description = scalars.get("description");
1499
+ const systemPrompt = scalars.get("systemPrompt");
1500
+ if (id === void 0 || name === void 0 || systemPrompt === void 0) throw new ProfileError(`${file}: id, name and systemPrompt are required`);
1501
+ return {
1502
+ id,
1503
+ name,
1504
+ description: description ?? "",
1505
+ systemPrompt,
1506
+ skills: lists.get("skills") ?? [],
1507
+ mcpServers: lists.get("mcpServers") ?? [],
1508
+ ...scalar(scalars, "model") === void 0 ? {} : { model: scalar(scalars, "model") }
1509
+ };
1510
+ }
1511
+ function scalar(scalars, key) {
1512
+ const value = scalars.get(key);
1513
+ return value !== void 0 && value.length > 0 ? value : void 0;
1514
+ }
1515
+ function loadAgentProfiles(root) {
1516
+ const profiles = /* @__PURE__ */ new Map();
1517
+ let entries;
1518
+ try {
1519
+ entries = readdirSync(root, { withFileTypes: true });
1520
+ } catch {
1521
+ return profiles;
1522
+ }
1523
+ for (const entry of entries) {
1524
+ if (!entry.isDirectory()) continue;
1525
+ const file = join(root, entry.name, "profile.yml");
1526
+ let text;
1527
+ try {
1528
+ text = readFileSync(file, "utf8");
1529
+ } catch {
1530
+ continue;
1531
+ }
1532
+ const profile = parseProfileYaml(text, file);
1533
+ if (profiles.has(profile.id)) throw new ProfileError(`duplicate AgentProfile id: ${profile.id}`);
1534
+ profiles.set(profile.id, profile);
1535
+ }
1536
+ return profiles;
1537
+ }
1538
+ //#endregion
1539
+ //#region src/docker/host.ts
1540
+ /**
1541
+ * docker/host.ts — docker 连接层:host 解析(本地优先回退链)、连接探测、
1542
+ * 引擎连接构造。与执行细节无关,可独立测试。
1543
+ */
1544
+ const DEFAULT_UNIX_SOCKET = "/var/run/docker.sock";
1545
+ var DockerError = class extends Error {};
1546
+ function parseDockerHost(value) {
1547
+ const raw = value.trim();
1548
+ if (raw === "") throw new DockerError("empty docker host");
1549
+ if (raw.startsWith("unix://")) return { socketPath: raw.slice(7) || "/var/run/docker.sock" };
1550
+ if (raw.startsWith("/") && !raw.includes("://")) return { socketPath: raw };
1551
+ for (const scheme of [
1552
+ "tcp://",
1553
+ "http://",
1554
+ "https://"
1555
+ ]) {
1556
+ if (!raw.startsWith(scheme)) continue;
1557
+ const protocol = scheme === "tcp://" ? "http" : scheme.replace("://", "");
1558
+ const [host, portText] = raw.slice(scheme.length).replace(/\/.*$/, "").split(":");
1559
+ if (host === void 0 || host === "") throw new DockerError(`docker host missing hostname: ${raw}`);
1560
+ const port = portText === void 0 ? 2375 : Number(portText);
1561
+ if (!Number.isInteger(port)) throw new DockerError(`docker host invalid port: ${raw}`);
1562
+ return {
1563
+ protocol,
1564
+ host,
1565
+ port
1566
+ };
1567
+ }
1568
+ if (/^[^/:]+:\d+$/.test(raw)) {
1569
+ const [host, portText] = raw.split(":");
1570
+ return {
1571
+ protocol: "http",
1572
+ host,
1573
+ port: Number(portText)
1574
+ };
1575
+ }
1576
+ throw new DockerError(`unsupported docker host: ${raw} (use unix:///path, tcp://host:port or http://host:port)`);
1577
+ }
1578
+ /**
1579
+ * 本地 Docker socket 候选路径(按平台常见布局,先命中先用)。
1580
+ * macOS:OrbStack / Docker Desktop;Linux:系统 socket。
1581
+ */
1582
+ function localSocketCandidates() {
1583
+ const home = process.env.HOME ?? "";
1584
+ return [DEFAULT_UNIX_SOCKET, ...home === "" ? [] : [join(home, ".orbstack", "run", "docker.sock"), join(home, ".docker", "run", "docker.sock")]];
1585
+ }
1586
+ /**
1587
+ * 解析全局 docker host。优先级:
1588
+ * 1. 显式配置(settings.json / 插件配置)
1589
+ * 2. 本地 socket(候选路径中第一个存在的 —— “未指定优先使用本地的”)
1590
+ * 3. DOCKER_HOST 环境变量
1591
+ * 4. 默认 unix socket
1592
+ */
1593
+ function resolveDockerHost(configured, deps = {}) {
1594
+ if (configured !== void 0 && configured.trim() !== "") return parseDockerHost(configured);
1595
+ return resolveDockerHostWithSource("", "", deps).host;
1596
+ }
1597
+ /**
1598
+ * 带来源的完整解析。优先级:
1599
+ * settings.json(Settings UI 保存的显式配置)
1600
+ * > 插件配置 dockerHost
1601
+ * > 本地 socket(自动发现 —— “未指定优先使用本地的”)
1602
+ * > DOCKER_HOST 环境变量
1603
+ * > 默认 unix socket
1604
+ */
1605
+ function resolveDockerHostWithSource(settingsHost, configHost, deps = {}) {
1606
+ if (settingsHost.trim() !== "") return {
1607
+ host: parseDockerHost(settingsHost),
1608
+ source: "settings"
1609
+ };
1610
+ if (configHost.trim() !== "") return {
1611
+ host: parseDockerHost(configHost),
1612
+ source: "config"
1613
+ };
1614
+ const exists = deps.socketExists ?? existsSync;
1615
+ const local = localSocketCandidates().find((candidate) => exists(candidate));
1616
+ if (local !== void 0) return {
1617
+ host: { socketPath: local },
1618
+ source: "local"
1619
+ };
1620
+ const env = process.env.DOCKER_HOST;
1621
+ if (env !== void 0 && env.trim() !== "") return {
1622
+ host: parseDockerHost(env),
1623
+ source: "env"
1624
+ };
1625
+ return {
1626
+ host: { socketPath: DEFAULT_UNIX_SOCKET },
1627
+ source: "default"
1628
+ };
1629
+ }
1630
+ /**
1631
+ * 探测一个 docker host 是否可用(GET /version)。engine 可注入用于测试。
1632
+ */
1633
+ async function probeDockerHost(host, engine) {
1634
+ const target = engine ?? engineFromOptions({ host });
1635
+ const endpoint = formatDockerHost(host);
1636
+ try {
1637
+ const info = await target.version();
1638
+ return {
1639
+ reachable: true,
1640
+ endpoint,
1641
+ ...typeof info.Version === "string" ? { version: info.Version } : {},
1642
+ ...typeof info.ApiVersion === "string" ? { apiVersion: info.ApiVersion } : {},
1643
+ ...typeof info.Os === "string" ? { os: info.Os } : {}
1644
+ };
1645
+ } catch (error) {
1646
+ return {
1647
+ reachable: false,
1648
+ endpoint,
1649
+ error: error instanceof Error ? error.message : String(error)
1650
+ };
1651
+ }
1652
+ }
1653
+ function isRemoteHost(host) {
1654
+ return host.protocol !== void 0;
1655
+ }
1656
+ function formatDockerHost(host) {
1657
+ if (host.socketPath !== void 0) return `unix://${host.socketPath}`;
1658
+ return `${host.protocol}://${host.host}:${host.port}`;
1659
+ }
1660
+ function engineFromOptions(options) {
1661
+ const host = options.host;
1662
+ const connection = host === void 0 || host.socketPath !== void 0 ? { socketPath: host?.socketPath ?? "/var/run/docker.sock" } : {
1663
+ protocol: host.protocol,
1664
+ host: host.host,
1665
+ port: host.port
1666
+ };
1667
+ return new Dockerode(connection);
1668
+ }
1669
+ //#endregion
1670
+ //#region src/docker/images.ts
1671
+ /**
1672
+ * docker/images.ts — 镜像层:Toolbox 白名单、inspect 原语。
1673
+ * 函数式无状态,引擎实例由调用方传入。
1674
+ */
1675
+ const TOOLBOX_ALLOWLIST = [{
1676
+ name: "kali",
1677
+ image: "fb0sh/dsh-pentester-kali:latest"
1678
+ }];
1679
+ function toolboxFor(name, overrides) {
1680
+ const spec = TOOLBOX_ALLOWLIST.find((toolbox) => toolbox.name === name);
1681
+ if (spec === void 0) throw new DockerError(`unknown toolbox: ${name}`);
1682
+ const image = overrides?.[name];
1683
+ return image === void 0 ? spec : {
1684
+ ...spec,
1685
+ image
1686
+ };
1687
+ }
1688
+ /** 镜像是否已就绪(本地存在)。 */
1689
+ async function imageReady(engine, image) {
1690
+ try {
1691
+ await engine.getImage(image).inspect();
1692
+ return true;
1693
+ } catch {
1694
+ return false;
1695
+ }
1696
+ }
1697
+ //#endregion
1698
+ //#region src/docker/runtime.ts
1699
+ /**
1700
+ * docker/runtime.ts — 执行运行时:硬化 HostConfig、容器/卷生命周期、
1701
+ * exec demux、volume→本地镜像同步、工具目录读取;镜像原语委托给
1702
+ * docker/images.ts。不变量见 docs/plan.md 与 ADR 0007。
1703
+ */
1704
+ const MAX_EXEC_TIMEOUT_MS = 6e5;
1705
+ const MAX_OUTPUT_CHARS = 2e5;
1706
+ const BLOCKED_ENV_PREFIXES = ["DOCKER", "DSH_"];
1707
+ const CAP_ADD_ALLOWLIST = [
1708
+ "NET_ADMIN",
1709
+ "NET_RAW",
1710
+ "NET_BIND_SERVICE"
1711
+ ];
1712
+ /** Toolbox container name: dsh-pentester-xxxxxxxx (8 lowercase hex). */
1713
+ function createToolboxContainerName() {
1714
+ return `dsh-pentester-${randomBytes(4).toString("hex")}`;
1715
+ }
1716
+ /** 固定注入 Toolbox 容器的代理环境变量(容器内部代理服务)。 */
1717
+ const FIXED_CONTAINER_ENV = ["HTTP_PROXY=http://127.0.0.1:8080", "HTTPS_PROXY=http://127.0.0.1:8080"];
1718
+ /** Defense in depth: validate the HostConfig we are about to submit. */
1719
+ function assertSafeHostConfig(hostConfig) {
1720
+ if (hostConfig.Privileged === true) throw new DockerError("privileged containers are forbidden");
1721
+ if (hostConfig.NetworkMode === "host") throw new DockerError("host network is forbidden");
1722
+ if (hostConfig.PidMode === "host") throw new DockerError("host pid namespace is forbidden");
1723
+ if (hostConfig.IpcMode === "host") throw new DockerError("host ipc namespace is forbidden");
1724
+ const binds = hostConfig.Binds;
1725
+ if (Array.isArray(binds)) for (const bind of binds) {
1726
+ if (typeof bind !== "string") continue;
1727
+ const source = bind.split(":")[0];
1728
+ if (source === "/" || source.endsWith("/docker.sock")) throw new DockerError(`forbidden bind source: ${source}`);
1729
+ }
1730
+ const capAdd = hostConfig.CapAdd;
1731
+ if (Array.isArray(capAdd)) {
1732
+ for (const cap of capAdd) if (typeof cap === "string" && !CAP_ADD_ALLOWLIST.includes(cap)) throw new DockerError(`capability ${cap} is not in the allowlist`);
1733
+ }
1734
+ }
1735
+ function safeEnv(env) {
1736
+ const safe = {};
1737
+ for (const [key, value] of Object.entries(env ?? {})) {
1738
+ if (BLOCKED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) throw new DockerError(`environment key ${key} is not allowed`);
1739
+ safe[key] = value;
1740
+ }
1741
+ return safe;
1742
+ }
1743
+ function volumeNameFor(projectDir) {
1744
+ return `dsh-pentester-ws-${createHash("sha1").update(projectDir).digest("hex").slice(0, 12)}`;
1745
+ }
1746
+ function localMirrorDir(projectDir) {
1747
+ return workspaceDir(projectDir);
1748
+ }
1749
+ var DockerRuntime = class {
1750
+ engine;
1751
+ host;
1752
+ imageOverrides;
1753
+ ensuring = /* @__PURE__ */ new Map();
1754
+ volumes = /* @__PURE__ */ new Set();
1755
+ syncing = /* @__PURE__ */ new Map();
1756
+ pushing = /* @__PURE__ */ new Map();
1757
+ constructor(options) {
1758
+ this.engine = options.engine ?? engineFromOptions(options);
1759
+ this.host = options.host ?? { socketPath: "/var/run/docker.sock" };
1760
+ this.imageOverrides = options.imageOverrides;
1761
+ }
1762
+ /** 更新 toolbox 镜像覆盖(Settings UI 保存后热生效)。 */
1763
+ updateToolboxImage(toolbox, image) {
1764
+ const next = { ...this.imageOverrides ?? {} };
1765
+ if (image === void 0 || image.trim() === "") delete next[toolbox];
1766
+ else next[toolbox] = image.trim();
1767
+ this.imageOverrides = next;
1768
+ }
1769
+ /**
1770
+ * 切换 docker host(Settings UI 保存后调用)。重建底层连接并清空
1771
+ * 卷/容器缓存;卷与容器按标签在新 daemon 上重新发现。
1772
+ */
1773
+ updateHost(host) {
1774
+ this.engine = engineFromOptions({ host });
1775
+ this.host = host;
1776
+ this.volumes.clear();
1777
+ this.ensuring.clear();
1778
+ }
1779
+ get endpoint() {
1780
+ return formatDockerHost(this.host);
1781
+ }
1782
+ get remote() {
1783
+ return isRemoteHost(this.host);
1784
+ }
1785
+ async ensureImage(toolboxName) {
1786
+ const spec = toolboxFor(toolboxName, this.imageOverrides);
1787
+ try {
1788
+ await this.engine.getImage(spec.image).inspect();
1789
+ return spec;
1790
+ } catch {
1791
+ throw new DockerError(`toolbox image ${spec.image} not found; ensure the image exists on the Docker host`);
1792
+ }
1793
+ }
1794
+ /**
1795
+ * One docker volume per project (all stages, all containers of the project
1796
+ * mount the same volume at /workspace). The container is per PentestRun and
1797
+ * reused while running.
1798
+ */
1799
+ async ensureRunContainer(options) {
1800
+ const volume = this.remote ? await this.ensureWorkspaceVolume(options.projectDir) : void 0;
1801
+ const label = "dsh.pentester.run";
1802
+ const existing = await this.engine.listContainers({
1803
+ all: true,
1804
+ filters: { label: [`${label}=${options.runId}`] }
1805
+ });
1806
+ for (const match of existing) {
1807
+ if (match.State === "running") return String(match.Id);
1808
+ await this.engine.getContainer(String(match.Id)).remove({ force: true }).catch(() => void 0);
1809
+ }
1810
+ let ensure = this.ensuring.get(options.runId);
1811
+ if (ensure === void 0) {
1812
+ ensure = this.createRunContainer(options, volume, label);
1813
+ this.ensuring.set(options.runId, ensure);
1814
+ ensure.finally(() => this.ensuring.delete(options.runId));
1815
+ }
1816
+ const containerId = await ensure;
1817
+ if (this.remote && volume?.created === true) await this.pushLocalMirror(containerId, options.projectDir).catch(() => void 0);
1818
+ return containerId;
1819
+ }
1820
+ /**
1821
+ * 远程 workspace volume:存在则复用(§24 recovery 优先级),不存在才创建。
1822
+ * `created: true` 表示本轮新建 —— 调用方据此决定是否从本地恢复数据。
1823
+ */
1824
+ async ensureWorkspaceVolume(projectDir) {
1825
+ const volume = volumeNameFor(projectDir);
1826
+ if (this.volumes.has(volume)) return {
1827
+ name: volume,
1828
+ created: false
1829
+ };
1830
+ if (await this.engine.getVolume(volume).inspect().then(() => true, () => false)) {
1831
+ this.volumes.add(volume);
1832
+ return {
1833
+ name: volume,
1834
+ created: false
1835
+ };
1836
+ }
1837
+ const created = await this.engine.createVolume({
1838
+ Name: volume,
1839
+ Labels: {
1840
+ "dsh.pentester.managed": "true",
1841
+ "dsh.pentester.project": volume
1842
+ }
1843
+ });
1844
+ this.volumes.add(created.Name);
1845
+ return {
1846
+ name: created.Name,
1847
+ created: true
1848
+ };
1849
+ }
1850
+ async createRunContainer(options, volume, label) {
1851
+ const hostConfig = {
1852
+ Mounts: [volume === void 0 ? {
1853
+ Type: "bind",
1854
+ Source: localMirrorDir(options.projectDir),
1855
+ Target: "/workspace"
1856
+ } : {
1857
+ Type: "volume",
1858
+ Source: volume.name,
1859
+ Target: "/workspace"
1860
+ }],
1861
+ CapAdd: [...CAP_ADD_ALLOWLIST],
1862
+ AutoRemove: false
1863
+ };
1864
+ assertSafeHostConfig(hostConfig);
1865
+ const containerName = createToolboxContainerName();
1866
+ const container = await this.engine.createContainer({
1867
+ name: containerName,
1868
+ Image: options.toolbox.image,
1869
+ Env: [...FIXED_CONTAINER_ENV],
1870
+ Labels: {
1871
+ [label]: options.runId,
1872
+ "dsh.pentester.managed": "true"
1873
+ },
1874
+ HostConfig: hostConfig,
1875
+ WorkingDir: "/workspace",
1876
+ Tty: false,
1877
+ OpenStdin: false,
1878
+ Cmd: ["sleep", "infinity"]
1879
+ });
1880
+ await container.start();
1881
+ this.exec(container.id, { argv: ["/pentester/bin/record-traffic.sh"] }).catch(() => void 0);
1882
+ return container.id;
1883
+ }
1884
+ catalogs = /* @__PURE__ */ new Map();
1885
+ /**
1886
+ * 读取镜像内的工具目录(/pentester/tools.json),按 containerId 缓存一次。
1887
+ * 供 worker prompt 注入“有什么工具、怎么用”的摘要。
1888
+ */
1889
+ async readToolCatalog(containerId) {
1890
+ let cached = this.catalogs.get(containerId);
1891
+ if (cached === void 0) {
1892
+ cached = this.exec(containerId, { argv: ["cat", "/pentester/tools.json"] }).then((result) => JSON.parse(result.stdout));
1893
+ this.catalogs.set(containerId, cached);
1894
+ cached.catch(() => this.catalogs.delete(containerId));
1895
+ }
1896
+ return cached;
1897
+ }
1898
+ async stopRunContainer(runId) {
1899
+ const matches = await this.engine.listContainers({
1900
+ all: true,
1901
+ filters: { label: [`dsh.pentester.run=${runId}`] }
1902
+ });
1903
+ for (const match of matches) await this.engine.getContainer(String(match.Id)).remove({ force: true }).catch(() => void 0);
1904
+ }
1905
+ /**
1906
+ * Delegation settle 的轻量交付读取(§12:settle 不做全量 Workspace sync)。
1907
+ * 远程模式直接通过 exec 读 result.md 并列出 delegation 目录文件;
1908
+ * 本地 bind mount 下 host == container,直接读本地文件系统。
1909
+ * @returns result.md 内容(缺失 undefined)与相对文件清单。
1910
+ */
1911
+ async readDelegationDelivery(runId, projectDir, delegationDir) {
1912
+ if (!this.remote) {
1913
+ const { readdir, readFile } = await import("node:fs/promises");
1914
+ const { join } = await import("node:path");
1915
+ const walk = async (dir, prefix, out) => {
1916
+ let entries;
1917
+ try {
1918
+ entries = await readdir(dir);
1919
+ } catch {
1920
+ return;
1921
+ }
1922
+ for (const entry of entries) {
1923
+ const abs = join(dir, entry);
1924
+ const rel = prefix === "" ? entry : `${prefix}/${entry}`;
1925
+ if ((await import("node:fs").then((fs) => fs.statSync(abs)).catch(() => void 0))?.isDirectory()) await walk(abs, rel, out);
1926
+ else out.push(rel);
1927
+ }
1928
+ };
1929
+ const files = [];
1930
+ await walk(delegationDir, "", files);
1931
+ let result;
1932
+ try {
1933
+ result = await readFile(join(delegationDir, "result.md"), "utf8");
1934
+ } catch {
1935
+ result = void 0;
1936
+ }
1937
+ return {
1938
+ result,
1939
+ files: files.sort()
1940
+ };
1941
+ }
1942
+ const containerPath = delegationDir.startsWith(projectDir) ? delegationDir.slice(projectDir.length).replace(/\\/g, "/") : "/workspace";
1943
+ const containerId = await this.findRunContainer(runId);
1944
+ if (containerId === void 0) return { files: [] };
1945
+ const files = ((await this.exec(containerId, { argv: [
1946
+ "sh",
1947
+ "-c",
1948
+ `find '${containerPath}' -type f | sort`
1949
+ ] }).catch(() => void 0))?.stdout ?? "").split("\n").map((line) => line.trim()).filter((line) => line.startsWith(`${containerPath}/`)).map((line) => line.slice(containerPath.length + 1));
1950
+ const resultText = await this.exec(containerId, { argv: ["cat", `${containerPath}/result.md`] }).catch(() => void 0);
1951
+ return {
1952
+ result: resultText !== void 0 && resultText.exitCode === 0 && resultText.stdout.trim() !== "" ? resultText.stdout : void 0,
1953
+ files
1954
+ };
1955
+ }
1956
+ /**
1957
+ * Push 方向:把宿主侧 workspace/(本地镜像)打包解压进容器 /workspace。
1958
+ * 派发 delegation / advance 后调用,让 Worker 看到宿主写好的
1959
+ * target/、stage scaffold。排除 .git(Worker 不可改 Git)。
1960
+ * 本地 bind mount 下 host == container,无操作直接返回。
1961
+ */
1962
+ async pushWorkspace(runId, projectDir) {
1963
+ if (!this.remote) return;
1964
+ const key = `${runId}:${projectDir}`;
1965
+ const inFlight = this.pushing.get(key);
1966
+ if (inFlight !== void 0) return inFlight;
1967
+ const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pushLocalMirror(containerId, projectDir)).finally(() => this.pushing.delete(key));
1968
+ this.pushing.set(key, task);
1969
+ return task;
1970
+ }
1971
+ async pushLocalMirror(containerId, projectDir) {
1972
+ const archive = await packDirectory(localMirrorDir(projectDir), { exclude: (name) => name === ".git" });
1973
+ await this.engine.getContainer(containerId).putArchive(archive, { path: "/workspace" });
1974
+ }
1975
+ /**
1976
+ * Pull the volume's /workspace tree down into the local mirror. Works for
1977
+ * remote daemons; concurrent calls coalesce. Fire-and-forget after writes,
1978
+ * awaited before the Root wake. 本地 bind mount 下 host == container,无操作。
1979
+ * Mirror 语义(§20):create/update/delete 都反映 —— 容器中不存在的本地
1980
+ * 幽灵文件会被删除。Host 控制态(.git / .dsh-pentester)不参与 pull,
1981
+ * 防止 remote stale state 覆盖宿主 run.json(§19)。
1982
+ */
1983
+ async syncWorkspace(runId, projectDir) {
1984
+ if (!this.remote) return;
1985
+ const key = `${runId}:${projectDir}`;
1986
+ const inFlight = this.syncing.get(key);
1987
+ if (inFlight !== void 0) return inFlight;
1988
+ const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pullWorkspace(containerId, projectDir)).finally(() => this.syncing.delete(key));
1989
+ this.syncing.set(key, task);
1990
+ return task;
1991
+ }
1992
+ async findRunContainer(runId) {
1993
+ const matches = await this.engine.listContainers({
1994
+ all: true,
1995
+ filters: {
1996
+ label: [`dsh.pentester.run=${runId}`],
1997
+ status: ["running"]
1998
+ }
1999
+ });
2000
+ return matches.length > 0 ? String(matches[0].Id) : void 0;
2001
+ }
2002
+ async pullWorkspace(containerId, projectDir) {
2003
+ const localDir = localMirrorDir(projectDir);
2004
+ const tarStream = await this.engine.getContainer(containerId).getArchive({ path: "/workspace" });
2005
+ const extract = tar.extract();
2006
+ const done = new Promise((resolve, reject) => {
2007
+ extract.on("error", reject);
2008
+ extract.on("finish", () => resolve());
2009
+ });
2010
+ const seen = /* @__PURE__ */ new Set();
2011
+ extract.on("entry", async (header, entryStream, next) => {
2012
+ try {
2013
+ const relative = normalizeArchivePath(header.name);
2014
+ if (relative !== void 0 && header.type === "file") {
2015
+ seen.add(relative);
2016
+ const target = join(localDir, relative);
2017
+ await mkdir(dirname(target), { recursive: true });
2018
+ const chunks = [];
2019
+ entryStream.on("data", (chunk) => chunks.push(chunk));
2020
+ entryStream.on("end", () => {
2021
+ writeFile(target, Buffer.concat(chunks)).then(next, next);
2022
+ });
2023
+ } else {
2024
+ entryStream.resume();
2025
+ entryStream.on("end", next);
2026
+ }
2027
+ } catch (error) {
2028
+ extract.destroy(error);
2029
+ }
2030
+ });
2031
+ await Promise.all([pipeline(Readable.from(tarStream), extract), done]);
2032
+ await deleteMissing(localDir, seen, /* @__PURE__ */ new Set([".git", ".dsh-pentester"]));
2033
+ }
2034
+ /** 镜像是否已就绪(本地存在)。 */
2035
+ async imageReady(image) {
2036
+ return imageReady(this.engine, image);
2037
+ }
2038
+ async exec(containerId, input) {
2039
+ if (input.argv.length === 0) throw new DockerError("argv must not be empty");
2040
+ const timeoutMs = Math.min(input.timeoutMs ?? 6e5, MAX_EXEC_TIMEOUT_MS);
2041
+ const exec = await this.engine.getContainer(containerId).exec({
2042
+ Cmd: [...input.argv],
2043
+ AttachStdout: true,
2044
+ AttachStderr: true,
2045
+ ...input.cwd === void 0 ? {} : { WorkingDir: input.cwd },
2046
+ Env: Object.entries(safeEnv(input.env)).map(([key, value]) => `${key}=${value}`)
2047
+ });
2048
+ const stdoutChunks = [];
2049
+ const stderrChunks = [];
2050
+ const stream = await exec.start({
2051
+ hijack: true,
2052
+ stdin: false
2053
+ });
2054
+ const demux = demuxChunks(stream, stdoutChunks, stderrChunks);
2055
+ let timedOut = false;
2056
+ const timer = setTimeout(() => {
2057
+ timedOut = true;
2058
+ stream.destroy?.(/* @__PURE__ */ new Error("exec timeout"));
2059
+ }, timeoutMs);
2060
+ let exitCode = 0;
2061
+ try {
2062
+ await demux;
2063
+ exitCode = (await exec.inspect().catch(() => void 0))?.ExitCode ?? 0;
2064
+ } finally {
2065
+ clearTimeout(timer);
2066
+ }
2067
+ return {
2068
+ exitCode: timedOut ? 124 : exitCode,
2069
+ stdout: truncate(Buffer.concat(stdoutChunks).toString("utf8")),
2070
+ stderr: truncate(Buffer.concat(stderrChunks).toString("utf8")),
2071
+ timedOut
2072
+ };
2073
+ }
2074
+ };
2075
+ /** docker cp archive names look like `workspace/stage/D-001/file`; strip the root. */
2076
+ function normalizeArchivePath(name) {
2077
+ const cleaned = name.replace(/\\/g, "/").replace(/^\.\//, "");
2078
+ const segments = posix.normalize(cleaned).split("/").filter((segment) => segment !== "" && segment !== ".");
2079
+ if (segments.length === 0) return void 0;
2080
+ if (segments[0] === "workspace") segments.shift();
2081
+ if (segments.some((segment) => segment === "..")) return void 0;
2082
+ return segments.join("/");
2083
+ }
2084
+ function demuxChunks(stream, stdout, stderr) {
2085
+ return new Promise((resolve, reject) => {
2086
+ let header;
2087
+ stream.on("data", (chunk) => {
2088
+ let buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
2089
+ while (buffer.length > 0) {
2090
+ if (header === void 0) {
2091
+ if (buffer.length < 8) {
2092
+ header = buffer;
2093
+ return;
2094
+ }
2095
+ header = buffer.subarray(0, 8);
2096
+ buffer = buffer.subarray(8);
2097
+ }
2098
+ const type = header[0];
2099
+ const length = header.readUInt32BE(4);
2100
+ const available = Math.min(length, buffer.length);
2101
+ const payload = buffer.subarray(0, available);
2102
+ if (type === 2) stderr.push(payload);
2103
+ else stdout.push(payload);
2104
+ buffer = buffer.subarray(available);
2105
+ if (available < length) {
2106
+ header = void 0;
2107
+ return;
2108
+ }
2109
+ header = void 0;
2110
+ }
2111
+ });
2112
+ stream.on("end", () => resolve());
2113
+ stream.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
2114
+ });
2115
+ }
2116
+ function truncate(text) {
2117
+ return text.length > MAX_OUTPUT_CHARS ? `${text.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]` : text;
2118
+ }
2119
+ /**
2120
+ * Mirror 语义:递归删除 `root` 下不在 `seen` 中的文件(幽灵文件清理)。
2121
+ * `excluded` 顶层目录(如 .git / .dsh-pentester 控制态)整体跳过。
2122
+ */
2123
+ async function deleteMissing(root, seen, excluded) {
2124
+ const walk = async (dir, prefix) => {
2125
+ let entries;
2126
+ try {
2127
+ entries = await readdir(dir);
2128
+ } catch {
2129
+ return;
2130
+ }
2131
+ for (const entry of entries) {
2132
+ const rel = prefix === "" ? entry : `${prefix}/${entry}`;
2133
+ if (prefix === "" && excluded.has(entry)) continue;
2134
+ const abs = join(dir, entry);
2135
+ let info;
2136
+ try {
2137
+ info = await stat(abs);
2138
+ } catch {
2139
+ continue;
2140
+ }
2141
+ if (info.isDirectory()) {
2142
+ await walk(abs, rel);
2143
+ if ((await readdir(abs).catch(() => [])).length === 0 && !excluded.has(entry)) await rm(abs, {
2144
+ recursive: true,
2145
+ force: true
2146
+ }).catch(() => void 0);
2147
+ } else if (!seen.has(rel)) await rm(abs, { force: true }).catch(() => void 0);
2148
+ }
2149
+ };
2150
+ await walk(root, "");
2151
+ }
2152
+ /** 把目录打包成 tar 流(push 方向;可排除顶层子目录如 .git)。 */
2153
+ async function packDirectory(root, options = {}) {
2154
+ const { readdir, readFile, stat } = await import("node:fs/promises");
2155
+ const pack = tar.pack();
2156
+ (async () => {
2157
+ const walk = async (dir, prefix) => {
2158
+ let entries;
2159
+ try {
2160
+ entries = await readdir(dir);
2161
+ } catch {
2162
+ return;
2163
+ }
2164
+ for (const entry of entries) {
2165
+ if (prefix === "" && options.exclude?.(entry) === true) continue;
2166
+ const abs = join(dir, entry);
2167
+ const rel = prefix === "" ? entry : `${posix.join(prefix, entry)}`;
2168
+ let info;
2169
+ try {
2170
+ info = await stat(abs);
2171
+ } catch {
2172
+ continue;
2173
+ }
2174
+ if (info.isDirectory()) await walk(abs, rel);
2175
+ else if (info.isFile()) {
2176
+ const content = await readFile(abs);
2177
+ await new Promise((resolve, reject) => {
2178
+ pack.entry({
2179
+ name: rel,
2180
+ mode: info.mode,
2181
+ size: content.length
2182
+ }, content, (error) => error === void 0 || error === null ? resolve() : reject(error));
2183
+ });
2184
+ }
2185
+ }
2186
+ };
2187
+ try {
2188
+ await walk(root, "");
2189
+ pack.finalize();
2190
+ } catch (error) {
2191
+ pack.destroy(error instanceof Error ? error : new DockerError(String(error)));
2192
+ }
2193
+ })();
2194
+ return pack;
2195
+ }
2196
+ //#endregion
2197
+ //#region src/worker-tools.ts
2198
+ /**
2199
+ * worker-tools.ts — pentester_container_exec:唯一命令执行工具。
2200
+ * Registered inside the pentester preset scope(run-state.mjs 经宿主
2201
+ * pentester service);Root 的 per-agent restrict 隐藏、Worker child 经官方
2202
+ * toolFilter allow 独享。运行期仍做 caller 授权(§33:child SessionId 必须
2203
+ * 已绑定到某个 Delegation)。
2204
+ */
2205
+ const CONTAINER_EXEC_SCHEMA = {
2206
+ type: "object",
2207
+ additionalProperties: false,
2208
+ required: ["argv"],
2209
+ properties: {
2210
+ argv: {
2211
+ type: "array",
2212
+ items: { type: "string" },
2213
+ minItems: 1,
2214
+ description: "Command and arguments, no shell. e.g. [\"nmap\", \"-sV\", \"TARGET\"]."
2215
+ },
2216
+ cwd: {
2217
+ type: "string",
2218
+ description: "Working directory inside the container. Defaults to your delegation work/."
2219
+ },
2220
+ env: {
2221
+ type: "object",
2222
+ additionalProperties: { type: "string" },
2223
+ description: "Optional environment variables."
2224
+ },
2225
+ timeoutMs: {
2226
+ type: "number",
2227
+ description: "Optional timeout in milliseconds (bounded by the host max)."
2228
+ }
2229
+ }
2230
+ };
2231
+ var WorkerToolError = class extends Error {};
2232
+ function makeContainerExecExecutor(docker, toolboxName = "kali") {
2233
+ return async (args, exec) => {
2234
+ const caller = trustedCallerFromExec(exec);
2235
+ if (caller === void 0 || !caller.isSubagent) throw new WorkerToolError("pentester_container_exec is reserved for delegated pentester workers");
2236
+ const projectDir = caller.cwd;
2237
+ if (projectDir === void 0) throw new WorkerToolError("worker session has no trusted workspace cwd");
2238
+ const run = await resolvePentestRun(projectDir);
2239
+ if (run === null) throw new WorkerToolError("no PentestRun in this project; the Root Agent must start one first");
2240
+ const delegation = run.delegations.find((candidate) => candidate.sessionId === caller.sessionId);
2241
+ if (delegation === void 0) throw new WorkerToolError(`session ${caller.sessionId} is not bound to any delegation; only delegated workers may execute commands`);
2242
+ const toolbox = await docker.ensureImage(toolboxName);
2243
+ const containerId = await docker.ensureRunContainer({
2244
+ runId: run.id,
2245
+ toolbox,
2246
+ projectDir
2247
+ });
2248
+ const input = {
2249
+ argv: Array.isArray(args.argv) ? args.argv.map(String) : [],
2250
+ ...typeof args.cwd === "string" ? { cwd: args.cwd } : { cwd: workerContainerWorkDir(projectDir, delegation) },
2251
+ ...isStringRecord(args.env) ? { env: args.env } : {},
2252
+ ...typeof args.timeoutMs === "number" ? { timeoutMs: args.timeoutMs } : {}
2253
+ };
2254
+ const result = await docker.exec(containerId, input);
2255
+ docker.syncWorkspace(run.id, projectDir).catch(() => void 0);
2256
+ return result;
2257
+ };
2258
+ }
2259
+ /** Worker 的容器工作目录:`/workspace/stages/<NN>-<stage>/delegations/D-00N/work`。 */
2260
+ function workerContainerWorkDir(projectDir, delegation) {
2261
+ return `${delegation.workspaceDir.startsWith(projectDir) ? delegation.workspaceDir.slice(projectDir.length).replace(/\\/g, "/") : delegation.workspaceDir}/work`;
2262
+ }
2263
+ /**
2264
+ * 在调用者给定的 tools registry 上注册 pentester_container_exec(run-state.mjs
2265
+ * 把它注册进 pentester preset 的 standing scope;Root 看不到、Worker 独享)。
2266
+ */
2267
+ function registerContainerExecTool(tools, docker) {
2268
+ const execute = makeContainerExecExecutor(docker);
2269
+ tools.register({
2270
+ name: "pentester_container_exec",
2271
+ description: "Execute a command in the project run Toolbox container (/workspace = 共享交付 volume)。pentester 会话专用。",
2272
+ parameters: CONTAINER_EXEC_SCHEMA,
2273
+ output: {
2274
+ schema: {
2275
+ type: "object",
2276
+ additionalProperties: true
2277
+ },
2278
+ render: (_args, value) => [{
2279
+ type: "text",
2280
+ text: JSON.stringify(value)
2281
+ }]
2282
+ },
2283
+ execute
2284
+ });
2285
+ }
2286
+ function isStringRecord(value) {
2287
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
2288
+ return Object.values(value).every((entry) => typeof entry === "string");
2289
+ }
2290
+ //#endregion
2291
+ //#region src/settings-store.ts
2292
+ /**
2293
+ * settings-store.ts — 插件全局设置存储(Docker Engine 等宿主级偏好)。
2294
+ *
2295
+ * 存放位置:`$DSH_HOME/dsh-pentester/settings.json`(DSH_HOME 未设置时用
2296
+ * `~/.dsh`)。原子写(tmp + rename),单对象快照,无事件日志。
2297
+ * 该存储中的 dockerHost 是全局唯一 Docker 连接的显式配置;未设置时
2298
+ * 按本地优先的回退链解析(见 docker.ts 的 resolveDockerHost)。
2299
+ */
2300
+ /** 设置文件当前 schema 版本。 */
2301
+ const SETTINGS_VERSION = 1;
2302
+ var SettingsError = class extends Error {};
2303
+ /** 解析 DSH 根目录(优先 DSH_HOME 环境变量)。 */
2304
+ function dshHome() {
2305
+ return process.env.DSH_HOME ?? join(homedir(), ".dsh");
2306
+ }
2307
+ function settingsFilePath(home = dshHome()) {
2308
+ return join(home, "dsh-pentester", "settings.json");
2309
+ }
2310
+ /** 读取全局设置;文件不存在时返回默认值(不落盘)。 */
2311
+ async function loadSettings(home = dshHome()) {
2312
+ const file = settingsFilePath(home);
2313
+ if (!existsSync(file)) return {
2314
+ schemaVersion: SETTINGS_VERSION,
2315
+ dockerHost: "",
2316
+ toolboxImages: {}
2317
+ };
2318
+ let parsed;
2319
+ try {
2320
+ parsed = JSON.parse(await readFile(file, "utf8"));
2321
+ } catch (error) {
2322
+ throw new SettingsError(`settings.json 解析失败: ${error instanceof Error ? error.message : String(error)}`);
2323
+ }
2324
+ if (parsed === null || typeof parsed !== "object" || parsed.schemaVersion !== SETTINGS_VERSION) throw new SettingsError("settings.json 版本不受支持");
2325
+ const record = parsed;
2326
+ const toolboxImages = {};
2327
+ if (record.toolboxImages !== null && typeof record.toolboxImages === "object" && !Array.isArray(record.toolboxImages)) {
2328
+ for (const [key, value] of Object.entries(record.toolboxImages)) if (typeof value === "string" && value.trim() !== "") toolboxImages[key] = value.trim();
2329
+ }
2330
+ return {
2331
+ schemaVersion: SETTINGS_VERSION,
2332
+ dockerHost: typeof record.dockerHost === "string" ? record.dockerHost : "",
2333
+ toolboxImages
2334
+ };
2335
+ }
2336
+ /** 确保 settings.json 存在:不存在则立即写入默认值(插件加载时调用)。 */
2337
+ async function ensureSettings(home = dshHome()) {
2338
+ const existing = await loadSettings(home);
2339
+ if (!existsSync(settingsFilePath(home))) await saveSettings(existing, home);
2340
+ return existing;
2341
+ }
2342
+ /** 原子保存全局设置。 */
2343
+ async function saveSettings(settings, home = dshHome()) {
2344
+ const file = settingsFilePath(home);
2345
+ await mkdir(dirname(file), { recursive: true });
2346
+ const tmp = `${file}.tmp`;
2347
+ await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`);
2348
+ await rename(tmp, file);
2349
+ }
2350
+ //#endregion
2351
+ //#region node_modules/.pnpm/@deepseek-ai+dsh-typert-protocol@0.1.0-rc.7_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-_d73d8b8dfb7ef1310693b69e6082fcb0/node_modules/@deepseek-ai/dsh-typert-protocol/lib/index.js
2352
+ /**
2353
+ * Remote decorators and explicit Gateway bindings backed only by private
2354
+ * module state. Strict reflection remains a Typert compiler responsibility.
2355
+ * @module @deepseek-ai/dsh-typert-protocol
2356
+ */
2357
+ const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
2358
+ /**
2359
+ * Test one generated Remote name against the Connection endpoint grammar.
2360
+ * @param value - namespace, method, lookup, or Context segment.
2361
+ * @returns whether the value can cross the shared RPC carrier unchanged.
2362
+ */
2363
+ function isTypertRemoteSegment(value) {
2364
+ return value !== "." && value !== ".." && TYPERT_REMOTE_SEGMENT_PATTERN.test(value);
2365
+ }
2366
+ /**
2367
+ * Bind one visible Service field to a Cordis key and Remote namespace.
2368
+ * @param service - owning Service instance, normally `this`.
2369
+ * @param serviceKey - exact Cordis service key.
2370
+ * @param options - optional distinct wire namespace.
2371
+ * @returns a frozen, inspectable binding with no compiler-injected metadata.
2372
+ */
2373
+ function bindTypertRemote(service, serviceKey, options = {}) {
2374
+ validateName("service key", serviceKey);
2375
+ const namespace = options.namespace ?? serviceKey;
2376
+ validateName("namespace", namespace);
2377
+ return Object.freeze({
2378
+ service,
2379
+ serviceKey,
2380
+ namespace
2381
+ });
2382
+ }
2383
+ /** Cordis Service base that exposes its registered name through Typert Gateway. */
2384
+ var TypertRemoteService = class extends Service {
2385
+ /** Visible binding consumed by the Gateway's source-mode discovery. */
2386
+ typertRemote;
2387
+ /**
2388
+ * Register the Service and bind the same key to Typert Gateway.
2389
+ * @param ctx - owning Cordis Context.
2390
+ * @param serviceKey - exact Cordis service key and default wire namespace.
2391
+ * @param options - optional distinct wire namespace.
2392
+ */
2393
+ constructor(ctx, serviceKey, options = {}) {
2394
+ super(ctx, serviceKey);
2395
+ this.typertRemote = bindTypertRemote(this, this.name, options);
2396
+ }
2397
+ };
2398
+ function validateName(subject, value) {
2399
+ if (!isTypertRemoteSegment(value)) throw new TypeError(`typert-protocol: ${subject} must contain only RPC endpoint segment characters`);
2400
+ }
2401
+ //#endregion
2402
+ //#region src/invocations.ts
2403
+ /**
2404
+ * invocations.ts — `remote.pentesterDocker` 的严格调用描述符。
2405
+ *
2406
+ * 宿主(Typert manifest)与客户端(remote 网关贡献)共用同一份:
2407
+ * 官方 api-gateway 契约要求客户端贡献必须携带 strict codec,
2408
+ * “Client Remote refuses to mount SRC descriptors that lack strict
2409
+ * codecs” —— 缺 codec 的描述符会被客户端网关拒绝挂载(此前 UI 报
2410
+ * rpc_unavailable 的根因)。本模块只依赖 zod,两侧均可安全导入。
2411
+ */
2412
+ /** 传输层输入 schema(宽松外壳,分发前再严格校验)。 */
2413
+ const dockerRpcInputSchema = z.object({ kind: z.string() }).passthrough();
2414
+ const dockerHostStateSchema = z.object({
2415
+ dockerHost: z.string(),
2416
+ effective: z.string(),
2417
+ remote: z.boolean(),
2418
+ source: z.enum([
2419
+ "settings",
2420
+ "config",
2421
+ "local",
2422
+ "env",
2423
+ "default"
2424
+ ])
2425
+ });
2426
+ const dockerProbeSchema = z.object({
2427
+ reachable: z.boolean(),
2428
+ endpoint: z.string(),
2429
+ version: z.string().optional(),
2430
+ apiVersion: z.string().optional(),
2431
+ os: z.string().optional(),
2432
+ error: z.string().optional()
2433
+ });
2434
+ /** `remote.pentesterDocker.command` 的严格描述符(含 codec 与 result schema)。 */
2435
+ const PENTESTER_DOCKER_INVOCATION = {
2436
+ id: "dsh-pentester#pentesterDocker/command",
2437
+ service: "pentesterDocker",
2438
+ namespace: "pentesterDocker",
2439
+ method: "command",
2440
+ invocation: { kind: "direct" },
2441
+ parameters: [{
2442
+ name: "input",
2443
+ wire: "input",
2444
+ source: "json",
2445
+ codec: {
2446
+ mode: "strict",
2447
+ typeSymbol: "dsh-pentester#DockerRpcInput",
2448
+ schema: dockerRpcInputSchema
2449
+ }
2450
+ }],
2451
+ result: {
2452
+ mode: "strict",
2453
+ typeSymbol: "dsh-pentester#DockerRpcResult",
2454
+ schema: z.object({
2455
+ ok: z.boolean(),
2456
+ value: z.union([
2457
+ dockerHostStateSchema,
2458
+ dockerProbeSchema,
2459
+ z.record(z.string(), z.unknown())
2460
+ ]).optional(),
2461
+ error: z.string().optional(),
2462
+ code: z.string().optional()
2463
+ })
2464
+ }
2465
+ };
2466
+ //#endregion
2467
+ //#region src/docker/connection.ts
2468
+ /**
2469
+ * docker/connection.ts — 远程 Docker 连接管理(Settings “Docker Host” 卡片的后端)。
2470
+ *
2471
+ * 职责:连接字符串解析(Node URL 类)、高级连接参数(TLS / SSH)→ dockerode
2472
+ * 构造参数、连接测试(GET /version + 超时)。只服务于 Settings 页的“测试连接”,
2473
+ * 不改变当前生效连接(全局 host 解析仍走 host.ts)。
2474
+ */
2475
+ /**
2476
+ * 用 Node 内置 URL 类解析连接字符串(与 DOCKER_HOST 同格式)。
2477
+ * 支持的协议:tcp: / http: / https: / unix:(或裸 socket 路径)。
2478
+ * tcp/http 缺省端口 2375,https 缺省 2376。
2479
+ */
2480
+ function parseConnectionString(value) {
2481
+ const raw = value.trim();
2482
+ if (raw === "") throw new DockerError("empty docker host");
2483
+ if (raw.startsWith("/") && !raw.includes("://")) return {
2484
+ protocol: "unix",
2485
+ socketPath: raw
2486
+ };
2487
+ const schemeMatch = /^(tcp|http|https):\/\//i.exec(raw);
2488
+ if (schemeMatch !== null) {
2489
+ const authority = raw.slice(schemeMatch[0].length).replace(/\/.*$/, "");
2490
+ if (authority === "" || authority.startsWith(":")) throw new DockerError(`docker host missing hostname: ${raw}`);
2491
+ }
2492
+ let url;
2493
+ try {
2494
+ url = new URL(raw);
2495
+ } catch {
2496
+ throw new DockerError(`无法解析的连接字符串:${raw}`);
2497
+ }
2498
+ const scheme = url.protocol.replace(":", "");
2499
+ if (scheme === "unix") {
2500
+ let socketPath = url.pathname;
2501
+ if (socketPath === "" || socketPath === "/") socketPath = url.hostname !== "" ? `/${url.hostname}` : DEFAULT_UNIX_SOCKET;
2502
+ return {
2503
+ protocol: "unix",
2504
+ socketPath
2505
+ };
2506
+ }
2507
+ if (scheme !== "tcp" && scheme !== "http" && scheme !== "https") throw new DockerError(`unsupported docker host: ${raw} (use tcp://host:port, http://host:port or unix:///path)`);
2508
+ if (url.hostname === "") throw new DockerError(`docker host missing hostname: ${raw}`);
2509
+ const port = url.port === "" ? scheme === "https" ? 2376 : 2375 : Number(url.port);
2510
+ if (!Number.isInteger(port)) throw new DockerError(`docker host invalid port: ${raw}`);
2511
+ return {
2512
+ protocol: scheme,
2513
+ host: url.hostname,
2514
+ port
2515
+ };
2516
+ }
2517
+ /** 高级设置面板 → 连接规格。 */
2518
+ function advancedToSpec(advanced) {
2519
+ const host = advanced.host.trim();
2520
+ if (host === "") throw new DockerError("高级设置:缺少主机地址");
2521
+ if (advanced.protocol === "unix") return {
2522
+ protocol: "unix",
2523
+ socketPath: host
2524
+ };
2525
+ return {
2526
+ protocol: advanced.protocol,
2527
+ host,
2528
+ port: advanced.port ?? 2375
2529
+ };
2530
+ }
2531
+ /**
2532
+ * 构建 dockerode 构造参数。
2533
+ * tcp/http 无 TLS → { host, port };TLS → 附 ca/cert/key(modem 自动切 https)
2534
+ * ssh → { protocol:'ssh', username, password | sshOptions.privateKey }
2535
+ * unix → { socketPath }
2536
+ */
2537
+ function buildDockerOptions(spec, auth) {
2538
+ if (spec.protocol === "unix") return { socketPath: spec.socketPath ?? "/var/run/docker.sock" };
2539
+ const host = spec.host ?? "";
2540
+ const port = spec.port ?? 2375;
2541
+ if (auth !== void 0 && auth.type === "ssh") {
2542
+ const options = {
2543
+ protocol: "ssh",
2544
+ host,
2545
+ port
2546
+ };
2547
+ if (auth.username !== void 0 && auth.username !== "") options.username = auth.username;
2548
+ if (auth.privateKey !== void 0 && auth.privateKey !== "") options.sshOptions = { privateKey: auth.privateKey };
2549
+ else if (auth.password !== void 0 && auth.password !== "") options.password = auth.password;
2550
+ return options;
2551
+ }
2552
+ if (auth !== void 0 && auth.type === "tls") {
2553
+ const options = {
2554
+ host,
2555
+ port
2556
+ };
2557
+ if (auth.ca !== void 0) options.ca = auth.ca;
2558
+ if (auth.cert !== void 0) options.cert = auth.cert;
2559
+ if (auth.key !== void 0) options.key = auth.key;
2560
+ return options;
2561
+ }
2562
+ if (spec.protocol === "https") return {
2563
+ protocol: "https",
2564
+ host,
2565
+ port
2566
+ };
2567
+ return {
2568
+ host,
2569
+ port
2570
+ };
2571
+ }
2572
+ /** 探测结果的 endpoint 展示(与 host.ts 的 formatDockerHost 对齐)。 */
2573
+ function endpointFor(spec, auth) {
2574
+ if (spec.protocol === "unix") return `unix://${spec.socketPath ?? "/var/run/docker.sock"}`;
2575
+ if (auth !== void 0 && auth.type === "ssh") return `ssh://${auth.username !== void 0 && auth.username !== "" ? `${auth.username}@` : ""}${spec.host}:${spec.port}`;
2576
+ return formatDockerHost(spec.protocol === "tcp" ? {
2577
+ protocol: "http",
2578
+ host: spec.host,
2579
+ port: spec.port
2580
+ } : {
2581
+ protocol: spec.protocol,
2582
+ host: spec.host,
2583
+ port: spec.port
2584
+ });
2585
+ }
2586
+ /** 请求 → dockerode 构造参数 + 展示 endpoint。 */
2587
+ function resolveConnection(input) {
2588
+ if (input.connectionString !== void 0 && input.connectionString.trim() !== "") {
2589
+ const spec = parseConnectionString(input.connectionString);
2590
+ return {
2591
+ options: buildDockerOptions(spec),
2592
+ endpoint: endpointFor(spec)
2593
+ };
2594
+ }
2595
+ if (input.advanced !== void 0) {
2596
+ const spec = advancedToSpec(input.advanced);
2597
+ const auth = input.advanced.auth;
2598
+ return {
2599
+ options: buildDockerOptions(spec, auth),
2600
+ endpoint: endpointFor(spec, auth)
2601
+ };
2602
+ }
2603
+ throw new DockerError("empty docker host");
2604
+ }
2605
+ /**
2606
+ * 测试一个(未保存的)连接:GET /version,成功返回版本信息,
2607
+ * 失败返回友好错误(含超时)。私钥/密码只进 dockerode 构造,不回传。
2608
+ */
2609
+ async function testDockerConnection(input, deps = {}) {
2610
+ let resolved;
2611
+ try {
2612
+ resolved = resolveConnection(input);
2613
+ } catch (error) {
2614
+ return {
2615
+ reachable: false,
2616
+ endpoint: "",
2617
+ error: error instanceof Error ? error.message : String(error)
2618
+ };
2619
+ }
2620
+ const { options, endpoint } = resolved;
2621
+ const timeoutMs = deps.timeoutMs ?? 1e4;
2622
+ const engine = deps.engine ?? new Dockerode(options);
2623
+ try {
2624
+ const info = await withTimeout(engine.version(), timeoutMs);
2625
+ return {
2626
+ reachable: true,
2627
+ endpoint,
2628
+ ...typeof info.Version === "string" ? { version: info.Version } : {},
2629
+ ...typeof info.ApiVersion === "string" ? { apiVersion: info.ApiVersion } : {},
2630
+ ...typeof info.Os === "string" ? { os: info.Os } : {}
2631
+ };
2632
+ } catch (error) {
2633
+ const message = error instanceof Error ? error.message : String(error);
2634
+ return {
2635
+ reachable: false,
2636
+ endpoint,
2637
+ error: message.startsWith("连接超时") ? message : `连接失败:${message}`
2638
+ };
2639
+ }
2640
+ }
2641
+ function withTimeout(promise, ms) {
2642
+ return new Promise((resolve, reject) => {
2643
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`连接超时(${ms / 1e3}s)`)), ms);
2644
+ promise.then((value) => {
2645
+ clearTimeout(timer);
2646
+ resolve(value);
2647
+ }, (error) => {
2648
+ clearTimeout(timer);
2649
+ reject(error);
2650
+ });
2651
+ });
2652
+ }
2653
+ //#endregion
2654
+ //#region src/rpc.ts
2655
+ /**
2656
+ * rpc.ts — Docker Engine 设置的 Typert RPC(宿主侧)。
2657
+ *
2658
+ * Settings UI 的 "Docker Engine" Tab 通过 `remote.pentesterDocker` 命名空间
2659
+ * 调用 `command(input)`;输入是类型化的 `{ kind: 'get' | 'set', ... }`,
2660
+ * 与旧 Config RPC 同构(单一 command 通道 + 双层信封解包)。
2661
+ * 不含任何自然语言命令;宿主侧校验后委托给 settings-store 与 DockerRuntime。
2662
+ */
2663
+ /** 传输层输入 schema(宽松外壳 + 严格分发校验)。 */
2664
+ const dockerRpcSchema = dockerRpcInputSchema;
2665
+ /** 高级连接参数校验(Settings "Docker Host" 卡片的 advanced 面板)。 */
2666
+ const connectionAuthSchema = z.discriminatedUnion("type", [
2667
+ z.object({ type: z.literal("none") }),
2668
+ z.object({
2669
+ type: z.literal("tls"),
2670
+ ca: z.string().optional(),
2671
+ cert: z.string().optional(),
2672
+ key: z.string().optional()
2673
+ }),
2674
+ z.object({
2675
+ type: z.literal("ssh"),
2676
+ username: z.string().optional(),
2677
+ password: z.string().optional(),
2678
+ privateKey: z.string().optional()
2679
+ })
2680
+ ]);
2681
+ const advancedConnectionSchema = z.object({
2682
+ protocol: z.enum([
2683
+ "tcp",
2684
+ "http",
2685
+ "unix"
2686
+ ]),
2687
+ host: z.string(),
2688
+ port: z.number().int().min(1).max(65535).optional(),
2689
+ auth: connectionAuthSchema.optional()
2690
+ });
2691
+ /** 校验一条输入(严格模式:未知 kind / 非法 host 直接拒绝)。 */
2692
+ function parseDockerRpcInput(input) {
2693
+ let parsed;
2694
+ try {
2695
+ parsed = dockerRpcSchema.parse(input);
2696
+ } catch {
2697
+ return {
2698
+ ok: false,
2699
+ reason: "invalid_request"
2700
+ };
2701
+ }
2702
+ if (parsed.kind === "get") return {
2703
+ ok: true,
2704
+ value: { kind: "get" }
2705
+ };
2706
+ if (parsed.kind === "test") {
2707
+ const raw = parsed.dockerHost;
2708
+ const advancedRaw = parsed.advanced;
2709
+ let advanced;
2710
+ if (advancedRaw !== void 0) {
2711
+ const check = advancedConnectionSchema.safeParse(advancedRaw);
2712
+ if (!check.success) return {
2713
+ ok: false,
2714
+ reason: "invalid_advanced"
2715
+ };
2716
+ advanced = check.data;
2717
+ }
2718
+ if (raw === void 0) return {
2719
+ ok: true,
2720
+ value: {
2721
+ kind: "test",
2722
+ ...advanced !== void 0 ? { advanced } : {}
2723
+ }
2724
+ };
2725
+ if (typeof raw !== "string") return {
2726
+ ok: false,
2727
+ reason: "invalid_docker_host"
2728
+ };
2729
+ if (raw.trim() !== "") try {
2730
+ parseDockerHost(raw);
2731
+ } catch (error) {
2732
+ return {
2733
+ ok: false,
2734
+ reason: error instanceof Error ? error.message : "invalid_docker_host"
2735
+ };
2736
+ }
2737
+ return {
2738
+ ok: true,
2739
+ value: {
2740
+ kind: "test",
2741
+ ...raw.trim() !== "" ? { dockerHost: raw.trim() } : {},
2742
+ ...advanced !== void 0 ? { advanced } : {}
2743
+ }
2744
+ };
2745
+ }
2746
+ if (parsed.kind === "image.status") return {
2747
+ ok: true,
2748
+ value: { kind: "image.status" }
2749
+ };
2750
+ if (parsed.kind === "image.set") {
2751
+ const imageValue = parsed.image;
2752
+ const image = typeof imageValue === "string" ? imageValue.trim() : void 0;
2753
+ if (image === void 0) return {
2754
+ ok: false,
2755
+ reason: "image_required"
2756
+ };
2757
+ return {
2758
+ ok: true,
2759
+ value: {
2760
+ kind: "image.set",
2761
+ image
2762
+ }
2763
+ };
2764
+ }
2765
+ if (parsed.kind === "set") {
2766
+ const hostValue = parsed.dockerHost;
2767
+ const host = typeof hostValue === "string" ? hostValue : void 0;
2768
+ if (host === void 0) return {
2769
+ ok: false,
2770
+ reason: "dockerHost_required"
2771
+ };
2772
+ if (host.trim() !== "") try {
2773
+ parseDockerHost(host);
2774
+ } catch (error) {
2775
+ return {
2776
+ ok: false,
2777
+ reason: error instanceof Error ? error.message : "invalid_docker_host"
2778
+ };
2779
+ }
2780
+ return {
2781
+ ok: true,
2782
+ value: {
2783
+ kind: "set",
2784
+ dockerHost: host.trim()
2785
+ }
2786
+ };
2787
+ }
2788
+ return {
2789
+ ok: false,
2790
+ reason: "unsupported_kind"
2791
+ };
2792
+ }
2793
+ /** 计算某个显式配置下的状态快照。 */
2794
+ function dockerHostState(settings, resolved) {
2795
+ return {
2796
+ dockerHost: settings.dockerHost,
2797
+ effective: formatDockerHost(resolved.host),
2798
+ remote: isRemoteHost(resolved.host),
2799
+ source: resolved.source
2800
+ };
2801
+ }
2802
+ /** 分发一条已校验的输入。 */
2803
+ async function dispatchDockerRpc(deps, input) {
2804
+ const resolve = deps.resolve ?? ((settingsHost, configHost) => resolveDockerHostWithSource(settingsHost, configHost));
2805
+ const probe = deps.probe ?? ((host) => probeDockerHost(host));
2806
+ try {
2807
+ if (input.kind === "get") {
2808
+ const settings = await deps.getSettings();
2809
+ return {
2810
+ ok: true,
2811
+ value: dockerHostState(settings, resolve(settings.dockerHost, deps.getConfigHost()))
2812
+ };
2813
+ }
2814
+ if (input.kind === "test") {
2815
+ const testConnection = deps.testConnection ?? testDockerConnection;
2816
+ if (input.dockerHost !== void 0 && input.dockerHost !== "") return {
2817
+ ok: true,
2818
+ value: await testConnection({ connectionString: input.dockerHost })
2819
+ };
2820
+ if (input.advanced !== void 0) return {
2821
+ ok: true,
2822
+ value: await testConnection({ advanced: input.advanced })
2823
+ };
2824
+ const host = resolve((await deps.getSettings()).dockerHost, deps.getConfigHost()).host;
2825
+ return {
2826
+ ok: true,
2827
+ value: await probe(host)
2828
+ };
2829
+ }
2830
+ if (input.kind === "image.status") {
2831
+ const image = toolboxFor("kali", (await deps.getSettings()).toolboxImages).image;
2832
+ return {
2833
+ ok: true,
2834
+ value: {
2835
+ image,
2836
+ ready: await deps.docker.imageReady(image),
2837
+ defaultImage: TOOLBOX_ALLOWLIST[0].image
2838
+ }
2839
+ };
2840
+ }
2841
+ if (input.kind === "image.set") {
2842
+ const settings = await deps.getSettings();
2843
+ const next = {
2844
+ ...settings,
2845
+ toolboxImages: { ...settings.toolboxImages }
2846
+ };
2847
+ if (input.image === "") delete next.toolboxImages.kali;
2848
+ else next.toolboxImages.kali = input.image;
2849
+ await deps.saveSettings(next);
2850
+ deps.docker.updateToolboxImage("kali", input.image === "" ? void 0 : input.image);
2851
+ const image = toolboxFor("kali", next.toolboxImages).image;
2852
+ return {
2853
+ ok: true,
2854
+ value: {
2855
+ image,
2856
+ ready: await deps.docker.imageReady(image),
2857
+ defaultImage: TOOLBOX_ALLOWLIST[0].image
2858
+ }
2859
+ };
2860
+ }
2861
+ const settings = await deps.saveDockerHost(input.dockerHost);
2862
+ const resolved = resolve(settings.dockerHost, deps.getConfigHost());
2863
+ deps.applyHost(resolved.host);
2864
+ return {
2865
+ ok: true,
2866
+ value: dockerHostState(settings, resolved)
2867
+ };
2868
+ } catch (error) {
2869
+ return {
2870
+ ok: false,
2871
+ error: error instanceof Error ? error.message : "docker_rpc_failed",
2872
+ code: "docker_rpc_failed"
2873
+ };
2874
+ }
2875
+ }
2876
+ /**
2877
+ * 宿主 Typert 服务:绑定到 `pentesterDocker` 命名空间。
2878
+ * Settings 页面是根级贡献,无 Agent 查找 —— 单参数 command。
2879
+ */
2880
+ var PentesterDockerTypertService = class extends TypertRemoteService {
2881
+ deps;
2882
+ constructor(ctx, deps) {
2883
+ super(ctx, "pentesterDocker");
2884
+ this.deps = deps;
2885
+ }
2886
+ command(input) {
2887
+ const parsed = parseDockerRpcInput(input);
2888
+ if (!parsed.ok) return Promise.resolve({
2889
+ ok: false,
2890
+ error: parsed.reason,
2891
+ code: "invalid_request"
2892
+ });
2893
+ return dispatchDockerRpc(this.deps, parsed.value);
2894
+ }
2895
+ };
2896
+ /** 插件的 Typert manifest(仅 Docker Engine 设置面)。 */
2897
+ const TYPERT_MANIFEST = {
2898
+ package: "dsh-pentester",
2899
+ face: "host",
2900
+ schemas: [],
2901
+ model: {
2902
+ services: [{
2903
+ key: "pentesterDocker",
2904
+ exportName: "PentesterDockerTypertService",
2905
+ description: "Pentester Docker Engine 设置(Settings UI Docker Engine Tab)。",
2906
+ tags: [],
2907
+ members: [{
2908
+ kind: "method",
2909
+ name: "command",
2910
+ signature: "command(input: object): Promise<{ok:boolean; value?: {dockerHost:string; effective:string; remote:boolean}; error?: string; code?: string}>"
2911
+ }],
2912
+ types: []
2913
+ }],
2914
+ events: [],
2915
+ objects: []
2916
+ },
2917
+ invocations: [PENTESTER_DOCKER_INVOCATION]
2918
+ };
2919
+ //#endregion
2920
+ //#region src/git.ts
2921
+ /**
2922
+ * git.ts — GitCheckpointService:workspace 的 git 仓库管理(宿主侧)。
2923
+ *
2924
+ * 仓库根 = <project>/workspace(= 容器 /workspace 的宿主镜像;.git 不随
2925
+ * volume 同步,Worker 物理上无法改 Git 状态)。约定式提交:
2926
+ * - 初始:`workspace: initialize <target>`
2927
+ * - 阶段完成:`ptes(<NN>-<stage>): complete` + tag `ptes/<NN>-<stage>`
2928
+ *
2929
+ * git 命令经注入的 runner 执行(生产 = child_process.execFile,测试 = fake)。
2930
+ */
2931
+ var GitCheckpointError = class extends Error {};
2932
+ function realGitRunner() {
2933
+ return (args, cwd) => new Promise((resolve, reject) => {
2934
+ execFile("git", [...args], {
2935
+ cwd,
2936
+ maxBuffer: 10485760
2937
+ }, (error, stdout) => {
2938
+ if (error !== null && error !== void 0) {
2939
+ reject(new GitCheckpointError(`git ${args[0] ?? ""} failed: ${error.message}`));
2940
+ return;
2941
+ }
2942
+ resolve(stdout);
2943
+ });
2944
+ });
2945
+ }
2946
+ /** workspace git 仓库管理(幂等 init;阶段完成 checkpoint)。 */
2947
+ var GitCheckpointService = class {
2948
+ runner;
2949
+ userName;
2950
+ userEmail;
2951
+ isRepoOverride;
2952
+ constructor(deps = {}) {
2953
+ this.runner = deps.runner ?? realGitRunner();
2954
+ this.userName = deps.userName ?? "dsh-pentester";
2955
+ this.userEmail = deps.userEmail ?? "dsh-pentester@local";
2956
+ this.isRepoOverride = deps.isRepo;
2957
+ }
2958
+ /** 仓库根目录(<project>/workspace)。 */
2959
+ repoDir(projectDir) {
2960
+ return workspaceDir(projectDir);
2961
+ }
2962
+ /** 是否已是 git 仓库(存在 .git)。 */
2963
+ async isRepo(projectDir) {
2964
+ if (this.isRepoOverride !== void 0) return this.isRepoOverride(projectDir);
2965
+ return existsSync(join(this.repoDir(projectDir), ".git"));
2966
+ }
2967
+ /**
2968
+ * 初始化仓库:`git init -b main` + 本地身份 + 初始提交。
2969
+ * 幂等:已是仓库则直接返回。
2970
+ */
2971
+ async init(projectDir, target) {
2972
+ const cwd = this.repoDir(projectDir);
2973
+ await mkdir(cwd, { recursive: true });
2974
+ if (await this.isRepo(projectDir)) return;
2975
+ await this.runner([
2976
+ "init",
2977
+ "-b",
2978
+ "main"
2979
+ ], cwd).catch(async () => {
2980
+ await this.runner(["init"], cwd);
2981
+ await this.runner([
2982
+ "checkout",
2983
+ "-b",
2984
+ "main"
2985
+ ], cwd).catch(() => void 0);
2986
+ });
2987
+ await this.runner([
2988
+ "config",
2989
+ "user.name",
2990
+ this.userName
2991
+ ], cwd);
2992
+ await this.runner([
2993
+ "config",
2994
+ "user.email",
2995
+ this.userEmail
2996
+ ], cwd);
2997
+ await this.runner(["add", "-A"], cwd);
2998
+ await this.commit(cwd, `workspace: initialize ${target ?? "pentest"}`);
2999
+ }
3000
+ /** 阶段完成 checkpoint:`git add -A` → commit → tag。返回 tag 名。 */
3001
+ async checkpointStage(projectDir, stage) {
3002
+ if (!await this.isRepo(projectDir)) throw new GitCheckpointError("workspace is not a git repository; run initWorkspace first");
3003
+ const cwd = this.repoDir(projectDir);
3004
+ const dir = stageDir(stage);
3005
+ await this.runner(["add", "-A"], cwd);
3006
+ await this.commit(cwd, `ptes(${dir}): complete`);
3007
+ const tag = `ptes/${dir}`;
3008
+ if (!await this.runner([
3009
+ "tag",
3010
+ "-l",
3011
+ tag
3012
+ ], cwd).then((out) => out.trim().length > 0, () => false)) await this.runner(["tag", tag], cwd);
3013
+ return tag;
3014
+ }
3015
+ /** 当前 git 分支名(rollback / Root state 用)。 */
3016
+ async currentBranch(projectDir) {
3017
+ const cwd = this.repoDir(projectDir);
3018
+ return (await this.runner(["branch", "--show-current"], cwd).catch(() => "")).trim() || "main";
3019
+ }
3020
+ /** working tree 是否有未提交变更(rollback 前决定是否建 backup branch)。 */
3021
+ async hasChanges(projectDir) {
3022
+ const cwd = this.repoDir(projectDir);
3023
+ return (await this.runner(["status", "--porcelain"], cwd).catch(() => "")).trim() !== "";
3024
+ }
3025
+ /**
3026
+ * 在 HEAD 的 first-parent reachable 历史中找目标 stage 的 completion commit
3027
+ * (subject `ptes(<NN>-<stage>): complete`)。不假设 tag 属于当前 branch
3028
+ * namespace(§31);tag 只是人类可读 ref。找不到返回 undefined。
3029
+ */
3030
+ async findStageCheckpoint(projectDir, stage) {
3031
+ const cwd = this.repoDir(projectDir);
3032
+ const dir = stageDir(stage);
3033
+ const out = await this.runner([
3034
+ "log",
3035
+ "--first-parent",
3036
+ "--format=%H%x00%s",
3037
+ "HEAD"
3038
+ ], cwd).catch(() => "");
3039
+ for (const line of out.split("\n")) {
3040
+ if (line.trim() === "") continue;
3041
+ const [hash, ...subjectParts] = line.split("\0");
3042
+ const subject = subjectParts.join("\0");
3043
+ if (hash !== void 0 && subject === `ptes(${dir}): complete`) return hash;
3044
+ }
3045
+ }
3046
+ /** 建 backup branch:`backup/rollback-<ts>` + WIP commit(只在有变更时调用)。 */
3047
+ async createBackupBranch(projectDir, stage) {
3048
+ const cwd = this.repoDir(projectDir);
3049
+ const branch = `backup/rollback-${Date.now()}`;
3050
+ await this.runner([
3051
+ "checkout",
3052
+ "-b",
3053
+ branch
3054
+ ], cwd);
3055
+ await this.runner(["add", "-A"], cwd);
3056
+ await this.commit(cwd, `wip: before rollback to ${stage}`);
3057
+ return branch;
3058
+ }
3059
+ /**
3060
+ * 从 checkpoint commit 建 rework branch:`rework/<next-stage-slug>-<n>`。
3061
+ * n 自动找下一个可用值(已有分支 +1)。
3062
+ */
3063
+ async createReworkBranch(projectDir, checkpoint, nextStageSlug) {
3064
+ const cwd = this.repoDir(projectDir);
3065
+ const existing = await this.runner(["branch", "--format=%(refname:short)"], cwd).catch(() => "");
3066
+ const prefix = `rework/${nextStageSlug}-`;
3067
+ let n = 1;
3068
+ for (const line of existing.split("\n")) {
3069
+ const match = /^rework\/[^/]+-(\d+)$/.exec(line.trim());
3070
+ if (match !== null) n = Math.max(n, Number(match[1]) + 1);
3071
+ }
3072
+ const branch = `${prefix}${n}`;
3073
+ await this.runner([
3074
+ "checkout",
3075
+ "-b",
3076
+ branch,
3077
+ checkpoint
3078
+ ], cwd);
3079
+ return branch;
3080
+ }
3081
+ /** checkout 到已有分支 / commit(rollback 收尾)。 */
3082
+ async checkout(projectDir, ref) {
3083
+ const cwd = this.repoDir(projectDir);
3084
+ await this.runner(["checkout", ref], cwd);
3085
+ }
3086
+ async commit(cwd, message) {
3087
+ await this.runner([
3088
+ "commit",
3089
+ "-m",
3090
+ message
3091
+ ], cwd).catch((error) => {
3092
+ const text = error instanceof Error ? error.message : String(error);
3093
+ if (text.includes("nothing to commit") || text.includes("nothing added")) return;
3094
+ throw error;
3095
+ });
3096
+ }
3097
+ };
3098
+ //#endregion
3099
+ //#region src/index.ts
3100
+ /**
3101
+ * index.ts — cordis plugin entry (contract per docs/architecture.md):
3102
+ * publish the pentester preset, then wire the Root tools over the
3103
+ * DSH agents seam。Workers 的工具面 restrict 到 pentester_container_exec。
3104
+ */
3105
+ const name = "dsh-pentester";
3106
+ const inject = ["tools", "subagents"];
3107
+ const Config = Schema.object({
3108
+ verbose: Schema.boolean().default(false),
3109
+ dockerHost: Schema.string().default(""),
3110
+ toolboxImages: Schema.object({}).default({})
3111
+ });
3112
+ async function apply(ctx, config) {
3113
+ const profiles = loadAgentProfiles(agentsRoot());
3114
+ const presetDir = await publishPentesterPreset();
3115
+ if (presetDir !== void 0) await writeStageProfilesManifest(presetDir, profiles);
3116
+ const settings = await ensureSettings();
3117
+ const explicit = settings.dockerHost !== "" ? settings.dockerHost : config.dockerHost;
3118
+ const docker = new DockerRuntime({
3119
+ host: resolveDockerHost(explicit === "" ? void 0 : explicit),
3120
+ ...config.toolboxImages === void 0 || Object.keys(config.toolboxImages).length === 0 ? {} : { imageOverrides: config.toolboxImages }
3121
+ });
3122
+ const delegations = new DelegationService(new DshClient(ctx), docker);
3123
+ const git = new GitCheckpointService();
3124
+ ctx.provide("pentester", {
3125
+ registerRootTools: (tools) => registerRootTools(tools, {
3126
+ delegations,
3127
+ profiles,
3128
+ git,
3129
+ pushWorkspace: async (projectDir, run) => {
3130
+ await docker.pushWorkspace(run.id, projectDir);
3131
+ },
3132
+ syncWorkspace: async (projectDir, run) => {
3133
+ await docker.syncWorkspace(run.id, projectDir);
3134
+ }
3135
+ }),
3136
+ registerContainerExecTool: (tools) => registerContainerExecTool(tools, docker)
3137
+ });
3138
+ ctx.inject(["typert"], (inner) => {
3139
+ new PentesterDockerTypertService(inner, {
3140
+ getSettings: () => ensureSettings(),
3141
+ getConfigHost: () => config.dockerHost ?? "",
3142
+ saveDockerHost: async (dockerHost) => {
3143
+ const next = {
3144
+ ...await ensureSettings(),
3145
+ dockerHost
3146
+ };
3147
+ await saveSettings(next);
3148
+ return next;
3149
+ },
3150
+ saveSettings: async (next) => {
3151
+ await saveSettings(next);
3152
+ },
3153
+ docker,
3154
+ applyHost: (host) => docker.updateHost(host)
3155
+ });
3156
+ inner.typert.register(TYPERT_MANIFEST);
3157
+ });
3158
+ if (config.verbose) console.log(`[dsh-pentester] loaded with ${profiles.size} agent profiles`);
3159
+ }
3160
+ function agentsRoot() {
3161
+ const here = dirname(fileURLToPath(import.meta.url));
3162
+ for (const candidate of [join(here, "..", "agents"), join(process.cwd(), "agents")]) if (existsSync(candidate)) return candidate;
3163
+ return join(process.cwd(), "agents");
3164
+ }
3165
+ /** run-state.mjs 读取的 stage → AgentProfile 清单文件名(与 preset 同目录发布)。 */
3166
+ const STAGE_PROFILES_FILE = "stage-profiles.json";
3167
+ /**
3168
+ * 生成并发布 stage → [{id, name}] 清单(docs/plan.md §7:Root compact state
3169
+ * 的 Available AgentProfiles 从 STAGE_DEFINITIONS + AgentProfile registry 生成,
3170
+ * 不硬编码第二套映射)。随 preset 目录发布,run-state.mjs 同目录读取。
3171
+ */
3172
+ async function writeStageProfilesManifest(presetDir, profiles) {
3173
+ const manifest = Object.fromEntries(STAGE_DEFINITIONS.map((definition) => [definition.id, definition.agentIds.map((id) => profiles.get(id)).filter((profile) => profile !== void 0).map((profile) => ({
3174
+ id: profile.id,
3175
+ name: profile.name
3176
+ }))]));
3177
+ await writeFile(join(presetDir, STAGE_PROFILES_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3178
+ }
3179
+ /** 把随包发布的 pentester preset 拷贝到 DSH 用户 preset 根目录。 */
3180
+ async function publishPentesterPreset(home) {
3181
+ const source = shippedPresetDir();
3182
+ if (source === void 0) return void 0;
3183
+ const dest = join(home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh"), ".agent-presets", "pentester");
3184
+ await mkdir(dirname(dest), { recursive: true });
3185
+ await cp(source, dest, { recursive: true });
3186
+ await readFile(join(dest, "agent.cordis.yml"), "utf8");
3187
+ return dest;
3188
+ }
3189
+ function shippedPresetDir() {
3190
+ const here = dirname(fileURLToPath(import.meta.url));
3191
+ return [
3192
+ join(here, "..", "presets", "pentester"),
3193
+ join(here, "presets", "pentester"),
3194
+ join(process.cwd(), "presets", "pentester")
3195
+ ].find((path) => existsSync(join(path, "agent.cordis.yml")));
3196
+ }
3197
+ //#endregion
3198
+ export { Config, STAGE_PROFILES_FILE, apply, inject, name, publishPentesterPreset, writeStageProfilesManifest };
3199
+
3200
+ //# sourceMappingURL=index.js.map