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,551 @@
1
+ import { createEffect, createMemo, createSignal, For, Show, on, onCleanup } from 'solid-js';
2
+ import { createStore, reconcile } from 'solid-js/store';
3
+ import type {
4
+ SecurityOperationInputs,
5
+ SecurityOperationState,
6
+ SecurityOperationApproval,
7
+ } from './core/security-operations';
8
+ import { elapsedLabel, phaseLabel, projectName, splitPaths, userError } from './scan-display';
9
+
10
+ export interface OperationPort {
11
+ capabilities?: { policy: boolean; validation: boolean; questionAnswers: boolean };
12
+ list(): Promise<unknown>;
13
+ prepare(input: SecurityOperationInputs): Promise<unknown>;
14
+ start(input: SecurityOperationApproval): Promise<unknown>;
15
+ answer(input: { operationId: string; questionId: string; answer: string }): Promise<unknown>;
16
+ cancel(input: { operationId: string }): Promise<unknown>;
17
+ }
18
+ export interface ValidationSeed {
19
+ repository: string;
20
+ finding: string;
21
+ }
22
+ export function SecurityOperationsPanel(props: {
23
+ kind: 'policy' | 'validation';
24
+ directory: string;
25
+ port: OperationPort;
26
+ seed?: ValidationSeed;
27
+ }) {
28
+ const [data, setData] = createStore<{ operations: SecurityOperationState[] }>({ operations: [] });
29
+ const [selected, select] = createSignal<string>();
30
+ const [form, setForm] = createSignal(true);
31
+ const [repository, setRepository] = createSignal(props.seed?.repository ?? props.directory);
32
+ const [path, setPath] = createSignal('');
33
+ const [documents, setDocuments] = createSignal('');
34
+ const [finding, setFinding] = createSignal(props.seed?.finding ?? '');
35
+ const [seeded, setSeeded] = createSignal(!!props.seed);
36
+ const [answer, setAnswer] = createSignal('');
37
+ const [uncapped, setUncapped] = createSignal(false);
38
+ const [busy, setBusy] = createSignal(false);
39
+ const [error, setError] = createSignal('');
40
+ const [now, setNow] = createSignal(Date.now());
41
+ const [loaded, setLoaded] = createSignal(false);
42
+ let stopped = false,
43
+ polling = false,
44
+ contextRevision = 0;
45
+ const resetForm = (seed?: ValidationSeed) => {
46
+ contextRevision++;
47
+ setRepository(seed?.repository ?? props.directory);
48
+ setSeeded(!!seed);
49
+ setFinding(seed?.finding ?? '');
50
+ setPath('');
51
+ setDocuments('');
52
+ setAnswer('');
53
+ setUncapped(false);
54
+ setError('');
55
+ select(undefined);
56
+ setForm(true);
57
+ };
58
+ createEffect(
59
+ on(
60
+ () => [props.kind, props.directory, props.seed] as const,
61
+ () => resetForm(props.seed),
62
+ { defer: true },
63
+ ),
64
+ );
65
+ const operations = createMemo(() => data.operations.filter((op) => op.kind === props.kind));
66
+ const current = createMemo(
67
+ () =>
68
+ operations().find((op) => op.operationId === selected()) ??
69
+ operations().find((op) => op.status === 'awaiting_answers') ??
70
+ operations()[0],
71
+ );
72
+ const label = () => (props.kind === 'policy' ? 'Policy draft' : 'Finding validation');
73
+ createEffect(() => {
74
+ void current()?.question?.id;
75
+ setAnswer('');
76
+ });
77
+ createEffect(() => {
78
+ void current()?.operationId;
79
+ setUncapped(false);
80
+ });
81
+ const refresh = async () => {
82
+ if (stopped || polling) return;
83
+ polling = true;
84
+ try {
85
+ const response = await props.port.list();
86
+ const next = Array.isArray(response)
87
+ ? response
88
+ : (response as { operations?: SecurityOperationState[] })?.operations;
89
+ if (!Array.isArray(next)) throw new Error('Could not read security operations.');
90
+ if (!stopped) {
91
+ setData('operations', reconcile(next, { key: 'operationId', merge: true }));
92
+ if (!loaded()) {
93
+ setLoaded(true);
94
+ if (!props.seed && next.some((op) => op.kind === props.kind)) setForm(false);
95
+ }
96
+ }
97
+ } catch (e) {
98
+ if (!stopped) setError(userError(e));
99
+ } finally {
100
+ polling = false;
101
+ }
102
+ };
103
+ const perform = async (action: () => Promise<unknown>) => {
104
+ if (busy() || stopped) return;
105
+ setBusy(true);
106
+ setError('');
107
+ try {
108
+ await action();
109
+ if (!stopped) await refresh();
110
+ } catch (e) {
111
+ if (!stopped) setError(userError(e));
112
+ } finally {
113
+ if (!stopped) setBusy(false);
114
+ }
115
+ };
116
+ const prepare = (event: SubmitEvent) => {
117
+ event.preventDefault();
118
+ const preparingRevision = contextRevision;
119
+ void perform(async () => {
120
+ const op = (await props.port.prepare({
121
+ kind: props.kind,
122
+ repository: repository().trim() || props.directory,
123
+ contextFiles: splitPaths(documents()),
124
+ ...(props.kind === 'policy'
125
+ ? path().trim()
126
+ ? { path: path().trim() }
127
+ : {}
128
+ : { finding: finding() }),
129
+ })) as SecurityOperationState;
130
+ if (!stopped && preparingRevision === contextRevision) {
131
+ select(op.operationId);
132
+ setData(
133
+ 'operations',
134
+ reconcile(
135
+ [op, ...data.operations.filter((item) => item.operationId !== op.operationId)],
136
+ { key: 'operationId', merge: true },
137
+ ),
138
+ );
139
+ setForm(false);
140
+ }
141
+ });
142
+ };
143
+ const start = (op: SecurityOperationState) =>
144
+ void perform(() =>
145
+ props.port.start({
146
+ operationId: op.operationId,
147
+ approveOpenAIInference: true,
148
+ ...(op.kind === 'validation' ? { approveUncappedValidation: true as const } : {}),
149
+ }),
150
+ );
151
+ void refresh();
152
+ const timer = setInterval(() => {
153
+ setNow(Date.now());
154
+ void refresh();
155
+ }, 1500);
156
+ onCleanup(() => {
157
+ stopped = true;
158
+ clearInterval(timer);
159
+ });
160
+ return (
161
+ <section aria-label={label()}>
162
+ <div class="gs-section-heading">
163
+ <h3>{label()}</h3>
164
+ <Show when={!form()}>
165
+ <button class="gs-button" disabled={busy()} onClick={() => resetForm()}>
166
+ New {props.kind === 'policy' ? 'draft' : 'validation'}
167
+ </button>
168
+ </Show>
169
+ </div>
170
+ <p class="gs-muted gs-small">
171
+ {props.kind === 'policy'
172
+ ? 'Build security guidance from the repository, with questions when context is missing. Draft only: existing files stay unchanged.'
173
+ : 'Assess a finding against the source. This does not run an exploit or prove runtime reproduction.'}
174
+ </p>
175
+ <Show when={error()}>
176
+ <p class="gs-notice gs-error" role="alert">
177
+ {error()}
178
+ </p>
179
+ </Show>
180
+ <Show when={form()}>
181
+ <form class="gs-form" onSubmit={prepare}>
182
+ <label>
183
+ Target repository
184
+ <input
185
+ aria-label="Operation repository"
186
+ value={repository()}
187
+ required
188
+ disabled={busy()}
189
+ onInput={(event) => {
190
+ setRepository(event.currentTarget.value);
191
+ setPath('');
192
+ setDocuments('');
193
+ setFinding('');
194
+ setSeeded(false);
195
+ }}
196
+ />
197
+ <span class="gs-help">
198
+ {seeded()
199
+ ? 'Starts from the selected finding’s repository. Check the target before review.'
200
+ : 'Defaults to this workspace. Changing it affects only this operation.'}
201
+ </span>
202
+ </label>
203
+ <Show
204
+ when={props.kind === 'policy'}
205
+ fallback={
206
+ <label>
207
+ Finding to assess
208
+ <textarea
209
+ aria-label="Finding to assess"
210
+ value={finding()}
211
+ required
212
+ maxLength={16000}
213
+ disabled={busy()}
214
+ onInput={(event) => setFinding(event.currentTarget.value)}
215
+ placeholder="Describe the suspected issue, location, and evidence."
216
+ />
217
+ <span class="gs-help">Use finding text or JSON. Do not include credentials.</span>
218
+ </label>
219
+ }
220
+ >
221
+ <label>
222
+ Component directory <span class="gs-muted">Optional</span>
223
+ <input
224
+ aria-label="Policy component directory"
225
+ value={path()}
226
+ disabled={busy()}
227
+ onInput={(event) => setPath(event.currentTarget.value)}
228
+ placeholder="src/api"
229
+ />
230
+ <span class="gs-help">
231
+ Relative to the repository. Leave blank for the whole project.
232
+ </span>
233
+ </label>
234
+ </Show>
235
+ <details>
236
+ <summary>Supporting documents</summary>
237
+ <label>
238
+ Context files
239
+ <textarea
240
+ aria-label="Operation context files"
241
+ value={documents()}
242
+ disabled={busy()}
243
+ onInput={(event) => setDocuments(event.currentTarget.value)}
244
+ placeholder="docs/architecture.md"
245
+ />
246
+ <span class="gs-help">
247
+ One repository-relative text document per line. Do not include secrets.
248
+ </span>
249
+ </label>
250
+ </details>
251
+ <div class="gs-actions">
252
+ <button class="gs-button gs-primary" disabled={busy()} type="submit">
253
+ {busy()
254
+ ? 'Preparing…'
255
+ : 'Review ' + (props.kind === 'policy' ? 'policy draft' : 'validation')}
256
+ </button>
257
+ <Show when={operations().length}>
258
+ <button
259
+ class="gs-button"
260
+ type="button"
261
+ disabled={busy()}
262
+ onClick={() => setForm(false)}
263
+ >
264
+ Back
265
+ </button>
266
+ </Show>
267
+ </div>
268
+ <p class="gs-muted gs-small">Preparation makes no model calls.</p>
269
+ </form>
270
+ </Show>
271
+ <Show when={operations().length > 1}>
272
+ <ul class="gs-run-list" aria-label="Recent operations">
273
+ <For each={operations()}>
274
+ {(op) => (
275
+ <li>
276
+ <button
277
+ class="gs-run-button"
278
+ aria-pressed={current()?.operationId === op.operationId}
279
+ onClick={() => {
280
+ select(op.operationId);
281
+ setForm(false);
282
+ }}
283
+ >
284
+ <div class="gs-run-top">
285
+ <span class="gs-run-title">
286
+ {projectName(op.repository)}
287
+ {op.plan.path ? ' / ' + op.plan.path : ''}
288
+ </span>
289
+ <span class="gs-status" data-status={op.status}>
290
+ {op.status === 'awaiting_answers'
291
+ ? 'Needs your answer'
292
+ : phaseLabel(op.status)}
293
+ </span>
294
+ </div>
295
+ <div class="gs-run-target">Target: {op.repository}</div>
296
+ <div class="gs-run-meta">
297
+ {new Date(op.createdAt).toLocaleTimeString([], {
298
+ hour: '2-digit',
299
+ minute: '2-digit',
300
+ })}{' '}
301
+ · {op.operationId.slice(0, 8)}
302
+ </div>
303
+ </button>
304
+ </li>
305
+ )}
306
+ </For>
307
+ </ul>
308
+ </Show>
309
+ <Show when={!form() && current()}>
310
+ {(op) => (
311
+ <article class="gs-detail" aria-label="Selected operation">
312
+ <div class="gs-detail-title">
313
+ <h3>{phaseLabel(op().phase)}</h3>
314
+ <p>
315
+ Target: {op().repository}
316
+ {op().plan.path ? ' / ' + op().plan.path : ''}
317
+ </p>
318
+ </div>
319
+ <Show when={op().status === 'prepared'}>
320
+ <div class="gs-review">
321
+ <h3>
322
+ Review {props.kind === 'policy' ? 'policy generation' : 'source validation'}
323
+ </h3>
324
+ <dl>
325
+ <dt>Repository</dt>
326
+ <dd>{op().repository}</dd>
327
+ <Show when={op().plan.path}>
328
+ <dt>Component</dt>
329
+ <dd>{op().plan.path}</dd>
330
+ </Show>
331
+ <Show when={op().plan.contextFiles.length}>
332
+ <dt>Supporting documents</dt>
333
+ <dd>
334
+ {op()
335
+ .plan.contextFiles.map((file) => file.path)
336
+ .join(', ')}
337
+ </dd>
338
+ </Show>
339
+ </dl>
340
+ <Show when={op().kind === 'validation'}>
341
+ <details class="gs-advanced">
342
+ <summary>Finding to assess</summary>
343
+ <pre>{op().plan.finding}</pre>
344
+ </details>
345
+ </Show>
346
+ <div class="gs-approval">
347
+ <Show
348
+ when={op().plan.mock}
349
+ fallback={
350
+ <>
351
+ <p>
352
+ Repository source{op().kind === 'validation' ? ', the finding,' : ''} and
353
+ selected context will be sent to OpenAI.
354
+ </p>
355
+ <p>
356
+ <strong>{op().plan.costNotice}</strong>
357
+ </p>
358
+ <p>
359
+ {op().kind === 'policy'
360
+ ? 'A policy draft will be returned for review. No files will be applied.'
361
+ : 'This is static source assessment, not runtime reproduction. No patch will be applied.'}
362
+ </p>
363
+ </>
364
+ }
365
+ >
366
+ <p>Mock operation only. No model calls or source uploads.</p>
367
+ </Show>
368
+ </div>
369
+ <Show when={op().kind === 'validation' && !op().plan.mock}>
370
+ <label class="gs-check">
371
+ <input
372
+ type="checkbox"
373
+ checked={uncapped()}
374
+ onChange={(e) => setUncapped(e.currentTarget.checked)}
375
+ />{' '}
376
+ I approve validation without a cost ceiling.
377
+ </label>
378
+ </Show>
379
+ <details class="gs-advanced">
380
+ <summary>Model settings</summary>
381
+ <p>
382
+ {op().plan.settings.codex.model} · {op().plan.settings.codex.reasoningEffort}{' '}
383
+ reasoning · Daybreak {op().plan.settings.codex.daybreak ? 'on' : 'off'}
384
+ </p>
385
+ <p class="gs-muted">
386
+ Account and model access are not verified. {op().plan.globalConfigFile}
387
+ </p>
388
+ </details>
389
+ <Show when={Date.parse(op().plan.expiresAt) <= now()}>
390
+ <p class="gs-error">This preview expired. Prepare a new one before starting.</p>
391
+ </Show>
392
+ <div class="gs-actions">
393
+ <button
394
+ class="gs-button gs-primary"
395
+ disabled={
396
+ busy() ||
397
+ Date.parse(op().plan.expiresAt) <= now() ||
398
+ (op().kind === 'validation' && !op().plan.mock && !uncapped())
399
+ }
400
+ onClick={() => start(op())}
401
+ >
402
+ {busy()
403
+ ? 'Starting…'
404
+ : op().plan.mock
405
+ ? 'Start mock operation'
406
+ : 'Approve and start'}
407
+ </button>
408
+ <button
409
+ class="gs-button"
410
+ disabled={busy()}
411
+ onClick={() => {
412
+ setRepository(op().repository);
413
+ setPath(op().plan.path ?? '');
414
+ setDocuments(
415
+ op()
416
+ .plan.contextFiles.map((file) => file.path)
417
+ .join('\n'),
418
+ );
419
+ setFinding(op().plan.finding ?? '');
420
+ setForm(true);
421
+ }}
422
+ >
423
+ Edit request
424
+ </button>
425
+ </div>
426
+ </div>
427
+ </Show>
428
+ <Show when={op().status === 'running' || op().status === 'awaiting_answers'}>
429
+ <div class="gs-facts">
430
+ <span>{elapsedLabel(op().startedAt, null, now())}</span>
431
+ <Show when={op().estimatedCostUsd !== undefined}>
432
+ <span>Estimated usage USD {(op().estimatedCostUsd ?? 0).toFixed(2)}</span>
433
+ </Show>
434
+ </div>
435
+ </Show>
436
+ <Show when={op().question}>
437
+ {(question) => (
438
+ <form
439
+ class="gs-form gs-questions"
440
+ onSubmit={(e) => {
441
+ e.preventDefault();
442
+ const q = question();
443
+ void perform(() =>
444
+ props.port.answer({
445
+ operationId: op().operationId,
446
+ questionId: q.id,
447
+ answer: answer(),
448
+ }),
449
+ );
450
+ }}
451
+ >
452
+ <h3>Your input is needed</h3>
453
+ <ol>
454
+ <For each={question().questions}>{(text) => <li>{text}</li>}</For>
455
+ </ol>
456
+ <label>
457
+ Your answer
458
+ <textarea
459
+ aria-label="Answer security questions"
460
+ maxLength={8000}
461
+ required
462
+ value={answer()}
463
+ disabled={busy()}
464
+ onInput={(e) => setAnswer(e.currentTarget.value)}
465
+ />
466
+ <span class="gs-help">
467
+ Answer each question, or say what is unknown. Your answer goes to OpenAI for
468
+ this operation; do not include secrets.
469
+ </span>
470
+ </label>
471
+ <button
472
+ class="gs-button gs-primary"
473
+ disabled={busy() || !answer().trim()}
474
+ type="submit"
475
+ >
476
+ Send answer and continue
477
+ </button>
478
+ </form>
479
+ )}
480
+ </Show>
481
+ <Show when={op().status === 'running' || op().status === 'awaiting_answers'}>
482
+ <button
483
+ class="gs-button"
484
+ disabled={busy()}
485
+ onClick={() =>
486
+ void perform(() => props.port.cancel({ operationId: op().operationId }))
487
+ }
488
+ >
489
+ Cancel this operation
490
+ </button>
491
+ </Show>
492
+ <Show when={op().error}>
493
+ <p class="gs-notice gs-error" role="alert">
494
+ {op().error}
495
+ </p>
496
+ </Show>
497
+ <Show when={op().status === 'cancelled'}>
498
+ <p class="gs-notice">Cancelled. No completed assessment is available.</p>
499
+ </Show>
500
+ <Show when={op().result}>
501
+ {(result) => (
502
+ <section class="gs-operation-result">
503
+ <h3>{result().kind === 'policy' ? 'Policy draft' : 'Source assessment'}</h3>
504
+ <Show
505
+ when={result().kind === 'policy'}
506
+ fallback={
507
+ <p>
508
+ <strong>
509
+ {phaseLabel((result() as { disposition: string }).disposition)}
510
+ </strong>{' '}
511
+ · Static review, not runtime reproduction.
512
+ </p>
513
+ }
514
+ >
515
+ <p class="gs-muted gs-small">
516
+ Draft only. Review it before applying. No repository files were changed.
517
+ </p>
518
+ </Show>
519
+ <pre>
520
+ {result().kind === 'policy'
521
+ ? (result() as { content: string }).content
522
+ : (result() as { report: string }).report}
523
+ </pre>
524
+ <Show when={result().kind === 'policy'}>
525
+ <For each={(result() as { reviewNotes: string[] }).reviewNotes}>
526
+ {(note) => <p class="gs-muted gs-small">{note}</p>}
527
+ </For>
528
+ </Show>
529
+ <Show when={result().truncated}>
530
+ <p class="gs-notice">
531
+ This preview is truncated. Read the full local SDK artifact before relying on
532
+ it.
533
+ </p>
534
+ </Show>
535
+ </section>
536
+ )}
537
+ </Show>
538
+ <details class="gs-advanced">
539
+ <summary>Operation details</summary>
540
+ <p class="gs-id">{op().operationId}</p>
541
+ <p class="gs-muted">
542
+ {op().answeredQuestionCount} questions answered. Operation history is kept for this
543
+ server session.
544
+ </p>
545
+ </details>
546
+ </article>
547
+ )}
548
+ </Show>
549
+ </section>
550
+ );
551
+ }