guardlink 1.1.0 → 1.2.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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/agents/config.d.ts +6 -0
  3. package/dist/agents/config.d.ts.map +1 -1
  4. package/dist/agents/config.js +27 -4
  5. package/dist/agents/config.js.map +1 -1
  6. package/dist/agents/index.d.ts +2 -1
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/index.js +1 -1
  9. package/dist/agents/index.js.map +1 -1
  10. package/dist/agents/launcher.d.ts +14 -0
  11. package/dist/agents/launcher.d.ts.map +1 -1
  12. package/dist/agents/launcher.js +126 -1
  13. package/dist/agents/launcher.js.map +1 -1
  14. package/dist/agents/prompts.d.ts.map +1 -1
  15. package/dist/agents/prompts.js +34 -6
  16. package/dist/agents/prompts.js.map +1 -1
  17. package/dist/analyze/index.d.ts +34 -1
  18. package/dist/analyze/index.d.ts.map +1 -1
  19. package/dist/analyze/index.js +281 -8
  20. package/dist/analyze/index.js.map +1 -1
  21. package/dist/analyze/llm.d.ts +54 -3
  22. package/dist/analyze/llm.d.ts.map +1 -1
  23. package/dist/analyze/llm.js +418 -97
  24. package/dist/analyze/llm.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 +227 -111
  28. package/dist/analyze/prompts.js.map +1 -1
  29. package/dist/analyze/tools.d.ts +22 -0
  30. package/dist/analyze/tools.d.ts.map +1 -0
  31. package/dist/analyze/tools.js +230 -0
  32. package/dist/analyze/tools.js.map +1 -0
  33. package/dist/cli/index.d.ts +15 -7
  34. package/dist/cli/index.d.ts.map +1 -1
  35. package/dist/cli/index.js +289 -95
  36. package/dist/cli/index.js.map +1 -1
  37. package/dist/dashboard/data.d.ts +5 -0
  38. package/dist/dashboard/data.d.ts.map +1 -1
  39. package/dist/dashboard/data.js +5 -0
  40. package/dist/dashboard/data.js.map +1 -1
  41. package/dist/dashboard/generate.d.ts.map +1 -1
  42. package/dist/dashboard/generate.js +176 -59
  43. package/dist/dashboard/generate.js.map +1 -1
  44. package/dist/init/templates.d.ts.map +1 -1
  45. package/dist/init/templates.js +51 -31
  46. package/dist/init/templates.js.map +1 -1
  47. package/dist/mcp/server.d.ts.map +1 -1
  48. package/dist/mcp/server.js +6 -2
  49. package/dist/mcp/server.js.map +1 -1
  50. package/dist/parser/index.d.ts +1 -1
  51. package/dist/parser/index.d.ts.map +1 -1
  52. package/dist/parser/index.js +1 -1
  53. package/dist/parser/index.js.map +1 -1
  54. package/dist/parser/validate.d.ts +12 -0
  55. package/dist/parser/validate.d.ts.map +1 -1
  56. package/dist/parser/validate.js +44 -0
  57. package/dist/parser/validate.js.map +1 -1
  58. package/dist/report/report.d.ts.map +1 -1
  59. package/dist/report/report.js +64 -0
  60. package/dist/report/report.js.map +1 -1
  61. package/dist/tui/commands.d.ts +6 -1
  62. package/dist/tui/commands.d.ts.map +1 -1
  63. package/dist/tui/commands.js +411 -102
  64. package/dist/tui/commands.js.map +1 -1
  65. package/dist/tui/format.d.ts +7 -0
  66. package/dist/tui/format.d.ts.map +1 -1
  67. package/dist/tui/format.js +59 -0
  68. package/dist/tui/format.js.map +1 -1
  69. package/dist/tui/index.d.ts.map +1 -1
  70. package/dist/tui/index.js +19 -2
  71. package/dist/tui/index.js.map +1 -1
  72. package/package.json +1 -1
@@ -5,18 +5,18 @@
5
5
  * Returns void. Throws on fatal errors.
6
6
  */
7
7
  import { resolve, basename } from 'node:path';
8
- import { writeFileSync } from 'node:fs';
9
- import { parseProject, findDanglingRefs, findUnmitigatedExposures } from '../parser/index.js';
8
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures } from '../parser/index.js';
10
10
  import { initProject, detectProject, promptAgentSelection } from '../init/index.js';
11
11
  import { generateReport } from '../report/index.js';
12
12
  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';
13
+ import { computeStats, computeSeverity, computeExposures } from '../dashboard/data.js';
14
+ import { generateThreatReport, serializeModel, listThreatReports, loadThreatReportsForDashboard, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage, buildProjectContext, extractCodeSnippets } from '../analyze/index.js';
15
15
  import { diffModels, formatDiff, parseAtRef } from '../diff/index.js';
16
16
  import { generateSarif } from '../analyzer/index.js';
17
- import { C, severityBadge, severityText, severityOrder, computeGrade, gradeColored, readCodeContext, bar, fileLink } from './format.js';
17
+ import { C, severityBadge, severityText, severityTextPad, severityOrder, computeGrade, gradeColored, readCodeContext, trunc, bar, fileLink, fileLinkTrunc, cleanCliArtifacts } from './format.js';
18
18
  import { resolveLLMConfig, saveTuiConfig, loadTuiConfig } from './config.js';
19
- import { AGENTS, parseAgentFlag, launchAgent, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
19
+ import { AGENTS, parseAgentFlag, launchAgent, launchAgentInline, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
20
20
  import { describeConfigSource } from '../agents/config.js';
21
21
  // ─── Shared context ──────────────────────────────────────────────────
22
22
  /** Prompt user to pick an agent interactively (TUI only) */
@@ -63,14 +63,17 @@ export function cmdHelp() {
63
63
  ['/status', 'Risk grade + summary stats'],
64
64
  ['/validate [--strict]', 'Check for syntax errors + dangling refs'],
65
65
  ['', ''],
66
+ ['/exposures [--all]', 'List open exposures by severity (filter: --asset --severity --threat --file)'],
67
+ ['/show <n>', 'Detail view + code context for an exposure (from /exposures list)'],
68
+ ['/scan', 'Annotation coverage scanner — find unannotated symbols'],
66
69
  ['/assets', 'Asset tree with threat/control counts'],
67
70
  ['/files', 'Annotated file tree with exposure counts'],
68
71
  ['/view <file>', 'Show all annotations in a file with code context'],
69
72
  ['', ''],
70
- ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general)'],
73
+ ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general|custom)'],
71
74
  ['/threat-reports', 'List saved AI threat reports'],
72
75
  ['/annotate <prompt>', 'Launch coding agent to annotate codebase'],
73
- ['/model', 'Set AI provider + API key'],
76
+ ['/model', 'Set AI provider (API or CLI agent: Claude Code, Codex, Gemini)'],
74
77
  ['(freeform text)', 'Chat about your threat model with AI'],
75
78
  ['', ''],
76
79
  ['/report', 'Generate markdown + JSON report'],
@@ -78,6 +81,7 @@ export function cmdHelp() {
78
81
  ['/diff [ref]', 'Compare model against a git ref (default: HEAD~1)'],
79
82
  ['/sarif [-o file]', 'Export SARIF 2.1.0 for GitHub / VS Code'],
80
83
  ['', ''],
84
+ ['/gal', 'GAL annotation language guide'],
81
85
  ['/help', 'This help'],
82
86
  ['/quit', 'Exit'],
83
87
  ];
@@ -276,6 +280,126 @@ export function cmdStatus(ctx) {
276
280
  }
277
281
  console.log('');
278
282
  }
283
+ // ─── /exposures ──────────────────────────────────────────────────────
284
+ export function cmdExposures(args, ctx) {
285
+ if (!ctx.model) {
286
+ console.log(C.warn(' No threat model. Run /parse first.'));
287
+ return;
288
+ }
289
+ const rows = computeExposures(ctx.model);
290
+ let filtered = rows.filter(r => !r.mitigated && !r.accepted); // open only by default
291
+ // Parse flags
292
+ const parts = args.split(/\s+/).filter(Boolean);
293
+ let showAll = false;
294
+ for (let i = 0; i < parts.length; i++) {
295
+ const flag = parts[i];
296
+ const val = parts[i + 1];
297
+ if (flag === '--asset' && val) {
298
+ filtered = filtered.filter(r => r.asset.includes(val));
299
+ i++;
300
+ }
301
+ else if (flag === '--severity' && val) {
302
+ filtered = filtered.filter(r => r.severity === val.toLowerCase());
303
+ i++;
304
+ }
305
+ else if (flag === '--file' && val) {
306
+ filtered = filtered.filter(r => r.file.includes(val));
307
+ i++;
308
+ }
309
+ else if (flag === '--threat' && val) {
310
+ filtered = filtered.filter(r => r.threat.includes(val));
311
+ i++;
312
+ }
313
+ else if (flag === '--all') {
314
+ filtered = rows;
315
+ showAll = true;
316
+ }
317
+ }
318
+ // Sort by severity
319
+ filtered.sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
320
+ // Cache for /show
321
+ ctx.lastExposures = filtered.map(r => {
322
+ 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);
323
+ return original;
324
+ }).filter(Boolean);
325
+ if (filtered.length === 0) {
326
+ console.log(C.green(' No matching exposures found.'));
327
+ return;
328
+ }
329
+ console.log('');
330
+ const termWidth = process.stdout.columns || 100;
331
+ 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')}`;
332
+ console.log(header);
333
+ console.log(C.dim(' ' + '─'.repeat(Math.min(termWidth - 4, 96))));
334
+ for (const [i, r] of filtered.entries()) {
335
+ const num = String(i + 1).padEnd(4);
336
+ const sev = severityTextPad(r.severity, 12);
337
+ const asset = trunc(r.asset, 16).padEnd(18);
338
+ const threat = trunc(r.threat, 18).padEnd(20);
339
+ const linkedFile = fileLinkTrunc(r.file, 28, r.line, ctx.root);
340
+ const filePad = ' '.repeat(Math.max(0, 30 - trunc(r.file, 28).length));
341
+ const line = ` ${num}${sev}${asset}${threat}${linkedFile}${filePad}${r.line}`;
342
+ console.log(line);
343
+ }
344
+ console.log('');
345
+ const countMsg = showAll
346
+ ? ` ${filtered.length} exposure(s) total`
347
+ : ` ${filtered.length} open exposure(s)`;
348
+ console.log(C.dim(countMsg + ' · /show <n> for detail · --asset --severity --threat --file to filter'));
349
+ console.log('');
350
+ }
351
+ // ─── /show ───────────────────────────────────────────────────────────
352
+ export function cmdShow(args, ctx) {
353
+ const num = parseInt(args.trim(), 10);
354
+ if (!num || num < 1 || num > ctx.lastExposures.length) {
355
+ console.log(C.warn(` Usage: /show <n> where n is 1-${ctx.lastExposures.length || '?'}. Run /exposures first.`));
356
+ return;
357
+ }
358
+ const exp = ctx.lastExposures[num - 1];
359
+ console.log('');
360
+ console.log(` ${C.cyan('┌')} ${exp.asset} → ${exp.threat} ${severityBadge(exp.severity)}`);
361
+ if (exp.description) {
362
+ console.log(` ${C.cyan('│')} ${exp.description}`);
363
+ }
364
+ if (exp.external_refs.length > 0) {
365
+ console.log(` ${C.cyan('│')} ${C.dim(exp.external_refs.join(' · '))}`);
366
+ }
367
+ console.log(` ${C.cyan('│')} ${C.dim(fileLink(exp.location.file, exp.location.line, ctx.root))}`);
368
+ console.log(` ${C.cyan('│')}`);
369
+ const { lines } = readCodeContext(exp.location.file, exp.location.line, ctx.root);
370
+ for (const l of lines) {
371
+ console.log(` ${C.cyan('│')} ${l}`);
372
+ }
373
+ console.log(` ${C.cyan('└')}`);
374
+ console.log('');
375
+ }
376
+ // ─── /scan ───────────────────────────────────────────────────────────
377
+ export function cmdScan(ctx) {
378
+ if (!ctx.model) {
379
+ console.log(C.warn(' No threat model. Run /parse first.'));
380
+ return;
381
+ }
382
+ const cov = ctx.model.coverage;
383
+ const pct = cov.coverage_percent;
384
+ console.log('');
385
+ console.log(` ${C.bold('Coverage:')} ${cov.annotated_symbols}/${cov.total_symbols} symbols (${pct}%)`);
386
+ const unannotated = cov.unannotated_critical || [];
387
+ if (unannotated.length === 0) {
388
+ console.log(C.green(' All security-relevant symbols are annotated!'));
389
+ }
390
+ else {
391
+ console.log(C.warn(` ${unannotated.length} unannotated symbol(s):`));
392
+ console.log('');
393
+ const show = unannotated.slice(0, 25);
394
+ for (const u of show) {
395
+ console.log(` ${C.dim(fileLink(u.file, u.line, ctx.root))} ${u.kind} ${C.bold(u.name)}`);
396
+ }
397
+ if (unannotated.length > 25) {
398
+ console.log(C.dim(` ... and ${unannotated.length - 25} more`));
399
+ }
400
+ }
401
+ console.log('');
402
+ }
279
403
  // ─── /assets ─────────────────────────────────────────────────────────
280
404
  export function cmdAssets(ctx) {
281
405
  if (!ctx.model) {
@@ -580,9 +704,13 @@ export async function cmdValidate(ctx) {
580
704
  ctx.model = model;
581
705
  // Dangling refs
582
706
  const danglingDiags = findDanglingRefs(model);
583
- const allDiags = [...diagnostics, ...danglingDiags];
707
+ // Check for @accepts without @audit (governance concern)
708
+ const acceptAuditDiags = findAcceptedWithoutAudit(model);
709
+ const allDiags = [...diagnostics, ...danglingDiags, ...acceptAuditDiags];
584
710
  // Unmitigated exposures
585
711
  const unmitigated = findUnmitigatedExposures(model);
712
+ // Accepted-but-unmitigated exposures
713
+ const acceptedOnly = findAcceptedExposures(model);
586
714
  // Print diagnostics
587
715
  const errors = allDiags.filter(d => d.level === 'error');
588
716
  const warnings = allDiags.filter(d => d.level === 'warning');
@@ -602,8 +730,16 @@ export async function cmdValidate(ctx) {
602
730
  console.log(` ${sev} ${u.asset} → ${u.threat} ${C.dim(fileLink(u.location.file, u.location.line, ctx.root))}`);
603
731
  }
604
732
  }
733
+ if (acceptedOnly.length > 0) {
734
+ console.log('');
735
+ console.log(C.warn(` ⚡ ${acceptedOnly.length} accepted-but-unmitigated exposure(s) (no control in code):`));
736
+ for (const a of acceptedOnly) {
737
+ const sev = a.severity ? severityBadge(a.severity) : C.dim('unset');
738
+ console.log(` ${sev} ${a.asset} → ${a.threat} ${C.dim(fileLink(a.location.file, a.location.line, ctx.root))}`);
739
+ }
740
+ }
605
741
  console.log('');
606
- if (errors.length === 0 && unmitigated.length === 0) {
742
+ if (errors.length === 0 && unmitigated.length === 0 && acceptedOnly.length === 0) {
607
743
  console.log(C.success(' ✓ All annotations valid, no unmitigated exposures.'));
608
744
  }
609
745
  else {
@@ -614,6 +750,8 @@ export async function cmdValidate(ctx) {
614
750
  parts.push(`${warnings.length} warning(s)`);
615
751
  if (unmitigated.length > 0)
616
752
  parts.push(`${unmitigated.length} unmitigated`);
753
+ if (acceptedOnly.length > 0)
754
+ parts.push(`${acceptedOnly.length} accepted without mitigation`);
617
755
  console.log(` ${parts.join(', ')}`);
618
756
  }
619
757
  }
@@ -691,17 +829,95 @@ export async function cmdSarif(args, ctx) {
691
829
  }
692
830
  console.log('');
693
831
  }
694
- // ─── /model ──────────────────────────────────────────────────────────
695
832
  const CLI_AGENT_OPTIONS = [
696
- { id: 'claude-code', name: 'Claude Code' },
697
- { id: 'codex', name: 'Codex CLI' },
698
- { id: 'gemini', name: 'Gemini CLI' },
833
+ { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic\'s coding agent (claude cli)' },
834
+ { id: 'codex', name: 'Codex CLI', desc: 'OpenAI\'s coding agent (codex cli)' },
835
+ { id: 'gemini', name: 'Gemini CLI', desc: 'Google\'s coding agent (gemini cli)' },
699
836
  ];
700
837
  const CLI_AGENT_NAMES = {
701
838
  'claude-code': 'Claude Code',
702
839
  'codex': 'Codex CLI',
703
840
  'gemini': 'Gemini CLI',
704
841
  };
842
+ /** Provider model catalogs — popular models per provider, ordered by capability */
843
+ const PROVIDER_MODELS = {
844
+ anthropic: [
845
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', desc: 'Latest, frontier coding & agents' },
846
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', desc: 'Most intelligent, complex reasoning' },
847
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', desc: 'Previous gen, strong all-rounder' },
848
+ { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', desc: 'Previous gen, deep analysis' },
849
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', desc: 'Fastest, lowest cost' },
850
+ ],
851
+ openai: [
852
+ { id: 'gpt-5.2', name: 'GPT-5.2', desc: 'Latest flagship, smartest & most precise' },
853
+ { id: 'gpt-5.2-pro', name: 'GPT-5.2 Pro', desc: 'Enhanced GPT-5.2 for complex tasks' },
854
+ { id: 'gpt-5', name: 'GPT-5', desc: 'Frontier model with reasoning' },
855
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', desc: 'Fast and affordable' },
856
+ { id: 'gpt-5-nano', name: 'GPT-5 Nano', desc: 'Fastest, lowest cost' },
857
+ { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', desc: 'Optimized for agentic coding' },
858
+ { id: 'o3', name: 'o3', desc: 'Reasoning model, complex analysis' },
859
+ { id: 'o4-mini', name: 'o4-mini', desc: 'Fast reasoning model' },
860
+ { id: 'gpt-4.1', name: 'GPT-4.1', desc: 'Previous gen flagship' },
861
+ { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', desc: 'Previous gen, fast' },
862
+ ],
863
+ google: [
864
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', desc: 'Best price-performance, reasoning' },
865
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', desc: 'Most advanced, deep reasoning & coding' },
866
+ { id: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash-Lite', desc: 'Fastest, most budget-friendly' },
867
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', desc: 'Preview: frontier-class at low cost' },
868
+ { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', desc: 'Preview: state-of-the-art reasoning' },
869
+ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', desc: 'Preview: advanced agentic & coding' },
870
+ ],
871
+ deepseek: [
872
+ { id: 'deepseek-chat', name: 'DeepSeek V3.2', desc: 'General purpose, fast (128K context)' },
873
+ { id: 'deepseek-reasoner', name: 'DeepSeek R1', desc: 'Thinking mode, best for analysis' },
874
+ ],
875
+ openrouter: [
876
+ { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', desc: 'Anthropic via OpenRouter' },
877
+ { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', desc: 'Anthropic via OpenRouter' },
878
+ { id: 'openai/gpt-5.2', name: 'GPT-5.2', desc: 'OpenAI via OpenRouter' },
879
+ { id: 'openai/o3', name: 'o3', desc: 'OpenAI reasoning via OpenRouter' },
880
+ { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', desc: 'Google via OpenRouter' },
881
+ { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', desc: 'Google via OpenRouter' },
882
+ { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', desc: 'DeepSeek via OpenRouter' },
883
+ ],
884
+ ollama: [
885
+ { id: 'llama3.2', name: 'Llama 3.2', desc: 'Meta, good general purpose' },
886
+ { id: 'qwen2.5-coder:32b', name: 'Qwen 2.5 Coder 32B', desc: 'Best local coding model' },
887
+ { id: 'deepseek-r1:32b', name: 'DeepSeek R1 32B', desc: 'Local reasoning model' },
888
+ { id: 'gemma3:27b', name: 'Gemma 3 27B', desc: 'Google, strong local model' },
889
+ { id: 'mistral', name: 'Mistral 7B', desc: 'Lightweight, fast' },
890
+ ],
891
+ };
892
+ /** Helper to display a numbered model selection menu and return the chosen model ID */
893
+ async function pickModel(ctx, provider) {
894
+ const models = PROVIDER_MODELS[provider];
895
+ if (!models || models.length === 0) {
896
+ // Fallback to free-text for unknown providers
897
+ const model = await ask(ctx, ' Model name: ');
898
+ return model || null;
899
+ }
900
+ console.log('');
901
+ console.log(' Select model:');
902
+ for (let i = 0; i < models.length; i++) {
903
+ const m = models[i];
904
+ console.log(` ${C.bold(String(i + 1))} ${m.name.padEnd(24)} ${C.dim(m.desc)}`);
905
+ }
906
+ console.log(` ${C.bold(String(models.length + 1))} ${C.dim('Custom (enter model ID manually)')}`);
907
+ console.log('');
908
+ const choice = await ask(ctx, ` Model [1-${models.length + 1}]: `);
909
+ const idx = parseInt(choice, 10) - 1;
910
+ if (idx < 0 || idx > models.length) {
911
+ console.log(C.warn(' Cancelled.'));
912
+ return null;
913
+ }
914
+ if (idx === models.length) {
915
+ // Custom model
916
+ const custom = await ask(ctx, ' Model ID: ');
917
+ return custom || null;
918
+ }
919
+ return models[idx].id;
920
+ }
705
921
  export async function cmdModel(ctx) {
706
922
  const current = resolveLLMConfig(ctx.root);
707
923
  const tuiCfg = loadTuiConfig(ctx.root);
@@ -744,7 +960,10 @@ export async function cmdModel(ctx) {
744
960
  // ── CLI Agent selection ──
745
961
  console.log('');
746
962
  console.log(' Select CLI Agent:');
747
- CLI_AGENT_OPTIONS.forEach((a, i) => console.log(` ${C.bold(String(i + 1))} ${a.name}`));
963
+ for (let i = 0; i < CLI_AGENT_OPTIONS.length; i++) {
964
+ const a = CLI_AGENT_OPTIONS[i];
965
+ console.log(` ${C.bold(String(i + 1))} ${a.name.padEnd(16)} ${C.dim(a.desc)}`);
966
+ }
748
967
  console.log('');
749
968
  const agentChoice = await ask(ctx, ` Agent [1-${CLI_AGENT_OPTIONS.length}]: `);
750
969
  const agentIdx = parseInt(agentChoice, 10) - 1;
@@ -765,10 +984,20 @@ export async function cmdModel(ctx) {
765
984
  }
766
985
  else {
767
986
  // ── API provider selection ──
768
- const providers = ['anthropic', 'openai', 'deepseek', 'openrouter', 'ollama'];
987
+ const providers = [
988
+ { id: 'anthropic', name: 'Anthropic', desc: 'Claude Sonnet 4.6, Opus 4.6, Haiku 4.5' },
989
+ { id: 'openai', name: 'OpenAI', desc: 'GPT-5.2, o3, o4-mini, GPT-5.1 Codex' },
990
+ { id: 'google', name: 'Google', desc: 'Gemini 2.5 Flash/Pro, Gemini 3 Pro' },
991
+ { id: 'deepseek', name: 'DeepSeek', desc: 'DeepSeek V3.2, R1 reasoning' },
992
+ { id: 'openrouter', name: 'OpenRouter', desc: 'Multi-provider gateway' },
993
+ { id: 'ollama', name: 'Ollama', desc: 'Local models (Llama, Qwen, Gemma)' },
994
+ ];
769
995
  console.log('');
770
996
  console.log(' Select provider:');
771
- providers.forEach((p, i) => console.log(` ${C.bold(String(i + 1))} ${p}`));
997
+ for (let i = 0; i < providers.length; i++) {
998
+ const p = providers[i];
999
+ console.log(` ${C.bold(String(i + 1))} ${p.name.padEnd(14)} ${C.dim(p.desc)}`);
1000
+ }
772
1001
  console.log('');
773
1002
  const choice = await ask(ctx, ` Provider [1-${providers.length}]: `);
774
1003
  const idx = parseInt(choice, 10) - 1;
@@ -776,10 +1005,15 @@ export async function cmdModel(ctx) {
776
1005
  console.log(C.warn(' Cancelled.'));
777
1006
  return;
778
1007
  }
779
- const provider = providers[idx];
1008
+ const provider = providers[idx].id;
1009
+ // Model selection — numbered menu
1010
+ const modelId = await pickModel(ctx, provider);
1011
+ if (!modelId)
1012
+ return;
780
1013
  // API key
781
1014
  let apiKey = '';
782
1015
  if (provider !== 'ollama') {
1016
+ console.log('');
783
1017
  apiKey = await ask(ctx, ' API Key: ');
784
1018
  if (!apiKey) {
785
1019
  console.log(C.warn(' Cancelled — no API key provided.'));
@@ -789,119 +1023,172 @@ export async function cmdModel(ctx) {
789
1023
  else {
790
1024
  apiKey = 'ollama-local';
791
1025
  }
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
1026
  saveTuiConfig(ctx.root, {
802
1027
  aiMode: 'api',
803
1028
  provider,
804
- model: model || defaults[provider],
1029
+ model: modelId,
805
1030
  apiKey,
806
1031
  });
807
1032
  const displayKey = apiKey.length > 8 ? apiKey.slice(0, 6) + '•'.repeat(8) : '•'.repeat(8);
1033
+ // Find display name for the model
1034
+ const modelEntry = PROVIDER_MODELS[provider]?.find(m => m.id === modelId);
1035
+ const modelDisplay = modelEntry ? `${modelEntry.name} (${modelId})` : modelId;
808
1036
  console.log('');
809
- console.log(` ${C.success('✓')} Configured: ${C.bold(model || defaults[provider])} (${provider})`);
810
- console.log(` Key: ${displayKey}`);
1037
+ console.log(` ${C.success('✓')} Configured: ${C.bold(modelDisplay)}`);
1038
+ console.log(` Provider: ${providers[idx].name} Key: ${displayKey}`);
811
1039
  console.log(C.dim(' Saved to .guardlink/config.json'));
812
1040
  console.log('');
813
1041
  }
814
1042
  }
815
1043
  // ─── /threat-report ──────────────────────────────────────────────────
1044
+ /**
1045
+ * Build the full analysis prompt for CLI agents.
1046
+ * Includes system prompt, serialized model, project context, code snippets,
1047
+ * and instructions to read source code.
1048
+ */
1049
+ function buildAgentAnalysisPrompt(root, model, fw, customPrompt, reportLabel) {
1050
+ const modelJson = serializeModel(model);
1051
+ const projectContext = buildProjectContext(root);
1052
+ const codeSnippets = extractCodeSnippets(root, model);
1053
+ const systemPrompt = FRAMEWORK_PROMPTS[fw];
1054
+ const userMessage = buildUserMessage(modelJson, fw, customPrompt, projectContext || undefined, codeSnippets || undefined);
1055
+ return `You are analyzing a codebase with GuardLink security annotations.
1056
+ You have access to the full source code in the current directory.
1057
+
1058
+ ${systemPrompt}
1059
+
1060
+ ## Task
1061
+ Read the source code and GuardLink annotations, then produce a thorough ${reportLabel}.
1062
+
1063
+ ## Threat Model (serialized from annotations)
1064
+ ${userMessage}
1065
+
1066
+ ## Instructions
1067
+ 1. Read the actual source files to understand the code — don't just rely on the serialized model above
1068
+ 2. Cross-reference the annotations with the real code to validate findings
1069
+ 3. Produce the full report as markdown
1070
+ 4. Be specific — reference actual files, functions, and line numbers from the codebase
1071
+ 5. Output ONLY the markdown report content — do NOT add any metadata comments, save confirmations, or file path messages
1072
+ 6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."`;
1073
+ }
1074
+ /**
1075
+ * Save inline agent output as a threat report markdown file.
1076
+ */
1077
+ function saveInlineReport(root, content, fw, agentName, project, annotationCount) {
1078
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1079
+ const reportsDir = resolve(root, '.guardlink', 'threat-reports');
1080
+ if (!existsSync(reportsDir))
1081
+ mkdirSync(reportsDir, { recursive: true });
1082
+ const filename = `${timestamp}-${fw}.md`;
1083
+ const filepath = resolve(reportsDir, filename);
1084
+ const cleanedContent = cleanCliArtifacts(content);
1085
+ const header = `---
1086
+ framework: ${fw}
1087
+ label: ${FRAMEWORK_LABELS[fw]}
1088
+ model: ${agentName}
1089
+ timestamp: ${new Date().toISOString()}
1090
+ project: ${project}
1091
+ annotations: ${annotationCount}
1092
+ ---
1093
+
1094
+ # ${FRAMEWORK_LABELS[fw]}
1095
+
1096
+ > Generated by \`guardlink threat-report ${fw}\` on ${new Date().toISOString().slice(0, 10)}
1097
+ > Agent: ${agentName} | Project: ${project} | Annotations: ${annotationCount}
1098
+
1099
+ `;
1100
+ writeFileSync(filepath, header + cleanedContent + '\n');
1101
+ return `.guardlink/threat-reports/${filename}`;
1102
+ }
816
1103
  export async function cmdThreatReport(args, ctx) {
817
1104
  if (!ctx.model) {
818
1105
  console.log(C.warn(' No threat model. Run /parse first.'));
819
1106
  return;
820
1107
  }
821
- const { agent, cleanArgs } = parseAgentFlag(args);
822
- const framework = cleanArgs.trim().toLowerCase() || '';
1108
+ // Parse any explicit --agent flag override
1109
+ const { agent: flagAgent, cleanArgs } = parseAgentFlag(args);
1110
+ const input = cleanArgs.trim();
823
1111
  const validFrameworks = ['stride', 'dread', 'pasta', 'attacker', 'rapid', 'general'];
824
- if (!framework) {
1112
+ // Show help when no arguments given
1113
+ if (!input) {
825
1114
  console.log('');
826
1115
  console.log(` ${C.bold('Threat report frameworks:')}`);
827
1116
  for (const fw of validFrameworks) {
828
1117
  console.log(` ${C.bold('/threat-report ' + fw.padEnd(12))} ${C.dim(FRAMEWORK_LABELS[fw])}`);
829
1118
  }
830
1119
  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'));
1120
+ console.log(` ${C.bold('Custom prompt:')}`);
1121
+ console.log(C.dim(' /threat-report <any text> Uses your text as the analysis prompt'));
1122
+ console.log(C.dim(' Example: /threat-report Create a comprehensive report mixing STRIDE and DREAD'));
1123
+ console.log('');
1124
+ console.log(C.dim(' Uses the AI provider configured via /model (API or CLI agent).'));
1125
+ console.log(C.dim(' Override with: --claude-code --codex --gemini --clipboard'));
834
1126
  console.log('');
835
1127
  return;
836
1128
  }
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('...')}`);
1129
+ // Determine framework vs custom prompt
1130
+ const inputLower = input.toLowerCase();
1131
+ const isStandard = validFrameworks.includes(inputLower);
1132
+ const fw = (isStandard ? inputLower : 'general');
1133
+ const customPrompt = isStandard ? undefined : input;
1134
+ const reportLabel = customPrompt ? 'Custom Threat Analysis' : FRAMEWORK_LABELS[fw];
1135
+ // ── Resolve execution method ──
1136
+ // Priority: explicit --flag > /model config > env-var API
1137
+ const tuiCfg = loadTuiConfig(ctx.root);
1138
+ // Resolve the agent to use (flag override or configured CLI agent)
1139
+ let agent = flagAgent;
1140
+ if (!agent && tuiCfg?.aiMode === 'cli-agent' && tuiCfg?.cliAgent) {
1141
+ agent = AGENTS.find(a => a.id === tuiCfg.cliAgent) || null;
1142
+ }
1143
+ // ── Path 1: CLI Agent (inline, non-interactive) ──
1144
+ if (agent && agent.cmd) {
1145
+ const analysisPrompt = buildAgentAnalysisPrompt(ctx.root, ctx.model, fw, customPrompt, reportLabel);
1146
+ console.log(` ${C.dim('Generating')} ${reportLabel} ${C.dim('via')} ${agent.name} ${C.dim('(inline)...')}`);
1147
+ console.log(C.dim(` Annotations: ${ctx.model.annotations_parsed} | Exposures: ${ctx.model.exposures.length}`));
863
1148
  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...')}`);
1149
+ const result = await launchAgentInline(agent, analysisPrompt, ctx.root, (text) => process.stdout.write(text), { autoYes: true });
1150
+ if (result.error) {
1151
+ console.log(C.error(`\n ✗ ${result.error}`));
871
1152
  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
- }
1153
+ return;
880
1154
  }
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
- }
1155
+ process.stdout.write('\n');
1156
+ // Save the agent's output as a report
1157
+ if (result.content.trim()) {
1158
+ const savedTo = saveInlineReport(ctx.root, result.content, fw, agent.name, ctx.model.project, ctx.model.annotations_parsed);
1159
+ console.log('');
1160
+ console.log(` ${C.success('✓')} Report saved to ${savedTo}`);
1161
+ }
1162
+ console.log('');
1163
+ return;
1164
+ }
1165
+ // ── Path 2: Clipboard / IDE agent (copy prompt, open app) ──
1166
+ if (agent && !agent.cmd) {
1167
+ const analysisPrompt = buildAgentAnalysisPrompt(ctx.root, ctx.model, fw, customPrompt, reportLabel);
1168
+ const result = launchAgent(agent, analysisPrompt, ctx.root);
1169
+ if (result.clipboardCopied) {
1170
+ console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
1171
+ }
1172
+ if (result.launched && agent.app) {
1173
+ console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
1174
+ console.log(`\n Paste (Cmd+V) the prompt in ${agent.name}.`);
1175
+ }
1176
+ else if (result.error) {
1177
+ console.log(C.error(` ✗ ${result.error}`));
893
1178
  }
894
1179
  console.log('');
895
1180
  return;
896
1181
  }
897
- // ── API path: direct LLM call ──
1182
+ // ── Path 3: Direct API call ──
898
1183
  const llmConfig = resolveLLMConfig(ctx.root);
899
1184
  if (!llmConfig) {
900
- console.log(C.warn(' No AI provider configured. Run /model first, or use --claude-code / --codex.'));
1185
+ console.log(C.warn(' No AI provider configured. Run /model first.'));
901
1186
  console.log(C.dim(' Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in environment.'));
1187
+ console.log(C.dim(' Or use: /threat-report <prompt> --claude-code'));
902
1188
  return;
903
1189
  }
904
- console.log(` ${C.dim('Generating report with')} ${llmConfig.model}${C.dim('...')}`);
1190
+ console.log(` ${C.dim('Generating')} ${reportLabel} ${C.dim('with')} ${llmConfig.model}${C.dim('...')}`);
1191
+ console.log(C.dim(` Annotations: ${ctx.model.annotations_parsed} | Exposures: ${ctx.model.exposures.length}`));
905
1192
  console.log('');
906
1193
  try {
907
1194
  const result = await generateThreatReport({
@@ -1010,8 +1297,10 @@ export async function cmdAnnotate(args, ctx) {
1010
1297
  }
1011
1298
  // ─── Freeform AI Chat ────────────────────────────────────────────────
1012
1299
  export async function cmdChat(text, ctx) {
1300
+ const tuiCfg = loadTuiConfig(ctx.root);
1013
1301
  const llmConfig = resolveLLMConfig(ctx.root);
1014
- if (!llmConfig) {
1302
+ const useAgent = tuiCfg?.aiMode === 'cli-agent' && !!tuiCfg?.cliAgent;
1303
+ if (!useAgent && !llmConfig) {
1015
1304
  console.log(C.warn(' No AI provider configured. Run /model first, or set an API key in environment.'));
1016
1305
  return;
1017
1306
  }
@@ -1034,17 +1323,37 @@ Keep responses under 500 words unless the user asks for detail.`;
1034
1323
  };
1035
1324
  userMessage = `Threat model context:\n${JSON.stringify(compact, null, 2)}\n\nUser question: ${text}`;
1036
1325
  }
1037
- console.log('');
1038
- console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1039
- 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');
1326
+ if (useAgent) {
1327
+ const agent = AGENTS.find(a => a.id === tuiCfg.cliAgent);
1328
+ if (!agent) {
1329
+ console.log(C.error(` ✗ Configured agent ${tuiCfg.cliAgent} not found.`));
1330
+ return;
1331
+ }
1332
+ console.log('');
1333
+ console.log(C.dim(` Thinking via ${agent.name}...`));
1334
+ console.log('');
1335
+ const prompt = `${systemPrompt}\n\n${userMessage}`;
1336
+ const result = await launchAgentInline(agent, prompt, ctx.root, (chunk) => process.stdout.write(chunk), { autoYes: true });
1337
+ if (result.error) {
1338
+ console.log(C.error(`\n ✗ AI request failed: ${result.error}`));
1339
+ }
1340
+ else {
1341
+ console.log('\n');
1342
+ }
1044
1343
  }
1045
- catch (err) {
1046
- console.log(C.error(` ✗ AI request failed: ${err.message}`));
1344
+ else {
1047
1345
  console.log('');
1346
+ console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1347
+ console.log('');
1348
+ try {
1349
+ const { chatCompletion } = await import('../analyze/llm.js');
1350
+ await chatCompletion(llmConfig, systemPrompt, userMessage, (chunk) => process.stdout.write(chunk));
1351
+ process.stdout.write('\n\n');
1352
+ }
1353
+ catch (err) {
1354
+ console.log(C.error(` ✗ AI request failed: ${err.message}`));
1355
+ console.log('');
1356
+ }
1048
1357
  }
1049
1358
  }
1050
1359
  // ─── /report ─────────────────────────────────────────────────────────