opencode-codex-security 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/DISCLOSURE +19 -0
  3. package/LICENSE +202 -0
  4. package/README.md +103 -2
  5. package/SECURITY.md +22 -0
  6. package/dist/gui-experimental.cjs +17 -0
  7. package/docs/GLOBAL-CONFIG.md +64 -0
  8. package/docs/MULTI-SCAN.md +62 -0
  9. package/docs/PUBLISHING.md +71 -0
  10. package/docs/RELEASING.md +53 -0
  11. package/docs/SDK.md +36 -0
  12. package/fixtures/vulnerable/docs/ARCHITECTURE.md +2 -0
  13. package/fixtures/vulnerable/src/accounts.js +4 -0
  14. package/fixtures/vulnerable/src/diagnostics.js +5 -0
  15. package/fixtures/vulnerable/src/download.js +5 -0
  16. package/fixtures/vulnerable/src/profile.js +4 -0
  17. package/package.json +79 -4
  18. package/src/core/codex-adapter.ts +430 -0
  19. package/src/core/credential-guard.ts +83 -0
  20. package/src/core/planned-scan.ts +316 -0
  21. package/src/core/report-safety.ts +41 -0
  22. package/src/core/result-summary.ts +512 -0
  23. package/src/core/run-history.ts +107 -0
  24. package/src/core/run-telemetry.ts +359 -0
  25. package/src/core/scan-plan.ts +182 -0
  26. package/src/core/scan-service.mjs +129 -0
  27. package/src/core/scanner.mjs +129 -0
  28. package/src/core/sdk-settings.ts +123 -0
  29. package/src/core/security-operations.ts +598 -0
  30. package/src/gui.tsx +102 -0
  31. package/src/operation-tools.ts +247 -0
  32. package/src/plan-view.ts +47 -0
  33. package/src/plugin.ts +346 -0
  34. package/src/rpc.ts +124 -0
  35. package/src/scan-display.ts +284 -0
  36. package/src/security-operations-panel.tsx +551 -0
  37. package/src/security-panel.tsx +1003 -0
  38. package/src/security-styles.ts +10 -0
  39. package/src/security-tui-operations.tsx +343 -0
  40. package/src/state-order.ts +21 -0
  41. package/src/tui.tsx +630 -0
  42. package/src/types.ts +84 -0
package/src/tui.tsx ADDED
@@ -0,0 +1,630 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from '@opencode/plugin/tui';
3
+ import { createSignal, createEffect, on, For, Show } from 'solid-js';
4
+ import { Security } from './rpc';
5
+ import { asState, type ScanList } from './types';
6
+ import type { ScanInputs, ScanPlan } from './core/scan-plan';
7
+ import {
8
+ describePlan,
9
+ isAbsoluteLocalDirectory,
10
+ latestAvailableDraft,
11
+ withScanMode,
12
+ } from './plan-view';
13
+ import { createTuiOperations } from './security-tui-operations';
14
+ import {
15
+ elapsedLabel,
16
+ runPhaseLabel,
17
+ hasPhaseProgress,
18
+ phaseContext,
19
+ deepProgressLabel,
20
+ findingsPendingLabel,
21
+ usageLabel,
22
+ usageContext,
23
+ activityRows,
24
+ workerActivityRows,
25
+ activityActor,
26
+ activityStatusLabel,
27
+ workerSummary,
28
+ dispatchLabel,
29
+ resultCountsLabel,
30
+ coverageLabel,
31
+ repositoryMatchingLabel,
32
+ progressLabel,
33
+ runTitle,
34
+ scanScope,
35
+ userError,
36
+ } from './scan-display';
37
+
38
+ export default Plugin.define({
39
+ id: 'gill.security.tui',
40
+ setup(ctx) {
41
+ const [catalog, setCatalog] = createSignal<ScanList>();
42
+ const [selected, select] = createSignal<string>();
43
+ const [details, setDetails] = createSignal(false);
44
+ const [error, setError] = createSignal('');
45
+ const [busy, setBusy] = createSignal(false);
46
+ const [now, setNow] = createSignal(Date.now());
47
+ const [nextInputs, setNextInputs] = createSignal<ScanInputs>({});
48
+ const [nextPlanId, setNextPlanId] = createSignal<string>();
49
+ let stopped = false,
50
+ polling = false;
51
+ const location = ctx.location ?? ctx.data.location.default();
52
+ const client = ctx.client.rpc(Security);
53
+ const runs = () => catalog()?.runs ?? [];
54
+ const current = () => runs().find((run) => run.runId === selected()) ?? runs()[0];
55
+ createEffect(
56
+ on(
57
+ () => current()?.runId,
58
+ () => setDetails(false),
59
+ ),
60
+ );
61
+ const open = () => ctx.ui.router.navigate({ type: 'plugin', name: 'security' });
62
+ const workspace = isAbsoluteLocalDirectory(ctx.location?.directory)
63
+ ? ctx.location!.directory
64
+ : undefined;
65
+ const chooseTarget = async (currentTarget = workspace): Promise<string | undefined> => {
66
+ const value = await ctx.ui.dialog.prompt({
67
+ title: 'Target repository',
68
+ description:
69
+ 'Choose an absolute local path. This changes only the next task, not the history workspace or global settings.',
70
+ value: currentTarget,
71
+ placeholder: '/path/to/project',
72
+ });
73
+ if (value === undefined || stopped) return;
74
+ if (!isAbsoluteLocalDirectory(value.trim()))
75
+ throw new Error('Choose an absolute local repository path before preparing a task.');
76
+ return value.trim();
77
+ };
78
+ const operationTarget = async () => workspace ?? chooseTarget();
79
+ const operations = createTuiOperations(ctx, location, current, open, operationTarget);
80
+ const active = () => runs().filter((run) => run.status === 'running').length;
81
+ const refresh = async () => {
82
+ if (stopped || polling) return;
83
+ polling = true;
84
+ try {
85
+ const next = (await client.list({}, { location })) as unknown as ScanList;
86
+ if (!stopped) setCatalog(next);
87
+ } catch (e) {
88
+ if (!stopped) setError(userError(e));
89
+ } finally {
90
+ polling = false;
91
+ }
92
+ };
93
+ const review = (plan: ScanPlan) =>
94
+ new Promise<boolean>((resolve) => {
95
+ let settled = false;
96
+ const finish = (approved: boolean) => {
97
+ if (settled) return;
98
+ settled = true;
99
+ resolve(approved);
100
+ ctx.ui.dialog.clear();
101
+ };
102
+ const Review = () => {
103
+ const [advanced, setAdvanced] = createSignal(false);
104
+ ctx.keymap.layer(() => ({
105
+ mode: 'global',
106
+ priority: 1000,
107
+ commands: [
108
+ { bind: 'ctrl+y', title: 'Approve and start', run: () => finish(true) },
109
+ {
110
+ bind: 'ctrl+d',
111
+ title: 'Toggle advanced details',
112
+ run: () => setAdvanced((v) => !v),
113
+ },
114
+ { bind: 'escape', title: 'Back without starting', run: () => finish(false) },
115
+ ],
116
+ }));
117
+ return (
118
+ <box flexDirection="column" padding={1} gap={1}>
119
+ <text fg="#9be4c6">{plan.mock ? 'Review mock scan' : 'Review this scan'}</text>
120
+ <scrollbox height={Math.max(6, Math.min(22, ctx.renderer.height - 12))} focused>
121
+ <box flexDirection="column" gap={1}>
122
+ <text>Repository: {plan.repository}</text>
123
+ <text>
124
+ Scope: {plan.scopePaths.join(', ') || 'Whole repository'} · Depth:{' '}
125
+ {plan.settings.scan.mode}
126
+ </text>
127
+ <Show when={plan.scanPrompt}>
128
+ <text>Focus: {plan.scanPrompt}</text>
129
+ </Show>
130
+ <Show when={plan.contextFiles.length}>
131
+ <text>Context: {plan.contextFiles.map((file) => file.path).join(', ')}</text>
132
+ </Show>
133
+ <Show
134
+ when={!plan.mock}
135
+ fallback={
136
+ <text>Mock SDK responses only. No model calls or source uploads.</text>
137
+ }
138
+ >
139
+ <text>
140
+ Repository source and listed context will be sent to OpenAI. Results stay
141
+ local.
142
+ </text>
143
+ <text>
144
+ Estimated stop: USD {plan.settings.scan.maxCostUsd} for this scan. Not a hard
145
+ cap; in-flight usage may exceed it.
146
+ </text>
147
+ <text>
148
+ Conversation history is not copied. Scope guides the scan; it is not a
149
+ filesystem sandbox.
150
+ </text>
151
+ </Show>
152
+ <Show when={advanced()}>
153
+ <text>{describePlan(plan)}</text>
154
+ </Show>
155
+ </box>
156
+ </scrollbox>
157
+ <text>
158
+ Ctrl+Y: {plan.mock ? 'start mock scan' : 'approve and start'} · Ctrl+D:{' '}
159
+ {advanced() ? 'hide' : 'show'} settings · Esc: back
160
+ </text>
161
+ </box>
162
+ );
163
+ };
164
+ ctx.ui.dialog.show(
165
+ () => <Review />,
166
+ () => {
167
+ if (!settled) {
168
+ settled = true;
169
+ resolve(false);
170
+ }
171
+ },
172
+ );
173
+ ctx.ui.dialog.set({ size: 'xlarge', centered: true });
174
+ });
175
+ const start = async () => {
176
+ if (busy() || stopped) return;
177
+ setBusy(true);
178
+ setError('');
179
+ try {
180
+ await refresh();
181
+ if (active() >= (catalog()?.maxConcurrent ?? 2))
182
+ throw new Error('All scan slots are in use. Start another scan after one finishes.');
183
+ let input = {};
184
+ if (!catalog()?.engine?.startsWith('local-patterns')) {
185
+ const draft = latestAvailableDraft(catalog(), Date.now(), nextPlanId());
186
+ let prepared = draft;
187
+ if (!prepared) {
188
+ const repository = nextInputs().repository || workspace || (await chooseTarget());
189
+ if (!repository || stopped) return;
190
+ prepared = asState(await client.prepare({ ...nextInputs(), repository }, { location }));
191
+ }
192
+ const plan = prepared.plan!;
193
+ if (!plan) throw new Error('No scan preview returned. Nothing was started.');
194
+ if (!(await review(plan)) || stopped) return;
195
+ input = { planId: plan.id, approveOpenAIInference: true };
196
+ }
197
+ const result = asState(await client.start(input, { location }));
198
+ if (!stopped) {
199
+ select(result.runId);
200
+ setNextInputs({});
201
+ setNextPlanId(undefined);
202
+ await refresh();
203
+ }
204
+ } catch (e) {
205
+ if (!stopped) setError(userError(e));
206
+ } finally {
207
+ if (!stopped) setBusy(false);
208
+ }
209
+ };
210
+ const chooseRun = async () => {
211
+ await refresh();
212
+ if (!runs().length) {
213
+ await ctx.ui.dialog.alert({
214
+ title: 'Scans',
215
+ message: 'No scans yet. Use /security-scan to review and start one.',
216
+ });
217
+ return;
218
+ }
219
+ const id = await ctx.ui.dialog.select({
220
+ title: 'Scans recorded in this workspace',
221
+ current: current()?.runId,
222
+ options: runs().map((run) => ({
223
+ title: `${run.status.toUpperCase()} · ${runTitle(run)}`,
224
+ description: `${run.repository} · ${scanScope(run)} · ${run.mode ?? 'fixture'} · ${run.runId?.slice(0, 8)}`,
225
+ value: run.runId!,
226
+ })),
227
+ });
228
+ if (id && !stopped) select(id);
229
+ };
230
+ const cancel = async () => {
231
+ const run = current();
232
+ if (!run?.runId || run.status !== 'running') {
233
+ await ctx.ui.dialog.alert({
234
+ title: 'Cancel scan',
235
+ message: 'Select a running scan with /security-scans first.',
236
+ });
237
+ return;
238
+ }
239
+ if (busy()) return;
240
+ setBusy(true);
241
+ setError('');
242
+ try {
243
+ await client.cancel({ runId: run.runId }, { location });
244
+ await refresh();
245
+ } catch (e) {
246
+ if (!stopped) setError(userError(e));
247
+ } finally {
248
+ if (!stopped) setBusy(false);
249
+ }
250
+ };
251
+ void refresh();
252
+ const timer = setInterval(() => {
253
+ setNow(Date.now());
254
+ void refresh();
255
+ void operations.refresh();
256
+ }, 1500);
257
+ const view = () => (
258
+ <scrollbox height="100%" focused>
259
+ <box flexDirection="column" padding={1} gap={1}>
260
+ <text fg="#9be4c6">Security</text>
261
+ <text fg="#b5b5b5">History workspace: {location.directory}</text>
262
+ <Show when={!workspace}>
263
+ <text>No active local workspace verified. Choose a target before preparing work.</text>
264
+ </Show>
265
+ <text>
266
+ {active()
267
+ ? `${active()} of ${catalog()?.maxConcurrent ?? 2} scans running`
268
+ : 'Review code, then follow each scan here.'}
269
+ </text>
270
+ <text fg="#b5b5b5">
271
+ /security-scan: new · /security-scans: switch · Ctrl+D: details · Esc: back
272
+ </text>
273
+ <Show when={operations.questionCount()}>
274
+ <text fg="#9be4c6">
275
+ {operations.questionCount()} operation(s) need your answer. /security-operations to
276
+ select.
277
+ </text>
278
+ </Show>
279
+ <Show when={operations.visible()} fallback={<></>}>
280
+ {operations.view()}
281
+ </Show>
282
+ <Show when={!operations.visible()}>
283
+ <Show when={error()}>
284
+ <text fg="#ffb3b3">{error()}</text>
285
+ </Show>
286
+ <Show when={catalog()?.historyError}>
287
+ <text fg="#ffb3b3">{catalog()?.historyError}</text>
288
+ </Show>
289
+ <Show when={details() && runs().length}>
290
+ <box flexDirection="column" gap={0}>
291
+ <text>Global settings share defaults, not a target or combined history.</text>
292
+ <text>RECENT SCANS IN THIS WORKSPACE</text>
293
+ <For each={runs().slice(0, 6)}>
294
+ {(run) => (
295
+ <text fg={run.runId === current()?.runId ? '#9be4c6' : undefined}>
296
+ {run.runId === current()?.runId ? '> ' : ' '}
297
+ {run.status.toUpperCase()} · {runTitle(run)} · {run.mode ?? 'fixture'} ·{' '}
298
+ {run.runId?.slice(0, 8)} · {run.repository}
299
+ </text>
300
+ )}
301
+ </For>
302
+ </box>
303
+ </Show>
304
+ <Show
305
+ when={current()}
306
+ fallback={
307
+ <text>
308
+ No scans yet. Preparation makes no model calls. Each scan starts only after
309
+ approval.
310
+ </text>
311
+ }
312
+ >
313
+ {(run) => (
314
+ <box flexDirection="column" gap={1}>
315
+ <text fg="#9be4c6">{runTitle(run())}</text>
316
+ <text fg="#b5b5b5">Target: {run().repository}</text>
317
+ <text fg="#b5b5b5">
318
+ {scanScope(run())} · {run().mode ?? 'Fixture pattern checks'}
319
+ </text>
320
+ <text>
321
+ <b>{runPhaseLabel(run())}</b>
322
+ <Show when={elapsedLabel(run().startedAt, run().finishedAt, now())}>
323
+ {(elapsed) => <> · {elapsed()}</>}
324
+ </Show>
325
+ </text>
326
+ <Show when={hasPhaseProgress(run())}>
327
+ <text>
328
+ {progressLabel(run())} · {phaseContext(run())}
329
+ </text>
330
+ </Show>
331
+ <Show when={run().status === 'running'}>
332
+ <text>{findingsPendingLabel(run())}</text>
333
+ <text fg="#b5b5b5">/security-cancel: stop this scan</text>
334
+ </Show>
335
+ <text>{usageLabel(run())}</text>
336
+ <Show when={run().error}>
337
+ <text fg="#ffb3b3">{run().error}</text>
338
+ </Show>
339
+ <Show when={run().status === 'cancelled'}>
340
+ <text>Cancelled. This scan is incomplete, not a clean result.</text>
341
+ </Show>
342
+ <Show when={run().status === 'completed'}>
343
+ <text>
344
+ <b>{resultCountsLabel(run())}</b> · /security-findings: evidence
345
+ </text>
346
+ </Show>
347
+ <For each={run().findings.slice(0, 5)}>
348
+ {(finding) => (
349
+ <text>
350
+ <span
351
+ style={{
352
+ fg:
353
+ finding.severity === 'high' || finding.severity === 'critical'
354
+ ? '#ffb3b3'
355
+ : '#ebc28c',
356
+ }}
357
+ >
358
+ {finding.severity.toUpperCase()}
359
+ </span>{' '}
360
+ · {finding.title}
361
+ </text>
362
+ )}
363
+ </For>
364
+ <Show when={run().status === 'completed' && !run().findings.length}>
365
+ <text fg="#b5b5b5">
366
+ No findings does not guarantee security. Check coverage in details.
367
+ </text>
368
+ </Show>
369
+ <text fg="#b5b5b5">
370
+ Ctrl+D: {details() ? 'hide' : 'show'} activity, coverage and scan details
371
+ </text>
372
+ <Show when={details()}>
373
+ <text>SCAN DETAILS</text>
374
+ <text>{run().repository}</text>
375
+ <text>Run: {run().runId}</text>
376
+ <Show when={run().telemetry?.measurement?.coverage !== 'complete'}>
377
+ <text>{usageContext(run())}</text>
378
+ </Show>
379
+ <Show when={workerSummary(run())}>
380
+ <text>{workerSummary(run())}</text>
381
+ </Show>
382
+ <Show when={dispatchLabel(run())}>
383
+ <text>{dispatchLabel(run())}</text>
384
+ </Show>
385
+ <Show when={run().status === 'running' && run().deepProgress}>
386
+ <text>{deepProgressLabel(run())}. The cap is not a completion target.</text>
387
+ </Show>
388
+ <Show when={run().status === 'completed'}>
389
+ <text>{coverageLabel(run())}</text>
390
+ <text>Repository matching: {repositoryMatchingLabel(run())}</text>
391
+ <text>
392
+ Coverage is reported metadata, not proof of exhaustive review. See local SDK
393
+ artifacts for details.
394
+ </text>
395
+ </Show>
396
+ <text>ACTIVITY · latest reported</text>
397
+ <Show
398
+ when={activityRows(run()).length}
399
+ fallback={<text>No detailed SDK activity recorded.</text>}
400
+ >
401
+ <For each={workerActivityRows(run()).slice(0, 5)}>
402
+ {(activity) => (
403
+ <text>
404
+ {activityActor(activity.worker)} · {activity.label} ·{' '}
405
+ {activityStatusLabel(activity.status, run())}
406
+ </text>
407
+ )}
408
+ </For>
409
+ </Show>
410
+ <Show when={run().events.length}>
411
+ <text>RECENT MILESTONES</text>
412
+ <For each={[...run().events].reverse().slice(0, 3)}>
413
+ {(event) => (
414
+ <text>
415
+ {new Date(event.time).toLocaleTimeString()} · {event.message}
416
+ </text>
417
+ )}
418
+ </For>
419
+ </Show>
420
+ </Show>
421
+ </box>
422
+ )}
423
+ </Show>
424
+ </Show>
425
+ </box>
426
+ </scrollbox>
427
+ );
428
+ const unregister = ctx.ui.router.register({ name: 'security', render: view });
429
+ ctx.keymap.layer(() => ({
430
+ mode: 'global',
431
+ commands: [
432
+ ...operations.commands,
433
+ {
434
+ id: 'security.details',
435
+ title: 'Security: toggle scan details',
436
+ bind: 'ctrl+d',
437
+ group: 'Security',
438
+ palette: true,
439
+ slash: { name: 'security-details' },
440
+ enabled: () => {
441
+ const route = ctx.ui.router.current();
442
+ return (
443
+ !busy() &&
444
+ !operations.visible() &&
445
+ !!current() &&
446
+ route.type === 'plugin' &&
447
+ route.name === 'security'
448
+ );
449
+ },
450
+ run: () => {
451
+ setDetails((value) => !value);
452
+ },
453
+ },
454
+ {
455
+ id: 'security.back',
456
+ title: 'Security: return home',
457
+ bind: 'escape',
458
+ enabled: () => {
459
+ const route = ctx.ui.router.current();
460
+ return !busy() && route.type === 'plugin' && route.name === 'security';
461
+ },
462
+ run: () => ctx.ui.router.navigate({ type: 'home' }),
463
+ },
464
+ {
465
+ id: 'security.open',
466
+ title: 'Security: open scans',
467
+ group: 'Security',
468
+ palette: true,
469
+ slash: { name: 'security' },
470
+ run: () => {
471
+ operations.close();
472
+ open();
473
+ },
474
+ },
475
+ {
476
+ id: 'security.scan',
477
+ title: 'Security: new scan',
478
+ group: 'Security',
479
+ palette: true,
480
+ slash: { name: 'security-scan' },
481
+ run: async () => {
482
+ operations.close();
483
+ open();
484
+ await start();
485
+ },
486
+ },
487
+ {
488
+ id: 'security.runs',
489
+ title: 'Security: choose a scan',
490
+ group: 'Security',
491
+ palette: true,
492
+ slash: { name: 'security-scans' },
493
+ run: async () => {
494
+ operations.close();
495
+ open();
496
+ await chooseRun();
497
+ },
498
+ },
499
+ {
500
+ id: 'security.cancel',
501
+ title: 'Security: cancel selected scan',
502
+ group: 'Security',
503
+ palette: true,
504
+ slash: { name: 'security-cancel' },
505
+ run: cancel,
506
+ },
507
+ {
508
+ id: 'security.target',
509
+ title: 'Security: choose next scan target',
510
+ group: 'Security',
511
+ palette: true,
512
+ slash: { name: 'security-target' },
513
+ run: async () => {
514
+ if (busy()) return;
515
+ setBusy(true);
516
+ setError('');
517
+ try {
518
+ const repository = await chooseTarget(workspace);
519
+ if (!repository || stopped) return;
520
+ // A different target must never inherit another repository's scope or context files.
521
+ const input: ScanInputs = { repository };
522
+ setNextInputs(input);
523
+ setNextPlanId('pending');
524
+ const prepared = asState(await client.prepare(input, { location }));
525
+ if (!prepared.plan) throw new Error('No scan preview returned. Nothing was started.');
526
+ setNextPlanId(prepared.plan.id);
527
+ await refresh();
528
+ operations.close();
529
+ open();
530
+ } catch (e) {
531
+ if (!stopped) setError(userError(e));
532
+ } finally {
533
+ if (!stopped) setBusy(false);
534
+ }
535
+ },
536
+ },
537
+ {
538
+ id: 'security.mode',
539
+ title: 'Security: choose next scan depth',
540
+ group: 'Security',
541
+ palette: true,
542
+ slash: { name: 'security-mode' },
543
+ run: async () => {
544
+ if (busy()) return;
545
+ setBusy(true);
546
+ setError('');
547
+ try {
548
+ const mode = await ctx.ui.dialog.select<'default' | 'standard' | 'deep'>({
549
+ title: 'Depth for the next scan',
550
+ current: nextInputs().mode ?? 'default',
551
+ options: [
552
+ {
553
+ title: 'Global default',
554
+ description: 'Use the configured depth.',
555
+ value: 'default',
556
+ },
557
+ { title: 'Standard', description: 'Standard security scan.', value: 'standard' },
558
+ {
559
+ title: 'Deep',
560
+ description: 'Additional work; can take longer and cost more.',
561
+ value: 'deep',
562
+ },
563
+ ],
564
+ });
565
+ if (!mode || stopped) return;
566
+ const draft = latestAvailableDraft(catalog(), Date.now(), nextPlanId())?.plan;
567
+ const input = withScanMode(draft?.inputs ?? nextInputs(), mode);
568
+ input.repository ||= draft?.repository || workspace || (await chooseTarget());
569
+ if (!input.repository || stopped) return;
570
+ setNextInputs(input);
571
+ setNextPlanId('pending');
572
+ const prepared = asState(await client.prepare(input, { location }));
573
+ if (!prepared.plan) throw new Error('No scan preview returned. Nothing was started.');
574
+ setNextPlanId(prepared.plan.id);
575
+ await refresh();
576
+ open();
577
+ } catch (e) {
578
+ if (!stopped) setError(userError(e));
579
+ } finally {
580
+ if (!stopped) setBusy(false);
581
+ }
582
+ },
583
+ },
584
+ {
585
+ id: 'security.findings',
586
+ title: 'Security: inspect selected scan findings',
587
+ group: 'Security',
588
+ palette: true,
589
+ slash: { name: 'security-findings' },
590
+ run: async () => {
591
+ const run = current(),
592
+ found = run?.findings ?? [];
593
+ if (!found.length) {
594
+ await ctx.ui.dialog.alert({
595
+ title: 'Findings',
596
+ message:
597
+ run?.status === 'running'
598
+ ? 'This scan is still running. Findings are available at completion.'
599
+ : run?.status === 'completed'
600
+ ? 'No findings were reported by this completed scan. This is not a guarantee of security.'
601
+ : 'Select a completed scan with /security-scans to inspect its findings.',
602
+ });
603
+ return;
604
+ }
605
+ const id = await ctx.ui.dialog.select({
606
+ title: 'Findings',
607
+ options: found.map((f) => ({
608
+ title: `${f.severity.toUpperCase()} ${f.title}`,
609
+ description: `${f.path}:${f.line}`,
610
+ value: f.id,
611
+ })),
612
+ });
613
+ const f = found.find((f) => f.id === id);
614
+ if (f)
615
+ await ctx.ui.dialog.alert({
616
+ title: f.title,
617
+ message: `${f.path}:${f.line} · ${f.cwe}\n\n${f.summary}\n\n${f.evidence}\n\nSuggested next step: ${f.remediation}\n\nConfidence: ${f.confidence}`,
618
+ });
619
+ },
620
+ },
621
+ ],
622
+ }));
623
+ return () => {
624
+ stopped = true;
625
+ clearInterval(timer);
626
+ operations.dispose();
627
+ unregister();
628
+ };
629
+ },
630
+ });