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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/agents/config.d.ts +46 -0
- package/dist/agents/config.d.ts.map +1 -0
- package/dist/agents/config.js +189 -0
- package/dist/agents/config.js.map +1 -0
- package/dist/agents/index.d.ts +24 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +42 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/launcher.d.ts +54 -0
- package/dist/agents/launcher.d.ts.map +1 -0
- package/dist/agents/launcher.js +152 -0
- package/dist/agents/launcher.js.map +1 -0
- package/dist/agents/prompts.d.ts +14 -0
- package/dist/agents/prompts.d.ts.map +1 -0
- package/dist/agents/prompts.js +120 -0
- package/dist/agents/prompts.js.map +1 -0
- package/dist/analyze/index.d.ts +80 -0
- package/dist/analyze/index.d.ts.map +1 -0
- package/dist/analyze/index.js +306 -0
- package/dist/analyze/index.js.map +1 -0
- package/dist/analyze/llm.d.ts +52 -0
- package/dist/analyze/llm.d.ts.map +1 -0
- package/dist/analyze/llm.js +295 -0
- package/dist/analyze/llm.js.map +1 -0
- package/dist/analyze/prompts.d.ts +14 -0
- package/dist/analyze/prompts.d.ts.map +1 -0
- package/dist/analyze/prompts.js +205 -0
- package/dist/analyze/prompts.js.map +1 -0
- package/dist/analyzer/index.d.ts +5 -0
- package/dist/analyzer/index.d.ts.map +1 -0
- package/dist/analyzer/index.js +5 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/analyzer/sarif.d.ts +84 -0
- package/dist/analyzer/sarif.d.ts.map +1 -0
- package/dist/analyzer/sarif.js +149 -0
- package/dist/analyzer/sarif.js.map +1 -0
- package/dist/cli/index.d.ts +25 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +821 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/dashboard/data.d.ts +52 -0
- package/dist/dashboard/data.d.ts.map +1 -0
- package/dist/dashboard/data.js +93 -0
- package/dist/dashboard/data.js.map +1 -0
- package/dist/dashboard/diagrams.d.ts +25 -0
- package/dist/dashboard/diagrams.d.ts.map +1 -0
- package/dist/dashboard/diagrams.js +243 -0
- package/dist/dashboard/diagrams.js.map +1 -0
- package/dist/dashboard/generate.d.ts +17 -0
- package/dist/dashboard/generate.d.ts.map +1 -0
- package/dist/dashboard/generate.js +1258 -0
- package/dist/dashboard/generate.js.map +1 -0
- package/dist/dashboard/index.d.ts +7 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +7 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/diff/engine.d.ts +51 -0
- package/dist/diff/engine.d.ts.map +1 -0
- package/dist/diff/engine.js +153 -0
- package/dist/diff/engine.js.map +1 -0
- package/dist/diff/format.d.ts +10 -0
- package/dist/diff/format.d.ts.map +1 -0
- package/dist/diff/format.js +111 -0
- package/dist/diff/format.js.map +1 -0
- package/dist/diff/git.d.ts +24 -0
- package/dist/diff/git.d.ts.map +1 -0
- package/dist/diff/git.js +85 -0
- package/dist/diff/git.js.map +1 -0
- package/dist/diff/index.d.ts +7 -0
- package/dist/diff/index.d.ts.map +1 -0
- package/dist/diff/index.js +7 -0
- package/dist/diff/index.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/init/detect.d.ts +42 -0
- package/dist/init/detect.d.ts.map +1 -0
- package/dist/init/detect.js +185 -0
- package/dist/init/detect.js.map +1 -0
- package/dist/init/index.d.ts +39 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +228 -0
- package/dist/init/index.js.map +1 -0
- package/dist/init/picker.d.ts +32 -0
- package/dist/init/picker.d.ts.map +1 -0
- package/dist/init/picker.js +105 -0
- package/dist/init/picker.js.map +1 -0
- package/dist/init/templates.d.ts +25 -0
- package/dist/init/templates.d.ts.map +1 -0
- package/dist/init/templates.js +263 -0
- package/dist/init/templates.js.map +1 -0
- package/dist/mcp/index.d.ts +12 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +18 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/lookup.d.ts +27 -0
- package/dist/mcp/lookup.d.ts.map +1 -0
- package/dist/mcp/lookup.js +282 -0
- package/dist/mcp/lookup.js.map +1 -0
- package/dist/mcp/server.d.ts +41 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +388 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/suggest.d.ts +35 -0
- package/dist/mcp/suggest.d.ts.map +1 -0
- package/dist/mcp/suggest.js +268 -0
- package/dist/mcp/suggest.js.map +1 -0
- package/dist/parser/comment-strip.d.ts +15 -0
- package/dist/parser/comment-strip.d.ts.map +1 -0
- package/dist/parser/comment-strip.js +76 -0
- package/dist/parser/comment-strip.js.map +1 -0
- package/dist/parser/index.d.ts +10 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +9 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/normalize.d.ts +22 -0
- package/dist/parser/normalize.d.ts.map +1 -0
- package/dist/parser/normalize.js +42 -0
- package/dist/parser/normalize.js.map +1 -0
- package/dist/parser/parse-file.d.ts +18 -0
- package/dist/parser/parse-file.d.ts.map +1 -0
- package/dist/parser/parse-file.js +68 -0
- package/dist/parser/parse-file.js.map +1 -0
- package/dist/parser/parse-line.d.ts +21 -0
- package/dist/parser/parse-line.d.ts.map +1 -0
- package/dist/parser/parse-line.js +230 -0
- package/dist/parser/parse-line.js.map +1 -0
- package/dist/parser/parse-project.d.ts +31 -0
- package/dist/parser/parse-project.d.ts.map +1 -0
- package/dist/parser/parse-project.js +281 -0
- package/dist/parser/parse-project.js.map +1 -0
- package/dist/report/index.d.ts +6 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +6 -0
- package/dist/report/index.js.map +1 -0
- package/dist/report/mermaid.d.ts +15 -0
- package/dist/report/mermaid.d.ts.map +1 -0
- package/dist/report/mermaid.js +260 -0
- package/dist/report/mermaid.js.map +1 -0
- package/dist/report/report.d.ts +16 -0
- package/dist/report/report.d.ts.map +1 -0
- package/dist/report/report.js +211 -0
- package/dist/report/report.js.map +1 -0
- package/dist/tui/commands.d.ts +42 -0
- package/dist/tui/commands.d.ts.map +1 -0
- package/dist/tui/commands.js +1216 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/config.d.ts +27 -0
- package/dist/tui/config.d.ts.map +1 -0
- package/dist/tui/config.js +27 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/format.d.ts +63 -0
- package/dist/tui/format.d.ts.map +1 -0
- package/dist/tui/format.js +253 -0
- package/dist/tui/format.js.map +1 -0
- package/dist/tui/index.d.ts +18 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +470 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/input.d.ts +63 -0
- package/dist/tui/input.d.ts.map +1 -0
- package/dist/tui/input.js +454 -0
- package/dist/tui/input.js.map +1 -0
- package/dist/types/index.d.ts +254 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- 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
|