opencode-codex-security 0.0.0-stage → 0.1.1

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/rpc.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { Rpc } from '@opencode/plugin/rpc';
2
+ const empty = { type: 'object', additionalProperties: false } as const;
3
+ export const scanInputSchema = {
4
+ type: 'object',
5
+ properties: {
6
+ mode: {
7
+ type: 'string',
8
+ enum: ['standard', 'deep'],
9
+ description:
10
+ 'Optional mode for this scan. Explicit user request wins; otherwise omit to use global mode (standard by default). Do not ask when the request is clear.',
11
+ },
12
+ repository: {
13
+ type: 'string',
14
+ description:
15
+ 'Optional local repository path. Defaults to the known current OpenCode workspace; use an absolute path when session context is unknown. This request only, never a global target.',
16
+ },
17
+ scopePaths: {
18
+ type: 'array',
19
+ items: { type: 'string' },
20
+ maxItems: 32,
21
+ description:
22
+ 'Existing paths relative to repository. Omit or use [] for whole repository. No globs or traversal.',
23
+ },
24
+ scanPrompt: {
25
+ type: 'string',
26
+ maxLength: 8000,
27
+ description:
28
+ 'Focused security instructions and relevant architecture/threat assumptions. Ask the user for missing material context. Never copy whole conversation or include secrets.',
29
+ },
30
+ contextFiles: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ maxItems: 8,
34
+ description:
35
+ 'Repository-relative text documentation to add as supporting context. No credentials or files outside repository.',
36
+ },
37
+ },
38
+ additionalProperties: false,
39
+ } as const;
40
+ export const operationInputSchema = {
41
+ type: 'object',
42
+ properties: {
43
+ kind: { type: 'string', enum: ['policy', 'validation'] },
44
+ repository: { type: 'string' },
45
+ path: { type: 'string' },
46
+ contextFiles: { type: 'array', items: { type: 'string' }, maxItems: 8 },
47
+ finding: { anyOf: [{ type: 'string' }, { type: 'object' }] },
48
+ },
49
+ required: ['kind'],
50
+ additionalProperties: false,
51
+ } as const;
52
+ const operationID = {
53
+ type: 'object',
54
+ properties: { operationId: { type: 'string' } },
55
+ required: ['operationId'],
56
+ additionalProperties: false,
57
+ } as const;
58
+ export const Security = Rpc.define({
59
+ id: 'gill.security',
60
+ methods: {
61
+ capabilities: { input: empty, output: { type: 'object' } },
62
+ operationPrepare: { input: operationInputSchema, output: { type: 'object' } },
63
+ operationStart: {
64
+ input: {
65
+ type: 'object',
66
+ properties: {
67
+ operationId: { type: 'string' },
68
+ approveOpenAIInference: { type: 'boolean' },
69
+ approveUncappedValidation: { type: 'boolean' },
70
+ },
71
+ required: ['operationId'],
72
+ additionalProperties: false,
73
+ },
74
+ output: { type: 'object' },
75
+ },
76
+ operationStatus: { input: operationID, output: { type: 'object' } },
77
+ operationList: { input: empty, output: { type: 'object' } },
78
+ operationAnswer: {
79
+ input: {
80
+ type: 'object',
81
+ properties: {
82
+ operationId: { type: 'string' },
83
+ questionId: { type: 'string' },
84
+ answer: { type: 'string', maxLength: 16384 },
85
+ },
86
+ required: ['operationId', 'questionId', 'answer'],
87
+ additionalProperties: false,
88
+ },
89
+ output: { type: 'object' },
90
+ },
91
+ operationCancel: { input: operationID, output: { type: 'object' } },
92
+ prepare: { input: scanInputSchema, output: { type: 'object' } },
93
+ start: {
94
+ input: {
95
+ type: 'object',
96
+ properties: { planId: { type: 'string' }, approveOpenAIInference: { type: 'boolean' } },
97
+ additionalProperties: false,
98
+ },
99
+ output: { type: 'object' },
100
+ },
101
+ list: { input: empty, output: { type: 'object' } },
102
+ status: {
103
+ input: {
104
+ type: 'object',
105
+ properties: { runId: { type: 'string' }, planId: { type: 'string' } },
106
+ additionalProperties: false,
107
+ },
108
+ output: { type: 'object' },
109
+ },
110
+ cancel: {
111
+ input: {
112
+ type: 'object',
113
+ properties: { runId: { type: 'string' } },
114
+ additionalProperties: false,
115
+ },
116
+ output: { type: 'object' },
117
+ },
118
+ },
119
+ events: {
120
+ changed: { schema: { type: 'object' } },
121
+ operationChanged: { schema: { type: 'object' } },
122
+ },
123
+ });
124
+ export default Security;
@@ -0,0 +1,284 @@
1
+ import type { ScanState, Finding } from './types';
2
+
3
+ export const projectName = (path: string) =>
4
+ path
5
+ .replace(/[\\/]+$/, '')
6
+ .split(/[\\/]/)
7
+ .at(-1) ||
8
+ path ||
9
+ 'Project';
10
+ export function runTitle(run: ScanState) {
11
+ return (
12
+ run.plan?.scanPrompt?.split('\n')[0].slice(0, 72) ||
13
+ (run.plan?.scopePaths.length ? run.plan.scopePaths.join(', ') : projectName(run.repository))
14
+ );
15
+ }
16
+ export function scanScope(run: ScanState) {
17
+ return run.plan?.scopePaths.join(', ') || 'Whole repository';
18
+ }
19
+ export function phaseLabel(phase: string) {
20
+ const clean = phase.replace(/\s*\((files|reviews)\)$/, '').replaceAll('_', ' ');
21
+ const labels: Record<string, string> = {
22
+ preflight: 'Preparing scan',
23
+ discovery: 'Reviewing source',
24
+ 'attack path': 'Reviewing attack paths',
25
+ 'file review': 'Reviewing source',
26
+ ranking: 'Prioritizing findings',
27
+ validation: 'Validating findings',
28
+ reporting: 'Preparing report',
29
+ };
30
+ if (labels[clean]) return labels[clean];
31
+ return clean ? clean[0].toUpperCase() + clean.slice(1) : 'Starting scan';
32
+ }
33
+ /** Phase counts are independent. A Deep maximum is a cap, never a denominator. */
34
+ export function phaseCounts(run: ScanState) {
35
+ const progress = run.telemetry?.phaseProgress;
36
+ const counts = progress
37
+ ? progress.reviews && progress.reviews.total > 0
38
+ ? { ...progress.reviews, unit: 'reviews' as const }
39
+ : { ...progress.files, unit: 'files' as const }
40
+ : { ...run.progress, unit: run.progress.unit ?? 'files' };
41
+ return {
42
+ ...counts,
43
+ done: Number.isFinite(counts.done) ? Math.max(0, counts.done) : 0,
44
+ total: Number.isFinite(counts.total) ? Math.max(0, counts.total) : 0,
45
+ };
46
+ }
47
+ export function finalizingRun(run: ScanState) {
48
+ return run.status === 'running' && run.telemetry?.lifecycle?.status === 'completed';
49
+ }
50
+ function sdkResultPending(run: ScanState) {
51
+ const lifecycle = run.telemetry?.lifecycle?.status;
52
+ return run.status === 'running' && lifecycle !== undefined && lifecycle !== 'running';
53
+ }
54
+ export function runPhaseLabel(run: ScanState) {
55
+ if (run.status === 'cancelled') return 'Scan cancelled';
56
+ if (run.status === 'error') return 'Scan stopped';
57
+ if (run.status === 'completed') return 'Scan complete';
58
+ if (finalizingRun(run)) return 'Finalizing results';
59
+ if (sdkResultPending(run))
60
+ return run.telemetry?.lifecycle?.status === 'failed'
61
+ ? 'Finishing failed scan'
62
+ : 'Finishing cancelled scan';
63
+ if (run.deepProgress?.consolidating) return 'Consolidating deep review';
64
+ return phaseLabel(run.telemetry?.phaseProgress?.phase ?? run.phase);
65
+ }
66
+ export function hasPhaseProgress(run: ScanState) {
67
+ return run.status === 'running' && !sdkResultPending(run) && !run.deepProgress?.consolidating;
68
+ }
69
+ export function hasDeterminateProgress(run: ScanState) {
70
+ const { done, total } = phaseCounts(run);
71
+ return hasPhaseProgress(run) && total > 0 && done <= total;
72
+ }
73
+ export function progressLabel(run: ScanState) {
74
+ const { done, total, unit } = phaseCounts(run);
75
+ return total > 0
76
+ ? `${done} of ${total} ${unit}`
77
+ : done > 0
78
+ ? `${done} ${unit} completed`
79
+ : 'Waiting for progress details';
80
+ }
81
+ export function phaseContext(run: ScanState) {
82
+ const progress = run.telemetry?.phaseProgress;
83
+ return progress?.reviews && progress.reviews.total > 0 && progress.files.total > 0
84
+ ? `Current phase only · ${progress.files.done} of ${progress.files.total} files`
85
+ : 'Current phase only';
86
+ }
87
+ export function deepProgressLabel(run: ScanState) {
88
+ const deep = run.deepProgress;
89
+ if (!deep) return '';
90
+ return `${deep.completed} deep ${deep.completed === 1 ? 'pass' : 'passes'} complete · ${deep.active} active · cap ${deep.maximum}`;
91
+ }
92
+ export function findingsPendingLabel(run: ScanState) {
93
+ if (sdkResultPending(run) && !finalizingRun(run))
94
+ return `The SDK reported ${run.telemetry?.lifecycle?.status === 'failed' ? 'a scan failure' : 'cancellation'}. Waiting for the final result; this is not a completed assessment.`;
95
+ return finalizingRun(run)
96
+ ? 'Analysis complete. Waiting for the SDK’s final result and findings.'
97
+ : 'Findings will appear when the scan finishes.';
98
+ }
99
+ const usd = (value: number) => (value > 0 && value < 0.01 ? '<0.01' : value.toFixed(2));
100
+ export function usageLabel(run: ScanState) {
101
+ const measurement = run.telemetry?.measurement;
102
+ if (measurement) {
103
+ const cost = measurement.cost;
104
+ if (!cost || !Number.isFinite(cost.baselineUsd)) return 'Cost estimate unavailable';
105
+ const amount = `USD ${usd(cost.baselineUsd)}`;
106
+ if (measurement.coverage === 'partial') return `Partial estimate ${amount}`;
107
+ if (measurement.coverage === 'unavailable')
108
+ return `Last estimate ${amount} · coverage unavailable`;
109
+ return `Estimated usage ${amount}`;
110
+ }
111
+ return run.estimatedCostUsd !== undefined && Number.isFinite(run.estimatedCostUsd)
112
+ ? `Estimated usage USD ${usd(run.estimatedCostUsd)} · coverage unknown`
113
+ : 'Cost estimate unavailable';
114
+ }
115
+ export function usageContext(run: ScanState) {
116
+ const measurement = run.telemetry?.measurement;
117
+ if (!measurement) return 'Usage coverage was not reported. Estimates are not invoices.';
118
+ const missing = measurement.missingThreadCount;
119
+ if (measurement.coverage === 'partial')
120
+ return `Measured subtotal only${missing ? `; ${missing} ${missing === 1 ? 'session is' : 'sessions are'} missing` : ''}. The full estimate may be higher.`;
121
+ if (measurement.coverage === 'unavailable') return 'The SDK could not establish usage coverage.';
122
+ return 'All reported sessions are included. This is a usage estimate, not an invoice.';
123
+ }
124
+ export function usageRangeLabel(run: ScanState) {
125
+ const range = run.telemetry?.measurement?.cost?.range;
126
+ if (!range) return '';
127
+ return range.max === null
128
+ ? `USD ${usd(range.min)} or more; upper estimate unavailable`
129
+ : `USD ${usd(range.min)}–${usd(range.max)}`;
130
+ }
131
+ export function activityRows(run: ScanState) {
132
+ return [...(run.telemetry?.activity ?? [])].sort((a, b) =>
133
+ b.updatedAt.localeCompare(a.updatedAt),
134
+ );
135
+ }
136
+ /** Show each observed actor once; the disclosure retains the individual keyed activities. */
137
+ export function workerActivityRows(run: ScanState) {
138
+ const seen = new Set<number | 'coordinator'>();
139
+ return activityRows(run).filter((activity) => {
140
+ const actor = activity.worker ?? 'coordinator';
141
+ if (seen.has(actor)) return false;
142
+ seen.add(actor);
143
+ return true;
144
+ });
145
+ }
146
+ export function activityActor(worker?: number) {
147
+ return worker === undefined ? 'Coordinator' : `Worker ${worker}`;
148
+ }
149
+ export function activityStatusLabel(status: string, run: ScanState) {
150
+ if (
151
+ status === 'running' &&
152
+ (run.status !== 'running' || sdkResultPending(run) || run.deepProgress?.consolidating)
153
+ )
154
+ return 'Last reported running';
155
+ return status === 'running' ? 'Working' : status === 'failed' ? 'Failed' : 'Done';
156
+ }
157
+ export function workerSummary(run: ScanState) {
158
+ const count = run.telemetry?.observedWorkers.length;
159
+ return count ? `${count} worker ${count === 1 ? 'session' : 'sessions'} observed` : '';
160
+ }
161
+ export function dispatchLabel(run: ScanState) {
162
+ const workers = run.workers;
163
+ return workers
164
+ ? `${phaseLabel(workers.phase)}: ${workers.started} started · ${workers.planned} planned`
165
+ : '';
166
+ }
167
+ export function reconnectLabel(run: ScanState) {
168
+ const retry = run.telemetry?.lastReconnect;
169
+ if (!retry) return '';
170
+ const reason = retry.reason ? ` · ${retry.reason.replaceAll('_', ' ')}` : '';
171
+ return `Last connection retry: ${retry.attempt} of ${retry.maxAttempts}${reason}`;
172
+ }
173
+ export function resultCountsLabel(run: ScanState) {
174
+ const counts = run.assessment?.returnedFindings;
175
+ if (!counts)
176
+ return `${run.findings.length} ${run.findings.length === 1 ? 'finding' : 'findings'} shown${run.skipped ? ` · ${run.skipped} other items omitted` : ''}`;
177
+ if (counts.total === null) return 'Returned finding counts unavailable';
178
+ return [
179
+ `${counts.reportable ?? 'Unknown'} reportable`,
180
+ `${counts.informational ?? 'Unknown'} informational`,
181
+ ...(counts.unclassified ? [`${counts.unclassified} unclassified`] : []),
182
+ ].join(' · ');
183
+ }
184
+ export function coverageLabel(run: ScanState) {
185
+ const coverage = run.assessment?.coverage;
186
+ if (!coverage || coverage.source === 'unavailable') return 'Coverage details unavailable';
187
+ return [
188
+ coverage.surfaces.total === null
189
+ ? 'Surfaces unavailable'
190
+ : `${coverage.surfaces.total} review ${coverage.surfaces.total === 1 ? 'surface' : 'surfaces'}`,
191
+ coverage.deferred.total === null
192
+ ? 'Deferred work unavailable'
193
+ : `${coverage.deferred.total} deferred`,
194
+ coverage.openQuestions.total === null
195
+ ? 'Open questions unavailable'
196
+ : `${coverage.openQuestions.total} open ${coverage.openQuestions.total === 1 ? 'question' : 'questions'}`,
197
+ ].join(' · ');
198
+ }
199
+ export function repositoryMatchingLabel(run: ScanState) {
200
+ const matches = run.assessment?.repositoryFindings;
201
+ if (!matches?.available) return 'Repository matching unavailable';
202
+ return `${matches.open ?? 'Unknown'} open · ${matches.closed ?? 'Unknown'} closed · ${matches.confirmedInLatestScan ?? 'Unknown'} confirmed in this scan`;
203
+ }
204
+ export function coverageDetails(run: ScanState) {
205
+ const coverage = run.assessment?.coverage;
206
+ if (!coverage) return [];
207
+ const rows: { label: string; value: string }[] = [];
208
+ const add = <T>(
209
+ label: string,
210
+ list: { total: number | null; items: T[]; omitted: number },
211
+ describe: (item: T) => string,
212
+ ) => {
213
+ rows.push({
214
+ label,
215
+ value:
216
+ list.total === null
217
+ ? 'Not reported'
218
+ : list.total === 0
219
+ ? 'None reported'
220
+ : `${list.total} reported${list.omitted ? `; ${list.omitted} not shown` : ''}`,
221
+ });
222
+ for (const item of list.items) rows.push({ label: '', value: describe(item) });
223
+ };
224
+ add(
225
+ 'Review surfaces',
226
+ coverage.surfaces,
227
+ (surface) =>
228
+ `${surface.label ?? 'Unnamed surface'} · ${surface.disposition.replaceAll('_', ' ')}${surface.riskArea ? ` · ${surface.riskArea}` : ''}`,
229
+ );
230
+ add(
231
+ 'Explicit exclusions',
232
+ coverage.explicitExclusions,
233
+ (item) => `${item.pattern ?? 'Unspecified scope'}${item.reason ? ` · ${item.reason}` : ''}`,
234
+ );
235
+ add(
236
+ 'Deferred work',
237
+ coverage.deferred,
238
+ (item) =>
239
+ `${item.reason ?? 'No reason reported'}${item.paths.items.length ? ` · ${item.paths.items.join(', ')}` : ''}`,
240
+ );
241
+ add(
242
+ 'Resolved deferred work',
243
+ coverage.resolvedDeferred,
244
+ (item) => item.reason ?? 'Resolution recorded',
245
+ );
246
+ add(
247
+ 'Open questions',
248
+ coverage.openQuestions,
249
+ (item) => item.question ?? 'Question details unavailable',
250
+ );
251
+ add('Included scope', coverage.scopeIncludePaths, (item) => item);
252
+ add('Excluded scope', coverage.scopeExcludePaths, (item) => item);
253
+ add('Review-excluded paths', coverage.reviewExcludedPaths, (item) => item);
254
+ add('Limitations', coverage.limitations, (item) => item);
255
+ return rows;
256
+ }
257
+ export function elapsedLabel(start: string | null, end: string | null, now = Date.now()) {
258
+ if (!start) return '';
259
+ const seconds = Math.max(
260
+ 0,
261
+ Math.floor(((end ? Date.parse(end) : now) - Date.parse(start)) / 1000),
262
+ );
263
+ if (!Number.isFinite(seconds)) return '';
264
+ return seconds < 60
265
+ ? `${seconds}s`
266
+ : seconds < 3600
267
+ ? `${Math.floor(seconds / 60)}m ${seconds % 60}s`
268
+ : `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
269
+ }
270
+ export function splitPaths(input: string) {
271
+ return input
272
+ .split('\n')
273
+ .map((s) => s.trim())
274
+ .filter(Boolean);
275
+ }
276
+ export function userError(error: unknown) {
277
+ return error && typeof error === 'object' && 'message' in error
278
+ ? String(error.message)
279
+ : String(error);
280
+ }
281
+
282
+ export function findingText(finding: Finding) {
283
+ return `${finding.title}\nSeverity: ${finding.severity} · ${finding.cwe}\nLocation: ${finding.path}:${finding.line}\n\n${finding.summary}\n\nEvidence:\n${finding.evidence}\n\nSuggested remediation:\n${finding.remediation}`;
284
+ }