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
@@ -0,0 +1,641 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { request } from "node:http";
5
+ //#region src/domain/tools.ts
6
+ const DEFAULT_TOOL_OUTPUT_LIMITS = {
7
+ maxInlineOutputBytes: 8192,
8
+ maxTotalCapturedBytes: 1048576
9
+ };
10
+ const DEFAULT_TOOL_BROKER_LIMITS = {
11
+ maxPerEngagement: 4,
12
+ maxPerAgentRun: 2
13
+ };
14
+ //#endregion
15
+ //#region src/tools/errors.ts
16
+ var ToolBrokerError = class extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.name = "ToolBrokerError";
21
+ this.code = code;
22
+ }
23
+ };
24
+ //#endregion
25
+ //#region src/tools/persist.ts
26
+ function toolsRuntimeDir(engagementDir) {
27
+ return `${engagementDir}/runtime/tools`;
28
+ }
29
+ async function writeJsonAtomic(target, value) {
30
+ await mkdir(dirname(target), { recursive: true });
31
+ const tmp = `${target}.tmp-${randomUUID()}`;
32
+ try {
33
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
34
+ await rename(tmp, target);
35
+ } catch (error) {
36
+ try {
37
+ await unlink(tmp);
38
+ } catch {}
39
+ throw new ToolBrokerError("persist_failed", error instanceof Error ? error.message : `Failed to write ${target}`);
40
+ }
41
+ }
42
+ async function readJsonFile(path) {
43
+ try {
44
+ return JSON.parse(await readFile(path, "utf8"));
45
+ } catch (error) {
46
+ if (error.code === "ENOENT") return null;
47
+ if (error instanceof SyntaxError) return null;
48
+ throw error;
49
+ }
50
+ }
51
+ //#endregion
52
+ //#region src/tools/output-capture.ts
53
+ var BoundedCapture = class {
54
+ truncated = false;
55
+ captureLimitReached = false;
56
+ buffered = 0;
57
+ remaining;
58
+ stdoutParts = [];
59
+ stderrParts = [];
60
+ constructor(maxTotalCapturedBytes) {
61
+ this.remaining = maxTotalCapturedBytes;
62
+ }
63
+ get full() {
64
+ return this.remaining <= 0;
65
+ }
66
+ accept(stream, chunk) {
67
+ if (chunk.length === 0) return;
68
+ if (this.remaining <= 0) {
69
+ this.captureLimitReached = true;
70
+ this.truncated = true;
71
+ return;
72
+ }
73
+ let take = chunk;
74
+ if (chunk.length > this.remaining) {
75
+ take = chunk.subarray(0, this.remaining);
76
+ this.captureLimitReached = true;
77
+ this.truncated = true;
78
+ }
79
+ this.remaining -= take.length;
80
+ this.buffered += take.length;
81
+ if (stream === "stderr") this.stderrParts.push(take);
82
+ else this.stdoutParts.push(take);
83
+ }
84
+ stdout() {
85
+ return Buffer.concat(this.stdoutParts);
86
+ }
87
+ stderr() {
88
+ return Buffer.concat(this.stderrParts);
89
+ }
90
+ };
91
+ var DockerMuxCapture = class {
92
+ inner;
93
+ leftover = Buffer.alloc(0);
94
+ constructor(maxTotalCapturedBytes) {
95
+ this.inner = new BoundedCapture(maxTotalCapturedBytes);
96
+ }
97
+ pushMuxed(chunk) {
98
+ this.leftover = Buffer.concat([this.leftover, chunk]);
99
+ while (this.leftover.length >= 8) {
100
+ const type = this.leftover[0];
101
+ if (type !== 1 && type !== 2 && type !== 0) {
102
+ this.inner.accept("stdout", this.leftover);
103
+ this.leftover = Buffer.alloc(0);
104
+ return;
105
+ }
106
+ const size = this.leftover.readUInt32BE(4);
107
+ if (this.leftover.length < 8 + size) return;
108
+ const payload = this.leftover.subarray(8, 8 + size);
109
+ this.leftover = this.leftover.subarray(8 + size);
110
+ this.inner.accept(type === 2 ? "stderr" : "stdout", payload);
111
+ }
112
+ }
113
+ };
114
+ function isBinaryBuffer(buf) {
115
+ if (buf.includes(0)) return true;
116
+ const sample = buf.subarray(0, Math.min(buf.length, 4096));
117
+ try {
118
+ return new TextDecoder("utf8", { fatal: true }).decode(sample).includes("�");
119
+ } catch {
120
+ return true;
121
+ }
122
+ }
123
+ function applyCaptureLimits(streams, limits) {
124
+ let truncated = false;
125
+ let captureLimitReached = false;
126
+ let remaining = limits.maxTotalCapturedBytes;
127
+ const take = (buf, name) => {
128
+ if (remaining <= 0) {
129
+ captureLimitReached = true;
130
+ truncated = true;
131
+ return Buffer.alloc(0);
132
+ }
133
+ if (buf.length > remaining) {
134
+ captureLimitReached = true;
135
+ truncated = true;
136
+ const slice = buf.subarray(0, remaining);
137
+ remaining = 0;
138
+ return slice;
139
+ }
140
+ remaining -= buf.length;
141
+ return buf;
142
+ };
143
+ const stdout = take(streams.stdout, "stdout");
144
+ const stderr = take(streams.stderr, "stderr");
145
+ const stdoutBinary = isBinaryBuffer(stdout) && stdout.length > 0;
146
+ const stderrBinary = isBinaryBuffer(stderr) && stderr.length > 0;
147
+ const files = {};
148
+ const binaryFiles = {};
149
+ const inline = {};
150
+ const put = (name, buf, binary) => {
151
+ if (buf.length === 0) return;
152
+ if (binary) {
153
+ binaryFiles[`${name}.bin`] = buf;
154
+ return;
155
+ }
156
+ const text = buf.toString("utf8");
157
+ files[`${name}.txt`] = text;
158
+ if (buf.length <= limits.maxInlineOutputBytes && !truncated) inline[name] = text;
159
+ };
160
+ put("stdout", stdout, stdoutBinary);
161
+ put("stderr", stderr, stderrBinary);
162
+ return {
163
+ files,
164
+ binaryFiles,
165
+ inline,
166
+ truncated,
167
+ captureLimitReached,
168
+ binary: stdoutBinary || stderrBinary
169
+ };
170
+ }
171
+ //#endregion
172
+ //#region src/tools/docker-engine.ts
173
+ /**
174
+ * The only extra capabilities a Toolbox container may receive. nmap's file
175
+ * capabilities (`cap_net_bind_service,cap_net_admin,cap_net_raw=eip`) require
176
+ * these to be present in the container's capability set, or exec fails with
177
+ * `Operation not permitted`. This stays a strict whitelist: arbitrary capAdd
178
+ * (e.g. SYS_ADMIN, DAC_OVERRIDE) remains forbidden.
179
+ */
180
+ const ALLOWED_CONTAINER_CAP_ADD = [
181
+ "NET_ADMIN",
182
+ "NET_RAW",
183
+ "NET_BIND_SERVICE"
184
+ ];
185
+ function assertSafeHostConfig(config) {
186
+ if (config.privileged === true) throw new Error("privileged containers are not allowed");
187
+ if (config.networkMode === "host") throw new Error("host network is not allowed");
188
+ if (config.pidMode === "host") throw new Error("host pid namespace is not allowed");
189
+ if (config.ipcMode === "host") throw new Error("host ipc namespace is not allowed");
190
+ for (const cap of config.capAdd ?? []) if (!ALLOWED_CONTAINER_CAP_ADD.includes(cap)) throw new Error(`capability ${cap} is not allowed`);
191
+ for (const bind of config.binds ?? []) {
192
+ if (/docker\.sock/i.test(bind) || bind.includes(":/") && bind.startsWith("/:")) throw new Error("forbidden bind");
193
+ if (bind === "/" || bind.startsWith("/:") || /(^|:)\/:/.test(bind)) throw new Error("forbidden bind");
194
+ }
195
+ }
196
+ const SAFE_CREATE_HOST_CONFIG = {
197
+ privileged: false,
198
+ networkMode: "bridge",
199
+ pidMode: "",
200
+ ipcMode: "",
201
+ capAdd: [...ALLOWED_CONTAINER_CAP_ADD]
202
+ };
203
+ function dockerSocketPath() {
204
+ return process.env.DOCKER_HOST?.startsWith("unix://") ? process.env.DOCKER_HOST.slice(7) : "/var/run/docker.sock";
205
+ }
206
+ var UnixDockerEngine = class {
207
+ kind = "unix";
208
+ firewallObjectsCreated = 0;
209
+ killExecStarts = 0;
210
+ lastKillPid;
211
+ socketPath;
212
+ api;
213
+ inflight = /* @__PURE__ */ new Map();
214
+ constructor(socketPath = dockerSocketPath(), api = "v1.41") {
215
+ this.socketPath = socketPath;
216
+ this.api = api;
217
+ }
218
+ async inspectImage(ref) {
219
+ const encoded = encodeURIComponent(ref);
220
+ const res = await this.json("GET", `/images/${encoded}/json`);
221
+ if (res.status === 404) return;
222
+ if (res.status >= 400) throw new Error(`inspect image failed: ${res.status}`);
223
+ const body = res.body;
224
+ return {
225
+ id: body.Id ?? ref,
226
+ digest: body.RepoDigests?.[0]
227
+ };
228
+ }
229
+ async buildImage(input) {
230
+ const { packDockerContext } = await import("./docker-tar-_wf8UrSj.js");
231
+ const tar = await packDockerContext(input.contextDir, input.dockerfile);
232
+ const res = await this.raw("POST", `/build?t=${encodeURIComponent(input.tag)}&dockerfile=${encodeURIComponent(input.dockerfile)}`, tar, "application/x-tar");
233
+ if (res.status >= 400) throw new Error(`build failed: ${res.status} ${res.body.toString("utf8").slice(0, 200)}`);
234
+ const inspect = await this.inspectImage(input.tag);
235
+ if (inspect === void 0) throw new Error("build completed but image missing");
236
+ return inspect;
237
+ }
238
+ async pullImage(image) {
239
+ const [name, tag] = image.includes(":") ? image.split(/:(?=[^:]+$)/) : [image, "latest"];
240
+ const res = await this.raw("POST", `/images/create?fromImage=${encodeURIComponent(name ?? image)}&tag=${encodeURIComponent(tag ?? "latest")}`);
241
+ if (res.status >= 400) throw new Error(`pull failed: ${res.status}`);
242
+ const inspect = await this.inspectImage(image);
243
+ if (inspect === void 0) throw new Error("pull completed but image missing");
244
+ return inspect;
245
+ }
246
+ async createContainer(input) {
247
+ assertSafeHostConfig(input.hostConfig);
248
+ const res = await this.json("POST", `/containers/create${input.name !== void 0 ? `?name=${encodeURIComponent(input.name)}` : ""}`, {
249
+ Image: input.image,
250
+ Cmd: input.cmd ?? ["sleep", "infinity"],
251
+ Env: input.env ?? [],
252
+ Labels: input.labels ?? {},
253
+ HostConfig: {
254
+ Privileged: false,
255
+ NetworkMode: input.hostConfig.networkMode ?? "bridge",
256
+ PidMode: input.hostConfig.pidMode ?? "",
257
+ IpcMode: input.hostConfig.ipcMode ?? "",
258
+ CapAdd: [...input.hostConfig.capAdd ?? []],
259
+ Binds: input.hostConfig.binds ?? [],
260
+ SecurityOpt: ["no-new-privileges"]
261
+ }
262
+ });
263
+ if (res.status >= 400) throw new Error(`create container failed: ${res.status} ${JSON.stringify(res.body)}`);
264
+ return { containerId: res.body.Id };
265
+ }
266
+ async inspectContainer(containerId) {
267
+ const res = await this.json("GET", `/containers/${encodeURIComponent(containerId)}/json`);
268
+ if (res.status === 404) return;
269
+ if (res.status >= 400) throw new Error(`inspect container failed: ${res.status}`);
270
+ const body = res.body;
271
+ return {
272
+ id: body.Id,
273
+ running: body.State?.Running === true,
274
+ image: body.Config?.Image ?? body.Image ?? ""
275
+ };
276
+ }
277
+ async startContainer(containerId) {
278
+ const res = await this.raw("POST", `/containers/${encodeURIComponent(containerId)}/start`);
279
+ if (res.status >= 400 && res.status !== 304) throw new Error(`start container failed: ${res.status}`);
280
+ }
281
+ async stopContainer(containerId) {
282
+ const res = await this.raw("POST", `/containers/${encodeURIComponent(containerId)}/stop`);
283
+ if (res.status >= 400 && res.status !== 304 && res.status !== 404) throw new Error(`stop container failed: ${res.status}`);
284
+ }
285
+ async removeContainer(containerId) {
286
+ const res = await this.raw("DELETE", `/containers/${encodeURIComponent(containerId)}?force=true`);
287
+ if (res.status >= 400 && res.status !== 404) throw new Error(`remove container failed: ${res.status}`);
288
+ }
289
+ async listContainers(input) {
290
+ const query = new URLSearchParams();
291
+ if (input?.all === true) query.set("all", "1");
292
+ if (input?.label !== void 0) query.set("filters", JSON.stringify({ label: [input.label] }));
293
+ const suffix = query.toString().length > 0 ? `?${query.toString()}` : "";
294
+ const res = await this.json("GET", `/containers/json${suffix}`);
295
+ if (res.status >= 400) throw new Error(`list containers failed: ${res.status}`);
296
+ return res.body.map((row) => ({
297
+ id: row.Id ?? "",
298
+ name: row.Names?.[0]?.replace(/^\//, "") ?? "",
299
+ image: row.Image ?? "",
300
+ running: row.State === "running",
301
+ ...row.Labels === void 0 ? {} : { labels: row.Labels }
302
+ })).filter((item) => item.id.length > 0);
303
+ }
304
+ async exec(input) {
305
+ const created = await this.json("POST", `/containers/${encodeURIComponent(input.containerId)}/exec`, {
306
+ AttachStdout: true,
307
+ AttachStderr: true,
308
+ Cmd: input.argv,
309
+ WorkingDir: input.cwd,
310
+ Env: input.env
311
+ });
312
+ if (created.status >= 400) throw new Error(`exec create failed: ${created.status}`);
313
+ const execId = created.body.Id;
314
+ input.onExecCreated?.(execId);
315
+ const abort = new AbortController();
316
+ this.inflight.set(execId, {
317
+ abort,
318
+ containerId: input.containerId
319
+ });
320
+ if (input.abort !== void 0) input.abort.addEventListener("abort", () => abort.abort());
321
+ const limit = input.maxTotalCapturedBytes ?? DEFAULT_TOOL_OUTPUT_LIMITS.maxTotalCapturedBytes;
322
+ try {
323
+ const started = await this.streamExecStart(execId, abort.signal, limit);
324
+ if (started.status >= 400) throw new Error(`exec start failed: ${started.status}`);
325
+ return {
326
+ execId,
327
+ exitCode: (await this.json("GET", `/exec/${encodeURIComponent(execId)}/json`)).body.ExitCode ?? 0,
328
+ stdout: started.stdout,
329
+ stderr: started.stderr,
330
+ truncated: started.truncated,
331
+ captureLimitReached: started.captureLimitReached
332
+ };
333
+ } finally {
334
+ this.inflight.delete(execId);
335
+ }
336
+ }
337
+ async cancelExec(execId) {
338
+ const current = this.inflight.get(execId);
339
+ const pid = (await this.json("GET", `/exec/${encodeURIComponent(execId)}/json`)).body.Pid;
340
+ if (typeof pid === "number" && pid > 0 && current !== void 0) {
341
+ this.lastKillPid = pid;
342
+ const kill = await this.json("POST", `/containers/${encodeURIComponent(current.containerId)}/exec`, {
343
+ AttachStdout: false,
344
+ AttachStderr: false,
345
+ Cmd: [
346
+ "kill",
347
+ "-TERM",
348
+ String(pid)
349
+ ]
350
+ });
351
+ if (kill.status < 400) {
352
+ const killId = kill.body.Id;
353
+ if (typeof killId === "string" && killId.length > 0) {
354
+ await this.raw("POST", `/exec/${encodeURIComponent(killId)}/start`, Buffer.from(JSON.stringify({
355
+ Detach: true,
356
+ Tty: false
357
+ })), "application/json");
358
+ this.killExecStarts += 1;
359
+ }
360
+ }
361
+ current.abort.abort();
362
+ return "requested";
363
+ }
364
+ current?.abort.abort();
365
+ return current !== void 0 ? "requested" : "unknown";
366
+ }
367
+ streamExecStart(execId, signal, maxTotalCapturedBytes) {
368
+ return new Promise((resolve, reject) => {
369
+ const capture = new DockerMuxCapture(maxTotalCapturedBytes);
370
+ const body = Buffer.from(JSON.stringify({
371
+ Detach: false,
372
+ Tty: false
373
+ }));
374
+ const req = request({
375
+ socketPath: this.socketPath,
376
+ path: `/${this.api}/exec/${encodeURIComponent(execId)}/start`,
377
+ method: "POST",
378
+ headers: {
379
+ Host: "localhost",
380
+ "Content-Type": "application/json",
381
+ "Content-Length": body.length
382
+ }
383
+ }, (res) => {
384
+ res.on("data", (chunk) => {
385
+ capture.pushMuxed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
386
+ });
387
+ res.on("end", () => {
388
+ resolve({
389
+ status: res.statusCode ?? 0,
390
+ stdout: capture.inner.stdout(),
391
+ stderr: capture.inner.stderr(),
392
+ truncated: capture.inner.truncated,
393
+ captureLimitReached: capture.inner.captureLimitReached
394
+ });
395
+ });
396
+ });
397
+ req.on("error", reject);
398
+ signal.addEventListener("abort", () => {
399
+ req.destroy(/* @__PURE__ */ new Error("aborted"));
400
+ });
401
+ req.write(body);
402
+ req.end();
403
+ });
404
+ }
405
+ json(method, path, body) {
406
+ const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
407
+ return this.raw(method, path, payload, "application/json").then((res) => ({
408
+ status: res.status,
409
+ body: res.body.length === 0 ? {} : JSON.parse(res.body.toString("utf8"))
410
+ }));
411
+ }
412
+ raw(method, path, body, contentType, signal) {
413
+ return new Promise((resolve, reject) => {
414
+ const req = request({
415
+ socketPath: this.socketPath,
416
+ path: `/${this.api}${path}`,
417
+ method,
418
+ headers: {
419
+ Host: "localhost",
420
+ ...contentType !== void 0 ? { "Content-Type": contentType } : {},
421
+ ...body !== void 0 ? { "Content-Length": body.length } : {}
422
+ }
423
+ }, (res) => {
424
+ const chunks = [];
425
+ res.on("data", (chunk) => {
426
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
427
+ });
428
+ res.on("end", () => {
429
+ resolve({
430
+ status: res.statusCode ?? 0,
431
+ body: Buffer.concat(chunks)
432
+ });
433
+ });
434
+ });
435
+ req.on("error", reject);
436
+ signal?.addEventListener("abort", () => {
437
+ req.destroy(/* @__PURE__ */ new Error("aborted"));
438
+ });
439
+ if (body !== void 0) req.write(body);
440
+ req.end();
441
+ });
442
+ }
443
+ };
444
+ //#endregion
445
+ //#region src/tools/engagement-container-store.ts
446
+ /**
447
+ * P0-B: ONE persistent execution container per Engagement × runtime-profile.
448
+ *
449
+ * Container ownership changed: `agentRunId`/`taskId` NEVER participate in the
450
+ * container identity. The key is `engagementId + runtimeProfile` (the factory
451
+ * Kali image is the single runtime profile in the MVP), so every Worker /
452
+ * Task / Stage of an Engagement reuses the SAME container. Provenance
453
+ * (taskId / agentRunId / toolCallId) stays in the audit log only.
454
+ *
455
+ * Lifecycle:
456
+ * - lazy create on first execution (ensure)
457
+ * - Task completed / AgentRun completed / Session ended → KEEP
458
+ * - Engagement CLOSED → stop + remove
459
+ * - explicit reset → stop + remove + recreate
460
+ * - plugin restart → recover/reuse (DockerBindingStore read + engine inspect)
461
+ * - orphan/stale managed containers (no live Engagement) → GC removes
462
+ *
463
+ * Concurrency: `ensure` is singleflighted per key — N concurrent first-time
464
+ * callers create exactly ONE container and all receive the same id.
465
+ */
466
+ const PENTESTER_MANAGED_LABEL = "dsh.pentester.managed";
467
+ const PENTESTER_ENGAGEMENT_LABEL = "dsh.pentester.engagement";
468
+ const PENTESTER_RUNTIME_PROFILE_LABEL = "dsh.pentester.runtime-profile";
469
+ const PENTESTER_WORKSPACE_LABEL = "dsh.pentester.workspace";
470
+ const PENTESTER_MANAGED_TRUE = "true";
471
+ /** MVP runtime profile: the single factory Kali image. */
472
+ const RUNTIME_PROFILE_KALI_DEFAULT = "kali-default";
473
+ var EngagementContainerStore = class EngagementContainerStore {
474
+ path;
475
+ bindings;
476
+ /** singleflight: key → in-flight ensure promise. */
477
+ inflight = /* @__PURE__ */ new Map();
478
+ constructor(path, bindings) {
479
+ this.path = path;
480
+ this.bindings = bindings;
481
+ }
482
+ static keyOf(engagementId, profile) {
483
+ return `${engagementId}::${profile}`;
484
+ }
485
+ static async open(engagementDir) {
486
+ const path = `${engagementDir}/runtime/engagement-containers.json`;
487
+ const raw = await readJsonFile(path);
488
+ const bindings = raw !== null && typeof raw === "object" && !Array.isArray(raw) && "bindings" in raw ? raw.bindings ?? {} : {};
489
+ return new EngagementContainerStore(path, bindings);
490
+ }
491
+ list() {
492
+ return Object.values(this.bindings);
493
+ }
494
+ get(key) {
495
+ return this.bindings[key];
496
+ }
497
+ /** Remove a binding record (after the container itself is removed). */
498
+ async remove(key) {
499
+ if (this.bindings[key] === void 0) return;
500
+ const next = { ...this.bindings };
501
+ delete next[key];
502
+ this.bindings = next;
503
+ await writeJsonAtomic(this.path, { bindings: this.bindings });
504
+ }
505
+ async put(binding) {
506
+ const key = EngagementContainerStore.keyOf(binding.engagementId, binding.profile);
507
+ this.bindings = {
508
+ ...this.bindings,
509
+ [key]: binding
510
+ };
511
+ await writeJsonAtomic(this.path, { bindings: this.bindings });
512
+ }
513
+ /**
514
+ * Ensure exactly ONE running container exists for the profile. Concurrency
515
+ * safe: concurrent first-time callers singleflight to a single create.
516
+ * Returns the container id.
517
+ */
518
+ ensure(input) {
519
+ const key = EngagementContainerStore.keyOf(input.profile.engagementId, input.profile.profile);
520
+ const inflight = this.inflight.get(key);
521
+ if (inflight !== void 0) return inflight;
522
+ const run = this.ensureInner(key, input);
523
+ this.inflight.set(key, run);
524
+ run.finally(() => {
525
+ this.inflight.delete(key);
526
+ });
527
+ return run;
528
+ }
529
+ async ensureInner(key, input) {
530
+ const existing = this.bindings[key];
531
+ if (existing !== void 0) {
532
+ const inspect = await input.engine.inspectContainer(existing.containerId);
533
+ if (inspect === void 0) return this.createAndRecord(key, input);
534
+ if (!inspect.running) await input.engine.startContainer(existing.containerId);
535
+ return existing.containerId;
536
+ }
537
+ return this.createAndRecord(key, input);
538
+ }
539
+ async createAndRecord(key, input) {
540
+ const profile = input.profile;
541
+ const binds = [];
542
+ if (profile.workspacePath !== void 0 && profile.workspacePath.length > 0) {
543
+ const { mkdir } = await import("node:fs/promises");
544
+ await mkdir(profile.workspacePath, { recursive: true });
545
+ binds.push(`${profile.workspacePath}:/workspace`);
546
+ if (profile.artifactsPath !== void 0 && profile.artifactsPath.length > 0) {
547
+ await mkdir(profile.artifactsPath, { recursive: true });
548
+ binds.push(`${profile.artifactsPath}:/artifacts`);
549
+ }
550
+ } else if (profile.artifactsPath !== void 0 && profile.artifactsPath.length > 0) {
551
+ const { mkdir } = await import("node:fs/promises");
552
+ await mkdir(profile.artifactsPath, { recursive: true });
553
+ binds.push(`${profile.artifactsPath}:/workspace`);
554
+ }
555
+ const created = await input.engine.createContainer({
556
+ image: profile.image,
557
+ name: engagementContainerName(profile.engagementId, profile.profile, profile.nameSlug),
558
+ cmd: ["sleep", "infinity"],
559
+ env: [],
560
+ labels: {
561
+ [PENTESTER_MANAGED_LABEL]: PENTESTER_MANAGED_TRUE,
562
+ [PENTESTER_ENGAGEMENT_LABEL]: profile.engagementId,
563
+ [PENTESTER_RUNTIME_PROFILE_LABEL]: profile.profile,
564
+ ...profile.workspaceLabel === void 0 ? {} : { [PENTESTER_WORKSPACE_LABEL]: profile.workspaceLabel }
565
+ },
566
+ hostConfig: {
567
+ ...SAFE_CREATE_HOST_CONFIG,
568
+ binds
569
+ }
570
+ });
571
+ await input.engine.startContainer(created.containerId);
572
+ const binding = {
573
+ engagementId: profile.engagementId,
574
+ profile: profile.profile,
575
+ image: profile.image,
576
+ containerId: created.containerId,
577
+ imageId: input.resolvedImage.id,
578
+ ...input.resolvedImage.digest === void 0 ? {} : { imageSource: input.imageSource },
579
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
580
+ };
581
+ const raced = this.bindings[key];
582
+ if (raced !== void 0 && raced.containerId !== created.containerId) {
583
+ if (await input.engine.inspectContainer(raced.containerId) !== void 0) {
584
+ try {
585
+ await input.engine.removeContainer(created.containerId);
586
+ } catch {}
587
+ return raced.containerId;
588
+ }
589
+ }
590
+ await this.put(binding);
591
+ return created.containerId;
592
+ }
593
+ /** Remove + recreate the container for a profile (explicit reset). */
594
+ async reset(key, input) {
595
+ const existing = this.bindings[key];
596
+ if (existing !== void 0) {
597
+ try {
598
+ await input.engine.stopContainer(existing.containerId);
599
+ } catch {}
600
+ try {
601
+ await input.engine.removeContainer(existing.containerId);
602
+ } catch {}
603
+ await this.remove(key);
604
+ }
605
+ return this.ensure(input);
606
+ }
607
+ /**
608
+ * Engagement CLOSED: stop + remove the managed container and forget the
609
+ * binding. Returns the removed container id, if any.
610
+ */
611
+ async closeEngagement(engagementId, engine) {
612
+ const keys = Object.keys(this.bindings).filter((key) => key.startsWith(`${engagementId}::`));
613
+ let removed;
614
+ for (const key of keys) {
615
+ const binding = this.bindings[key];
616
+ if (binding === void 0) continue;
617
+ try {
618
+ await engine.stopContainer(binding.containerId);
619
+ } catch {}
620
+ try {
621
+ await engine.removeContainer(binding.containerId);
622
+ } catch {}
623
+ removed = binding.containerId;
624
+ await this.remove(key);
625
+ }
626
+ return removed;
627
+ }
628
+ };
629
+ /** Deterministic container name: readable slug + profile (never agentRun/taskId,
630
+ * never a UUID). E.g. `pentest-192-168-107-5-kali`. Falls back to a short
631
+ * engagement hash only when no readable slug is available. */
632
+ function engagementContainerName(engagementId, profile, slug) {
633
+ return `pentest-${slug !== void 0 && slug.length > 0 ? safeName(slug) : `eng-${safeName(engagementId).slice(0, 12)}`}-${profile === "kali-default" ? "kali" : safeName(profile)}`;
634
+ }
635
+ function safeName(value) {
636
+ return value.replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 40) || "x";
637
+ }
638
+ //#endregion
639
+ export { PENTESTER_RUNTIME_PROFILE_LABEL as a, applyCaptureLimits as c, writeJsonAtomic as d, ToolBrokerError as f, PENTESTER_MANAGED_TRUE as i, readJsonFile as l, DEFAULT_TOOL_OUTPUT_LIMITS as m, PENTESTER_ENGAGEMENT_LABEL as n, RUNTIME_PROFILE_KALI_DEFAULT as o, DEFAULT_TOOL_BROKER_LIMITS as p, PENTESTER_MANAGED_LABEL as r, UnixDockerEngine as s, EngagementContainerStore as t, toolsRuntimeDir as u };
640
+
641
+ //# sourceMappingURL=engagement-container-store-B9D8g0wq.js.map