guardlink 1.0.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 (172) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/dist/agents/config.d.ts +46 -0
  5. package/dist/agents/config.d.ts.map +1 -0
  6. package/dist/agents/config.js +189 -0
  7. package/dist/agents/config.js.map +1 -0
  8. package/dist/agents/index.d.ts +24 -0
  9. package/dist/agents/index.d.ts.map +1 -0
  10. package/dist/agents/index.js +42 -0
  11. package/dist/agents/index.js.map +1 -0
  12. package/dist/agents/launcher.d.ts +54 -0
  13. package/dist/agents/launcher.d.ts.map +1 -0
  14. package/dist/agents/launcher.js +152 -0
  15. package/dist/agents/launcher.js.map +1 -0
  16. package/dist/agents/prompts.d.ts +14 -0
  17. package/dist/agents/prompts.d.ts.map +1 -0
  18. package/dist/agents/prompts.js +120 -0
  19. package/dist/agents/prompts.js.map +1 -0
  20. package/dist/analyze/index.d.ts +80 -0
  21. package/dist/analyze/index.d.ts.map +1 -0
  22. package/dist/analyze/index.js +306 -0
  23. package/dist/analyze/index.js.map +1 -0
  24. package/dist/analyze/llm.d.ts +52 -0
  25. package/dist/analyze/llm.d.ts.map +1 -0
  26. package/dist/analyze/llm.js +295 -0
  27. package/dist/analyze/llm.js.map +1 -0
  28. package/dist/analyze/prompts.d.ts +14 -0
  29. package/dist/analyze/prompts.d.ts.map +1 -0
  30. package/dist/analyze/prompts.js +205 -0
  31. package/dist/analyze/prompts.js.map +1 -0
  32. package/dist/analyzer/index.d.ts +5 -0
  33. package/dist/analyzer/index.d.ts.map +1 -0
  34. package/dist/analyzer/index.js +5 -0
  35. package/dist/analyzer/index.js.map +1 -0
  36. package/dist/analyzer/sarif.d.ts +84 -0
  37. package/dist/analyzer/sarif.d.ts.map +1 -0
  38. package/dist/analyzer/sarif.js +149 -0
  39. package/dist/analyzer/sarif.js.map +1 -0
  40. package/dist/cli/index.d.ts +25 -0
  41. package/dist/cli/index.d.ts.map +1 -0
  42. package/dist/cli/index.js +821 -0
  43. package/dist/cli/index.js.map +1 -0
  44. package/dist/dashboard/data.d.ts +52 -0
  45. package/dist/dashboard/data.d.ts.map +1 -0
  46. package/dist/dashboard/data.js +93 -0
  47. package/dist/dashboard/data.js.map +1 -0
  48. package/dist/dashboard/diagrams.d.ts +25 -0
  49. package/dist/dashboard/diagrams.d.ts.map +1 -0
  50. package/dist/dashboard/diagrams.js +243 -0
  51. package/dist/dashboard/diagrams.js.map +1 -0
  52. package/dist/dashboard/generate.d.ts +17 -0
  53. package/dist/dashboard/generate.d.ts.map +1 -0
  54. package/dist/dashboard/generate.js +1258 -0
  55. package/dist/dashboard/generate.js.map +1 -0
  56. package/dist/dashboard/index.d.ts +7 -0
  57. package/dist/dashboard/index.d.ts.map +1 -0
  58. package/dist/dashboard/index.js +7 -0
  59. package/dist/dashboard/index.js.map +1 -0
  60. package/dist/diff/engine.d.ts +51 -0
  61. package/dist/diff/engine.d.ts.map +1 -0
  62. package/dist/diff/engine.js +153 -0
  63. package/dist/diff/engine.js.map +1 -0
  64. package/dist/diff/format.d.ts +10 -0
  65. package/dist/diff/format.d.ts.map +1 -0
  66. package/dist/diff/format.js +111 -0
  67. package/dist/diff/format.js.map +1 -0
  68. package/dist/diff/git.d.ts +24 -0
  69. package/dist/diff/git.d.ts.map +1 -0
  70. package/dist/diff/git.js +85 -0
  71. package/dist/diff/git.js.map +1 -0
  72. package/dist/diff/index.d.ts +7 -0
  73. package/dist/diff/index.d.ts.map +1 -0
  74. package/dist/diff/index.js +7 -0
  75. package/dist/diff/index.js.map +1 -0
  76. package/dist/index.d.ts +20 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +17 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/init/detect.d.ts +42 -0
  81. package/dist/init/detect.d.ts.map +1 -0
  82. package/dist/init/detect.js +185 -0
  83. package/dist/init/detect.js.map +1 -0
  84. package/dist/init/index.d.ts +39 -0
  85. package/dist/init/index.d.ts.map +1 -0
  86. package/dist/init/index.js +228 -0
  87. package/dist/init/index.js.map +1 -0
  88. package/dist/init/picker.d.ts +32 -0
  89. package/dist/init/picker.d.ts.map +1 -0
  90. package/dist/init/picker.js +105 -0
  91. package/dist/init/picker.js.map +1 -0
  92. package/dist/init/templates.d.ts +25 -0
  93. package/dist/init/templates.d.ts.map +1 -0
  94. package/dist/init/templates.js +263 -0
  95. package/dist/init/templates.js.map +1 -0
  96. package/dist/mcp/index.d.ts +12 -0
  97. package/dist/mcp/index.d.ts.map +1 -0
  98. package/dist/mcp/index.js +18 -0
  99. package/dist/mcp/index.js.map +1 -0
  100. package/dist/mcp/lookup.d.ts +27 -0
  101. package/dist/mcp/lookup.d.ts.map +1 -0
  102. package/dist/mcp/lookup.js +282 -0
  103. package/dist/mcp/lookup.js.map +1 -0
  104. package/dist/mcp/server.d.ts +41 -0
  105. package/dist/mcp/server.d.ts.map +1 -0
  106. package/dist/mcp/server.js +388 -0
  107. package/dist/mcp/server.js.map +1 -0
  108. package/dist/mcp/suggest.d.ts +35 -0
  109. package/dist/mcp/suggest.d.ts.map +1 -0
  110. package/dist/mcp/suggest.js +268 -0
  111. package/dist/mcp/suggest.js.map +1 -0
  112. package/dist/parser/comment-strip.d.ts +15 -0
  113. package/dist/parser/comment-strip.d.ts.map +1 -0
  114. package/dist/parser/comment-strip.js +76 -0
  115. package/dist/parser/comment-strip.js.map +1 -0
  116. package/dist/parser/index.d.ts +10 -0
  117. package/dist/parser/index.d.ts.map +1 -0
  118. package/dist/parser/index.js +9 -0
  119. package/dist/parser/index.js.map +1 -0
  120. package/dist/parser/normalize.d.ts +22 -0
  121. package/dist/parser/normalize.d.ts.map +1 -0
  122. package/dist/parser/normalize.js +42 -0
  123. package/dist/parser/normalize.js.map +1 -0
  124. package/dist/parser/parse-file.d.ts +18 -0
  125. package/dist/parser/parse-file.d.ts.map +1 -0
  126. package/dist/parser/parse-file.js +68 -0
  127. package/dist/parser/parse-file.js.map +1 -0
  128. package/dist/parser/parse-line.d.ts +21 -0
  129. package/dist/parser/parse-line.d.ts.map +1 -0
  130. package/dist/parser/parse-line.js +230 -0
  131. package/dist/parser/parse-line.js.map +1 -0
  132. package/dist/parser/parse-project.d.ts +31 -0
  133. package/dist/parser/parse-project.d.ts.map +1 -0
  134. package/dist/parser/parse-project.js +281 -0
  135. package/dist/parser/parse-project.js.map +1 -0
  136. package/dist/report/index.d.ts +6 -0
  137. package/dist/report/index.d.ts.map +1 -0
  138. package/dist/report/index.js +6 -0
  139. package/dist/report/index.js.map +1 -0
  140. package/dist/report/mermaid.d.ts +15 -0
  141. package/dist/report/mermaid.d.ts.map +1 -0
  142. package/dist/report/mermaid.js +260 -0
  143. package/dist/report/mermaid.js.map +1 -0
  144. package/dist/report/report.d.ts +16 -0
  145. package/dist/report/report.d.ts.map +1 -0
  146. package/dist/report/report.js +211 -0
  147. package/dist/report/report.js.map +1 -0
  148. package/dist/tui/commands.d.ts +42 -0
  149. package/dist/tui/commands.d.ts.map +1 -0
  150. package/dist/tui/commands.js +1216 -0
  151. package/dist/tui/commands.js.map +1 -0
  152. package/dist/tui/config.d.ts +27 -0
  153. package/dist/tui/config.d.ts.map +1 -0
  154. package/dist/tui/config.js +27 -0
  155. package/dist/tui/config.js.map +1 -0
  156. package/dist/tui/format.d.ts +63 -0
  157. package/dist/tui/format.d.ts.map +1 -0
  158. package/dist/tui/format.js +253 -0
  159. package/dist/tui/format.js.map +1 -0
  160. package/dist/tui/index.d.ts +18 -0
  161. package/dist/tui/index.d.ts.map +1 -0
  162. package/dist/tui/index.js +470 -0
  163. package/dist/tui/index.js.map +1 -0
  164. package/dist/tui/input.d.ts +63 -0
  165. package/dist/tui/input.d.ts.map +1 -0
  166. package/dist/tui/input.js +454 -0
  167. package/dist/tui/input.js.map +1 -0
  168. package/dist/types/index.d.ts +254 -0
  169. package/dist/types/index.d.ts.map +1 -0
  170. package/dist/types/index.js +6 -0
  171. package/dist/types/index.js.map +1 -0
  172. package/package.json +97 -0
@@ -0,0 +1,1216 @@
1
+ /**
2
+ * GuardLink TUI — Command implementations.
3
+ *
4
+ * Each command function takes (args, ctx) and prints output directly.
5
+ * Returns void. Throws on fatal errors.
6
+ */
7
+ import { resolve, basename } from 'node:path';
8
+ import { writeFileSync } from 'node:fs';
9
+ import { parseProject } from '../parser/index.js';
10
+ import { initProject, detectProject, promptAgentSelection } from '../init/index.js';
11
+ import { generateReport } from '../report/index.js';
12
+ import { generateDashboardHTML } from '../dashboard/index.js';
13
+ import { computeStats, computeSeverity, computeExposures } from '../dashboard/data.js';
14
+ import { generateThreatReport, serializeModel, listThreatReports, loadThreatReportsForDashboard, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage } from '../analyze/index.js';
15
+ import { diffModels, formatDiff, parseAtRef } from '../diff/index.js';
16
+ import { generateSarif } from '../analyzer/index.js';
17
+ import { C, severityBadge, severityText, severityTextPad, severityOrder, computeGrade, gradeColored, readCodeContext, trunc, bar, fileLink, fileLinkTrunc } from './format.js';
18
+ import { resolveLLMConfig, saveTuiConfig } from './config.js';
19
+ import { AGENTS, parseAgentFlag, launchAgent, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
20
+ import { describeConfigSource } from '../agents/config.js';
21
+ // ─── Shared context ──────────────────────────────────────────────────
22
+ /** Prompt user to pick an agent interactively (TUI only) */
23
+ async function pickAgent(ctx) {
24
+ console.log(' Which agent?');
25
+ AGENTS.forEach((a, i) => console.log(` ${C.bold(String(i + 1))} ${a.name}`));
26
+ console.log('');
27
+ const choice = await ask(ctx, ` Agent [1-${AGENTS.length}]: `);
28
+ const idx = parseInt(choice, 10) - 1;
29
+ if (idx < 0 || idx >= AGENTS.length) {
30
+ console.log(C.warn(' Cancelled.'));
31
+ return null;
32
+ }
33
+ return AGENTS[idx];
34
+ }
35
+ /** Re-parse the project and update context */
36
+ export async function refreshModel(ctx) {
37
+ const { model } = await parseProject({ root: ctx.root, project: ctx.projectName });
38
+ ctx.model = model;
39
+ }
40
+ /** Prompt the user for input (single line).
41
+ * Uses once('line') instead of rl.question() to avoid double-echo
42
+ * when the main REPL's on('line') handler is also registered. */
43
+ function ask(ctx, prompt) {
44
+ return new Promise(res => {
45
+ ctx._askActive = true;
46
+ process.stdout.write(prompt);
47
+ ctx.rl.resume();
48
+ ctx.rl.once('line', (answer) => {
49
+ ctx._askActive = false;
50
+ ctx.rl.pause();
51
+ res(answer.trim());
52
+ });
53
+ });
54
+ }
55
+ // ─── /help ───────────────────────────────────────────────────────────
56
+ export function cmdHelp() {
57
+ console.log('');
58
+ console.log(C.bold(' Commands'));
59
+ console.log('');
60
+ const cmds = [
61
+ ['/init [name]', 'Initialize GuardLink in this project'],
62
+ ['/parse', 'Parse annotations, build threat model'],
63
+ ['/status', 'Risk grade + summary stats'],
64
+ ['/scan', 'Find unannotated security-relevant functions'],
65
+ ['/validate [--strict]', 'Check for syntax errors + dangling refs'],
66
+ ['', ''],
67
+ ['/exposures [flags]', 'List exposures (--asset, --severity, --file, --threat)'],
68
+ ['/show <n>', 'Detail view of exposure #n with code context'],
69
+ ['/assets', 'Asset tree with threat/control counts'],
70
+ ['/files', 'Annotated file tree with exposure counts'],
71
+ ['/view <file>', 'Show all annotations in a file with code context'],
72
+ ['', ''],
73
+ ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general)'],
74
+ ['/threat-reports', 'List saved AI threat reports'],
75
+ ['/annotate <prompt>', 'Launch coding agent to annotate codebase'],
76
+ ['/model', 'Set AI provider + API key'],
77
+ ['(freeform text)', 'Chat about your threat model with AI'],
78
+ ['', ''],
79
+ ['/report', 'Generate markdown + JSON report'],
80
+ ['/dashboard', 'Generate HTML dashboard + open browser'],
81
+ ['/diff [ref]', 'Compare model against a git ref (default: HEAD~1)'],
82
+ ['/sarif [-o file]', 'Export SARIF 2.1.0 for GitHub / VS Code'],
83
+ ['', ''],
84
+ ['/help', 'This help'],
85
+ ['/quit', 'Exit'],
86
+ ];
87
+ for (const [cmd, desc] of cmds) {
88
+ if (!cmd) {
89
+ console.log('');
90
+ continue;
91
+ }
92
+ console.log(` ${C.bold(cmd.padEnd(24))} ${C.dim(desc)}`);
93
+ }
94
+ console.log('');
95
+ console.log(C.dim(' Tab to autocomplete · ↑↓ history · /gal for annotation guide · Ctrl+C to exit'));
96
+ console.log('');
97
+ }
98
+ // ─── /gal ────────────────────────────────────────────────────────────
99
+ export function cmdGal() {
100
+ const H = (s) => C.bold(C.teal(s));
101
+ const V = (s) => C.bold(C.cyan(s));
102
+ const K = (s) => C.yellow(s);
103
+ const D = (s) => C.dim(s);
104
+ const EX = (s) => C.green(s);
105
+ console.log('');
106
+ console.log(H(' ══════════════════════════════════════════════════════════'));
107
+ console.log(H(' GAL — GuardLink Annotation Language'));
108
+ console.log(H(' ══════════════════════════════════════════════════════════'));
109
+ console.log('');
110
+ console.log(D(' Annotations live in source code comments. GuardLink parses'));
111
+ console.log(D(' them to build a live threat model from your codebase.'));
112
+ console.log('');
113
+ console.log(D(' Syntax: @verb subject [preposition object] [: description]'));
114
+ console.log('');
115
+ // ── DEFINITIONS ──────────────────────────────────────────────────
116
+ console.log(H(' ── Definitions ─────────────────────────────────────────────'));
117
+ console.log('');
118
+ console.log(` ${V('@asset')} ${K('<path>')} ${D('[: description]')}`);
119
+ console.log(D(' Declare a named asset (component, service, data store).'));
120
+ console.log(D(' Path uses dot notation for hierarchy.'));
121
+ console.log(EX(' // @asset api.auth.token_store : Stores JWT refresh tokens'));
122
+ console.log(EX(' // @asset db.users'));
123
+ console.log('');
124
+ console.log(` ${V('@threat')} ${K('<name>')} ${D('[severity: critical|high|medium|low] [: description]')}`);
125
+ console.log(D(' Declare a named threat. Severity aliases: P0=critical P1=high P2=medium P3=low.'));
126
+ console.log(EX(' // @threat SQL Injection severity:high : Unsanitized input reaches DB'));
127
+ console.log(EX(' // @threat Token Theft severity:P0'));
128
+ console.log('');
129
+ console.log(` ${V('@control')} ${K('<name>')} ${D('[: description]')}`);
130
+ console.log(D(' Declare a security control (mitigation mechanism).'));
131
+ console.log(EX(' // @control Input Validation : Sanitize all user-supplied strings'));
132
+ console.log(EX(' // @control Rate Limiting'));
133
+ console.log('');
134
+ // ── RELATIONSHIPS ─────────────────────────────────────────────────
135
+ console.log(H(' ── Relationships ───────────────────────────────────────────'));
136
+ console.log('');
137
+ console.log(` ${V('@exposes')} ${K('<asset>')} ${D('to')} ${K('<threat>')} ${D('[severity: ...] [: description]')}`);
138
+ console.log(D(' Mark an asset as exposed to a threat at this code location.'));
139
+ console.log(D(' This is the primary annotation — every exposure creates a finding.'));
140
+ console.log(EX(' // @exposes api.auth to SQL Injection severity:high'));
141
+ console.log(EX(' // @exposes db.users to Token Theft severity:critical : No token rotation'));
142
+ console.log('');
143
+ console.log(` ${V('@mitigates')} ${K('<asset>')} ${D('against')} ${K('<threat>')} ${D('[with')} ${K('<control>')}${D('] [: description]')}`);
144
+ console.log(D(' Mark that a control mitigates a threat on an asset.'));
145
+ console.log(D(' Closes the exposure — removes it from open findings.'));
146
+ console.log(EX(' // @mitigates api.auth against SQL Injection with Input Validation'));
147
+ console.log(EX(' // @mitigates db.users against Token Theft : Rotation implemented in v2'));
148
+ console.log('');
149
+ console.log(` ${V('@accepts')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[: reason]')}`);
150
+ console.log(D(' Explicitly accept a risk. Removes it from open findings.'));
151
+ console.log(D(' Use when the risk is known and intentionally not mitigated.'));
152
+ console.log(EX(' // @accepts Timing Attack on api.auth : Acceptable for current threat model'));
153
+ console.log('');
154
+ console.log(` ${V('@transfers')} ${K('<threat>')} ${D('from')} ${K('<source>')} ${D('to')} ${K('<target>')} ${D('[: description]')}`);
155
+ console.log(D(' Transfer responsibility for a threat to another asset/team.'));
156
+ console.log(EX(' // @transfers DDoS from api.gateway to cdn.cloudflare : Handled by CDN layer'));
157
+ console.log('');
158
+ // ── DATA FLOWS ────────────────────────────────────────────────────
159
+ console.log(H(' ── Data Flows & Boundaries ─────────────────────────────────'));
160
+ console.log('');
161
+ console.log(` ${V('@flows')} ${K('<source>')} ${D('to')} ${K('<target>')} ${D('[via')} ${K('<mechanism>')}${D('] [: description]')}`);
162
+ console.log(D(' Document data movement between components.'));
163
+ console.log(D(' Appears in the Data Flow Diagram.'));
164
+ console.log(EX(' // @flows api.auth to db.users via TLS 1.3'));
165
+ console.log(EX(' // @flows mobile.app to api.gateway via HTTPS : User credentials'));
166
+ console.log('');
167
+ console.log(` ${V('@boundary')} ${K('<asset_a>')} ${D('and')} ${K('<asset_b>')} ${D('[: description]')}`);
168
+ console.log(D(' Declare a trust boundary between two assets.'));
169
+ console.log(D(' Groups assets in the Data Flow Diagram.'));
170
+ console.log(EX(' // @boundary internet and api.gateway : Public-facing edge'));
171
+ console.log(EX(' // @boundary api.gateway and db.users : Internal network boundary'));
172
+ console.log('');
173
+ // ── LIFECYCLE ─────────────────────────────────────────────────────
174
+ console.log(H(' ── Lifecycle & Governance ──────────────────────────────────'));
175
+ console.log('');
176
+ console.log(` ${V('@handles')} ${K('<classification>')} ${D('on')} ${K('<asset>')} ${D('[: description]')}`);
177
+ console.log(D(' Declare data classification handled by an asset.'));
178
+ console.log(D(' Classifications: pii phi financial secrets internal public'));
179
+ console.log(EX(' // @handles pii on db.users : Stores name, email, phone'));
180
+ console.log(EX(' // @handles secrets on api.auth.token_store'));
181
+ console.log('');
182
+ console.log(` ${V('@owns')} ${K('<owner>')} ${K('<asset>')} ${D('[: description]')}`);
183
+ console.log(D(' Assign ownership of an asset to a team or person.'));
184
+ console.log(EX(' // @owns platform-team api.auth'));
185
+ console.log('');
186
+ console.log(` ${V('@validates')} ${K('<control>')} ${D('on')} ${K('<asset>')} ${D('[: description]')}`);
187
+ console.log(D(' Assert that a control has been validated/tested on an asset.'));
188
+ console.log(EX(' // @validates Input Validation on api.auth : Pen-tested 2024-Q3'));
189
+ console.log('');
190
+ console.log(` ${V('@audit')} ${K('<asset>')} ${D('[: description]')}`);
191
+ console.log(D(' Mark that this code path is an audit trail point.'));
192
+ console.log(EX(' // @audit db.users : All writes logged to audit_log table'));
193
+ console.log('');
194
+ console.log(` ${V('@assumes')} ${K('<asset>')} ${D('[: description]')}`);
195
+ console.log(D(' Document a security assumption about an asset.'));
196
+ console.log(EX(' // @assumes api.gateway : Upstream WAF filters malformed requests'));
197
+ console.log('');
198
+ console.log(` ${V('@comment')} ${D('[: description]')}`);
199
+ console.log(D(' Free-form developer security note (no structural effect).'));
200
+ console.log(EX(' // @comment : TODO — add rate limiting before v2 launch'));
201
+ console.log('');
202
+ // ── SHIELD BLOCKS ─────────────────────────────────────────────────
203
+ console.log(H(' ── Shield Blocks ───────────────────────────────────────────'));
204
+ console.log('');
205
+ console.log(` ${V('@shield:begin')} ${D('/')} ${V('@shield:end')}`);
206
+ console.log(D(' Wrap a code block to mark it as security-sensitive.'));
207
+ console.log(D(' GuardLink will flag unannotated symbols inside the block.'));
208
+ console.log(EX(' // @shield:begin'));
209
+ console.log(EX(' function verifyToken(token: string) { ... }'));
210
+ console.log(EX(' // @shield:end'));
211
+ console.log('');
212
+ // ── TIPS ──────────────────────────────────────────────────────────
213
+ console.log(H(' ── Tips ────────────────────────────────────────────────────'));
214
+ console.log('');
215
+ console.log(D(' • Annotations work in any comment style: // /* # -- <!-- -->'));
216
+ console.log(D(' • Place annotations on the line ABOVE the code they describe'));
217
+ console.log(D(' • Asset names are case-insensitive and normalized (spaces→underscores)'));
218
+ console.log(D(' • Threat/control names can reference IDs with #id syntax'));
219
+ console.log(D(' • Run /parse after adding annotations to update the threat model'));
220
+ console.log(D(' • Run /validate to check for syntax errors and dangling references'));
221
+ console.log(D(' • Run /annotate to have an AI agent add annotations automatically'));
222
+ console.log('');
223
+ console.log(H(' ══════════════════════════════════════════════════════════'));
224
+ console.log('');
225
+ }
226
+ // ─── /status ─────────────────────────────────────────────────────────
227
+ export function cmdStatus(ctx) {
228
+ if (!ctx.model) {
229
+ console.log(C.warn(' No threat model. Run /init then /run first.'));
230
+ return;
231
+ }
232
+ const m = ctx.model;
233
+ const stats = computeStats(m);
234
+ const sev = computeSeverity(m);
235
+ const grade = computeGrade(stats.exposures, stats.mitigations);
236
+ const total = sev.critical + sev.high + sev.medium + sev.low + sev.unset;
237
+ console.log('');
238
+ console.log(` ${C.bold('Risk Grade:')} ${gradeColored(grade)} ${C.dim(`(${stats.exposures} open, ${stats.mitigations} mitigated)`)}`);
239
+ console.log('');
240
+ // Severity bars
241
+ if (total > 0) {
242
+ const bw = 15;
243
+ console.log(` ${C.red.bold(String(sev.critical).padStart(3))} critical ${C.red(bar(sev.critical, total, bw))}`);
244
+ console.log(` ${C.yellow.bold(String(sev.high).padStart(3))} high ${C.yellow(bar(sev.high, total, bw))}`);
245
+ console.log(` ${C.yellow(String(sev.medium).padStart(3))} medium ${C.yellow(bar(sev.medium, total, bw))}`);
246
+ console.log(` ${C.blue(String(sev.low).padStart(3))} low ${C.blue(bar(sev.low, total, bw))}`);
247
+ if (sev.unset > 0) {
248
+ console.log(` ${C.gray(String(sev.unset).padStart(3))} unset ${C.gray(bar(sev.unset, total, bw))}`);
249
+ }
250
+ console.log('');
251
+ }
252
+ console.log(` ${C.dim('Assets:')} ${stats.assets} ${C.dim('Threats:')} ${stats.threats} ${C.dim('Controls:')} ${stats.controls}`);
253
+ console.log(` ${C.dim('Flows:')} ${stats.flows} ${C.dim('Boundaries:')} ${stats.boundaries} ${C.dim('Annotations:')} ${stats.annotations}`);
254
+ console.log(` ${C.dim('Coverage:')} ${stats.coverageAnnotated}/${stats.coverageTotal} symbols (${stats.coveragePercent}%)`);
255
+ // Top threats
256
+ if (m.exposures.length > 0) {
257
+ const threatCounts = new Map();
258
+ for (const e of m.exposures) {
259
+ const key = e.threat;
260
+ const existing = threatCounts.get(key);
261
+ if (!existing) {
262
+ threatCounts.set(key, { count: 1, maxSev: e.severity || '' });
263
+ }
264
+ else {
265
+ existing.count++;
266
+ if (severityOrder(e.severity) < severityOrder(existing.maxSev)) {
267
+ existing.maxSev = e.severity || '';
268
+ }
269
+ }
270
+ }
271
+ const sorted = [...threatCounts.entries()]
272
+ .sort((a, b) => a[1].count > b[1].count ? -1 : 1)
273
+ .slice(0, 5);
274
+ console.log('');
275
+ console.log(` ${C.bold('Top threats:')}`);
276
+ for (const [threat, info] of sorted) {
277
+ console.log(` ${threat.padEnd(22)} ×${String(info.count).padEnd(4)} (${severityText(info.maxSev)})`);
278
+ }
279
+ }
280
+ console.log('');
281
+ }
282
+ // ─── /exposures ──────────────────────────────────────────────────────
283
+ export function cmdExposures(args, ctx) {
284
+ if (!ctx.model) {
285
+ console.log(C.warn(' No threat model. Run /parse first.'));
286
+ return;
287
+ }
288
+ const rows = computeExposures(ctx.model);
289
+ let filtered = rows.filter(r => !r.mitigated && !r.accepted); // open only by default
290
+ // Parse flags
291
+ const parts = args.split(/\s+/).filter(Boolean);
292
+ let showAll = false;
293
+ for (let i = 0; i < parts.length; i++) {
294
+ const flag = parts[i];
295
+ const val = parts[i + 1];
296
+ if (flag === '--asset' && val) {
297
+ filtered = filtered.filter(r => r.asset.includes(val));
298
+ i++;
299
+ }
300
+ else if (flag === '--severity' && val) {
301
+ filtered = filtered.filter(r => r.severity === val.toLowerCase());
302
+ i++;
303
+ }
304
+ else if (flag === '--file' && val) {
305
+ filtered = filtered.filter(r => r.file.includes(val));
306
+ i++;
307
+ }
308
+ else if (flag === '--threat' && val) {
309
+ filtered = filtered.filter(r => r.threat.includes(val));
310
+ i++;
311
+ }
312
+ else if (flag === '--all') {
313
+ filtered = rows;
314
+ showAll = true;
315
+ }
316
+ }
317
+ // Sort by severity
318
+ filtered.sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
319
+ // Cache for /show
320
+ ctx.lastExposures = filtered.map(r => {
321
+ 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);
322
+ return original;
323
+ }).filter(Boolean);
324
+ if (filtered.length === 0) {
325
+ console.log(C.green(' No matching exposures found.'));
326
+ return;
327
+ }
328
+ console.log('');
329
+ // Determine terminal width for adaptive layout
330
+ const termWidth = process.stdout.columns || 100;
331
+ // Manual table (we need colored cells which formatTable can't do directly)
332
+ 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')}`;
333
+ console.log(header);
334
+ console.log(C.dim(' ' + '─'.repeat(Math.min(termWidth - 4, 96))));
335
+ for (const [i, r] of filtered.entries()) {
336
+ const num = String(i + 1).padEnd(4);
337
+ const sev = severityTextPad(r.severity, 12);
338
+ const asset = trunc(r.asset, 16).padEnd(18);
339
+ const threat = trunc(r.threat, 18).padEnd(20);
340
+ const linkedFile = fileLinkTrunc(r.file, 28, r.line, ctx.root);
341
+ const filePad = ' '.repeat(Math.max(0, 30 - trunc(r.file, 28).length));
342
+ const line = ` ${num}${sev}${asset}${threat}${linkedFile}${filePad}${r.line}`;
343
+ console.log(line);
344
+ }
345
+ console.log('');
346
+ const countMsg = showAll
347
+ ? ` ${filtered.length} exposure(s) total`
348
+ : ` ${filtered.length} open exposure(s)`;
349
+ console.log(C.dim(countMsg + ' · /show <n> for detail · --asset --severity --threat --file to filter'));
350
+ console.log('');
351
+ }
352
+ // ─── /show ───────────────────────────────────────────────────────────
353
+ export function cmdShow(args, ctx) {
354
+ const num = parseInt(args.trim(), 10);
355
+ if (!num || num < 1 || num > ctx.lastExposures.length) {
356
+ console.log(C.warn(` Usage: /show <n> where n is 1-${ctx.lastExposures.length || '?'}. Run /exposures first.`));
357
+ return;
358
+ }
359
+ const exp = ctx.lastExposures[num - 1];
360
+ console.log('');
361
+ console.log(` ${C.cyan('┌')} ${exp.asset} → ${exp.threat} ${severityBadge(exp.severity)}`);
362
+ if (exp.description) {
363
+ console.log(` ${C.cyan('│')} ${exp.description}`);
364
+ }
365
+ if (exp.external_refs.length > 0) {
366
+ console.log(` ${C.cyan('│')} ${C.dim(exp.external_refs.join(' · '))}`);
367
+ }
368
+ console.log(` ${C.cyan('│')} ${C.dim(fileLink(exp.location.file, exp.location.line, ctx.root))}`);
369
+ console.log(` ${C.cyan('│')}`);
370
+ // Code context
371
+ const { lines } = readCodeContext(exp.location.file, exp.location.line, ctx.root);
372
+ for (const l of lines) {
373
+ console.log(` ${C.cyan('│')} ${l}`);
374
+ }
375
+ console.log(` ${C.cyan('└')}`);
376
+ console.log('');
377
+ }
378
+ // ─── /assets ─────────────────────────────────────────────────────────
379
+ export function cmdAssets(ctx) {
380
+ if (!ctx.model) {
381
+ console.log(C.warn(' No threat model. Run /parse first.'));
382
+ return;
383
+ }
384
+ const m = ctx.model;
385
+ // Build asset → exposure/mitigation counts
386
+ const assetNames = new Set();
387
+ for (const a of m.assets)
388
+ assetNames.add(a.path.join('.'));
389
+ for (const e of m.exposures)
390
+ assetNames.add(e.asset);
391
+ for (const mi of m.mitigations)
392
+ assetNames.add(mi.asset);
393
+ const sorted = [...assetNames].sort();
394
+ console.log('');
395
+ console.log(` ${C.bold('Assets')} (${sorted.length})`);
396
+ console.log('');
397
+ for (const name of sorted) {
398
+ const exposures = m.exposures.filter(e => e.asset === name);
399
+ const mitigations = m.mitigations.filter(mi => mi.asset === name);
400
+ const open = exposures.length - mitigations.length;
401
+ const flowCount = m.flows.filter(f => f.source === name || f.target === name).length;
402
+ let statusIcon = C.green('✓');
403
+ if (open > 0)
404
+ statusIcon = open >= 3 ? C.red('✗') : C.warn('⚠');
405
+ console.log(` ${statusIcon} ${C.bold(name)}`);
406
+ console.log(` ${C.dim('Exposures:')} ${exposures.length} ${C.dim('Mitigated:')} ${mitigations.length} ${C.dim('Open:')} ${open > 0 ? C.red(String(open)) : C.green('0')} ${C.dim('Flows:')} ${flowCount}`);
407
+ // Show threats for this asset
408
+ const threats = new Map();
409
+ for (const e of exposures) {
410
+ if (!threats.has(e.threat))
411
+ threats.set(e.threat, e.severity || 'unset');
412
+ }
413
+ if (threats.size > 0) {
414
+ const threatList = [...threats.entries()]
415
+ .sort((a, b) => severityOrder(a[1]) - severityOrder(b[1]))
416
+ .map(([t, s]) => `${severityText(s)} ${t}`)
417
+ .join(C.dim(' · '));
418
+ console.log(` ${C.dim('Threats:')} ${threatList}`);
419
+ }
420
+ console.log('');
421
+ }
422
+ }
423
+ // ─── /files ──────────────────────────────────────────────────────────
424
+ export function cmdFiles(ctx) {
425
+ if (!ctx.model) {
426
+ console.log(C.warn(' No threat model. Run /parse first.'));
427
+ return;
428
+ }
429
+ const m = ctx.model;
430
+ // Collect per-file stats from all annotation sources
431
+ const fileStats = new Map();
432
+ const touch = (file) => {
433
+ if (!fileStats.has(file))
434
+ fileStats.set(file, { annotations: 0, exposures: 0, maxSev: 'low', threats: new Set() });
435
+ return fileStats.get(file);
436
+ };
437
+ // Count from exposures
438
+ for (const e of m.exposures) {
439
+ const s = touch(e.location.file);
440
+ s.annotations++;
441
+ s.exposures++;
442
+ s.threats.add(e.threat);
443
+ if (severityOrder(e.severity) < severityOrder(s.maxSev))
444
+ s.maxSev = e.severity || 'unset';
445
+ }
446
+ // Count from mitigations
447
+ for (const mi of m.mitigations) {
448
+ touch(mi.location.file).annotations++;
449
+ }
450
+ // Count from other annotation types
451
+ for (const a of m.acceptances) {
452
+ touch(a.location.file).annotations++;
453
+ }
454
+ for (const t of m.transfers) {
455
+ touch(t.location.file).annotations++;
456
+ }
457
+ for (const f of m.flows) {
458
+ touch(f.location.file).annotations++;
459
+ }
460
+ // Sort: files with exposures first (by severity), then alphabetically
461
+ const sorted = [...fileStats.entries()].sort((a, b) => {
462
+ if (a[1].exposures !== b[1].exposures)
463
+ return b[1].exposures - a[1].exposures;
464
+ if (a[1].maxSev !== b[1].maxSev)
465
+ return severityOrder(a[1].maxSev) - severityOrder(b[1].maxSev);
466
+ return a[0].localeCompare(b[0]);
467
+ });
468
+ console.log('');
469
+ console.log(` ${C.bold('Annotated files')} (${sorted.length})`);
470
+ console.log('');
471
+ // Group by directory
472
+ let lastDir = '';
473
+ for (const [file, stats] of sorted) {
474
+ const parts = file.split('/');
475
+ const dir = parts.slice(0, -1).join('/');
476
+ const name = parts[parts.length - 1];
477
+ if (dir !== lastDir) {
478
+ if (lastDir !== '')
479
+ console.log('');
480
+ console.log(` ${C.dim(dir + '/')}`);
481
+ lastDir = dir;
482
+ }
483
+ // Status indicator
484
+ let icon = C.dim('·');
485
+ if (stats.exposures > 0) {
486
+ icon = severityOrder(stats.maxSev) <= 1 ? C.red('●') : C.yellow('●');
487
+ }
488
+ // Counts
489
+ const expLabel = stats.exposures > 0
490
+ ? ` ${C.red(String(stats.exposures) + ' exp')}`
491
+ : '';
492
+ const annLabel = C.dim(`${stats.annotations} ann`);
493
+ // Threat badges (top 2)
494
+ const threatList = [...stats.threats].slice(0, 2).map(t => C.dim(t)).join(C.dim(', '));
495
+ const threatSuffix = threatList ? ` ${threatList}` : '';
496
+ console.log(` ${icon} ${fileLink(file, undefined, ctx.root, name)} ${annLabel}${expLabel}${threatSuffix}`);
497
+ }
498
+ console.log('');
499
+ console.log(C.dim(` /view <file> to see annotations in a file`));
500
+ console.log('');
501
+ }
502
+ // ─── /view ───────────────────────────────────────────────────────────
503
+ export function cmdView(args, ctx) {
504
+ if (!ctx.model) {
505
+ console.log(C.warn(' No threat model. Run /parse first.'));
506
+ return;
507
+ }
508
+ const query = args.trim();
509
+ if (!query) {
510
+ console.log(C.warn(' Usage: /view <file> (partial path match works)'));
511
+ return;
512
+ }
513
+ const m = ctx.model;
514
+ const allAnnotations = [];
515
+ for (const e of m.exposures) {
516
+ allAnnotations.push({
517
+ type: 'exposes',
518
+ summary: `${e.asset} → ${e.threat}${e.description ? ': ' + e.description : ''}`,
519
+ severity: e.severity,
520
+ file: e.location.file,
521
+ line: e.location.line,
522
+ refs: e.external_refs,
523
+ });
524
+ }
525
+ for (const mi of m.mitigations) {
526
+ allAnnotations.push({
527
+ type: 'mitigates',
528
+ summary: `${mi.asset}: ${mi.threat}${mi.control ? ' via ' + mi.control : ''}`,
529
+ file: mi.location.file,
530
+ line: mi.location.line,
531
+ refs: [],
532
+ });
533
+ }
534
+ for (const a of m.acceptances) {
535
+ allAnnotations.push({
536
+ type: 'accepts',
537
+ summary: `${a.asset}: ${a.threat}`,
538
+ file: a.location.file,
539
+ line: a.location.line,
540
+ refs: [],
541
+ });
542
+ }
543
+ for (const f of m.flows) {
544
+ allAnnotations.push({
545
+ type: 'flows',
546
+ summary: `${f.source} → ${f.target}${f.mechanism ? ' (' + f.mechanism + ')' : ''}`,
547
+ file: f.location.file,
548
+ line: f.location.line,
549
+ refs: [],
550
+ });
551
+ }
552
+ // Find files matching query
553
+ const matchingFiles = [...new Set(allAnnotations.map(a => a.file))]
554
+ .filter(f => f.includes(query));
555
+ if (matchingFiles.length === 0) {
556
+ console.log(C.warn(` No annotated files matching "${query}".`));
557
+ console.log(C.dim(' Run /files to see all annotated files.'));
558
+ return;
559
+ }
560
+ if (matchingFiles.length > 1) {
561
+ console.log('');
562
+ console.log(` ${C.bold('Multiple matches')} — be more specific:`);
563
+ for (const f of matchingFiles.slice(0, 10)) {
564
+ console.log(` ${fileLink(f, undefined, ctx.root)}`);
565
+ }
566
+ if (matchingFiles.length > 10)
567
+ console.log(C.dim(` ... and ${matchingFiles.length - 10} more`));
568
+ console.log('');
569
+ return;
570
+ }
571
+ const targetFile = matchingFiles[0];
572
+ const fileAnns = allAnnotations
573
+ .filter(a => a.file === targetFile)
574
+ .sort((a, b) => a.line - b.line);
575
+ console.log('');
576
+ console.log(` ${C.bold(fileLink(targetFile, undefined, ctx.root))} ${C.dim(`(${fileAnns.length} annotations)`)}`);
577
+ console.log(C.dim(' ' + '─'.repeat(56)));
578
+ for (const ann of fileAnns) {
579
+ // Type badge
580
+ let badge;
581
+ if (ann.type === 'exposes')
582
+ badge = ann.severity ? severityBadge(ann.severity) : C.red(' EXP ');
583
+ else if (ann.type === 'mitigates')
584
+ badge = C.green(' MIT ');
585
+ else if (ann.type === 'accepts')
586
+ badge = C.yellow(' ACC ');
587
+ else if (ann.type === 'flows')
588
+ badge = C.cyan(' FLOW ');
589
+ else
590
+ badge = C.dim(` ${ann.type.toUpperCase().padEnd(4)} `);
591
+ console.log('');
592
+ console.log(` ${badge} ${ann.summary}`);
593
+ if (ann.refs.length > 0) {
594
+ console.log(` ${C.dim(' ' + ann.refs.join(' · '))}`);
595
+ }
596
+ // Code context (±3 lines for compact view)
597
+ const { lines } = readCodeContext(ann.file, ann.line, ctx.root, 3);
598
+ for (const l of lines) {
599
+ console.log(` ${l}`);
600
+ }
601
+ }
602
+ console.log('');
603
+ console.log(C.dim(' ' + '─'.repeat(56)));
604
+ console.log('');
605
+ }
606
+ // ─── /init ───────────────────────────────────────────────────────────
607
+ export async function cmdInit(args, ctx) {
608
+ const info = detectProject(ctx.root);
609
+ console.log(` ${C.dim('Detected:')} ${info.language} project "${info.name}"`);
610
+ if (info.alreadyInitialized) {
611
+ console.log(C.warn(' .guardlink/ already exists. Skipping init.'));
612
+ return;
613
+ }
614
+ // Agent selection
615
+ let agentIds;
616
+ if (process.stdin.isTTY) {
617
+ agentIds = await promptAgentSelection(info.agentFiles);
618
+ }
619
+ else {
620
+ agentIds = ['claude'];
621
+ }
622
+ const name = args.trim() || info.name || basename(ctx.root);
623
+ const result = initProject({
624
+ root: ctx.root,
625
+ project: name,
626
+ agentIds,
627
+ });
628
+ console.log('');
629
+ for (const f of result.created)
630
+ console.log(` ${C.green('Created:')} ${f}`);
631
+ for (const f of result.updated)
632
+ console.log(` ${C.green('Updated:')} ${f}`);
633
+ for (const f of result.skipped)
634
+ console.log(` ${C.dim('Skipped:')} ${f}`);
635
+ if (result.created.length > 0 || result.updated.length > 0) {
636
+ ctx.projectName = name;
637
+ console.log('');
638
+ console.log(C.success(' ✓ GuardLink initialized.'));
639
+ console.log(C.dim(' Run /annotate to add annotations, or /run if annotations already exist.'));
640
+ }
641
+ console.log('');
642
+ }
643
+ // ─── /parse (was /run) ───────────────────────────────────────────────
644
+ export async function cmdParse(ctx) {
645
+ console.log(C.dim(' Parsing annotations...'));
646
+ try {
647
+ const { model, diagnostics } = await parseProject({ root: ctx.root, project: ctx.projectName });
648
+ ctx.model = model;
649
+ // Print errors/warnings
650
+ const errors = diagnostics.filter(d => d.level === 'error');
651
+ const warnings = diagnostics.filter(d => d.level === 'warning');
652
+ if (errors.length > 0) {
653
+ for (const d of errors)
654
+ console.log(` ${C.red('✗')} ${d.file}:${d.line}: ${d.message}`);
655
+ }
656
+ if (warnings.length > 0 && warnings.length <= 5) {
657
+ for (const d of warnings)
658
+ console.log(` ${C.warn('⚠')} ${d.file}:${d.line}: ${d.message}`);
659
+ }
660
+ else if (warnings.length > 5) {
661
+ console.log(` ${C.warn('⚠')} ${warnings.length} warnings (run guardlink validate for details)`);
662
+ }
663
+ const grade = computeGrade(model.exposures.length, model.mitigations.length);
664
+ console.log('');
665
+ console.log(` ${C.success('✓')} Parsed ${C.bold(String(model.annotations_parsed))} annotations from ${model.source_files} files`);
666
+ console.log(` ${model.assets.length} assets · ${model.threats.length} threats · ${model.controls.length} controls`);
667
+ console.log(` ${model.exposures.length} exposures · ${model.mitigations.length} mitigations · Grade: ${gradeColored(grade)}`);
668
+ console.log('');
669
+ }
670
+ catch (err) {
671
+ console.log(C.error(` ✗ Parse failed: ${err.message}`));
672
+ }
673
+ }
674
+ // ─── /scan ───────────────────────────────────────────────────────────
675
+ export function cmdScan(ctx) {
676
+ if (!ctx.model) {
677
+ console.log(C.warn(' No threat model. Run /parse first.'));
678
+ return;
679
+ }
680
+ const cov = ctx.model.coverage;
681
+ const pct = cov.coverage_percent;
682
+ console.log('');
683
+ console.log(` ${C.bold('Coverage:')} ${cov.annotated_symbols}/${cov.total_symbols} symbols (${pct}%)`);
684
+ const unannotated = cov.unannotated_critical || [];
685
+ if (unannotated.length === 0) {
686
+ console.log(C.green(' All security-relevant symbols are annotated!'));
687
+ }
688
+ else {
689
+ console.log(C.warn(` ${unannotated.length} unannotated symbol(s):`));
690
+ console.log('');
691
+ const show = unannotated.slice(0, 25);
692
+ for (const u of show) {
693
+ console.log(` ${C.dim(fileLink(u.file, u.line, ctx.root))} ${u.kind} ${C.bold(u.name)}`);
694
+ }
695
+ if (unannotated.length > 25) {
696
+ console.log(C.dim(` ... and ${unannotated.length - 25} more`));
697
+ }
698
+ }
699
+ console.log('');
700
+ }
701
+ // ─── /validate ───────────────────────────────────────────────────────
702
+ export async function cmdValidate(ctx) {
703
+ console.log(C.dim(' Checking annotations...'));
704
+ try {
705
+ const { model, diagnostics } = await parseProject({ root: ctx.root, project: ctx.projectName });
706
+ ctx.model = model;
707
+ // Dangling refs
708
+ const danglingDiags = findDanglingRefs(model);
709
+ const allDiags = [...diagnostics, ...danglingDiags];
710
+ // Unmitigated exposures
711
+ const unmitigated = findUnmitigatedExposures(model);
712
+ // Print diagnostics
713
+ const errors = allDiags.filter(d => d.level === 'error');
714
+ const warnings = allDiags.filter(d => d.level === 'warning');
715
+ if (allDiags.length > 0) {
716
+ console.log('');
717
+ for (const d of allDiags) {
718
+ const prefix = d.level === 'error' ? C.error(' ✗') : C.warn(' ⚠');
719
+ const loc = d.file ? `${fileLink(d.file, d.line, ctx.root)}` : '';
720
+ console.log(`${prefix} ${d.message}${loc ? ` ${C.dim(loc)}` : ''}`);
721
+ }
722
+ }
723
+ if (unmitigated.length > 0) {
724
+ console.log('');
725
+ console.log(C.warn(` ${unmitigated.length} unmitigated exposure(s):`));
726
+ for (const u of unmitigated) {
727
+ const sev = u.severity ? severityBadge(u.severity) : C.dim('unset');
728
+ console.log(` ${sev} ${u.asset} → ${u.threat} ${C.dim(fileLink(u.location.file, u.location.line, ctx.root))}`);
729
+ }
730
+ }
731
+ console.log('');
732
+ if (errors.length === 0 && unmitigated.length === 0) {
733
+ console.log(C.success(' ✓ All annotations valid, no unmitigated exposures.'));
734
+ }
735
+ else {
736
+ const parts = [];
737
+ if (errors.length > 0)
738
+ parts.push(`${errors.length} error(s)`);
739
+ if (warnings.length > 0)
740
+ parts.push(`${warnings.length} warning(s)`);
741
+ if (unmitigated.length > 0)
742
+ parts.push(`${unmitigated.length} unmitigated`);
743
+ console.log(` ${parts.join(', ')}`);
744
+ }
745
+ }
746
+ catch (err) {
747
+ console.log(C.error(` ✗ ${err.message}`));
748
+ }
749
+ console.log('');
750
+ }
751
+ // ─── /diff ───────────────────────────────────────────────────────────
752
+ export async function cmdDiff(args, ctx) {
753
+ const ref = args.trim() || 'HEAD~1';
754
+ console.log(C.dim(` Comparing against ${ref}...`));
755
+ try {
756
+ const { model: current } = await parseProject({ root: ctx.root, project: ctx.projectName });
757
+ ctx.model = current;
758
+ let previous;
759
+ try {
760
+ previous = await parseAtRef(ctx.root, ref, ctx.projectName);
761
+ }
762
+ catch (err) {
763
+ console.log(C.error(` ✗ Could not parse at ${ref}: ${err.message}`));
764
+ console.log(C.dim(' Make sure you have git history and the ref exists.'));
765
+ return;
766
+ }
767
+ const diff = diffModels(previous, current);
768
+ const output = formatDiff(diff);
769
+ console.log('');
770
+ // Indent each line
771
+ for (const line of output.split('\n')) {
772
+ console.log(` ${line}`);
773
+ }
774
+ }
775
+ catch (err) {
776
+ console.log(C.error(` ✗ ${err.message}`));
777
+ }
778
+ console.log('');
779
+ }
780
+ // ─── /sarif ──────────────────────────────────────────────────────────
781
+ export async function cmdSarif(args, ctx) {
782
+ const outputFile = args.replace(/-o\s+/, '').trim() || null;
783
+ if (!ctx.model) {
784
+ console.log(C.dim(' Parsing annotations first...'));
785
+ try {
786
+ const { model } = await parseProject({ root: ctx.root, project: ctx.projectName });
787
+ ctx.model = model;
788
+ }
789
+ catch (err) {
790
+ console.log(C.error(` ✗ ${err.message}`));
791
+ return;
792
+ }
793
+ }
794
+ try {
795
+ const { diagnostics } = await parseProject({ root: ctx.root, project: ctx.projectName });
796
+ const danglingDiags = findDanglingRefs(ctx.model);
797
+ const sarif = generateSarif(ctx.model, diagnostics, danglingDiags, {
798
+ includeDiagnostics: true,
799
+ includeDanglingRefs: true,
800
+ });
801
+ const json = JSON.stringify(sarif, null, 2);
802
+ const resultCount = sarif.runs[0]?.results?.length ?? 0;
803
+ if (outputFile) {
804
+ const outPath = resolve(ctx.root, outputFile);
805
+ writeFileSync(outPath, json + '\n');
806
+ console.log(C.success(` ✓ Wrote SARIF to ${outputFile}`));
807
+ }
808
+ else {
809
+ const defaultPath = resolve(ctx.root, 'guardlink.sarif.json');
810
+ writeFileSync(defaultPath, json + '\n');
811
+ console.log(C.success(` ✓ Wrote SARIF to guardlink.sarif.json`));
812
+ }
813
+ console.log(C.dim(` ${resultCount} result(s)`));
814
+ }
815
+ catch (err) {
816
+ console.log(C.error(` ✗ ${err.message}`));
817
+ }
818
+ console.log('');
819
+ }
820
+ // ─── Helpers: validate ───────────────────────────────────────────────
821
+ function findDanglingRefs(model) {
822
+ const diagnostics = [];
823
+ const definedIds = new Set();
824
+ for (const a of model.assets)
825
+ if (a.id)
826
+ definedIds.add(a.id);
827
+ for (const t of model.threats)
828
+ if (t.id)
829
+ definedIds.add(t.id);
830
+ for (const c of model.controls)
831
+ if (c.id)
832
+ definedIds.add(c.id);
833
+ for (const b of model.boundaries)
834
+ if (b.id)
835
+ definedIds.add(b.id);
836
+ const checkRef = (ref, loc) => {
837
+ if (ref.startsWith('#')) {
838
+ const id = ref.slice(1);
839
+ if (!definedIds.has(id)) {
840
+ diagnostics.push({
841
+ level: 'warning',
842
+ message: `Dangling reference: #${id} is never defined`,
843
+ file: loc.file,
844
+ line: loc.line,
845
+ });
846
+ }
847
+ }
848
+ };
849
+ for (const m of model.mitigations) {
850
+ checkRef(m.threat, m.location);
851
+ if (m.control)
852
+ checkRef(m.control, m.location);
853
+ }
854
+ for (const e of model.exposures)
855
+ checkRef(e.threat, e.location);
856
+ for (const a of model.acceptances)
857
+ checkRef(a.threat, a.location);
858
+ for (const t of model.transfers)
859
+ checkRef(t.threat, t.location);
860
+ if (model.validations) {
861
+ for (const v of model.validations)
862
+ checkRef(v.control, v.location);
863
+ }
864
+ return diagnostics;
865
+ }
866
+ function findUnmitigatedExposures(model) {
867
+ const mitigated = new Set();
868
+ for (const m of model.mitigations)
869
+ mitigated.add(`${m.asset}::${m.threat}`);
870
+ for (const a of model.acceptances)
871
+ mitigated.add(`${a.asset}::${a.threat}`);
872
+ return model.exposures.filter(e => !mitigated.has(`${e.asset}::${e.threat}`));
873
+ }
874
+ // ─── /model ──────────────────────────────────────────────────────────
875
+ export async function cmdModel(ctx) {
876
+ const current = resolveLLMConfig(ctx.root);
877
+ const source = describeConfigSource(ctx.root);
878
+ if (current) {
879
+ console.log(` ${C.dim('Current:')} ${current.provider} / ${current.model}`);
880
+ console.log(` ${C.dim('Source:')} ${source}`);
881
+ console.log('');
882
+ // If config comes from env vars, offer to keep it
883
+ if (source.includes('env var')) {
884
+ const override = await ask(ctx, ' Override with project config? (y/N): ');
885
+ if (override.toLowerCase() !== 'y') {
886
+ console.log(C.dim(' Keeping environment configuration.'));
887
+ return;
888
+ }
889
+ }
890
+ }
891
+ else {
892
+ console.log(C.dim(' No AI provider configured.'));
893
+ console.log('');
894
+ }
895
+ // Provider selection
896
+ const providers = ['anthropic', 'openai', 'openrouter', 'deepseek', 'ollama'];
897
+ console.log(' Select provider:');
898
+ providers.forEach((p, i) => console.log(` ${C.bold(String(i + 1))} ${p}`));
899
+ console.log('');
900
+ const choice = await ask(ctx, ` Provider [1-${providers.length}]: `);
901
+ const idx = parseInt(choice, 10) - 1;
902
+ if (idx < 0 || idx >= providers.length) {
903
+ console.log(C.warn(' Cancelled.'));
904
+ return;
905
+ }
906
+ const provider = providers[idx];
907
+ // API key
908
+ let apiKey = '';
909
+ if (provider !== 'ollama') {
910
+ apiKey = await ask(ctx, ' API Key: ');
911
+ if (!apiKey) {
912
+ console.log(C.warn(' Cancelled — no API key provided.'));
913
+ return;
914
+ }
915
+ }
916
+ else {
917
+ apiKey = 'ollama-local';
918
+ }
919
+ // Model selection
920
+ const defaults = {
921
+ anthropic: 'claude-sonnet-4-5-20250929',
922
+ openai: 'gpt-4o',
923
+ openrouter: 'anthropic/claude-sonnet-4-5-20250929',
924
+ deepseek: 'deepseek-chat',
925
+ ollama: 'llama3.2',
926
+ };
927
+ const model = await ask(ctx, ` Model [${defaults[provider]}]: `);
928
+ saveTuiConfig(ctx.root, {
929
+ provider,
930
+ model: model || defaults[provider],
931
+ apiKey,
932
+ });
933
+ const displayKey = apiKey.length > 8 ? apiKey.slice(0, 6) + '•'.repeat(8) : '•'.repeat(8);
934
+ console.log('');
935
+ console.log(` ${C.success('✓')} Configured: ${C.bold(model || defaults[provider])} (${provider})`);
936
+ console.log(` Key: ${displayKey}`);
937
+ console.log(C.dim(' Saved to .guardlink/config.json'));
938
+ console.log('');
939
+ }
940
+ // ─── /threat-report ──────────────────────────────────────────────────
941
+ export async function cmdThreatReport(args, ctx) {
942
+ if (!ctx.model) {
943
+ console.log(C.warn(' No threat model. Run /parse first.'));
944
+ return;
945
+ }
946
+ const { agent, cleanArgs } = parseAgentFlag(args);
947
+ const framework = cleanArgs.trim().toLowerCase() || '';
948
+ const validFrameworks = ['stride', 'dread', 'pasta', 'attacker', 'rapid', 'general'];
949
+ if (!framework) {
950
+ console.log('');
951
+ console.log(` ${C.bold('Threat report frameworks:')}`);
952
+ for (const fw of validFrameworks) {
953
+ console.log(` ${C.bold('/threat-report ' + fw.padEnd(12))} ${C.dim(FRAMEWORK_LABELS[fw])}`);
954
+ }
955
+ console.log('');
956
+ console.log(C.dim(' Flags: --claude-code --codex --gemini --cursor --windsurf --clipboard'));
957
+ console.log(C.dim(' Without flag: uses configured API provider (see /model)'));
958
+ console.log(C.dim(' Example: /threat-report stride --claude-code'));
959
+ console.log('');
960
+ return;
961
+ }
962
+ const isStandard = validFrameworks.includes(framework);
963
+ const fw = (isStandard ? framework : 'general');
964
+ const customPrompt = isStandard ? undefined : cleanArgs.trim();
965
+ // ── Agent path: spawn CLI agent or copy to clipboard ──
966
+ if (agent) {
967
+ const modelJson = serializeModel(ctx.model);
968
+ const systemPrompt = FRAMEWORK_PROMPTS[fw];
969
+ const userMessage = buildUserMessage(modelJson, fw, customPrompt);
970
+ const analysisPrompt = `You are analyzing a codebase with GuardLink security annotations.
971
+ You have access to the full source code in the current directory.
972
+
973
+ ${systemPrompt}
974
+
975
+ ## Task
976
+ Read the source code and GuardLink annotations, then produce a thorough ${FRAMEWORK_LABELS[fw]}.
977
+
978
+ ## Threat Model (serialized from annotations)
979
+ ${userMessage}
980
+
981
+ ## Instructions
982
+ 1. Read the actual source files to understand the code — don't just rely on the serialized model above
983
+ 2. Cross-reference the annotations with the real code to validate findings
984
+ 3. Produce the full report as markdown
985
+ 4. Save the output to .guardlink/threat-reports/ with a timestamped filename
986
+ 5. Be specific — reference actual files, functions, and line numbers from the codebase`;
987
+ console.log(` ${C.dim('Sending')} ${FRAMEWORK_LABELS[fw]} ${C.dim('to')} ${agent.name}${C.dim('...')}`);
988
+ console.log('');
989
+ // Use shared launcher — foreground for terminal agents, IDE open for others
990
+ if (agent.cmd) {
991
+ const copied = copyToClipboard(analysisPrompt);
992
+ if (copied) {
993
+ console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
994
+ }
995
+ console.log(` ${C.dim('Launching')} ${agent.name} ${C.dim('in foreground...')}`);
996
+ console.log('');
997
+ const result = launchAgent(agent, analysisPrompt, ctx.root);
998
+ if (result.error) {
999
+ console.log(C.error(` ✗ ${result.error}`));
1000
+ }
1001
+ else {
1002
+ console.log(`\n ${C.success('✓')} ${agent.name} session ended.`);
1003
+ console.log(` Run ${C.bold('/threat-reports')} to see saved results.`);
1004
+ }
1005
+ }
1006
+ else {
1007
+ const result = launchAgent(agent, analysisPrompt, ctx.root);
1008
+ if (result.clipboardCopied) {
1009
+ console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
1010
+ }
1011
+ if (result.launched && agent.app) {
1012
+ console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
1013
+ console.log(`\n Paste (Cmd+V) the prompt in ${agent.name}.`);
1014
+ }
1015
+ else if (result.error) {
1016
+ console.log(C.error(` ✗ ${result.error}`));
1017
+ }
1018
+ }
1019
+ console.log('');
1020
+ return;
1021
+ }
1022
+ // ── API path: direct LLM call ──
1023
+ const llmConfig = resolveLLMConfig(ctx.root);
1024
+ if (!llmConfig) {
1025
+ console.log(C.warn(' No AI provider configured. Run /model first, or use --claude-code / --codex.'));
1026
+ console.log(C.dim(' Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in environment.'));
1027
+ return;
1028
+ }
1029
+ console.log(` ${C.dim('Generating report with')} ${llmConfig.model}${C.dim('...')}`);
1030
+ console.log('');
1031
+ try {
1032
+ const result = await generateThreatReport({
1033
+ root: ctx.root,
1034
+ model: ctx.model,
1035
+ framework: fw,
1036
+ llmConfig,
1037
+ customPrompt,
1038
+ stream: true,
1039
+ onChunk: (text) => process.stdout.write(text),
1040
+ });
1041
+ process.stdout.write('\n');
1042
+ console.log('');
1043
+ console.log(` ${C.success('✓')} Report saved to ${result.savedTo}`);
1044
+ if (result.inputTokens || result.outputTokens) {
1045
+ console.log(C.dim(` Tokens: ${result.inputTokens || '?'} in / ${result.outputTokens || '?'} out`));
1046
+ }
1047
+ console.log('');
1048
+ }
1049
+ catch (err) {
1050
+ console.log(C.error(`\n ✗ Threat report failed: ${err.message}`));
1051
+ console.log('');
1052
+ }
1053
+ }
1054
+ // ─── /threat-reports ─────────────────────────────────────────────────
1055
+ export function cmdThreatReports(ctx) {
1056
+ const reports = listThreatReports(ctx.root);
1057
+ if (reports.length === 0) {
1058
+ console.log('');
1059
+ console.log(C.dim(' No saved threat reports yet.'));
1060
+ console.log(C.dim(' Run /threat-report <framework> to generate one.'));
1061
+ console.log('');
1062
+ return;
1063
+ }
1064
+ console.log('');
1065
+ console.log(C.bold(` ${reports.length} saved threat report(s)`));
1066
+ console.log('');
1067
+ for (const r of reports) {
1068
+ const model = r.model ? C.dim(` (${r.model})`) : '';
1069
+ const dirLabel = r.dirName || 'threat-reports';
1070
+ const path = `.guardlink/${dirLabel}/${r.filename}`;
1071
+ console.log(` ${C.cyan(r.timestamp)} ${C.bold(r.label)}${model}`);
1072
+ console.log(` ${C.dim(fileLink(path, undefined, ctx.root))}`);
1073
+ }
1074
+ console.log('');
1075
+ }
1076
+ // ─── /annotate ───────────────────────────────────────────────────────
1077
+ export async function cmdAnnotate(args, ctx) {
1078
+ const { agent: flagAgent, cleanArgs } = parseAgentFlag(args);
1079
+ if (!cleanArgs.trim()) {
1080
+ console.log(C.warn(' Usage: /annotate <prompt> [--claude-code|--codex|--gemini|--cursor|--windsurf|--clipboard]'));
1081
+ console.log(C.dim(' Example: /annotate "annotate auth endpoints for OWASP Top 10" --claude-code'));
1082
+ return;
1083
+ }
1084
+ console.log('');
1085
+ // Resolve agent: flag takes priority, otherwise interactive picker
1086
+ const agent = flagAgent || await pickAgent(ctx);
1087
+ if (!agent)
1088
+ return;
1089
+ // Build context prompt using shared builder
1090
+ const prompt = buildAnnotatePrompt(cleanArgs.trim(), ctx.root, ctx.model);
1091
+ // For terminal agents: foreground spawn (agent takes over terminal)
1092
+ if (agent.cmd) {
1093
+ const copied = copyToClipboard(prompt);
1094
+ if (copied) {
1095
+ console.log(C.success(` ✓ Prompt copied to clipboard (${prompt.length.toLocaleString()} chars)`));
1096
+ }
1097
+ console.log(` ${C.dim('Launching')} ${agent.name} ${C.dim('in foreground...')}`);
1098
+ console.log(` ${C.dim('Exit the agent to return to GuardLink TUI.')}`);
1099
+ console.log('');
1100
+ const result = launchAgent(agent, prompt, ctx.root);
1101
+ if (result.error) {
1102
+ console.log(C.error(` ✗ ${result.error}`));
1103
+ if (result.clipboardCopied) {
1104
+ console.log(C.dim(' Prompt is on your clipboard — paste it manually.'));
1105
+ }
1106
+ }
1107
+ else {
1108
+ console.log('');
1109
+ console.log(C.success(` ✓ ${agent.name} session ended.`));
1110
+ console.log(` Run ${C.bold('/parse')} to update the threat model.`);
1111
+ }
1112
+ }
1113
+ else {
1114
+ // IDE or clipboard — copies + opens app
1115
+ const result = launchAgent(agent, prompt, ctx.root);
1116
+ if (result.clipboardCopied) {
1117
+ console.log(C.success(` ✓ Prompt copied to clipboard (${prompt.length.toLocaleString()} chars)`));
1118
+ }
1119
+ if (result.launched && agent.app) {
1120
+ console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
1121
+ console.log('');
1122
+ console.log(` ${C.bold('1.')} In ${agent.name}, open the AI Chat/Composer panel`);
1123
+ console.log(` ${C.bold('2.')} ${C.green('Paste (Cmd+V)')} the prompt`);
1124
+ console.log(` ${C.bold('3.')} When done, run ${C.bold('/parse')} to update the model`);
1125
+ }
1126
+ else if (result.error) {
1127
+ console.log(C.error(` ✗ ${result.error}`));
1128
+ }
1129
+ else if (agent.id === 'clipboard') {
1130
+ console.log(C.dim(' Paste the prompt into your preferred AI tool.'));
1131
+ console.log(` When done, run ${C.bold('/parse')} to update the model.`);
1132
+ }
1133
+ }
1134
+ console.log('');
1135
+ }
1136
+ // ─── Freeform AI Chat ────────────────────────────────────────────────
1137
+ export async function cmdChat(text, ctx) {
1138
+ const llmConfig = resolveLLMConfig(ctx.root);
1139
+ if (!llmConfig) {
1140
+ console.log(C.warn(' No AI provider configured. Run /model first, or set an API key in environment.'));
1141
+ return;
1142
+ }
1143
+ // Build system prompt with model context
1144
+ let systemPrompt = `You are a security expert helping a developer understand their project's threat model.
1145
+ Answer concisely and directly. Reference specific assets, threats, and exposures from the model when relevant.
1146
+ Keep responses under 500 words unless the user asks for detail.`;
1147
+ let userMessage = text;
1148
+ if (ctx.model) {
1149
+ // Serialize compact model for context
1150
+ const compact = {
1151
+ project: ctx.model.project,
1152
+ annotations: ctx.model.annotations_parsed,
1153
+ assets: ctx.model.assets.map(a => a.path.join('.')),
1154
+ threats: ctx.model.threats.map(t => ({ name: t.name, id: t.id, severity: t.severity })),
1155
+ exposures: ctx.model.exposures.map(e => ({ asset: e.asset, threat: e.threat, severity: e.severity, file: e.location.file })),
1156
+ mitigations: ctx.model.mitigations.map(m => ({ asset: m.asset, threat: m.threat, control: m.control })),
1157
+ controls: ctx.model.controls.map(c => ({ name: c.name, id: c.id })),
1158
+ flows: ctx.model.flows.map(f => ({ source: f.source, target: f.target, mechanism: f.mechanism })),
1159
+ };
1160
+ userMessage = `Threat model context:\n${JSON.stringify(compact, null, 2)}\n\nUser question: ${text}`;
1161
+ }
1162
+ console.log('');
1163
+ console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1164
+ console.log('');
1165
+ try {
1166
+ const { chatCompletion } = await import('../analyze/llm.js');
1167
+ const response = await chatCompletion(llmConfig, systemPrompt, userMessage, (chunk) => process.stdout.write(chunk));
1168
+ process.stdout.write('\n\n');
1169
+ }
1170
+ catch (err) {
1171
+ console.log(C.error(` ✗ AI request failed: ${err.message}`));
1172
+ console.log('');
1173
+ }
1174
+ }
1175
+ // ─── /report ─────────────────────────────────────────────────────────
1176
+ export async function cmdReport(ctx) {
1177
+ if (!ctx.model) {
1178
+ console.log(C.warn(' No threat model. Run /parse first.'));
1179
+ return;
1180
+ }
1181
+ const report = generateReport(ctx.model);
1182
+ const outFile = resolve(ctx.root, 'threat-model.md');
1183
+ const { writeFile } = await import('node:fs/promises');
1184
+ await writeFile(outFile, report + '\n');
1185
+ console.log(` ${C.success('✓')} Report written to threat-model.md`);
1186
+ // Also write JSON
1187
+ const jsonFile = resolve(ctx.root, 'threat-model.json');
1188
+ await writeFile(jsonFile, JSON.stringify(ctx.model, null, 2) + '\n');
1189
+ console.log(` ${C.success('✓')} JSON written to threat-model.json`);
1190
+ console.log('');
1191
+ }
1192
+ // ─── /dashboard ──────────────────────────────────────────────────────
1193
+ export async function cmdDashboard(ctx) {
1194
+ if (!ctx.model) {
1195
+ console.log(C.warn(' No threat model. Run /parse first.'));
1196
+ return;
1197
+ }
1198
+ const analyses = loadThreatReportsForDashboard(ctx.root);
1199
+ const html = generateDashboardHTML(ctx.model, ctx.root, analyses);
1200
+ const outFile = resolve(ctx.root, 'threat-dashboard.html');
1201
+ const { writeFile } = await import('node:fs/promises');
1202
+ await writeFile(outFile, html);
1203
+ console.log(` ${C.success('✓')} Dashboard generated: threat-dashboard.html`);
1204
+ // Open in browser
1205
+ try {
1206
+ const { exec } = await import('node:child_process');
1207
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1208
+ exec(`${openCmd} "${outFile}"`);
1209
+ console.log(C.dim(' Opened in browser.'));
1210
+ }
1211
+ catch {
1212
+ console.log(C.dim(' Open the file in your browser.'));
1213
+ }
1214
+ console.log('');
1215
+ }
1216
+ //# sourceMappingURL=commands.js.map