guardlink 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +11 -2
  3. package/dist/agents/config.d.ts +17 -0
  4. package/dist/agents/config.d.ts.map +1 -1
  5. package/dist/agents/config.js +38 -4
  6. package/dist/agents/config.js.map +1 -1
  7. package/dist/agents/index.d.ts +5 -1
  8. package/dist/agents/index.d.ts.map +1 -1
  9. package/dist/agents/index.js +4 -1
  10. package/dist/agents/index.js.map +1 -1
  11. package/dist/agents/launcher.d.ts +25 -8
  12. package/dist/agents/launcher.d.ts.map +1 -1
  13. package/dist/agents/launcher.js +137 -9
  14. package/dist/agents/launcher.js.map +1 -1
  15. package/dist/agents/prompts.d.ts +9 -0
  16. package/dist/agents/prompts.d.ts.map +1 -1
  17. package/dist/agents/prompts.js +43 -6
  18. package/dist/agents/prompts.js.map +1 -1
  19. package/dist/analyze/index.d.ts +44 -8
  20. package/dist/analyze/index.d.ts.map +1 -1
  21. package/dist/analyze/index.js +291 -15
  22. package/dist/analyze/index.js.map +1 -1
  23. package/dist/analyze/llm.d.ts +65 -13
  24. package/dist/analyze/llm.d.ts.map +1 -1
  25. package/dist/analyze/llm.js +429 -107
  26. package/dist/analyze/llm.js.map +1 -1
  27. package/dist/analyze/prompts.d.ts +6 -2
  28. package/dist/analyze/prompts.d.ts.map +1 -1
  29. package/dist/analyze/prompts.js +230 -111
  30. package/dist/analyze/prompts.js.map +1 -1
  31. package/dist/analyze/tools.d.ts +28 -0
  32. package/dist/analyze/tools.d.ts.map +1 -0
  33. package/dist/analyze/tools.js +236 -0
  34. package/dist/analyze/tools.js.map +1 -0
  35. package/dist/analyzer/index.d.ts +3 -0
  36. package/dist/analyzer/index.d.ts.map +1 -1
  37. package/dist/analyzer/index.js +3 -0
  38. package/dist/analyzer/index.js.map +1 -1
  39. package/dist/analyzer/sarif.d.ts +5 -6
  40. package/dist/analyzer/sarif.d.ts.map +1 -1
  41. package/dist/analyzer/sarif.js +5 -6
  42. package/dist/analyzer/sarif.js.map +1 -1
  43. package/dist/cli/index.d.ts +27 -16
  44. package/dist/cli/index.d.ts.map +1 -1
  45. package/dist/cli/index.js +524 -105
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/dashboard/data.d.ts +5 -0
  48. package/dist/dashboard/data.d.ts.map +1 -1
  49. package/dist/dashboard/data.js +5 -0
  50. package/dist/dashboard/data.js.map +1 -1
  51. package/dist/dashboard/generate.d.ts +8 -5
  52. package/dist/dashboard/generate.d.ts.map +1 -1
  53. package/dist/dashboard/generate.js +206 -66
  54. package/dist/dashboard/generate.js.map +1 -1
  55. package/dist/dashboard/index.d.ts +5 -0
  56. package/dist/dashboard/index.d.ts.map +1 -1
  57. package/dist/dashboard/index.js +5 -0
  58. package/dist/dashboard/index.js.map +1 -1
  59. package/dist/diff/git.d.ts +10 -7
  60. package/dist/diff/git.d.ts.map +1 -1
  61. package/dist/diff/git.js +10 -7
  62. package/dist/diff/git.js.map +1 -1
  63. package/dist/diff/index.d.ts +4 -0
  64. package/dist/diff/index.d.ts.map +1 -1
  65. package/dist/diff/index.js +4 -0
  66. package/dist/diff/index.js.map +1 -1
  67. package/dist/init/detect.d.ts +5 -0
  68. package/dist/init/detect.d.ts.map +1 -1
  69. package/dist/init/detect.js +5 -0
  70. package/dist/init/detect.js.map +1 -1
  71. package/dist/init/index.d.ts +26 -6
  72. package/dist/init/index.d.ts.map +1 -1
  73. package/dist/init/index.js +91 -11
  74. package/dist/init/index.js.map +1 -1
  75. package/dist/init/picker.d.ts.map +1 -1
  76. package/dist/init/picker.js +17 -6
  77. package/dist/init/picker.js.map +1 -1
  78. package/dist/init/templates.d.ts +20 -0
  79. package/dist/init/templates.d.ts.map +1 -1
  80. package/dist/init/templates.js +167 -36
  81. package/dist/init/templates.js.map +1 -1
  82. package/dist/mcp/index.d.ts +5 -0
  83. package/dist/mcp/index.d.ts.map +1 -1
  84. package/dist/mcp/index.js +5 -0
  85. package/dist/mcp/index.js.map +1 -1
  86. package/dist/mcp/lookup.d.ts +5 -0
  87. package/dist/mcp/lookup.d.ts.map +1 -1
  88. package/dist/mcp/lookup.js +5 -0
  89. package/dist/mcp/lookup.js.map +1 -1
  90. package/dist/mcp/server.d.ts +16 -13
  91. package/dist/mcp/server.d.ts.map +1 -1
  92. package/dist/mcp/server.js +140 -17
  93. package/dist/mcp/server.js.map +1 -1
  94. package/dist/mcp/suggest.d.ts +8 -6
  95. package/dist/mcp/suggest.d.ts.map +1 -1
  96. package/dist/mcp/suggest.js +8 -6
  97. package/dist/mcp/suggest.js.map +1 -1
  98. package/dist/parser/clear.d.ts +36 -0
  99. package/dist/parser/clear.d.ts.map +1 -0
  100. package/dist/parser/clear.js +148 -0
  101. package/dist/parser/clear.js.map +1 -0
  102. package/dist/parser/index.d.ts +3 -1
  103. package/dist/parser/index.d.ts.map +1 -1
  104. package/dist/parser/index.js +2 -1
  105. package/dist/parser/index.js.map +1 -1
  106. package/dist/parser/parse-file.d.ts +5 -2
  107. package/dist/parser/parse-file.d.ts.map +1 -1
  108. package/dist/parser/parse-file.js +29 -2
  109. package/dist/parser/parse-file.js.map +1 -1
  110. package/dist/parser/parse-line.d.ts +3 -3
  111. package/dist/parser/parse-line.js +3 -3
  112. package/dist/parser/parse-project.d.ts +7 -7
  113. package/dist/parser/parse-project.d.ts.map +1 -1
  114. package/dist/parser/parse-project.js +24 -11
  115. package/dist/parser/parse-project.js.map +1 -1
  116. package/dist/parser/validate.d.ts +12 -0
  117. package/dist/parser/validate.d.ts.map +1 -1
  118. package/dist/parser/validate.js +44 -0
  119. package/dist/parser/validate.js.map +1 -1
  120. package/dist/report/index.d.ts +3 -0
  121. package/dist/report/index.d.ts.map +1 -1
  122. package/dist/report/index.js +3 -0
  123. package/dist/report/index.js.map +1 -1
  124. package/dist/report/report.d.ts +4 -7
  125. package/dist/report/report.d.ts.map +1 -1
  126. package/dist/report/report.js +68 -7
  127. package/dist/report/report.js.map +1 -1
  128. package/dist/review/index.d.ts +62 -0
  129. package/dist/review/index.d.ts.map +1 -0
  130. package/dist/review/index.js +226 -0
  131. package/dist/review/index.js.map +1 -0
  132. package/dist/tui/commands.d.ts +26 -1
  133. package/dist/tui/commands.d.ts.map +1 -1
  134. package/dist/tui/commands.js +608 -101
  135. package/dist/tui/commands.js.map +1 -1
  136. package/dist/tui/config.d.ts +6 -0
  137. package/dist/tui/config.d.ts.map +1 -1
  138. package/dist/tui/config.js +6 -0
  139. package/dist/tui/config.js.map +1 -1
  140. package/dist/tui/format.d.ts +7 -0
  141. package/dist/tui/format.d.ts.map +1 -1
  142. package/dist/tui/format.js +59 -0
  143. package/dist/tui/format.js.map +1 -1
  144. package/dist/tui/index.d.ts +8 -8
  145. package/dist/tui/index.d.ts.map +1 -1
  146. package/dist/tui/index.js +47 -10
  147. package/dist/tui/index.js.map +1 -1
  148. package/dist/tui/input.d.ts +6 -0
  149. package/dist/tui/input.d.ts.map +1 -1
  150. package/dist/tui/input.js +6 -0
  151. package/dist/tui/input.js.map +1 -1
  152. package/dist/types/index.d.ts +2 -0
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/package.json +1 -1
@@ -3,21 +3,38 @@
3
3
  *
4
4
  * Each command function takes (args, ctx) and prints output directly.
5
5
  * Returns void. Throws on fatal errors.
6
+ *
7
+ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o"
8
+ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access"
9
+ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files"
10
+ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root"
11
+ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes"
12
+ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts"
13
+ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage"
14
+ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output"
15
+ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM"
16
+ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only"
17
+ * @flows UserArgs -> #tui via args -- "Command argument input"
18
+ * @flows #tui -> FileSystem via writeFile -- "Report/config output"
19
+ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path"
20
+ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path"
21
+ * @handles secrets on #tui -- "Processes and stores API keys via /model"
6
22
  */
7
23
  import { resolve, basename } from 'node:path';
8
- import { writeFileSync } from 'node:fs';
9
- import { parseProject, findDanglingRefs, findUnmitigatedExposures } from '../parser/index.js';
10
- import { initProject, detectProject, promptAgentSelection } from '../init/index.js';
24
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
25
+ import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, clearAnnotations } from '../parser/index.js';
26
+ import { initProject, detectProject, promptAgentSelection, syncAgentFiles } from '../init/index.js';
11
27
  import { generateReport } from '../report/index.js';
12
28
  import { generateDashboardHTML } from '../dashboard/index.js';
13
- import { computeStats, computeSeverity } from '../dashboard/data.js';
14
- import { generateThreatReport, serializeModel, listThreatReports, loadThreatReportsForDashboard, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage } from '../analyze/index.js';
29
+ import { computeStats, computeSeverity, computeExposures } from '../dashboard/data.js';
30
+ import { generateThreatReport, serializeModel, listThreatReports, loadThreatReportsForDashboard, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage, buildProjectContext, extractCodeSnippets } from '../analyze/index.js';
15
31
  import { diffModels, formatDiff, parseAtRef } from '../diff/index.js';
16
32
  import { generateSarif } from '../analyzer/index.js';
17
- import { C, severityBadge, severityText, severityOrder, computeGrade, gradeColored, readCodeContext, bar, fileLink } from './format.js';
33
+ import { C, severityBadge, severityText, severityTextPad, severityOrder, computeGrade, gradeColored, readCodeContext, trunc, bar, fileLink, fileLinkTrunc, cleanCliArtifacts } from './format.js';
18
34
  import { resolveLLMConfig, saveTuiConfig, loadTuiConfig } from './config.js';
19
- import { AGENTS, parseAgentFlag, launchAgent, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
35
+ import { AGENTS, parseAgentFlag, launchAgent, launchAgentInline, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
20
36
  import { describeConfigSource } from '../agents/config.js';
37
+ import { getReviewableExposures, applyReviewAction, summarizeReview } from '../review/index.js';
21
38
  // ─── Shared context ──────────────────────────────────────────────────
22
39
  /** Prompt user to pick an agent interactively (TUI only) */
23
40
  async function pickAgent(ctx) {
@@ -63,14 +80,20 @@ export function cmdHelp() {
63
80
  ['/status', 'Risk grade + summary stats'],
64
81
  ['/validate [--strict]', 'Check for syntax errors + dangling refs'],
65
82
  ['', ''],
83
+ ['/exposures [--all]', 'List open exposures by severity (filter: --asset --severity --threat --file)'],
84
+ ['/show <n>', 'Detail view + code context for an exposure (from /exposures list)'],
85
+ ['/scan', 'Annotation coverage scanner — find unannotated symbols'],
66
86
  ['/assets', 'Asset tree with threat/control counts'],
67
87
  ['/files', 'Annotated file tree with exposure counts'],
68
88
  ['/view <file>', 'Show all annotations in a file with code context'],
69
89
  ['', ''],
70
- ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general)'],
90
+ ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general|custom)'],
71
91
  ['/threat-reports', 'List saved AI threat reports'],
72
92
  ['/annotate <prompt>', 'Launch coding agent to annotate codebase'],
73
- ['/model', 'Set AI provider + API key'],
93
+ ['/model', 'Set AI provider (API or CLI agent: Claude Code, Codex, Gemini)'],
94
+ ['/clear', 'Remove all annotations from source files (start fresh)'],
95
+ ['/sync', 'Sync agent instruction files with current threat model'],
96
+ ['/review [severity]', 'Interactive governance review of unmitigated exposures'],
74
97
  ['(freeform text)', 'Chat about your threat model with AI'],
75
98
  ['', ''],
76
99
  ['/report', 'Generate markdown + JSON report'],
@@ -78,6 +101,7 @@ export function cmdHelp() {
78
101
  ['/diff [ref]', 'Compare model against a git ref (default: HEAD~1)'],
79
102
  ['/sarif [-o file]', 'Export SARIF 2.1.0 for GitHub / VS Code'],
80
103
  ['', ''],
104
+ ['/gal', 'GAL annotation language guide'],
81
105
  ['/help', 'This help'],
82
106
  ['/quit', 'Exit'],
83
107
  ];
@@ -249,6 +273,10 @@ export function cmdStatus(ctx) {
249
273
  console.log(` ${C.dim('Assets:')} ${stats.assets} ${C.dim('Threats:')} ${stats.threats} ${C.dim('Controls:')} ${stats.controls}`);
250
274
  console.log(` ${C.dim('Flows:')} ${stats.flows} ${C.dim('Boundaries:')} ${stats.boundaries} ${C.dim('Annotations:')} ${stats.annotations}`);
251
275
  console.log(` ${C.dim('Coverage:')} ${stats.coverageAnnotated}/${stats.coverageTotal} symbols (${stats.coveragePercent}%)`);
276
+ console.log(` ${C.dim('Files:')} ${m.annotated_files.length} annotated, ${m.unannotated_files.length} not annotated of ${m.source_files} scanned`);
277
+ if (m.unannotated_files.length > 0) {
278
+ console.log(` ${C.dim('Run')} /unannotated ${C.dim('to list files without annotations')}`);
279
+ }
252
280
  // Top threats
253
281
  if (m.exposures.length > 0) {
254
282
  const threatCounts = new Map();
@@ -276,6 +304,126 @@ export function cmdStatus(ctx) {
276
304
  }
277
305
  console.log('');
278
306
  }
307
+ // ─── /exposures ──────────────────────────────────────────────────────
308
+ export function cmdExposures(args, ctx) {
309
+ if (!ctx.model) {
310
+ console.log(C.warn(' No threat model. Run /parse first.'));
311
+ return;
312
+ }
313
+ const rows = computeExposures(ctx.model);
314
+ let filtered = rows.filter(r => !r.mitigated && !r.accepted); // open only by default
315
+ // Parse flags
316
+ const parts = args.split(/\s+/).filter(Boolean);
317
+ let showAll = false;
318
+ for (let i = 0; i < parts.length; i++) {
319
+ const flag = parts[i];
320
+ const val = parts[i + 1];
321
+ if (flag === '--asset' && val) {
322
+ filtered = filtered.filter(r => r.asset.includes(val));
323
+ i++;
324
+ }
325
+ else if (flag === '--severity' && val) {
326
+ filtered = filtered.filter(r => r.severity === val.toLowerCase());
327
+ i++;
328
+ }
329
+ else if (flag === '--file' && val) {
330
+ filtered = filtered.filter(r => r.file.includes(val));
331
+ i++;
332
+ }
333
+ else if (flag === '--threat' && val) {
334
+ filtered = filtered.filter(r => r.threat.includes(val));
335
+ i++;
336
+ }
337
+ else if (flag === '--all') {
338
+ filtered = rows;
339
+ showAll = true;
340
+ }
341
+ }
342
+ // Sort by severity
343
+ filtered.sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
344
+ // Cache for /show
345
+ ctx.lastExposures = filtered.map(r => {
346
+ const original = ctx.model.exposures.find(e => e.asset === r.asset && e.threat === r.threat && e.location.file === r.file && e.location.line === r.line);
347
+ return original;
348
+ }).filter(Boolean);
349
+ if (filtered.length === 0) {
350
+ console.log(C.green(' No matching exposures found.'));
351
+ return;
352
+ }
353
+ console.log('');
354
+ const termWidth = process.stdout.columns || 100;
355
+ const header = ` ${C.dim('#'.padEnd(4))}${C.dim('SEVERITY'.padEnd(12))}${C.dim('ASSET'.padEnd(18))}${C.dim('THREAT'.padEnd(20))}${C.dim('FILE'.padEnd(30))}${C.dim('LINE')}`;
356
+ console.log(header);
357
+ console.log(C.dim(' ' + '─'.repeat(Math.min(termWidth - 4, 96))));
358
+ for (const [i, r] of filtered.entries()) {
359
+ const num = String(i + 1).padEnd(4);
360
+ const sev = severityTextPad(r.severity, 12);
361
+ const asset = trunc(r.asset, 16).padEnd(18);
362
+ const threat = trunc(r.threat, 18).padEnd(20);
363
+ const linkedFile = fileLinkTrunc(r.file, 28, r.line, ctx.root);
364
+ const filePad = ' '.repeat(Math.max(0, 30 - trunc(r.file, 28).length));
365
+ const line = ` ${num}${sev}${asset}${threat}${linkedFile}${filePad}${r.line}`;
366
+ console.log(line);
367
+ }
368
+ console.log('');
369
+ const countMsg = showAll
370
+ ? ` ${filtered.length} exposure(s) total`
371
+ : ` ${filtered.length} open exposure(s)`;
372
+ console.log(C.dim(countMsg + ' · /show <n> for detail · --asset --severity --threat --file to filter'));
373
+ console.log('');
374
+ }
375
+ // ─── /show ───────────────────────────────────────────────────────────
376
+ export function cmdShow(args, ctx) {
377
+ const num = parseInt(args.trim(), 10);
378
+ if (!num || num < 1 || num > ctx.lastExposures.length) {
379
+ console.log(C.warn(` Usage: /show <n> where n is 1-${ctx.lastExposures.length || '?'}. Run /exposures first.`));
380
+ return;
381
+ }
382
+ const exp = ctx.lastExposures[num - 1];
383
+ console.log('');
384
+ console.log(` ${C.cyan('┌')} ${exp.asset} → ${exp.threat} ${severityBadge(exp.severity)}`);
385
+ if (exp.description) {
386
+ console.log(` ${C.cyan('│')} ${exp.description}`);
387
+ }
388
+ if (exp.external_refs.length > 0) {
389
+ console.log(` ${C.cyan('│')} ${C.dim(exp.external_refs.join(' · '))}`);
390
+ }
391
+ console.log(` ${C.cyan('│')} ${C.dim(fileLink(exp.location.file, exp.location.line, ctx.root))}`);
392
+ console.log(` ${C.cyan('│')}`);
393
+ const { lines } = readCodeContext(exp.location.file, exp.location.line, ctx.root);
394
+ for (const l of lines) {
395
+ console.log(` ${C.cyan('│')} ${l}`);
396
+ }
397
+ console.log(` ${C.cyan('└')}`);
398
+ console.log('');
399
+ }
400
+ // ─── /scan ───────────────────────────────────────────────────────────
401
+ export function cmdScan(ctx) {
402
+ if (!ctx.model) {
403
+ console.log(C.warn(' No threat model. Run /parse first.'));
404
+ return;
405
+ }
406
+ const cov = ctx.model.coverage;
407
+ const pct = cov.coverage_percent;
408
+ console.log('');
409
+ console.log(` ${C.bold('Coverage:')} ${cov.annotated_symbols}/${cov.total_symbols} symbols (${pct}%)`);
410
+ const unannotated = cov.unannotated_critical || [];
411
+ if (unannotated.length === 0) {
412
+ console.log(C.green(' All security-relevant symbols are annotated!'));
413
+ }
414
+ else {
415
+ console.log(C.warn(` ${unannotated.length} unannotated symbol(s):`));
416
+ console.log('');
417
+ const show = unannotated.slice(0, 25);
418
+ for (const u of show) {
419
+ console.log(` ${C.dim(fileLink(u.file, u.line, ctx.root))} ${u.kind} ${C.bold(u.name)}`);
420
+ }
421
+ if (unannotated.length > 25) {
422
+ console.log(C.dim(` ... and ${unannotated.length - 25} more`));
423
+ }
424
+ }
425
+ console.log('');
426
+ }
279
427
  // ─── /assets ─────────────────────────────────────────────────────────
280
428
  export function cmdAssets(ctx) {
281
429
  if (!ctx.model) {
@@ -566,6 +714,13 @@ export async function cmdParse(ctx) {
566
714
  console.log(` ${C.success('✓')} Parsed ${C.bold(String(model.annotations_parsed))} annotations from ${model.source_files} files`);
567
715
  console.log(` ${model.assets.length} assets · ${model.threats.length} threats · ${model.controls.length} controls`);
568
716
  console.log(` ${model.exposures.length} exposures · ${model.mitigations.length} mitigations · Grade: ${gradeColored(grade)}`);
717
+ // Auto-sync agent instruction files with updated model
718
+ if (model.annotations_parsed > 0) {
719
+ const syncResult = syncAgentFiles({ root: ctx.root, model });
720
+ if (syncResult.updated.length > 0) {
721
+ console.log(C.dim(` ↻ Synced ${syncResult.updated.length} agent instruction file(s)`));
722
+ }
723
+ }
569
724
  console.log('');
570
725
  }
571
726
  catch (err) {
@@ -580,9 +735,13 @@ export async function cmdValidate(ctx) {
580
735
  ctx.model = model;
581
736
  // Dangling refs
582
737
  const danglingDiags = findDanglingRefs(model);
583
- const allDiags = [...diagnostics, ...danglingDiags];
738
+ // Check for @accepts without @audit (governance concern)
739
+ const acceptAuditDiags = findAcceptedWithoutAudit(model);
740
+ const allDiags = [...diagnostics, ...danglingDiags, ...acceptAuditDiags];
584
741
  // Unmitigated exposures
585
742
  const unmitigated = findUnmitigatedExposures(model);
743
+ // Accepted-but-unmitigated exposures
744
+ const acceptedOnly = findAcceptedExposures(model);
586
745
  // Print diagnostics
587
746
  const errors = allDiags.filter(d => d.level === 'error');
588
747
  const warnings = allDiags.filter(d => d.level === 'warning');
@@ -602,8 +761,16 @@ export async function cmdValidate(ctx) {
602
761
  console.log(` ${sev} ${u.asset} → ${u.threat} ${C.dim(fileLink(u.location.file, u.location.line, ctx.root))}`);
603
762
  }
604
763
  }
764
+ if (acceptedOnly.length > 0) {
765
+ console.log('');
766
+ console.log(C.warn(` ⚡ ${acceptedOnly.length} accepted-but-unmitigated exposure(s) (no control in code):`));
767
+ for (const a of acceptedOnly) {
768
+ const sev = a.severity ? severityBadge(a.severity) : C.dim('unset');
769
+ console.log(` ${sev} ${a.asset} → ${a.threat} ${C.dim(fileLink(a.location.file, a.location.line, ctx.root))}`);
770
+ }
771
+ }
605
772
  console.log('');
606
- if (errors.length === 0 && unmitigated.length === 0) {
773
+ if (errors.length === 0 && unmitigated.length === 0 && acceptedOnly.length === 0) {
607
774
  console.log(C.success(' ✓ All annotations valid, no unmitigated exposures.'));
608
775
  }
609
776
  else {
@@ -614,6 +781,8 @@ export async function cmdValidate(ctx) {
614
781
  parts.push(`${warnings.length} warning(s)`);
615
782
  if (unmitigated.length > 0)
616
783
  parts.push(`${unmitigated.length} unmitigated`);
784
+ if (acceptedOnly.length > 0)
785
+ parts.push(`${acceptedOnly.length} accepted without mitigation`);
617
786
  console.log(` ${parts.join(', ')}`);
618
787
  }
619
788
  }
@@ -691,17 +860,95 @@ export async function cmdSarif(args, ctx) {
691
860
  }
692
861
  console.log('');
693
862
  }
694
- // ─── /model ──────────────────────────────────────────────────────────
695
863
  const CLI_AGENT_OPTIONS = [
696
- { id: 'claude-code', name: 'Claude Code' },
697
- { id: 'codex', name: 'Codex CLI' },
698
- { id: 'gemini', name: 'Gemini CLI' },
864
+ { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic\'s coding agent (claude cli)' },
865
+ { id: 'codex', name: 'Codex CLI', desc: 'OpenAI\'s coding agent (codex cli)' },
866
+ { id: 'gemini', name: 'Gemini CLI', desc: 'Google\'s coding agent (gemini cli)' },
699
867
  ];
700
868
  const CLI_AGENT_NAMES = {
701
869
  'claude-code': 'Claude Code',
702
870
  'codex': 'Codex CLI',
703
871
  'gemini': 'Gemini CLI',
704
872
  };
873
+ /** Provider model catalogs — popular models per provider, ordered by capability */
874
+ const PROVIDER_MODELS = {
875
+ anthropic: [
876
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', desc: 'Latest, frontier coding & agents' },
877
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', desc: 'Most intelligent, complex reasoning' },
878
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', desc: 'Previous gen, strong all-rounder' },
879
+ { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', desc: 'Previous gen, deep analysis' },
880
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', desc: 'Fastest, lowest cost' },
881
+ ],
882
+ openai: [
883
+ { id: 'gpt-5.2', name: 'GPT-5.2', desc: 'Latest flagship, smartest & most precise' },
884
+ { id: 'gpt-5.2-pro', name: 'GPT-5.2 Pro', desc: 'Enhanced GPT-5.2 for complex tasks' },
885
+ { id: 'gpt-5', name: 'GPT-5', desc: 'Frontier model with reasoning' },
886
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', desc: 'Fast and affordable' },
887
+ { id: 'gpt-5-nano', name: 'GPT-5 Nano', desc: 'Fastest, lowest cost' },
888
+ { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', desc: 'Optimized for agentic coding' },
889
+ { id: 'o3', name: 'o3', desc: 'Reasoning model, complex analysis' },
890
+ { id: 'o4-mini', name: 'o4-mini', desc: 'Fast reasoning model' },
891
+ { id: 'gpt-4.1', name: 'GPT-4.1', desc: 'Previous gen flagship' },
892
+ { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', desc: 'Previous gen, fast' },
893
+ ],
894
+ google: [
895
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', desc: 'Best price-performance, reasoning' },
896
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', desc: 'Most advanced, deep reasoning & coding' },
897
+ { id: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash-Lite', desc: 'Fastest, most budget-friendly' },
898
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', desc: 'Preview: frontier-class at low cost' },
899
+ { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', desc: 'Preview: state-of-the-art reasoning' },
900
+ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', desc: 'Preview: advanced agentic & coding' },
901
+ ],
902
+ deepseek: [
903
+ { id: 'deepseek-chat', name: 'DeepSeek V3.2', desc: 'General purpose, fast (128K context)' },
904
+ { id: 'deepseek-reasoner', name: 'DeepSeek R1', desc: 'Thinking mode, best for analysis' },
905
+ ],
906
+ openrouter: [
907
+ { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', desc: 'Anthropic via OpenRouter' },
908
+ { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', desc: 'Anthropic via OpenRouter' },
909
+ { id: 'openai/gpt-5.2', name: 'GPT-5.2', desc: 'OpenAI via OpenRouter' },
910
+ { id: 'openai/o3', name: 'o3', desc: 'OpenAI reasoning via OpenRouter' },
911
+ { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', desc: 'Google via OpenRouter' },
912
+ { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', desc: 'Google via OpenRouter' },
913
+ { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', desc: 'DeepSeek via OpenRouter' },
914
+ ],
915
+ ollama: [
916
+ { id: 'llama3.2', name: 'Llama 3.2', desc: 'Meta, good general purpose' },
917
+ { id: 'qwen2.5-coder:32b', name: 'Qwen 2.5 Coder 32B', desc: 'Best local coding model' },
918
+ { id: 'deepseek-r1:32b', name: 'DeepSeek R1 32B', desc: 'Local reasoning model' },
919
+ { id: 'gemma3:27b', name: 'Gemma 3 27B', desc: 'Google, strong local model' },
920
+ { id: 'mistral', name: 'Mistral 7B', desc: 'Lightweight, fast' },
921
+ ],
922
+ };
923
+ /** Helper to display a numbered model selection menu and return the chosen model ID */
924
+ async function pickModel(ctx, provider) {
925
+ const models = PROVIDER_MODELS[provider];
926
+ if (!models || models.length === 0) {
927
+ // Fallback to free-text for unknown providers
928
+ const model = await ask(ctx, ' Model name: ');
929
+ return model || null;
930
+ }
931
+ console.log('');
932
+ console.log(' Select model:');
933
+ for (let i = 0; i < models.length; i++) {
934
+ const m = models[i];
935
+ console.log(` ${C.bold(String(i + 1))} ${m.name.padEnd(24)} ${C.dim(m.desc)}`);
936
+ }
937
+ console.log(` ${C.bold(String(models.length + 1))} ${C.dim('Custom (enter model ID manually)')}`);
938
+ console.log('');
939
+ const choice = await ask(ctx, ` Model [1-${models.length + 1}]: `);
940
+ const idx = parseInt(choice, 10) - 1;
941
+ if (idx < 0 || idx > models.length) {
942
+ console.log(C.warn(' Cancelled.'));
943
+ return null;
944
+ }
945
+ if (idx === models.length) {
946
+ // Custom model
947
+ const custom = await ask(ctx, ' Model ID: ');
948
+ return custom || null;
949
+ }
950
+ return models[idx].id;
951
+ }
705
952
  export async function cmdModel(ctx) {
706
953
  const current = resolveLLMConfig(ctx.root);
707
954
  const tuiCfg = loadTuiConfig(ctx.root);
@@ -744,7 +991,10 @@ export async function cmdModel(ctx) {
744
991
  // ── CLI Agent selection ──
745
992
  console.log('');
746
993
  console.log(' Select CLI Agent:');
747
- CLI_AGENT_OPTIONS.forEach((a, i) => console.log(` ${C.bold(String(i + 1))} ${a.name}`));
994
+ for (let i = 0; i < CLI_AGENT_OPTIONS.length; i++) {
995
+ const a = CLI_AGENT_OPTIONS[i];
996
+ console.log(` ${C.bold(String(i + 1))} ${a.name.padEnd(16)} ${C.dim(a.desc)}`);
997
+ }
748
998
  console.log('');
749
999
  const agentChoice = await ask(ctx, ` Agent [1-${CLI_AGENT_OPTIONS.length}]: `);
750
1000
  const agentIdx = parseInt(agentChoice, 10) - 1;
@@ -765,10 +1015,20 @@ export async function cmdModel(ctx) {
765
1015
  }
766
1016
  else {
767
1017
  // ── API provider selection ──
768
- const providers = ['anthropic', 'openai', 'deepseek', 'openrouter', 'ollama'];
1018
+ const providers = [
1019
+ { id: 'anthropic', name: 'Anthropic', desc: 'Claude Sonnet 4.6, Opus 4.6, Haiku 4.5' },
1020
+ { id: 'openai', name: 'OpenAI', desc: 'GPT-5.2, o3, o4-mini, GPT-5.1 Codex' },
1021
+ { id: 'google', name: 'Google', desc: 'Gemini 2.5 Flash/Pro, Gemini 3 Pro' },
1022
+ { id: 'deepseek', name: 'DeepSeek', desc: 'DeepSeek V3.2, R1 reasoning' },
1023
+ { id: 'openrouter', name: 'OpenRouter', desc: 'Multi-provider gateway' },
1024
+ { id: 'ollama', name: 'Ollama', desc: 'Local models (Llama, Qwen, Gemma)' },
1025
+ ];
769
1026
  console.log('');
770
1027
  console.log(' Select provider:');
771
- providers.forEach((p, i) => console.log(` ${C.bold(String(i + 1))} ${p}`));
1028
+ for (let i = 0; i < providers.length; i++) {
1029
+ const p = providers[i];
1030
+ console.log(` ${C.bold(String(i + 1))} ${p.name.padEnd(14)} ${C.dim(p.desc)}`);
1031
+ }
772
1032
  console.log('');
773
1033
  const choice = await ask(ctx, ` Provider [1-${providers.length}]: `);
774
1034
  const idx = parseInt(choice, 10) - 1;
@@ -776,10 +1036,15 @@ export async function cmdModel(ctx) {
776
1036
  console.log(C.warn(' Cancelled.'));
777
1037
  return;
778
1038
  }
779
- const provider = providers[idx];
1039
+ const provider = providers[idx].id;
1040
+ // Model selection — numbered menu
1041
+ const modelId = await pickModel(ctx, provider);
1042
+ if (!modelId)
1043
+ return;
780
1044
  // API key
781
1045
  let apiKey = '';
782
1046
  if (provider !== 'ollama') {
1047
+ console.log('');
783
1048
  apiKey = await ask(ctx, ' API Key: ');
784
1049
  if (!apiKey) {
785
1050
  console.log(C.warn(' Cancelled — no API key provided.'));
@@ -789,119 +1054,172 @@ export async function cmdModel(ctx) {
789
1054
  else {
790
1055
  apiKey = 'ollama-local';
791
1056
  }
792
- // Model selection
793
- const defaults = {
794
- anthropic: 'claude-sonnet-4-5-20250929',
795
- openai: 'gpt-4o',
796
- openrouter: 'anthropic/claude-sonnet-4-5-20250929',
797
- deepseek: 'deepseek-chat',
798
- ollama: 'llama3.2',
799
- };
800
- const model = await ask(ctx, ` Model [${defaults[provider]}]: `);
801
1057
  saveTuiConfig(ctx.root, {
802
1058
  aiMode: 'api',
803
1059
  provider,
804
- model: model || defaults[provider],
1060
+ model: modelId,
805
1061
  apiKey,
806
1062
  });
807
1063
  const displayKey = apiKey.length > 8 ? apiKey.slice(0, 6) + '•'.repeat(8) : '•'.repeat(8);
1064
+ // Find display name for the model
1065
+ const modelEntry = PROVIDER_MODELS[provider]?.find(m => m.id === modelId);
1066
+ const modelDisplay = modelEntry ? `${modelEntry.name} (${modelId})` : modelId;
808
1067
  console.log('');
809
- console.log(` ${C.success('✓')} Configured: ${C.bold(model || defaults[provider])} (${provider})`);
810
- console.log(` Key: ${displayKey}`);
1068
+ console.log(` ${C.success('✓')} Configured: ${C.bold(modelDisplay)}`);
1069
+ console.log(` Provider: ${providers[idx].name} Key: ${displayKey}`);
811
1070
  console.log(C.dim(' Saved to .guardlink/config.json'));
812
1071
  console.log('');
813
1072
  }
814
1073
  }
815
1074
  // ─── /threat-report ──────────────────────────────────────────────────
1075
+ /**
1076
+ * Build the full analysis prompt for CLI agents.
1077
+ * Includes system prompt, serialized model, project context, code snippets,
1078
+ * and instructions to read source code.
1079
+ */
1080
+ function buildAgentAnalysisPrompt(root, model, fw, customPrompt, reportLabel) {
1081
+ const modelJson = serializeModel(model);
1082
+ const projectContext = buildProjectContext(root);
1083
+ const codeSnippets = extractCodeSnippets(root, model);
1084
+ const systemPrompt = FRAMEWORK_PROMPTS[fw];
1085
+ const userMessage = buildUserMessage(modelJson, fw, customPrompt, projectContext || undefined, codeSnippets || undefined);
1086
+ return `You are analyzing a codebase with GuardLink security annotations.
1087
+ You have access to the full source code in the current directory.
1088
+
1089
+ ${systemPrompt}
1090
+
1091
+ ## Task
1092
+ Read the source code and GuardLink annotations, then produce a thorough ${reportLabel}.
1093
+
1094
+ ## Threat Model (serialized from annotations)
1095
+ ${userMessage}
1096
+
1097
+ ## Instructions
1098
+ 1. Read the actual source files to understand the code — don't just rely on the serialized model above
1099
+ 2. Cross-reference the annotations with the real code to validate findings
1100
+ 3. Produce the full report as markdown
1101
+ 4. Be specific — reference actual files, functions, and line numbers from the codebase
1102
+ 5. Output ONLY the markdown report content — do NOT add any metadata comments, save confirmations, or file path messages
1103
+ 6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."`;
1104
+ }
1105
+ /**
1106
+ * Save inline agent output as a threat report markdown file.
1107
+ */
1108
+ function saveInlineReport(root, content, fw, agentName, project, annotationCount) {
1109
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1110
+ const reportsDir = resolve(root, '.guardlink', 'threat-reports');
1111
+ if (!existsSync(reportsDir))
1112
+ mkdirSync(reportsDir, { recursive: true });
1113
+ const filename = `${timestamp}-${fw}.md`;
1114
+ const filepath = resolve(reportsDir, filename);
1115
+ const cleanedContent = cleanCliArtifacts(content);
1116
+ const header = `---
1117
+ framework: ${fw}
1118
+ label: ${FRAMEWORK_LABELS[fw]}
1119
+ model: ${agentName}
1120
+ timestamp: ${new Date().toISOString()}
1121
+ project: ${project}
1122
+ annotations: ${annotationCount}
1123
+ ---
1124
+
1125
+ # ${FRAMEWORK_LABELS[fw]}
1126
+
1127
+ > Generated by \`guardlink threat-report ${fw}\` on ${new Date().toISOString().slice(0, 10)}
1128
+ > Agent: ${agentName} | Project: ${project} | Annotations: ${annotationCount}
1129
+
1130
+ `;
1131
+ writeFileSync(filepath, header + cleanedContent + '\n');
1132
+ return `.guardlink/threat-reports/${filename}`;
1133
+ }
816
1134
  export async function cmdThreatReport(args, ctx) {
817
1135
  if (!ctx.model) {
818
1136
  console.log(C.warn(' No threat model. Run /parse first.'));
819
1137
  return;
820
1138
  }
821
- const { agent, cleanArgs } = parseAgentFlag(args);
822
- const framework = cleanArgs.trim().toLowerCase() || '';
1139
+ // Parse any explicit --agent flag override
1140
+ const { agent: flagAgent, cleanArgs } = parseAgentFlag(args);
1141
+ const input = cleanArgs.trim();
823
1142
  const validFrameworks = ['stride', 'dread', 'pasta', 'attacker', 'rapid', 'general'];
824
- if (!framework) {
1143
+ // Show help when no arguments given
1144
+ if (!input) {
825
1145
  console.log('');
826
1146
  console.log(` ${C.bold('Threat report frameworks:')}`);
827
1147
  for (const fw of validFrameworks) {
828
1148
  console.log(` ${C.bold('/threat-report ' + fw.padEnd(12))} ${C.dim(FRAMEWORK_LABELS[fw])}`);
829
1149
  }
830
1150
  console.log('');
831
- console.log(C.dim(' Flags: --claude-code --codex --gemini --cursor --windsurf --clipboard'));
832
- console.log(C.dim(' Without flag: uses configured API provider (see /model)'));
833
- console.log(C.dim(' Example: /threat-report stride --claude-code'));
1151
+ console.log(` ${C.bold('Custom prompt:')}`);
1152
+ console.log(C.dim(' /threat-report <any text> Uses your text as the analysis prompt'));
1153
+ console.log(C.dim(' Example: /threat-report Create a comprehensive report mixing STRIDE and DREAD'));
1154
+ console.log('');
1155
+ console.log(C.dim(' Uses the AI provider configured via /model (API or CLI agent).'));
1156
+ console.log(C.dim(' Override with: --claude-code --codex --gemini --clipboard'));
834
1157
  console.log('');
835
1158
  return;
836
1159
  }
837
- const isStandard = validFrameworks.includes(framework);
838
- const fw = (isStandard ? framework : 'general');
839
- const customPrompt = isStandard ? undefined : cleanArgs.trim();
840
- // ── Agent path: spawn CLI agent or copy to clipboard ──
841
- if (agent) {
842
- const modelJson = serializeModel(ctx.model);
843
- const systemPrompt = FRAMEWORK_PROMPTS[fw];
844
- const userMessage = buildUserMessage(modelJson, fw, customPrompt);
845
- const analysisPrompt = `You are analyzing a codebase with GuardLink security annotations.
846
- You have access to the full source code in the current directory.
847
-
848
- ${systemPrompt}
849
-
850
- ## Task
851
- Read the source code and GuardLink annotations, then produce a thorough ${FRAMEWORK_LABELS[fw]}.
852
-
853
- ## Threat Model (serialized from annotations)
854
- ${userMessage}
855
-
856
- ## Instructions
857
- 1. Read the actual source files to understand the code — don't just rely on the serialized model above
858
- 2. Cross-reference the annotations with the real code to validate findings
859
- 3. Produce the full report as markdown
860
- 4. Save the output to .guardlink/threat-reports/ with a timestamped filename
861
- 5. Be specific — reference actual files, functions, and line numbers from the codebase`;
862
- console.log(` ${C.dim('Sending')} ${FRAMEWORK_LABELS[fw]} ${C.dim('to')} ${agent.name}${C.dim('...')}`);
1160
+ // Determine framework vs custom prompt
1161
+ const inputLower = input.toLowerCase();
1162
+ const isStandard = validFrameworks.includes(inputLower);
1163
+ const fw = (isStandard ? inputLower : 'general');
1164
+ const customPrompt = isStandard ? undefined : input;
1165
+ const reportLabel = customPrompt ? 'Custom Threat Analysis' : FRAMEWORK_LABELS[fw];
1166
+ // ── Resolve execution method ──
1167
+ // Priority: explicit --flag > /model config > env-var API
1168
+ const tuiCfg = loadTuiConfig(ctx.root);
1169
+ // Resolve the agent to use (flag override or configured CLI agent)
1170
+ let agent = flagAgent;
1171
+ if (!agent && tuiCfg?.aiMode === 'cli-agent' && tuiCfg?.cliAgent) {
1172
+ agent = AGENTS.find(a => a.id === tuiCfg.cliAgent) || null;
1173
+ }
1174
+ // ── Path 1: CLI Agent (inline, non-interactive) ──
1175
+ if (agent && agent.cmd) {
1176
+ const analysisPrompt = buildAgentAnalysisPrompt(ctx.root, ctx.model, fw, customPrompt, reportLabel);
1177
+ console.log(` ${C.dim('Generating')} ${reportLabel} ${C.dim('via')} ${agent.name} ${C.dim('(inline)...')}`);
1178
+ console.log(C.dim(` Annotations: ${ctx.model.annotations_parsed} | Exposures: ${ctx.model.exposures.length}`));
863
1179
  console.log('');
864
- // Use shared launcher foreground for terminal agents, IDE open for others
865
- if (agent.cmd) {
866
- const copied = copyToClipboard(analysisPrompt);
867
- if (copied) {
868
- console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
869
- }
870
- console.log(` ${C.dim('Launching')} ${agent.name} ${C.dim('in foreground...')}`);
1180
+ const result = await launchAgentInline(agent, analysisPrompt, ctx.root, (text) => process.stdout.write(text), { autoYes: true });
1181
+ if (result.error) {
1182
+ console.log(C.error(`\n ✗ ${result.error}`));
871
1183
  console.log('');
872
- const result = launchAgent(agent, analysisPrompt, ctx.root);
873
- if (result.error) {
874
- console.log(C.error(` ✗ ${result.error}`));
875
- }
876
- else {
877
- console.log(`\n ${C.success('✓')} ${agent.name} session ended.`);
878
- console.log(` Run ${C.bold('/threat-reports')} to see saved results.`);
879
- }
1184
+ return;
880
1185
  }
881
- else {
882
- const result = launchAgent(agent, analysisPrompt, ctx.root);
883
- if (result.clipboardCopied) {
884
- console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
885
- }
886
- if (result.launched && agent.app) {
887
- console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
888
- console.log(`\n Paste (Cmd+V) the prompt in ${agent.name}.`);
889
- }
890
- else if (result.error) {
891
- console.log(C.error(` ✗ ${result.error}`));
892
- }
1186
+ process.stdout.write('\n');
1187
+ // Save the agent's output as a report
1188
+ if (result.content.trim()) {
1189
+ const savedTo = saveInlineReport(ctx.root, result.content, fw, agent.name, ctx.model.project, ctx.model.annotations_parsed);
1190
+ console.log('');
1191
+ console.log(` ${C.success('✓')} Report saved to ${savedTo}`);
1192
+ }
1193
+ console.log('');
1194
+ return;
1195
+ }
1196
+ // ── Path 2: Clipboard / IDE agent (copy prompt, open app) ──
1197
+ if (agent && !agent.cmd) {
1198
+ const analysisPrompt = buildAgentAnalysisPrompt(ctx.root, ctx.model, fw, customPrompt, reportLabel);
1199
+ const result = launchAgent(agent, analysisPrompt, ctx.root);
1200
+ if (result.clipboardCopied) {
1201
+ console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
1202
+ }
1203
+ if (result.launched && agent.app) {
1204
+ console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
1205
+ console.log(`\n Paste (Cmd+V) the prompt in ${agent.name}.`);
1206
+ }
1207
+ else if (result.error) {
1208
+ console.log(C.error(` ✗ ${result.error}`));
893
1209
  }
894
1210
  console.log('');
895
1211
  return;
896
1212
  }
897
- // ── API path: direct LLM call ──
1213
+ // ── Path 3: Direct API call ──
898
1214
  const llmConfig = resolveLLMConfig(ctx.root);
899
1215
  if (!llmConfig) {
900
- console.log(C.warn(' No AI provider configured. Run /model first, or use --claude-code / --codex.'));
1216
+ console.log(C.warn(' No AI provider configured. Run /model first.'));
901
1217
  console.log(C.dim(' Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in environment.'));
1218
+ console.log(C.dim(' Or use: /threat-report <prompt> --claude-code'));
902
1219
  return;
903
1220
  }
904
- console.log(` ${C.dim('Generating report with')} ${llmConfig.model}${C.dim('...')}`);
1221
+ console.log(` ${C.dim('Generating')} ${reportLabel} ${C.dim('with')} ${llmConfig.model}${C.dim('...')}`);
1222
+ console.log(C.dim(` Annotations: ${ctx.model.annotations_parsed} | Exposures: ${ctx.model.exposures.length}`));
905
1223
  console.log('');
906
1224
  try {
907
1225
  const result = await generateThreatReport({
@@ -1010,8 +1328,10 @@ export async function cmdAnnotate(args, ctx) {
1010
1328
  }
1011
1329
  // ─── Freeform AI Chat ────────────────────────────────────────────────
1012
1330
  export async function cmdChat(text, ctx) {
1331
+ const tuiCfg = loadTuiConfig(ctx.root);
1013
1332
  const llmConfig = resolveLLMConfig(ctx.root);
1014
- if (!llmConfig) {
1333
+ const useAgent = tuiCfg?.aiMode === 'cli-agent' && !!tuiCfg?.cliAgent;
1334
+ if (!useAgent && !llmConfig) {
1015
1335
  console.log(C.warn(' No AI provider configured. Run /model first, or set an API key in environment.'));
1016
1336
  return;
1017
1337
  }
@@ -1034,18 +1354,205 @@ Keep responses under 500 words unless the user asks for detail.`;
1034
1354
  };
1035
1355
  userMessage = `Threat model context:\n${JSON.stringify(compact, null, 2)}\n\nUser question: ${text}`;
1036
1356
  }
1357
+ if (useAgent) {
1358
+ const agent = AGENTS.find(a => a.id === tuiCfg.cliAgent);
1359
+ if (!agent) {
1360
+ console.log(C.error(` ✗ Configured agent ${tuiCfg.cliAgent} not found.`));
1361
+ return;
1362
+ }
1363
+ console.log('');
1364
+ console.log(C.dim(` Thinking via ${agent.name}...`));
1365
+ console.log('');
1366
+ const prompt = `${systemPrompt}\n\n${userMessage}`;
1367
+ const result = await launchAgentInline(agent, prompt, ctx.root, (chunk) => process.stdout.write(chunk), { autoYes: true });
1368
+ if (result.error) {
1369
+ console.log(C.error(`\n ✗ AI request failed: ${result.error}`));
1370
+ }
1371
+ else {
1372
+ console.log('\n');
1373
+ }
1374
+ }
1375
+ else {
1376
+ console.log('');
1377
+ console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1378
+ console.log('');
1379
+ try {
1380
+ const { chatCompletion } = await import('../analyze/llm.js');
1381
+ await chatCompletion(llmConfig, systemPrompt, userMessage, (chunk) => process.stdout.write(chunk));
1382
+ process.stdout.write('\n\n');
1383
+ }
1384
+ catch (err) {
1385
+ console.log(C.error(` ✗ AI request failed: ${err.message}`));
1386
+ console.log('');
1387
+ }
1388
+ }
1389
+ }
1390
+ // ─── /clear ──────────────────────────────────────────────────────
1391
+ export async function cmdClear(args, ctx) {
1392
+ const includeDefinitions = args.includes('--include-definitions');
1393
+ const isDryRun = args.includes('--dry-run');
1394
+ console.log(C.dim(' Scanning for annotations...'));
1395
+ const preview = await clearAnnotations({
1396
+ root: ctx.root,
1397
+ dryRun: true,
1398
+ includeDefinitions,
1399
+ });
1400
+ if (preview.totalRemoved === 0) {
1401
+ console.log('');
1402
+ console.log(C.dim(' No GuardLink annotations found in source files.'));
1403
+ console.log('');
1404
+ return;
1405
+ }
1037
1406
  console.log('');
1038
- console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1407
+ console.log(` Found ${C.bold(String(preview.totalRemoved))} annotation line(s) across ${C.bold(String(preview.modifiedFiles.length))} file(s):`);
1039
1408
  console.log('');
1040
- try {
1041
- const { chatCompletion } = await import('../analyze/llm.js');
1042
- const response = await chatCompletion(llmConfig, systemPrompt, userMessage, (chunk) => process.stdout.write(chunk));
1043
- process.stdout.write('\n\n');
1409
+ for (const [file, count] of preview.perFile) {
1410
+ console.log(` ${file} ${C.dim(`(${count} line${count > 1 ? 's' : ''})`)}`);
1044
1411
  }
1045
- catch (err) {
1046
- console.log(C.error(` ✗ AI request failed: ${err.message}`));
1412
+ console.log('');
1413
+ if (isDryRun) {
1414
+ console.log(C.dim(' (dry run) No files were modified.'));
1047
1415
  console.log('');
1416
+ return;
1048
1417
  }
1418
+ const answer = await ask(ctx, ` ${C.warn('⚠')} Remove all annotations? This cannot be undone. (y/N): `);
1419
+ if (answer.trim().toLowerCase() !== 'y') {
1420
+ console.log(C.dim(' Cancelled.'));
1421
+ console.log('');
1422
+ return;
1423
+ }
1424
+ const result = await clearAnnotations({
1425
+ root: ctx.root,
1426
+ dryRun: false,
1427
+ includeDefinitions,
1428
+ });
1429
+ console.log('');
1430
+ console.log(` ${C.success('✓')} Removed ${C.bold(String(result.totalRemoved))} annotation line(s) from ${result.modifiedFiles.length} file(s).`);
1431
+ console.log(C.dim(' Run /annotate to re-annotate from scratch, or /parse to update the model.'));
1432
+ ctx.model = null;
1433
+ ctx.lastExposures = [];
1434
+ console.log('');
1435
+ }
1436
+ // ─── /sync ───────────────────────────────────────────────────────
1437
+ export async function cmdSync(ctx) {
1438
+ if (!ctx.model) {
1439
+ console.log(C.warn(' No threat model. Run /parse first.'));
1440
+ return;
1441
+ }
1442
+ console.log(C.dim(' Syncing agent instruction files with current threat model...'));
1443
+ console.log('');
1444
+ const result = syncAgentFiles({ root: ctx.root, model: ctx.model });
1445
+ if (result.updated.length > 0) {
1446
+ console.log(` ${C.success('✓')} Updated ${C.bold(String(result.updated.length))} agent instruction file(s):`);
1447
+ console.log('');
1448
+ for (const f of result.updated) {
1449
+ console.log(` ${f}`);
1450
+ }
1451
+ }
1452
+ if (result.skipped.length > 0) {
1453
+ console.log('');
1454
+ console.log(C.dim(` Skipped: ${result.skipped.join(', ')}`));
1455
+ }
1456
+ console.log('');
1457
+ console.log(` ${C.dim(`${ctx.model.assets.length} assets, ${ctx.model.threats.length} threats, ${ctx.model.controls.length} controls, ${ctx.model.exposures.length} exposures synced.`)}`);
1458
+ console.log(C.dim(' Any coding agent (Cursor, Claude, Copilot, Windsurf, etc.) will see these IDs.'));
1459
+ console.log('');
1460
+ }
1461
+ // ─── /unannotated ────────────────────────────────────────────────────
1462
+ export function cmdUnannotated(ctx) {
1463
+ if (!ctx.model) {
1464
+ console.log(C.warn(' No threat model. Run /parse first.'));
1465
+ return;
1466
+ }
1467
+ const files = ctx.model.unannotated_files;
1468
+ if (files.length === 0) {
1469
+ console.log(`\n ${C.success('✓')} All source files have GuardLink annotations.\n`);
1470
+ return;
1471
+ }
1472
+ console.log(`\n ${C.warn('⚠')} ${C.bold(String(files.length))} source file(s) with no annotations:\n`);
1473
+ for (const f of files) {
1474
+ console.log(` ${f}`);
1475
+ }
1476
+ console.log(`\n ${C.dim('Not all files need annotations — only those that touch security boundaries.')}`);
1477
+ console.log('');
1478
+ }
1479
+ // ─── /review ─────────────────────────────────────────────────────────
1480
+ export async function cmdReview(args, ctx) {
1481
+ if (!ctx.model) {
1482
+ console.log(C.warn(' No threat model. Run /parse first.'));
1483
+ return;
1484
+ }
1485
+ let exposures = getReviewableExposures(ctx.model);
1486
+ // Parse severity filter from args (e.g., "/review critical,high")
1487
+ if (args) {
1488
+ const allowed = new Set(args.split(',').map(s => s.trim().toLowerCase()));
1489
+ exposures = exposures.filter(e => allowed.has(e.exposure.severity || 'low'));
1490
+ exposures = exposures.map((e, i) => ({ ...e, index: i + 1 }));
1491
+ }
1492
+ if (exposures.length === 0) {
1493
+ console.log(`\n ${C.success('✓')} No unmitigated exposures to review.\n`);
1494
+ return;
1495
+ }
1496
+ console.log(`\n ${C.bold('guardlink review')} — ${exposures.length} unmitigated exposure(s)\n`);
1497
+ const results = [];
1498
+ for (const reviewable of exposures) {
1499
+ const e = reviewable.exposure;
1500
+ const sev = severityText(e.severity || 'low');
1501
+ console.log(` ${C.bold(`[${reviewable.index}/${exposures.length}]`)} ${e.asset} → ${e.threat} ${sev}`);
1502
+ console.log(` File: ${fileLink(e.location.file, e.location.line)}`);
1503
+ console.log(` Exposure: ${C.dim('"' + (e.description || 'no description') + '"')}`);
1504
+ console.log('');
1505
+ console.log(` ${C.bold('a')} Accept ${C.dim('— risk acknowledged and intentional')}`);
1506
+ console.log(` ${C.bold('r')} Remediate ${C.dim('— mark as planned fix')}`);
1507
+ console.log(` ${C.bold('s')} Skip ${C.dim('— leave open for now')}`);
1508
+ console.log(` ${C.bold('q')} Quit`);
1509
+ console.log('');
1510
+ const choice = (await ask(ctx, ' Choice [a/r/s/q]: ')).toLowerCase();
1511
+ if (choice === 'q') {
1512
+ console.log(`\n ${C.dim('Review ended.')}\n`);
1513
+ break;
1514
+ }
1515
+ if (choice === 'a') {
1516
+ let justification = '';
1517
+ while (!justification) {
1518
+ justification = await ask(ctx, ' Justification (required): ');
1519
+ if (!justification)
1520
+ console.log(C.warn(' ⚠ Justification is mandatory for acceptance.'));
1521
+ }
1522
+ const result = await applyReviewAction(ctx.root, reviewable, { decision: 'accept', justification });
1523
+ results.push(result);
1524
+ console.log(` ${C.success('✓')} Accepted — ${result.linesInserted} line(s) written\n`);
1525
+ }
1526
+ else if (choice === 'r') {
1527
+ let note = '';
1528
+ while (!note) {
1529
+ note = await ask(ctx, ' Remediation note (required): ');
1530
+ if (!note)
1531
+ console.log(C.warn(' ⚠ Remediation note is mandatory.'));
1532
+ }
1533
+ const result = await applyReviewAction(ctx.root, reviewable, { decision: 'remediate', justification: note });
1534
+ results.push(result);
1535
+ console.log(` ${C.success('✓')} Marked for remediation — ${result.linesInserted} line(s) written\n`);
1536
+ }
1537
+ else {
1538
+ results.push({ exposure: reviewable, action: { decision: 'skip', justification: '' }, linesInserted: 0 });
1539
+ console.log(` ${C.dim('— Skipped')}\n`);
1540
+ }
1541
+ }
1542
+ if (results.length > 0) {
1543
+ console.log(`\n ${summarizeReview(results)}`);
1544
+ // Re-parse and sync if annotations were written
1545
+ if (results.some(r => r.linesInserted > 0)) {
1546
+ await refreshModel(ctx);
1547
+ try {
1548
+ const syncResult = syncAgentFiles({ root: ctx.root, model: ctx.model });
1549
+ if (syncResult.updated.length > 0)
1550
+ console.log(` ${C.dim('↻ Synced')} ${syncResult.updated.length} agent instruction file(s)`);
1551
+ }
1552
+ catch { }
1553
+ }
1554
+ }
1555
+ console.log('');
1049
1556
  }
1050
1557
  // ─── /report ─────────────────────────────────────────────────────────
1051
1558
  export async function cmdReport(ctx) {