@gpzhang2001/sharpkit-analysis 0.2.1

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/lib/index.js ADDED
@@ -0,0 +1,1231 @@
1
+ import { dirname, join } from "node:path";
2
+ import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ //#region src/stores.ts
5
+ /**
6
+ * Run-scoped JSON mirror stores (strix .state/ mirrors): atomic writes
7
+ * (temp-in-dir + rename), hydrate-on-start, per-store in-memory maps.
8
+ * One instance per scan, owned by the analysis service.
9
+ * @module @gpzhang2001/sharpkit-analysis/stores
10
+ */
11
+ /** A JSON file mirror with atomic write and hydrate. */
12
+ var JsonMirrorStore = class {
13
+ path;
14
+ map = /* @__PURE__ */ new Map();
15
+ constructor(path) {
16
+ this.path = path;
17
+ }
18
+ /** Load the mirror file; corrupt JSON is tolerated as empty on hydrate (resume aid only). */
19
+ async hydrate() {
20
+ if (this.path === null) return;
21
+ try {
22
+ const raw = await readFile(this.path, "utf8");
23
+ const parsed = JSON.parse(raw);
24
+ if (typeof parsed !== "object" || parsed === null) return;
25
+ for (const [key, value] of Object.entries(parsed)) if (typeof value === "object" && value !== null) this.map.set(key, value);
26
+ } catch {}
27
+ }
28
+ /** Persist the whole map atomically (strix `_persist_locked`). */
29
+ async persist() {
30
+ if (this.path === null) return;
31
+ const payload = JSON.stringify(Object.fromEntries(this.map), null, 2);
32
+ await mkdir(dirname(this.path), { recursive: true });
33
+ const temp = `${dirname(this.path)}/.${join("", this.path.split("/").pop() ?? "store")}.${process.pid}.tmp`;
34
+ await writeFile(temp, payload, "utf8");
35
+ await rename(temp, this.path);
36
+ }
37
+ get(key) {
38
+ return this.map.get(key);
39
+ }
40
+ set(key, value) {
41
+ this.map.set(key, value);
42
+ }
43
+ delete(key) {
44
+ return this.map.delete(key);
45
+ }
46
+ values() {
47
+ return [...this.map.values()];
48
+ }
49
+ get size() {
50
+ return this.map.size;
51
+ }
52
+ };
53
+ /** Generate a 6-hex id with bounded collision retries (strix parity). */
54
+ function generateId(existing) {
55
+ for (let attempt = 0; attempt < 1024; attempt++) {
56
+ const id = Math.random().toString(16).slice(2, 8).padEnd(6, "0");
57
+ if (!existing.has(id)) return id;
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Normalize a remote target to its identity (threat_model/tools.py
63
+ * `_normalize_remote_target` + `_normalize_git_remote`): scp-style and
64
+ * scheme'd git remotes map to https URLs, `.git` stripped, trailing slash
65
+ * stripped, host lowercased with default ports collapsed.
66
+ * @param target - the raw target string.
67
+ */
68
+ function normalizeTargetIdentity(target) {
69
+ const trimmed = target.trim();
70
+ const scp = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(trimmed);
71
+ if (scp !== null) return `https://${scp[1]?.toLowerCase()}/${scp[2]}`;
72
+ const withScheme = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]+)([^#]*)/i.exec(trimmed);
73
+ if (withScheme !== null) {
74
+ const scheme = withScheme[1] ?? "";
75
+ const authority = withScheme[2] ?? "";
76
+ const path = withScheme[3] ?? "";
77
+ const defaultPort = scheme.toLowerCase() === "https" ? "443" : scheme.toLowerCase() === "http" ? "80" : null;
78
+ let host = authority.toLowerCase();
79
+ if (defaultPort !== null && host.endsWith(`:${defaultPort}`)) host = host.slice(0, -defaultPort.length - 1);
80
+ return `https://${host}${path.replace(/\/$/, "")}`.replace(/\.git$/, "");
81
+ }
82
+ return trimmed.replace(/\/$/, "").replace(/\.git$/, "");
83
+ }
84
+ //#endregion
85
+ //#region src/index.ts
86
+ /**
87
+ * Analysis tools — port of strix tools/{threat_model,coverage,notes,
88
+ * thinking}/ + finish_scan: get/save/amend_threat_model (validated
89
+ * sections, size gates, append-only amendments), record/update/
90
+ * list_coverage (duplicate guard, evidence requirements, history),
91
+ * create/list/get/update/delete_note, think, and finish_scan (root-only,
92
+ * four required sections, coverage summary, final artifacts). State lives
93
+ * in per-scan mirror stores under the run dir (.state parity) and is
94
+ * provided as `pentestAnalysis` for the reporting package's coverage
95
+ * document and SARIF bridge.
96
+ * @module @gpzhang2001/sharpkit-analysis
97
+ */
98
+ /** Coverage outcome values (strix VALID_OUTCOMES). */
99
+ const VALID_OUTCOMES = [
100
+ "reported",
101
+ "no_issue_found",
102
+ "ruled_out",
103
+ "not_applicable",
104
+ "needs_follow_up"
105
+ ];
106
+ /** Outcomes that require evidence text (strix `_OUTCOMES_REQUIRING_EVIDENCE`). */
107
+ const OUTCOMES_REQUIRING_EVIDENCE = /* @__PURE__ */ new Set([
108
+ "ruled_out",
109
+ "not_applicable",
110
+ "needs_follow_up"
111
+ ]);
112
+ /** Note categories (strix `_VALID_NOTE_CATEGORIES`). */
113
+ const VALID_NOTE_CATEGORIES = [
114
+ "general",
115
+ "findings",
116
+ "methodology",
117
+ "questions",
118
+ "plan",
119
+ "wiki"
120
+ ];
121
+ /** Required threat-model sections, matched as lowercase substrings (strix parity). */
122
+ const REQUIRED_SECTIONS = [
123
+ "overview",
124
+ "trust boundaries",
125
+ "attack surface",
126
+ "severity calibration"
127
+ ];
128
+ /** Threat model gates (strix constants). */
129
+ const MAX_MODEL_BYTES = 524288;
130
+ const MIN_MODEL_CHARS = 400;
131
+ const MIN_AMENDMENT_CHARS = 80;
132
+ const MAX_AMENDMENTS = 40;
133
+ /** Display timestamp in the coverage format. */
134
+ function displayTimestamp(date) {
135
+ const pad = (value) => String(value).padStart(2, "0");
136
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
137
+ }
138
+ const name = "pentest-tool-analysis";
139
+ const inject = ["tools", "pentestReporting"];
140
+ /** ISO-8601 timestamp (threat model + notes stores). */
141
+ function isoNow() {
142
+ return (/* @__PURE__ */ new Date()).toISOString().replace("Z", "+00:00");
143
+ }
144
+ function apply(ctx, config = {}) {
145
+ const scanId = config.scanId ?? "pentest";
146
+ const runDir = config.runDir ?? join("sharpkit_runs", scanId);
147
+ const stateDir = join(runDir, ".state");
148
+ const coverage = new JsonMirrorStore(join(stateDir, "coverage.json"));
149
+ const threatModels = new JsonMirrorStore(join(stateDir, "threat_models.json"));
150
+ const notes = new JsonMirrorStore(join(stateDir, "notes.json"));
151
+ coverage.hydrate();
152
+ threatModels.hydrate();
153
+ notes.hydrate();
154
+ const coverageEntries = () => coverage.values().map((entry) => ({
155
+ ...entry,
156
+ entry_id: String(entry["id"] ?? "")
157
+ }));
158
+ const outcomeCounts = () => {
159
+ const counts = {};
160
+ for (const outcome of VALID_OUTCOMES) {
161
+ const count = coverage.values().filter((entry) => entry["outcome"] === outcome).length;
162
+ if (count > 0) counts[outcome] = count;
163
+ }
164
+ return counts;
165
+ };
166
+ const handle = {
167
+ coverage,
168
+ threatModels,
169
+ notes,
170
+ coverageEntries,
171
+ outcomeCounts
172
+ };
173
+ ctx.provide("pentestAnalysis", handle);
174
+ const resolveTarget = (target) => {
175
+ const cleaned = target?.trim() ?? "";
176
+ if (cleaned === "") {
177
+ const targets = config.scanTargets ?? [];
178
+ if (targets.length === 1) return { identity: normalizeTargetIdentity(targets[0] ?? "") };
179
+ return { error: "target cannot be empty - name the target this threat model describes" };
180
+ }
181
+ return { identity: normalizeTargetIdentity(cleaned) };
182
+ };
183
+ ctx.tools.register(defineTool({
184
+ name: "get_threat_model",
185
+ description: "Fetch the threat model shared for a target in this scan (found=false when none exists yet). Read-only.",
186
+ parameters: { target: {
187
+ type: "string",
188
+ description: "Target the model describes; omit when the scan has exactly one target."
189
+ } },
190
+ output: {
191
+ schema: {
192
+ type: "object",
193
+ properties: {
194
+ success: {
195
+ type: "boolean",
196
+ required: true
197
+ },
198
+ found: { type: "boolean" },
199
+ target: { type: "string" },
200
+ content: { type: "string" },
201
+ amendments: {
202
+ type: "array",
203
+ items: {
204
+ type: "object",
205
+ properties: {},
206
+ additionalProperties: true
207
+ }
208
+ },
209
+ amendments_note: { type: "string" },
210
+ message: { type: "string" },
211
+ error: { type: "string" }
212
+ },
213
+ additionalProperties: false
214
+ },
215
+ render: (_args, value) => {
216
+ const result = value;
217
+ if (!result.success) return [{
218
+ type: "text",
219
+ text: `get_threat_model failed: ${result.error ?? "unknown"}`
220
+ }];
221
+ return [{
222
+ type: "text",
223
+ text: result.found === true ? "threat model found" : "no threat model yet"
224
+ }];
225
+ }
226
+ },
227
+ execute: (async (rawArgs) => {
228
+ const resolved = resolveTarget(rawArgs.target);
229
+ if ("error" in resolved) return {
230
+ success: false,
231
+ error: resolved.error
232
+ };
233
+ const model = threatModels.get(resolved.identity);
234
+ if (model === void 0 || String(model["content"] ?? "").trim() === "") return {
235
+ success: true,
236
+ found: false,
237
+ target: resolved.identity,
238
+ message: "No threat model exists for this target yet. Save one with save_threat_model."
239
+ };
240
+ const amendments = model["amendments"];
241
+ return {
242
+ success: true,
243
+ found: true,
244
+ target: resolved.identity,
245
+ content: String(model["content"]),
246
+ ...amendments !== void 0 && amendments.length > 0 ? {
247
+ amendments,
248
+ amendments_note: "Addenda recorded by agents after the model was saved."
249
+ } : {}
250
+ };
251
+ })
252
+ }));
253
+ ctx.tools.register(defineTool({
254
+ name: "save_threat_model",
255
+ description: `Share your threat model for a target with the whole scan team (full replace; clears amendments). Must cover Overview, Trust Boundaries, Attack Surface, and Severity Calibration; minimum ${String(MIN_MODEL_CHARS)} characters, maximum 512KB.`,
256
+ parameters: {
257
+ target: {
258
+ type: "string",
259
+ description: "Target the model describes; omit when the scan has exactly one target."
260
+ },
261
+ content: {
262
+ type: "string",
263
+ required: true,
264
+ description: "The full markdown threat model."
265
+ }
266
+ },
267
+ output: {
268
+ schema: {
269
+ type: "object",
270
+ properties: {
271
+ success: {
272
+ type: "boolean",
273
+ required: true
274
+ },
275
+ target: { type: "string" },
276
+ amendments_cleared: { type: "integer" },
277
+ message: { type: "string" },
278
+ error: { type: "string" }
279
+ },
280
+ additionalProperties: false
281
+ },
282
+ render: (_args, value) => {
283
+ const result = value;
284
+ return [{
285
+ type: "text",
286
+ text: result.success ? "threat model saved" : `save_threat_model failed: ${result.error ?? "unknown"}`
287
+ }];
288
+ }
289
+ },
290
+ execute: (async (rawArgs) => {
291
+ const args = rawArgs;
292
+ const resolved = resolveTarget(args.target);
293
+ if ("error" in resolved) return {
294
+ success: false,
295
+ error: resolved.error
296
+ };
297
+ const content = args.content.trim();
298
+ if (content.length < MIN_MODEL_CHARS) return {
299
+ success: false,
300
+ error: `Threat model is too thin (${String(content.length)} chars). It has to be usable by every agent in this scan.`
301
+ };
302
+ if (Buffer.byteLength(content, "utf8") > MAX_MODEL_BYTES) return {
303
+ success: false,
304
+ error: "Threat model exceeds 512KB; tighten it."
305
+ };
306
+ const lower = content.toLowerCase();
307
+ const missing = REQUIRED_SECTIONS.filter((section) => !lower.includes(section));
308
+ if (missing.length > 0) return {
309
+ success: false,
310
+ error: `Threat model is missing required section(s): ${missing.join(", ")}. Cover Overview, Trust Boundaries, Attack Surface, and Severity Calibration.`
311
+ };
312
+ const previousAmendments = threatModels.get(resolved.identity)?.["amendments"];
313
+ const amendmentsCleared = Array.isArray(previousAmendments) ? previousAmendments.length : 0;
314
+ threatModels.set(resolved.identity, {
315
+ target: resolved.identity,
316
+ written_at: isoNow(),
317
+ written_by: null,
318
+ content
319
+ });
320
+ await threatModels.persist();
321
+ return {
322
+ success: true,
323
+ target: resolved.identity,
324
+ amendments_cleared: amendmentsCleared,
325
+ message: amendmentsCleared > 0 ? `Threat model shared with this scan. It replaced a previous model, folding ${String(amendmentsCleared)} amendment(s) into the full rewrite.` : "Threat model shared with this scan."
326
+ };
327
+ })
328
+ }));
329
+ ctx.tools.register(defineTool({
330
+ name: "amend_threat_model",
331
+ description: `Append an addendum to the existing threat model without rewriting it (minimum ${String(MIN_AMENDMENT_CHARS)} characters). Use save_threat_model for a full rewrite that folds amendments.`,
332
+ parameters: {
333
+ target: {
334
+ type: "string",
335
+ description: "Target the model describes; omit when the scan has exactly one target."
336
+ },
337
+ addendum: {
338
+ type: "string",
339
+ required: true,
340
+ description: "The markdown addendum."
341
+ }
342
+ },
343
+ output: {
344
+ schema: {
345
+ type: "object",
346
+ properties: {
347
+ success: {
348
+ type: "boolean",
349
+ required: true
350
+ },
351
+ target: { type: "string" },
352
+ amendment_count: { type: "integer" },
353
+ message: { type: "string" },
354
+ error: { type: "string" }
355
+ },
356
+ additionalProperties: false
357
+ },
358
+ render: (_args, value) => {
359
+ const result = value;
360
+ return [{
361
+ type: "text",
362
+ text: result.success ? "amendment recorded" : `amend_threat_model failed: ${result.error ?? "unknown"}`
363
+ }];
364
+ }
365
+ },
366
+ execute: (async (rawArgs) => {
367
+ const args = rawArgs;
368
+ const resolved = resolveTarget(args.target);
369
+ if ("error" in resolved) return {
370
+ success: false,
371
+ error: resolved.error
372
+ };
373
+ const addendum = args.addendum.trim();
374
+ if (addendum.length < MIN_AMENDMENT_CHARS) return {
375
+ success: false,
376
+ error: `Amendment is too thin (${String(addendum.length)} chars). Give the new knowledge in full.`
377
+ };
378
+ const model = threatModels.get(resolved.identity);
379
+ if (model === void 0) return {
380
+ success: false,
381
+ error: "No threat model exists for this target yet. Save the full model with save_threat_model first."
382
+ };
383
+ const amendments = model["amendments"] ?? [];
384
+ if (amendments.length >= MAX_AMENDMENTS) return {
385
+ success: false,
386
+ error: `This threat model already carries ${String(amendments.length)} amendments. Fold them into a full save_threat_model rewrite.`
387
+ };
388
+ if (Buffer.byteLength(String(model["content"]) + addendum, "utf8") > MAX_MODEL_BYTES) return {
389
+ success: false,
390
+ error: "Combined threat model exceeds 512KB; fold amendments with save_threat_model."
391
+ };
392
+ amendments.push({
393
+ at: isoNow(),
394
+ by: null,
395
+ content: addendum
396
+ });
397
+ model["amendments"] = amendments;
398
+ await threatModels.persist();
399
+ return {
400
+ success: true,
401
+ target: resolved.identity,
402
+ amendment_count: amendments.length,
403
+ message: "Amendment recorded. Every agent reading the threat model will see it."
404
+ };
405
+ })
406
+ }));
407
+ /** strix coverage `_validate`: normalization + per-field errors. */
408
+ function validateCoverage(input) {
409
+ const errors = [];
410
+ if (input.surface === "") errors.push("surface cannot be empty - name the endpoint, route, file, or component");
411
+ if (input.riskArea === "") errors.push("risk_area cannot be empty - name what you were testing for");
412
+ const outcome = input.outcome.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
413
+ if (!VALID_OUTCOMES.includes(outcome)) errors.push(`Invalid outcome: '${input.outcome}'. Must be one of: [${VALID_OUTCOMES.join(", ")}]`);
414
+ if (OUTCOMES_REQUIRING_EVIDENCE.has(outcome) && input.evidence === "") errors.push(`evidence is required for outcome '${outcome}' - name the specific control, response, or observation that justifies it`);
415
+ return errors.length > 0 ? { errors } : { outcome };
416
+ }
417
+ const findCoverageDuplicate = (surface, riskArea) => coverage.values().find((entry) => String(entry["surface"]).toLowerCase() === surface.toLowerCase() && String(entry["risk_area"]).toLowerCase() === riskArea.toLowerCase());
418
+ ctx.tools.register(defineTool({
419
+ name: "record_coverage",
420
+ description: "Record that you exercised an attack surface for a risk area, with an outcome. Outcomes: reported / no_issue_found / ruled_out / not_applicable / needs_follow_up. ruled_out, not_applicable, and needs_follow_up REQUIRE evidence. One entry per (surface, risk_area).",
421
+ parameters: {
422
+ surface: {
423
+ type: "string",
424
+ required: true,
425
+ description: "The endpoint, route, file, or component you exercised."
426
+ },
427
+ risk_area: {
428
+ type: "string",
429
+ required: true,
430
+ description: "What you were testing for (e.g. \"SQL injection in login\")."
431
+ },
432
+ outcome: {
433
+ type: "string",
434
+ required: true,
435
+ enum: [...VALID_OUTCOMES],
436
+ description: "The testing outcome."
437
+ },
438
+ evidence: {
439
+ type: "string",
440
+ description: "Concrete observation justifying the outcome (required for some outcomes)."
441
+ }
442
+ },
443
+ output: {
444
+ schema: {
445
+ type: "object",
446
+ properties: {
447
+ success: {
448
+ type: "boolean",
449
+ required: true
450
+ },
451
+ entry_id: { type: "string" },
452
+ outcome: { type: "string" },
453
+ message: { type: "string" },
454
+ error: { type: "string" },
455
+ errors: {
456
+ type: "array",
457
+ items: { type: "string" }
458
+ },
459
+ existing_entry_id: { type: "string" },
460
+ existing_outcome: { type: "string" }
461
+ },
462
+ additionalProperties: false
463
+ },
464
+ render: (_args, value) => {
465
+ const result = value;
466
+ const reason = result.error ?? result.errors?.join("; ") ?? "unknown";
467
+ return [{
468
+ type: "text",
469
+ text: result.success ? "coverage recorded" : `record_coverage failed: ${reason}`
470
+ }];
471
+ }
472
+ },
473
+ execute: (async (rawArgs) => {
474
+ const args = rawArgs;
475
+ const surface = (args.surface ?? "").trim();
476
+ const riskArea = (args.risk_area ?? "").trim();
477
+ const evidence = (args.evidence ?? "").trim();
478
+ const checked = validateCoverage({
479
+ surface,
480
+ riskArea,
481
+ outcome: args.outcome ?? "",
482
+ evidence
483
+ });
484
+ if ("errors" in checked) return {
485
+ success: false,
486
+ error: "Validation failed",
487
+ errors: checked.errors
488
+ };
489
+ const duplicate = findCoverageDuplicate(surface, riskArea);
490
+ if (duplicate !== void 0) return {
491
+ success: false,
492
+ error: `'${surface}' (${riskArea}) already has coverage entry ${String(duplicate["id"])}, recorded by ${String(duplicate["agent_name"] ?? "an agent")} as '${String(duplicate["outcome"])}'. Two rows for the same surface and risk area are never allowed; update_coverage to change the outcome.`,
493
+ existing_entry_id: String(duplicate["id"]),
494
+ existing_outcome: String(duplicate["outcome"])
495
+ };
496
+ const id = generateId(new Set(coverage.values().map((entry) => String(entry["id"] ?? ""))));
497
+ if (id === null) return {
498
+ success: false,
499
+ error: "could not allocate a coverage entry id"
500
+ };
501
+ coverage.set(id, {
502
+ id,
503
+ surface,
504
+ risk_area: riskArea,
505
+ outcome: checked.outcome,
506
+ created_at: displayTimestamp(/* @__PURE__ */ new Date()),
507
+ ...evidence !== "" ? { evidence } : {}
508
+ });
509
+ await coverage.persist();
510
+ return {
511
+ success: true,
512
+ entry_id: id,
513
+ outcome: checked.outcome,
514
+ message: `Coverage recorded for '${surface}' (${checked.outcome})`
515
+ };
516
+ })
517
+ }));
518
+ ctx.tools.register(defineTool({
519
+ name: "update_coverage",
520
+ description: "Move an existing coverage entry to a new outcome (surface and risk_area are never editable). The previous state is kept as history.",
521
+ parameters: {
522
+ entry_id: {
523
+ type: "string",
524
+ required: true,
525
+ description: "Id from record_coverage or list_coverage."
526
+ },
527
+ outcome: {
528
+ type: "string",
529
+ required: true,
530
+ enum: [...VALID_OUTCOMES],
531
+ description: "The new outcome."
532
+ },
533
+ evidence: {
534
+ type: "string",
535
+ description: "Evidence for the new outcome (required for some outcomes)."
536
+ }
537
+ },
538
+ output: {
539
+ schema: {
540
+ type: "object",
541
+ properties: {
542
+ success: {
543
+ type: "boolean",
544
+ required: true
545
+ },
546
+ entry_id: { type: "string" },
547
+ previous_outcome: { type: "string" },
548
+ outcome: { type: "string" },
549
+ message: { type: "string" },
550
+ error: { type: "string" },
551
+ errors: {
552
+ type: "array",
553
+ items: { type: "string" }
554
+ }
555
+ },
556
+ additionalProperties: false
557
+ },
558
+ render: (_args, value) => {
559
+ const result = value;
560
+ const reason = result.error ?? result.errors?.join("; ") ?? "unknown";
561
+ return [{
562
+ type: "text",
563
+ text: result.success ? "coverage updated" : `update_coverage failed: ${reason}`
564
+ }];
565
+ }
566
+ },
567
+ execute: (async (rawArgs) => {
568
+ const args = rawArgs;
569
+ const entryId = (args.entry_id ?? "").trim();
570
+ const entry = coverage.get(entryId);
571
+ if (entry === void 0) return {
572
+ success: false,
573
+ error: `No coverage entry '${entryId}'. Call list_coverage to see recorded entries.`
574
+ };
575
+ const surface = String(entry["surface"] ?? "");
576
+ const riskArea = String(entry["risk_area"] ?? "");
577
+ const evidence = (args.evidence ?? "").trim();
578
+ const checked = validateCoverage({
579
+ surface,
580
+ riskArea,
581
+ outcome: args.outcome ?? "",
582
+ evidence
583
+ });
584
+ if ("errors" in checked) return {
585
+ success: false,
586
+ error: "Validation failed",
587
+ errors: checked.errors
588
+ };
589
+ const previousOutcome = String(entry["outcome"]);
590
+ const history = entry["history"] ?? [];
591
+ const prior = {
592
+ outcome: previousOutcome,
593
+ recorded_at: String(entry["created_at"])
594
+ };
595
+ if (entry["evidence"] !== void 0) prior["evidence"] = entry["evidence"];
596
+ history.push(prior);
597
+ entry["history"] = history;
598
+ entry["outcome"] = checked.outcome;
599
+ entry["updated_at"] = displayTimestamp(/* @__PURE__ */ new Date());
600
+ if (evidence !== "") entry["evidence"] = evidence;
601
+ await coverage.persist();
602
+ return {
603
+ success: true,
604
+ entry_id: entryId,
605
+ previous_outcome: previousOutcome,
606
+ outcome: checked.outcome,
607
+ message: `'${surface}' (${riskArea}) moved from ${previousOutcome} to ${checked.outcome}. The previous state is kept as history.`
608
+ };
609
+ })
610
+ }));
611
+ ctx.tools.register(defineTool({
612
+ name: "list_coverage",
613
+ description: "List coverage entries recorded in this scan (filters compose; outcome counts are over all entries).",
614
+ parameters: {
615
+ outcome: {
616
+ type: "string",
617
+ description: "Filter to one outcome."
618
+ },
619
+ surface: {
620
+ type: "string",
621
+ description: "Case-insensitive substring match on the surface."
622
+ }
623
+ },
624
+ output: {
625
+ schema: {
626
+ type: "object",
627
+ properties: {
628
+ success: {
629
+ type: "boolean",
630
+ required: true
631
+ },
632
+ entries: {
633
+ type: "array",
634
+ items: {
635
+ type: "object",
636
+ properties: {},
637
+ additionalProperties: true
638
+ },
639
+ required: true
640
+ },
641
+ filtered_count: {
642
+ type: "integer",
643
+ required: true
644
+ },
645
+ total_count: {
646
+ type: "integer",
647
+ required: true
648
+ },
649
+ outcome_counts: {
650
+ type: "object",
651
+ properties: {},
652
+ additionalProperties: true,
653
+ required: true
654
+ },
655
+ error: { type: "string" }
656
+ },
657
+ additionalProperties: false
658
+ },
659
+ render: (_args, value) => {
660
+ const result = value;
661
+ if (result.error !== void 0) return [{
662
+ type: "text",
663
+ text: `list_coverage failed: ${result.error}`
664
+ }];
665
+ return [{
666
+ type: "text",
667
+ text: `${String(result.entries?.length ?? 0)} coverage entry(ies)`
668
+ }];
669
+ }
670
+ },
671
+ execute: (async (rawArgs) => {
672
+ const args = rawArgs;
673
+ let outcomeFilter;
674
+ if (args.outcome !== void 0 && args.outcome !== "") {
675
+ outcomeFilter = args.outcome.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
676
+ if (outcomeFilter !== void 0 && !VALID_OUTCOMES.includes(outcomeFilter)) return {
677
+ success: false,
678
+ error: `Invalid outcome: '${args.outcome}'. Must be one of: [${VALID_OUTCOMES.join(", ")}]`,
679
+ entries: [],
680
+ filtered_count: 0,
681
+ total_count: coverage.size,
682
+ outcome_counts: {}
683
+ };
684
+ }
685
+ const surfaceFilter = args.surface?.toLowerCase() ?? "";
686
+ const entries = coverageEntries().filter((entry) => outcomeFilter === void 0 || entry["outcome"] === outcomeFilter).filter((entry) => surfaceFilter === "" || String(entry["surface"]).toLowerCase().includes(surfaceFilter)).sort((a, b) => String(a["created_at"]).localeCompare(String(b["created_at"]))).map((entry) => {
687
+ const listing = {
688
+ entry_id: entry.entry_id,
689
+ surface: entry["surface"],
690
+ risk_area: entry["risk_area"],
691
+ outcome: entry["outcome"],
692
+ created_at: entry["created_at"]
693
+ };
694
+ const evidence = entry["evidence"];
695
+ if (typeof evidence === "string" && evidence !== "") listing["evidence"] = evidence.length > 240 ? `${evidence.slice(0, 240)}...` : evidence;
696
+ const history = entry["history"];
697
+ if (history !== void 0 && history.length > 0) listing["previous_outcomes"] = history.map((item) => item["outcome"]);
698
+ return listing;
699
+ });
700
+ return {
701
+ success: true,
702
+ entries,
703
+ filtered_count: entries.length,
704
+ total_count: coverage.size,
705
+ outcome_counts: outcomeCounts()
706
+ };
707
+ })
708
+ }));
709
+ const noteList = () => notes.values().map((entry) => ({
710
+ ...entry,
711
+ id: String(entry["id"] ?? "")
712
+ }));
713
+ ctx.tools.register(defineTool({
714
+ name: "create_note",
715
+ description: "Create a persistent note shared across the scan team (categories: general/findings/methodology/questions/plan/wiki).",
716
+ parameters: {
717
+ title: {
718
+ type: "string",
719
+ required: true,
720
+ description: "Short note title."
721
+ },
722
+ content: {
723
+ type: "string",
724
+ required: true,
725
+ description: "The note body."
726
+ },
727
+ category: {
728
+ type: "string",
729
+ enum: [...VALID_NOTE_CATEGORIES],
730
+ description: "Note category (default general)."
731
+ },
732
+ tags: {
733
+ type: "array",
734
+ items: { type: "string" },
735
+ description: "Optional tags."
736
+ }
737
+ },
738
+ output: {
739
+ schema: {
740
+ type: "object",
741
+ properties: {
742
+ success: {
743
+ type: "boolean",
744
+ required: true
745
+ },
746
+ note_id: { type: "string" },
747
+ message: { type: "string" },
748
+ total_count: { type: "integer" },
749
+ error: { type: "string" }
750
+ },
751
+ additionalProperties: false
752
+ },
753
+ render: (_args, value) => {
754
+ const result = value;
755
+ return [{
756
+ type: "text",
757
+ text: result.success ? "note created" : `create_note failed: ${result.error ?? "unknown"}`
758
+ }];
759
+ }
760
+ },
761
+ execute: (async (rawArgs) => {
762
+ const args = rawArgs;
763
+ const title = (args.title ?? "").trim();
764
+ const content = (args.content ?? "").trim();
765
+ const category = (args.category ?? "general").trim();
766
+ if (title === "") return {
767
+ success: false,
768
+ error: "Title cannot be empty"
769
+ };
770
+ if (content === "") return {
771
+ success: false,
772
+ error: "Content cannot be empty"
773
+ };
774
+ if (!VALID_NOTE_CATEGORIES.includes(category)) return {
775
+ success: false,
776
+ error: `Invalid category. Must be one of: ${VALID_NOTE_CATEGORIES.join(", ")}`
777
+ };
778
+ const id = generateId(new Set(noteList().map((note) => note.id)));
779
+ if (id === null) return {
780
+ success: false,
781
+ error: "could not allocate a note id"
782
+ };
783
+ const now = isoNow();
784
+ notes.set(id, {
785
+ id,
786
+ title,
787
+ content,
788
+ category,
789
+ tags: args.tags ?? [],
790
+ created_at: now,
791
+ updated_at: now
792
+ });
793
+ await notes.persist();
794
+ return {
795
+ success: true,
796
+ note_id: id,
797
+ message: `Note '${title}' created successfully`,
798
+ total_count: notes.size
799
+ };
800
+ })
801
+ }));
802
+ ctx.tools.register(defineTool({
803
+ name: "list_notes",
804
+ description: "List notes (filters compose; newest first).",
805
+ parameters: {
806
+ category: {
807
+ type: "string",
808
+ description: "Exact category filter."
809
+ },
810
+ tags: {
811
+ type: "array",
812
+ items: { type: "string" },
813
+ description: "ANY-match tag filter."
814
+ },
815
+ search: {
816
+ type: "string",
817
+ description: "Substring match on title or content."
818
+ },
819
+ include_content: {
820
+ type: "boolean",
821
+ description: "Full content instead of a 280-char preview."
822
+ }
823
+ },
824
+ output: {
825
+ schema: {
826
+ type: "object",
827
+ properties: {
828
+ success: {
829
+ type: "boolean",
830
+ required: true
831
+ },
832
+ notes: {
833
+ type: "array",
834
+ items: {
835
+ type: "object",
836
+ properties: {},
837
+ additionalProperties: true
838
+ },
839
+ required: true
840
+ },
841
+ filtered_count: {
842
+ type: "integer",
843
+ required: true
844
+ },
845
+ total_count: {
846
+ type: "integer",
847
+ required: true
848
+ }
849
+ },
850
+ additionalProperties: false
851
+ },
852
+ render: (_args, value) => {
853
+ return [{
854
+ type: "text",
855
+ text: `${String(value.notes?.length ?? 0)} note(s)`
856
+ }];
857
+ }
858
+ },
859
+ execute: (async (rawArgs) => {
860
+ const args = rawArgs;
861
+ const category = args.category?.trim() ?? "";
862
+ const search = args.search?.toLowerCase() ?? "";
863
+ const tags = args.tags ?? [];
864
+ const entries = noteList().filter((note) => category === "" || note["category"] === category).filter((note) => tags.length === 0 || tags.some((tag) => note["tags"]?.includes(tag) === true)).filter((note) => search === "" || String(note["title"]).toLowerCase().includes(search) || String(note["content"]).toLowerCase().includes(search)).sort((a, b) => String(b["created_at"]).localeCompare(String(a["created_at"]))).map((note) => {
865
+ const listing = {
866
+ note_id: note.id,
867
+ title: note["title"],
868
+ category: note["category"],
869
+ tags: note["tags"],
870
+ created_at: note["created_at"],
871
+ updated_at: note["updated_at"]
872
+ };
873
+ const content = String(note["content"] ?? "");
874
+ listing[args.include_content === true ? "content" : "content_preview"] = args.include_content === true ? content : content.length > 280 ? `${content.slice(0, 280)}...` : content;
875
+ return listing;
876
+ });
877
+ return {
878
+ success: true,
879
+ notes: entries,
880
+ filtered_count: entries.length,
881
+ total_count: notes.size
882
+ };
883
+ })
884
+ }));
885
+ ctx.tools.register(defineTool({
886
+ name: "get_note",
887
+ description: "Fetch one note by id.",
888
+ parameters: { note_id: {
889
+ type: "string",
890
+ required: true,
891
+ description: "Note id from list_notes or create_note."
892
+ } },
893
+ output: {
894
+ schema: {
895
+ type: "object",
896
+ properties: {
897
+ success: {
898
+ type: "boolean",
899
+ required: true
900
+ },
901
+ note: {
902
+ type: "object",
903
+ properties: {},
904
+ additionalProperties: true
905
+ },
906
+ error: { type: "string" }
907
+ },
908
+ additionalProperties: false
909
+ },
910
+ render: (_args, value) => {
911
+ const result = value;
912
+ return [{
913
+ type: "text",
914
+ text: result.success ? "note returned" : `get_note failed: ${result.error ?? "unknown"}`
915
+ }];
916
+ }
917
+ },
918
+ execute: (async (rawArgs) => {
919
+ const noteId = (rawArgs.note_id ?? "").trim();
920
+ if (noteId === "") return {
921
+ success: false,
922
+ error: "Note ID cannot be empty"
923
+ };
924
+ const note = notes.get(noteId);
925
+ if (note === void 0) return {
926
+ success: false,
927
+ error: `Note with ID '${noteId}' not found`
928
+ };
929
+ return {
930
+ success: true,
931
+ note: {
932
+ ...note,
933
+ note_id: noteId
934
+ }
935
+ };
936
+ })
937
+ }));
938
+ ctx.tools.register(defineTool({
939
+ name: "update_note",
940
+ description: "Revise a note (only the fields you pass change; updated_at always bumps).",
941
+ parameters: {
942
+ note_id: {
943
+ type: "string",
944
+ required: true,
945
+ description: "Note id."
946
+ },
947
+ title: {
948
+ type: "string",
949
+ description: "Replacement title."
950
+ },
951
+ content: {
952
+ type: "string",
953
+ description: "Replacement content."
954
+ },
955
+ tags: {
956
+ type: "array",
957
+ items: { type: "string" },
958
+ description: "Replacement tags (full replace)."
959
+ }
960
+ },
961
+ output: {
962
+ schema: {
963
+ type: "object",
964
+ properties: {
965
+ success: {
966
+ type: "boolean",
967
+ required: true
968
+ },
969
+ note_id: { type: "string" },
970
+ message: { type: "string" },
971
+ total_count: { type: "integer" },
972
+ error: { type: "string" }
973
+ },
974
+ additionalProperties: false
975
+ },
976
+ render: (_args, value) => {
977
+ const result = value;
978
+ return [{
979
+ type: "text",
980
+ text: result.success ? "note updated" : `update_note failed: ${result.error ?? "unknown"}`
981
+ }];
982
+ }
983
+ },
984
+ execute: (async (rawArgs) => {
985
+ const args = rawArgs;
986
+ const noteId = (args.note_id ?? "").trim();
987
+ const note = notes.get(noteId);
988
+ if (note === void 0) return {
989
+ success: false,
990
+ error: `Note with ID '${noteId}' not found`
991
+ };
992
+ if (args.title !== void 0 && args.title.trim() === "") return {
993
+ success: false,
994
+ error: "Title cannot be empty"
995
+ };
996
+ if (args.content !== void 0 && args.content.trim() === "") return {
997
+ success: false,
998
+ error: "Content cannot be empty"
999
+ };
1000
+ if (args.title !== void 0) note["title"] = args.title.trim();
1001
+ if (args.content !== void 0) note["content"] = args.content.trim();
1002
+ if (args.tags !== void 0) note["tags"] = args.tags;
1003
+ note["updated_at"] = isoNow();
1004
+ await notes.persist();
1005
+ return {
1006
+ success: true,
1007
+ note_id: noteId,
1008
+ message: `Note '${String(note["title"])}' updated successfully`,
1009
+ total_count: notes.size
1010
+ };
1011
+ })
1012
+ }));
1013
+ ctx.tools.register(defineTool({
1014
+ name: "delete_note",
1015
+ description: "Delete a note by id.",
1016
+ parameters: { note_id: {
1017
+ type: "string",
1018
+ required: true,
1019
+ description: "Note id."
1020
+ } },
1021
+ output: {
1022
+ schema: {
1023
+ type: "object",
1024
+ properties: {
1025
+ success: {
1026
+ type: "boolean",
1027
+ required: true
1028
+ },
1029
+ note_id: { type: "string" },
1030
+ message: { type: "string" },
1031
+ total_count: { type: "integer" },
1032
+ error: { type: "string" }
1033
+ },
1034
+ additionalProperties: false
1035
+ },
1036
+ render: (_args, value) => {
1037
+ const result = value;
1038
+ return [{
1039
+ type: "text",
1040
+ text: result.success ? "note deleted" : `delete_note failed: ${result.error ?? "unknown"}`
1041
+ }];
1042
+ }
1043
+ },
1044
+ execute: (async (rawArgs) => {
1045
+ const noteId = (rawArgs.note_id ?? "").trim();
1046
+ const note = notes.get(noteId);
1047
+ if (note === void 0) return {
1048
+ success: false,
1049
+ error: `Note with ID '${noteId}' not found`
1050
+ };
1051
+ notes.delete(noteId);
1052
+ await notes.persist();
1053
+ return {
1054
+ success: true,
1055
+ note_id: noteId,
1056
+ message: `Note '${String(note["title"])}' deleted successfully`,
1057
+ total_count: notes.size
1058
+ };
1059
+ })
1060
+ }));
1061
+ ctx.tools.register(defineTool({
1062
+ name: "think",
1063
+ description: "Record a private reasoning step (no storage; use notes for persistent knowledge).",
1064
+ parameters: { thought: {
1065
+ type: "string",
1066
+ required: true,
1067
+ description: "The reasoning step."
1068
+ } },
1069
+ output: {
1070
+ schema: {
1071
+ type: "object",
1072
+ properties: {
1073
+ success: {
1074
+ type: "boolean",
1075
+ required: true
1076
+ },
1077
+ message: { type: "string" },
1078
+ error: { type: "string" }
1079
+ },
1080
+ additionalProperties: false
1081
+ },
1082
+ render: (_args, value) => {
1083
+ const result = value;
1084
+ return [{
1085
+ type: "text",
1086
+ text: result.success ? "recorded" : `think failed: ${result.error ?? "unknown"}`
1087
+ }];
1088
+ }
1089
+ },
1090
+ execute: (async (rawArgs) => {
1091
+ if ((rawArgs.thought ?? "").trim() === "") return {
1092
+ success: false,
1093
+ error: "Thought cannot be empty"
1094
+ };
1095
+ return {
1096
+ success: true,
1097
+ message: "Thought recorded"
1098
+ };
1099
+ })
1100
+ }));
1101
+ ctx.tools.register(defineTool({
1102
+ name: "finish_scan",
1103
+ description: "Complete the scan: validates the four report sections, records the coverage summary, writes the final artifacts, and marks the scan completed. Root agent only.",
1104
+ parameters: {
1105
+ executive_summary: {
1106
+ type: "string",
1107
+ required: true,
1108
+ description: "Non-technical summary for stakeholders."
1109
+ },
1110
+ methodology: {
1111
+ type: "string",
1112
+ required: true,
1113
+ description: "How the scan was conducted."
1114
+ },
1115
+ technical_analysis: {
1116
+ type: "string",
1117
+ required: true,
1118
+ description: "Technical findings analysis."
1119
+ },
1120
+ recommendations: {
1121
+ type: "string",
1122
+ required: true,
1123
+ description: "Prioritized remediation recommendations."
1124
+ }
1125
+ },
1126
+ output: {
1127
+ schema: {
1128
+ type: "object",
1129
+ properties: {
1130
+ success: {
1131
+ type: "boolean",
1132
+ required: true
1133
+ },
1134
+ scan_completed: { type: "boolean" },
1135
+ message: { type: "string" },
1136
+ vulnerabilities_found: { type: "integer" },
1137
+ coverage_recorded: { type: "integer" },
1138
+ coverage_outcomes: {
1139
+ type: "object",
1140
+ properties: {},
1141
+ additionalProperties: true
1142
+ },
1143
+ coverage_warning: { type: "string" },
1144
+ unresolved_surfaces: {
1145
+ type: "array",
1146
+ items: {
1147
+ type: "object",
1148
+ properties: {},
1149
+ additionalProperties: true
1150
+ }
1151
+ },
1152
+ warning: { type: "string" },
1153
+ error: { type: "string" },
1154
+ errors: {
1155
+ type: "array",
1156
+ items: { type: "string" }
1157
+ }
1158
+ },
1159
+ additionalProperties: false
1160
+ },
1161
+ render: (_args, value) => {
1162
+ const result = value;
1163
+ const reason = result.error ?? result.errors?.join("; ") ?? "unknown";
1164
+ return [{
1165
+ type: "text",
1166
+ text: result.success ? "scan completed" : `finish_scan failed: ${reason}`
1167
+ }];
1168
+ }
1169
+ },
1170
+ execute: (async (rawArgs) => {
1171
+ const args = rawArgs;
1172
+ if (config.allowFinish === false) return {
1173
+ success: false,
1174
+ scan_completed: false,
1175
+ error: "This tool can only be used by the root/main agent. If you are a subagent, use agent_finish instead"
1176
+ };
1177
+ const sections = {
1178
+ executiveSummary: (args.executive_summary ?? "").trim(),
1179
+ methodology: (args.methodology ?? "").trim(),
1180
+ technicalAnalysis: (args.technical_analysis ?? "").trim(),
1181
+ recommendations: (args.recommendations ?? "").trim()
1182
+ };
1183
+ const errors = [];
1184
+ if (sections.executiveSummary === "") errors.push("Executive summary cannot be empty");
1185
+ if (sections.methodology === "") errors.push("Methodology cannot be empty");
1186
+ if (sections.technicalAnalysis === "") errors.push("Technical analysis cannot be empty");
1187
+ if (sections.recommendations === "") errors.push("Recommendations cannot be empty");
1188
+ if (errors.length > 0) return {
1189
+ success: false,
1190
+ error: "Validation failed",
1191
+ errors
1192
+ };
1193
+ const summary = {
1194
+ coverage_recorded: coverage.size,
1195
+ coverage_outcomes: outcomeCounts()
1196
+ };
1197
+ if (coverage.size === 0) summary["coverage_warning"] = "No coverage was recorded for this scan. The report cannot state what was and was not tested; record coverage with record_coverage in future scans.";
1198
+ else {
1199
+ const unresolved = coverageEntries().filter((entry) => entry["outcome"] === "needs_follow_up").map((entry) => ({
1200
+ surface: entry["surface"],
1201
+ risk_area: entry["risk_area"]
1202
+ }));
1203
+ if (unresolved.length > 0) {
1204
+ summary["coverage_warning"] = `${String(unresolved.length)} surface(s) still need follow-up; they are listed in unresolved_surfaces.`;
1205
+ summary["unresolved_surfaces"] = unresolved;
1206
+ }
1207
+ }
1208
+ const reporting = ctx.pentestReporting;
1209
+ if (reporting === void 0) return {
1210
+ success: true,
1211
+ scan_completed: true,
1212
+ message: "Scan completed (not persisted)",
1213
+ warning: "Results could not be persisted - report state unavailable",
1214
+ ...summary
1215
+ };
1216
+ await reporting.finishScan(sections);
1217
+ return {
1218
+ success: true,
1219
+ scan_completed: true,
1220
+ message: "Scan completed successfully",
1221
+ vulnerabilities_found: reporting.state.vulnerabilityReports.length,
1222
+ ...summary
1223
+ };
1224
+ })
1225
+ }));
1226
+ return handle;
1227
+ }
1228
+ //#endregion
1229
+ export { VALID_NOTE_CATEGORIES, VALID_OUTCOMES, apply, inject, name, normalizeTargetIdentity };
1230
+
1231
+ //# sourceMappingURL=index.js.map