@lievclouds/dsh-cortex 0.3.9 → 0.4.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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

@@ -0,0 +1,208 @@
1
+ /**
2
+ * Defensive normalizers for agent-written asset JSON.
3
+ *
4
+ * MCP-era skills asked agents for one canonical shape; with the plugin tools
5
+ * the model sometimes writes aliases (`raw` instead of `request`, `type`/`file`
6
+ * instead of `info_type`/`source`, `param`/`where`/`example` instead of
7
+ * `name`/`location`/`values`). The original renderer and the memory-graph
8
+ * builder assume the canonical shape, so malformed-but-useful records used to
9
+ * crash the asset drawer / the nebula graph. Normalize on read and on write.
10
+ */
11
+
12
+ export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
13
+ export const LEAKED_INFO_TYPES = [
14
+ 'Credential',
15
+ 'API_Key',
16
+ 'Token',
17
+ 'PII',
18
+ 'Internal_IP',
19
+ 'Other',
20
+ ]
21
+ export const TEST_PARAMETER_LOCATIONS = ['path', 'query', 'body', 'header', 'cookie']
22
+
23
+ const text = (value) => (typeof value === 'string' ? value.trim() : '')
24
+ const pick = (...values) => {
25
+ for (const value of values) {
26
+ const candidate = text(value)
27
+ if (candidate.length > 0) return candidate
28
+ }
29
+ return ''
30
+ }
31
+
32
+ /**
33
+ * Canonical project label: agents sometimes write `上海春芝堂 SRC` and
34
+ * `上海春芝堂_SRC`; underscores and repeated spaces are display noise. The
35
+ * graph groups by this value, so normalizing it merges variant roots.
36
+ */
37
+ export function normalizeProjectName(value) {
38
+ const label = (typeof value === 'string' ? value : '')
39
+ .replace(/\s+/g, ' ')
40
+ .trim()
41
+ // CJK projects often get written as `上海春芝堂_SRC` vs `上海春芝堂 SRC`;
42
+ // merge those variants without renaming pure-ASCII projects like `app_web`.
43
+ return /[\u4e00-\u9fff]/.test(label) ? label.replace(/_/g, ' ').replace(/\s+/g, ' ').trim() : label
44
+ }
45
+
46
+ export function parseJsonArray(value) {
47
+ if (Array.isArray(value)) return value
48
+ if (typeof value === 'string' && value.trim().length > 0) {
49
+ try {
50
+ const parsed = JSON.parse(value)
51
+ return Array.isArray(parsed) ? parsed : []
52
+ } catch {
53
+ return []
54
+ }
55
+ }
56
+ return []
57
+ }
58
+
59
+ /** Pull the query string / body out of a captured raw HTTP request. */
60
+ export function extractParamsFromRaw(raw) {
61
+ const source = text(raw).replace(/\r\n/g, '\n')
62
+ if (source.length === 0) return ''
63
+ const lines = source.split('\n')
64
+ const requestLine = lines[0] || ''
65
+ const queryIndex = requestLine.indexOf('?')
66
+ const query = queryIndex >= 0 ? requestLine.slice(queryIndex).trim() : ''
67
+ const separator = source.indexOf('\n\n')
68
+ const body = separator >= 0 ? source.slice(separator + 2).trim() : ''
69
+ return [query, body].filter((part) => part.length > 0).join('\n')
70
+ }
71
+
72
+ /** @returns {{path:string, method?:string, params?:string, request?:string}|null} */
73
+ export function normalizeInterface(raw) {
74
+ if (typeof raw === 'string') {
75
+ const path = raw.trim()
76
+ return path.length > 0 ? { path } : null
77
+ }
78
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
79
+ const path = pick(raw.path, raw.url, raw.route, raw.endpoint)
80
+ if (path.length === 0) return null
81
+ const entry = { path }
82
+ const method = pick(raw.method).toUpperCase()
83
+ if (HTTP_METHODS.includes(method)) entry.method = method
84
+ const request = pick(raw.request, raw.raw, raw.http)
85
+ const params = pick(raw.params, raw.parameters, raw.query, raw.data) || extractParamsFromRaw(request)
86
+ if (params.length > 0) entry.params = params
87
+ if (request.length > 0) entry.request = request
88
+ return entry
89
+ }
90
+
91
+ /** @returns {Array<{path:string, method?:string, params?:string, request?:string}>} */
92
+ export function normalizeInterfaces(raw, interfaceDir) {
93
+ const result = []
94
+ const seen = new Set()
95
+ const push = (entry) => {
96
+ if (!entry) return
97
+ const key = `${entry.method || ''}\u0000${entry.path}`
98
+ if (seen.has(key)) return
99
+ seen.add(key)
100
+ result.push(entry)
101
+ }
102
+ for (const item of parseJsonArray(raw)) push(normalizeInterface(item))
103
+ for (const item of parseJsonArray(interfaceDir)) {
104
+ const path = text(item)
105
+ if (path.length > 0) push(normalizeInterface({ path }))
106
+ }
107
+ return result
108
+ }
109
+
110
+ /** @returns {{info_type:string, value:string, source:string, potential_impact:string}} */
111
+ export function normalizeLeak(raw) {
112
+ const item = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
113
+ const rawType = pick(item.info_type, item.infoType, item.type, item.kind, item.category)
114
+ const infoType = LEAKED_INFO_TYPES.includes(rawType)
115
+ ? rawType
116
+ : rawType === 'public_config' || rawType === 'config'
117
+ ? 'Other'
118
+ : 'Other'
119
+ return {
120
+ info_type: infoType,
121
+ value: pick(item.value, item.content, item.data, item.secret, item.example),
122
+ source: pick(item.source, item.file, item.where, item.url, item.path, item.endpoint),
123
+ potential_impact: pick(item.potential_impact, item.potentialImpact, item.impact, item.note),
124
+ }
125
+ }
126
+
127
+ /** @returns {Array<ReturnType<typeof normalizeLeak>>} */
128
+ export function normalizeLeaks(raw) {
129
+ const result = []
130
+ for (const item of parseJsonArray(raw)) {
131
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
132
+ const leak = normalizeLeak(item)
133
+ if (leak.value.length === 0 && leak.source.length === 0) continue
134
+ result.push(leak)
135
+ }
136
+ return result
137
+ }
138
+
139
+ /** @returns {object|null} canonical TestParameterCandidate */
140
+ export function normalizeTestParameter(raw) {
141
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
142
+ const name = pick(raw.name, raw.param, raw.key)
143
+ const endpoint = pick(raw.endpoint, raw.interface, raw.api, raw.path)
144
+ const values = Array.isArray(raw.values)
145
+ ? raw.values.filter((value) => typeof value === 'string' || typeof value === 'number').map(String)
146
+ : [pick(raw.example, raw.example_value, raw.value, raw.sample)].filter((value) => value.length > 0)
147
+ const locationRaw = pick(raw.location, raw.where, raw.in).toLowerCase()
148
+ const locationAliases = {
149
+ json: 'body',
150
+ form: 'body',
151
+ formdata: 'body',
152
+ post: 'body',
153
+ query: 'query',
154
+ querystring: 'query',
155
+ url: 'query',
156
+ header: 'header',
157
+ headers: 'header',
158
+ cookie: 'cookie',
159
+ path: 'path',
160
+ }
161
+ const mappedLocation = locationAliases[locationRaw] || locationRaw
162
+ const location = TEST_PARAMETER_LOCATIONS.includes(mappedLocation) ? mappedLocation : 'query'
163
+ const source = pick(raw.source, raw.file, raw.interface, raw.endpoint)
164
+ if (name.length === 0 && endpoint.length === 0 && values.length === 0) return null
165
+ const entry = {
166
+ name: name || 'value',
167
+ endpoint: endpoint || source || '(unknown)',
168
+ location,
169
+ values: values.slice(0, 50),
170
+ source: source || endpoint || '(unknown)',
171
+ tested: raw.tested === true,
172
+ related_vuln_ids: Array.isArray(raw.related_vuln_ids)
173
+ ? raw.related_vuln_ids.filter((value) => typeof value === 'string' && value.trim().length > 0)
174
+ : [],
175
+ }
176
+ const pattern = pick(raw.pattern)
177
+ if (pattern.length > 0) entry.pattern = pattern
178
+ const notes = pick(raw.notes, raw.note)
179
+ if (notes.length > 0) entry.notes = notes
180
+ return entry
181
+ }
182
+
183
+ /** @returns {Array<ReturnType<typeof normalizeTestParameter>>} */
184
+ export function normalizeTestParameters(raw) {
185
+ const result = []
186
+ const seen = new Set()
187
+ for (const item of parseJsonArray(raw)) {
188
+ const entry = normalizeTestParameter(item)
189
+ if (!entry) continue
190
+ const key = `${entry.endpoint}\u0000${entry.name}\u0000${entry.location}`
191
+ if (seen.has(key)) continue
192
+ seen.add(key)
193
+ result.push(entry)
194
+ }
195
+ return result
196
+ }
197
+
198
+ /** Normalize one raw asset row for API / graph consumers. */
199
+ export function normalizeAssetRow(row) {
200
+ if (!row || typeof row !== 'object') return row
201
+ return {
202
+ ...row,
203
+ interfaces: normalizeInterfaces(row.interfaces, row.interface_dir),
204
+ leaked_infos: normalizeLeaks(row.leaked_infos),
205
+ test_parameters: normalizeTestParameters(row.test_parameters),
206
+ related_vuln_ids: parseJsonArray(row.related_vuln_ids).filter((value) => typeof value === 'string'),
207
+ }
208
+ }
@@ -7,6 +7,13 @@
7
7
  * honours the same schema-v5 guard and read-only mode as the HTTP API.
8
8
  */
9
9
  import { withBusyRetry } from '../db.js'
10
+ import {
11
+ normalizeInterfaces,
12
+ normalizeLeaks,
13
+ normalizeProjectName,
14
+ normalizeTestParameters,
15
+ } from './normalize-asset.js'
16
+ import { auditCompleteness, syncInternalScope } from './data-tools.js'
10
17
 
11
18
  const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
12
19
  const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
@@ -394,7 +401,7 @@ export function createCortexToolDefinitions({ holder, config }) {
394
401
 
395
402
  const syncAsset = defineTool(
396
403
  'cortex_sync_asset_info',
397
- 'Insert or update local Cortex asset context (fingerprints, structured interfaces, leaked info, test parameters, related vulns). mode=merge (default) appends new array entries and only overwrites provided scalars; mode=replace replaces the whole asset snapshot.',
404
+ 'Insert or update local Cortex asset context. mode=merge (default) appends canonicalized array entries and only overwrites provided scalars; mode=replace replaces the whole asset snapshot. Canonical fields: interfaces[] = {path, method?, params?, request?} where request is a complete raw HTTP request; leaked_infos[] = {info_type, value, source, potential_impact}; test_parameters[] = {name, endpoint, location (path|query|body|header|cookie), values[], pattern?, source, tested?, notes?, related_vuln_ids?}. Common aliases are normalized (raw->request, type->info_type, file->source, param->name, where->location, example->values[0]).',
398
405
  {
399
406
  host_port: { type: 'string', description: 'Full URL/authority, e.g. http://127.0.0.1:8080/' },
400
407
  project_name: { type: 'string' },
@@ -402,9 +409,53 @@ export function createCortexToolDefinitions({ holder, config }) {
402
409
  tech_stack: { type: 'string' },
403
410
  auth_required: { type: 'boolean' },
404
411
  status: { type: 'string', enum: ['Alive', 'Dead', 'WAF_Blocked'] },
405
- interfaces: { type: 'array', items: { type: 'object' } },
406
- leaked_infos: { type: 'array', items: { type: 'object' } },
407
- test_parameters: { type: 'array', items: { type: 'object' } },
412
+ interfaces: {
413
+ type: 'array',
414
+ description: 'Structured API interfaces; raw static files are not interfaces. Example: {path: "/api/login", method: "POST", params: "{\\"user\\":\\"a\\"}", request: "POST /api/login HTTP/1.1\nHost: host\n\n{...}"}',
415
+ items: {
416
+ type: 'object',
417
+ properties: {
418
+ path: { type: 'string', description: 'API path (required)' },
419
+ method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] },
420
+ params: { type: 'string', description: 'Query string or body fields' },
421
+ request: { type: 'string', description: 'Complete raw HTTP request (request line + headers + blank line + body)' },
422
+ },
423
+ required: ['path'],
424
+ },
425
+ },
426
+ leaked_infos: {
427
+ type: 'array',
428
+ description: 'Secrets/PII/internal IPs. Example: {info_type: "Token", value: "eyJ...", source: "/js/app.js", potential_impact: "可伪造会话"}',
429
+ items: {
430
+ type: 'object',
431
+ properties: {
432
+ info_type: { type: 'string', enum: ['Credential', 'API_Key', 'Token', 'PII', 'Internal_IP', 'Other'] },
433
+ value: { type: 'string' },
434
+ source: { type: 'string', description: 'Where it was found (URL/file/JS bundle)' },
435
+ potential_impact: { type: 'string' },
436
+ },
437
+ required: ['info_type', 'value', 'source', 'potential_impact'],
438
+ },
439
+ },
440
+ test_parameters: {
441
+ type: 'array',
442
+ description: 'Non-secret seeds for authz tests. Example: {name: "orderId", endpoint: "GET /api/orders/{id}", location: "path", values: ["1001"], source: "/js/app.js", pattern: "sequential numeric"}',
443
+ items: {
444
+ type: 'object',
445
+ properties: {
446
+ name: { type: 'string' },
447
+ endpoint: { type: 'string' },
448
+ location: { type: 'string', enum: ['path', 'query', 'body', 'header', 'cookie'] },
449
+ values: { type: 'array', items: { type: 'string' } },
450
+ pattern: { type: 'string' },
451
+ source: { type: 'string' },
452
+ tested: { type: 'boolean' },
453
+ notes: { type: 'string' },
454
+ related_vuln_ids: { type: 'array', items: { type: 'string' } },
455
+ },
456
+ required: ['name', 'endpoint', 'location', 'source'],
457
+ },
458
+ },
408
459
  related_vuln_ids: { type: 'array', items: { type: 'string' } },
409
460
  mode: { type: 'string', enum: ['merge', 'replace'] },
410
461
  },
@@ -413,15 +464,14 @@ export function createCortexToolDefinitions({ holder, config }) {
413
464
  assertWritable(holder)
414
465
  const db = ensureDb(holder)
415
466
  const hostPort = text(args.host_port).trim()
416
- const project = text(args.project_name).trim()
467
+ const project = normalizeProjectName(text(args.project_name))
417
468
  if (!hostPort || !project) throw new Error('host_port and project_name are required')
418
469
  const merge = args.mode !== 'replace'
419
470
  const existing = db.prepare('SELECT * FROM assets WHERE host_port = ?').get(hostPort)
420
471
  const now = new Date().toISOString()
421
- const arrayField = (name) => {
422
- const value = Array.isArray(args[name]) ? args[name] : undefined
472
+ const arrayField = (name, normalize) => {
473
+ const value = args[name] !== undefined && normalize ? normalize(args[name]) : undefined
423
474
  if (merge && existing) return jsonColumn(uniqueJsonMerge(existing[name], value))
424
- if (merge && !existing) return jsonColumn(value)
425
475
  return jsonColumn(value)
426
476
  }
427
477
  const scalar = (name, fallback) =>
@@ -432,10 +482,12 @@ export function createCortexToolDefinitions({ holder, config }) {
432
482
  tech_stack: args.tech_stack !== undefined ? text(args.tech_stack) : merge && existing ? existing.tech_stack : '',
433
483
  auth_required: scalar('auth_required', false) ? 1 : 0,
434
484
  status: scalar('status', 'Alive'),
435
- interfaces: arrayField('interfaces'),
436
- leaked_infos: arrayField('leaked_infos'),
437
- test_parameters: arrayField('test_parameters'),
438
- related_vuln_ids: arrayField('related_vuln_ids'),
485
+ interfaces: arrayField('interfaces', normalizeInterfaces),
486
+ leaked_infos: arrayField('leaked_infos', normalizeLeaks),
487
+ test_parameters: arrayField('test_parameters', normalizeTestParameters),
488
+ related_vuln_ids: arrayField('related_vuln_ids', (value) =>
489
+ Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [],
490
+ ),
439
491
  last_seen: now,
440
492
  }
441
493
  runWrite(db, () => {
@@ -454,12 +506,157 @@ export function createCortexToolDefinitions({ holder, config }) {
454
506
  }
455
507
  })
456
508
  holder.refreshFileStamp()
509
+ const counts = {
510
+ interfaces: jsonArray(values.interfaces).length,
511
+ leaks: jsonArray(values.leaked_infos).length,
512
+ parameters: jsonArray(values.test_parameters).length,
513
+ }
514
+ const warnings = []
515
+ for (const [name, key] of [
516
+ ['interfaces', 'interfaces'],
517
+ ['leaked_infos', 'leaks'],
518
+ ['test_parameters', 'parameters'],
519
+ ]) {
520
+ if (args[name] === undefined) continue
521
+ const sent = Array.isArray(args[name]) ? args[name].length : 0
522
+ if (sent > counts[key]) warnings.push(`${name}: ${sent - counts[key]} invalid entries dropped by normalization`)
523
+ }
524
+ if (counts.interfaces === 0) warnings.push('interfaces is empty — add {path, method, params?, request?} for each API')
525
+ if (counts.leaks === 0) warnings.push('leaked_infos is empty — record secrets/tokens/internal IPs with value+source+impact')
526
+ if (counts.parameters === 0) warnings.push('test_parameters is empty — record id/tenant/order seeds with name+endpoint+location+values+source')
457
527
  return {
458
- text: `${LOG} ${existing ? (merge ? 'merged' : 'replaced') : 'created'} asset ${hostPort} (project ${project})`,
528
+ text: [
529
+ `${LOG} ${existing ? (merge ? 'merged' : 'replaced') : 'created'} asset ${hostPort} (project ${project})`,
530
+ `stored: interfaces=${counts.interfaces}, leaked_infos=${counts.leaks}, test_parameters=${counts.parameters}`,
531
+ ...warnings.map((warning) => `warning: ${warning}`),
532
+ ].join('\n'),
459
533
  }
460
534
  },
461
535
  )
462
536
 
537
+ const saveSnapshot = defineTool(
538
+ 'cortex_save_snapshot',
539
+ 'Create or update a local Cortex cognitive snapshot (认知快照) for the current task: project, phase, targets, unfinished tasks, failed paths, tested interfaces/notes/next steps and related vulns. Call it at task milestones, before context compaction and at session end. Omit session_id to create a new snapshot; pass session_id to update one; force_new=true creates another session for a project that already has one. Updates keep a previous_state rollback buffer.',
540
+ {
541
+ project_name: { type: 'string', minLength: 1, maxLength: 120 },
542
+ current_phase: { type: 'string', maxLength: 200 },
543
+ target_hosts: { type: 'array', items: { type: 'string' } },
544
+ unfinished_tasks: { type: 'array', items: { type: 'string' } },
545
+ failed_paths: { type: 'array', items: { type: 'string' } },
546
+ tested_interfaces: { type: 'array', items: { type: 'object' }, description: 'Tested interface matrix entries (endpoint/result/...)' },
547
+ notes: { type: 'string', maxLength: 30000 },
548
+ related_vuln_ids: { type: 'array', items: { type: 'string' } },
549
+ next_steps: { type: 'string', maxLength: 10000 },
550
+ session_id: { type: 'string', maxLength: 100 },
551
+ parent_session_id: { type: 'string', maxLength: 100 },
552
+ force_new: { type: 'boolean' },
553
+ },
554
+ ['project_name'],
555
+ async (args) => {
556
+ assertWritable(holder)
557
+ const db = ensureDb(holder)
558
+ const project = normalizeProjectName(text(args.project_name))
559
+ if (!project) throw new Error('project_name is required')
560
+ const stringList = (name) =>
561
+ args[name] === undefined
562
+ ? undefined
563
+ : JSON.stringify(
564
+ (Array.isArray(args[name]) ? args[name] : [])
565
+ .filter((entry) => typeof entry === 'string')
566
+ .map((entry) => entry.trim())
567
+ .filter((entry) => entry.length > 0),
568
+ )
569
+ const objectList = (name) =>
570
+ args[name] === undefined
571
+ ? undefined
572
+ : JSON.stringify((Array.isArray(args[name]) ? args[name] : []).filter((entry) => entry && typeof entry === 'object'))
573
+ const fields = {
574
+ project_name: project,
575
+ current_phase: args.current_phase !== undefined ? text(args.current_phase) : undefined,
576
+ target_hosts: stringList('target_hosts'),
577
+ unfinished_tasks: stringList('unfinished_tasks'),
578
+ failed_paths: stringList('failed_paths'),
579
+ tested_interfaces: objectList('tested_interfaces'),
580
+ notes: args.notes !== undefined ? text(args.notes) : undefined,
581
+ related_vuln_ids: stringList('related_vuln_ids'),
582
+ next_steps: args.next_steps !== undefined ? text(args.next_steps) : undefined,
583
+ }
584
+ const now = new Date().toISOString()
585
+ const sessionId = text(args.session_id).trim()
586
+ if (sessionId) {
587
+ const existing = db.prepare('SELECT * FROM snapshot_sessions WHERE session_id = ?').get(sessionId)
588
+ if (!existing) throw new Error(`snapshot ${sessionId} not found; omit session_id to create a new one`)
589
+ const previous = {
590
+ project_name: existing.project_name,
591
+ target_hosts: jsonArray(existing.target_hosts),
592
+ current_phase: existing.current_phase,
593
+ unfinished_tasks: jsonArray(existing.unfinished_tasks),
594
+ failed_paths: jsonArray(existing.failed_paths),
595
+ tested_interfaces: jsonArray(existing.tested_interfaces),
596
+ notes: existing.notes,
597
+ related_vuln_ids: jsonArray(existing.related_vuln_ids),
598
+ next_steps: existing.next_steps,
599
+ }
600
+ const sets = []
601
+ const values = []
602
+ for (const [column, value] of Object.entries(fields)) {
603
+ if (value === undefined) continue
604
+ sets.push(`${column} = ?`)
605
+ values.push(value)
606
+ }
607
+ sets.push('previous_state = ?', 'updated_at = ?')
608
+ values.push(JSON.stringify(previous), now, sessionId)
609
+ await runWrite(db, () =>
610
+ db.prepare(`UPDATE snapshot_sessions SET ${sets.join(', ')} WHERE session_id = ?`).run(...values),
611
+ )
612
+ holder.refreshFileStamp()
613
+ return { text: `${LOG} snapshot updated: ${sessionId} | ${project} | phase: ${fields.current_phase ?? existing.current_phase}` }
614
+ }
615
+ if (args.force_new !== true) {
616
+ const candidates = db
617
+ .prepare('SELECT session_id, current_phase, updated_at FROM snapshot_sessions WHERE project_name = ? AND archived_at IS NULL ORDER BY updated_at DESC LIMIT 5')
618
+ .all(project)
619
+ if (candidates.length > 0) {
620
+ return {
621
+ text: `${LOG} project ${project} already has snapshots: ${candidates
622
+ .map((row) => `${row.session_id} (${row.current_phase || '-'}, ${row.updated_at})`)
623
+ .join('; ')} — pass session_id to update one, or force_new=true to create a new session.`,
624
+ }
625
+ }
626
+ }
627
+ const id = globalThis.crypto?.randomUUID?.() ?? `snap-${Date.now()}-${Math.random().toString(16).slice(2)}`
628
+ const columns = [
629
+ 'session_id', 'project_name', 'target_hosts', 'current_phase', 'unfinished_tasks', 'failed_paths',
630
+ 'tested_interfaces', 'notes', 'related_vuln_ids', 'next_steps', 'previous_state',
631
+ 'parent_session_id', 'archived_at', 'created_at', 'updated_at',
632
+ ]
633
+ const values = [
634
+ id,
635
+ project,
636
+ fields.target_hosts ?? '[]',
637
+ fields.current_phase ?? '',
638
+ fields.unfinished_tasks ?? '[]',
639
+ fields.failed_paths ?? '[]',
640
+ fields.tested_interfaces ?? '[]',
641
+ fields.notes ?? '',
642
+ fields.related_vuln_ids ?? '[]',
643
+ fields.next_steps ?? '',
644
+ null,
645
+ text(args.parent_session_id) || null,
646
+ null,
647
+ now,
648
+ now,
649
+ ]
650
+ await runWrite(db, () =>
651
+ db
652
+ .prepare(`INSERT INTO snapshot_sessions (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`)
653
+ .run(...values),
654
+ )
655
+ holder.refreshFileStamp()
656
+ return { text: `${LOG} snapshot saved: ${id} | ${project} | phase: ${fields.current_phase || '-'}` }
657
+ },
658
+ )
659
+
463
660
  const listAssets = defineTool(
464
661
  'cortex_list_assets',
465
662
  'List local Cortex assets (host_port, project, tech stack, interface/leak/test-parameter counts). Optional search/status/project filters.',
@@ -493,7 +690,7 @@ export function createCortexToolDefinitions({ holder, config }) {
493
690
  .all(...params)
494
691
  const lines = rows.map(
495
692
  (row) =>
496
- `${row.host_port} | ${row.status} | ${row.project_name} | ifaces=${jsonArray(row.interfaces).length} leaks=${jsonArray(row.leaked_infos).length} params=${jsonArray(row.test_parameters).length} vulns=${jsonArray(row.related_vuln_ids).length} | ${row.tech_stack}`,
693
+ `${row.host_port} | ${row.status} | ${normalizeProjectName(row.project_name)} | ifaces=${jsonArray(row.interfaces).length} leaks=${jsonArray(row.leaked_infos).length} params=${jsonArray(row.test_parameters).length} vulns=${jsonArray(row.related_vuln_ids).length} | ${row.tech_stack}`,
497
694
  )
498
695
  return { text: [`${LOG} ${rows.length} assets`, ...lines].join('\n') }
499
696
  },
@@ -508,22 +705,26 @@ export function createCortexToolDefinitions({ holder, config }) {
508
705
  const db = ensureDb(holder)
509
706
  const row = db.prepare('SELECT * FROM assets WHERE host_port = ?').get(text(args.hostPort).trim())
510
707
  if (!row) return { text: `${LOG} asset not found: ${args.hostPort}` }
708
+ const interfaces = normalizeInterfaces(row.interfaces, row.interface_dir)
709
+ const leaks = normalizeLeaks(row.leaked_infos)
710
+ const testParameters = normalizeTestParameters(row.test_parameters)
711
+ const relatedIds = jsonArray(row.related_vuln_ids).filter((entry) => typeof entry === 'string')
511
712
  const parts = [
512
713
  `# ${row.host_port}`,
513
- `host: ${row.host} · project: ${row.project_name}`,
714
+ `host: ${row.host} · project: ${normalizeProjectName(row.project_name)}`,
514
715
  `status: ${row.status} · auth_required: ${row.auth_required ? 'true' : 'false'} · last_seen: ${row.last_seen}`,
515
716
  `tech_stack: ${row.tech_stack}`,
516
717
  '',
517
- `## interfaces (${jsonArray(row.interfaces).length})`,
518
- JSON.stringify(jsonArray(row.interfaces), null, 2),
718
+ `## interfaces (${interfaces.length})`,
719
+ JSON.stringify(interfaces, null, 2),
519
720
  '',
520
- `## leaked_infos (${jsonArray(row.leaked_infos).length})`,
521
- JSON.stringify(jsonArray(row.leaked_infos), null, 2),
721
+ `## leaked_infos (${leaks.length})`,
722
+ JSON.stringify(leaks, null, 2),
522
723
  '',
523
- `## test_parameters (${jsonArray(row.test_parameters).length})`,
524
- JSON.stringify(jsonArray(row.test_parameters), null, 2),
724
+ `## test_parameters (${testParameters.length})`,
725
+ JSON.stringify(testParameters, null, 2),
525
726
  '',
526
- `## related_vuln_ids\n${jsonArray(row.related_vuln_ids).join(', ')}`,
727
+ `## related_vuln_ids\n${relatedIds.join(', ')}`,
527
728
  ]
528
729
  return { text: parts.join('\n') }
529
730
  },
@@ -650,6 +851,120 @@ export function createCortexToolDefinitions({ holder, config }) {
650
851
  },
651
852
  )
652
853
 
854
+ const auditTool = defineTool(
855
+ 'cortex_audit_completeness',
856
+ 'Audit local Cortex data completeness: per project assets missing interfaces/leaks/test parameters, vulnerability records with incomplete evidence, internal-scope entity counts and snapshot counts. Run this after a testing phase and fill every reported gap before finishing.',
857
+ { project_name: { type: 'string', description: 'Optional project filter (canonical name)' } },
858
+ [],
859
+ async (args) => ({ text: auditCompleteness({ holder, args }).text }),
860
+ )
861
+
862
+ const internalSyncTool = defineTool(
863
+ 'cortex_sync_internal_scope',
864
+ 'Register internal operations data for a project (schema v3): internal_hosts, internal_services, footholds, credentials and pivots. Call it after initial access and whenever new internal hosts/services/credentials are found. Arrays are upserted; footholds accept host_ips to link reachable hosts, pivots accept hops [{ref_kind, ref_id, label?, via?}].',
865
+ {
866
+ project_name: { type: 'string', minLength: 1, maxLength: 120 },
867
+ internal_hosts: {
868
+ type: 'array',
869
+ description: 'Example: {ip: "10.0.0.5", hostname: "db01", os: "Ubuntu 22.04", subnet: "10.0.0.0/24", state: "alive", source: "nmap"}',
870
+ items: {
871
+ type: 'object',
872
+ properties: {
873
+ ip: { type: 'string' },
874
+ hostname: { type: 'string' },
875
+ os: { type: 'string' },
876
+ subnet: { type: 'string' },
877
+ state: { type: 'string', enum: ['unknown', 'alive', 'compromised', 'unreachable', 'firewalled'] },
878
+ source: { type: 'string' },
879
+ confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
880
+ },
881
+ required: ['ip'],
882
+ },
883
+ },
884
+ internal_services: {
885
+ type: 'array',
886
+ description: 'Example: {ip: "10.0.0.5", port: 6379, protocol: "tcp", service: "redis", banner: "Redis 7.4", state: "open"}',
887
+ items: {
888
+ type: 'object',
889
+ properties: {
890
+ ip: { type: 'string' },
891
+ host_id: { type: 'string' },
892
+ port: { type: 'integer' },
893
+ protocol: { type: 'string' },
894
+ service: { type: 'string' },
895
+ banner: { type: 'string' },
896
+ state: { type: 'string', enum: ['open', 'closed', 'filtered', 'unknown'] },
897
+ source: { type: 'string' },
898
+ },
899
+ required: ['port'],
900
+ },
901
+ },
902
+ footholds: {
903
+ type: 'array',
904
+ description: 'Example: {entry_host_port: "https://target/", entry_vuln_id: "VULN-...", access_type: "rce", privilege_level: "admin", host_ips: ["10.0.0.5"], evidence: "id; whoami", notes: "..."}',
905
+ items: {
906
+ type: 'object',
907
+ properties: {
908
+ entry_host_port: { type: 'string' },
909
+ entry_vuln_id: { type: 'string' },
910
+ access_type: { type: 'string', enum: ['rce', 'webshell', 'ssrf_proxy', 'credential', 'vpn', 'other'] },
911
+ privilege_level: { type: 'string', enum: ['user', 'admin', 'system', 'domain_admin', 'unknown'] },
912
+ status: { type: 'string', enum: ['planned', 'established', 'degraded', 'lost'] },
913
+ evidence: { type: 'string' },
914
+ notes: { type: 'string' },
915
+ host_ips: { type: 'array', items: { type: 'string' } },
916
+ },
917
+ required: ['entry_host_port'],
918
+ },
919
+ },
920
+ credentials: {
921
+ type: 'array',
922
+ description: 'Example: {type: "password", username: "deploy", secret: "***", host: "10.0.0.5", service: "ssh", source: "backup.sh", confidence: "high", status: "valid"}',
923
+ items: {
924
+ type: 'object',
925
+ properties: {
926
+ type: { type: 'string', enum: ['password', 'ntlm_hash', 'netntlmv2', 'kerberos_ticket', 'ssh_key', 'cert', 'api_key', 'other'] },
927
+ username: { type: 'string' },
928
+ domain: { type: 'string' },
929
+ secret: { type: 'string' },
930
+ host: { type: 'string' },
931
+ port: { type: 'integer' },
932
+ protocol: { type: 'string' },
933
+ service: { type: 'string' },
934
+ source: { type: 'string' },
935
+ confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
936
+ status: { type: 'string', enum: ['untested', 'valid', 'invalid', 'expired'] },
937
+ notes: { type: 'string' },
938
+ },
939
+ },
940
+ },
941
+ pivots: {
942
+ type: 'array',
943
+ description: 'Example: {entry_host_port: "https://target/", kind: "proxy", local_port: 1080, local_protocol: "socks5", tool: "chisel", status: "established"}',
944
+ items: {
945
+ type: 'object',
946
+ properties: {
947
+ foothold_id: { type: 'string' },
948
+ entry_host_port: { type: 'string' },
949
+ kind: { type: 'string', enum: ['chain', 'proxy'] },
950
+ local_host: { type: 'string' },
951
+ local_port: { type: 'integer' },
952
+ local_protocol: { type: 'string' },
953
+ tool: { type: 'string' },
954
+ status: { type: 'string', enum: ['planned', 'established', 'degraded', 'lost'] },
955
+ notes: { type: 'string' },
956
+ hops: { type: 'array', items: { type: 'object' } },
957
+ },
958
+ },
959
+ },
960
+ },
961
+ ['project_name'],
962
+ async (args) => {
963
+ assertWritable(holder)
964
+ return { text: syncInternalScope({ holder, args }).text }
965
+ },
966
+ )
967
+
653
968
  const tools = [
654
969
  listVulns,
655
970
  getVuln,
@@ -661,8 +976,11 @@ export function createCortexToolDefinitions({ holder, config }) {
661
976
  getAsset,
662
977
  listSnapshots,
663
978
  getSnapshot,
979
+ saveSnapshot,
664
980
  internalOverview,
665
981
  graphSummary,
982
+ auditTool,
983
+ internalSyncTool,
666
984
  ]
667
985
  return readOnly ? tools.map((tool) => ({ ...tool, description: `${tool.description} (current host: read-only)` })) : tools
668
986
  }