guardlink 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +111 -7
  2. package/README.md +53 -5
  3. package/dist/agents/config.d.ts +7 -0
  4. package/dist/agents/config.d.ts.map +1 -1
  5. package/dist/agents/config.js.map +1 -1
  6. package/dist/agents/index.d.ts +9 -1
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/index.js +36 -1
  9. package/dist/agents/index.js.map +1 -1
  10. package/dist/agents/launcher.d.ts.map +1 -1
  11. package/dist/agents/launcher.js +5 -0
  12. package/dist/agents/launcher.js.map +1 -1
  13. package/dist/agents/prompts.d.ts +16 -1
  14. package/dist/agents/prompts.d.ts.map +1 -1
  15. package/dist/agents/prompts.js +511 -16
  16. package/dist/agents/prompts.js.map +1 -1
  17. package/dist/analyze/format.d.ts +72 -0
  18. package/dist/analyze/format.d.ts.map +1 -0
  19. package/dist/analyze/format.js +176 -0
  20. package/dist/analyze/format.js.map +1 -0
  21. package/dist/analyze/index.d.ts +76 -0
  22. package/dist/analyze/index.d.ts.map +1 -1
  23. package/dist/analyze/index.js +165 -2
  24. package/dist/analyze/index.js.map +1 -1
  25. package/dist/analyze/prompts.d.ts +3 -2
  26. package/dist/analyze/prompts.d.ts.map +1 -1
  27. package/dist/analyze/prompts.js +17 -3
  28. package/dist/analyze/prompts.js.map +1 -1
  29. package/dist/analyzer/sarif.d.ts +3 -2
  30. package/dist/analyzer/sarif.d.ts.map +1 -1
  31. package/dist/analyzer/sarif.js +29 -3
  32. package/dist/analyzer/sarif.js.map +1 -1
  33. package/dist/cli/index.d.ts +2 -0
  34. package/dist/cli/index.d.ts.map +1 -1
  35. package/dist/cli/index.js +408 -37
  36. package/dist/cli/index.js.map +1 -1
  37. package/dist/dashboard/data.d.ts +11 -0
  38. package/dist/dashboard/data.d.ts.map +1 -1
  39. package/dist/dashboard/data.js +12 -0
  40. package/dist/dashboard/data.js.map +1 -1
  41. package/dist/dashboard/diagrams.d.ts +81 -12
  42. package/dist/dashboard/diagrams.d.ts.map +1 -1
  43. package/dist/dashboard/diagrams.js +750 -362
  44. package/dist/dashboard/diagrams.js.map +1 -1
  45. package/dist/dashboard/generate.d.ts +5 -2
  46. package/dist/dashboard/generate.d.ts.map +1 -1
  47. package/dist/dashboard/generate.js +2516 -244
  48. package/dist/dashboard/generate.js.map +1 -1
  49. package/dist/diff/engine.d.ts +2 -1
  50. package/dist/diff/engine.d.ts.map +1 -1
  51. package/dist/diff/engine.js +3 -2
  52. package/dist/diff/engine.js.map +1 -1
  53. package/dist/diff/git.js +3 -3
  54. package/dist/diff/git.js.map +1 -1
  55. package/dist/init/index.d.ts +7 -0
  56. package/dist/init/index.d.ts.map +1 -1
  57. package/dist/init/index.js +82 -27
  58. package/dist/init/index.js.map +1 -1
  59. package/dist/init/migrate.d.ts +39 -0
  60. package/dist/init/migrate.d.ts.map +1 -0
  61. package/dist/init/migrate.js +45 -0
  62. package/dist/init/migrate.js.map +1 -0
  63. package/dist/init/templates.d.ts +8 -0
  64. package/dist/init/templates.d.ts.map +1 -1
  65. package/dist/init/templates.js +68 -6
  66. package/dist/init/templates.js.map +1 -1
  67. package/dist/mcp/lookup.d.ts +1 -0
  68. package/dist/mcp/lookup.d.ts.map +1 -1
  69. package/dist/mcp/lookup.js +138 -10
  70. package/dist/mcp/lookup.js.map +1 -1
  71. package/dist/mcp/server.d.ts +2 -1
  72. package/dist/mcp/server.d.ts.map +1 -1
  73. package/dist/mcp/server.js +32 -15
  74. package/dist/mcp/server.js.map +1 -1
  75. package/dist/parser/clear.d.ts +2 -1
  76. package/dist/parser/clear.d.ts.map +1 -1
  77. package/dist/parser/clear.js +19 -29
  78. package/dist/parser/clear.js.map +1 -1
  79. package/dist/parser/comment-strip.d.ts +5 -0
  80. package/dist/parser/comment-strip.d.ts.map +1 -1
  81. package/dist/parser/comment-strip.js +8 -0
  82. package/dist/parser/comment-strip.js.map +1 -1
  83. package/dist/parser/feature-filter.d.ts +42 -0
  84. package/dist/parser/feature-filter.d.ts.map +1 -0
  85. package/dist/parser/feature-filter.js +109 -0
  86. package/dist/parser/feature-filter.js.map +1 -0
  87. package/dist/parser/format.d.ts +24 -0
  88. package/dist/parser/format.d.ts.map +1 -0
  89. package/dist/parser/format.js +29 -0
  90. package/dist/parser/format.js.map +1 -0
  91. package/dist/parser/index.d.ts +2 -0
  92. package/dist/parser/index.d.ts.map +1 -1
  93. package/dist/parser/index.js +1 -0
  94. package/dist/parser/index.js.map +1 -1
  95. package/dist/parser/parse-file.d.ts +1 -0
  96. package/dist/parser/parse-file.d.ts.map +1 -1
  97. package/dist/parser/parse-file.js +34 -9
  98. package/dist/parser/parse-file.js.map +1 -1
  99. package/dist/parser/parse-line.d.ts +9 -0
  100. package/dist/parser/parse-line.d.ts.map +1 -1
  101. package/dist/parser/parse-line.js +100 -26
  102. package/dist/parser/parse-line.js.map +1 -1
  103. package/dist/parser/parse-project.d.ts +1 -0
  104. package/dist/parser/parse-project.d.ts.map +1 -1
  105. package/dist/parser/parse-project.js +36 -2
  106. package/dist/parser/parse-project.js.map +1 -1
  107. package/dist/parser/validate.d.ts +3 -0
  108. package/dist/parser/validate.d.ts.map +1 -1
  109. package/dist/parser/validate.js +7 -0
  110. package/dist/parser/validate.js.map +1 -1
  111. package/dist/report/index.d.ts +1 -0
  112. package/dist/report/index.d.ts.map +1 -1
  113. package/dist/report/index.js +1 -0
  114. package/dist/report/index.js.map +1 -1
  115. package/dist/report/report.d.ts.map +1 -1
  116. package/dist/report/report.js +924 -24
  117. package/dist/report/report.js.map +1 -1
  118. package/dist/report/sequence.d.ts +11 -0
  119. package/dist/report/sequence.d.ts.map +1 -0
  120. package/dist/report/sequence.js +140 -0
  121. package/dist/report/sequence.js.map +1 -0
  122. package/dist/review/index.d.ts +3 -1
  123. package/dist/review/index.d.ts.map +1 -1
  124. package/dist/review/index.js +77 -35
  125. package/dist/review/index.js.map +1 -1
  126. package/dist/tui/commands.d.ts +1 -0
  127. package/dist/tui/commands.d.ts.map +1 -1
  128. package/dist/tui/commands.js +98 -12
  129. package/dist/tui/commands.js.map +1 -1
  130. package/dist/tui/index.d.ts.map +1 -1
  131. package/dist/tui/index.js +7 -2
  132. package/dist/tui/index.js.map +1 -1
  133. package/dist/types/index.d.ts +59 -3
  134. package/dist/types/index.d.ts.map +1 -1
  135. package/dist/workspace/merge.d.ts.map +1 -1
  136. package/dist/workspace/merge.js +6 -2
  137. package/dist/workspace/merge.js.map +1 -1
  138. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * guardlink sarif [dir] Export SARIF 2.1.0 for GitHub / VS Code
13
13
  * guardlink threat-report <prompt> AI-powered threat analysis (STRIDE, DREAD, PASTA, etc.)
14
14
  * guardlink threat-reports List saved AI threat reports
15
+ * guardlink translate [prompt] Generate CERT-X-GEN pentest templates from threats
16
+ * guardlink ask <query> Ask questions about threats and codebase context
15
17
  * guardlink annotate <prompt> Launch coding agent to add annotations
16
18
  * guardlink config <action> Manage LLM provider configuration
17
19
  * guardlink dashboard [dir] Generate interactive HTML dashboard
@@ -37,15 +39,17 @@
37
39
  import { Command } from 'commander';
38
40
  import { resolve, basename } from 'node:path';
39
41
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
40
- import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, clearAnnotations } from '../parser/index.js';
42
+ import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries } from '../parser/index.js';
43
+ import { diagnosticIcon } from '../parser/format.js';
41
44
  import { initProject, detectProject, promptAgentSelection, syncAgentFiles } from '../init/index.js';
45
+ import { ensurePromptMd } from '../init/migrate.js';
42
46
  import { generateReport, generateMermaid } from '../report/index.js';
43
47
  import { diffModels, formatDiff, formatDiffMarkdown, parseAtRef } from '../diff/index.js';
44
48
  import { generateSarif } from '../analyzer/index.js';
45
49
  import { startStdioServer } from '../mcp/index.js';
46
- import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, buildConfig, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, serializeModel, buildUserMessage } from '../analyze/index.js';
50
+ import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, loadPentestData, serializePentestFindings, buildConfig, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, serializeModel, buildUserMessage } from '../analyze/index.js';
47
51
  import { generateDashboardHTML } from '../dashboard/index.js';
48
- import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt } from '../agents/index.js';
52
+ import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt, buildTranslatePrompt, buildAskPrompt, resolveAnnotationMode } from '../agents/index.js';
49
53
  import { resolveConfig, saveProjectConfig, saveGlobalConfig, loadProjectConfig, loadGlobalConfig, maskKey, describeConfigSource } from '../agents/config.js';
50
54
  import { getReviewableExposures, applyReviewAction, formatExposureForReview, summarizeReview } from '../review/index.js';
51
55
  import { populateMetadata, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace } from '../workspace/index.js';
@@ -95,7 +99,7 @@ function detectProjectName(root, explicit) {
95
99
  program
96
100
  .name('guardlink')
97
101
  .description('GuardLink — Security annotations for code. Threat modeling that lives in your codebase.')
98
- .version('1.4.1')
102
+ .version('1.4.3')
99
103
  .addHelpText('before', gradient(['#00ff41', '#00d4ff'])(ASCII_LOGO));
100
104
  // ─── init ────────────────────────────────────────────────────────────
101
105
  program
@@ -104,6 +108,7 @@ program
104
108
  .argument('[dir]', 'Project directory', '.')
105
109
  .option('-p, --project <n>', 'Override project name')
106
110
  .option('-a, --agent <agents>', 'Agent(s) to create files for: claude,cursor,codex,copilot,windsurf,cline,none (comma-separated)')
111
+ .option('--mode <mode>', 'Annotation mode: inline (default) or external. external restricts all writes to .guardlink/ — no agent files, no .mcp.json at root', 'inline')
107
112
  .option('--skip-agent-files', 'Only create .guardlink/, skip agent file updates')
108
113
  .option('--force', 'Overwrite existing GuardLink config and instructions')
109
114
  .option('--dry-run', 'Show what would be created without writing files')
@@ -140,6 +145,7 @@ program
140
145
  const result = initProject({
141
146
  root,
142
147
  project: opts.project,
148
+ mode: resolveAnnotationMode(opts.mode),
143
149
  skipAgentFiles: opts.skipAgentFiles,
144
150
  force: opts.force,
145
151
  dryRun: opts.dryRun,
@@ -194,9 +200,16 @@ program
194
200
  .argument('[dir]', 'Project directory to scan', '.')
195
201
  .option('-p, --project <n>', 'Project name', 'unknown')
196
202
  .option('--not-annotated', 'List source files with no GuardLink annotations')
203
+ .option('--feature <names>', 'Filter status to specific feature(s) (comma-separated)')
197
204
  .action(async (dir, opts) => {
198
205
  const root = resolve(dir);
199
- const { model, diagnostics } = await parseProject({ root, project: opts.project });
206
+ let { model, diagnostics } = await parseProject({ root, project: opts.project });
207
+ // Apply feature filter if specified
208
+ if (opts.feature) {
209
+ const featureNames = opts.feature.split(',').map(s => s.trim());
210
+ model = filterByFeature(model, featureNames);
211
+ console.log(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}\n`);
212
+ }
200
213
  printDiagnostics(diagnostics);
201
214
  printStatus(model);
202
215
  if (opts.notAnnotated) {
@@ -242,6 +255,12 @@ program
242
255
  console.error(` ${a.asset} → ${a.threat} [${a.severity || 'unset'}] (${a.location.file}:${a.location.line})`);
243
256
  }
244
257
  }
258
+ if ((model.confirmed || []).length > 0) {
259
+ console.error(`\n🔴 ${model.confirmed.length} confirmed exploitable finding(s) — verified, not false positives:`);
260
+ for (const c of model.confirmed) {
261
+ console.error(` ${c.asset} ← ${c.threat} [${c.severity || 'unset'}] (${c.location.file}:${c.location.line})`);
262
+ }
263
+ }
245
264
  const errorCount = allDiags.filter(d => d.level === 'error').length;
246
265
  const hasUnmitigated = unmitigated.length > 0;
247
266
  if (errorCount === 0 && !hasUnmitigated && acceptedOnly.length === 0) {
@@ -273,17 +292,43 @@ program
273
292
  .option('-f, --format <fmt>', 'Output format: md, json, or both (default: md)', 'md')
274
293
  .option('--diagram-only', 'Output only the Mermaid diagram, no report wrapper')
275
294
  .option('--json', 'Also output threat-model.json alongside the report (legacy; prefer --format)')
295
+ .option('--feature <names>', 'Filter report to specific feature(s) (comma-separated)')
276
296
  .action(async (dir, opts) => {
277
297
  const root = resolve(dir);
278
- const { model, diagnostics } = await parseProject({ root, project: opts.project });
279
- // Show errors if any
298
+ let { model, diagnostics } = await parseProject({ root, project: opts.project });
299
+ // Apply feature filter if specified
300
+ if (opts.feature) {
301
+ const featureNames = opts.feature.split(',').map(s => s.trim());
302
+ model = filterByFeature(model, featureNames);
303
+ console.error(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}`);
304
+ }
305
+ // Show errors if any. Per-annotation errors don't block the report —
306
+ // affected annotations are skipped, the rest of the model still renders.
280
307
  const errors = diagnostics.filter(d => d.level === 'error');
281
- if (errors.length > 0) {
308
+ if (errors.length > 0)
282
309
  printDiagnostics(errors);
283
- console.error(`Fix errors above before generating report.\n`);
284
- }
285
310
  // Enrich with provenance metadata (git SHA, branch, workspace, schema version)
286
311
  const enrichedModel = populateMetadata(model, root);
312
+ // Auto-create .guardlink/prompt.md if a v1.4.x project doesn't have it
313
+ // (`init` short-circuits when .guardlink/ exists, so upgrades skip the
314
+ // template). One-line hint on first creation so the user knows it's a
315
+ // feature; silent thereafter.
316
+ const migrationResult = ensurePromptMd(root);
317
+ if (migrationResult === 'created') {
318
+ console.error('• Created .guardlink/prompt.md — fill it in to customize the report\'s Application Overview.');
319
+ }
320
+ // Load project description from .guardlink/prompt.md if it exists
321
+ try {
322
+ const { readFile } = await import('node:fs/promises');
323
+ const promptPath = resolve(root, '.guardlink', 'prompt.md');
324
+ const promptContent = await readFile(promptPath, 'utf-8');
325
+ if (promptContent.trim()) {
326
+ enrichedModel.prompt = promptContent.trim();
327
+ }
328
+ }
329
+ catch {
330
+ // No prompt file — that's fine, report will use annotation-derived overview
331
+ }
287
332
  if (opts.diagramOnly) {
288
333
  // Just output Mermaid
289
334
  const mermaid = generateMermaid(enrichedModel);
@@ -434,8 +479,11 @@ program
434
479
  const { buildProjectContext, extractCodeSnippets } = await import('../analyze/index.js');
435
480
  const projectContext = buildProjectContext(root);
436
481
  const codeSnippets = extractCodeSnippets(root, model);
482
+ const pentestData = loadPentestData(root);
483
+ const pentestContext = serializePentestFindings(pentestData);
437
484
  const systemPrompt = FRAMEWORK_PROMPTS[fw];
438
- const userMessage = buildUserMessage(serialized, fw, customPrompt, projectContext || undefined, codeSnippets || undefined);
485
+ const userMessage = buildUserMessage(serialized, fw, customPrompt, projectContext || undefined, codeSnippets || undefined, pentestContext || undefined);
486
+ const hasPentest = pentestData.totalFindings > 0;
439
487
  const analysisPrompt = `You are analyzing a codebase with GuardLink security annotations.
440
488
  You have access to the full source code in the current directory.
441
489
 
@@ -453,7 +501,8 @@ ${userMessage}
453
501
  3. Produce the full report as markdown
454
502
  4. Be specific — reference actual files, functions, and line numbers from the codebase
455
503
  5. Output ONLY the markdown report content — do NOT add any metadata comments, save confirmations, or file path messages
456
- 6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."`;
504
+ 6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."${hasPentest ? `
505
+ 7. The <pentest_findings> section contains CONFIRMED vulnerabilities from automated CXG security scans with real evidence. Cross-reference these against the threat model — mark confirmed findings as exploitable, identify which @exposes are now validated, and include a dedicated "Pentest Results" section summarizing all confirmed findings with their evidence and remediation guidance` : ''}`;
457
506
  // Resolve agent: explicit flag > project config CLI agent
458
507
  let agent = agentFromOpts(opts);
459
508
  if (!agent) {
@@ -586,15 +635,25 @@ program
586
635
  .argument('<prompt>', 'Annotation instructions (e.g., "annotate auth endpoints for OWASP Top 10")')
587
636
  .argument('[dir]', 'Project directory', '.')
588
637
  .option('-p, --project <n>', 'Project name', 'unknown')
638
+ .option('--mode <mode>', 'Annotation placement mode: inline (default) or external (externalized .gal files)', 'inline')
589
639
  .option('--claude-code', 'Launch Claude Code in foreground')
590
640
  .option('--codex', 'Launch Codex CLI in foreground')
591
641
  .option('--gemini', 'Launch Gemini CLI in foreground')
592
642
  .option('--cursor', 'Open Cursor IDE with prompt on clipboard')
593
643
  .option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
594
644
  .option('--clipboard', 'Copy annotation prompt to clipboard only')
645
+ .option('--stdout', 'Print annotation prompt to stdout and exit (for piping)')
595
646
  .action(async (prompt, dir, opts) => {
596
647
  const root = resolve(dir);
597
648
  const project = detectProjectName(root, opts.project);
649
+ let annotationMode;
650
+ try {
651
+ annotationMode = resolveAnnotationMode(opts.mode);
652
+ }
653
+ catch (err) {
654
+ console.error(err.message);
655
+ process.exit(1);
656
+ }
598
657
  // Resolve agent
599
658
  const agent = agentFromOpts(opts);
600
659
  if (!agent) {
@@ -614,13 +673,18 @@ program
614
673
  }
615
674
  catch { /* no model yet — that's fine */ }
616
675
  // Build prompt
617
- const fullPrompt = buildAnnotatePrompt(prompt, root, model);
676
+ const fullPrompt = buildAnnotatePrompt(prompt, root, model, annotationMode);
618
677
  // Launch agent
619
- console.log(`Launching ${agent.name} for annotation...`);
620
- if (agent.cmd) {
621
- console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
678
+ if (agent.id !== 'stdout') {
679
+ console.log(`Launching ${agent.name} for annotation...`);
680
+ if (agent.cmd) {
681
+ console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
682
+ }
622
683
  }
623
684
  const result = launchAgent(agent, fullPrompt, root);
685
+ // stdout mode: prompt already written to stdout — nothing else to do
686
+ if (agent.id === 'stdout')
687
+ return;
624
688
  if (result.clipboardCopied) {
625
689
  console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
626
690
  }
@@ -646,6 +710,164 @@ program
646
710
  console.log('When done, run: guardlink parse');
647
711
  }
648
712
  });
713
+ // ─── translate ───────────────────────────────────────────────────────
714
+ program
715
+ .command('translate')
716
+ .description('Translate GuardLink threats into CERT-X-GEN pentest templates (generation only, no execution)')
717
+ .argument('[prompt...]', 'Optional translation instructions')
718
+ .option('-d, --dir <dir>', 'Project directory', '.')
719
+ .option('-p, --project <n>', 'Project name', 'unknown')
720
+ .option('--claude-code', 'Launch Claude Code in foreground')
721
+ .option('--codex', 'Launch Codex CLI in foreground')
722
+ .option('--gemini', 'Launch Gemini CLI in foreground')
723
+ .option('--cursor', 'Open Cursor IDE with prompt on clipboard')
724
+ .option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
725
+ .option('--clipboard', 'Copy translation prompt to clipboard only')
726
+ .option('--feature <names>', 'Filter to specific feature(s) (comma-separated)')
727
+ .action(async (promptParts, opts) => {
728
+ const root = resolve(opts.dir);
729
+ const project = detectProjectName(root, opts.project);
730
+ const userPrompt = promptParts.join(' ').trim();
731
+ let { model, diagnostics } = await parseProject({ root, project });
732
+ // Apply feature filter if specified
733
+ if (opts.feature) {
734
+ const featureNames = opts.feature.split(',').map(s => s.trim());
735
+ model = filterByFeature(model, featureNames);
736
+ console.error(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}`);
737
+ }
738
+ const errors = diagnostics.filter(d => d.level === 'error');
739
+ if (errors.length > 0)
740
+ printDiagnostics(errors);
741
+ if (model.annotations_parsed === 0) {
742
+ console.error('No annotations found. Run: guardlink init . && add annotations first.');
743
+ process.exit(1);
744
+ }
745
+ // Build translate prompt
746
+ const fullPrompt = buildTranslatePrompt(userPrompt, root, model);
747
+ // Resolve agent: explicit flag > project config > default (Claude Code)
748
+ let agent = agentFromOpts(opts);
749
+ if (!agent) {
750
+ const projCfg = loadProjectConfig(root);
751
+ if (projCfg?.aiMode === 'cli-agent' && projCfg?.cliAgent) {
752
+ agent = AGENTS.find(a => a.id === projCfg.cliAgent) || null;
753
+ }
754
+ if (!agent) {
755
+ agent = AGENTS.find(a => a.id === 'claude-code') || null;
756
+ }
757
+ }
758
+ if (!agent) {
759
+ console.error('No agent available. Use one of:');
760
+ for (const a of AGENTS) {
761
+ console.error(` ${a.flag.padEnd(16)} ${a.name}`);
762
+ }
763
+ process.exit(1);
764
+ }
765
+ console.log(`Launching ${agent.name} for CXG template translation...`);
766
+ console.log(`Threat model: ${model.annotations_parsed} annotations, ${model.exposures.length} exposures`);
767
+ if (agent.cmd) {
768
+ console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
769
+ }
770
+ const result = launchAgent(agent, fullPrompt, root);
771
+ if (result.clipboardCopied) {
772
+ console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
773
+ }
774
+ if (result.error) {
775
+ console.error(`✗ ${result.error}`);
776
+ if (result.clipboardCopied) {
777
+ console.log('Prompt is on your clipboard — paste it manually.');
778
+ }
779
+ process.exit(1);
780
+ }
781
+ if (agent.cmd && result.launched) {
782
+ console.log(`\n✓ ${agent.name} session ended.`);
783
+ console.log(' Expected output location: .guardlink/cxg-templates/');
784
+ console.log(' Note: Templates are generated only, not executed.');
785
+ }
786
+ else if (agent.app && result.launched) {
787
+ console.log(`✓ ${agent.name} launched with project: ${project}`);
788
+ console.log('\nPaste (Cmd+V) the prompt in the AI chat panel.');
789
+ console.log('When done, review .guardlink/cxg-templates/');
790
+ }
791
+ else if (agent.id === 'clipboard') {
792
+ console.log('\nPaste the prompt into your preferred AI tool.');
793
+ console.log('When done, review .guardlink/cxg-templates/');
794
+ }
795
+ });
796
+ // ─── ask ─────────────────────────────────────────────────────────────
797
+ program
798
+ .command('ask')
799
+ .description('Ask questions about this project, its threat model, and security posture')
800
+ .argument('[query...]', 'Question to answer')
801
+ .option('-d, --dir <dir>', 'Project directory', '.')
802
+ .option('-p, --project <n>', 'Project name', 'unknown')
803
+ .option('--claude-code', 'Launch Claude Code in foreground')
804
+ .option('--codex', 'Launch Codex CLI in foreground')
805
+ .option('--gemini', 'Launch Gemini CLI in foreground')
806
+ .option('--cursor', 'Open Cursor IDE with prompt on clipboard')
807
+ .option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
808
+ .option('--clipboard', 'Copy ask prompt to clipboard only')
809
+ .action(async (queryParts, opts) => {
810
+ const root = resolve(opts.dir);
811
+ const project = detectProjectName(root, opts.project);
812
+ const query = queryParts.join(' ').trim();
813
+ if (!query) {
814
+ console.error('Usage: guardlink ask "<question>" [--claude-code|--codex|--gemini|--cursor|--windsurf|--clipboard]');
815
+ process.exit(1);
816
+ }
817
+ // Parse model if available; allow questions even for lightly-annotated projects
818
+ let model = null;
819
+ try {
820
+ const parsed = await parseProject({ root, project });
821
+ model = parsed.model;
822
+ }
823
+ catch {
824
+ model = null;
825
+ }
826
+ const fullPrompt = buildAskPrompt(query, root, model);
827
+ // Resolve agent: explicit flag > project config > default (Claude Code)
828
+ let agent = agentFromOpts(opts);
829
+ if (!agent) {
830
+ const projCfg = loadProjectConfig(root);
831
+ if (projCfg?.aiMode === 'cli-agent' && projCfg?.cliAgent) {
832
+ agent = AGENTS.find(a => a.id === projCfg.cliAgent) || null;
833
+ }
834
+ if (!agent) {
835
+ agent = AGENTS.find(a => a.id === 'claude-code') || null;
836
+ }
837
+ }
838
+ if (!agent) {
839
+ console.error('No agent available. Use one of:');
840
+ for (const a of AGENTS) {
841
+ console.error(` ${a.flag.padEnd(16)} ${a.name}`);
842
+ }
843
+ process.exit(1);
844
+ }
845
+ console.log(`Launching ${agent.name} for question answering...`);
846
+ if (agent.cmd) {
847
+ console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
848
+ }
849
+ const result = launchAgent(agent, fullPrompt, root);
850
+ if (result.clipboardCopied) {
851
+ console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
852
+ }
853
+ if (result.error) {
854
+ console.error(`✗ ${result.error}`);
855
+ if (result.clipboardCopied) {
856
+ console.log('Prompt is on your clipboard — paste it manually.');
857
+ }
858
+ process.exit(1);
859
+ }
860
+ if (agent.cmd && result.launched) {
861
+ console.log(`\n✓ ${agent.name} session ended.`);
862
+ }
863
+ else if (agent.app && result.launched) {
864
+ console.log(`✓ ${agent.name} launched with project: ${project}`);
865
+ console.log('\nPaste (Cmd+V) the prompt in the AI chat panel.');
866
+ }
867
+ else if (agent.id === 'clipboard') {
868
+ console.log('\nPaste the prompt into your preferred AI tool.');
869
+ }
870
+ });
649
871
  // ─── clear ───────────────────────────────────────────────────────────
650
872
  program
651
873
  .command('clear')
@@ -803,7 +1025,7 @@ program
803
1025
  }
804
1026
  const result = await applyReviewAction(root, reviewable, { decision: 'accept', justification });
805
1027
  results.push(result);
806
- console.error(` ✓ Accepted — ${result.linesInserted} line(s) written to ${reviewable.exposure.location.file}\n`);
1028
+ console.error(` ✓ Accepted — ${result.linesInserted} line(s) written to ${result.targetFile}\n`);
807
1029
  }
808
1030
  else if (choice === 'r') {
809
1031
  let note = '';
@@ -814,10 +1036,10 @@ program
814
1036
  }
815
1037
  const result = await applyReviewAction(root, reviewable, { decision: 'remediate', justification: note });
816
1038
  results.push(result);
817
- console.error(` ✓ Marked for remediation — ${result.linesInserted} line(s) written to ${reviewable.exposure.location.file}\n`);
1039
+ console.error(` ✓ Marked for remediation — ${result.linesInserted} line(s) written to ${result.targetFile}\n`);
818
1040
  }
819
1041
  else {
820
- results.push({ exposure: reviewable, action: { decision: 'skip', justification: '' }, linesInserted: 0 });
1042
+ results.push({ exposure: reviewable, action: { decision: 'skip', justification: '' }, linesInserted: 0, targetFile: reviewable.exposure.location.file });
821
1043
  console.error(' — Skipped\n');
822
1044
  }
823
1045
  }
@@ -842,7 +1064,7 @@ program
842
1064
  .command('config')
843
1065
  .description('Manage LLM provider configuration')
844
1066
  .argument('<action>', 'Action: set, show, clear')
845
- .argument('[key]', 'Config key: provider, api-key, model, ai-mode, cli-agent')
1067
+ .argument('[key]', 'Config key: provider, api-key, model, ai-mode, cli-agent, redact-evidence')
846
1068
  .argument('[value]', 'Value to set')
847
1069
  .option('--global', 'Use global config (~/.config/guardlink/) instead of project')
848
1070
  .action(async (action, key, value, opts) => {
@@ -855,12 +1077,13 @@ program
855
1077
  const projCfg = isGlobal ? loadGlobalConfig() : loadProjectConfig(root);
856
1078
  const aiMode = projCfg?.aiMode || 'api';
857
1079
  const cliAgent = projCfg?.cliAgent;
858
- console.log(`AI Mode: ${aiMode}${cliAgent ? ` (${cliAgent})` : ''}`);
1080
+ const redactEvidence = projCfg?.redactEvidence === true;
1081
+ console.log(`AI Mode: ${aiMode}${cliAgent ? ` (${cliAgent})` : ''}`);
859
1082
  if (config) {
860
- console.log(`Provider: ${config.provider}`);
861
- console.log(`Model: ${config.model}`);
862
- console.log(`API Key: ${maskKey(config.apiKey)}`);
863
- console.log(`Source: ${source}`);
1083
+ console.log(`Provider: ${config.provider}`);
1084
+ console.log(`Model: ${config.model}`);
1085
+ console.log(`API Key: ${maskKey(config.apiKey)}`);
1086
+ console.log(`Source: ${source}`);
864
1087
  }
865
1088
  else if (aiMode !== 'cli-agent') {
866
1089
  console.log('No LLM configuration found.');
@@ -874,6 +1097,7 @@ program
874
1097
  console.log(' export GUARDLINK_LLM_KEY=sk-ant-...');
875
1098
  console.log(' export GUARDLINK_LLM_PROVIDER=anthropic');
876
1099
  }
1100
+ console.log(`Redact evidence: ${redactEvidence ? 'on (pentest evidence is surgically redacted at load)' : 'off (full evidence; see docs/handling-evidence.md)'}`);
877
1101
  break;
878
1102
  }
879
1103
  case 'set': {
@@ -919,8 +1143,26 @@ program
919
1143
  existing.cliAgent = value;
920
1144
  existing.aiMode = 'cli-agent';
921
1145
  break;
1146
+ case 'redact-evidence': {
1147
+ // Accept truthy/falsy spellings — `true`, `false`, `on`, `off`,
1148
+ // `1`, `0`. Reject anything else so a typo doesn't silently
1149
+ // enable redaction the user didn't want.
1150
+ const v = value.toLowerCase();
1151
+ if (['true', 'on', '1', 'yes'].includes(v)) {
1152
+ existing.redactEvidence = true;
1153
+ }
1154
+ else if (['false', 'off', '0', 'no'].includes(v)) {
1155
+ existing.redactEvidence = false;
1156
+ }
1157
+ else {
1158
+ console.error(`Invalid value for redact-evidence: ${value}`);
1159
+ console.error('Use: true | false (also accepted: on/off, 1/0, yes/no)');
1160
+ process.exit(1);
1161
+ }
1162
+ break;
1163
+ }
922
1164
  default:
923
- console.error(`Unknown config key: ${key}. Use: provider, api-key, model, ai-mode, cli-agent`);
1165
+ console.error(`Unknown config key: ${key}. Use: provider, api-key, model, ai-mode, cli-agent, redact-evidence`);
924
1166
  process.exit(1);
925
1167
  }
926
1168
  if (isGlobal) {
@@ -958,19 +1200,27 @@ program
958
1200
  .option('-p, --project <n>', 'Project name', 'unknown')
959
1201
  .option('-o, --output <file>', 'Output file (default: threat-dashboard.html)')
960
1202
  .option('--light', 'Default to light theme instead of dark')
1203
+ .option('--feature <names>', 'Filter dashboard to specific feature(s) (comma-separated)')
961
1204
  .action(async (dir, opts) => {
962
1205
  const root = resolve(dir);
963
1206
  const project = detectProjectName(root, opts.project);
964
- const { model, diagnostics } = await parseProject({ root, project });
1207
+ let { model, diagnostics } = await parseProject({ root, project });
1208
+ // Apply feature filter if specified
1209
+ if (opts.feature) {
1210
+ const featureNames = opts.feature.split(',').map(s => s.trim());
1211
+ model = filterByFeature(model, featureNames);
1212
+ console.error(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}`);
1213
+ }
965
1214
  const errors = diagnostics.filter(d => d.level === 'error');
966
1215
  if (errors.length > 0)
967
1216
  printDiagnostics(errors);
968
- if (model.annotations_parsed === 0) {
1217
+ if (model.annotations_parsed === 0 && !opts.feature) {
969
1218
  console.error('No annotations found. Add GuardLink annotations first.');
970
1219
  process.exit(1);
971
1220
  }
972
1221
  const analyses = loadThreatReportsForDashboard(root);
973
- let html = generateDashboardHTML(model, root, analyses);
1222
+ const pentestData = loadPentestData(root);
1223
+ let html = generateDashboardHTML(model, root, analyses, pentestData);
974
1224
  // Switch default theme if requested
975
1225
  if (opts.light) {
976
1226
  html = html.replace('data-theme="dark"', 'data-theme="light"');
@@ -1199,6 +1449,94 @@ program
1199
1449
  // Print full markdown summary to stdout (pipeable)
1200
1450
  console.log(formatMergeSummary(merged));
1201
1451
  });
1452
+ // ─── feature ──────────────────────────────────────────────────────────
1453
+ const featureCmd = program
1454
+ .command('feature')
1455
+ .description('Manage and inspect feature tags across the threat model');
1456
+ featureCmd
1457
+ .command('list')
1458
+ .description('List all features found in annotations')
1459
+ .argument('[dir]', 'Project directory to scan', '.')
1460
+ .option('-p, --project <n>', 'Project name', 'unknown')
1461
+ .option('--json', 'Output as JSON')
1462
+ .action(async (dir, opts) => {
1463
+ const root = resolve(dir);
1464
+ const project = detectProjectName(root, opts.project);
1465
+ const { model } = await parseProject({ root, project });
1466
+ const features = listFeatures(model);
1467
+ if (features.length === 0) {
1468
+ console.log('No @feature annotations found.');
1469
+ console.log('Tag code with: // @feature "Feature Name" -- "description"');
1470
+ return;
1471
+ }
1472
+ if (opts.json) {
1473
+ const summaries = getFeatureSummaries(model);
1474
+ console.log(JSON.stringify(summaries, null, 2));
1475
+ return;
1476
+ }
1477
+ const summaries = getFeatureSummaries(model);
1478
+ console.log(`Features in ${project}:\n`);
1479
+ for (const s of summaries) {
1480
+ const files = s.files.length === 1 ? '1 file' : `${s.files.length} files`;
1481
+ const exposureInfo = s.exposures > 0 ? ` | ${s.exposures} exposure(s)` : '';
1482
+ const confirmedInfo = s.confirmed > 0 ? ` | ${s.confirmed} confirmed` : '';
1483
+ console.log(` "${s.name}" (${files}, ${s.annotations} annotations${exposureInfo}${confirmedInfo})`);
1484
+ for (const f of s.files) {
1485
+ console.log(` → ${f}`);
1486
+ }
1487
+ }
1488
+ console.log(`\n${features.length} feature(s) total`);
1489
+ });
1490
+ featureCmd
1491
+ .command('show')
1492
+ .description('Show detailed threat model for a specific feature')
1493
+ .argument('<name>', 'Feature name (case-insensitive)')
1494
+ .option('-d, --dir <dir>', 'Project directory', '.')
1495
+ .option('-p, --project <n>', 'Project name', 'unknown')
1496
+ .option('--json', 'Output as JSON')
1497
+ .action(async (name, opts) => {
1498
+ const root = resolve(opts.dir);
1499
+ const project = detectProjectName(root, opts.project);
1500
+ const { model } = await parseProject({ root, project });
1501
+ const filtered = filterByFeature(model, [name]);
1502
+ const totalAnnotations = filtered.assets.length + filtered.threats.length +
1503
+ filtered.controls.length + filtered.mitigations.length + filtered.exposures.length +
1504
+ filtered.confirmed.length + filtered.flows.length + filtered.boundaries.length;
1505
+ if (totalAnnotations === 0) {
1506
+ console.error(`No annotations found for feature "${name}".`);
1507
+ const available = listFeatures(model);
1508
+ if (available.length > 0) {
1509
+ console.error(`Available features: ${available.map(f => `"${f}"`).join(', ')}`);
1510
+ }
1511
+ process.exit(1);
1512
+ }
1513
+ if (opts.json) {
1514
+ console.log(JSON.stringify(filtered, null, 2));
1515
+ return;
1516
+ }
1517
+ console.log(`Feature: "${name}"\n`);
1518
+ console.log(` Assets: ${filtered.assets.length}`);
1519
+ console.log(` Threats: ${filtered.threats.length}`);
1520
+ console.log(` Controls: ${filtered.controls.length}`);
1521
+ console.log(` Mitigations: ${filtered.mitigations.length}`);
1522
+ console.log(` Exposures: ${filtered.exposures.length}`);
1523
+ if (filtered.confirmed.length > 0)
1524
+ console.log(` Confirmed: ${filtered.confirmed.length} 🔴`);
1525
+ console.log(` Flows: ${filtered.flows.length}`);
1526
+ console.log(` Boundaries: ${filtered.boundaries.length}`);
1527
+ if (filtered.exposures.length > 0) {
1528
+ console.log(`\n Exposures:`);
1529
+ for (const e of filtered.exposures) {
1530
+ console.log(` ${e.asset} → ${e.threat} [${e.severity || 'unset'}] (${e.location.file}:${e.location.line})`);
1531
+ }
1532
+ }
1533
+ if (filtered.mitigations.length > 0) {
1534
+ console.log(`\n Mitigations:`);
1535
+ for (const m of filtered.mitigations) {
1536
+ console.log(` ${m.asset} against ${m.threat}${m.control ? ` using ${m.control}` : ''} (${m.location.file}:${m.location.line})`);
1537
+ }
1538
+ }
1539
+ });
1202
1540
  // ─── mcp ─────────────────────────────────────────────────────────────
1203
1541
  program
1204
1542
  .command('mcp')
@@ -1238,10 +1576,12 @@ program
1238
1576
  console.log(H(' GAL — GuardLink Annotation Language'));
1239
1577
  console.log(H(' ══════════════════════════════════════════════════════════'));
1240
1578
  console.log('');
1241
- console.log(D(' Annotations live in source code comments. GuardLink parses'));
1242
- console.log(D(' them to build a live threat model from your codebase.'));
1579
+ console.log(D(' Annotations live in source comments or standalone .gal files.'));
1580
+ console.log(D(' GuardLink parses them into a live threat model for your codebase.'));
1243
1581
  console.log('');
1244
1582
  console.log(D(' Syntax: @verb subject [preposition object] [-- "description"]'));
1583
+ console.log(D(' Inline examples below use comment prefixes; raw .gal files use the same lines without // or #.'));
1584
+ console.log(D(' In .gal files, use @source file:<path> line:<n> [symbol:<name>] to anchor following annotations.'));
1245
1585
  console.log('');
1246
1586
  // ── DEFINITIONS ──
1247
1587
  console.log(H(' ── Definitions ─────────────────────────────────────────────'));
@@ -1278,6 +1618,12 @@ program
1278
1618
  console.log(EX(' // @mitigates api.auth against SQL Injection using Input Validation'));
1279
1619
  console.log(EX(' // @mitigates db.users against Token Theft -- "Rotation implemented in v2"'));
1280
1620
  console.log('');
1621
+ console.log(` ${V('@confirmed')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[severity]')} ${D('[ext-refs]')} ${D('[-- "evidence"]')}`);
1622
+ console.log(D(' Mark a threat as verified exploitable (pentest, scan, or manual repro).'));
1623
+ console.log(D(' Not a false positive — use observed severity. Distinct from @exposes (hypothesis).'));
1624
+ console.log(EX(' // @confirmed SQL Injection on api.auth [critical] cwe:CWE-89 -- "Pen test: blind SQLi on /login"'));
1625
+ console.log(EX(' // @confirmed #secret-exposure on App.Config [critical] -- "Live key in repo; verified with provider"'));
1626
+ console.log('');
1281
1627
  console.log(` ${V('@accepts')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[-- "reason"]')}`);
1282
1628
  console.log(D(' Explicitly accept a risk. Removes it from open findings.'));
1283
1629
  console.log(D(' Use when the risk is known and intentionally not mitigated.'));
@@ -1328,6 +1674,21 @@ program
1328
1674
  console.log(D(' Document a security assumption about an asset.'));
1329
1675
  console.log(EX(' // @assumes api.gateway -- "Upstream WAF filters malformed requests"'));
1330
1676
  console.log('');
1677
+ // ── FEATURE TAGGING ──
1678
+ console.log(H(' ── Feature Tagging ─────────────────────────────────────────'));
1679
+ console.log('');
1680
+ console.log(` ${V('@feature')} ${K('"Feature Name"')} ${D('[-- "description"]')}`);
1681
+ console.log(D(' Tag code with a feature name for filtering reports and dashboards.'));
1682
+ console.log(D(' A file can have multiple @feature tags. All annotations in that file'));
1683
+ console.log(D(' are associated with the tagged features.'));
1684
+ console.log(EX(' // @feature "SSO Login" -- "Single sign-on authentication flow"'));
1685
+ console.log(EX(' // @feature "Payment Processing"'));
1686
+ console.log(D(''));
1687
+ console.log(D(' Filter commands by feature:'));
1688
+ console.log(EX(' guardlink feature list'));
1689
+ console.log(EX(' guardlink report . --feature "SSO Login"'));
1690
+ console.log(EX(' guardlink dashboard . --feature "SSO Login,Payment Processing"'));
1691
+ console.log('');
1331
1692
  console.log(` ${V('@comment')} ${D('[-- "description"]')}`);
1332
1693
  console.log(D(' Free-form developer security note (no structural effect).'));
1333
1694
  console.log(EX(' // @comment -- "TODO — add rate limiting before v2 launch"'));
@@ -1349,7 +1710,7 @@ program
1349
1710
  // ── EXTERNAL REFERENCES ──
1350
1711
  console.log(H(' ── External References ─────────────────────────────────────'));
1351
1712
  console.log('');
1352
- console.log(D(' Append space-separated refs after severity on @threat and @exposes:'));
1713
+ console.log(D(' Append space-separated refs after severity on @threat, @exposes, and @confirmed:'));
1353
1714
  console.log(EX(' cwe:CWE-89 owasp:A03:2021 capec:CAPEC-66 attack:T1190'));
1354
1715
  console.log('');
1355
1716
  console.log(D(' Example:'));
@@ -1383,23 +1744,27 @@ else {
1383
1744
  // ─── Helpers ─────────────────────────────────────────────────────────
1384
1745
  function printDiagnostics(diagnostics) {
1385
1746
  for (const d of diagnostics) {
1386
- const prefix = d.level === 'error' ? '✗' : '⚠';
1387
- console.error(`${prefix} ${d.file}:${d.line}: ${d.message}`);
1747
+ console.error(`${diagnosticIcon(d.level)} ${d.file}:${d.line}: ${d.message}`);
1388
1748
  if (d.raw)
1389
1749
  console.error(` → ${d.raw}`);
1390
1750
  }
1391
1751
  if (diagnostics.length > 0) {
1752
+ const fatals = diagnostics.filter(d => d.level === 'fatal').length;
1392
1753
  const errors = diagnostics.filter(d => d.level === 'error').length;
1393
1754
  const warnings = diagnostics.filter(d => d.level === 'warning').length;
1394
- console.error(`\n${errors} error(s), ${warnings} warning(s)\n`);
1755
+ const parts = [];
1756
+ if (fatals > 0)
1757
+ parts.push(`${fatals} fatal(s)`);
1758
+ parts.push(`${errors} error(s)`, `${warnings} warning(s)`);
1759
+ console.error(`\n${parts.join(', ')}\n`);
1395
1760
  }
1396
1761
  }
1397
1762
  function printStatus(model) {
1398
1763
  console.log(`GuardLink Status: ${model.project}`);
1399
1764
  console.log(`${'─'.repeat(40)}`);
1400
1765
  console.log(`Files scanned: ${model.source_files}`);
1401
- console.log(` Annotated: ${model.annotated_files.length}`);
1402
- console.log(` Not annotated: ${model.unannotated_files.length}`);
1766
+ console.log(` Files annotated: ${model.annotated_files.length}`);
1767
+ console.log(` Files unannotated: ${model.unannotated_files.length}`);
1403
1768
  console.log(`Annotations: ${model.annotations_parsed}`);
1404
1769
  console.log(`${'─'.repeat(40)}`);
1405
1770
  console.log(`Assets: ${model.assets.length}`);
@@ -1407,6 +1772,8 @@ function printStatus(model) {
1407
1772
  console.log(`Controls: ${model.controls.length}`);
1408
1773
  console.log(`Mitigations: ${model.mitigations.length}`);
1409
1774
  console.log(`Exposures: ${model.exposures.length}`);
1775
+ if ((model.confirmed || []).length > 0)
1776
+ console.log(`Confirmed: ${model.confirmed.length} 🔴`);
1410
1777
  console.log(`Acceptances: ${model.acceptances.length}`);
1411
1778
  console.log(`Transfers: ${model.transfers.length}`);
1412
1779
  console.log(`Flows: ${model.flows.length}`);
@@ -1416,6 +1783,10 @@ function printStatus(model) {
1416
1783
  console.log(`Ownership: ${model.ownership.length}`);
1417
1784
  console.log(`Data handling: ${model.data_handling.length}`);
1418
1785
  console.log(`Assumptions: ${model.assumptions.length}`);
1786
+ if (model.features.length > 0) {
1787
+ const uniqueFeatures = new Set(model.features.map(f => f.feature));
1788
+ console.log(`Features: ${uniqueFeatures.size} (${model.features.length} tag${model.features.length > 1 ? 's' : ''})`);
1789
+ }
1419
1790
  console.log(`Comments: ${model.comments.length}`);
1420
1791
  console.log(`Shields: ${model.shields.length}`);
1421
1792
  }