dsh-pentester 1.0.0 → 2.2.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.
- package/README.md +22 -5
- package/docker/kali/TOOL_PROMPT.md +7 -8
- package/lib/build-id.json +1 -0
- package/lib/client.js +3705 -48
- package/lib/client.js.map +4 -4
- package/lib/index.d.ts +0 -12
- package/lib/index.js +2076 -430
- package/lib/index.js.map +1 -1
- package/package.json +13 -13
- package/presets/pentester/agent.cordis.yml +71 -44
- package/presets/pentester/run-state.mjs +99 -90
package/lib/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { dirname, join, posix } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, join, posix, relative, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
4
5
|
import Schema from "@deepseek-ai/schemastery";
|
|
5
|
-
import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { appendFile, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
|
-
import { createHash
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import { Readable } from "node:stream";
|
|
9
10
|
import { pipeline } from "node:stream/promises";
|
|
10
11
|
import tar from "tar-stream";
|
|
@@ -26,6 +27,7 @@ const SUBAGENT_PROVIDER = "spawn";
|
|
|
26
27
|
* DSH tools.restrict() 对未知名字做严格存在性校验。
|
|
27
28
|
*/
|
|
28
29
|
const WORKER_TOOL_FILTER = { deny: [
|
|
30
|
+
"pentester_start",
|
|
29
31
|
"pentester_delegate",
|
|
30
32
|
"pentester_cancel_delegation",
|
|
31
33
|
"pentester_advance_stage",
|
|
@@ -265,35 +267,213 @@ function isStageId(value) {
|
|
|
265
267
|
return STAGE_IDS.includes(value);
|
|
266
268
|
}
|
|
267
269
|
//#endregion
|
|
268
|
-
//#region src/
|
|
270
|
+
//#region src/targets.ts
|
|
269
271
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
+
* targets.ts — Target Registry: multi-target workspace isolation.
|
|
273
|
+
*
|
|
274
|
+
* Project workspace is shared only as a container for Target workspaces.
|
|
275
|
+
* Every Target has an isolated Root Session, PentestRun, PTES timeline,
|
|
276
|
+
* filesystem tree, Git history and Toolbox runtime.
|
|
277
|
+
*
|
|
278
|
+
* TargetContext is the mandatory resolver for all target-specific code.
|
|
279
|
+
* No target-scoped function accepts a bare projectDir.
|
|
272
280
|
*/
|
|
273
|
-
const RUN_DIR = ".dsh-pentester";
|
|
274
|
-
const RUN_FILE = "run.json";
|
|
275
|
-
/** 工作区交付树:`<project>/workspace` = 容器 `/workspace`(共享 volume 的宿主镜像)。 */
|
|
276
281
|
const WORKSPACE_DIR = "workspace";
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
282
|
+
const RUN_DIR$1 = ".dsh-pentester";
|
|
283
|
+
const RUN_FILE = "run.json";
|
|
284
|
+
const TARGETS_FILE = "targets.json";
|
|
285
|
+
const TARGETS_SUBDIR = "targets";
|
|
286
|
+
const TARGET_REGISTRY_SCHEMA = 1;
|
|
287
|
+
/**
|
|
288
|
+
* Conservative canonicalization: trim, lowercase hostname portion,
|
|
289
|
+
* strip trailing slash/path for URLs. Preserves port and scheme distinctions.
|
|
290
|
+
* Never merges semantically different targets.
|
|
291
|
+
*/
|
|
292
|
+
function canonicalizeTarget(raw) {
|
|
293
|
+
let cleaned = raw.trim();
|
|
294
|
+
if (cleaned === "") return cleaned;
|
|
295
|
+
let scheme = "";
|
|
296
|
+
const schemeMatch = /^(https?:\/\/)/i.exec(cleaned);
|
|
297
|
+
if (schemeMatch !== null) {
|
|
298
|
+
scheme = schemeMatch[1].toLowerCase();
|
|
299
|
+
cleaned = cleaned.slice(schemeMatch[1].length);
|
|
300
|
+
}
|
|
301
|
+
const hostEnd = cleaned.indexOf("/");
|
|
302
|
+
if (hostEnd !== -1) cleaned = cleaned.slice(0, hostEnd);
|
|
303
|
+
cleaned = cleaned.replace(/:$/, "");
|
|
304
|
+
const portMatch = /^(.+):(\d+)$/.exec(cleaned);
|
|
305
|
+
if (portMatch !== null) cleaned = `${portMatch[1].toLowerCase()}:${portMatch[2]}`;
|
|
306
|
+
else cleaned = cleaned.toLowerCase();
|
|
307
|
+
return scheme === "" ? cleaned : `${scheme}${cleaned}`;
|
|
308
|
+
}
|
|
309
|
+
/** 8-char hex digest for stable short id. */
|
|
310
|
+
function makeTargetId(canonicalTarget) {
|
|
311
|
+
return createHash("sha1").update(canonicalTarget).digest("hex").slice(0, 8);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Slug-safe directory name from a target string.
|
|
315
|
+
* Replaces path-unsafe characters with hyphens.
|
|
316
|
+
* Format: <readable-slug>-<short-hash>
|
|
317
|
+
*/
|
|
318
|
+
function makeTargetDirName(target, id) {
|
|
319
|
+
return `${target.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40)}-${id}`;
|
|
280
320
|
}
|
|
281
|
-
function
|
|
321
|
+
function projectWorkspaceDir(projectDir) {
|
|
282
322
|
return join(projectDir, WORKSPACE_DIR);
|
|
283
323
|
}
|
|
324
|
+
function targetRegistryPath(projectDir) {
|
|
325
|
+
return join(projectWorkspaceDir(projectDir), RUN_DIR$1, TARGETS_FILE);
|
|
326
|
+
}
|
|
327
|
+
function targetsRoot(projectDir) {
|
|
328
|
+
return join(projectWorkspaceDir(projectDir), TARGETS_SUBDIR);
|
|
329
|
+
}
|
|
330
|
+
function runFilePath(ctx) {
|
|
331
|
+
return join(ctx.targetRoot, RUN_DIR$1, RUN_FILE);
|
|
332
|
+
}
|
|
333
|
+
function eventsFilePath(ctx) {
|
|
334
|
+
return join(ctx.targetRoot, RUN_DIR$1, "events.jsonl");
|
|
335
|
+
}
|
|
336
|
+
function targetInputDir(ctx) {
|
|
337
|
+
return join(ctx.targetRoot, "target", "inputs");
|
|
338
|
+
}
|
|
339
|
+
function stagesDir(ctx) {
|
|
340
|
+
return join(ctx.targetRoot, "stages");
|
|
341
|
+
}
|
|
342
|
+
function stageDelegationsDir(ctx, stage) {
|
|
343
|
+
return join(stagesDir(ctx), stageDir(stage), "delegations");
|
|
344
|
+
}
|
|
345
|
+
/** Host-side delegation directory: `<targetRoot>/stages/<NN>-<stage>/delegations/D-00N`. */
|
|
346
|
+
function delegationHostDir(ctx, stage, id) {
|
|
347
|
+
return join(stageDelegationsDir(ctx, stage), id);
|
|
348
|
+
}
|
|
349
|
+
/** Container-side delegation directory: `/workspace/stages/<NN>-<stage>/delegations/D-00N`. */
|
|
350
|
+
function delegationContainerDir(stage, id) {
|
|
351
|
+
return `/workspace/stages/${stageDir(stage)}/delegations/${id}`;
|
|
352
|
+
}
|
|
353
|
+
var TargetRegistryError = class extends Error {};
|
|
354
|
+
/**
|
|
355
|
+
* Load the workspace target registry. Returns empty registry if file does not
|
|
356
|
+
* exist (no targets yet). Throws on corruption.
|
|
357
|
+
*/
|
|
358
|
+
async function loadTargetRegistry(projectDir) {
|
|
359
|
+
const file = targetRegistryPath(projectDir);
|
|
360
|
+
if (!existsSync(file)) return {
|
|
361
|
+
schemaVersion: TARGET_REGISTRY_SCHEMA,
|
|
362
|
+
targets: []
|
|
363
|
+
};
|
|
364
|
+
let parsed;
|
|
365
|
+
try {
|
|
366
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
367
|
+
} catch (error) {
|
|
368
|
+
throw new TargetRegistryError(`targets.json is corrupted: ${error instanceof Error ? error.message : String(error)}`);
|
|
369
|
+
}
|
|
370
|
+
if (parsed === null || typeof parsed !== "object") throw new TargetRegistryError("targets.json must be an object");
|
|
371
|
+
const record = parsed;
|
|
372
|
+
if (record.schemaVersion !== TARGET_REGISTRY_SCHEMA) throw new TargetRegistryError(`targets.json: unsupported schemaVersion ${String(record.schemaVersion)}`);
|
|
373
|
+
if (!Array.isArray(record.targets)) throw new TargetRegistryError("targets.json: targets must be an array");
|
|
374
|
+
return record;
|
|
375
|
+
}
|
|
376
|
+
/** Atomic write via tmp+rename. */
|
|
377
|
+
async function saveTargetRegistry(projectDir, registry) {
|
|
378
|
+
const file = targetRegistryPath(projectDir);
|
|
379
|
+
await mkdir(join(projectWorkspaceDir(projectDir), RUN_DIR$1), { recursive: true });
|
|
380
|
+
const tmp = `${file}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
381
|
+
await writeFile(tmp, `${JSON.stringify(registry, null, 2)}\n`);
|
|
382
|
+
await rename(tmp, file);
|
|
383
|
+
}
|
|
384
|
+
const registryQueue = /* @__PURE__ */ new Map();
|
|
385
|
+
/**
|
|
386
|
+
* Serial read-modify-write for targets.json. Keyed by projectDir.
|
|
387
|
+
* Concurrent inserts from different sessions are serialized to prevent
|
|
388
|
+
* lost updates.
|
|
389
|
+
*/
|
|
390
|
+
async function updateTargetRegistry(projectDir, fn) {
|
|
391
|
+
const next = (registryQueue.get(projectDir) ?? Promise.resolve()).then(async () => {
|
|
392
|
+
const registry = await loadTargetRegistry(projectDir);
|
|
393
|
+
await fn(registry);
|
|
394
|
+
await saveTargetRegistry(projectDir, registry);
|
|
395
|
+
});
|
|
396
|
+
registryQueue.set(projectDir, next.catch(() => void 0).then(() => {}));
|
|
397
|
+
return next;
|
|
398
|
+
}
|
|
399
|
+
function listTargetRecords(registry) {
|
|
400
|
+
return registry.targets;
|
|
401
|
+
}
|
|
402
|
+
function resolveTargetByCanonicalTarget(registry, canonicalTarget) {
|
|
403
|
+
return registry.targets.find((t) => t.canonicalTarget === canonicalTarget);
|
|
404
|
+
}
|
|
405
|
+
function resolveTargetByRootSession(registry, sessionId) {
|
|
406
|
+
return registry.targets.find((t) => t.rootSessionId === sessionId);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Find a TargetRecord whose PentestRun contains a Delegation with the given
|
|
410
|
+
* worker sessionId. Returns undefined if no active run matches.
|
|
411
|
+
* This is a heavier query that requires loading each target's run.json.
|
|
412
|
+
*/
|
|
413
|
+
async function resolveTargetByWorkerSession(projectDir, registry, sessionId) {
|
|
414
|
+
for (const record of registry.targets) {
|
|
415
|
+
const runFile = runFilePath(buildTargetContext(projectDir, record));
|
|
416
|
+
if (!existsSync(runFile)) continue;
|
|
417
|
+
try {
|
|
418
|
+
const run = JSON.parse(await readFile(runFile, "utf8"));
|
|
419
|
+
const delegations = run?.delegations;
|
|
420
|
+
if (!Array.isArray(delegations)) continue;
|
|
421
|
+
if (delegations.some((d) => d.sessionId === sessionId)) return {
|
|
422
|
+
record,
|
|
423
|
+
run
|
|
424
|
+
};
|
|
425
|
+
} catch {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function buildTargetContext(projectDir, record) {
|
|
431
|
+
const targetRootPath = join(targetsRoot(projectDir), record.dirName);
|
|
432
|
+
return {
|
|
433
|
+
projectDir,
|
|
434
|
+
workspaceRoot: projectWorkspaceDir(projectDir),
|
|
435
|
+
targetId: record.id,
|
|
436
|
+
target: record.target,
|
|
437
|
+
canonicalTarget: record.canonicalTarget,
|
|
438
|
+
dirName: record.dirName,
|
|
439
|
+
targetRoot: targetRootPath,
|
|
440
|
+
rootSessionId: record.rootSessionId
|
|
441
|
+
};
|
|
442
|
+
}
|
|
284
443
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
444
|
+
* Resolve the TargetContext for the given Root session.
|
|
445
|
+
* Returns undefined if this session is not bound to any target.
|
|
287
446
|
*/
|
|
447
|
+
async function resolveSessionTargetContext(projectDir, sessionId) {
|
|
448
|
+
const record = resolveTargetByRootSession(await loadTargetRegistry(projectDir), sessionId);
|
|
449
|
+
if (record === void 0) return void 0;
|
|
450
|
+
return buildTargetContext(projectDir, record);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Resolve TargetContext for a worker session (via delegation binding).
|
|
454
|
+
* Returns undefined if no delegation matches this session.
|
|
455
|
+
*/
|
|
456
|
+
async function resolveWorkerTargetContext(projectDir, sessionId) {
|
|
457
|
+
const result = await resolveTargetByWorkerSession(projectDir, await loadTargetRegistry(projectDir), sessionId);
|
|
458
|
+
if (result === void 0) return void 0;
|
|
459
|
+
return buildTargetContext(projectDir, result.record);
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/store.ts
|
|
288
463
|
/**
|
|
289
|
-
*
|
|
290
|
-
*
|
|
464
|
+
* store.ts — target-scoped PentestRun persistence (docs/workspace.md).
|
|
465
|
+
* Atomic write via tmp+rename; no event log, no replay.
|
|
466
|
+
*
|
|
467
|
+
* All run I/O requires a TargetContext (from src/targets.ts). Bare projectDir
|
|
468
|
+
* is no longer accepted for run-level operations.
|
|
291
469
|
*/
|
|
292
|
-
|
|
293
|
-
|
|
470
|
+
const RUN_DIR = ".dsh-pentester";
|
|
471
|
+
var StoreError = class extends Error {};
|
|
472
|
+
async function resolvePentestRun(ctx) {
|
|
473
|
+
return tryLoadRun(ctx);
|
|
294
474
|
}
|
|
295
|
-
async function tryLoadRun(
|
|
296
|
-
const file = runFilePath(
|
|
475
|
+
async function tryLoadRun(ctx) {
|
|
476
|
+
const file = runFilePath(ctx);
|
|
297
477
|
if (!existsSync(file)) return null;
|
|
298
478
|
let parsed;
|
|
299
479
|
try {
|
|
@@ -303,14 +483,13 @@ async function tryLoadRun(projectDir) {
|
|
|
303
483
|
}
|
|
304
484
|
return validateRun(parsed, file);
|
|
305
485
|
}
|
|
306
|
-
async function saveRun(
|
|
307
|
-
const file = runFilePath(
|
|
308
|
-
await mkdir(join(
|
|
486
|
+
async function saveRun(ctx, run) {
|
|
487
|
+
const file = runFilePath(ctx);
|
|
488
|
+
await mkdir(join(ctx.targetRoot, RUN_DIR), { recursive: true });
|
|
309
489
|
const tmp = `${file}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
310
490
|
await writeFile(tmp, `${JSON.stringify(run, null, 2)}\n`);
|
|
311
491
|
await rename(tmp, file);
|
|
312
492
|
}
|
|
313
|
-
/** Linear advance only; the host never judges information sufficiency. */
|
|
314
493
|
function advanceStage(run, reason) {
|
|
315
494
|
if (run.currentStage === null || run.status === "completed") throw new StoreError("run is already completed; cannot advance");
|
|
316
495
|
const current = run.currentStage;
|
|
@@ -335,8 +514,6 @@ function advanceStage(run, reason) {
|
|
|
335
514
|
});
|
|
336
515
|
return target;
|
|
337
516
|
}
|
|
338
|
-
/** 当前 stage 状态(缺省:currentStage=active,其余=pending)。 */
|
|
339
|
-
/** 把当前 stage 标为 reviewing(最后一个 delegation 结算时调用)。 */
|
|
340
517
|
function markStageReviewing(run) {
|
|
341
518
|
if (run.currentStage === null) return;
|
|
342
519
|
run.stageStatuses = {
|
|
@@ -344,14 +521,6 @@ function markStageReviewing(run) {
|
|
|
344
521
|
[run.currentStage]: "reviewing"
|
|
345
522
|
};
|
|
346
523
|
}
|
|
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
524
|
function nextDelegationId(run) {
|
|
356
525
|
let max = 0;
|
|
357
526
|
for (const delegation of run.delegations) {
|
|
@@ -366,7 +535,7 @@ function findDelegation(run, id) {
|
|
|
366
535
|
function validateRun(value, file) {
|
|
367
536
|
if (value === null || typeof value !== "object") throw new StoreError(`${file}: run.json must be an object`);
|
|
368
537
|
const record = value;
|
|
369
|
-
if (record.schemaVersion !== 1) throw new StoreError(`${file}: unsupported schemaVersion ${String(record.schemaVersion)}`);
|
|
538
|
+
if (record.schemaVersion !== 2 && record.schemaVersion !== 1) throw new StoreError(`${file}: unsupported schemaVersion ${String(record.schemaVersion)}`);
|
|
370
539
|
if (record.currentStage !== null && (typeof record.currentStage !== "string" || !isStageId(record.currentStage))) throw new StoreError(`${file}: invalid currentStage`);
|
|
371
540
|
if (!Array.isArray(record.delegations)) throw new StoreError(`${file}: delegations must be an array`);
|
|
372
541
|
return record;
|
|
@@ -374,33 +543,34 @@ function validateRun(value, file) {
|
|
|
374
543
|
//#endregion
|
|
375
544
|
//#region src/workspace.ts
|
|
376
545
|
/**
|
|
377
|
-
* workspace.ts —
|
|
546
|
+
* workspace.ts — target-scoped workspace initialization and host-side artifacts.
|
|
378
547
|
*
|
|
379
|
-
*
|
|
548
|
+
* New layout (per docs/workspace.md, updated for multi-target):
|
|
380
549
|
*
|
|
381
|
-
* <project>/workspace/
|
|
382
|
-
* ├── .
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* ├──
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
550
|
+
* <project>/workspace/
|
|
551
|
+
* ├── .dsh-pentester/
|
|
552
|
+
* │ └── targets.json ← Target registry (workspace-level)
|
|
553
|
+
* └── targets/
|
|
554
|
+
* └── <target-id>/
|
|
555
|
+
* ├── .git/
|
|
556
|
+
* ├── .dsh-pentester/
|
|
557
|
+
* │ ├── run.json
|
|
558
|
+
* │ ├── workspace.json
|
|
559
|
+
* │ └── events.jsonl
|
|
560
|
+
* ├── target/
|
|
561
|
+
* ├── assets/
|
|
562
|
+
* ├── findings/
|
|
563
|
+
* ├── stages/
|
|
564
|
+
* ├── report/
|
|
565
|
+
* └── traffic/
|
|
393
566
|
*/
|
|
394
|
-
function workspaceFilePath(
|
|
395
|
-
return join(
|
|
396
|
-
}
|
|
397
|
-
function eventsFilePath(projectDir) {
|
|
398
|
-
return join(workspaceDir(projectDir), RUN_DIR, "events.jsonl");
|
|
567
|
+
function workspaceFilePath(ctx) {
|
|
568
|
+
return join(ctx.targetRoot, RUN_DIR, "workspace.json");
|
|
399
569
|
}
|
|
400
|
-
/**
|
|
401
|
-
async function appendEvent(
|
|
570
|
+
/** Append an event to the target's events.jsonl (best-effort, never blocks). */
|
|
571
|
+
async function appendEvent(ctx, event) {
|
|
402
572
|
try {
|
|
403
|
-
await appendFile(eventsFilePath(
|
|
573
|
+
await appendFile(eventsFilePath(ctx), `${JSON.stringify({
|
|
404
574
|
ts: event.ts,
|
|
405
575
|
type: event.type,
|
|
406
576
|
data: event.data
|
|
@@ -408,14 +578,14 @@ async function appendEvent(projectDir, event) {
|
|
|
408
578
|
} catch {}
|
|
409
579
|
}
|
|
410
580
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
581
|
+
* Initialize a target workspace with the full skeleton.
|
|
582
|
+
* Creates: .dsh-pentester/, target/, assets/, findings/, stages/,
|
|
583
|
+
* report/, traffic/, .gitignore.
|
|
584
|
+
* Idempotent: only creates missing directories.
|
|
415
585
|
*/
|
|
416
|
-
async function
|
|
417
|
-
const root =
|
|
418
|
-
const metaPath = workspaceFilePath(
|
|
586
|
+
async function initTargetWorkspace(ctx, options = {}) {
|
|
587
|
+
const root = ctx.targetRoot;
|
|
588
|
+
const metaPath = workspaceFilePath(ctx);
|
|
419
589
|
await mkdir(join(root, RUN_DIR), { recursive: true });
|
|
420
590
|
let meta;
|
|
421
591
|
if (existsSync(metaPath)) meta = JSON.parse(await readFile(metaPath, "utf8"));
|
|
@@ -429,7 +599,7 @@ async function initWorkspace(projectDir, options = {}) {
|
|
|
429
599
|
gitBranch: "main"
|
|
430
600
|
};
|
|
431
601
|
await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
432
|
-
await appendEvent(
|
|
602
|
+
await appendEvent(ctx, {
|
|
433
603
|
ts: meta.createdAt,
|
|
434
604
|
type: "run.created",
|
|
435
605
|
data: { target: meta.target }
|
|
@@ -454,37 +624,30 @@ async function initWorkspace(projectDir, options = {}) {
|
|
|
454
624
|
};
|
|
455
625
|
await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
456
626
|
}
|
|
457
|
-
await writeTarget(
|
|
627
|
+
await writeTarget(ctx, meta.target);
|
|
458
628
|
if (options.scope !== void 0) await writeFile(join(root, "target", "scope.md"), options.scope.endsWith("\n") ? options.scope : `${options.scope}\n`, "utf8");
|
|
459
629
|
if (options.roe !== void 0) await writeFile(join(root, "target", "roe.md"), options.roe.endsWith("\n") ? options.roe : `${options.roe}\n`, "utf8");
|
|
460
630
|
return meta;
|
|
461
631
|
}
|
|
462
|
-
/**
|
|
463
|
-
async function writeTarget(
|
|
632
|
+
/** Write target/target.json (idempotent). */
|
|
633
|
+
async function writeTarget(ctx, target) {
|
|
464
634
|
if (target === void 0) return;
|
|
465
|
-
const file = join(
|
|
635
|
+
const file = join(ctx.targetRoot, "target", "target.json");
|
|
466
636
|
if ((existsSync(file) ? JSON.parse(await readFile(file, "utf8")) : void 0)?.target === target) return;
|
|
467
637
|
await writeFile(file, `${JSON.stringify({
|
|
468
638
|
target,
|
|
469
639
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
470
640
|
}, null, 2)}\n`, "utf8");
|
|
471
641
|
}
|
|
472
|
-
/**
|
|
473
|
-
|
|
474
|
-
* pentester_advance_stage(summary) 提供,Host 只负责写磁盘、不解析。
|
|
475
|
-
*/
|
|
476
|
-
async function writeStageSummary(projectDir, stage, summary) {
|
|
642
|
+
/** Write stage summary.md. */
|
|
643
|
+
async function writeStageSummary(ctx, stage, summary) {
|
|
477
644
|
const text = summary.trim();
|
|
478
645
|
if (text === "") return;
|
|
479
|
-
await writeFile(join(
|
|
646
|
+
await writeFile(join(ctx.targetRoot, "stages", stageDir(stage), "summary.md"), `# ${stageDir(stage)}\n\n${text}\n`, "utf8");
|
|
480
647
|
}
|
|
481
|
-
/**
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
* (src/pentest/)负责聚合提升,不再走简单 copyFile。
|
|
485
|
-
*/
|
|
486
|
-
async function promoteDeliverables(projectDir, dir) {
|
|
487
|
-
const root = workspaceDir(projectDir);
|
|
648
|
+
/** Promote threat-model.md from delegation dir to canonical findings/. */
|
|
649
|
+
async function promoteDeliverables(ctx, dir) {
|
|
650
|
+
const root = ctx.targetRoot;
|
|
488
651
|
for (const [name, relative] of [["threat-model.md", "findings/threat-model.md"]]) {
|
|
489
652
|
const source = join(dir, name);
|
|
490
653
|
if (!existsSync(source)) continue;
|
|
@@ -493,13 +656,9 @@ async function promoteDeliverables(projectDir, dir) {
|
|
|
493
656
|
await copyFile(source, target);
|
|
494
657
|
}
|
|
495
658
|
}
|
|
496
|
-
/**
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
* 在 bootstrap(首次 advance)时调用。
|
|
500
|
-
*/
|
|
501
|
-
async function writePreEngagementInput(projectDir, options) {
|
|
502
|
-
const inputsDir = join(workspaceDir(projectDir), "target", "inputs");
|
|
659
|
+
/** Write pre-engagement provenance to target/inputs/pre-engagement.md. */
|
|
660
|
+
async function writePreEngagementInput(ctx, options) {
|
|
661
|
+
const inputsDir = targetInputDir(ctx);
|
|
503
662
|
await mkdir(inputsDir, { recursive: true });
|
|
504
663
|
const content = [
|
|
505
664
|
"# Pre-engagement Confirmation",
|
|
@@ -507,7 +666,7 @@ async function writePreEngagementInput(projectDir, options) {
|
|
|
507
666
|
`**Recorded**: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
508
667
|
"",
|
|
509
668
|
"## Target",
|
|
510
|
-
|
|
669
|
+
`- ${options.target}`,
|
|
511
670
|
"",
|
|
512
671
|
"## Scope",
|
|
513
672
|
options.scope,
|
|
@@ -532,7 +691,7 @@ async function writePreEngagementInput(projectDir, options) {
|
|
|
532
691
|
* durable Session that survives across turns and can accept followup messages
|
|
533
692
|
* from Root or user. The plugin only manages PTES domain state.
|
|
534
693
|
*
|
|
535
|
-
*
|
|
694
|
+
* 目录契约:`<targetRoot>/stages/<NN>-<stage>/delegations/D-00N/`
|
|
536
695
|
* input/ work/ artifacts/ evidence/ result.md
|
|
537
696
|
* Worker 只写自己的 delegation 目录;宿主写 input/task_prompt.md 快照。
|
|
538
697
|
* result.md 是当前 Delegation 的"最新正式可交付结果",不是 child Session
|
|
@@ -562,11 +721,6 @@ const PROFILE_CATEGORIES = {
|
|
|
562
721
|
"threat-model": []
|
|
563
722
|
};
|
|
564
723
|
var DelegationError = class extends Error {};
|
|
565
|
-
/**
|
|
566
|
-
* Worker persona(官方 ContinuableStartSpec.request.persona):常驻规则 +
|
|
567
|
-
* AgentProfile 角色。Worker 是长期存在的 continuable child,可能收到多轮
|
|
568
|
-
* 来自 Root 或用户的消息。
|
|
569
|
-
*/
|
|
570
724
|
const WORKER_BASELINE = `You are a long-lived PTES execution worker — a continuable child agent.
|
|
571
725
|
|
|
572
726
|
You may receive multiple rounds of tasks from:
|
|
@@ -604,7 +758,10 @@ Standing rules:
|
|
|
604
758
|
- One round ending does NOT mean your Delegation is permanently over. You may
|
|
605
759
|
receive follow-up messages from Root or a user.
|
|
606
760
|
- Your final response each round must summarize: what you did, what you found,
|
|
607
|
-
which files you wrote, and what you recommend investigating next
|
|
761
|
+
which files you wrote, and what you recommend investigating next.
|
|
762
|
+
- Set the timeout duration for invoking the tool to 1800000 milliseconds.
|
|
763
|
+
For operations such as enumeration, directory scanning, brute force cracking, etc.,
|
|
764
|
+
a longer duration such as 1 hour or 2 hours can be set.`;
|
|
608
765
|
function buildWorkerPersona(profile, catalogText, toolPrompt) {
|
|
609
766
|
const parts = [WORKER_BASELINE];
|
|
610
767
|
if (toolPrompt !== void 0 && toolPrompt.length > 0) parts.push(toolPrompt);
|
|
@@ -615,16 +772,15 @@ function buildWorkerPersona(profile, catalogText, toolPrompt) {
|
|
|
615
772
|
/**
|
|
616
773
|
* Worker 首轮 user message(官方 ContinuableStartSpec.request.prompt):
|
|
617
774
|
* standalone task context。官方 continuable child 不继承 parent transcript,
|
|
618
|
-
*
|
|
619
|
-
* 目录路径 / 完成契约 / task_prompt。
|
|
775
|
+
* 所以必须自包含。
|
|
620
776
|
*/
|
|
621
777
|
function buildWorkerTaskPrompt(options) {
|
|
622
|
-
const { run, delegation,
|
|
623
|
-
const stage = stageDefinition(
|
|
624
|
-
const containerDir =
|
|
778
|
+
const { run, delegation, stageId } = options;
|
|
779
|
+
const stage = stageDefinition(stageId);
|
|
780
|
+
const containerDir = delegationContainerDir(stageId, delegation.id);
|
|
625
781
|
const metadata = [
|
|
626
782
|
`Delegation ID: ${delegation.id}`,
|
|
627
|
-
`Stage: ${
|
|
783
|
+
`Stage: ${stageId}${stage === void 0 ? "" : ` — ${stage.name}`}`,
|
|
628
784
|
`Project workspace root: /workspace (READ-ONLY outside your own directory)`,
|
|
629
785
|
`Your deliverable directory: ${containerDir}`,
|
|
630
786
|
` work/ scratch working files (default container cwd)`,
|
|
@@ -633,7 +789,7 @@ function buildWorkerTaskPrompt(options) {
|
|
|
633
789
|
` result.md current formal deliverable — write/update it as results accumulate`,
|
|
634
790
|
`Write policy: write files ONLY inside ${containerDir}; never modify`,
|
|
635
791
|
` .git/, .dsh-pentester/, target/, other delegation directories or the workspace root.`,
|
|
636
|
-
|
|
792
|
+
`Target: ${run.target || "(not yet recorded)"}`,
|
|
637
793
|
run.rulesOfEngagement === void 0 ? "" : `Rules of Engagement: ${run.rulesOfEngagement}`
|
|
638
794
|
].filter((line) => line !== "");
|
|
639
795
|
return [
|
|
@@ -656,11 +812,6 @@ function buildWorkerTaskPrompt(options) {
|
|
|
656
812
|
` use the report tool to notify the Root Agent.`
|
|
657
813
|
].join("\n");
|
|
658
814
|
}
|
|
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
815
|
function renderToolCatalog(raw, profileId) {
|
|
665
816
|
const data = raw;
|
|
666
817
|
if (data === null || data === void 0) return "";
|
|
@@ -698,25 +849,13 @@ var DelegationService = class {
|
|
|
698
849
|
this.dsh = dsh;
|
|
699
850
|
this.docker = docker;
|
|
700
851
|
}
|
|
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
852
|
async delegate(deps, assignments) {
|
|
715
853
|
if (assignments.length === 0) throw new DelegationError("assignments must not be empty");
|
|
716
854
|
if (deps.run.status === "completed" || deps.run.currentStage === null) throw new DelegationError("PentestRun is completed; cannot delegate new work");
|
|
717
855
|
const stage = stageDefinition(deps.run.currentStage);
|
|
718
856
|
if (stage === void 0) throw new DelegationError(`unknown stage: ${deps.run.currentStage}`);
|
|
719
857
|
if (!this.dsh.subagentAvailable) throw new DelegationError("subagent infrastructure unavailable: ctx.subagents provider \"spawn\" is not registered");
|
|
858
|
+
const ctx = deps.targetContext;
|
|
720
859
|
const created = [];
|
|
721
860
|
for (const assignment of assignments) {
|
|
722
861
|
const profile = deps.profiles.get(assignment.agent);
|
|
@@ -725,8 +864,7 @@ var DelegationService = class {
|
|
|
725
864
|
if (assignment.objective.trim() === "" || assignment.taskPrompt.trim() === "") throw new DelegationError("objective and task_prompt must not be empty");
|
|
726
865
|
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
866
|
const id = nextDelegationId(deps.run);
|
|
728
|
-
|
|
729
|
-
await scaffoldDelegationDir(dir, {
|
|
867
|
+
await scaffoldDelegationDir(delegationHostDir(ctx, deps.run.currentStage, id), {
|
|
730
868
|
id,
|
|
731
869
|
stage: deps.run.currentStage,
|
|
732
870
|
agent: profile.id,
|
|
@@ -739,13 +877,12 @@ var DelegationService = class {
|
|
|
739
877
|
agentId: profile.id,
|
|
740
878
|
objective: assignment.objective,
|
|
741
879
|
taskPrompt: assignment.taskPrompt,
|
|
742
|
-
workspaceDir: dir,
|
|
743
880
|
status: "starting",
|
|
744
881
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
745
882
|
};
|
|
746
883
|
deps.run.delegations.push(delegation);
|
|
747
|
-
await saveRun(
|
|
748
|
-
await appendEvent(
|
|
884
|
+
await saveRun(ctx, deps.run);
|
|
885
|
+
await appendEvent(ctx, {
|
|
749
886
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
750
887
|
type: "delegation.created",
|
|
751
888
|
data: {
|
|
@@ -756,23 +893,20 @@ var DelegationService = class {
|
|
|
756
893
|
});
|
|
757
894
|
created.push(delegation);
|
|
758
895
|
}
|
|
759
|
-
await saveRun(
|
|
760
|
-
await this.docker.pushWorkspace(deps.run.id,
|
|
896
|
+
await saveRun(ctx, deps.run);
|
|
897
|
+
await this.docker.pushWorkspace(deps.run.id, ctx).catch(() => void 0);
|
|
761
898
|
const toolbox = await this.docker.ensureImage("kali");
|
|
762
899
|
const containerId = await this.docker.ensureRunContainer({
|
|
763
900
|
runId: deps.run.id,
|
|
901
|
+
targetId: ctx.targetId,
|
|
902
|
+
target: ctx.canonicalTarget,
|
|
764
903
|
toolbox,
|
|
765
|
-
|
|
904
|
+
targetRoot: ctx.targetRoot
|
|
766
905
|
});
|
|
767
906
|
for (const delegation of created) await this.dispatch(deps, delegation, containerId);
|
|
768
|
-
await saveRun(
|
|
907
|
+
await saveRun(ctx, deps.run);
|
|
769
908
|
return created;
|
|
770
909
|
}
|
|
771
|
-
/**
|
|
772
|
-
* 派发一个 delegation:创建 continuable child。
|
|
773
|
-
* startContinuable() resolve 时 child 已接受 prompt 但尚未开始执行。
|
|
774
|
-
* 之后 child 作为 durable Session 持续存在,可接受 followup。
|
|
775
|
-
*/
|
|
776
910
|
async dispatch(deps, delegation, containerId) {
|
|
777
911
|
const profile = deps.profiles.get(delegation.agentId);
|
|
778
912
|
let catalogText;
|
|
@@ -789,15 +923,15 @@ var DelegationService = class {
|
|
|
789
923
|
prompt: buildWorkerTaskPrompt({
|
|
790
924
|
run: deps.run,
|
|
791
925
|
delegation,
|
|
792
|
-
|
|
926
|
+
stageId: delegation.stageId
|
|
793
927
|
}),
|
|
794
928
|
...profile.model === void 0 ? {} : { model: profile.model }
|
|
795
929
|
};
|
|
796
930
|
const start = await this.dsh.startContinuableWorker(input, deps.parent, deps.signal);
|
|
797
931
|
delegation.sessionId = start.childId;
|
|
798
932
|
delegation.status = "active";
|
|
799
|
-
await saveRun(deps.
|
|
800
|
-
await appendEvent(deps.
|
|
933
|
+
await saveRun(deps.targetContext, deps.run);
|
|
934
|
+
await appendEvent(deps.targetContext, {
|
|
801
935
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
802
936
|
type: "delegation.dispatched",
|
|
803
937
|
data: {
|
|
@@ -807,15 +941,6 @@ var DelegationService = class {
|
|
|
807
941
|
}
|
|
808
942
|
});
|
|
809
943
|
}
|
|
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
944
|
async cancel(deps, delegationId, reason) {
|
|
820
945
|
const delegation = findDelegation(deps.run, delegationId);
|
|
821
946
|
if (delegation === void 0) throw new DelegationError(`unknown delegation: ${delegationId}`);
|
|
@@ -828,8 +953,8 @@ var DelegationService = class {
|
|
|
828
953
|
delegation.status = "closed";
|
|
829
954
|
delegation.resultSummary = reason ?? "closed by Root";
|
|
830
955
|
delegation.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
831
|
-
await saveRun(deps.
|
|
832
|
-
await appendEvent(deps.
|
|
956
|
+
await saveRun(deps.targetContext, deps.run);
|
|
957
|
+
await appendEvent(deps.targetContext, {
|
|
833
958
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
834
959
|
type: "delegation.closed",
|
|
835
960
|
data: {
|
|
@@ -839,29 +964,21 @@ var DelegationService = class {
|
|
|
839
964
|
});
|
|
840
965
|
return delegation;
|
|
841
966
|
}
|
|
842
|
-
/**
|
|
843
|
-
* 当前 stage 全部 delegation 是否都已 settled(没有 active/starting)。
|
|
844
|
-
* 用于 advance 前的机械检查。
|
|
845
|
-
*/
|
|
846
967
|
stageHasPendingWork(run, stageId) {
|
|
847
968
|
return run.delegations.some((d) => d.stageId === stageId && (d.status === "active" || d.status === "starting"));
|
|
848
969
|
}
|
|
849
|
-
/**
|
|
850
|
-
* 当前 stage 全部 delegation 中没有 active/starting → 标记 reviewing。
|
|
851
|
-
*/
|
|
852
970
|
async maybeMarkReviewing(deps) {
|
|
853
971
|
if (deps.run.currentStage === null) return;
|
|
854
972
|
if (this.stageHasPendingWork(deps.run, deps.run.currentStage)) return;
|
|
855
973
|
markStageReviewing(deps.run);
|
|
856
|
-
await saveRun(deps.
|
|
857
|
-
await appendEvent(deps.
|
|
974
|
+
await saveRun(deps.targetContext, deps.run);
|
|
975
|
+
await appendEvent(deps.targetContext, {
|
|
858
976
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
859
977
|
type: "stage.reviewing",
|
|
860
978
|
data: { stage: deps.run.currentStage }
|
|
861
979
|
});
|
|
862
980
|
}
|
|
863
981
|
};
|
|
864
|
-
/** 预建 delegation 目录骨架 + input/task_prompt 快照。 */
|
|
865
982
|
async function scaffoldDelegationDir(dir, info) {
|
|
866
983
|
for (const sub of [
|
|
867
984
|
"input",
|
|
@@ -943,8 +1060,8 @@ function readJsonArray(file) {
|
|
|
943
1060
|
* deduplicate, and write the canonical `assets/assets.json` and (if any
|
|
944
1061
|
* services found) `assets/services.json`.
|
|
945
1062
|
*/
|
|
946
|
-
async function promoteAssets(
|
|
947
|
-
const root =
|
|
1063
|
+
async function promoteAssets(ctx, stage, delegationDirs, delegationIds) {
|
|
1064
|
+
const root = ctx.targetRoot;
|
|
948
1065
|
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
949
1066
|
const assetMap = /* @__PURE__ */ new Map();
|
|
950
1067
|
for (let index = 0; index < delegationDirs.length; index += 1) {
|
|
@@ -1004,8 +1121,8 @@ async function promoteAssets(projectDir, stage, delegationDirs, delegationIds) {
|
|
|
1004
1121
|
* If a delegation produces `artifacts/findings.md`, its content is copied
|
|
1005
1122
|
* into the canonical detail file with a provenance header.
|
|
1006
1123
|
*/
|
|
1007
|
-
async function promoteFindings(
|
|
1008
|
-
const root =
|
|
1124
|
+
async function promoteFindings(ctx, stage, delegationDirs, delegationIds) {
|
|
1125
|
+
const root = ctx.targetRoot;
|
|
1009
1126
|
const findingsDir = join(root, "findings");
|
|
1010
1127
|
await mkdir(findingsDir, { recursive: true });
|
|
1011
1128
|
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1055,8 +1172,268 @@ function loadExistingIndex(findingsDir) {
|
|
|
1055
1172
|
}
|
|
1056
1173
|
}
|
|
1057
1174
|
//#endregion
|
|
1175
|
+
//#region src/pentest/pre-engagement.ts
|
|
1176
|
+
/**
|
|
1177
|
+
* pre-engagement.ts — fixed one-batch Pre-engagement questionnaire.
|
|
1178
|
+
*
|
|
1179
|
+
* The Root Agent calls ask_user_question EXACTLY ONCE with every question
|
|
1180
|
+
* in a single array. The host validates this contract mechanically before
|
|
1181
|
+
* pentester_start executes any side effect.
|
|
1182
|
+
*
|
|
1183
|
+
* Two planes:
|
|
1184
|
+
* 1. Pure domain constants (question IDs, definitions, answer helpers).
|
|
1185
|
+
* 2. Host-side session-event validator (reads persisted ask_user_question
|
|
1186
|
+
* invocations from the Root Session via sessionQuery).
|
|
1187
|
+
*/
|
|
1188
|
+
/** Fixed question IDs in strict order. */
|
|
1189
|
+
const PRE_ENGAGEMENT_QUESTION_IDS = [
|
|
1190
|
+
"target_confirm",
|
|
1191
|
+
"scope_confirm",
|
|
1192
|
+
"exclusions_confirm",
|
|
1193
|
+
"roe_confirm",
|
|
1194
|
+
"language_confirm",
|
|
1195
|
+
"final_confirm"
|
|
1196
|
+
];
|
|
1197
|
+
/**
|
|
1198
|
+
* Parse the ask_user_question result's answers array into an id→answer map.
|
|
1199
|
+
*/
|
|
1200
|
+
function parseAnswers(answers) {
|
|
1201
|
+
const map = /* @__PURE__ */ new Map();
|
|
1202
|
+
if (answers !== void 0) {
|
|
1203
|
+
for (const entry of answers) if (typeof entry.id === "string") map.set(entry.id, entry);
|
|
1204
|
+
}
|
|
1205
|
+
return map;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Check whether a single answer is a "confirmed" (not "rejected") choice.
|
|
1209
|
+
* For questions that use options, we check the selected label.
|
|
1210
|
+
*/
|
|
1211
|
+
function isAnswerAccepted(questionId, answer) {
|
|
1212
|
+
if (answer === void 0) return false;
|
|
1213
|
+
const selected = (answer.selected ?? [])[0];
|
|
1214
|
+
if (selected === void 0) return false;
|
|
1215
|
+
switch (questionId) {
|
|
1216
|
+
case "target_confirm": return selected !== "目标不正确";
|
|
1217
|
+
case "scope_confirm": return selected !== "范围不正确";
|
|
1218
|
+
case "exclusions_confirm": return selected === "无额外排除项";
|
|
1219
|
+
case "roe_confirm": return selected === "标准 RoE" || selected === "仅非侵入式测试";
|
|
1220
|
+
case "language_confirm": return selected === "中文" || selected === "English";
|
|
1221
|
+
case "final_confirm": return selected === "确认并开始";
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* True when ALL six questions received confirmed answers.
|
|
1226
|
+
*/
|
|
1227
|
+
function isPreEngagementConfirmed(answersById) {
|
|
1228
|
+
return PRE_ENGAGEMENT_QUESTION_IDS.every((id) => isAnswerAccepted(id, answersById.get(id)));
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Validate that the current Root Session contains exactly one completed
|
|
1232
|
+
* ask_user_question invocation with the six canonical Pre-engagement questions
|
|
1233
|
+
* in the canonical order, all confirmed, matching the start arguments.
|
|
1234
|
+
*
|
|
1235
|
+
* Reads from the session event log via sessionQuery — never trusts model
|
|
1236
|
+
* arguments or browser state.
|
|
1237
|
+
*/
|
|
1238
|
+
async function validatePreEngagementBatch(sessionQuery, sessionId, startArgs) {
|
|
1239
|
+
const events = (await sessionQuery.readSession(sessionId)).events;
|
|
1240
|
+
let bestCall;
|
|
1241
|
+
let bestResult;
|
|
1242
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1243
|
+
const event = events[i];
|
|
1244
|
+
if (event.type !== "tool/call") continue;
|
|
1245
|
+
const data = event.data;
|
|
1246
|
+
if (data === void 0 || data.name !== "ask_user_question") continue;
|
|
1247
|
+
if (isExistingTargetActionCall(data)) continue;
|
|
1248
|
+
const result = findMatchingResult(events, data.callId);
|
|
1249
|
+
if (result === void 0) continue;
|
|
1250
|
+
bestCall = data;
|
|
1251
|
+
bestResult = result;
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
if (bestCall === void 0 || bestResult === void 0) return {
|
|
1255
|
+
ok: false,
|
|
1256
|
+
code: "pre_engagement_missing",
|
|
1257
|
+
message: "No completed ask_user_question invocation found in this session. Ask all six Pre-engagement questions together in one ask_user_question call: " + PRE_ENGAGEMENT_QUESTION_IDS.join(", ") + "."
|
|
1258
|
+
};
|
|
1259
|
+
let questions;
|
|
1260
|
+
try {
|
|
1261
|
+
const parsed = JSON.parse(bestCall.arguments);
|
|
1262
|
+
questions = Array.isArray(parsed.questions) ? parsed.questions : [];
|
|
1263
|
+
} catch {
|
|
1264
|
+
return {
|
|
1265
|
+
ok: false,
|
|
1266
|
+
code: "pre_engagement_missing",
|
|
1267
|
+
message: "The ask_user_question invocation has malformed arguments."
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
const questionIds = questions.map((q) => typeof q?.id === "string" ? q.id : "");
|
|
1271
|
+
if (questionIds.length !== PRE_ENGAGEMENT_QUESTION_IDS.length) return {
|
|
1272
|
+
ok: false,
|
|
1273
|
+
code: "pre_engagement_wrong_question_count",
|
|
1274
|
+
message: `Pre-engagement requires exactly ${PRE_ENGAGEMENT_QUESTION_IDS.length} questions, got ${questionIds.length}. Re-ask all six questions together in one batch.`
|
|
1275
|
+
};
|
|
1276
|
+
for (let i = 0; i < PRE_ENGAGEMENT_QUESTION_IDS.length; i++) if (questionIds[i] !== PRE_ENGAGEMENT_QUESTION_IDS[i]) return {
|
|
1277
|
+
ok: false,
|
|
1278
|
+
code: "pre_engagement_wrong_question_order",
|
|
1279
|
+
message: `Pre-engagement question at position ${i + 1} must be "${PRE_ENGAGEMENT_QUESTION_IDS[i]}", got "${questionIds[i]}". The six questions must be in this exact order: ` + PRE_ENGAGEMENT_QUESTION_IDS.join(", ") + "."
|
|
1280
|
+
};
|
|
1281
|
+
if (new Set(questionIds).size !== questionIds.length) return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
code: "pre_engagement_wrong_question_order",
|
|
1284
|
+
message: "Pre-engagement question IDs must be unique. Re-ask all six questions together."
|
|
1285
|
+
};
|
|
1286
|
+
if (bestResult.error !== void 0) return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
code: "pre_engagement_incomplete",
|
|
1289
|
+
message: `The Pre-engagement questionnaire was not answered (error: ${bestResult.error.code}). Re-ask all six questions together.`
|
|
1290
|
+
};
|
|
1291
|
+
let answers;
|
|
1292
|
+
try {
|
|
1293
|
+
const rawContent = bestResult.message.content;
|
|
1294
|
+
const toolResultBlock = rawContent.find((b) => b.type === "tool-result");
|
|
1295
|
+
if (toolResultBlock !== void 0) {
|
|
1296
|
+
if (toolResultBlock.isError === true) return {
|
|
1297
|
+
ok: false,
|
|
1298
|
+
code: "pre_engagement_incomplete",
|
|
1299
|
+
message: "The Pre-engagement questionnaire result returned an error. Re-ask all six questions together."
|
|
1300
|
+
};
|
|
1301
|
+
const innerText = (toolResultBlock.content ?? []).find((b) => b.type === "text");
|
|
1302
|
+
if (innerText === void 0 || innerText.text === void 0) return {
|
|
1303
|
+
ok: false,
|
|
1304
|
+
code: "pre_engagement_incomplete",
|
|
1305
|
+
message: "The Pre-engagement questionnaire result is empty (tool-result block has no text content). Re-ask all six questions together."
|
|
1306
|
+
};
|
|
1307
|
+
const parsed = JSON.parse(innerText.text);
|
|
1308
|
+
answers = Array.isArray(parsed.answers) ? parsed.answers : [];
|
|
1309
|
+
} else {
|
|
1310
|
+
const textBlock = rawContent.find((b) => b.type === "text");
|
|
1311
|
+
if (textBlock !== void 0 && textBlock.text !== void 0) {
|
|
1312
|
+
const parsed = JSON.parse(textBlock.text);
|
|
1313
|
+
answers = Array.isArray(parsed.answers) ? parsed.answers : [];
|
|
1314
|
+
} else return {
|
|
1315
|
+
ok: false,
|
|
1316
|
+
code: "pre_engagement_incomplete",
|
|
1317
|
+
message: `The Pre-engagement questionnaire result is in an unrecognized format. Found content types: ${JSON.stringify(rawContent.map((b) => b.type))}. Re-ask all six questions together.`
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
} catch {
|
|
1321
|
+
return {
|
|
1322
|
+
ok: false,
|
|
1323
|
+
code: "pre_engagement_incomplete",
|
|
1324
|
+
message: "The Pre-engagement questionnaire result is malformed. Re-ask all six questions together."
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
const answersById = parseAnswers(answers);
|
|
1328
|
+
const unanswered = PRE_ENGAGEMENT_QUESTION_IDS.filter((id) => !answersById.has(id));
|
|
1329
|
+
if (unanswered.length > 0) return {
|
|
1330
|
+
ok: false,
|
|
1331
|
+
code: "pre_engagement_incomplete",
|
|
1332
|
+
message: `Pre-engagement questions not answered: ${unanswered.join(", ")}. Re-ask all six questions together.`
|
|
1333
|
+
};
|
|
1334
|
+
if (!isPreEngagementConfirmed(answersById)) return {
|
|
1335
|
+
ok: false,
|
|
1336
|
+
code: "pre_engagement_not_confirmed",
|
|
1337
|
+
message: "Not all Pre-engagement questions were confirmed. The user must confirm all six questions before starting."
|
|
1338
|
+
};
|
|
1339
|
+
if (!isAnswerAccepted("final_confirm", answersById.get("final_confirm"))) return {
|
|
1340
|
+
ok: false,
|
|
1341
|
+
code: "pre_engagement_not_confirmed",
|
|
1342
|
+
message: "The final confirmation was not accepted. The user must select \"确认并开始\" to proceed."
|
|
1343
|
+
};
|
|
1344
|
+
const targetAnswer = answersById.get("target_confirm");
|
|
1345
|
+
if (targetAnswer !== void 0) {
|
|
1346
|
+
const selected = (targetAnswer.selected ?? [])[0];
|
|
1347
|
+
if (selected !== void 0 && selected !== "目标不正确") {}
|
|
1348
|
+
}
|
|
1349
|
+
const proofAnswers = Object.fromEntries(PRE_ENGAGEMENT_QUESTION_IDS.map((id) => [id, answersById.get(id)]));
|
|
1350
|
+
return {
|
|
1351
|
+
ok: true,
|
|
1352
|
+
invocationCallId: bestCall.callId,
|
|
1353
|
+
answers: proofAnswers
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Require a valid pre-engagement proof. Throws with a descriptive error
|
|
1358
|
+
* message on failure.
|
|
1359
|
+
*/
|
|
1360
|
+
async function requirePreEngagementProof(sessionQuery, sessionId, startArgs) {
|
|
1361
|
+
const result = await validatePreEngagementBatch(sessionQuery, sessionId, startArgs);
|
|
1362
|
+
if (!result.ok) throw new Error(`[${result.code}] ${result.message}`);
|
|
1363
|
+
return result;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Check whether an ask_user_question call is the existing_target_action
|
|
1367
|
+
* selector ("Continue/Restart") rather than the Pre-engagement batch.
|
|
1368
|
+
*/
|
|
1369
|
+
function isExistingTargetActionCall(data) {
|
|
1370
|
+
try {
|
|
1371
|
+
const parsed = JSON.parse(data.arguments);
|
|
1372
|
+
const questions = Array.isArray(parsed.questions) ? parsed.questions : [];
|
|
1373
|
+
return questions.length === 1 && typeof questions[0]?.id === "string" && questions[0].id === "existing_target_action";
|
|
1374
|
+
} catch {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Find the tool/result event matching a given tool/call by callId.
|
|
1380
|
+
* Checks both `data.message.source.callId` (standard) and `data.callId` (fallback).
|
|
1381
|
+
*/
|
|
1382
|
+
function findMatchingResult(events, callId) {
|
|
1383
|
+
for (const event of events) {
|
|
1384
|
+
if (event.type !== "tool/result") continue;
|
|
1385
|
+
const data = event.data;
|
|
1386
|
+
if (data === void 0) continue;
|
|
1387
|
+
if (data.message?.source?.callId === callId) return data;
|
|
1388
|
+
if (data.callId === callId) return data;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
//#endregion
|
|
1058
1392
|
//#region src/tools.ts
|
|
1059
1393
|
const ROOT_TOOL_SCHEMAS = {
|
|
1394
|
+
pentester_start: {
|
|
1395
|
+
type: "object",
|
|
1396
|
+
additionalProperties: false,
|
|
1397
|
+
required: [
|
|
1398
|
+
"target",
|
|
1399
|
+
"scope",
|
|
1400
|
+
"rules_of_engagement",
|
|
1401
|
+
"language",
|
|
1402
|
+
"summary",
|
|
1403
|
+
"mode"
|
|
1404
|
+
],
|
|
1405
|
+
properties: {
|
|
1406
|
+
target: {
|
|
1407
|
+
type: "string",
|
|
1408
|
+
description: "Primary target (hostname, IP, URL)."
|
|
1409
|
+
},
|
|
1410
|
+
scope: {
|
|
1411
|
+
type: "string",
|
|
1412
|
+
description: "Authorized scope description."
|
|
1413
|
+
},
|
|
1414
|
+
rules_of_engagement: {
|
|
1415
|
+
type: "string",
|
|
1416
|
+
description: "Rules of Engagement text."
|
|
1417
|
+
},
|
|
1418
|
+
language: {
|
|
1419
|
+
type: "string",
|
|
1420
|
+
description: "Output language (e.g. zh-CN, en)."
|
|
1421
|
+
},
|
|
1422
|
+
summary: {
|
|
1423
|
+
type: "string",
|
|
1424
|
+
description: "Pre-engagement confirmation summary."
|
|
1425
|
+
},
|
|
1426
|
+
mode: {
|
|
1427
|
+
type: "string",
|
|
1428
|
+
enum: ["new", "restart"],
|
|
1429
|
+
description: "new for fresh target, restart for existing target."
|
|
1430
|
+
},
|
|
1431
|
+
expected_target_id: {
|
|
1432
|
+
type: "string",
|
|
1433
|
+
description: "For restart: expected target id to prevent race."
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
},
|
|
1060
1437
|
pentester_delegate: {
|
|
1061
1438
|
type: "object",
|
|
1062
1439
|
additionalProperties: false,
|
|
@@ -1076,7 +1453,7 @@ const ROOT_TOOL_SCHEMAS = {
|
|
|
1076
1453
|
properties: {
|
|
1077
1454
|
agent: {
|
|
1078
1455
|
type: "string",
|
|
1079
|
-
description: "AgentProfile id attached to the current Stage
|
|
1456
|
+
description: "AgentProfile id attached to the current Stage."
|
|
1080
1457
|
},
|
|
1081
1458
|
objective: {
|
|
1082
1459
|
type: "string",
|
|
@@ -1084,7 +1461,7 @@ const ROOT_TOOL_SCHEMAS = {
|
|
|
1084
1461
|
},
|
|
1085
1462
|
task_prompt: {
|
|
1086
1463
|
type: "string",
|
|
1087
|
-
description: "Full task context for the worker
|
|
1464
|
+
description: "Full task context for the worker."
|
|
1088
1465
|
}
|
|
1089
1466
|
}
|
|
1090
1467
|
}
|
|
@@ -1103,28 +1480,10 @@ const ROOT_TOOL_SCHEMAS = {
|
|
|
1103
1480
|
type: "object",
|
|
1104
1481
|
additionalProperties: false,
|
|
1105
1482
|
required: ["summary"],
|
|
1106
|
-
properties: {
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
}
|
|
1483
|
+
properties: { summary: {
|
|
1484
|
+
type: "string",
|
|
1485
|
+
description: "Stage handoff summary."
|
|
1486
|
+
} }
|
|
1128
1487
|
},
|
|
1129
1488
|
pentester_rollback_stage: {
|
|
1130
1489
|
type: "object",
|
|
@@ -1133,7 +1492,7 @@ const ROOT_TOOL_SCHEMAS = {
|
|
|
1133
1492
|
properties: {
|
|
1134
1493
|
stage: {
|
|
1135
1494
|
type: "string",
|
|
1136
|
-
description: "Domain StageId to roll back to
|
|
1495
|
+
description: "Domain StageId to roll back to."
|
|
1137
1496
|
},
|
|
1138
1497
|
reason: {
|
|
1139
1498
|
type: "string",
|
|
@@ -1171,6 +1530,13 @@ function registerRootTools(tools, deps) {
|
|
|
1171
1530
|
text: JSON.stringify(value)
|
|
1172
1531
|
}]
|
|
1173
1532
|
};
|
|
1533
|
+
tools.register({
|
|
1534
|
+
name: "pentester_start",
|
|
1535
|
+
description: ROOT_TOOL_DESCRIPTIONS.pentester_start,
|
|
1536
|
+
parameters: ROOT_TOOL_SCHEMAS.pentester_start,
|
|
1537
|
+
output,
|
|
1538
|
+
execute: (args, exec) => startRun(deps, args, exec)
|
|
1539
|
+
});
|
|
1174
1540
|
tools.register({
|
|
1175
1541
|
name: "pentester_delegate",
|
|
1176
1542
|
description: ROOT_TOOL_DESCRIPTIONS.pentester_delegate,
|
|
@@ -1200,6 +1566,7 @@ function registerRootTools(tools, deps) {
|
|
|
1200
1566
|
execute: (args, exec) => rollback(deps, args, exec)
|
|
1201
1567
|
});
|
|
1202
1568
|
return [
|
|
1569
|
+
"pentester_start",
|
|
1203
1570
|
"pentester_delegate",
|
|
1204
1571
|
"pentester_cancel_delegation",
|
|
1205
1572
|
"pentester_advance_stage",
|
|
@@ -1207,44 +1574,231 @@ function registerRootTools(tools, deps) {
|
|
|
1207
1574
|
];
|
|
1208
1575
|
}
|
|
1209
1576
|
const ROOT_TOOL_DESCRIPTIONS = {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1577
|
+
pentester_start: "Initialize or restart a PentestRun for a target. Must be called before any other pentester tool. Mode \"new\" creates a fresh target; mode \"restart\" resets an existing target (old data destroyed).",
|
|
1578
|
+
pentester_delegate: "Creates a new long-lived continuable PTES worker. IMPORTANT: explain to the user which worker you are creating before calling this tool.",
|
|
1579
|
+
pentester_cancel_delegation: "Close a PTES Delegation. Interrupts the live child turn if active, then marks the Delegation as closed.",
|
|
1580
|
+
pentester_advance_stage: "Advance to the next PTES stage. The host verifies linear order and that no active/starting delegations remain.",
|
|
1581
|
+
pentester_rollback_stage: "Roll the timeline back to a completed stage checkpoint and start a rework branch."
|
|
1214
1582
|
};
|
|
1215
|
-
async function
|
|
1583
|
+
async function startRun(deps, args, exec) {
|
|
1216
1584
|
const caller = requireRootCaller(exec);
|
|
1217
1585
|
const projectDir = caller.cwd;
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
const
|
|
1221
|
-
const
|
|
1222
|
-
const
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1586
|
+
const sessionId = caller.sessionId;
|
|
1587
|
+
const target = typeof args.target === "string" ? args.target.trim() : "";
|
|
1588
|
+
const scope = typeof args.scope === "string" ? args.scope : "";
|
|
1589
|
+
const roe = typeof args.rules_of_engagement === "string" ? args.rules_of_engagement : "";
|
|
1590
|
+
const language = typeof args.language === "string" ? args.language : "zh-CN";
|
|
1591
|
+
const summary = typeof args.summary === "string" ? args.summary : "";
|
|
1592
|
+
const mode = args.mode === "restart" ? "restart" : "new";
|
|
1593
|
+
const expectedTargetId = typeof args.expected_target_id === "string" ? args.expected_target_id : void 0;
|
|
1594
|
+
if (target === "") throw new Error("pentester_start requires a non-empty target");
|
|
1595
|
+
if (scope === "") throw new Error("pentester_start requires a non-empty scope");
|
|
1596
|
+
if (summary.trim() === "") throw new Error("pentester_start requires a non-empty summary");
|
|
1597
|
+
await requirePreEngagementProof(deps.sessionQuery, sessionId, {
|
|
1598
|
+
target,
|
|
1599
|
+
scope,
|
|
1600
|
+
language
|
|
1601
|
+
});
|
|
1602
|
+
const canonical = canonicalizeTarget(target);
|
|
1603
|
+
if (canonical === "") throw new Error(`invalid target: "${target}"`);
|
|
1604
|
+
const registry = await loadTargetRegistry(projectDir);
|
|
1605
|
+
const existingBinding = resolveTargetByRootSession(registry, sessionId);
|
|
1606
|
+
if (existingBinding !== void 0 && existingBinding.canonicalTarget !== canonical) throw new Error(`This session is already bound to target "${existingBinding.target}". Create a new Pentester Session for a different target.`);
|
|
1607
|
+
const existingTarget = resolveTargetByCanonicalTarget(registry, canonical);
|
|
1608
|
+
if (mode === "new") {
|
|
1609
|
+
if (existingTarget !== void 0) throw new Error(`Target "${canonical}" already exists. Use mode "restart" to restart it, or open the existing Root Session for this target.`);
|
|
1610
|
+
return bootstrapNewTarget(deps, projectDir, caller, registry, {
|
|
1611
|
+
target,
|
|
1612
|
+
canonical,
|
|
1613
|
+
scope,
|
|
1614
|
+
roe,
|
|
1615
|
+
language,
|
|
1616
|
+
summary
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
if (existingTarget === void 0) throw new Error(`Target "${canonical}" does not exist. Use mode "new" to create it.`);
|
|
1620
|
+
if (expectedTargetId !== void 0 && expectedTargetId !== existingTarget.id) throw new Error(`Target id mismatch: expected ${expectedTargetId}, got ${existingTarget.id}. The target may have been modified concurrently.`);
|
|
1621
|
+
return restartTarget(deps, projectDir, caller, registry, existingTarget, {
|
|
1622
|
+
target,
|
|
1623
|
+
canonical,
|
|
1624
|
+
scope,
|
|
1625
|
+
roe,
|
|
1626
|
+
language,
|
|
1627
|
+
summary
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
async function bootstrapNewTarget(deps, projectDir, caller, registry, opts) {
|
|
1631
|
+
const id = makeTargetId(opts.canonical);
|
|
1632
|
+
const dirName = makeTargetDirName(opts.target, id);
|
|
1633
|
+
await updateTargetRegistry(projectDir, (r) => {
|
|
1634
|
+
r.targets.push({
|
|
1635
|
+
id,
|
|
1636
|
+
target: opts.target,
|
|
1637
|
+
canonicalTarget: opts.canonical,
|
|
1638
|
+
dirName,
|
|
1639
|
+
rootSessionId: caller.sessionId,
|
|
1640
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1641
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1642
|
+
});
|
|
1643
|
+
});
|
|
1644
|
+
const ctx = buildTargetContext(projectDir, {
|
|
1645
|
+
id,
|
|
1646
|
+
target: opts.target,
|
|
1647
|
+
canonicalTarget: opts.canonical,
|
|
1648
|
+
dirName,
|
|
1227
1649
|
rootSessionId: caller.sessionId,
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1650
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1651
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1652
|
+
});
|
|
1653
|
+
await initTargetWorkspace(ctx, {
|
|
1654
|
+
target: opts.target,
|
|
1655
|
+
language: opts.language,
|
|
1656
|
+
scope: opts.scope,
|
|
1657
|
+
roe: opts.roe
|
|
1658
|
+
});
|
|
1659
|
+
await writePreEngagementInput(ctx, {
|
|
1660
|
+
target: opts.target,
|
|
1661
|
+
scope: opts.scope,
|
|
1662
|
+
roe: opts.roe,
|
|
1663
|
+
language: opts.language
|
|
1664
|
+
});
|
|
1665
|
+
const run = {
|
|
1666
|
+
schemaVersion: 2,
|
|
1667
|
+
id: crypto.randomUUID(),
|
|
1668
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1669
|
+
target: opts.canonical,
|
|
1670
|
+
scope: opts.scope,
|
|
1671
|
+
rulesOfEngagement: opts.roe,
|
|
1672
|
+
language: opts.language,
|
|
1673
|
+
status: "active",
|
|
1674
|
+
currentStage: "pre-engagement",
|
|
1675
|
+
stageHistory: [{
|
|
1676
|
+
stage: "pre-engagement",
|
|
1677
|
+
enteredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1678
|
+
}],
|
|
1679
|
+
delegations: []
|
|
1680
|
+
};
|
|
1681
|
+
await saveRun(ctx, run);
|
|
1682
|
+
await deps.git.init(ctx, opts.target);
|
|
1683
|
+
await writeStageSummary(ctx, "pre-engagement", opts.summary);
|
|
1684
|
+
advanceStage(run, opts.summary);
|
|
1685
|
+
await saveRun(ctx, run);
|
|
1686
|
+
await deps.git.checkpointStage(ctx, "pre-engagement");
|
|
1687
|
+
await appendEvent(ctx, {
|
|
1688
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1689
|
+
type: "stage.completed",
|
|
1690
|
+
data: {
|
|
1691
|
+
stage: "pre-engagement",
|
|
1692
|
+
next: run.currentStage
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
await deps.pushWorkspace(ctx, run).catch(() => void 0);
|
|
1696
|
+
return {
|
|
1697
|
+
mode: "new",
|
|
1698
|
+
targetId: id,
|
|
1699
|
+
target: opts.canonical,
|
|
1700
|
+
...rootState(run, deps.profiles)
|
|
1239
1701
|
};
|
|
1240
1702
|
}
|
|
1241
|
-
async function
|
|
1703
|
+
async function restartTarget(deps, projectDir, caller, registry, existing, opts) {
|
|
1704
|
+
const ctx = buildTargetContext(projectDir, existing);
|
|
1705
|
+
const oldRun = await resolvePentestRun(ctx);
|
|
1706
|
+
if (oldRun !== null) await deps.pushWorkspace(ctx, oldRun).catch(() => void 0);
|
|
1707
|
+
const { rm } = await import("node:fs/promises");
|
|
1708
|
+
await rm(ctx.targetRoot, {
|
|
1709
|
+
recursive: true,
|
|
1710
|
+
force: true
|
|
1711
|
+
}).catch(() => void 0);
|
|
1712
|
+
await initTargetWorkspace(ctx, {
|
|
1713
|
+
target: opts.target,
|
|
1714
|
+
language: opts.language,
|
|
1715
|
+
scope: opts.scope,
|
|
1716
|
+
roe: opts.roe
|
|
1717
|
+
});
|
|
1718
|
+
await writePreEngagementInput(ctx, {
|
|
1719
|
+
target: opts.target,
|
|
1720
|
+
scope: opts.scope,
|
|
1721
|
+
roe: opts.roe,
|
|
1722
|
+
language: opts.language
|
|
1723
|
+
});
|
|
1724
|
+
const run = {
|
|
1725
|
+
schemaVersion: 2,
|
|
1726
|
+
id: crypto.randomUUID(),
|
|
1727
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1728
|
+
target: opts.canonical,
|
|
1729
|
+
scope: opts.scope,
|
|
1730
|
+
rulesOfEngagement: opts.roe,
|
|
1731
|
+
language: opts.language,
|
|
1732
|
+
status: "active",
|
|
1733
|
+
currentStage: "pre-engagement",
|
|
1734
|
+
stageHistory: [{
|
|
1735
|
+
stage: "pre-engagement",
|
|
1736
|
+
enteredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1737
|
+
}],
|
|
1738
|
+
delegations: []
|
|
1739
|
+
};
|
|
1740
|
+
await saveRun(ctx, run);
|
|
1741
|
+
await deps.git.init(ctx, opts.target);
|
|
1742
|
+
await updateTargetRegistry(projectDir, (r) => {
|
|
1743
|
+
const rec = r.targets.find((t) => t.id === existing.id);
|
|
1744
|
+
if (rec !== void 0) {
|
|
1745
|
+
rec.rootSessionId = caller.sessionId;
|
|
1746
|
+
rec.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
await writeStageSummary(ctx, "pre-engagement", opts.summary);
|
|
1750
|
+
advanceStage(run, opts.summary);
|
|
1751
|
+
await saveRun(ctx, run);
|
|
1752
|
+
await deps.git.checkpointStage(ctx, "pre-engagement");
|
|
1753
|
+
await appendEvent(ctx, {
|
|
1754
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1755
|
+
type: "stage.completed",
|
|
1756
|
+
data: {
|
|
1757
|
+
stage: "pre-engagement",
|
|
1758
|
+
next: run.currentStage
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
await deps.pushWorkspace(ctx, run).catch(() => void 0);
|
|
1762
|
+
return {
|
|
1763
|
+
mode: "restart",
|
|
1764
|
+
targetId: existing.id,
|
|
1765
|
+
target: opts.canonical,
|
|
1766
|
+
...rootState(run, deps.profiles)
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
async function delegate(deps, args, exec) {
|
|
1242
1770
|
const caller = requireRootCaller(exec);
|
|
1243
|
-
const
|
|
1771
|
+
const projectDir = caller.cwd;
|
|
1772
|
+
const parent = agentOf(exec);
|
|
1773
|
+
if (parent === void 0) throw new Error("pentester_delegate requires a calling agent (exec.agent was undefined)");
|
|
1774
|
+
const signal = signalOf(exec);
|
|
1775
|
+
const { ctx, run } = await requireTargetRun(caller);
|
|
1776
|
+
const assignments = parseAssignments(args.assignments);
|
|
1777
|
+
const created = await deps.delegations.delegate({
|
|
1778
|
+
projectDir,
|
|
1779
|
+
targetContext: ctx,
|
|
1780
|
+
run,
|
|
1781
|
+
profiles: deps.profiles,
|
|
1782
|
+
rootSessionId: caller.sessionId,
|
|
1783
|
+
parent,
|
|
1784
|
+
signal
|
|
1785
|
+
}, assignments);
|
|
1786
|
+
return {
|
|
1787
|
+
...rootState(run, deps.profiles),
|
|
1788
|
+
created: created.map((d) => ({
|
|
1789
|
+
id: d.id,
|
|
1790
|
+
agent: d.agentId,
|
|
1791
|
+
status: d.status,
|
|
1792
|
+
...d.sessionId === void 0 ? {} : { childSessionId: d.sessionId }
|
|
1793
|
+
}))
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
async function cancel(deps, args, exec) {
|
|
1797
|
+
const { ctx, run } = await requireTargetRun(requireRootCaller(exec));
|
|
1244
1798
|
const delegationId = typeof args.delegation_id === "string" ? args.delegation_id : "";
|
|
1245
1799
|
const reason = typeof args.reason === "string" ? args.reason : void 0;
|
|
1246
1800
|
const cancelled = await deps.delegations.cancel({
|
|
1247
|
-
|
|
1801
|
+
targetContext: ctx,
|
|
1248
1802
|
run
|
|
1249
1803
|
}, delegationId, reason);
|
|
1250
1804
|
return {
|
|
@@ -1256,27 +1810,25 @@ async function cancel(deps, args, exec) {
|
|
|
1256
1810
|
};
|
|
1257
1811
|
}
|
|
1258
1812
|
async function advance(deps, args, exec) {
|
|
1259
|
-
const
|
|
1813
|
+
const caller = requireRootCaller(exec);
|
|
1260
1814
|
const summary = typeof args.summary === "string" ? args.summary : "";
|
|
1261
1815
|
if (summary.trim() === "") throw new Error("pentester_advance_stage requires a non-empty summary (stage handoff text)");
|
|
1262
|
-
|
|
1263
|
-
if (run === null) run = await bootstrapRun(deps, projectDir, parseRunInfo(args.run));
|
|
1264
|
-
else await initWorkspace(projectDir, { language: run.language });
|
|
1816
|
+
const { ctx, run } = await requireTargetRun(caller);
|
|
1265
1817
|
if (run.status === "completed" || run.currentStage === null) throw new Error("PentestRun is already completed; no stage to advance");
|
|
1266
1818
|
const previous = run.currentStage;
|
|
1267
1819
|
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(
|
|
1820
|
+
await deps.syncWorkspace(ctx, run).catch(() => void 0);
|
|
1269
1821
|
const stageDelegations = run.delegations.filter((d) => d.stageId === previous);
|
|
1270
|
-
const delegationDirs = stageDelegations.map((d) => d.
|
|
1822
|
+
const delegationDirs = stageDelegations.map((d) => delegationHostDir(ctx, previous, d.id));
|
|
1271
1823
|
const delegationIds = stageDelegations.map((d) => d.id);
|
|
1272
|
-
await promoteAssets(
|
|
1273
|
-
await promoteFindings(
|
|
1274
|
-
for (const delegation of stageDelegations) await promoteDeliverables(
|
|
1275
|
-
await writeStageSummary(
|
|
1824
|
+
await promoteAssets(ctx, previous, delegationDirs, delegationIds);
|
|
1825
|
+
await promoteFindings(ctx, previous, delegationDirs, delegationIds);
|
|
1826
|
+
for (const delegation of stageDelegations) await promoteDeliverables(ctx, delegationHostDir(ctx, previous, delegation.id));
|
|
1827
|
+
await writeStageSummary(ctx, previous, summary);
|
|
1276
1828
|
const target = advanceStage(run, summary);
|
|
1277
|
-
await saveRun(
|
|
1278
|
-
const tag = await deps.git.checkpointStage(
|
|
1279
|
-
await appendEvent(
|
|
1829
|
+
await saveRun(ctx, run);
|
|
1830
|
+
const tag = await deps.git.checkpointStage(ctx, previous);
|
|
1831
|
+
await appendEvent(ctx, {
|
|
1280
1832
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1281
1833
|
type: "stage.completed",
|
|
1282
1834
|
data: {
|
|
@@ -1285,7 +1837,7 @@ async function advance(deps, args, exec) {
|
|
|
1285
1837
|
next: target
|
|
1286
1838
|
}
|
|
1287
1839
|
});
|
|
1288
|
-
await deps.pushWorkspace(
|
|
1840
|
+
await deps.pushWorkspace(ctx, run).catch(() => void 0);
|
|
1289
1841
|
return {
|
|
1290
1842
|
previous,
|
|
1291
1843
|
...target === null ? { completed: true } : { next: target },
|
|
@@ -1294,9 +1846,7 @@ async function advance(deps, args, exec) {
|
|
|
1294
1846
|
};
|
|
1295
1847
|
}
|
|
1296
1848
|
async function rollback(deps, args, exec) {
|
|
1297
|
-
const
|
|
1298
|
-
const projectDir = caller.cwd;
|
|
1299
|
-
const run = await requireRun(caller, deps);
|
|
1849
|
+
const { ctx, run } = await requireTargetRun(requireRootCaller(exec));
|
|
1300
1850
|
const stageArg = typeof args.stage === "string" ? args.stage : "";
|
|
1301
1851
|
const reason = typeof args.reason === "string" ? args.reason : "";
|
|
1302
1852
|
if (!isStageId(stageArg)) throw new Error(`pentester_rollback_stage: unknown stage "${stageArg}"`);
|
|
@@ -1305,17 +1855,17 @@ async function rollback(deps, args, exec) {
|
|
|
1305
1855
|
if (!run.stageHistory.some((entry) => entry.stage === stageArg)) throw new Error(`pentester_rollback_stage: stage ${stageArg} has no completed checkpoint on this timeline`);
|
|
1306
1856
|
const active = run.delegations.filter((d) => d.status === "active" || d.status === "starting");
|
|
1307
1857
|
for (const delegation of active) await deps.delegations.cancel({
|
|
1308
|
-
|
|
1858
|
+
targetContext: ctx,
|
|
1309
1859
|
run
|
|
1310
1860
|
}, delegation.id, "rolled back by Root");
|
|
1311
1861
|
let backup;
|
|
1312
|
-
if (await deps.git.hasChanges(
|
|
1313
|
-
const checkpoint = await deps.git.findStageCheckpoint(
|
|
1862
|
+
if (await deps.git.hasChanges(ctx)) backup = await deps.git.createBackupBranch(ctx, stageArg);
|
|
1863
|
+
const checkpoint = await deps.git.findStageCheckpoint(ctx, stageArg);
|
|
1314
1864
|
if (checkpoint === void 0) throw new Error(`pentester_rollback_stage: no checkpoint commit found for stage ${stageArg}`);
|
|
1315
|
-
const branch = await deps.git.createReworkBranch(
|
|
1316
|
-
const authoritativeRun = await resolvePentestRun(
|
|
1865
|
+
const branch = await deps.git.createReworkBranch(ctx, checkpoint, stageDir(next));
|
|
1866
|
+
const authoritativeRun = await resolvePentestRun(ctx);
|
|
1317
1867
|
if (authoritativeRun === null) throw new Error("pentester_rollback_stage: run.json disappeared after checkout — this is a bug");
|
|
1318
|
-
await appendEvent(
|
|
1868
|
+
await appendEvent(ctx, {
|
|
1319
1869
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1320
1870
|
type: "stage.rollback",
|
|
1321
1871
|
data: {
|
|
@@ -1326,7 +1876,7 @@ async function rollback(deps, args, exec) {
|
|
|
1326
1876
|
reason
|
|
1327
1877
|
}
|
|
1328
1878
|
});
|
|
1329
|
-
await deps.pushWorkspace(
|
|
1879
|
+
await deps.pushWorkspace(ctx, authoritativeRun).catch(() => void 0);
|
|
1330
1880
|
return {
|
|
1331
1881
|
reopenedStage: stageArg,
|
|
1332
1882
|
currentStage: stageArg,
|
|
@@ -1336,56 +1886,17 @@ async function rollback(deps, args, exec) {
|
|
|
1336
1886
|
...rootState(authoritativeRun, deps.profiles)
|
|
1337
1887
|
};
|
|
1338
1888
|
}
|
|
1339
|
-
const NO_RUN_MESSAGE = "No PentestRun exists. Complete pre-engagement and call
|
|
1340
|
-
async function
|
|
1889
|
+
const NO_RUN_MESSAGE = "No PentestRun exists. Complete pre-engagement and call pentester_start first.";
|
|
1890
|
+
async function requireTargetRun(caller) {
|
|
1341
1891
|
const projectDir = caller.cwd;
|
|
1342
|
-
await
|
|
1343
|
-
|
|
1892
|
+
const ctx = await resolveSessionTargetContext(projectDir, caller.sessionId);
|
|
1893
|
+
if (ctx === void 0) throw new Error("This session is not bound to any target. Start a pentest with pentester_start first.");
|
|
1894
|
+
const run = await resolvePentestRun(ctx);
|
|
1344
1895
|
if (run === null) throw new Error(NO_RUN_MESSAGE);
|
|
1345
|
-
return
|
|
1346
|
-
|
|
1347
|
-
|
|
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: []
|
|
1896
|
+
return {
|
|
1897
|
+
ctx,
|
|
1898
|
+
run
|
|
1376
1899
|
};
|
|
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
1900
|
}
|
|
1390
1901
|
function parseAssignments(value) {
|
|
1391
1902
|
if (!Array.isArray(value) || value.length === 0) throw new Error("assignments must be a non-empty array");
|
|
@@ -1400,19 +1911,6 @@ function parseAssignments(value) {
|
|
|
1400
1911
|
};
|
|
1401
1912
|
});
|
|
1402
1913
|
}
|
|
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
1914
|
function rootState(run, profiles) {
|
|
1417
1915
|
const stage = run.currentStage === null ? void 0 : stageDefinition(run.currentStage);
|
|
1418
1916
|
const allowedProfiles = (stage?.agentIds ?? []).map((id) => profiles.get(id)).filter((profile) => profile !== void 0).map((profile) => ({
|
|
@@ -1423,7 +1921,8 @@ function rootState(run, profiles) {
|
|
|
1423
1921
|
run: {
|
|
1424
1922
|
currentStage: run.currentStage,
|
|
1425
1923
|
status: run.status ?? "active",
|
|
1426
|
-
|
|
1924
|
+
target: run.target,
|
|
1925
|
+
scope: run.scope
|
|
1427
1926
|
},
|
|
1428
1927
|
stage: stage === void 0 ? null : {
|
|
1429
1928
|
id: stage.id,
|
|
@@ -1709,9 +2208,28 @@ const CAP_ADD_ALLOWLIST = [
|
|
|
1709
2208
|
"NET_RAW",
|
|
1710
2209
|
"NET_BIND_SERVICE"
|
|
1711
2210
|
];
|
|
1712
|
-
/**
|
|
1713
|
-
|
|
1714
|
-
|
|
2211
|
+
/** Sanitize a target string into a valid Docker container name segment.
|
|
2212
|
+
*
|
|
2213
|
+
* - trim, lowercase
|
|
2214
|
+
* - strip http://, https://
|
|
2215
|
+
* - keep [a-z0-9._-], replace everything else with -
|
|
2216
|
+
* - collapse consecutive dashes
|
|
2217
|
+
* - strip leading/trailing non-alphanumeric
|
|
2218
|
+
* - cap length
|
|
2219
|
+
*/
|
|
2220
|
+
function sanitizeDockerTargetName(target) {
|
|
2221
|
+
let name = target.trim().toLowerCase();
|
|
2222
|
+
name = name.replace(/^https?:\/\//, "");
|
|
2223
|
+
name = name.replace(/[^a-z0-9._-]/g, "-");
|
|
2224
|
+
name = name.replace(/-{2,}/g, "-");
|
|
2225
|
+
name = name.replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "");
|
|
2226
|
+
if (name.length > 114) name = name.slice(0, 114);
|
|
2227
|
+
if (name.length === 0) name = "pentest";
|
|
2228
|
+
return name;
|
|
2229
|
+
}
|
|
2230
|
+
/** Generate a human-readable Docker container name for a target. */
|
|
2231
|
+
function containerNameForTarget(target) {
|
|
2232
|
+
return `dsh-pentester-${sanitizeDockerTargetName(target)}`;
|
|
1715
2233
|
}
|
|
1716
2234
|
/** 固定注入 Toolbox 容器的代理环境变量(容器内部代理服务)。 */
|
|
1717
2235
|
const FIXED_CONTAINER_ENV = ["HTTP_PROXY=http://127.0.0.1:8080", "HTTPS_PROXY=http://127.0.0.1:8080"];
|
|
@@ -1740,11 +2258,11 @@ function safeEnv(env) {
|
|
|
1740
2258
|
}
|
|
1741
2259
|
return safe;
|
|
1742
2260
|
}
|
|
1743
|
-
function volumeNameFor(
|
|
1744
|
-
return `dsh-pentester
|
|
2261
|
+
function volumeNameFor(targetId, runId) {
|
|
2262
|
+
return `dsh-pentester-${createHash("sha1").update(`${targetId}:${runId}`).digest("hex").slice(0, 12)}`;
|
|
1745
2263
|
}
|
|
1746
|
-
function localMirrorDir(
|
|
1747
|
-
return
|
|
2264
|
+
function localMirrorDir(targetRoot) {
|
|
2265
|
+
return targetRoot;
|
|
1748
2266
|
}
|
|
1749
2267
|
var DockerRuntime = class {
|
|
1750
2268
|
engine;
|
|
@@ -1797,7 +2315,7 @@ var DockerRuntime = class {
|
|
|
1797
2315
|
* reused while running.
|
|
1798
2316
|
*/
|
|
1799
2317
|
async ensureRunContainer(options) {
|
|
1800
|
-
const volume = this.remote ? await this.ensureWorkspaceVolume(options.
|
|
2318
|
+
const volume = this.remote ? await this.ensureWorkspaceVolume(options.targetId, options.runId) : void 0;
|
|
1801
2319
|
const label = "dsh.pentester.run";
|
|
1802
2320
|
const existing = await this.engine.listContainers({
|
|
1803
2321
|
all: true,
|
|
@@ -1814,15 +2332,11 @@ var DockerRuntime = class {
|
|
|
1814
2332
|
ensure.finally(() => this.ensuring.delete(options.runId));
|
|
1815
2333
|
}
|
|
1816
2334
|
const containerId = await ensure;
|
|
1817
|
-
if (this.remote && volume?.created === true) await this.pushLocalMirror(containerId, options.
|
|
2335
|
+
if (this.remote && volume?.created === true) await this.pushLocalMirror(containerId, options.targetRoot).catch(() => void 0);
|
|
1818
2336
|
return containerId;
|
|
1819
2337
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
* `created: true` 表示本轮新建 —— 调用方据此决定是否从本地恢复数据。
|
|
1823
|
-
*/
|
|
1824
|
-
async ensureWorkspaceVolume(projectDir) {
|
|
1825
|
-
const volume = volumeNameFor(projectDir);
|
|
2338
|
+
async ensureWorkspaceVolume(targetId, runId) {
|
|
2339
|
+
const volume = volumeNameFor(targetId, runId);
|
|
1826
2340
|
if (this.volumes.has(volume)) return {
|
|
1827
2341
|
name: volume,
|
|
1828
2342
|
created: false
|
|
@@ -1838,7 +2352,8 @@ var DockerRuntime = class {
|
|
|
1838
2352
|
Name: volume,
|
|
1839
2353
|
Labels: {
|
|
1840
2354
|
"dsh.pentester.managed": "true",
|
|
1841
|
-
"dsh.pentester.
|
|
2355
|
+
"dsh.pentester.run": runId,
|
|
2356
|
+
"dsh.pentester.target": targetId
|
|
1842
2357
|
}
|
|
1843
2358
|
});
|
|
1844
2359
|
this.volumes.add(created.Name);
|
|
@@ -1851,7 +2366,7 @@ var DockerRuntime = class {
|
|
|
1851
2366
|
const hostConfig = {
|
|
1852
2367
|
Mounts: [volume === void 0 ? {
|
|
1853
2368
|
Type: "bind",
|
|
1854
|
-
Source: localMirrorDir(options.
|
|
2369
|
+
Source: localMirrorDir(options.targetRoot),
|
|
1855
2370
|
Target: "/workspace"
|
|
1856
2371
|
} : {
|
|
1857
2372
|
Type: "volume",
|
|
@@ -1862,14 +2377,15 @@ var DockerRuntime = class {
|
|
|
1862
2377
|
AutoRemove: false
|
|
1863
2378
|
};
|
|
1864
2379
|
assertSafeHostConfig(hostConfig);
|
|
1865
|
-
const containerName =
|
|
2380
|
+
const containerName = containerNameForTarget(options.target);
|
|
1866
2381
|
const container = await this.engine.createContainer({
|
|
1867
2382
|
name: containerName,
|
|
1868
2383
|
Image: options.toolbox.image,
|
|
1869
2384
|
Env: [...FIXED_CONTAINER_ENV],
|
|
1870
2385
|
Labels: {
|
|
1871
2386
|
[label]: options.runId,
|
|
1872
|
-
"dsh.pentester.managed": "true"
|
|
2387
|
+
"dsh.pentester.managed": "true",
|
|
2388
|
+
"dsh.pentester.target": options.targetId
|
|
1873
2389
|
},
|
|
1874
2390
|
HostConfig: hostConfig,
|
|
1875
2391
|
WorkingDir: "/workspace",
|
|
@@ -1908,7 +2424,7 @@ var DockerRuntime = class {
|
|
|
1908
2424
|
* 本地 bind mount 下 host == container,直接读本地文件系统。
|
|
1909
2425
|
* @returns result.md 内容(缺失 undefined)与相对文件清单。
|
|
1910
2426
|
*/
|
|
1911
|
-
async readDelegationDelivery(runId,
|
|
2427
|
+
async readDelegationDelivery(runId, ctx, delegationDir) {
|
|
1912
2428
|
if (!this.remote) {
|
|
1913
2429
|
const { readdir, readFile } = await import("node:fs/promises");
|
|
1914
2430
|
const { join } = await import("node:path");
|
|
@@ -1939,7 +2455,7 @@ var DockerRuntime = class {
|
|
|
1939
2455
|
files: files.sort()
|
|
1940
2456
|
};
|
|
1941
2457
|
}
|
|
1942
|
-
const containerPath = delegationDir.startsWith(
|
|
2458
|
+
const containerPath = delegationDir.startsWith(ctx.targetRoot) ? "/workspace" + delegationDir.slice(ctx.targetRoot.length).replace(/\\/g, "/") : "/workspace";
|
|
1943
2459
|
const containerId = await this.findRunContainer(runId);
|
|
1944
2460
|
if (containerId === void 0) return { files: [] };
|
|
1945
2461
|
const files = ((await this.exec(containerId, { argv: [
|
|
@@ -1959,17 +2475,17 @@ var DockerRuntime = class {
|
|
|
1959
2475
|
* target/、stage scaffold。排除 .git(Worker 不可改 Git)。
|
|
1960
2476
|
* 本地 bind mount 下 host == container,无操作直接返回。
|
|
1961
2477
|
*/
|
|
1962
|
-
async pushWorkspace(runId,
|
|
2478
|
+
async pushWorkspace(runId, ctx) {
|
|
1963
2479
|
if (!this.remote) return;
|
|
1964
|
-
const key = `${runId}:${
|
|
2480
|
+
const key = `${runId}:${ctx.targetRoot}`;
|
|
1965
2481
|
const inFlight = this.pushing.get(key);
|
|
1966
2482
|
if (inFlight !== void 0) return inFlight;
|
|
1967
|
-
const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pushLocalMirror(containerId,
|
|
2483
|
+
const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pushLocalMirror(containerId, ctx.targetRoot)).finally(() => this.pushing.delete(key));
|
|
1968
2484
|
this.pushing.set(key, task);
|
|
1969
2485
|
return task;
|
|
1970
2486
|
}
|
|
1971
|
-
async pushLocalMirror(containerId,
|
|
1972
|
-
const archive = await packDirectory(localMirrorDir(
|
|
2487
|
+
async pushLocalMirror(containerId, targetRoot) {
|
|
2488
|
+
const archive = await packDirectory(localMirrorDir(targetRoot), { exclude: (name) => name === ".git" });
|
|
1973
2489
|
await this.engine.getContainer(containerId).putArchive(archive, { path: "/workspace" });
|
|
1974
2490
|
}
|
|
1975
2491
|
/**
|
|
@@ -1980,12 +2496,12 @@ var DockerRuntime = class {
|
|
|
1980
2496
|
* 幽灵文件会被删除。Host 控制态(.git / .dsh-pentester)不参与 pull,
|
|
1981
2497
|
* 防止 remote stale state 覆盖宿主 run.json(§19)。
|
|
1982
2498
|
*/
|
|
1983
|
-
async syncWorkspace(runId,
|
|
2499
|
+
async syncWorkspace(runId, ctx) {
|
|
1984
2500
|
if (!this.remote) return;
|
|
1985
|
-
const key = `${runId}:${
|
|
2501
|
+
const key = `${runId}:${ctx.targetRoot}`;
|
|
1986
2502
|
const inFlight = this.syncing.get(key);
|
|
1987
2503
|
if (inFlight !== void 0) return inFlight;
|
|
1988
|
-
const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pullWorkspace(containerId,
|
|
2504
|
+
const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pullWorkspace(containerId, ctx.targetRoot)).finally(() => this.syncing.delete(key));
|
|
1989
2505
|
this.syncing.set(key, task);
|
|
1990
2506
|
return task;
|
|
1991
2507
|
}
|
|
@@ -1999,8 +2515,8 @@ var DockerRuntime = class {
|
|
|
1999
2515
|
});
|
|
2000
2516
|
return matches.length > 0 ? String(matches[0].Id) : void 0;
|
|
2001
2517
|
}
|
|
2002
|
-
async pullWorkspace(containerId,
|
|
2003
|
-
const localDir = localMirrorDir(
|
|
2518
|
+
async pullWorkspace(containerId, targetRoot) {
|
|
2519
|
+
const localDir = localMirrorDir(targetRoot);
|
|
2004
2520
|
const tarStream = await this.engine.getContainer(containerId).getArchive({ path: "/workspace" });
|
|
2005
2521
|
const extract = tar.extract();
|
|
2006
2522
|
const done = new Promise((resolve, reject) => {
|
|
@@ -2235,35 +2751,35 @@ function makeContainerExecExecutor(docker, toolboxName = "kali") {
|
|
|
2235
2751
|
if (caller === void 0 || !caller.isSubagent) throw new WorkerToolError("pentester_container_exec is reserved for delegated pentester workers");
|
|
2236
2752
|
const projectDir = caller.cwd;
|
|
2237
2753
|
if (projectDir === void 0) throw new WorkerToolError("worker session has no trusted workspace cwd");
|
|
2238
|
-
const
|
|
2239
|
-
if (
|
|
2754
|
+
const ctx = await resolveWorkerTargetContext(projectDir, caller.sessionId);
|
|
2755
|
+
if (ctx === void 0) throw new WorkerToolError(`session ${caller.sessionId} is not bound to any delegation; only delegated workers may execute commands`);
|
|
2756
|
+
const run = await resolvePentestRun(ctx);
|
|
2757
|
+
if (run === null) throw new WorkerToolError("no PentestRun for this target; the Root Agent must start one first");
|
|
2240
2758
|
const delegation = run.delegations.find((candidate) => candidate.sessionId === caller.sessionId);
|
|
2241
2759
|
if (delegation === void 0) throw new WorkerToolError(`session ${caller.sessionId} is not bound to any delegation; only delegated workers may execute commands`);
|
|
2242
2760
|
const toolbox = await docker.ensureImage(toolboxName);
|
|
2243
2761
|
const containerId = await docker.ensureRunContainer({
|
|
2244
2762
|
runId: run.id,
|
|
2763
|
+
targetId: ctx.targetId,
|
|
2764
|
+
target: ctx.canonicalTarget,
|
|
2245
2765
|
toolbox,
|
|
2246
|
-
|
|
2766
|
+
targetRoot: ctx.targetRoot
|
|
2247
2767
|
});
|
|
2248
2768
|
const input = {
|
|
2249
2769
|
argv: Array.isArray(args.argv) ? args.argv.map(String) : [],
|
|
2250
|
-
...typeof args.cwd === "string" ? { cwd: args.cwd } : { cwd: workerContainerWorkDir(
|
|
2770
|
+
...typeof args.cwd === "string" ? { cwd: args.cwd } : { cwd: workerContainerWorkDir(ctx, delegation) },
|
|
2251
2771
|
...isStringRecord(args.env) ? { env: args.env } : {},
|
|
2252
2772
|
...typeof args.timeoutMs === "number" ? { timeoutMs: args.timeoutMs } : {}
|
|
2253
2773
|
};
|
|
2254
2774
|
const result = await docker.exec(containerId, input);
|
|
2255
|
-
docker.syncWorkspace(run.id,
|
|
2775
|
+
docker.syncWorkspace(run.id, ctx).catch(() => void 0);
|
|
2256
2776
|
return result;
|
|
2257
2777
|
};
|
|
2258
2778
|
}
|
|
2259
2779
|
/** Worker 的容器工作目录:`/workspace/stages/<NN>-<stage>/delegations/D-00N/work`。 */
|
|
2260
|
-
function workerContainerWorkDir(
|
|
2261
|
-
return `${
|
|
2780
|
+
function workerContainerWorkDir(ctx, delegation) {
|
|
2781
|
+
return `${delegationContainerDir(delegation.stageId, delegation.id)}/work`;
|
|
2262
2782
|
}
|
|
2263
|
-
/**
|
|
2264
|
-
* 在调用者给定的 tools registry 上注册 pentester_container_exec(run-state.mjs
|
|
2265
|
-
* 把它注册进 pentester preset 的 standing scope;Root 看不到、Worker 独享)。
|
|
2266
|
-
*/
|
|
2267
2783
|
function registerContainerExecTool(tools, docker) {
|
|
2268
2784
|
const execute = makeContainerExecExecutor(docker);
|
|
2269
2785
|
tools.register({
|
|
@@ -2893,8 +3409,1101 @@ var PentesterDockerTypertService = class extends TypertRemoteService {
|
|
|
2893
3409
|
return dispatchDockerRpc(this.deps, parsed.value);
|
|
2894
3410
|
}
|
|
2895
3411
|
};
|
|
2896
|
-
|
|
2897
|
-
|
|
3412
|
+
//#endregion
|
|
3413
|
+
//#region src/ui-view/snapshot.ts
|
|
3414
|
+
/**
|
|
3415
|
+
* ui-view/snapshot.ts — Build read-model views from run.json + filesystem.
|
|
3416
|
+
*
|
|
3417
|
+
* Core snapshot: run, stages, delegations, findings, trace — NO output trees.
|
|
3418
|
+
* Output trees: lazy via listOutput().
|
|
3419
|
+
* File reads: async, bounded concurrency, partial preview.
|
|
3420
|
+
*/
|
|
3421
|
+
const STAGE_SHORT = {
|
|
3422
|
+
"pre-engagement": "Pre",
|
|
3423
|
+
"intelligence-gathering": "Intel",
|
|
3424
|
+
"threat-modeling": "Threat",
|
|
3425
|
+
"vulnerability-analysis": "Vuln",
|
|
3426
|
+
"exploitation": "Exploit",
|
|
3427
|
+
"post-exploitation": "Post",
|
|
3428
|
+
"reporting": "Report"
|
|
3429
|
+
};
|
|
3430
|
+
function stageStatus(run, stageId) {
|
|
3431
|
+
const statuses = run.stageStatuses ?? {};
|
|
3432
|
+
if (run.currentStage === stageId) return statuses[stageId] ?? "active";
|
|
3433
|
+
return statuses[stageId] ?? "pending";
|
|
3434
|
+
}
|
|
3435
|
+
function buildRunView(run) {
|
|
3436
|
+
return {
|
|
3437
|
+
id: run.id,
|
|
3438
|
+
status: run.status ?? "active",
|
|
3439
|
+
currentStage: run.currentStage,
|
|
3440
|
+
targets: [run.target],
|
|
3441
|
+
createdAt: run.createdAt
|
|
3442
|
+
};
|
|
3443
|
+
}
|
|
3444
|
+
function buildStageViews(run) {
|
|
3445
|
+
return STAGE_IDS.map((id) => {
|
|
3446
|
+
const def = STAGE_DEFINITIONS.find((d) => d.id === id);
|
|
3447
|
+
const delegations = run.delegations.filter((d) => d.stageId === id);
|
|
3448
|
+
return {
|
|
3449
|
+
id,
|
|
3450
|
+
name: def?.name ?? id,
|
|
3451
|
+
shortName: STAGE_SHORT[id] ?? id.slice(0, 4),
|
|
3452
|
+
status: stageStatus(run, id),
|
|
3453
|
+
delegationCount: delegations.length,
|
|
3454
|
+
activeDelegationCount: delegations.filter((d) => d.status === "active" || d.status === "starting").length
|
|
3455
|
+
};
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
function buildDelegationViews(run) {
|
|
3459
|
+
return run.delegations.map((d) => ({
|
|
3460
|
+
id: d.id,
|
|
3461
|
+
stageId: d.stageId,
|
|
3462
|
+
agentId: d.agentId,
|
|
3463
|
+
...d.sessionId === void 0 ? {} : { sessionId: d.sessionId },
|
|
3464
|
+
objective: d.objective,
|
|
3465
|
+
status: d.status,
|
|
3466
|
+
createdAt: d.createdAt,
|
|
3467
|
+
...d.finishedAt === void 0 ? {} : { finishedAt: d.finishedAt }
|
|
3468
|
+
}));
|
|
3469
|
+
}
|
|
3470
|
+
async function parseFindingsIndex(ctx) {
|
|
3471
|
+
const indexFile = join(ctx.targetRoot, "findings", "findings.json");
|
|
3472
|
+
if (!existsSync(indexFile)) return [];
|
|
3473
|
+
try {
|
|
3474
|
+
const raw = await readFile(indexFile, "utf8");
|
|
3475
|
+
const entries = JSON.parse(raw);
|
|
3476
|
+
if (!Array.isArray(entries)) return [];
|
|
3477
|
+
return entries.filter((e) => e !== null && typeof e === "object").map((e) => {
|
|
3478
|
+
const detailFile = typeof e.detailFile === "string" ? e.detailFile : "";
|
|
3479
|
+
const id = detailFile.replace(/-findings\.md$/, "");
|
|
3480
|
+
return {
|
|
3481
|
+
id,
|
|
3482
|
+
title: id,
|
|
3483
|
+
severity: typeof e.severity === "string" ? e.severity : "info",
|
|
3484
|
+
sourceStage: typeof e.sourceStage === "string" ? e.sourceStage : "",
|
|
3485
|
+
sourceDelegationId: typeof e.sourceDelegationId === "string" ? e.sourceDelegationId : "",
|
|
3486
|
+
detailFile,
|
|
3487
|
+
updatedAt: typeof e.updatedAt === "string" ? e.updatedAt : ""
|
|
3488
|
+
};
|
|
3489
|
+
});
|
|
3490
|
+
} catch {
|
|
3491
|
+
return [];
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
async function parseAssetsCount(ctx) {
|
|
3495
|
+
const assetsFile = join(ctx.targetRoot, "assets", "assets.json");
|
|
3496
|
+
if (!existsSync(assetsFile)) return 0;
|
|
3497
|
+
try {
|
|
3498
|
+
const raw = await readFile(assetsFile, "utf8");
|
|
3499
|
+
const parsed = JSON.parse(raw);
|
|
3500
|
+
return Array.isArray(parsed) ? parsed.length : 0;
|
|
3501
|
+
} catch {
|
|
3502
|
+
return 0;
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
function buildTraceModel(run, ctx, findings) {
|
|
3506
|
+
const nodes = [];
|
|
3507
|
+
const edges = [];
|
|
3508
|
+
nodes.push({
|
|
3509
|
+
id: "start",
|
|
3510
|
+
kind: "start",
|
|
3511
|
+
label: "Start",
|
|
3512
|
+
timestamp: run.createdAt
|
|
3513
|
+
});
|
|
3514
|
+
let prevStageId = null;
|
|
3515
|
+
for (const stageId of STAGE_IDS) {
|
|
3516
|
+
const status = stageStatus(run, stageId);
|
|
3517
|
+
const stageNodeId = `stage:${stageId}`;
|
|
3518
|
+
const stageEntry = run.stageHistory.find((h) => h.stage === stageId);
|
|
3519
|
+
nodes.push({
|
|
3520
|
+
id: stageNodeId,
|
|
3521
|
+
kind: "stage",
|
|
3522
|
+
label: STAGE_SHORT[stageId] ?? stageId,
|
|
3523
|
+
status,
|
|
3524
|
+
nav: {
|
|
3525
|
+
tab: "stages",
|
|
3526
|
+
focus: stageId
|
|
3527
|
+
},
|
|
3528
|
+
...stageEntry?.enteredAt === void 0 ? {} : { timestamp: stageEntry.enteredAt }
|
|
3529
|
+
});
|
|
3530
|
+
if (prevStageId === null) edges.push({
|
|
3531
|
+
id: `edge:start-${stageId}`,
|
|
3532
|
+
source: "start",
|
|
3533
|
+
target: stageNodeId,
|
|
3534
|
+
kind: "transition"
|
|
3535
|
+
});
|
|
3536
|
+
else edges.push({
|
|
3537
|
+
id: `edge:${prevStageId}-${stageId}`,
|
|
3538
|
+
source: `stage:${prevStageId}`,
|
|
3539
|
+
target: stageNodeId,
|
|
3540
|
+
kind: "transition"
|
|
3541
|
+
});
|
|
3542
|
+
const stageDelegations = run.delegations.filter((d) => d.stageId === stageId);
|
|
3543
|
+
for (const d of stageDelegations) {
|
|
3544
|
+
const delNodeId = `delegation:${d.id}`;
|
|
3545
|
+
nodes.push({
|
|
3546
|
+
id: delNodeId,
|
|
3547
|
+
kind: "delegation",
|
|
3548
|
+
label: `${d.id} · ${d.agentId}`,
|
|
3549
|
+
status: d.status,
|
|
3550
|
+
sublabel: d.objective.slice(0, 60),
|
|
3551
|
+
timestamp: d.createdAt
|
|
3552
|
+
});
|
|
3553
|
+
edges.push({
|
|
3554
|
+
id: `edge:${stageNodeId}-${d.id}`,
|
|
3555
|
+
source: stageNodeId,
|
|
3556
|
+
target: delNodeId,
|
|
3557
|
+
kind: "delegate"
|
|
3558
|
+
});
|
|
3559
|
+
}
|
|
3560
|
+
const stageFindings = findings.filter((f) => f.sourceStage === stageId);
|
|
3561
|
+
for (const f of stageFindings) {
|
|
3562
|
+
nodes.push({
|
|
3563
|
+
id: `finding:${f.id}`,
|
|
3564
|
+
kind: "finding",
|
|
3565
|
+
label: f.title,
|
|
3566
|
+
status: f.severity.toUpperCase(),
|
|
3567
|
+
nav: {
|
|
3568
|
+
tab: "findings",
|
|
3569
|
+
focus: f.id
|
|
3570
|
+
}
|
|
3571
|
+
});
|
|
3572
|
+
edges.push({
|
|
3573
|
+
id: `edge:found-${f.id}`,
|
|
3574
|
+
source: `delegation:${f.sourceDelegationId}`,
|
|
3575
|
+
target: `finding:${f.id}`,
|
|
3576
|
+
kind: "found"
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
prevStageId = stageId;
|
|
3580
|
+
}
|
|
3581
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3582
|
+
return {
|
|
3583
|
+
nodes,
|
|
3584
|
+
edges: edges.filter((e) => {
|
|
3585
|
+
const k = `${e.source}->${e.target}`;
|
|
3586
|
+
if (seen.has(k)) return false;
|
|
3587
|
+
seen.add(k);
|
|
3588
|
+
return true;
|
|
3589
|
+
})
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3592
|
+
const snapshotPerf = typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
|
|
3593
|
+
async function buildSnapshot(ctx) {
|
|
3594
|
+
const t0 = performance.now();
|
|
3595
|
+
const [run, findings, assetCount, registry] = await Promise.all([
|
|
3596
|
+
tryLoadRun(ctx),
|
|
3597
|
+
parseFindingsIndex(ctx),
|
|
3598
|
+
parseAssetsCount(ctx),
|
|
3599
|
+
loadTargetRegistry(ctx.projectDir).catch(() => null)
|
|
3600
|
+
]);
|
|
3601
|
+
const tData = (performance.now() - t0).toFixed(1);
|
|
3602
|
+
if (snapshotPerf) console.log(`[SESSION_SWITCH_PERF] snapshot data-load=${tData}ms (run+findings+assets+registry)`);
|
|
3603
|
+
const targets = [];
|
|
3604
|
+
if (registry !== null) for (const record of listTargetRecords(registry)) targets.push({
|
|
3605
|
+
id: record.id,
|
|
3606
|
+
target: record.target,
|
|
3607
|
+
canonicalTarget: record.canonicalTarget,
|
|
3608
|
+
rootSessionId: record.rootSessionId,
|
|
3609
|
+
status: "unknown",
|
|
3610
|
+
currentStage: null,
|
|
3611
|
+
createdAt: record.createdAt
|
|
3612
|
+
});
|
|
3613
|
+
if (run === null) return {
|
|
3614
|
+
run: null,
|
|
3615
|
+
stats: {
|
|
3616
|
+
assets: 0,
|
|
3617
|
+
findings: 0,
|
|
3618
|
+
delegations: 0
|
|
3619
|
+
},
|
|
3620
|
+
stages: STAGE_IDS.map((id) => ({
|
|
3621
|
+
id,
|
|
3622
|
+
name: STAGE_DEFINITIONS.find((d) => d.id === id)?.name ?? id,
|
|
3623
|
+
shortName: STAGE_SHORT[id] ?? id.slice(0, 4),
|
|
3624
|
+
status: "pending",
|
|
3625
|
+
delegationCount: 0,
|
|
3626
|
+
activeDelegationCount: 0
|
|
3627
|
+
})),
|
|
3628
|
+
delegations: [],
|
|
3629
|
+
findings,
|
|
3630
|
+
trace: {
|
|
3631
|
+
nodes: [{
|
|
3632
|
+
id: "start",
|
|
3633
|
+
kind: "start",
|
|
3634
|
+
label: "Start"
|
|
3635
|
+
}],
|
|
3636
|
+
edges: []
|
|
3637
|
+
},
|
|
3638
|
+
targets
|
|
3639
|
+
};
|
|
3640
|
+
const result = {
|
|
3641
|
+
run: buildRunView(run),
|
|
3642
|
+
stats: {
|
|
3643
|
+
assets: assetCount,
|
|
3644
|
+
findings: findings.length,
|
|
3645
|
+
delegations: run.delegations.length
|
|
3646
|
+
},
|
|
3647
|
+
stages: buildStageViews(run),
|
|
3648
|
+
delegations: buildDelegationViews(run),
|
|
3649
|
+
findings,
|
|
3650
|
+
trace: buildTraceModel(run, ctx, findings),
|
|
3651
|
+
targets
|
|
3652
|
+
};
|
|
3653
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
3654
|
+
if (snapshotPerf) console.log(`[SESSION_SWITCH_PERF] snapshot projection=${(Number(totalMs) - Number(tData)).toFixed(1)}ms total=${totalMs}ms`);
|
|
3655
|
+
return result;
|
|
3656
|
+
}
|
|
3657
|
+
const outputCache = /* @__PURE__ */ new Map();
|
|
3658
|
+
const OUTPUT_CACHE_TTL = 1e4;
|
|
3659
|
+
const outputInflight = /* @__PURE__ */ new Map();
|
|
3660
|
+
const FS_SEMAPHORE = 16;
|
|
3661
|
+
let fsInFlight = 0;
|
|
3662
|
+
const fsWaiters = [];
|
|
3663
|
+
function fsAcquire() {
|
|
3664
|
+
if (fsInFlight < FS_SEMAPHORE) {
|
|
3665
|
+
fsInFlight++;
|
|
3666
|
+
return Promise.resolve();
|
|
3667
|
+
}
|
|
3668
|
+
return new Promise((resolve) => {
|
|
3669
|
+
fsWaiters.push(resolve);
|
|
3670
|
+
});
|
|
3671
|
+
}
|
|
3672
|
+
function fsRelease() {
|
|
3673
|
+
fsInFlight--;
|
|
3674
|
+
const next = fsWaiters.shift();
|
|
3675
|
+
if (next !== void 0) {
|
|
3676
|
+
fsInFlight++;
|
|
3677
|
+
next();
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
async function buildOutputTreeForTarget(ctx, targetId, signal) {
|
|
3681
|
+
let targetCtx = ctx;
|
|
3682
|
+
if (targetId !== void 0 && targetId !== ctx.targetId) {
|
|
3683
|
+
const record = (await loadTargetRegistry(ctx.projectDir)).targets.find((t) => t.id === targetId);
|
|
3684
|
+
if (record === void 0) throw new Error("target not found");
|
|
3685
|
+
targetCtx = buildTargetContext(ctx.projectDir, record);
|
|
3686
|
+
}
|
|
3687
|
+
const cacheKey = targetCtx.targetId;
|
|
3688
|
+
const cached = outputCache.get(cacheKey);
|
|
3689
|
+
if (cached !== void 0 && Date.now() - cached.builtAt < OUTPUT_CACHE_TTL) {
|
|
3690
|
+
console.log(`[OUTPUT_SCAN] cache=hit targetId=${cacheKey}`);
|
|
3691
|
+
return {
|
|
3692
|
+
targetId: cacheKey,
|
|
3693
|
+
target: targetCtx.target,
|
|
3694
|
+
current: cacheKey === ctx.targetId,
|
|
3695
|
+
children: cached.tree
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3698
|
+
const inflight = outputInflight.get(cacheKey);
|
|
3699
|
+
if (inflight !== void 0) {
|
|
3700
|
+
console.log(`[OUTPUT_SCAN] inflight=dedup targetId=${cacheKey}`);
|
|
3701
|
+
const tree = await inflight;
|
|
3702
|
+
return {
|
|
3703
|
+
targetId: cacheKey,
|
|
3704
|
+
target: targetCtx.target,
|
|
3705
|
+
current: cacheKey === ctx.targetId,
|
|
3706
|
+
children: tree
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
console.log(`[OUTPUT_SCAN] cache=miss targetId=${cacheKey}`);
|
|
3710
|
+
const promise = buildOutputTree(targetCtx, signal);
|
|
3711
|
+
outputInflight.set(cacheKey, promise);
|
|
3712
|
+
try {
|
|
3713
|
+
const tree = await promise;
|
|
3714
|
+
outputCache.set(cacheKey, {
|
|
3715
|
+
tree,
|
|
3716
|
+
builtAt: Date.now()
|
|
3717
|
+
});
|
|
3718
|
+
return {
|
|
3719
|
+
targetId: cacheKey,
|
|
3720
|
+
target: targetCtx.target,
|
|
3721
|
+
current: cacheKey === ctx.targetId,
|
|
3722
|
+
children: tree
|
|
3723
|
+
};
|
|
3724
|
+
} finally {
|
|
3725
|
+
outputInflight.delete(cacheKey);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
async function buildOutputTree(ctx, signal) {
|
|
3729
|
+
const root = ctx.targetRoot;
|
|
3730
|
+
if (!existsSync(root)) return [];
|
|
3731
|
+
const entries = [];
|
|
3732
|
+
const stats = {
|
|
3733
|
+
dirs: 0,
|
|
3734
|
+
files: 0,
|
|
3735
|
+
statCalls: 0,
|
|
3736
|
+
readdirCalls: 0,
|
|
3737
|
+
maxDepth: 0,
|
|
3738
|
+
maxDirEntries: 0
|
|
3739
|
+
};
|
|
3740
|
+
const categoryTimings = {};
|
|
3741
|
+
const categoryCounts = {};
|
|
3742
|
+
for (const cat of [
|
|
3743
|
+
{
|
|
3744
|
+
dir: "report",
|
|
3745
|
+
label: "Reports",
|
|
3746
|
+
category: "report"
|
|
3747
|
+
},
|
|
3748
|
+
{
|
|
3749
|
+
dir: "findings",
|
|
3750
|
+
label: "Findings",
|
|
3751
|
+
category: "finding"
|
|
3752
|
+
},
|
|
3753
|
+
{
|
|
3754
|
+
dir: "assets",
|
|
3755
|
+
label: "Artifacts",
|
|
3756
|
+
category: "artifact"
|
|
3757
|
+
},
|
|
3758
|
+
{
|
|
3759
|
+
dir: "evidence",
|
|
3760
|
+
label: "Evidence",
|
|
3761
|
+
category: "evidence"
|
|
3762
|
+
}
|
|
3763
|
+
]) {
|
|
3764
|
+
signal?.throwIfAborted();
|
|
3765
|
+
const catDir = join(root, cat.dir);
|
|
3766
|
+
if (!existsSync(catDir)) continue;
|
|
3767
|
+
const tCat = performance.now();
|
|
3768
|
+
const children = await scanDir(catDir, root, cat.category, 0, stats, signal);
|
|
3769
|
+
const catMs = performance.now() - tCat;
|
|
3770
|
+
categoryTimings[cat.dir] = catMs;
|
|
3771
|
+
categoryCounts[cat.dir] = children.length;
|
|
3772
|
+
if (children.length > 0) entries.push({
|
|
3773
|
+
path: cat.dir,
|
|
3774
|
+
name: cat.label,
|
|
3775
|
+
kind: "directory",
|
|
3776
|
+
category: cat.category,
|
|
3777
|
+
children: flattenSingleChildDirs(children)
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
const stagesDir = join(root, "stages");
|
|
3781
|
+
if (existsSync(stagesDir)) {
|
|
3782
|
+
const tStages = performance.now();
|
|
3783
|
+
let stageEntryCount = 0;
|
|
3784
|
+
for (let i = 0; i < STAGE_IDS.length; i++) {
|
|
3785
|
+
signal?.throwIfAborted();
|
|
3786
|
+
const stageId = STAGE_IDS[i];
|
|
3787
|
+
const stageSubDir = join(stagesDir, `${String(i + 1).padStart(2, "0")}-${stageId}`);
|
|
3788
|
+
if (!existsSync(stageSubDir)) continue;
|
|
3789
|
+
const children = [];
|
|
3790
|
+
const summaryFile = join(stageSubDir, "summary.md");
|
|
3791
|
+
if (existsSync(summaryFile)) {
|
|
3792
|
+
stats.statCalls++;
|
|
3793
|
+
const st = await stat(summaryFile);
|
|
3794
|
+
children.push({
|
|
3795
|
+
path: relative(root, summaryFile),
|
|
3796
|
+
name: "summary.md",
|
|
3797
|
+
kind: "file",
|
|
3798
|
+
size: st.size,
|
|
3799
|
+
mtime: st.mtime.toISOString(),
|
|
3800
|
+
category: "stage",
|
|
3801
|
+
sourceStage: stageId
|
|
3802
|
+
});
|
|
3803
|
+
}
|
|
3804
|
+
const delegationsDir = join(stageSubDir, "delegations");
|
|
3805
|
+
if (existsSync(delegationsDir)) {
|
|
3806
|
+
stats.readdirCalls++;
|
|
3807
|
+
const delDirs = await readdir(delegationsDir, { withFileTypes: true });
|
|
3808
|
+
for (const delDir of delDirs) {
|
|
3809
|
+
if (!delDir.isDirectory()) continue;
|
|
3810
|
+
const delId = delDir.name;
|
|
3811
|
+
const resultFile = join(delegationsDir, delId, "result.md");
|
|
3812
|
+
if (existsSync(resultFile)) {
|
|
3813
|
+
stats.statCalls++;
|
|
3814
|
+
const st = await stat(resultFile);
|
|
3815
|
+
children.push({
|
|
3816
|
+
path: relative(root, resultFile),
|
|
3817
|
+
name: `${delId}/result.md`,
|
|
3818
|
+
kind: "file",
|
|
3819
|
+
size: st.size,
|
|
3820
|
+
mtime: st.mtime.toISOString(),
|
|
3821
|
+
category: "stage",
|
|
3822
|
+
sourceStage: stageId,
|
|
3823
|
+
sourceDelegationId: delId
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
const evidenceDir = join(delegationsDir, delId, "evidence");
|
|
3827
|
+
if (existsSync(evidenceDir)) children.push(...flattenSingleChildDirs(await scanDir(evidenceDir, root, "evidence", 0, stats, signal, stageId, delId)));
|
|
3828
|
+
const artifactsDir = join(delegationsDir, delId, "artifacts");
|
|
3829
|
+
if (existsSync(artifactsDir)) children.push(...flattenSingleChildDirs(await scanDir(artifactsDir, root, "artifact", 0, stats, signal, stageId, delId)));
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
if (children.length > 0) {
|
|
3833
|
+
stageEntryCount += children.length;
|
|
3834
|
+
entries.push({
|
|
3835
|
+
path: relative(root, stageSubDir),
|
|
3836
|
+
name: STAGE_SHORT[stageId] ?? stageId,
|
|
3837
|
+
kind: "directory",
|
|
3838
|
+
category: "stage",
|
|
3839
|
+
children: flattenSingleChildDirs(children)
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
categoryTimings["stages"] = performance.now() - tStages;
|
|
3844
|
+
categoryCounts["stages"] = stageEntryCount;
|
|
3845
|
+
}
|
|
3846
|
+
const totalMs = Object.values(categoryTimings).reduce((a, b) => a + b, 0);
|
|
3847
|
+
const catReport = Object.entries(categoryTimings).map(([cat, ms]) => `${cat}=${ms.toFixed(0)}ms entries=${categoryCounts[cat] ?? 0}`).join("\n");
|
|
3848
|
+
const totalEntries = Object.values(categoryCounts).reduce((a, b) => a + b, 0);
|
|
3849
|
+
console.log(`[OUTPUT_SCAN]\n${catReport}\nstages=${categoryTimings["stages"]?.toFixed(0) ?? "0"}ms entries=${categoryCounts["stages"] ?? 0}\ntotal=${totalMs.toFixed(0)}ms entries=${totalEntries}\ndirs=${stats.dirs} files=${stats.files} stat=${stats.statCalls} readdir=${stats.readdirCalls} maxDepth=${stats.maxDepth} maxDirEntries=${stats.maxDirEntries}`);
|
|
3850
|
+
return entries;
|
|
3851
|
+
}
|
|
3852
|
+
const SCAN_CONCURRENCY = 16;
|
|
3853
|
+
async function scanDir(dir, root, parentCategory, depth = 0, stats, signal, sourceStage, sourceDelegationId) {
|
|
3854
|
+
if (depth > 3) return [];
|
|
3855
|
+
signal?.throwIfAborted();
|
|
3856
|
+
try {
|
|
3857
|
+
if (stats) {
|
|
3858
|
+
stats.dirs++;
|
|
3859
|
+
if (depth > stats.maxDepth) stats.maxDepth = depth;
|
|
3860
|
+
}
|
|
3861
|
+
await fsAcquire();
|
|
3862
|
+
if (stats) stats.readdirCalls++;
|
|
3863
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
3864
|
+
fsRelease();
|
|
3865
|
+
if (stats && items.length > stats.maxDirEntries) stats.maxDirEntries = items.length;
|
|
3866
|
+
const filtered = items.filter((i) => !i.name.startsWith(".") && i.name !== "tmp" && i.name !== "cache" && i.name !== ".dsh-pentester" && i.name !== ".git");
|
|
3867
|
+
const entries = [];
|
|
3868
|
+
for (let batch = 0; batch < filtered.length; batch += SCAN_CONCURRENCY) {
|
|
3869
|
+
const batchItems = filtered.slice(batch, batch + SCAN_CONCURRENCY);
|
|
3870
|
+
const results = await Promise.all(batchItems.map(async (item) => {
|
|
3871
|
+
signal?.throwIfAborted();
|
|
3872
|
+
const fullPath = join(dir, item.name);
|
|
3873
|
+
const relPath = relative(root, fullPath);
|
|
3874
|
+
if (item.isDirectory()) {
|
|
3875
|
+
const children = await scanDir(fullPath, root, parentCategory, depth + 1, stats, signal, sourceStage, sourceDelegationId);
|
|
3876
|
+
if (children.length === 0) return null;
|
|
3877
|
+
return {
|
|
3878
|
+
path: relPath,
|
|
3879
|
+
name: item.name,
|
|
3880
|
+
kind: "directory",
|
|
3881
|
+
category: parentCategory,
|
|
3882
|
+
...sourceStage !== void 0 ? { sourceStage } : {},
|
|
3883
|
+
...sourceDelegationId !== void 0 ? { sourceDelegationId } : {},
|
|
3884
|
+
children
|
|
3885
|
+
};
|
|
3886
|
+
}
|
|
3887
|
+
try {
|
|
3888
|
+
if (stats) {
|
|
3889
|
+
stats.files++;
|
|
3890
|
+
stats.statCalls++;
|
|
3891
|
+
}
|
|
3892
|
+
await fsAcquire();
|
|
3893
|
+
const st = await stat(fullPath);
|
|
3894
|
+
fsRelease();
|
|
3895
|
+
return {
|
|
3896
|
+
path: relPath,
|
|
3897
|
+
name: item.name,
|
|
3898
|
+
kind: "file",
|
|
3899
|
+
size: st.size,
|
|
3900
|
+
mtime: st.mtime.toISOString(),
|
|
3901
|
+
category: parentCategory,
|
|
3902
|
+
...sourceStage !== void 0 ? { sourceStage } : {},
|
|
3903
|
+
...sourceDelegationId !== void 0 ? { sourceDelegationId } : {}
|
|
3904
|
+
};
|
|
3905
|
+
} catch {
|
|
3906
|
+
return null;
|
|
3907
|
+
}
|
|
3908
|
+
}));
|
|
3909
|
+
for (const r of results) if (r !== null) entries.push(r);
|
|
3910
|
+
}
|
|
3911
|
+
return entries.sort((a, b) => a.kind !== b.kind ? a.kind === "directory" ? -1 : 1 : a.name.localeCompare(b.name));
|
|
3912
|
+
} catch {
|
|
3913
|
+
return [];
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
function flattenSingleChildDirs(entries) {
|
|
3917
|
+
const result = [];
|
|
3918
|
+
for (const entry of entries) {
|
|
3919
|
+
if (entry.kind !== "directory" || entry.children === void 0 || entry.children.length !== 1 || entry.children[0].kind !== "directory") {
|
|
3920
|
+
result.push(entry);
|
|
3921
|
+
continue;
|
|
3922
|
+
}
|
|
3923
|
+
const child = entry.children[0];
|
|
3924
|
+
result.push({
|
|
3925
|
+
path: child.path,
|
|
3926
|
+
name: `${entry.name}/${child.name}`,
|
|
3927
|
+
kind: "directory",
|
|
3928
|
+
category: entry.category,
|
|
3929
|
+
...entry.sourceStage !== void 0 ? { sourceStage: entry.sourceStage } : {},
|
|
3930
|
+
...entry.sourceDelegationId !== void 0 ? { sourceDelegationId: entry.sourceDelegationId } : {},
|
|
3931
|
+
children: child.children !== void 0 ? flattenSingleChildDirs(child.children) : void 0
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
return result;
|
|
3935
|
+
}
|
|
3936
|
+
const MAX_PREVIEW = 1048576;
|
|
3937
|
+
async function readOutputFile(ctx, relativePath) {
|
|
3938
|
+
if (relativePath.includes("..") || relativePath.startsWith("/") || isAbsolute(relativePath) || relativePath.includes("\0")) return {
|
|
3939
|
+
ok: false,
|
|
3940
|
+
error: "path denied"
|
|
3941
|
+
};
|
|
3942
|
+
const root = ctx.targetRoot;
|
|
3943
|
+
const fullPath = join(root, relativePath);
|
|
3944
|
+
const { realpathSync } = await import("node:fs");
|
|
3945
|
+
let realRoot, realFile;
|
|
3946
|
+
try {
|
|
3947
|
+
realRoot = realpathSync(root);
|
|
3948
|
+
realFile = realpathSync(fullPath);
|
|
3949
|
+
} catch {
|
|
3950
|
+
return {
|
|
3951
|
+
ok: false,
|
|
3952
|
+
error: "file not found"
|
|
3953
|
+
};
|
|
3954
|
+
}
|
|
3955
|
+
if (!realFile.startsWith(realRoot + sep) && realFile !== realRoot) return {
|
|
3956
|
+
ok: false,
|
|
3957
|
+
error: "path escape denied"
|
|
3958
|
+
};
|
|
3959
|
+
try {
|
|
3960
|
+
const st = await stat(fullPath);
|
|
3961
|
+
if (st.isDirectory()) return {
|
|
3962
|
+
ok: false,
|
|
3963
|
+
error: "is a directory"
|
|
3964
|
+
};
|
|
3965
|
+
const ext = relativePath.split(".").pop()?.toLowerCase() ?? "";
|
|
3966
|
+
if ((/* @__PURE__ */ new Set([
|
|
3967
|
+
"png",
|
|
3968
|
+
"jpg",
|
|
3969
|
+
"jpeg",
|
|
3970
|
+
"gif",
|
|
3971
|
+
"webp",
|
|
3972
|
+
"bmp",
|
|
3973
|
+
"ico",
|
|
3974
|
+
"pdf",
|
|
3975
|
+
"zip",
|
|
3976
|
+
"gz",
|
|
3977
|
+
"tar",
|
|
3978
|
+
"pcap",
|
|
3979
|
+
"mitm",
|
|
3980
|
+
"bin",
|
|
3981
|
+
"exe",
|
|
3982
|
+
"dll",
|
|
3983
|
+
"so"
|
|
3984
|
+
])).has(ext)) return {
|
|
3985
|
+
ok: true,
|
|
3986
|
+
content: `[Binary file: ${ext.toUpperCase()} — ${(st.size / 1024).toFixed(1)} KB]`,
|
|
3987
|
+
mime: "binary",
|
|
3988
|
+
size: st.size,
|
|
3989
|
+
mtime: st.mtime.toISOString(),
|
|
3990
|
+
name: relativePath.split("/").pop() ?? relativePath
|
|
3991
|
+
};
|
|
3992
|
+
const fh = await open(fullPath, "r");
|
|
3993
|
+
const buf = Buffer.alloc(Math.min(st.size, MAX_PREVIEW));
|
|
3994
|
+
await fh.read(buf, 0, buf.length, 0);
|
|
3995
|
+
await fh.close();
|
|
3996
|
+
let content = buf.toString("utf8");
|
|
3997
|
+
if (st.size > MAX_PREVIEW) content += `\n\n... [truncated: ${(st.size / 1024).toFixed(1)} KB total]`;
|
|
3998
|
+
let mime = "text";
|
|
3999
|
+
if (ext === "md") mime = "markdown";
|
|
4000
|
+
else if (ext === "json") mime = "json";
|
|
4001
|
+
return {
|
|
4002
|
+
ok: true,
|
|
4003
|
+
content,
|
|
4004
|
+
mime,
|
|
4005
|
+
size: st.size,
|
|
4006
|
+
mtime: st.mtime.toISOString(),
|
|
4007
|
+
name: relativePath.split("/").pop() ?? relativePath
|
|
4008
|
+
};
|
|
4009
|
+
} catch (error) {
|
|
4010
|
+
return {
|
|
4011
|
+
ok: false,
|
|
4012
|
+
error: error instanceof Error ? error.message : "read error"
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
//#endregion
|
|
4017
|
+
//#region src/ui-view/binding.ts
|
|
4018
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4019
|
+
async function resolveBinding(deps, sessionId) {
|
|
4020
|
+
const t0 = performance.now();
|
|
4021
|
+
const perf = deps.verbose || typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
|
|
4022
|
+
const cached = cache.get(sessionId);
|
|
4023
|
+
if (cached !== void 0) {
|
|
4024
|
+
if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=cache total=${(performance.now() - t0).toFixed(1)}ms`);
|
|
4025
|
+
return cached;
|
|
4026
|
+
}
|
|
4027
|
+
const tLive = performance.now();
|
|
4028
|
+
const projectDir = deps.getLiveSessionCwd(sessionId);
|
|
4029
|
+
const liveMs = (performance.now() - tLive).toFixed(1);
|
|
4030
|
+
let source = "live";
|
|
4031
|
+
let resolvedDir = projectDir;
|
|
4032
|
+
if (resolvedDir === void 0) {
|
|
4033
|
+
const tWs = performance.now();
|
|
4034
|
+
resolvedDir = deps.getWorkspaceForSession(sessionId);
|
|
4035
|
+
const wsMs = (performance.now() - tWs).toFixed(1);
|
|
4036
|
+
source = "workspace-index";
|
|
4037
|
+
if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} workspaceLookup=${wsMs}ms`);
|
|
4038
|
+
}
|
|
4039
|
+
if (resolvedDir === void 0 || resolvedDir === "") {
|
|
4040
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
4041
|
+
if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=${source} result=unbound total=${totalMs}ms`);
|
|
4042
|
+
return {
|
|
4043
|
+
kind: "unbound",
|
|
4044
|
+
projectDir: ""
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
4047
|
+
const tTarget = performance.now();
|
|
4048
|
+
const targetCtx = await resolveSessionTargetContext(resolvedDir, sessionId);
|
|
4049
|
+
const targetMs = (performance.now() - tTarget).toFixed(1);
|
|
4050
|
+
if (targetCtx !== void 0) {
|
|
4051
|
+
const result = {
|
|
4052
|
+
kind: "bound",
|
|
4053
|
+
projectDir: resolvedDir,
|
|
4054
|
+
targetContext: targetCtx
|
|
4055
|
+
};
|
|
4056
|
+
cache.set(sessionId, result);
|
|
4057
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
4058
|
+
if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=${source} cwd=${resolvedDir} liveLookup=${liveMs}ms targetLookup=${targetMs}ms total=${totalMs}ms`);
|
|
4059
|
+
if (Number(totalMs) > 100) console.warn(`[SESSION_SWITCH_PERF] slow binding ${totalMs}ms source=${source}`);
|
|
4060
|
+
return result;
|
|
4061
|
+
}
|
|
4062
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
4063
|
+
if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=${source} result=target-unresolved total=${totalMs}ms`);
|
|
4064
|
+
return {
|
|
4065
|
+
kind: "unbound",
|
|
4066
|
+
projectDir: resolvedDir
|
|
4067
|
+
};
|
|
4068
|
+
}
|
|
4069
|
+
//#endregion
|
|
4070
|
+
//#region src/build-id.ts
|
|
4071
|
+
/**
|
|
4072
|
+
* build-id.ts — Build identity injected at build time.
|
|
4073
|
+
*
|
|
4074
|
+
* Generated by scripts/build-id.mjs before each build.
|
|
4075
|
+
* Both host (tsdown) and client (esbuild) import this module.
|
|
4076
|
+
*
|
|
4077
|
+
* Format: version+shortSha (e.g. "1.1.0+391bfd6")
|
|
4078
|
+
*/
|
|
4079
|
+
const BUILD_ID = "2.2.0+478786d";
|
|
4080
|
+
//#endregion
|
|
4081
|
+
//#region src/ui-view/invocations.ts
|
|
4082
|
+
/**
|
|
4083
|
+
* ui-view/invocations.ts — Pentester View 的 Typert 调用描述符与 schemas。
|
|
4084
|
+
*
|
|
4085
|
+
* 仅包含纯数据常量:客户端和宿主两侧均可安全导入,无 Node.js 依赖。
|
|
4086
|
+
*/
|
|
4087
|
+
const pentesterViewInputSchema$1 = z.discriminatedUnion("kind", [
|
|
4088
|
+
z.object({ kind: z.literal("health") }),
|
|
4089
|
+
z.object({
|
|
4090
|
+
kind: z.literal("snapshot"),
|
|
4091
|
+
sessionId: z.string()
|
|
4092
|
+
}),
|
|
4093
|
+
z.object({
|
|
4094
|
+
kind: z.literal("listOutput"),
|
|
4095
|
+
sessionId: z.string(),
|
|
4096
|
+
targetId: z.string().optional()
|
|
4097
|
+
}),
|
|
4098
|
+
z.object({
|
|
4099
|
+
kind: z.literal("readOutput"),
|
|
4100
|
+
sessionId: z.string(),
|
|
4101
|
+
targetId: z.string().optional(),
|
|
4102
|
+
path: z.string()
|
|
4103
|
+
}),
|
|
4104
|
+
z.object({
|
|
4105
|
+
kind: z.literal("readFinding"),
|
|
4106
|
+
sessionId: z.string(),
|
|
4107
|
+
detailFile: z.string()
|
|
4108
|
+
})
|
|
4109
|
+
]);
|
|
4110
|
+
const snapshotResultSchema = z.object({
|
|
4111
|
+
run: z.union([z.object({
|
|
4112
|
+
id: z.string(),
|
|
4113
|
+
status: z.enum(["active", "completed"]),
|
|
4114
|
+
currentStage: z.string().nullable(),
|
|
4115
|
+
targets: z.array(z.string()),
|
|
4116
|
+
createdAt: z.string()
|
|
4117
|
+
}), z.null()]),
|
|
4118
|
+
stats: z.object({
|
|
4119
|
+
assets: z.number(),
|
|
4120
|
+
findings: z.number(),
|
|
4121
|
+
delegations: z.number()
|
|
4122
|
+
}),
|
|
4123
|
+
stages: z.array(z.object({
|
|
4124
|
+
id: z.string(),
|
|
4125
|
+
name: z.string(),
|
|
4126
|
+
shortName: z.string(),
|
|
4127
|
+
status: z.string(),
|
|
4128
|
+
delegationCount: z.number(),
|
|
4129
|
+
activeDelegationCount: z.number()
|
|
4130
|
+
})),
|
|
4131
|
+
delegations: z.array(z.object({
|
|
4132
|
+
id: z.string(),
|
|
4133
|
+
stageId: z.string(),
|
|
4134
|
+
agentId: z.string(),
|
|
4135
|
+
sessionId: z.string().optional(),
|
|
4136
|
+
objective: z.string(),
|
|
4137
|
+
status: z.string(),
|
|
4138
|
+
createdAt: z.string(),
|
|
4139
|
+
finishedAt: z.string().optional()
|
|
4140
|
+
})),
|
|
4141
|
+
findings: z.array(z.object({
|
|
4142
|
+
id: z.string(),
|
|
4143
|
+
title: z.string(),
|
|
4144
|
+
severity: z.string(),
|
|
4145
|
+
status: z.string().optional(),
|
|
4146
|
+
sourceStage: z.string(),
|
|
4147
|
+
sourceDelegationId: z.string(),
|
|
4148
|
+
detailFile: z.string(),
|
|
4149
|
+
affectedAsset: z.string().optional(),
|
|
4150
|
+
description: z.string().optional(),
|
|
4151
|
+
updatedAt: z.string()
|
|
4152
|
+
})),
|
|
4153
|
+
trace: z.object({
|
|
4154
|
+
nodes: z.array(z.object({
|
|
4155
|
+
id: z.string(),
|
|
4156
|
+
kind: z.string(),
|
|
4157
|
+
label: z.string(),
|
|
4158
|
+
status: z.string().optional(),
|
|
4159
|
+
sublabel: z.string().optional(),
|
|
4160
|
+
timestamp: z.string().optional()
|
|
4161
|
+
})),
|
|
4162
|
+
edges: z.array(z.object({
|
|
4163
|
+
id: z.string(),
|
|
4164
|
+
source: z.string(),
|
|
4165
|
+
target: z.string(),
|
|
4166
|
+
kind: z.string(),
|
|
4167
|
+
label: z.string().optional()
|
|
4168
|
+
}))
|
|
4169
|
+
}),
|
|
4170
|
+
targets: z.array(z.object({
|
|
4171
|
+
id: z.string(),
|
|
4172
|
+
target: z.string(),
|
|
4173
|
+
canonicalTarget: z.string(),
|
|
4174
|
+
rootSessionId: z.string().nullable(),
|
|
4175
|
+
status: z.string(),
|
|
4176
|
+
currentStage: z.string().nullable(),
|
|
4177
|
+
createdAt: z.string()
|
|
4178
|
+
}))
|
|
4179
|
+
});
|
|
4180
|
+
const unboundResultSchema = z.object({
|
|
4181
|
+
kind: z.literal("unbound"),
|
|
4182
|
+
targets: z.array(z.object({
|
|
4183
|
+
id: z.string(),
|
|
4184
|
+
target: z.string(),
|
|
4185
|
+
canonicalTarget: z.string(),
|
|
4186
|
+
rootSessionId: z.string().nullable(),
|
|
4187
|
+
status: z.string(),
|
|
4188
|
+
currentStage: z.string().nullable(),
|
|
4189
|
+
createdAt: z.string()
|
|
4190
|
+
}))
|
|
4191
|
+
});
|
|
4192
|
+
const outputFileResultSchema = z.object({
|
|
4193
|
+
path: z.string(),
|
|
4194
|
+
name: z.string(),
|
|
4195
|
+
size: z.number(),
|
|
4196
|
+
mtime: z.string(),
|
|
4197
|
+
mime: z.enum([
|
|
4198
|
+
"text",
|
|
4199
|
+
"markdown",
|
|
4200
|
+
"json",
|
|
4201
|
+
"image",
|
|
4202
|
+
"binary"
|
|
4203
|
+
]),
|
|
4204
|
+
content: z.string().optional()
|
|
4205
|
+
});
|
|
4206
|
+
const outputTreeResultSchema = z.object({
|
|
4207
|
+
targetId: z.string(),
|
|
4208
|
+
target: z.string(),
|
|
4209
|
+
current: z.boolean(),
|
|
4210
|
+
children: z.array(z.unknown())
|
|
4211
|
+
});
|
|
4212
|
+
/** 客户端 remote 贡献的 invocation 描述符 */
|
|
4213
|
+
const PENTESTER_VIEW_INVOCATION = {
|
|
4214
|
+
id: "dsh-pentester#pentesterView/command",
|
|
4215
|
+
service: "pentesterView",
|
|
4216
|
+
namespace: "pentesterView",
|
|
4217
|
+
method: "command",
|
|
4218
|
+
invocation: { kind: "direct" },
|
|
4219
|
+
cancellation: { parameter: "signal" },
|
|
4220
|
+
parameters: [{
|
|
4221
|
+
name: "input",
|
|
4222
|
+
wire: "input",
|
|
4223
|
+
source: "json",
|
|
4224
|
+
codec: {
|
|
4225
|
+
mode: "strict",
|
|
4226
|
+
typeSymbol: "dsh-pentester#PentesterViewInput",
|
|
4227
|
+
schema: pentesterViewInputSchema$1
|
|
4228
|
+
}
|
|
4229
|
+
}],
|
|
4230
|
+
result: {
|
|
4231
|
+
mode: "strict",
|
|
4232
|
+
typeSymbol: "dsh-pentester#PentesterViewResult",
|
|
4233
|
+
schema: z.object({
|
|
4234
|
+
ok: z.boolean(),
|
|
4235
|
+
value: z.union([
|
|
4236
|
+
snapshotResultSchema,
|
|
4237
|
+
outputTreeResultSchema,
|
|
4238
|
+
outputFileResultSchema,
|
|
4239
|
+
unboundResultSchema,
|
|
4240
|
+
z.object({
|
|
4241
|
+
ready: z.literal(true),
|
|
4242
|
+
buildId: z.string()
|
|
4243
|
+
})
|
|
4244
|
+
]).optional(),
|
|
4245
|
+
error: z.string().optional(),
|
|
4246
|
+
code: z.string().optional()
|
|
4247
|
+
})
|
|
4248
|
+
}
|
|
4249
|
+
};
|
|
4250
|
+
//#endregion
|
|
4251
|
+
//#region src/ui-view/rpc.ts
|
|
4252
|
+
const pentesterViewInputSchema = z.discriminatedUnion("kind", [
|
|
4253
|
+
z.object({ kind: z.literal("health") }),
|
|
4254
|
+
z.object({
|
|
4255
|
+
kind: z.literal("snapshot"),
|
|
4256
|
+
sessionId: z.string()
|
|
4257
|
+
}),
|
|
4258
|
+
z.object({
|
|
4259
|
+
kind: z.literal("listOutput"),
|
|
4260
|
+
sessionId: z.string(),
|
|
4261
|
+
targetId: z.string().optional()
|
|
4262
|
+
}),
|
|
4263
|
+
z.object({
|
|
4264
|
+
kind: z.literal("readOutput"),
|
|
4265
|
+
sessionId: z.string(),
|
|
4266
|
+
targetId: z.string().optional(),
|
|
4267
|
+
path: z.string()
|
|
4268
|
+
}),
|
|
4269
|
+
z.object({
|
|
4270
|
+
kind: z.literal("readFinding"),
|
|
4271
|
+
sessionId: z.string(),
|
|
4272
|
+
detailFile: z.string()
|
|
4273
|
+
})
|
|
4274
|
+
]);
|
|
4275
|
+
let requestSeq = 0;
|
|
4276
|
+
const HOST_PERF = typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
|
|
4277
|
+
var PentesterViewTypertService = class extends TypertRemoteService {
|
|
4278
|
+
deps;
|
|
4279
|
+
constructor(ctx, deps) {
|
|
4280
|
+
super(ctx, "pentesterView");
|
|
4281
|
+
this.deps = deps;
|
|
4282
|
+
}
|
|
4283
|
+
async command(input, signal) {
|
|
4284
|
+
const requestId = ++requestSeq;
|
|
4285
|
+
const t0 = performance.now();
|
|
4286
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} ENTER +0ms`);
|
|
4287
|
+
const parsed = pentesterViewInputSchema.safeParse(input);
|
|
4288
|
+
if (!parsed.success) {
|
|
4289
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} INVALID_REQUEST +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4290
|
+
return {
|
|
4291
|
+
ok: false,
|
|
4292
|
+
error: "invalid_request",
|
|
4293
|
+
code: "invalid_request"
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4296
|
+
const data = parsed.data;
|
|
4297
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} PARSED kind=${data.kind} +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4298
|
+
try {
|
|
4299
|
+
signal?.throwIfAborted();
|
|
4300
|
+
if (data.kind === "health") {
|
|
4301
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} HEALTH +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4302
|
+
return {
|
|
4303
|
+
ok: true,
|
|
4304
|
+
value: {
|
|
4305
|
+
ready: true,
|
|
4306
|
+
buildId: BUILD_ID
|
|
4307
|
+
}
|
|
4308
|
+
};
|
|
4309
|
+
}
|
|
4310
|
+
if (data.kind === "snapshot") {
|
|
4311
|
+
signal?.throwIfAborted();
|
|
4312
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} BINDING_START +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4313
|
+
const tResolve = performance.now();
|
|
4314
|
+
const resolution = await resolveBinding(this.deps, data.sessionId);
|
|
4315
|
+
const resolveMs = (performance.now() - tResolve).toFixed(1);
|
|
4316
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} BINDING_END kind=${resolution.kind} +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4317
|
+
if (resolution.kind === "bound") {
|
|
4318
|
+
signal?.throwIfAborted();
|
|
4319
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} SNAPSHOT_START +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4320
|
+
const tBuild = performance.now();
|
|
4321
|
+
const snapshot = await buildSnapshot(resolution.targetContext);
|
|
4322
|
+
const buildMs = (performance.now() - tBuild).toFixed(1);
|
|
4323
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} SNAPSHOT_END +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4324
|
+
signal?.throwIfAborted();
|
|
4325
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
4326
|
+
if (HOST_PERF) {
|
|
4327
|
+
const payload = JSON.stringify(snapshot).length;
|
|
4328
|
+
console.log(`[PENTESTER_RPC_HOST] #${requestId} RETURN binding=${resolveMs}ms build=${buildMs}ms total=${totalMs}ms payload=${(payload / 1024).toFixed(0)}KB`);
|
|
4329
|
+
}
|
|
4330
|
+
if (Number(totalMs) > 1e3) console.warn(`[PENTESTER_RPC_HOST] #${requestId} slow ${totalMs}ms`);
|
|
4331
|
+
return {
|
|
4332
|
+
ok: true,
|
|
4333
|
+
value: snapshot
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4336
|
+
if (resolution.projectDir !== "") return buildUnboundSnapshot(resolution.projectDir);
|
|
4337
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} RETURN unbound +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4338
|
+
return {
|
|
4339
|
+
ok: true,
|
|
4340
|
+
value: {
|
|
4341
|
+
kind: "unbound",
|
|
4342
|
+
targets: []
|
|
4343
|
+
}
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
4346
|
+
if (data.kind === "listOutput") {
|
|
4347
|
+
signal?.throwIfAborted();
|
|
4348
|
+
const tResolve = performance.now();
|
|
4349
|
+
console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} ENTER`);
|
|
4350
|
+
console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} BINDING_START`);
|
|
4351
|
+
const resolution = await resolveBinding(this.deps, data.sessionId);
|
|
4352
|
+
if (resolution.kind !== "bound") return {
|
|
4353
|
+
ok: false,
|
|
4354
|
+
error: "not bound",
|
|
4355
|
+
code: "no_binding"
|
|
4356
|
+
};
|
|
4357
|
+
console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} BINDING_END ${(performance.now() - tResolve).toFixed(1)}ms`);
|
|
4358
|
+
signal?.throwIfAborted();
|
|
4359
|
+
console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} TREE_START targetId=${data.targetId ?? "default"}`);
|
|
4360
|
+
const tTree = performance.now();
|
|
4361
|
+
const tree = await buildOutputTreeForTarget(resolution.targetContext, data.targetId, signal);
|
|
4362
|
+
const treeMs = (performance.now() - tTree).toFixed(1);
|
|
4363
|
+
console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} TREE_END ${treeMs}ms`);
|
|
4364
|
+
signal?.throwIfAborted();
|
|
4365
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
4366
|
+
const payload = JSON.stringify(tree).length;
|
|
4367
|
+
console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} RETURN total=${totalMs}ms entryCount=${tree.children.length} payload=${(payload / 1024).toFixed(0)}KB`);
|
|
4368
|
+
return {
|
|
4369
|
+
ok: true,
|
|
4370
|
+
value: tree
|
|
4371
|
+
};
|
|
4372
|
+
}
|
|
4373
|
+
if (data.kind === "readOutput") {
|
|
4374
|
+
signal?.throwIfAborted();
|
|
4375
|
+
const resolution = await resolveBinding(this.deps, data.sessionId);
|
|
4376
|
+
if (resolution.kind !== "bound") return {
|
|
4377
|
+
ok: false,
|
|
4378
|
+
error: "not bound",
|
|
4379
|
+
code: "no_binding"
|
|
4380
|
+
};
|
|
4381
|
+
const path = data.path;
|
|
4382
|
+
if (path === "") return {
|
|
4383
|
+
ok: false,
|
|
4384
|
+
error: "path required",
|
|
4385
|
+
code: "invalid_request"
|
|
4386
|
+
};
|
|
4387
|
+
let targetCtx = resolution.targetContext;
|
|
4388
|
+
if (data.targetId !== void 0 && data.targetId !== resolution.targetContext.targetId) {
|
|
4389
|
+
const record = (await loadTargetRegistry(resolution.projectDir)).targets.find((t) => t.id === data.targetId);
|
|
4390
|
+
if (record === void 0) return {
|
|
4391
|
+
ok: false,
|
|
4392
|
+
error: "target not found",
|
|
4393
|
+
code: "invalid_target"
|
|
4394
|
+
};
|
|
4395
|
+
targetCtx = buildTargetContext(resolution.projectDir, record);
|
|
4396
|
+
}
|
|
4397
|
+
const result = await readOutputFile(targetCtx, path);
|
|
4398
|
+
if (!result.ok) return {
|
|
4399
|
+
ok: false,
|
|
4400
|
+
error: result.error,
|
|
4401
|
+
code: "read_error"
|
|
4402
|
+
};
|
|
4403
|
+
return {
|
|
4404
|
+
ok: true,
|
|
4405
|
+
value: {
|
|
4406
|
+
path,
|
|
4407
|
+
name: result.name,
|
|
4408
|
+
size: result.size,
|
|
4409
|
+
mtime: result.mtime,
|
|
4410
|
+
mime: result.mime,
|
|
4411
|
+
content: result.content
|
|
4412
|
+
}
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
4415
|
+
if (data.kind === "readFinding") {
|
|
4416
|
+
signal?.throwIfAborted();
|
|
4417
|
+
const resolution = await resolveBinding(this.deps, data.sessionId);
|
|
4418
|
+
if (resolution.kind !== "bound") return {
|
|
4419
|
+
ok: false,
|
|
4420
|
+
error: "not bound",
|
|
4421
|
+
code: "no_binding"
|
|
4422
|
+
};
|
|
4423
|
+
const detailFile = data.detailFile;
|
|
4424
|
+
if (detailFile === "") return {
|
|
4425
|
+
ok: false,
|
|
4426
|
+
error: "detailFile required",
|
|
4427
|
+
code: "invalid_request"
|
|
4428
|
+
};
|
|
4429
|
+
const result = await readOutputFile(resolution.targetContext, `findings/${detailFile}`);
|
|
4430
|
+
if (!result.ok) return {
|
|
4431
|
+
ok: false,
|
|
4432
|
+
error: result.error,
|
|
4433
|
+
code: "read_error"
|
|
4434
|
+
};
|
|
4435
|
+
return {
|
|
4436
|
+
ok: true,
|
|
4437
|
+
value: {
|
|
4438
|
+
path: `findings/${detailFile}`,
|
|
4439
|
+
name: detailFile,
|
|
4440
|
+
size: result.size,
|
|
4441
|
+
mtime: result.mtime,
|
|
4442
|
+
mime: "markdown",
|
|
4443
|
+
content: result.content
|
|
4444
|
+
}
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
4447
|
+
return {
|
|
4448
|
+
ok: false,
|
|
4449
|
+
error: "unknown kind",
|
|
4450
|
+
code: "invalid_request"
|
|
4451
|
+
};
|
|
4452
|
+
} catch (error) {
|
|
4453
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4454
|
+
if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} ABORTED +${(performance.now() - t0).toFixed(1)}ms`);
|
|
4455
|
+
return {
|
|
4456
|
+
ok: false,
|
|
4457
|
+
error: "aborted",
|
|
4458
|
+
code: "aborted"
|
|
4459
|
+
};
|
|
4460
|
+
}
|
|
4461
|
+
const totalMs = (performance.now() - t0).toFixed(1);
|
|
4462
|
+
if (HOST_PERF) console.error(`[PENTESTER_RPC_HOST] #${requestId} FAILED kind=${data.kind} +${totalMs}ms: ${error instanceof Error ? error.message : "error"}`);
|
|
4463
|
+
return {
|
|
4464
|
+
ok: false,
|
|
4465
|
+
error: error instanceof Error ? error.message : "failed",
|
|
4466
|
+
code: "snapshot_error"
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
};
|
|
4471
|
+
async function buildUnboundSnapshot(projectDir) {
|
|
4472
|
+
const registry = await loadTargetRegistry(projectDir);
|
|
4473
|
+
const targets = [];
|
|
4474
|
+
for (const record of registry.targets) {
|
|
4475
|
+
const run = await tryLoadRun(buildTargetContext(projectDir, record));
|
|
4476
|
+
targets.push({
|
|
4477
|
+
id: record.id,
|
|
4478
|
+
target: record.target,
|
|
4479
|
+
canonicalTarget: record.canonicalTarget,
|
|
4480
|
+
rootSessionId: record.rootSessionId,
|
|
4481
|
+
status: run?.status ?? "unknown",
|
|
4482
|
+
currentStage: run?.currentStage ?? null,
|
|
4483
|
+
createdAt: record.createdAt
|
|
4484
|
+
});
|
|
4485
|
+
}
|
|
4486
|
+
return {
|
|
4487
|
+
ok: true,
|
|
4488
|
+
value: {
|
|
4489
|
+
kind: "unbound",
|
|
4490
|
+
targets
|
|
4491
|
+
}
|
|
4492
|
+
};
|
|
4493
|
+
}
|
|
4494
|
+
//#endregion
|
|
4495
|
+
//#region src/host-manifest.ts
|
|
4496
|
+
/**
|
|
4497
|
+
* host-manifest.ts — Single unified Typert host manifest for dsh-pentester.
|
|
4498
|
+
*
|
|
4499
|
+
* Combines pentesterDocker (Settings UI) and pentesterView (Pentester panel)
|
|
4500
|
+
* invocations into ONE manifest. Two separate register() calls with the same
|
|
4501
|
+
* package name cause the first to be withdrawn (SRC fallback denied).
|
|
4502
|
+
*
|
|
4503
|
+
* Client uses the same invocation constants via PENTESTER_REMOTE and
|
|
4504
|
+
* PENTESTER_VIEW_REMOTE — no duplication of endpoint definitions.
|
|
4505
|
+
*/
|
|
4506
|
+
const PENTESTER_HOST_MANIFEST = {
|
|
2898
4507
|
package: "dsh-pentester",
|
|
2899
4508
|
face: "host",
|
|
2900
4509
|
schemas: [],
|
|
@@ -2907,26 +4516,35 @@ const TYPERT_MANIFEST = {
|
|
|
2907
4516
|
members: [{
|
|
2908
4517
|
kind: "method",
|
|
2909
4518
|
name: "command",
|
|
2910
|
-
signature: "command(input: object): Promise<{ok:boolean; value?:
|
|
4519
|
+
signature: "command(input: object): Promise<{ok:boolean; value?:any; error?:string; code?:string}>"
|
|
4520
|
+
}],
|
|
4521
|
+
types: []
|
|
4522
|
+
}, {
|
|
4523
|
+
key: "pentesterView",
|
|
4524
|
+
exportName: "PentesterViewTypertService",
|
|
4525
|
+
description: "Pentester View 数据服务(snapshot / readOutput)。",
|
|
4526
|
+
tags: [],
|
|
4527
|
+
members: [{
|
|
4528
|
+
kind: "method",
|
|
4529
|
+
name: "command",
|
|
4530
|
+
signature: "command(input: {kind:string; ...}): Promise<{ok:boolean; value?:any; error?:string; code?:string}>"
|
|
2911
4531
|
}],
|
|
2912
4532
|
types: []
|
|
2913
4533
|
}],
|
|
2914
4534
|
events: [],
|
|
2915
4535
|
objects: []
|
|
2916
4536
|
},
|
|
2917
|
-
invocations: [PENTESTER_DOCKER_INVOCATION]
|
|
4537
|
+
invocations: [PENTESTER_DOCKER_INVOCATION, PENTESTER_VIEW_INVOCATION]
|
|
2918
4538
|
};
|
|
2919
4539
|
//#endregion
|
|
2920
4540
|
//#region src/git.ts
|
|
2921
4541
|
/**
|
|
2922
|
-
* git.ts — GitCheckpointService:
|
|
4542
|
+
* git.ts — GitCheckpointService:target-scoped git 仓库管理(宿主侧)。
|
|
2923
4543
|
*
|
|
2924
|
-
* 仓库根 =
|
|
2925
|
-
*
|
|
4544
|
+
* 仓库根 = targetRoot(每 Target 独立 .git)。
|
|
4545
|
+
* 约定式提交:
|
|
2926
4546
|
* - 初始:`workspace: initialize <target>`
|
|
2927
4547
|
* - 阶段完成:`ptes(<NN>-<stage>): complete` + tag `ptes/<NN>-<stage>`
|
|
2928
|
-
*
|
|
2929
|
-
* git 命令经注入的 runner 执行(生产 = child_process.execFile,测试 = fake)。
|
|
2930
4548
|
*/
|
|
2931
4549
|
var GitCheckpointError = class extends Error {};
|
|
2932
4550
|
function realGitRunner() {
|
|
@@ -2943,7 +4561,6 @@ function realGitRunner() {
|
|
|
2943
4561
|
});
|
|
2944
4562
|
});
|
|
2945
4563
|
}
|
|
2946
|
-
/** workspace git 仓库管理(幂等 init;阶段完成 checkpoint)。 */
|
|
2947
4564
|
var GitCheckpointService = class {
|
|
2948
4565
|
runner;
|
|
2949
4566
|
userName;
|
|
@@ -2955,23 +4572,17 @@ var GitCheckpointService = class {
|
|
|
2955
4572
|
this.userEmail = deps.userEmail ?? "dsh-pentester@local";
|
|
2956
4573
|
this.isRepoOverride = deps.isRepo;
|
|
2957
4574
|
}
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
return workspaceDir(projectDir);
|
|
4575
|
+
repoDir(ctx) {
|
|
4576
|
+
return ctx.targetRoot;
|
|
2961
4577
|
}
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
return existsSync(join(this.repoDir(projectDir), ".git"));
|
|
4578
|
+
async isRepo(ctx) {
|
|
4579
|
+
if (this.isRepoOverride !== void 0) return this.isRepoOverride(ctx);
|
|
4580
|
+
return existsSync(join(this.repoDir(ctx), ".git"));
|
|
2966
4581
|
}
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
* 幂等:已是仓库则直接返回。
|
|
2970
|
-
*/
|
|
2971
|
-
async init(projectDir, target) {
|
|
2972
|
-
const cwd = this.repoDir(projectDir);
|
|
4582
|
+
async init(ctx, target) {
|
|
4583
|
+
const cwd = this.repoDir(ctx);
|
|
2973
4584
|
await mkdir(cwd, { recursive: true });
|
|
2974
|
-
if (await this.isRepo(
|
|
4585
|
+
if (await this.isRepo(ctx)) return;
|
|
2975
4586
|
await this.runner([
|
|
2976
4587
|
"init",
|
|
2977
4588
|
"-b",
|
|
@@ -2997,10 +4608,9 @@ var GitCheckpointService = class {
|
|
|
2997
4608
|
await this.runner(["add", "-A"], cwd);
|
|
2998
4609
|
await this.commit(cwd, `workspace: initialize ${target ?? "pentest"}`);
|
|
2999
4610
|
}
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
const cwd = this.repoDir(projectDir);
|
|
4611
|
+
async checkpointStage(ctx, stage) {
|
|
4612
|
+
if (!await this.isRepo(ctx)) throw new GitCheckpointError("workspace is not a git repository; run initTargetWorkspace first");
|
|
4613
|
+
const cwd = this.repoDir(ctx);
|
|
3004
4614
|
const dir = stageDir(stage);
|
|
3005
4615
|
await this.runner(["add", "-A"], cwd);
|
|
3006
4616
|
await this.commit(cwd, `ptes(${dir}): complete`);
|
|
@@ -3012,23 +4622,16 @@ var GitCheckpointService = class {
|
|
|
3012
4622
|
], cwd).then((out) => out.trim().length > 0, () => false)) await this.runner(["tag", tag], cwd);
|
|
3013
4623
|
return tag;
|
|
3014
4624
|
}
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
const cwd = this.repoDir(projectDir);
|
|
4625
|
+
async currentBranch(ctx) {
|
|
4626
|
+
const cwd = this.repoDir(ctx);
|
|
3018
4627
|
return (await this.runner(["branch", "--show-current"], cwd).catch(() => "")).trim() || "main";
|
|
3019
4628
|
}
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
const cwd = this.repoDir(projectDir);
|
|
4629
|
+
async hasChanges(ctx) {
|
|
4630
|
+
const cwd = this.repoDir(ctx);
|
|
3023
4631
|
return (await this.runner(["status", "--porcelain"], cwd).catch(() => "")).trim() !== "";
|
|
3024
4632
|
}
|
|
3025
|
-
|
|
3026
|
-
|
|
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);
|
|
4633
|
+
async findStageCheckpoint(ctx, stage) {
|
|
4634
|
+
const cwd = this.repoDir(ctx);
|
|
3032
4635
|
const dir = stageDir(stage);
|
|
3033
4636
|
const out = await this.runner([
|
|
3034
4637
|
"log",
|
|
@@ -3043,9 +4646,8 @@ var GitCheckpointService = class {
|
|
|
3043
4646
|
if (hash !== void 0 && subject === `ptes(${dir}): complete`) return hash;
|
|
3044
4647
|
}
|
|
3045
4648
|
}
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
const cwd = this.repoDir(projectDir);
|
|
4649
|
+
async createBackupBranch(ctx, stage) {
|
|
4650
|
+
const cwd = this.repoDir(ctx);
|
|
3049
4651
|
const branch = `backup/rollback-${Date.now()}`;
|
|
3050
4652
|
await this.runner([
|
|
3051
4653
|
"checkout",
|
|
@@ -3056,12 +4658,8 @@ var GitCheckpointService = class {
|
|
|
3056
4658
|
await this.commit(cwd, `wip: before rollback to ${stage}`);
|
|
3057
4659
|
return branch;
|
|
3058
4660
|
}
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
* n 自动找下一个可用值(已有分支 +1)。
|
|
3062
|
-
*/
|
|
3063
|
-
async createReworkBranch(projectDir, checkpoint, nextStageSlug) {
|
|
3064
|
-
const cwd = this.repoDir(projectDir);
|
|
4661
|
+
async createReworkBranch(ctx, checkpoint, nextStageSlug) {
|
|
4662
|
+
const cwd = this.repoDir(ctx);
|
|
3065
4663
|
const existing = await this.runner(["branch", "--format=%(refname:short)"], cwd).catch(() => "");
|
|
3066
4664
|
const prefix = `rework/${nextStageSlug}-`;
|
|
3067
4665
|
let n = 1;
|
|
@@ -3078,9 +4676,8 @@ var GitCheckpointService = class {
|
|
|
3078
4676
|
], cwd);
|
|
3079
4677
|
return branch;
|
|
3080
4678
|
}
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
const cwd = this.repoDir(projectDir);
|
|
4679
|
+
async checkout(ctx, ref) {
|
|
4680
|
+
const cwd = this.repoDir(ctx);
|
|
3084
4681
|
await this.runner(["checkout", ref], cwd);
|
|
3085
4682
|
}
|
|
3086
4683
|
async commit(cwd, message) {
|
|
@@ -3101,15 +4698,44 @@ var GitCheckpointService = class {
|
|
|
3101
4698
|
* index.ts — cordis plugin entry (contract per docs/architecture.md):
|
|
3102
4699
|
* publish the pentester preset, then wire the Root tools over the
|
|
3103
4700
|
* DSH agents seam。Workers 的工具面 restrict 到 pentester_container_exec。
|
|
4701
|
+
*
|
|
4702
|
+
* RPC endpoints (pentesterDocker, pentesterView) are registered in the
|
|
4703
|
+
* main fiber — NOT a child plugin. Their lifetime equals the dsh-pentester
|
|
4704
|
+
* plugin lifetime. sessions/workspaceRegistry are resolved dynamically
|
|
4705
|
+
* via ctx.get() on each command invocation, so their lifecycle does not
|
|
4706
|
+
* affect RPC endpoint registration.
|
|
3104
4707
|
*/
|
|
3105
4708
|
const name = "dsh-pentester";
|
|
3106
|
-
const inject = [
|
|
4709
|
+
const inject = [
|
|
4710
|
+
"tools",
|
|
4711
|
+
"subagents",
|
|
4712
|
+
"typert"
|
|
4713
|
+
];
|
|
3107
4714
|
const Config = Schema.object({
|
|
3108
4715
|
verbose: Schema.boolean().default(false),
|
|
3109
4716
|
dockerHost: Schema.string().default(""),
|
|
3110
4717
|
toolboxImages: Schema.object({}).default({})
|
|
3111
4718
|
});
|
|
3112
4719
|
async function apply(ctx, config) {
|
|
4720
|
+
console.log(`[dsh-pentester] host build=${BUILD_ID}`);
|
|
4721
|
+
const perf = config.verbose || typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
|
|
4722
|
+
if (perf) {
|
|
4723
|
+
const INTERVAL = 100;
|
|
4724
|
+
let expected = performance.now() + INTERVAL;
|
|
4725
|
+
setInterval(() => {
|
|
4726
|
+
const now = performance.now();
|
|
4727
|
+
const lag = now - expected;
|
|
4728
|
+
if (lag > 250) console.warn(`[HOST_EVENT_LOOP_LAG] ${lag.toFixed(0)}ms (expected interval=${INTERVAL}ms)`);
|
|
4729
|
+
expected = now + INTERVAL;
|
|
4730
|
+
}, INTERVAL).unref();
|
|
4731
|
+
console.log("[dsh-pentester] event loop stall detector active (interval=100ms, warn>250ms)");
|
|
4732
|
+
}
|
|
4733
|
+
if (perf) try {
|
|
4734
|
+
const typertLocal = ctx.typert?.local;
|
|
4735
|
+
if (typertLocal?.subscribe !== void 0) typertLocal.subscribe((event) => {
|
|
4736
|
+
if (event.key === "pentesterView/command") console.log(`[PENTESTER_RPC_LIFECYCLE] pentesterView/command ${event.kind}`);
|
|
4737
|
+
});
|
|
4738
|
+
} catch {}
|
|
3113
4739
|
const profiles = loadAgentProfiles(agentsRoot());
|
|
3114
4740
|
const presetDir = await publishPentesterPreset();
|
|
3115
4741
|
if (presetDir !== void 0) await writeStageProfilesManifest(presetDir, profiles);
|
|
@@ -3126,35 +4752,62 @@ async function apply(ctx, config) {
|
|
|
3126
4752
|
delegations,
|
|
3127
4753
|
profiles,
|
|
3128
4754
|
git,
|
|
3129
|
-
pushWorkspace: async (
|
|
3130
|
-
await docker.pushWorkspace(run.id,
|
|
4755
|
+
pushWorkspace: async (targetCtx, run) => {
|
|
4756
|
+
await docker.pushWorkspace(run.id, targetCtx);
|
|
3131
4757
|
},
|
|
3132
|
-
syncWorkspace: async (
|
|
3133
|
-
await docker.syncWorkspace(run.id,
|
|
3134
|
-
}
|
|
4758
|
+
syncWorkspace: async (targetCtx, run) => {
|
|
4759
|
+
await docker.syncWorkspace(run.id, targetCtx);
|
|
4760
|
+
},
|
|
4761
|
+
sessionQuery: { readSession: async (sessionId) => {
|
|
4762
|
+
const sq = ctx.get?.("sessionQuery");
|
|
4763
|
+
if (sq === void 0) throw new Error("sessionQuery service unavailable");
|
|
4764
|
+
return sq.readSession(sessionId);
|
|
4765
|
+
} }
|
|
3135
4766
|
}),
|
|
3136
4767
|
registerContainerExecTool: (tools) => registerContainerExecTool(tools, docker)
|
|
3137
4768
|
});
|
|
3138
|
-
ctx.
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
});
|
|
3156
|
-
inner.typert.register(TYPERT_MANIFEST);
|
|
4769
|
+
ctx.typert.register(PENTESTER_HOST_MANIFEST);
|
|
4770
|
+
new PentesterDockerTypertService(ctx, {
|
|
4771
|
+
getSettings: () => ensureSettings(),
|
|
4772
|
+
getConfigHost: () => config.dockerHost ?? "",
|
|
4773
|
+
saveDockerHost: async (dockerHost) => {
|
|
4774
|
+
const next = {
|
|
4775
|
+
...await ensureSettings(),
|
|
4776
|
+
dockerHost
|
|
4777
|
+
};
|
|
4778
|
+
await saveSettings(next);
|
|
4779
|
+
return next;
|
|
4780
|
+
},
|
|
4781
|
+
saveSettings: async (next) => {
|
|
4782
|
+
await saveSettings(next);
|
|
4783
|
+
},
|
|
4784
|
+
applyHost: (host) => docker.updateHost(host),
|
|
4785
|
+
docker
|
|
3157
4786
|
});
|
|
4787
|
+
new PentesterViewTypertService(ctx, {
|
|
4788
|
+
getLiveSessionCwd(sessionId) {
|
|
4789
|
+
try {
|
|
4790
|
+
const cwd = ((ctx.get?.("sessions"))?.get?.(sessionId))?.header?.cwd;
|
|
4791
|
+
if (typeof cwd === "string" && cwd.length > 0 && cwd.startsWith("/")) return cwd;
|
|
4792
|
+
} catch {}
|
|
4793
|
+
},
|
|
4794
|
+
getWorkspaceForSession(sessionId) {
|
|
4795
|
+
try {
|
|
4796
|
+
const registry = ctx.get?.("workspaceRegistry");
|
|
4797
|
+
if (registry?.list === void 0) return void 0;
|
|
4798
|
+
for (const workspace of registry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace.path;
|
|
4799
|
+
} catch {}
|
|
4800
|
+
},
|
|
4801
|
+
verbose: config.verbose
|
|
4802
|
+
});
|
|
4803
|
+
const typertLocal = ctx.typert.local;
|
|
4804
|
+
const dockerEndpoint = typertLocal.get("pentesterDocker/command");
|
|
4805
|
+
const viewEndpoint = typertLocal.get("pentesterView/command");
|
|
4806
|
+
if (dockerEndpoint === void 0 || viewEndpoint === void 0) throw new Error(`dsh-pentester startup failed: RPC descriptors not registered. docker=${dockerEndpoint !== void 0 ? "ok" : "MISSING"} view=${viewEndpoint !== void 0 ? "ok" : "MISSING"}`);
|
|
4807
|
+
console.log(`[dsh-pentester] RPC ready build=${BUILD_ID}`);
|
|
4808
|
+
console.log(` pentesterDocker/command = registered`);
|
|
4809
|
+
console.log(` pentesterView/command = registered`);
|
|
4810
|
+
console.log(` host module = ${import.meta.url}`);
|
|
3158
4811
|
if (config.verbose) console.log(`[dsh-pentester] loaded with ${profiles.size} agent profiles`);
|
|
3159
4812
|
}
|
|
3160
4813
|
function agentsRoot() {
|
|
@@ -3162,13 +4815,7 @@ function agentsRoot() {
|
|
|
3162
4815
|
for (const candidate of [join(here, "..", "agents"), join(process.cwd(), "agents")]) if (existsSync(candidate)) return candidate;
|
|
3163
4816
|
return join(process.cwd(), "agents");
|
|
3164
4817
|
}
|
|
3165
|
-
/** run-state.mjs 读取的 stage → AgentProfile 清单文件名(与 preset 同目录发布)。 */
|
|
3166
4818
|
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
4819
|
async function writeStageProfilesManifest(presetDir, profiles) {
|
|
3173
4820
|
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
4821
|
id: profile.id,
|
|
@@ -3176,7 +4823,6 @@ async function writeStageProfilesManifest(presetDir, profiles) {
|
|
|
3176
4823
|
}))]));
|
|
3177
4824
|
await writeFile(join(presetDir, STAGE_PROFILES_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
3178
4825
|
}
|
|
3179
|
-
/** 把随包发布的 pentester preset 拷贝到 DSH 用户 preset 根目录。 */
|
|
3180
4826
|
async function publishPentesterPreset(home) {
|
|
3181
4827
|
const source = shippedPresetDir();
|
|
3182
4828
|
if (source === void 0) return void 0;
|