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
@@ -0,0 +1,1003 @@
1
+ import { createMemo, createSignal, createEffect, on, For, Show, onCleanup } from 'solid-js';
2
+ import { createStore, reconcile } from 'solid-js/store';
3
+ import { asState, type ScanState } from './types';
4
+ import type { ScanInputs, ScanPlan } from './core/scan-plan';
5
+ import { describePlan, latestAvailableDraft } from './plan-view';
6
+ import {
7
+ elapsedLabel,
8
+ findingText,
9
+ runPhaseLabel,
10
+ phaseCounts,
11
+ phaseContext,
12
+ hasPhaseProgress,
13
+ hasDeterminateProgress,
14
+ deepProgressLabel,
15
+ findingsPendingLabel,
16
+ usageLabel,
17
+ usageContext,
18
+ usageRangeLabel,
19
+ activityRows,
20
+ workerActivityRows,
21
+ activityActor,
22
+ activityStatusLabel,
23
+ workerSummary,
24
+ dispatchLabel,
25
+ reconnectLabel,
26
+ resultCountsLabel,
27
+ coverageLabel,
28
+ coverageDetails,
29
+ repositoryMatchingLabel,
30
+ progressLabel,
31
+ projectName,
32
+ runTitle,
33
+ scanScope,
34
+ splitPaths,
35
+ userError,
36
+ } from './scan-display';
37
+ import {
38
+ SecurityOperationsPanel,
39
+ type OperationPort,
40
+ type ValidationSeed,
41
+ } from './security-operations-panel';
42
+ import { securityStyles } from './security-styles';
43
+
44
+ export interface SecurityPort {
45
+ list(): Promise<unknown>;
46
+ prepare(input: ScanInputs): Promise<unknown>;
47
+ start(input: { planId?: string; approveOpenAIInference?: boolean }): Promise<unknown>;
48
+ cancel(input: { runId: string }): Promise<unknown>;
49
+ }
50
+ interface Catalog {
51
+ engine?: string;
52
+ instanceId?: string;
53
+ revision?: number;
54
+ runs: ScanState[];
55
+ drafts: ScanState[];
56
+ preview?: ScanState;
57
+ maxConcurrent: number;
58
+ historyError?: string;
59
+ }
60
+ function readCatalog(value: unknown): Catalog {
61
+ if (!value || typeof value !== 'object' || !('runs' in value) || !Array.isArray(value.runs))
62
+ throw new Error(
63
+ 'Could not read scan history. Reopen Security after updating the server plugin.',
64
+ );
65
+ const catalog = value as Catalog;
66
+ return {
67
+ ...catalog,
68
+ runs: catalog.runs.map(asState),
69
+ drafts: (catalog.drafts ?? []).map(asState),
70
+ maxConcurrent: catalog.maxConcurrent ?? 2,
71
+ };
72
+ }
73
+ const clockTime = (value: string) =>
74
+ new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
75
+ const startedTime = (value: string | null) =>
76
+ value ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
77
+
78
+ /** Actual Desktop panel, also mounted by the isolated mock visual harness. */
79
+ export function SecurityPanel(props: {
80
+ directory: string;
81
+ port: SecurityPort;
82
+ operations?: OperationPort;
83
+ pollMs?: number;
84
+ }) {
85
+ const [catalogStore, updateCatalog] = createStore<Catalog>({
86
+ runs: [],
87
+ drafts: [],
88
+ maxConcurrent: 2,
89
+ });
90
+ const catalog = () => catalogStore;
91
+ const setCatalog = (next: Catalog | ((previous: Catalog) => Catalog)) =>
92
+ updateCatalog(
93
+ reconcile(typeof next === 'function' ? next(catalogStore) : next, {
94
+ key: 'runId',
95
+ merge: true,
96
+ }),
97
+ );
98
+ const [loaded, setLoaded] = createSignal(false);
99
+ const [view, setView] = createSignal<'scans' | 'policy' | 'validation'>('scans');
100
+ const [pendingQuestions, setPendingQuestions] = createSignal(0);
101
+ const [validationSeed, setValidationSeed] = createSignal<ValidationSeed>();
102
+ const [selectedId, selectRun] = createSignal<string>();
103
+ const [composer, setComposer] = createSignal(true);
104
+ const [preview, setPreview] = createSignal<ScanPlan>();
105
+ const [repository, setRepository] = createSignal(props.directory);
106
+ const [mode, setMode] = createSignal<'default' | 'standard' | 'deep'>('default');
107
+ const [focus, setFocus] = createSignal('');
108
+ const [paths, setPaths] = createSignal('');
109
+ const [documents, setDocuments] = createSignal('');
110
+ const [busy, setBusy] = createSignal<'prepare' | 'start' | 'cancel' | null>(null);
111
+ const [error, setError] = createSignal('');
112
+ const [connectionError, setConnectionError] = createSignal('');
113
+ const [now, setNow] = createSignal(Date.now());
114
+ let detailElement: HTMLElement | undefined;
115
+ let historyElement: HTMLDetailsElement | undefined;
116
+ let stopped = false,
117
+ refreshing = false,
118
+ touched = false,
119
+ refreshSequence = 0;
120
+ let acceptedSequence = 0,
121
+ draftRevision = 0;
122
+ const activeCount = createMemo(() => catalog().runs.filter((r) => r.status === 'running').length);
123
+ const current = createMemo(
124
+ () => catalog().runs.find((r) => r.runId === selectedId()) ?? catalog().runs[0],
125
+ );
126
+ createEffect(
127
+ on(
128
+ () => current()?.runId,
129
+ () => {
130
+ detailElement
131
+ ?.querySelectorAll('details[open]')
132
+ .forEach((detail) => detail.removeAttribute('open'));
133
+ if (historyElement) historyElement.open = false;
134
+ },
135
+ ),
136
+ );
137
+ const availableDraft = createMemo(() => latestAvailableDraft(catalog(), now()));
138
+ const fixture = createMemo(
139
+ () =>
140
+ catalog().engine?.startsWith('local-patterns') ||
141
+ catalog().runs[0]?.engine?.startsWith('local-patterns') ||
142
+ catalog().drafts[0]?.engine?.startsWith('local-patterns'),
143
+ );
144
+ const refresh = async () => {
145
+ if (refreshing || stopped) return;
146
+ refreshing = true;
147
+ const sequence = ++refreshSequence;
148
+ try {
149
+ const next = readCatalog(await props.port.list());
150
+ if (stopped || sequence < acceptedSequence) return;
151
+ acceptedSequence = sequence;
152
+ setCatalog(next);
153
+ setConnectionError('');
154
+ if (props.operations) {
155
+ try {
156
+ const result = (await props.operations.list()) as { operations?: { status: string }[] };
157
+ if (!stopped)
158
+ setPendingQuestions(
159
+ (result.operations ?? []).filter((op) => op.status === 'awaiting_answers').length,
160
+ );
161
+ } catch {
162
+ /* A secondary operation refresh must not hide scan state. */
163
+ }
164
+ }
165
+ if (!loaded()) {
166
+ setLoaded(true);
167
+ if (!touched && next.runs.length) setComposer(false);
168
+ }
169
+ } catch (e) {
170
+ if (!stopped) setConnectionError(userError(e));
171
+ } finally {
172
+ refreshing = false;
173
+ }
174
+ };
175
+ const edit = () => {
176
+ draftRevision++;
177
+ touched = true;
178
+ setPreview(undefined);
179
+ };
180
+ const openComposer = () => {
181
+ edit();
182
+ setRepository(props.directory);
183
+ setMode('default');
184
+ setFocus('');
185
+ setPaths('');
186
+ setDocuments('');
187
+ setComposer(true);
188
+ setError('');
189
+ };
190
+ const adoptDraft = (plan: ScanPlan) => {
191
+ edit();
192
+ setRepository(plan.repository);
193
+ setMode(plan.inputs.mode ?? 'default');
194
+ setFocus(plan.scanPrompt);
195
+ setPaths(plan.scopePaths.join('\n'));
196
+ setDocuments(plan.contextFiles.map((f) => f.path).join('\n'));
197
+ setPreview(plan);
198
+ setComposer(true);
199
+ setError('');
200
+ };
201
+ const prepare = async (event: SubmitEvent) => {
202
+ event.preventDefault();
203
+ if (busy() || stopped) return;
204
+ setBusy('prepare');
205
+ setError('');
206
+ const preparingRevision = draftRevision;
207
+ try {
208
+ const input: ScanInputs = {
209
+ repository: repository().trim() || props.directory,
210
+ scanPrompt: focus(),
211
+ scopePaths: splitPaths(paths()),
212
+ contextFiles: splitPaths(documents()),
213
+ ...(mode() !== 'default' ? { mode: mode() as 'standard' | 'deep' } : {}),
214
+ };
215
+ const next = asState(await props.port.prepare(input));
216
+ if (stopped) return;
217
+ if (!next.plan)
218
+ throw new Error('The server did not return a scan preview. Nothing was started.');
219
+ if (draftRevision === preparingRevision) setPreview(next.plan);
220
+ await refresh();
221
+ } catch (e) {
222
+ if (!stopped) setError(userError(e));
223
+ } finally {
224
+ if (!stopped) setBusy(null);
225
+ }
226
+ };
227
+ const start = async () => {
228
+ if (busy() || stopped || (!preview() && !fixture())) return;
229
+ setBusy('start');
230
+ setError('');
231
+ try {
232
+ const plan = preview();
233
+ const run = asState(
234
+ await props.port.start(plan ? { planId: plan.id, approveOpenAIInference: true } : {}),
235
+ );
236
+ if (stopped) return;
237
+ acceptedSequence = ++refreshSequence;
238
+ selectRun(run.runId);
239
+ setComposer(false);
240
+ setPreview(undefined);
241
+ setCatalog((old) => ({
242
+ ...old,
243
+ runs: [run, ...old.runs.filter((r) => r.runId !== run.runId)],
244
+ }));
245
+ await refresh();
246
+ } catch (e) {
247
+ if (!stopped) setError(userError(e));
248
+ } finally {
249
+ if (!stopped) setBusy(null);
250
+ }
251
+ };
252
+ const cancel = async (run: ScanState) => {
253
+ if (busy() || stopped || !run.runId) return;
254
+ setBusy('cancel');
255
+ setError('');
256
+ try {
257
+ await props.port.cancel({ runId: run.runId });
258
+ if (!stopped) await refresh();
259
+ } catch (e) {
260
+ if (!stopped) setError(userError(e));
261
+ } finally {
262
+ if (!stopped) setBusy(null);
263
+ }
264
+ };
265
+ void refresh();
266
+ const timer = setInterval(() => {
267
+ setNow(Date.now());
268
+ void refresh();
269
+ }, props.pollMs ?? 1500);
270
+ onCleanup(() => {
271
+ stopped = true;
272
+ clearInterval(timer);
273
+ });
274
+
275
+ return (
276
+ <section class="gill-security" aria-label="Security scans">
277
+ <style>{securityStyles}</style>
278
+ <div class="gs-body">
279
+ <header class="gs-header">
280
+ <div>
281
+ <h2>Security</h2>
282
+ <p class="gs-muted">
283
+ Workspace · {projectName(props.directory)}
284
+ {activeCount() > 0 ? ` · ${activeCount()} running` : ''}
285
+ </p>
286
+ </div>
287
+ <Show when={view() === 'scans' && !composer()}>
288
+ <button class="gs-button gs-primary" onClick={openComposer}>
289
+ New scan
290
+ </button>
291
+ </Show>
292
+ </header>
293
+ <details class="gs-workspace">
294
+ <summary>Workspace and history</summary>
295
+ <p class="gs-id">{props.directory}</p>
296
+ <p>History belongs to this workspace, including runs with a different target.</p>
297
+ <p>
298
+ Global settings share model and scan defaults, not a scan target or combined history.
299
+ </p>
300
+ </details>
301
+ <Show when={props.operations && loaded() && !fixture()}>
302
+ <nav class="gs-tabs" aria-label="Security actions">
303
+ <button
304
+ aria-current={view() === 'scans' ? 'page' : undefined}
305
+ onClick={() => setView('scans')}
306
+ >
307
+ Scans
308
+ </button>
309
+ <Show when={props.operations?.capabilities?.policy ?? !!props.operations}>
310
+ <button
311
+ aria-current={view() === 'policy' ? 'page' : undefined}
312
+ onClick={() => setView('policy')}
313
+ >
314
+ Policy{pendingQuestions() ? ` · ${pendingQuestions()} waiting` : ''}
315
+ </button>
316
+ </Show>
317
+ <Show when={props.operations?.capabilities?.validation ?? !!props.operations}>
318
+ <button
319
+ aria-current={view() === 'validation' ? 'page' : undefined}
320
+ onClick={() => {
321
+ setValidationSeed(undefined);
322
+ setView('validation');
323
+ }}
324
+ >
325
+ Validate
326
+ </button>
327
+ </Show>
328
+ </nav>
329
+ </Show>
330
+ <Show when={view() !== 'scans' ? view() : undefined} keyed>
331
+ {(kind) => (
332
+ <SecurityOperationsPanel
333
+ kind={kind as 'policy' | 'validation'}
334
+ directory={props.directory}
335
+ port={props.operations!}
336
+ seed={validationSeed()}
337
+ />
338
+ )}
339
+ </Show>
340
+ <Show when={view() === 'scans'}>
341
+ <Show when={pendingQuestions()}>
342
+ <div class="gs-pending">
343
+ <span>A policy draft needs your input.</span>
344
+ <button class="gs-button" onClick={() => setView('policy')}>
345
+ Answer questions
346
+ </button>
347
+ </div>
348
+ </Show>
349
+ <Show when={connectionError()}>
350
+ <div class="gs-notice gs-error" role="alert">
351
+ {connectionError()}{' '}
352
+ <button class="gs-link" onClick={() => void refresh()}>
353
+ Retry connection
354
+ </button>
355
+ </div>
356
+ </Show>
357
+ <Show when={catalog().historyError}>
358
+ <p class="gs-notice gs-error" role="alert">
359
+ {catalog().historyError}
360
+ </p>
361
+ </Show>
362
+ <Show when={error()}>
363
+ <p class="gs-notice gs-error" role="alert">
364
+ {error()}
365
+ </p>
366
+ </Show>
367
+ <Show when={!composer() && availableDraft()?.plan}>
368
+ {(_plan) => (
369
+ <div class="gs-pending">
370
+ <span>Prepared target: {availableDraft()?.plan?.repository}</span>
371
+ <button class="gs-button" onClick={() => adoptDraft(availableDraft()!.plan!)}>
372
+ Review draft
373
+ </button>
374
+ </div>
375
+ )}
376
+ </Show>
377
+ <Show when={composer()}>
378
+ <section class="gs-form" aria-label="New scan">
379
+ <div class="gs-section-heading">
380
+ <h3>New scan</h3>
381
+ <Show when={catalog().runs.length}>
382
+ <button class="gs-link" disabled={!!busy()} onClick={() => setComposer(false)}>
383
+ Close
384
+ </button>
385
+ </Show>
386
+ </div>
387
+ <Show
388
+ when={!fixture()}
389
+ fallback={
390
+ <>
391
+ <p class="gs-muted gs-small">
392
+ Synthetic fixture test. Local pattern matching only, with no source upload or
393
+ model calls.
394
+ </p>
395
+ <button
396
+ class="gs-button gs-primary"
397
+ disabled={!!busy()}
398
+ onClick={() => void start()}
399
+ >
400
+ Scan fixture
401
+ </button>
402
+ </>
403
+ }
404
+ >
405
+ <Show
406
+ when={preview()}
407
+ fallback={
408
+ <form onSubmit={(event) => void prepare(event)}>
409
+ <Show when={availableDraft()?.plan}>
410
+ <button
411
+ type="button"
412
+ class="gs-link"
413
+ onClick={() => adoptDraft(availableDraft()!.plan!)}
414
+ >
415
+ Review prepared scan · {availableDraft()?.plan?.repository}
416
+ </button>
417
+ </Show>
418
+ <label>
419
+ Target repository
420
+ <input
421
+ value={repository()}
422
+ onInput={(e) => {
423
+ edit();
424
+ setRepository(e.currentTarget.value);
425
+ setPaths('');
426
+ setDocuments('');
427
+ }}
428
+ required
429
+ aria-label="Repository"
430
+ spellcheck={false}
431
+ />
432
+ <span class="gs-help">
433
+ Defaults to this workspace. Changing the target clears scope and context
434
+ files.
435
+ </span>
436
+ </label>
437
+ <label>
438
+ Scan depth
439
+ <select
440
+ value={mode()}
441
+ onChange={(e) => {
442
+ edit();
443
+ setMode(e.currentTarget.value as 'default' | 'standard' | 'deep');
444
+ }}
445
+ aria-label="Scan depth"
446
+ >
447
+ <option value="default">Use global default</option>
448
+ <option value="standard">Standard</option>
449
+ <option value="deep">Deep</option>
450
+ </select>
451
+ <span class="gs-help">
452
+ Deep can take longer and cost more. This choice applies only to this scan.
453
+ </span>
454
+ </label>
455
+ <label>
456
+ What should this scan focus on? <span class="gs-muted">Optional</span>
457
+ <textarea
458
+ value={focus()}
459
+ maxLength={8000}
460
+ onInput={(e) => {
461
+ edit();
462
+ setFocus(e.currentTarget.value);
463
+ }}
464
+ placeholder="For example, review authorization and tenant isolation."
465
+ aria-label="Scan focus"
466
+ />
467
+ </label>
468
+ <details>
469
+ <summary>Scope and supporting documents</summary>
470
+ <label>
471
+ Paths to review
472
+ <textarea
473
+ value={paths()}
474
+ onInput={(e) => {
475
+ edit();
476
+ setPaths(e.currentTarget.value);
477
+ }}
478
+ placeholder={'src/auth\nsrc/api'}
479
+ aria-label="Scope paths"
480
+ />
481
+ <span class="gs-help">
482
+ One repository-relative path per line. Leave blank for the whole
483
+ repository.
484
+ </span>
485
+ </label>
486
+ <label>
487
+ Context files
488
+ <textarea
489
+ value={documents()}
490
+ onInput={(e) => {
491
+ edit();
492
+ setDocuments(e.currentTarget.value);
493
+ }}
494
+ placeholder={'docs/architecture.md\nSECURITY.md'}
495
+ aria-label="Context files"
496
+ />
497
+ <span class="gs-help">
498
+ Optional text documents within this repository. Do not include secrets.
499
+ </span>
500
+ </label>
501
+ </details>
502
+ <div class="gs-actions">
503
+ <button
504
+ type="submit"
505
+ class="gs-button gs-primary"
506
+ disabled={!!busy() || !loaded()}
507
+ >
508
+ {busy() === 'prepare' ? 'Preparing preview…' : 'Review scan'}
509
+ </button>
510
+ <span class="gs-muted gs-small">Nothing is sent to OpenAI yet.</span>
511
+ </div>
512
+ </form>
513
+ }
514
+ >
515
+ {(plan) => (
516
+ <div class="gs-review">
517
+ <h3>Review this scan</h3>
518
+ <dl>
519
+ <dt>Repository</dt>
520
+ <dd>{plan().repository}</dd>
521
+ <dt>Scope</dt>
522
+ <dd>{plan().scopePaths.join(', ') || 'Whole repository'}</dd>
523
+ <dt>Depth</dt>
524
+ <dd>{plan().settings.scan.mode === 'deep' ? 'Deep' : 'Standard'}</dd>
525
+ <Show when={plan().scanPrompt}>
526
+ <dt>Focus</dt>
527
+ <dd>{plan().scanPrompt}</dd>
528
+ </Show>
529
+ <Show when={plan().contextFiles.length}>
530
+ <dt>Supporting documents</dt>
531
+ <dd>
532
+ {plan()
533
+ .contextFiles.map((f) => f.path)
534
+ .join(', ')}
535
+ </dd>
536
+ </Show>
537
+ </dl>
538
+ <details class="gs-advanced">
539
+ <summary>Model and advanced settings</summary>
540
+ <dl class="gs-details-list">
541
+ <dt>Model</dt>
542
+ <dd>
543
+ {plan().settings.codex.model} · {plan().settings.codex.reasoningEffort}{' '}
544
+ reasoning
545
+ </dd>
546
+ <dt>Daybreak</dt>
547
+ <dd>{plan().settings.codex.daybreak ? 'On' : 'Off'}</dd>
548
+ <dt>Global configuration</dt>
549
+ <dd>{plan().globalConfigFile}</dd>
550
+ <dt>Settings scope</dt>
551
+ <dd>
552
+ Shared defaults only. This target and history belong to this workspace.
553
+ </dd>
554
+ </dl>
555
+ <p class="gs-muted">Model and account access have not been verified.</p>
556
+ <details>
557
+ <summary>Full approval details</summary>
558
+ <pre>{describePlan(plan())}</pre>
559
+ </details>
560
+ </details>
561
+ <div class="gs-approval">
562
+ <Show
563
+ when={plan().mock}
564
+ fallback={
565
+ <>
566
+ <p>
567
+ Repository source and listed context will be sent to OpenAI. Results
568
+ are stored locally.
569
+ </p>
570
+ <p>
571
+ Estimated cost stop:{' '}
572
+ <strong>USD {plan().settings.scan.maxCostUsd}</strong> for this
573
+ scan. This is not a hard cap; in-flight usage may exceed it.
574
+ </p>
575
+ <p>
576
+ Only the focus and documents above are added as context, not your
577
+ conversation. Scope guides the review; it is not a filesystem
578
+ sandbox.
579
+ </p>
580
+ </>
581
+ }
582
+ >
583
+ <p>
584
+ Mock SDK test. Synthetic responses only; no model calls or source
585
+ uploads.
586
+ </p>
587
+ </Show>
588
+ </div>
589
+ <Show when={activeCount() >= catalog().maxConcurrent}>
590
+ <p role="status" class="gs-small gs-muted">
591
+ {catalog().maxConcurrent} scans are already running. Start this scan after
592
+ one finishes.
593
+ </p>
594
+ </Show>
595
+ <Show when={Date.parse(plan().expiresAt) <= now()}>
596
+ <p class="gs-small gs-error">
597
+ This preview has expired. Edit and review it again.
598
+ </p>
599
+ </Show>
600
+ <div class="gs-actions">
601
+ <button
602
+ class="gs-button gs-primary"
603
+ disabled={
604
+ !!busy() ||
605
+ activeCount() >= catalog().maxConcurrent ||
606
+ Date.parse(plan().expiresAt) <= now()
607
+ }
608
+ onClick={() => void start()}
609
+ >
610
+ {busy() === 'start'
611
+ ? 'Starting…'
612
+ : plan().mock
613
+ ? 'Start mock scan'
614
+ : 'Approve and start'}
615
+ </button>
616
+ <button
617
+ class="gs-button"
618
+ disabled={!!busy()}
619
+ onClick={() => setPreview(undefined)}
620
+ >
621
+ Edit scan
622
+ </button>
623
+ </div>
624
+ </div>
625
+ )}
626
+ </Show>
627
+ </Show>
628
+ </section>
629
+ </Show>
630
+ <Show
631
+ when={catalog().runs.length}
632
+ fallback={
633
+ <Show when={loaded() && !composer()}>
634
+ <div class="gs-empty">
635
+ <h3>No scans yet</h3>
636
+ <p>Start a scan to review findings and follow its activity here.</p>
637
+ </div>
638
+ </Show>
639
+ }
640
+ >
641
+ <details
642
+ ref={(element) => (historyElement = element)}
643
+ class="gs-runs"
644
+ aria-label="Scan history"
645
+ >
646
+ <summary>
647
+ Switch scan <span>{catalog().runs.length} recent</span>
648
+ </summary>
649
+ <p class="gs-muted">Runs recorded in this workspace. Each target is shown below.</p>
650
+ <ul class="gs-run-list">
651
+ <For each={catalog().runs}>
652
+ {(run) => (
653
+ <li>
654
+ <button
655
+ class="gs-run-button"
656
+ aria-pressed={current()?.runId === run.runId}
657
+ onClick={() => selectRun(run.runId)}
658
+ >
659
+ <div class="gs-run-top">
660
+ <span class="gs-run-title">{runTitle(run)}</span>
661
+ <span class="gs-status" data-status={run.status}>
662
+ {run.status === 'error' ? 'Failed' : run.status}
663
+ </span>
664
+ </div>
665
+ <div class="gs-run-target">Target: {run.repository}</div>
666
+ <div class="gs-run-meta">
667
+ {run.mode ?? run.plan?.settings.scan.mode ?? 'Fixture'} · {scanScope(run)}{' '}
668
+ · {startedTime(run.startedAt)}
669
+ {run.status === 'completed'
670
+ ? ` · ${run.findings.length} ${run.findings.length === 1 ? 'finding' : 'findings'} shown`
671
+ : ''}
672
+ </div>
673
+ </button>
674
+ </li>
675
+ )}
676
+ </For>
677
+ </ul>
678
+ </details>
679
+ </Show>
680
+ <Show when={current()}>
681
+ {(run) => (
682
+ <article
683
+ ref={(element) => (detailElement = element)}
684
+ class="gs-detail"
685
+ aria-label="Selected scan"
686
+ >
687
+ <div class="gs-detail-title">
688
+ <h3>{runTitle(run())}</h3>
689
+ <p>Target: {run().repository}</p>
690
+ <p>
691
+ {run().mode ?? run().plan?.settings.scan.mode ?? 'Fixture'} · {scanScope(run())}
692
+ </p>
693
+ </div>
694
+ <div class="gs-progress-heading" role="status" aria-live="polite">
695
+ <h3>{runPhaseLabel(run())}</h3>
696
+ <span>{elapsedLabel(run().startedAt, run().finishedAt, now())}</span>
697
+ </div>
698
+ <Show when={hasDeterminateProgress(run())}>
699
+ <progress
700
+ class="gs-progress"
701
+ aria-label={`${runPhaseLabel(run())}: reported phase progress`}
702
+ value={phaseCounts(run()).done}
703
+ max={phaseCounts(run()).total}
704
+ />
705
+ </Show>
706
+ <Show when={hasPhaseProgress(run())}>
707
+ <div class="gs-progress-meta">
708
+ <span>{progressLabel(run())}</span>
709
+ <span>{phaseContext(run())}</span>
710
+ </div>
711
+ </Show>
712
+ <div class="gs-scan-actions">
713
+ <span class="gs-muted gs-small">{usageLabel(run())}</span>
714
+ <Show when={run().status === 'running'}>
715
+ <button class="gs-link" disabled={!!busy()} onClick={() => void cancel(run())}>
716
+ {busy() === 'cancel' ? 'Requesting stop…' : 'Cancel scan'}
717
+ </button>
718
+ </Show>
719
+ </div>
720
+ <Show when={run().status === 'running'}>
721
+ <p class="gs-live-note">{findingsPendingLabel(run())}</p>
722
+ </Show>
723
+ <Show when={run().error}>
724
+ <p class="gs-notice gs-error" role="alert">
725
+ {run().error}
726
+ </p>
727
+ </Show>
728
+ <Show when={run().status === 'cancelled'}>
729
+ <p class="gs-notice">
730
+ Cancelled. This scan is incomplete and is not a clean result.
731
+ </p>
732
+ </Show>
733
+ <Show when={run().status === 'completed' && run().findings.length === 0}>
734
+ <div class="gs-empty">
735
+ <h3>No reportable findings shown</h3>
736
+ <p>No findings does not guarantee security. Check coverage below.</p>
737
+ </div>
738
+ </Show>
739
+ <Show when={run().findings.length}>
740
+ <section class="gs-findings" aria-label="Scan findings">
741
+ <div class="gs-section-heading">
742
+ <h3>Findings</h3>
743
+ <span>{run().findings.length} to review</span>
744
+ </div>
745
+ <For each={run().findings}>
746
+ {(finding) => (
747
+ <details>
748
+ <summary>
749
+ <span class="gs-severity" data-severity={finding.severity}>
750
+ {finding.severity}
751
+ </span>{' '}
752
+ {finding.title}
753
+ </summary>
754
+ <div class="gs-finding-location">
755
+ {finding.path}:{finding.line} · {finding.cwe}
756
+ </div>
757
+ <p>{finding.summary}</p>
758
+ <Show when={finding.evidence}>
759
+ <pre>{finding.evidence}</pre>
760
+ </Show>
761
+ <strong class="gs-small">Suggested next step</strong>
762
+ <p>{finding.remediation}</p>
763
+ <p class="gs-muted gs-small">
764
+ Confidence: {finding.confidence}. Review the evidence before making
765
+ changes.
766
+ </p>
767
+ <Show
768
+ when={
769
+ props.operations &&
770
+ (props.operations.capabilities?.validation ?? true) &&
771
+ !fixture()
772
+ }
773
+ >
774
+ <button
775
+ class="gs-button"
776
+ onClick={() => {
777
+ setValidationSeed({
778
+ repository: run().repository,
779
+ finding: findingText(finding),
780
+ });
781
+ setView('validation');
782
+ }}
783
+ >
784
+ Validate this finding
785
+ </button>
786
+ </Show>
787
+ </details>
788
+ )}
789
+ </For>
790
+ </section>
791
+ </Show>
792
+ <Show when={run().status === 'completed'}>
793
+ <section class="gs-outcome" aria-label="Scan outcome">
794
+ <p class="gs-outcome-summary">{resultCountsLabel(run())}</p>
795
+ <details class="gs-coverage">
796
+ <summary>Coverage and repository matching</summary>
797
+ <p>{coverageLabel(run())}</p>
798
+ <p class="gs-muted">Reported metadata, not proof of exhaustive review.</p>
799
+ <dl class="gs-details-list">
800
+ <For each={coverageDetails(run())}>
801
+ {(row) => (
802
+ <>
803
+ <Show when={row.label}>
804
+ <dt>{row.label}</dt>
805
+ </Show>
806
+ <dd>{row.value}</dd>
807
+ </>
808
+ )}
809
+ </For>
810
+ <dt>Repository matching</dt>
811
+ <dd>{repositoryMatchingLabel(run())}</dd>
812
+ <Show when={run().assessment?.repositoryFindings.available}>
813
+ <dd class="gs-muted">
814
+ {run().assessment?.repositoryFindings.withMatchedFindingIds ??
815
+ 'Unknown'}{' '}
816
+ records have matching finding IDs.{' '}
817
+ {run().assessment?.repositoryFindings.notConfirmedInLatestScan ??
818
+ 'Unknown'}{' '}
819
+ were not confirmed in this scan. These are repository records, not
820
+ additional findings or deduplication progress.
821
+ </dd>
822
+ </Show>
823
+ </dl>
824
+ <For each={run().assessment?.notes ?? []}>
825
+ {(note) => <p class="gs-muted">{note}</p>}
826
+ </For>
827
+ <Show
828
+ when={run().skipped || run().assessment?.returnedFindings.informational}
829
+ >
830
+ <p class="gs-muted">
831
+ Informational and unclassified items are not shown in the four-severity
832
+ findings list. Review the full local SDK result for those items.
833
+ </p>
834
+ </Show>
835
+ </details>
836
+ </section>
837
+ </Show>
838
+ <details class="gs-activity" aria-label="Scan activity">
839
+ <summary>Activity and workers</summary>
840
+ <Show when={workerSummary(run())}>
841
+ <p class="gs-muted gs-small">{workerSummary(run())}</p>
842
+ </Show>
843
+ <Show when={dispatchLabel(run())}>
844
+ <p class="gs-muted gs-small">{dispatchLabel(run())}</p>
845
+ </Show>
846
+ <Show
847
+ when={activityRows(run()).length}
848
+ fallback={
849
+ <p class="gs-muted gs-small">
850
+ {run().status === 'running'
851
+ ? 'Waiting for detailed SDK activity.'
852
+ : 'No detailed activity was recorded for this run.'}
853
+ </p>
854
+ }
855
+ >
856
+ <ul class="gs-worker-activity">
857
+ <For each={workerActivityRows(run()).slice(0, 4)}>
858
+ {(activity) => (
859
+ <li data-activity-id={activity.id}>
860
+ <div class="gs-activity-copy">
861
+ <span class="gs-activity-actor">
862
+ {activityActor(activity.worker)}
863
+ </span>
864
+ <span>{activity.label}</span>
865
+ </div>
866
+ <span class="gs-activity-state" data-status={activity.status}>
867
+ {activityStatusLabel(activity.status, run())}
868
+ </span>
869
+ </li>
870
+ )}
871
+ </For>
872
+ </ul>
873
+ </Show>
874
+ <Show when={activityRows(run()).length || run().events.length}>
875
+ <details class="gs-activity-history">
876
+ <summary>Activity history</summary>
877
+ <ol class="gs-activity-list">
878
+ <For each={activityRows(run())}>
879
+ {(activity) => (
880
+ <li>
881
+ <time dateTime={activity.updatedAt}>
882
+ {clockTime(activity.updatedAt)}
883
+ </time>
884
+ <span>
885
+ {activityActor(activity.worker)} · {activity.label} ·{' '}
886
+ {activityStatusLabel(activity.status, run())}
887
+ <Show when={activity.paths.length}>
888
+ <span class="gs-activity-paths">{activity.paths.join(', ')}</span>
889
+ </Show>
890
+ </span>
891
+ </li>
892
+ )}
893
+ </For>
894
+ </ol>
895
+ <Show when={run().events.length}>
896
+ <p class="gs-muted gs-small">Scan milestones · latest first</p>
897
+ <ol class="gs-activity-list">
898
+ <For each={[...run().events].reverse().slice(0, 20)}>
899
+ {(event) => (
900
+ <li>
901
+ <time dateTime={event.time}>{clockTime(event.time)}</time>
902
+ <span>{event.message}</span>
903
+ </li>
904
+ )}
905
+ </For>
906
+ </ol>
907
+ </Show>
908
+ </details>
909
+ </Show>
910
+ </details>
911
+ <details class="gs-advanced">
912
+ <summary>Usage and scan details</summary>
913
+ <dl class="gs-details-list">
914
+ <dt>Usage estimate</dt>
915
+ <dd>{usageLabel(run())}</dd>
916
+ <dd class="gs-muted">{usageContext(run())}</dd>
917
+ <Show when={usageRangeLabel(run())}>
918
+ <dt>Estimated range</dt>
919
+ <dd>{usageRangeLabel(run())}</dd>
920
+ </Show>
921
+ <Show when={run().telemetry?.measurement?.cost?.model}>
922
+ <dt>Pricing model</dt>
923
+ <dd>{run().telemetry?.measurement?.cost?.model}</dd>
924
+ </Show>
925
+ <Show when={run().telemetry?.measurement?.cost?.pricingAsOf}>
926
+ <dt>Pricing as of</dt>
927
+ <dd>{run().telemetry?.measurement?.cost?.pricingAsOf}</dd>
928
+ </Show>
929
+ <Show when={run().telemetry?.measurement?.usage}>
930
+ {(usage) => (
931
+ <>
932
+ <dt>Reported tokens</dt>
933
+ <dd>
934
+ {usage().inputTokens.toLocaleString()} input ·{' '}
935
+ {usage().cachedInputTokens.toLocaleString()} cached input ·{' '}
936
+ {usage().outputTokens.toLocaleString()} output
937
+ </dd>
938
+ </>
939
+ )}
940
+ </Show>
941
+ <Show when={run().deepProgress}>
942
+ <dt>Deep review</dt>
943
+ <dd>{deepProgressLabel(run())}</dd>
944
+ <dd class="gs-muted">
945
+ The cap is a maximum, not a target or completion percentage.
946
+ </dd>
947
+ </Show>
948
+ <Show when={reconnectLabel(run())}>
949
+ <dt>Connection</dt>
950
+ <dd>{reconnectLabel(run())}</dd>
951
+ </Show>
952
+ <dt>Repository</dt>
953
+ <dd>{run().repository}</dd>
954
+ <dt>Run ID</dt>
955
+ <dd class="gs-id">{run().runId}</dd>
956
+ <Show when={run().scanId}>
957
+ <dt>SDK scan ID</dt>
958
+ <dd class="gs-id">{run().scanId}</dd>
959
+ </Show>
960
+ <dt>Scope</dt>
961
+ <dd>{scanScope(run())}</dd>
962
+ <dt>Depth</dt>
963
+ <dd>
964
+ {run().mode ?? run().plan?.settings.scan.mode ?? 'Fixture pattern checks'}
965
+ </dd>
966
+ <Show when={run().model ?? run().plan?.settings.codex.model}>
967
+ <dt>Model</dt>
968
+ <dd>{run().model ?? run().plan?.settings.codex.model}</dd>
969
+ </Show>
970
+ <Show when={run().plan?.scanPrompt}>
971
+ <dt>Focus</dt>
972
+ <dd>{run().plan?.scanPrompt}</dd>
973
+ </Show>
974
+ <Show when={run().plan?.contextFiles.length}>
975
+ <dt>Supporting documents</dt>
976
+ <dd>
977
+ {run()
978
+ .plan?.contextFiles.map((f) => f.path)
979
+ .join(', ')}
980
+ </dd>
981
+ </Show>
982
+ </dl>
983
+ <Show when={run().archived}>
984
+ <p class="gs-muted">Restored from local history.</p>
985
+ </Show>
986
+ </details>
987
+ </article>
988
+ )}
989
+ </Show>
990
+ </Show>
991
+ <footer class="gs-footer">
992
+ {fixture()
993
+ ? 'Fixture engine · not a security assessment'
994
+ : 'Codex Security · local results, OpenAI inference'}
995
+ <Show when={catalog().runs.length}>
996
+ {' '}
997
+ · Up to {catalog().maxConcurrent} concurrent scans per project
998
+ </Show>
999
+ </footer>
1000
+ </div>
1001
+ </section>
1002
+ );
1003
+ }