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