@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/src/index.ts ADDED
@@ -0,0 +1,815 @@
1
+ /**
2
+ * Analysis tools — port of strix tools/{threat_model,coverage,notes,
3
+ * thinking}/ + finish_scan: get/save/amend_threat_model (validated
4
+ * sections, size gates, append-only amendments), record/update/
5
+ * list_coverage (duplicate guard, evidence requirements, history),
6
+ * create/list/get/update/delete_note, think, and finish_scan (root-only,
7
+ * four required sections, coverage summary, final artifacts). State lives
8
+ * in per-scan mirror stores under the run dir (.state parity) and is
9
+ * provided as `pentestAnalysis` for the reporting package's coverage
10
+ * document and SARIF bridge.
11
+ * @module @gpzhang2001/sharpkit-analysis
12
+ */
13
+
14
+ import { join } from 'node:path'
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+ import { JsonMirrorStore, generateId, normalizeTargetIdentity } from './stores.ts'
18
+
19
+ export { normalizeTargetIdentity } from './stores.ts'
20
+
21
+ /** Coverage outcome values (strix VALID_OUTCOMES). */
22
+ export const VALID_OUTCOMES = ['reported', 'no_issue_found', 'ruled_out', 'not_applicable', 'needs_follow_up'] as const
23
+
24
+ /** Outcomes that require evidence text (strix `_OUTCOMES_REQUIRING_EVIDENCE`). */
25
+ const OUTCOMES_REQUIRING_EVIDENCE = new Set(['ruled_out', 'not_applicable', 'needs_follow_up'])
26
+
27
+ /** Note categories (strix `_VALID_NOTE_CATEGORIES`). */
28
+ export const VALID_NOTE_CATEGORIES = ['general', 'findings', 'methodology', 'questions', 'plan', 'wiki'] as const
29
+
30
+ /** Required threat-model sections, matched as lowercase substrings (strix parity). */
31
+ const REQUIRED_SECTIONS = ['overview', 'trust boundaries', 'attack surface', 'severity calibration'] as const
32
+
33
+ /** Threat model gates (strix constants). */
34
+ const MAX_MODEL_BYTES = 512 * 1024
35
+ const MIN_MODEL_CHARS = 400
36
+ const MIN_AMENDMENT_CHARS = 80
37
+ const MAX_AMENDMENTS = 40
38
+
39
+ /** Display timestamp in the coverage format. */
40
+ function displayTimestamp(date: Date): string {
41
+ const pad = (value: number): string => String(value).padStart(2, '0')
42
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`
43
+ }
44
+
45
+ /** Deployment-tunable configuration. */
46
+ export interface Config {
47
+ /** Sandbox session cache key shared with the other tool packages. */
48
+ readonly scanId?: string
49
+ /** Scan targets (threat-model identity snapping). */
50
+ readonly scanTargets?: readonly string[]
51
+ /** Run directory for the .state mirrors (default sharpkit_runs/<scanId>/.state). */
52
+ readonly runDir?: string
53
+ /** Whether this composition's agent may call finish_scan (root guard). */
54
+ readonly allowFinish?: boolean
55
+ }
56
+
57
+ export const name = 'pentest-tool-analysis'
58
+
59
+ export const inject = ['tools', 'pentestReporting']
60
+
61
+ /** The per-scan analysis stores exposed to other packages. */
62
+ export interface AnalysisHandle {
63
+ readonly coverage: JsonMirrorStore
64
+ readonly threatModels: JsonMirrorStore
65
+ readonly notes: JsonMirrorStore
66
+ /** Coverage entries in list order with ids (reporting package consumes). */
67
+ coverageEntries(): Array<Record<string, unknown> & { entry_id: string }>
68
+ outcomeCounts(): Record<string, number>
69
+ }
70
+
71
+ declare module '@deepseek-ai/cordis' {
72
+ interface Context {
73
+ pentestAnalysis: AnalysisHandle
74
+ }
75
+ }
76
+
77
+ /** ISO-8601 timestamp (threat model + notes stores). */
78
+ function isoNow(): string {
79
+ return new Date().toISOString().replace('Z', '+00:00')
80
+ }
81
+
82
+ export function apply(ctx: Context, config: Config = {}): AnalysisHandle {
83
+ const scanId = config.scanId ?? 'pentest'
84
+ const runDir = config.runDir ?? join('sharpkit_runs', scanId)
85
+ const stateDir = join(runDir, '.state')
86
+ const coverage = new JsonMirrorStore(join(stateDir, 'coverage.json'))
87
+ const threatModels = new JsonMirrorStore(join(stateDir, 'threat_models.json'))
88
+ const notes = new JsonMirrorStore(join(stateDir, 'notes.json'))
89
+ void coverage.hydrate()
90
+ void threatModels.hydrate()
91
+ void notes.hydrate()
92
+
93
+ const coverageEntries = (): Array<Record<string, unknown> & { entry_id: string }> =>
94
+ coverage.values().map(entry => ({ ...entry, entry_id: String(entry['id'] ?? '') })) as Array<Record<string, unknown> & { entry_id: string }>
95
+
96
+ const outcomeCounts = (): Record<string, number> => {
97
+ const counts: Record<string, number> = {}
98
+ for (const outcome of VALID_OUTCOMES) {
99
+ const count = coverage.values().filter(entry => entry['outcome'] === outcome).length
100
+ if (count > 0) counts[outcome] = count
101
+ }
102
+ return counts
103
+ }
104
+
105
+ const handle: AnalysisHandle = { coverage, threatModels, notes, coverageEntries, outcomeCounts }
106
+ ctx.provide('pentestAnalysis', handle)
107
+
108
+ const resolveTarget = (target: string | undefined): { readonly identity: string } | { readonly error: string } => {
109
+ const cleaned = target?.trim() ?? ''
110
+ if (cleaned === '') {
111
+ const targets = config.scanTargets ?? []
112
+ if (targets.length === 1) return { identity: normalizeTargetIdentity(targets[0] ?? '') }
113
+ return { error: 'target cannot be empty - name the target this threat model describes' }
114
+ }
115
+ return { identity: normalizeTargetIdentity(cleaned) }
116
+ }
117
+
118
+ ctx.tools.register(defineTool({
119
+ name: 'get_threat_model',
120
+ description: 'Fetch the threat model shared for a target in this scan (found=false when none exists yet). Read-only.',
121
+ parameters: {
122
+ target: { type: 'string', description: 'Target the model describes; omit when the scan has exactly one target.' },
123
+ },
124
+ output: {
125
+ schema: {
126
+ type: 'object',
127
+ properties: {
128
+ success: { type: 'boolean', required: true },
129
+ found: { type: 'boolean' },
130
+ target: { type: 'string' },
131
+ content: { type: 'string' },
132
+ amendments: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
133
+ amendments_note: { type: 'string' },
134
+ message: { type: 'string' },
135
+ error: { type: 'string' },
136
+ },
137
+ additionalProperties: false,
138
+ },
139
+ render: (_args, value) => {
140
+ const result = value as { success: boolean; found?: boolean; error?: string }
141
+ if (!result.success) return [{ type: 'text', text: `get_threat_model failed: ${result.error ?? 'unknown'}` }]
142
+ return [{ type: 'text', text: result.found === true ? 'threat model found' : 'no threat model yet' }]
143
+ },
144
+ },
145
+ execute: (async (rawArgs: never) => {
146
+ const args = rawArgs as never as { target?: string }
147
+ const resolved = resolveTarget(args.target)
148
+ if ('error' in resolved) return { success: false, error: resolved.error }
149
+ const model = threatModels.get(resolved.identity)
150
+ if (model === undefined || String(model['content'] ?? '').trim() === '') {
151
+ return { success: true, found: false, target: resolved.identity, message: 'No threat model exists for this target yet. Save one with save_threat_model.' }
152
+ }
153
+ const amendments = model['amendments'] as unknown[] | undefined
154
+ return {
155
+ success: true,
156
+ found: true,
157
+ target: resolved.identity,
158
+ content: String(model['content']),
159
+ ...(amendments !== undefined && amendments.length > 0 ? { amendments, amendments_note: 'Addenda recorded by agents after the model was saved.' } : {}),
160
+ }
161
+ }) as never,
162
+ }))
163
+
164
+ ctx.tools.register(defineTool({
165
+ name: 'save_threat_model',
166
+ 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.`,
167
+ parameters: {
168
+ target: { type: 'string', description: 'Target the model describes; omit when the scan has exactly one target.' },
169
+ content: { type: 'string', required: true, description: 'The full markdown threat model.' },
170
+ },
171
+ output: {
172
+ schema: {
173
+ type: 'object',
174
+ properties: {
175
+ success: { type: 'boolean', required: true },
176
+ target: { type: 'string' },
177
+ amendments_cleared: { type: 'integer' },
178
+ message: { type: 'string' },
179
+ error: { type: 'string' },
180
+ },
181
+ additionalProperties: false,
182
+ },
183
+ render: (_args, value) => {
184
+ const result = value as { success: boolean; error?: string }
185
+ return [{ type: 'text', text: result.success ? 'threat model saved' : `save_threat_model failed: ${result.error ?? 'unknown'}` }]
186
+ },
187
+ },
188
+ execute: (async (rawArgs: never) => {
189
+ const args = rawArgs as never as { target?: string; content: string }
190
+ const resolved = resolveTarget(args.target)
191
+ if ('error' in resolved) return { success: false, error: resolved.error }
192
+ const content = args.content.trim()
193
+ if (content.length < MIN_MODEL_CHARS) {
194
+ return { success: false, error: `Threat model is too thin (${String(content.length)} chars). It has to be usable by every agent in this scan.` }
195
+ }
196
+ if (Buffer.byteLength(content, 'utf8') > MAX_MODEL_BYTES) return { success: false, error: 'Threat model exceeds 512KB; tighten it.' }
197
+ const lower = content.toLowerCase()
198
+ const missing = REQUIRED_SECTIONS.filter(section => !lower.includes(section))
199
+ if (missing.length > 0) {
200
+ return { success: false, error: `Threat model is missing required section(s): ${missing.join(', ')}. Cover Overview, Trust Boundaries, Attack Surface, and Severity Calibration.` }
201
+ }
202
+ const previous = threatModels.get(resolved.identity)
203
+ const previousAmendments = previous?.['amendments']
204
+ const amendmentsCleared = Array.isArray(previousAmendments) ? previousAmendments.length : 0
205
+ threatModels.set(resolved.identity, {
206
+ target: resolved.identity,
207
+ written_at: isoNow(),
208
+ written_by: null,
209
+ content,
210
+ })
211
+ await threatModels.persist()
212
+ return {
213
+ success: true,
214
+ target: resolved.identity,
215
+ amendments_cleared: amendmentsCleared,
216
+ message: amendmentsCleared > 0
217
+ ? `Threat model shared with this scan. It replaced a previous model, folding ${String(amendmentsCleared)} amendment(s) into the full rewrite.`
218
+ : 'Threat model shared with this scan.',
219
+ }
220
+ }) as never,
221
+ }))
222
+
223
+ ctx.tools.register(defineTool({
224
+ name: 'amend_threat_model',
225
+ 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.`,
226
+ parameters: {
227
+ target: { type: 'string', description: 'Target the model describes; omit when the scan has exactly one target.' },
228
+ addendum: { type: 'string', required: true, description: 'The markdown addendum.' },
229
+ },
230
+ output: {
231
+ schema: {
232
+ type: 'object',
233
+ properties: {
234
+ success: { type: 'boolean', required: true },
235
+ target: { type: 'string' },
236
+ amendment_count: { type: 'integer' },
237
+ message: { type: 'string' },
238
+ error: { type: 'string' },
239
+ },
240
+ additionalProperties: false,
241
+ },
242
+ render: (_args, value) => {
243
+ const result = value as { success: boolean; error?: string }
244
+ return [{ type: 'text', text: result.success ? 'amendment recorded' : `amend_threat_model failed: ${result.error ?? 'unknown'}` }]
245
+ },
246
+ },
247
+ execute: (async (rawArgs: never) => {
248
+ const args = rawArgs as never as { target?: string; addendum: string }
249
+ const resolved = resolveTarget(args.target)
250
+ if ('error' in resolved) return { success: false, error: resolved.error }
251
+ const addendum = args.addendum.trim()
252
+ if (addendum.length < MIN_AMENDMENT_CHARS) {
253
+ return { success: false, error: `Amendment is too thin (${String(addendum.length)} chars). Give the new knowledge in full.` }
254
+ }
255
+ const model = threatModels.get(resolved.identity)
256
+ if (model === undefined) return { success: false, error: 'No threat model exists for this target yet. Save the full model with save_threat_model first.' }
257
+ const amendments = (model['amendments'] as unknown[] | undefined) ?? []
258
+ if (amendments.length >= MAX_AMENDMENTS) {
259
+ return { success: false, error: `This threat model already carries ${String(amendments.length)} amendments. Fold them into a full save_threat_model rewrite.` }
260
+ }
261
+ if (Buffer.byteLength(String(model['content']) + addendum, 'utf8') > MAX_MODEL_BYTES) {
262
+ return { success: false, error: 'Combined threat model exceeds 512KB; fold amendments with save_threat_model.' }
263
+ }
264
+ amendments.push({ at: isoNow(), by: null, content: addendum })
265
+ model['amendments'] = amendments
266
+ await threatModels.persist()
267
+ return { success: true, target: resolved.identity, amendment_count: amendments.length, message: 'Amendment recorded. Every agent reading the threat model will see it.' }
268
+ }) as never,
269
+ }))
270
+
271
+ // ---- coverage ----
272
+
273
+ interface CoverageValidateInput {
274
+ readonly surface: string
275
+ readonly riskArea: string
276
+ readonly outcome: string
277
+ readonly evidence: string
278
+ }
279
+
280
+ /** strix coverage `_validate`: normalization + per-field errors. */
281
+ function validateCoverage(input: CoverageValidateInput): { readonly outcome: string } | { readonly errors: string[] } {
282
+ const errors: string[] = []
283
+ if (input.surface === '') errors.push('surface cannot be empty - name the endpoint, route, file, or component')
284
+ if (input.riskArea === '') errors.push('risk_area cannot be empty - name what you were testing for')
285
+ const outcome = input.outcome.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_')
286
+ if (!(VALID_OUTCOMES as readonly string[]).includes(outcome)) {
287
+ errors.push(`Invalid outcome: '${input.outcome}'. Must be one of: [${VALID_OUTCOMES.join(', ')}]`)
288
+ }
289
+ if (OUTCOMES_REQUIRING_EVIDENCE.has(outcome) && input.evidence === '') {
290
+ errors.push(`evidence is required for outcome '${outcome}' - name the specific control, response, or observation that justifies it`)
291
+ }
292
+ return errors.length > 0 ? { errors } : { outcome }
293
+ }
294
+
295
+ const findCoverageDuplicate = (surface: string, riskArea: string): Record<string, unknown> | undefined =>
296
+ coverage.values().find(entry => String(entry['surface']).toLowerCase() === surface.toLowerCase() && String(entry['risk_area']).toLowerCase() === riskArea.toLowerCase())
297
+
298
+ ctx.tools.register(defineTool({
299
+ name: 'record_coverage',
300
+ 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).",
301
+ parameters: {
302
+ surface: { type: 'string', required: true, description: 'The endpoint, route, file, or component you exercised.' },
303
+ risk_area: { type: 'string', required: true, description: 'What you were testing for (e.g. "SQL injection in login").' },
304
+ outcome: { type: 'string', required: true, enum: [...VALID_OUTCOMES], description: 'The testing outcome.' },
305
+ evidence: { type: 'string', description: 'Concrete observation justifying the outcome (required for some outcomes).' },
306
+ },
307
+ output: {
308
+ schema: {
309
+ type: 'object',
310
+ properties: {
311
+ success: { type: 'boolean', required: true },
312
+ entry_id: { type: 'string' },
313
+ outcome: { type: 'string' },
314
+ message: { type: 'string' },
315
+ error: { type: 'string' },
316
+ errors: { type: 'array', items: { type: 'string' } },
317
+ existing_entry_id: { type: 'string' },
318
+ existing_outcome: { type: 'string' },
319
+ },
320
+ additionalProperties: false,
321
+ },
322
+ render: (_args, value) => {
323
+ const result = value as { success: boolean; error?: string; errors?: string[] }
324
+ const reason = result.error ?? result.errors?.join('; ') ?? 'unknown'
325
+ return [{ type: 'text', text: result.success ? 'coverage recorded' : `record_coverage failed: ${reason}` }]
326
+ },
327
+ },
328
+ execute: (async (rawArgs: never) => {
329
+ const args = rawArgs as never as { surface?: string; risk_area?: string; outcome?: string; evidence?: string }
330
+ const surface = (args.surface ?? '').trim()
331
+ const riskArea = (args.risk_area ?? '').trim()
332
+ const evidence = (args.evidence ?? '').trim()
333
+ const checked = validateCoverage({ surface, riskArea, outcome: args.outcome ?? '', evidence })
334
+ if ('errors' in checked) return { success: false, error: 'Validation failed', errors: checked.errors }
335
+ const duplicate = findCoverageDuplicate(surface, riskArea)
336
+ if (duplicate !== undefined) {
337
+ return {
338
+ success: false,
339
+ 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.`,
340
+ existing_entry_id: String(duplicate['id']),
341
+ existing_outcome: String(duplicate['outcome']),
342
+ }
343
+ }
344
+ const id = generateId(new Set(coverage.values().map(entry => String(entry['id'] ?? ''))))
345
+ if (id === null) return { success: false, error: 'could not allocate a coverage entry id' }
346
+ coverage.set(id, {
347
+ id,
348
+ surface,
349
+ risk_area: riskArea,
350
+ outcome: checked.outcome,
351
+ created_at: displayTimestamp(new Date()),
352
+ ...(evidence !== '' ? { evidence } : {}),
353
+ })
354
+ await coverage.persist()
355
+ return { success: true, entry_id: id, outcome: checked.outcome, message: `Coverage recorded for '${surface}' (${checked.outcome})` }
356
+ }) as never,
357
+ }))
358
+
359
+ ctx.tools.register(defineTool({
360
+ name: 'update_coverage',
361
+ description: "Move an existing coverage entry to a new outcome (surface and risk_area are never editable). The previous state is kept as history.",
362
+ parameters: {
363
+ entry_id: { type: 'string', required: true, description: 'Id from record_coverage or list_coverage.' },
364
+ outcome: { type: 'string', required: true, enum: [...VALID_OUTCOMES], description: 'The new outcome.' },
365
+ evidence: { type: 'string', description: 'Evidence for the new outcome (required for some outcomes).' },
366
+ },
367
+ output: {
368
+ schema: {
369
+ type: 'object',
370
+ properties: {
371
+ success: { type: 'boolean', required: true },
372
+ entry_id: { type: 'string' },
373
+ previous_outcome: { type: 'string' },
374
+ outcome: { type: 'string' },
375
+ message: { type: 'string' },
376
+ error: { type: 'string' },
377
+ errors: { type: 'array', items: { type: 'string' } },
378
+ },
379
+ additionalProperties: false,
380
+ },
381
+ render: (_args, value) => {
382
+ const result = value as { success: boolean; error?: string; errors?: string[] }
383
+ const reason = result.error ?? result.errors?.join('; ') ?? 'unknown'
384
+ return [{ type: 'text', text: result.success ? 'coverage updated' : `update_coverage failed: ${reason}` }]
385
+ },
386
+ },
387
+ execute: (async (rawArgs: never) => {
388
+ const args = rawArgs as never as { entry_id?: string; outcome?: string; evidence?: string }
389
+ const entryId = (args.entry_id ?? '').trim()
390
+ const entry = coverage.get(entryId)
391
+ if (entry === undefined) return { success: false, error: `No coverage entry '${entryId}'. Call list_coverage to see recorded entries.` }
392
+ const surface = String(entry['surface'] ?? '')
393
+ const riskArea = String(entry['risk_area'] ?? '')
394
+ const evidence = (args.evidence ?? '').trim()
395
+ const checked = validateCoverage({ surface, riskArea, outcome: args.outcome ?? '', evidence })
396
+ if ('errors' in checked) return { success: false, error: 'Validation failed', errors: checked.errors }
397
+ const previousOutcome = String(entry['outcome'])
398
+ const history = (entry['history'] as Array<Record<string, unknown>> | undefined) ?? []
399
+ const prior: Record<string, unknown> = { outcome: previousOutcome, recorded_at: String(entry['created_at']) }
400
+ if (entry['evidence'] !== undefined) prior['evidence'] = entry['evidence']
401
+ history.push(prior)
402
+ entry['history'] = history
403
+ entry['outcome'] = checked.outcome
404
+ entry['updated_at'] = displayTimestamp(new Date())
405
+ if (evidence !== '') entry['evidence'] = evidence
406
+ await coverage.persist()
407
+ return {
408
+ success: true,
409
+ entry_id: entryId,
410
+ previous_outcome: previousOutcome,
411
+ outcome: checked.outcome,
412
+ message: `'${surface}' (${riskArea}) moved from ${previousOutcome} to ${checked.outcome}. The previous state is kept as history.`,
413
+ }
414
+ }) as never,
415
+ }))
416
+
417
+ ctx.tools.register(defineTool({
418
+ name: 'list_coverage',
419
+ description: 'List coverage entries recorded in this scan (filters compose; outcome counts are over all entries).',
420
+ parameters: {
421
+ outcome: { type: 'string', description: 'Filter to one outcome.' },
422
+ surface: { type: 'string', description: 'Case-insensitive substring match on the surface.' },
423
+ },
424
+ output: {
425
+ schema: {
426
+ type: 'object',
427
+ properties: {
428
+ success: { type: 'boolean', required: true },
429
+ entries: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true }, required: true },
430
+ filtered_count: { type: 'integer', required: true },
431
+ total_count: { type: 'integer', required: true },
432
+ outcome_counts: { type: 'object', properties: {}, additionalProperties: true, required: true },
433
+ error: { type: 'string' },
434
+ },
435
+ additionalProperties: false,
436
+ },
437
+ render: (_args, value) => {
438
+ const result = value as { entries?: unknown[]; error?: string }
439
+ if (result.error !== undefined) return [{ type: 'text', text: `list_coverage failed: ${result.error}` }]
440
+ return [{ type: 'text', text: `${String(result.entries?.length ?? 0)} coverage entry(ies)` }]
441
+ },
442
+ },
443
+ execute: (async (rawArgs: never) => {
444
+ const args = rawArgs as never as { outcome?: string; surface?: string }
445
+ let outcomeFilter: string | undefined
446
+ if (args.outcome !== undefined && args.outcome !== '') {
447
+ outcomeFilter = args.outcome.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_')
448
+ if (outcomeFilter !== undefined && !(VALID_OUTCOMES as readonly string[]).includes(outcomeFilter)) {
449
+ return {
450
+ success: false,
451
+ error: `Invalid outcome: '${args.outcome}'. Must be one of: [${VALID_OUTCOMES.join(', ')}]`,
452
+ entries: [],
453
+ filtered_count: 0,
454
+ total_count: coverage.size,
455
+ outcome_counts: {},
456
+ }
457
+ }
458
+ }
459
+ const surfaceFilter = args.surface?.toLowerCase() ?? ''
460
+ const entries = coverageEntries()
461
+ .filter(entry => (outcomeFilter === undefined || entry['outcome'] === outcomeFilter))
462
+ .filter(entry => surfaceFilter === '' || String(entry['surface']).toLowerCase().includes(surfaceFilter))
463
+ .sort((a, b) => String(a['created_at']).localeCompare(String(b['created_at'])))
464
+ .map(entry => {
465
+ const listing: Record<string, unknown> = {
466
+ entry_id: entry.entry_id,
467
+ surface: entry['surface'],
468
+ risk_area: entry['risk_area'],
469
+ outcome: entry['outcome'],
470
+ created_at: entry['created_at'],
471
+ }
472
+ const evidence = entry['evidence']
473
+ if (typeof evidence === 'string' && evidence !== '') {
474
+ listing['evidence'] = evidence.length > 240 ? `${evidence.slice(0, 240)}...` : evidence
475
+ }
476
+ const history = entry['history'] as Array<Record<string, unknown>> | undefined
477
+ if (history !== undefined && history.length > 0) {
478
+ listing['previous_outcomes'] = history.map(item => item['outcome'])
479
+ }
480
+ return listing
481
+ })
482
+ return {
483
+ success: true,
484
+ entries,
485
+ filtered_count: entries.length,
486
+ total_count: coverage.size,
487
+ outcome_counts: outcomeCounts(),
488
+ }
489
+ }) as never,
490
+ }))
491
+
492
+ // ---- notes ----
493
+
494
+ const noteList = (): Array<Record<string, unknown> & { id: string }> =>
495
+ notes.values().map(entry => ({ ...entry, id: String(entry['id'] ?? '') })) as Array<Record<string, unknown> & { id: string }>
496
+
497
+ ctx.tools.register(defineTool({
498
+ name: 'create_note',
499
+ description: 'Create a persistent note shared across the scan team (categories: general/findings/methodology/questions/plan/wiki).',
500
+ parameters: {
501
+ title: { type: 'string', required: true, description: 'Short note title.' },
502
+ content: { type: 'string', required: true, description: 'The note body.' },
503
+ category: { type: 'string', enum: [...VALID_NOTE_CATEGORIES], description: 'Note category (default general).' },
504
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
505
+ },
506
+ output: {
507
+ schema: {
508
+ type: 'object',
509
+ properties: {
510
+ success: { type: 'boolean', required: true },
511
+ note_id: { type: 'string' },
512
+ message: { type: 'string' },
513
+ total_count: { type: 'integer' },
514
+ error: { type: 'string' },
515
+ },
516
+ additionalProperties: false,
517
+ },
518
+ render: (_args, value) => {
519
+ const result = value as { success: boolean; error?: string }
520
+ return [{ type: 'text', text: result.success ? 'note created' : `create_note failed: ${result.error ?? 'unknown'}` }]
521
+ },
522
+ },
523
+ execute: (async (rawArgs: never) => {
524
+ const args = rawArgs as never as { title?: string; content?: string; category?: string; tags?: string[] }
525
+ const title = (args.title ?? '').trim()
526
+ const content = (args.content ?? '').trim()
527
+ const category = (args.category ?? 'general').trim()
528
+ if (title === '') return { success: false, error: 'Title cannot be empty' }
529
+ if (content === '') return { success: false, error: 'Content cannot be empty' }
530
+ if (!(VALID_NOTE_CATEGORIES as readonly string[]).includes(category)) {
531
+ return { success: false, error: `Invalid category. Must be one of: ${VALID_NOTE_CATEGORIES.join(', ')}` }
532
+ }
533
+ const id = generateId(new Set(noteList().map(note => note.id)))
534
+ if (id === null) return { success: false, error: 'could not allocate a note id' }
535
+ const now = isoNow()
536
+ notes.set(id, {
537
+ id,
538
+ title,
539
+ content,
540
+ category,
541
+ tags: args.tags ?? [],
542
+ created_at: now,
543
+ updated_at: now,
544
+ })
545
+ await notes.persist()
546
+ return { success: true, note_id: id, message: `Note '${title}' created successfully`, total_count: notes.size }
547
+ }) as never,
548
+ }))
549
+
550
+ ctx.tools.register(defineTool({
551
+ name: 'list_notes',
552
+ description: 'List notes (filters compose; newest first).',
553
+ parameters: {
554
+ category: { type: 'string', description: 'Exact category filter.' },
555
+ tags: { type: 'array', items: { type: 'string' }, description: 'ANY-match tag filter.' },
556
+ search: { type: 'string', description: 'Substring match on title or content.' },
557
+ include_content: { type: 'boolean', description: 'Full content instead of a 280-char preview.' },
558
+ },
559
+ output: {
560
+ schema: {
561
+ type: 'object',
562
+ properties: {
563
+ success: { type: 'boolean', required: true },
564
+ notes: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true }, required: true },
565
+ filtered_count: { type: 'integer', required: true },
566
+ total_count: { type: 'integer', required: true },
567
+ },
568
+ additionalProperties: false,
569
+ },
570
+ render: (_args, value) => {
571
+ const result = value as { notes?: unknown[] }
572
+ return [{ type: 'text', text: `${String(result.notes?.length ?? 0)} note(s)` }]
573
+ },
574
+ },
575
+ execute: (async (rawArgs: never) => {
576
+ const args = rawArgs as never as { category?: string; tags?: string[]; search?: string; include_content?: boolean }
577
+ const category = args.category?.trim() ?? ''
578
+ const search = args.search?.toLowerCase() ?? ''
579
+ const tags = args.tags ?? []
580
+ const entries = noteList()
581
+ .filter(note => category === '' || note['category'] === category)
582
+ .filter(note => tags.length === 0 || tags.some(tag => (note['tags'] as string[] | undefined)?.includes(tag) === true))
583
+ .filter(note => search === '' || String(note['title']).toLowerCase().includes(search) || String(note['content']).toLowerCase().includes(search))
584
+ .sort((a, b) => String(b['created_at']).localeCompare(String(a['created_at'])))
585
+ .map(note => {
586
+ const listing: Record<string, unknown> = {
587
+ note_id: note.id,
588
+ title: note['title'],
589
+ category: note['category'],
590
+ tags: note['tags'],
591
+ created_at: note['created_at'],
592
+ updated_at: note['updated_at'],
593
+ }
594
+ const content = String(note['content'] ?? '')
595
+ listing[args.include_content === true ? 'content' : 'content_preview'] = args.include_content === true ? content : content.length > 280 ? `${content.slice(0, 280)}...` : content
596
+ return listing
597
+ })
598
+ return { success: true, notes: entries, filtered_count: entries.length, total_count: notes.size }
599
+ }) as never,
600
+ }))
601
+
602
+ ctx.tools.register(defineTool({
603
+ name: 'get_note',
604
+ description: 'Fetch one note by id.',
605
+ parameters: {
606
+ note_id: { type: 'string', required: true, description: 'Note id from list_notes or create_note.' },
607
+ },
608
+ output: {
609
+ schema: {
610
+ type: 'object',
611
+ properties: {
612
+ success: { type: 'boolean', required: true },
613
+ note: { type: 'object', properties: {}, additionalProperties: true },
614
+ error: { type: 'string' },
615
+ },
616
+ additionalProperties: false,
617
+ },
618
+ render: (_args, value) => {
619
+ const result = value as { success: boolean; error?: string }
620
+ return [{ type: 'text', text: result.success ? 'note returned' : `get_note failed: ${result.error ?? 'unknown'}` }]
621
+ },
622
+ },
623
+ execute: (async (rawArgs: never) => {
624
+ const args = rawArgs as never as { note_id?: string }
625
+ const noteId = (args.note_id ?? '').trim()
626
+ if (noteId === '') return { success: false, error: 'Note ID cannot be empty' }
627
+ const note = notes.get(noteId)
628
+ if (note === undefined) return { success: false, error: `Note with ID '${noteId}' not found` }
629
+ return { success: true, note: { ...note, note_id: noteId } }
630
+ }) as never,
631
+ }))
632
+
633
+ ctx.tools.register(defineTool({
634
+ name: 'update_note',
635
+ description: 'Revise a note (only the fields you pass change; updated_at always bumps).',
636
+ parameters: {
637
+ note_id: { type: 'string', required: true, description: 'Note id.' },
638
+ title: { type: 'string', description: 'Replacement title.' },
639
+ content: { type: 'string', description: 'Replacement content.' },
640
+ tags: { type: 'array', items: { type: 'string' }, description: 'Replacement tags (full replace).' },
641
+ },
642
+ output: {
643
+ schema: {
644
+ type: 'object',
645
+ properties: {
646
+ success: { type: 'boolean', required: true },
647
+ note_id: { type: 'string' },
648
+ message: { type: 'string' },
649
+ total_count: { type: 'integer' },
650
+ error: { type: 'string' },
651
+ },
652
+ additionalProperties: false,
653
+ },
654
+ render: (_args, value) => {
655
+ const result = value as { success: boolean; error?: string }
656
+ return [{ type: 'text', text: result.success ? 'note updated' : `update_note failed: ${result.error ?? 'unknown'}` }]
657
+ },
658
+ },
659
+ execute: (async (rawArgs: never) => {
660
+ const args = rawArgs as never as { note_id?: string; title?: string; content?: string; tags?: string[] }
661
+ const noteId = (args.note_id ?? '').trim()
662
+ const note = notes.get(noteId)
663
+ if (note === undefined) return { success: false, error: `Note with ID '${noteId}' not found` }
664
+ if (args.title !== undefined && args.title.trim() === '') return { success: false, error: 'Title cannot be empty' }
665
+ if (args.content !== undefined && args.content.trim() === '') return { success: false, error: 'Content cannot be empty' }
666
+ if (args.title !== undefined) note['title'] = args.title.trim()
667
+ if (args.content !== undefined) note['content'] = args.content.trim()
668
+ if (args.tags !== undefined) note['tags'] = args.tags
669
+ note['updated_at'] = isoNow()
670
+ await notes.persist()
671
+ return { success: true, note_id: noteId, message: `Note '${String(note['title'])}' updated successfully`, total_count: notes.size }
672
+ }) as never,
673
+ }))
674
+
675
+ ctx.tools.register(defineTool({
676
+ name: 'delete_note',
677
+ description: 'Delete a note by id.',
678
+ parameters: {
679
+ note_id: { type: 'string', required: true, description: 'Note id.' },
680
+ },
681
+ output: {
682
+ schema: {
683
+ type: 'object',
684
+ properties: {
685
+ success: { type: 'boolean', required: true },
686
+ note_id: { type: 'string' },
687
+ message: { type: 'string' },
688
+ total_count: { type: 'integer' },
689
+ error: { type: 'string' },
690
+ },
691
+ additionalProperties: false,
692
+ },
693
+ render: (_args, value) => {
694
+ const result = value as { success: boolean; error?: string }
695
+ return [{ type: 'text', text: result.success ? 'note deleted' : `delete_note failed: ${result.error ?? 'unknown'}` }]
696
+ },
697
+ },
698
+ execute: (async (rawArgs: never) => {
699
+ const args = rawArgs as never as { note_id?: string }
700
+ const noteId = (args.note_id ?? '').trim()
701
+ const note = notes.get(noteId)
702
+ if (note === undefined) return { success: false, error: `Note with ID '${noteId}' not found` }
703
+ notes.delete(noteId)
704
+ await notes.persist()
705
+ return { success: true, note_id: noteId, message: `Note '${String(note['title'])}' deleted successfully`, total_count: notes.size }
706
+ }) as never,
707
+ }))
708
+
709
+ ctx.tools.register(defineTool({
710
+ name: 'think',
711
+ description: 'Record a private reasoning step (no storage; use notes for persistent knowledge).',
712
+ parameters: {
713
+ thought: { type: 'string', required: true, description: 'The reasoning step.' },
714
+ },
715
+ output: {
716
+ schema: {
717
+ type: 'object',
718
+ properties: {
719
+ success: { type: 'boolean', required: true },
720
+ message: { type: 'string' },
721
+ error: { type: 'string' },
722
+ },
723
+ additionalProperties: false,
724
+ },
725
+ render: (_args, value) => {
726
+ const result = value as { success: boolean; error?: string }
727
+ return [{ type: 'text', text: result.success ? 'recorded' : `think failed: ${result.error ?? 'unknown'}` }]
728
+ },
729
+ },
730
+ execute: (async (rawArgs: never) => {
731
+ const args = rawArgs as never as { thought?: string }
732
+ if ((args.thought ?? '').trim() === '') return { success: false, error: 'Thought cannot be empty' }
733
+ return { success: true, message: 'Thought recorded' }
734
+ }) as never,
735
+ }))
736
+
737
+ // ---- finish_scan ----
738
+
739
+ ctx.tools.register(defineTool({
740
+ name: 'finish_scan',
741
+ 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.',
742
+ parameters: {
743
+ executive_summary: { type: 'string', required: true, description: 'Non-technical summary for stakeholders.' },
744
+ methodology: { type: 'string', required: true, description: 'How the scan was conducted.' },
745
+ technical_analysis: { type: 'string', required: true, description: 'Technical findings analysis.' },
746
+ recommendations: { type: 'string', required: true, description: 'Prioritized remediation recommendations.' },
747
+ },
748
+ output: {
749
+ schema: {
750
+ type: 'object',
751
+ properties: {
752
+ success: { type: 'boolean', required: true },
753
+ scan_completed: { type: 'boolean' },
754
+ message: { type: 'string' },
755
+ vulnerabilities_found: { type: 'integer' },
756
+ coverage_recorded: { type: 'integer' },
757
+ coverage_outcomes: { type: 'object', properties: {}, additionalProperties: true },
758
+ coverage_warning: { type: 'string' },
759
+ unresolved_surfaces: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
760
+ warning: { type: 'string' },
761
+ error: { type: 'string' },
762
+ errors: { type: 'array', items: { type: 'string' } },
763
+ },
764
+ additionalProperties: false,
765
+ },
766
+ render: (_args, value) => {
767
+ const result = value as { success: boolean; error?: string; errors?: string[] }
768
+ const reason = result.error ?? result.errors?.join('; ') ?? 'unknown'
769
+ return [{ type: 'text', text: result.success ? 'scan completed' : `finish_scan failed: ${reason}` }]
770
+ },
771
+ },
772
+ execute: (async (rawArgs: never) => {
773
+ const args = rawArgs as never as { executive_summary?: string; methodology?: string; technical_analysis?: string; recommendations?: string }
774
+ if (config.allowFinish === false) {
775
+ return { success: false, scan_completed: false, error: 'This tool can only be used by the root/main agent. If you are a subagent, use agent_finish instead' }
776
+ }
777
+ const sections = {
778
+ executiveSummary: (args.executive_summary ?? '').trim(),
779
+ methodology: (args.methodology ?? '').trim(),
780
+ technicalAnalysis: (args.technical_analysis ?? '').trim(),
781
+ recommendations: (args.recommendations ?? '').trim(),
782
+ }
783
+ const errors: string[] = []
784
+ if (sections.executiveSummary === '') errors.push('Executive summary cannot be empty')
785
+ if (sections.methodology === '') errors.push('Methodology cannot be empty')
786
+ if (sections.technicalAnalysis === '') errors.push('Technical analysis cannot be empty')
787
+ if (sections.recommendations === '') errors.push('Recommendations cannot be empty')
788
+ if (errors.length > 0) return { success: false, error: 'Validation failed', errors }
789
+ const summary: Record<string, unknown> = {
790
+ coverage_recorded: coverage.size,
791
+ coverage_outcomes: outcomeCounts(),
792
+ }
793
+ if (coverage.size === 0) {
794
+ 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.'
795
+ } else {
796
+ const unresolved = coverageEntries()
797
+ .filter(entry => entry['outcome'] === 'needs_follow_up')
798
+ .map(entry => ({ surface: entry['surface'], risk_area: entry['risk_area'] }))
799
+ if (unresolved.length > 0) {
800
+ summary['coverage_warning'] = `${String(unresolved.length)} surface(s) still need follow-up; they are listed in unresolved_surfaces.`
801
+ summary['unresolved_surfaces'] = unresolved
802
+ }
803
+ }
804
+ const reporting = (ctx as unknown as { pentestReporting?: { finishScan(sections: { executiveSummary: string; methodology: string; technicalAnalysis: string; recommendations: string }, status?: string): Promise<void>; state: { vulnerabilityReports: unknown[] } } }).pentestReporting
805
+ if (reporting === undefined) {
806
+ return { success: true, scan_completed: true, message: 'Scan completed (not persisted)', warning: 'Results could not be persisted - report state unavailable', ...summary }
807
+ }
808
+ await reporting.finishScan(sections)
809
+ return { success: true, scan_completed: true, message: 'Scan completed successfully', vulnerabilities_found: reporting.state.vulnerabilityReports.length, ...summary }
810
+ }) as never,
811
+ }))
812
+
813
+ return handle
814
+ }
815
+