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