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,359 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import type { ScanOptions } from '@openai/codex-security';
4
+ import { safeReportText } from './report-safety.ts';
5
+
6
+ type CallbackValue<K extends keyof ScanOptions> = Parameters<
7
+ NonNullable<ScanOptions[K]> extends (...args: infer A) => unknown
8
+ ? (...args: A) => unknown
9
+ : never
10
+ >[0];
11
+ export interface ScanActivityEntry {
12
+ id: string;
13
+ kind: 'command' | 'tool' | 'reasoning' | 'message';
14
+ status: 'running' | 'completed' | 'failed';
15
+ label: string;
16
+ paths: string[];
17
+ worker?: number;
18
+ startedAt: string;
19
+ updatedAt: string;
20
+ }
21
+ export interface ScanMeasurement {
22
+ coverage: 'complete' | 'partial' | 'unavailable';
23
+ observedAt: string;
24
+ threadCount?: number;
25
+ missingThreadCount?: number;
26
+ usage?: {
27
+ inputTokens: number;
28
+ cachedInputTokens: number;
29
+ outputTokens: number;
30
+ reasoningOutputTokens: number;
31
+ totalTokens: number;
32
+ };
33
+ cost?: {
34
+ baselineUsd: number;
35
+ range?: { min: number; max: number | null };
36
+ model?: string;
37
+ pricingAsOf?: string;
38
+ pricingSource?: string;
39
+ };
40
+ }
41
+ export interface ScanTelemetry {
42
+ activity: ScanActivityEntry[];
43
+ observedWorkers: number[];
44
+ lifecycle?: {
45
+ status: 'running' | 'completed' | 'failed' | 'canceled';
46
+ hasReportableFindings: boolean | null;
47
+ finishedAt: string | null;
48
+ };
49
+ phaseProgress?: {
50
+ phase: string;
51
+ files: { done: number; total: number };
52
+ reviews?: { done: number; total: number };
53
+ };
54
+ measurement?: ScanMeasurement;
55
+ lastReconnect?: {
56
+ attempt: number;
57
+ maxAttempts: number;
58
+ reason?: 'rate_limit' | 'network' | 'authentication' | 'authorization';
59
+ retryAfterSeconds?: number;
60
+ at: string;
61
+ };
62
+ }
63
+ const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0;
64
+ export const count = (n: unknown) =>
65
+ finite(n) ? Math.min(Math.floor(n), Number.MAX_SAFE_INTEGER) : 0;
66
+ const timestamp = (value: unknown, fallback: string) =>
67
+ typeof value === 'string' && Number.isFinite(Date.parse(value))
68
+ ? new Date(value).toISOString()
69
+ : fallback;
70
+ export const emptyTelemetry = (): ScanTelemetry => ({ activity: [], observedWorkers: [] });
71
+
72
+ /** Mentioned repository paths only; never expose command arguments or outside/credential paths. */
73
+ export function safeActivityPaths(values: unknown, repository: string): string[] {
74
+ if (!Array.isArray(values)) return [];
75
+ return [
76
+ ...new Set(
77
+ values.slice(0, 8).flatMap((value) => {
78
+ if (
79
+ typeof value !== 'string' ||
80
+ value.length > 1000 ||
81
+ safeReportText(value) !== value ||
82
+ value.includes('\\') ||
83
+ /[\r\n\t\u202a-\u202e\u2066-\u2069]/.test(value)
84
+ )
85
+ return [];
86
+ const path = relative(resolve(repository), resolve(repository, value));
87
+ if (!path || path === '..' || path.startsWith('..' + sep) || isAbsolute(path)) return [];
88
+ const parts = path.split(sep);
89
+ if (
90
+ parts.some(
91
+ (part) =>
92
+ [
93
+ '.git',
94
+ '.ssh',
95
+ '.aws',
96
+ '.codex',
97
+ '.runtime',
98
+ 'node_modules',
99
+ 'auth.json',
100
+ 'credentials.json',
101
+ 'id_rsa',
102
+ 'id_ed25519',
103
+ ].includes(part) || /^\.env(?:\.|$)/.test(part),
104
+ )
105
+ )
106
+ return [];
107
+ return [parts.join('/')];
108
+ }),
109
+ ),
110
+ ];
111
+ }
112
+ function activityLabel(kind: ScanActivityEntry['kind'], description: string): string {
113
+ // Raw descriptions include commands, arguments and reasoning. Extract only a fixed category.
114
+ if (kind === 'reasoning') return 'Analysis update';
115
+ if (kind === 'message') return 'Scan update';
116
+ const executable = /^\s*(?:\/(?:usr\/)?bin\/)?([a-z][a-z0-9_-]*)(?:\s|$)/i
117
+ .exec(description)?.[1]
118
+ ?.toLowerCase();
119
+ if (['rg', 'grep'].includes(executable ?? '')) return 'Searching source';
120
+ if (['cat', 'sed', 'head', 'tail', 'read_file'].includes(executable ?? ''))
121
+ return 'Reading source';
122
+ if (['ls', 'find', 'list_files'].includes(executable ?? '')) return 'Listing files';
123
+ if (executable === 'git') return 'Repository command';
124
+ return kind === 'tool' ? 'Tool call' : 'Command';
125
+ }
126
+ export function recordActivity(
127
+ telemetry: ScanTelemetry,
128
+ value: CallbackValue<'onActivity'>,
129
+ repository: string,
130
+ now = new Date().toISOString(),
131
+ ): void {
132
+ if (
133
+ !value ||
134
+ typeof value.id !== 'string' ||
135
+ !['command', 'tool', 'reasoning', 'message'].includes(value.kind) ||
136
+ !['running', 'completed', 'failed'].includes(value.status)
137
+ )
138
+ return;
139
+ const worker = finite(value.worker) ? count(value.worker) : undefined;
140
+ // Some SDK activity IDs contain raw prose. Hash before storage or any presentation.
141
+ const id = createHash('sha256')
142
+ .update(`${worker ?? 'coordinator'}:${value.kind}:${value.id}`)
143
+ .digest('hex')
144
+ .slice(0, 24);
145
+ const previous = telemetry.activity.find((item) => item.id === id);
146
+ if (previous && previous.status !== 'running' && value.status === 'running') return;
147
+ const item: ScanActivityEntry = {
148
+ id,
149
+ kind: value.kind,
150
+ status: value.status,
151
+ label: activityLabel(value.kind, value.description ?? ''),
152
+ paths: safeActivityPaths(value.paths, repository),
153
+ ...(worker === undefined ? {} : { worker }),
154
+ startedAt: previous?.startedAt ?? now,
155
+ updatedAt: now,
156
+ };
157
+ if (previous) telemetry.activity[telemetry.activity.indexOf(previous)] = item;
158
+ else telemetry.activity.push(item);
159
+ telemetry.activity = telemetry.activity.slice(-100);
160
+ }
161
+ export function costSummary(value: CallbackValue<'onCost'>): ScanMeasurement['cost'] {
162
+ if (!value || !finite(value.estimatedUsd)) return;
163
+ const range = value.estimatedUsdRange;
164
+ return {
165
+ baselineUsd: value.estimatedUsd,
166
+ ...(range &&
167
+ finite(range.min) &&
168
+ (range.max === null || (finite(range.max) && range.max >= range.min))
169
+ ? { range: { min: range.min, max: range.max } }
170
+ : {}),
171
+ model: safeReportText(value.model, 200),
172
+ ...(value.pricing?.asOf ? { pricingAsOf: safeReportText(value.pricing.asOf, 40) } : {}),
173
+ ...(value.pricing?.source === 'https://developers.openai.com/api/docs/pricing'
174
+ ? { pricingSource: value.pricing.source }
175
+ : {}),
176
+ };
177
+ }
178
+ export function measurementSummary(
179
+ value: CallbackValue<'onMeasurement'>,
180
+ now = new Date().toISOString(),
181
+ ): ScanMeasurement {
182
+ const usage = value.usage;
183
+ return {
184
+ coverage: ['complete', 'partial', 'unavailable'].includes(value.coverage)
185
+ ? value.coverage
186
+ : 'unavailable',
187
+ observedAt: timestamp(value.observedAt, now),
188
+ threadCount: count(value.threadCount),
189
+ missingThreadCount: count(value.missingThreadCount),
190
+ ...(usage &&
191
+ [
192
+ usage.input_tokens,
193
+ usage.cached_input_tokens,
194
+ usage.output_tokens,
195
+ usage.reasoning_output_tokens,
196
+ usage.total_tokens,
197
+ ].every(finite)
198
+ ? {
199
+ usage: {
200
+ inputTokens: count(usage.input_tokens),
201
+ cachedInputTokens: count(usage.cached_input_tokens),
202
+ outputTokens: count(usage.output_tokens),
203
+ reasoningOutputTokens: count(usage.reasoning_output_tokens),
204
+ totalTokens: count(usage.total_tokens),
205
+ },
206
+ }
207
+ : {}),
208
+ ...(value.cost ? { cost: costSummary(value.cost) } : {}),
209
+ };
210
+ }
211
+ export function lifecycleSummary(value: CallbackValue<'onLifecycle'>): ScanTelemetry['lifecycle'] {
212
+ if (!['running', 'completed', 'failed', 'canceled'].includes(value.status)) return;
213
+ return {
214
+ status: value.status,
215
+ hasReportableFindings:
216
+ typeof value.hasReportableFindings === 'boolean' ? value.hasReportableFindings : null,
217
+ finishedAt: value.finishedAt ? timestamp(value.finishedAt, '') || null : null,
218
+ };
219
+ }
220
+ export function phaseSummary(
221
+ value: CallbackValue<'onProgress'>,
222
+ ): NonNullable<ScanTelemetry['phaseProgress']> {
223
+ return {
224
+ phase: [
225
+ 'preflight',
226
+ 'threat_model',
227
+ 'discovery',
228
+ 'validation',
229
+ 'attack_path',
230
+ 'reporting',
231
+ ].includes(value.phase)
232
+ ? value.phase
233
+ : 'unknown',
234
+ files: { done: count(value.filesCompleted), total: count(value.filesTotal) },
235
+ ...(value.reviewItems
236
+ ? {
237
+ reviews: {
238
+ done: count(value.reviewItems.completed),
239
+ total: count(value.reviewItems.total),
240
+ },
241
+ }
242
+ : {}),
243
+ };
244
+ }
245
+
246
+ /** Restore only our projected schema, never arbitrary extra fields from a local JSON record. */
247
+ export function sanitizeStoredTelemetry(
248
+ value: unknown,
249
+ repository: string,
250
+ ): ScanTelemetry | undefined {
251
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return;
252
+ const stored = value as Partial<ScanTelemetry>,
253
+ output = emptyTelemetry();
254
+ const labels = [
255
+ 'Analysis update',
256
+ 'Scan update',
257
+ 'Searching source',
258
+ 'Reading source',
259
+ 'Listing files',
260
+ 'Repository command',
261
+ 'Tool call',
262
+ 'Command',
263
+ ];
264
+ for (const item of Array.isArray(stored.activity) ? stored.activity.slice(-100) : []) {
265
+ if (
266
+ !item ||
267
+ !/^[a-f0-9]{24}$/.test(item.id) ||
268
+ !['command', 'tool', 'reasoning', 'message'].includes(item.kind) ||
269
+ !['running', 'completed', 'failed'].includes(item.status)
270
+ )
271
+ continue;
272
+ const startedAt = timestamp(item.startedAt, '');
273
+ const updatedAt = timestamp(item.updatedAt, '');
274
+ if (!startedAt || !updatedAt) continue;
275
+ output.activity.push({
276
+ id: item.id,
277
+ kind: item.kind,
278
+ status: item.status,
279
+ label: labels.includes(item.label) ? item.label : activityLabel(item.kind, ''),
280
+ paths: safeActivityPaths(item.paths, repository),
281
+ ...(finite(item.worker) ? { worker: count(item.worker) } : {}),
282
+ startedAt,
283
+ updatedAt,
284
+ });
285
+ }
286
+ output.observedWorkers = [
287
+ ...new Set(
288
+ (Array.isArray(stored.observedWorkers) ? stored.observedWorkers : [])
289
+ .filter(finite)
290
+ .map(count),
291
+ ),
292
+ ].slice(0, 128);
293
+ if (stored.lifecycle)
294
+ output.lifecycle = lifecycleSummary(stored.lifecycle as CallbackValue<'onLifecycle'>);
295
+ const progress = stored.phaseProgress;
296
+ if (progress && typeof progress === 'object')
297
+ output.phaseProgress = phaseSummary({
298
+ phase: progress.phase,
299
+ filesCompleted: progress.files?.done,
300
+ filesTotal: progress.files?.total,
301
+ ...(progress.reviews
302
+ ? { reviewItems: { completed: progress.reviews.done, total: progress.reviews.total } }
303
+ : {}),
304
+ } as CallbackValue<'onProgress'>);
305
+ const m = stored.measurement;
306
+ if (
307
+ m &&
308
+ ['complete', 'partial', 'unavailable'].includes(m.coverage) &&
309
+ timestamp(m.observedAt, '')
310
+ ) {
311
+ const c = m.cost;
312
+ const cost =
313
+ c && typeof c === 'object'
314
+ ? costSummary({
315
+ estimatedUsd: c.baselineUsd,
316
+ model: c.model,
317
+ estimatedUsdRange: c.range,
318
+ pricing: { asOf: c.pricingAsOf, source: c.pricingSource },
319
+ } as CallbackValue<'onCost'>)
320
+ : undefined;
321
+ output.measurement = {
322
+ coverage: m.coverage,
323
+ observedAt: timestamp(m.observedAt, ''),
324
+ ...(finite(m.threadCount) ? { threadCount: count(m.threadCount) } : {}),
325
+ ...(finite(m.missingThreadCount) ? { missingThreadCount: count(m.missingThreadCount) } : {}),
326
+ ...(cost ? { cost } : {}),
327
+ ...(m.usage &&
328
+ [
329
+ m.usage.inputTokens,
330
+ m.usage.cachedInputTokens,
331
+ m.usage.outputTokens,
332
+ m.usage.reasoningOutputTokens,
333
+ m.usage.totalTokens,
334
+ ].every(finite)
335
+ ? {
336
+ usage: {
337
+ inputTokens: count(m.usage.inputTokens),
338
+ cachedInputTokens: count(m.usage.cachedInputTokens),
339
+ outputTokens: count(m.usage.outputTokens),
340
+ reasoningOutputTokens: count(m.usage.reasoningOutputTokens),
341
+ totalTokens: count(m.usage.totalTokens),
342
+ },
343
+ }
344
+ : {}),
345
+ };
346
+ }
347
+ const r = stored.lastReconnect;
348
+ if (r && finite(r.attempt) && finite(r.maxAttempts) && timestamp(r.at, ''))
349
+ output.lastReconnect = {
350
+ attempt: count(r.attempt),
351
+ maxAttempts: count(r.maxAttempts),
352
+ at: timestamp(r.at, ''),
353
+ ...(['rate_limit', 'network', 'authentication', 'authorization'].includes(r.reason ?? '')
354
+ ? { reason: r.reason }
355
+ : {}),
356
+ ...(finite(r.retryAfterSeconds) ? { retryAfterSeconds: r.retryAfterSeconds } : {}),
357
+ };
358
+ return output;
359
+ }
@@ -0,0 +1,182 @@
1
+ import { loadGlobalSettings, mergeSettings, type ResolvedSdkSettings } from './sdk-settings.ts';
2
+ import { realpath, stat, open } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { isAbsolute, relative, resolve, sep, extname } from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+
8
+ export interface ScanInputs {
9
+ mode?: 'standard' | 'deep';
10
+ repository?: string;
11
+ scopePaths?: string[];
12
+ scanPrompt?: string;
13
+ contextFiles?: string[];
14
+ }
15
+ export interface ScanPlan {
16
+ settings: ResolvedSdkSettings;
17
+ globalConfigFile: string;
18
+ globalConfigDigest: string | null;
19
+ id: string;
20
+ repository: string;
21
+ scopePaths: string[];
22
+ scanPrompt: string;
23
+ contextFiles: { path: string; bytes: number; sha256: string }[];
24
+ expiresAt: string;
25
+ mock: boolean;
26
+ inputs: ScanInputs;
27
+ }
28
+ const hash = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
29
+ const inside = (root: string, path: string) => {
30
+ const r = relative(root, path);
31
+ return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r);
32
+ };
33
+ const denied = new Set(['.git', '.ssh', '.aws', '.codex', '.runtime', 'node_modules']);
34
+ function validateRelative(path: string) {
35
+ if (
36
+ !path ||
37
+ isAbsolute(path) ||
38
+ path.includes('\\') ||
39
+ path.split('/').some((p) => p === '..' || denied.has(p)) ||
40
+ /[\x00-\x1f*?[\]{}]/.test(path)
41
+ )
42
+ throw new Error(
43
+ 'Use existing repository-relative paths without traversal, globs, credentials, or runtime directories.',
44
+ );
45
+ if (
46
+ path
47
+ .split('/')
48
+ .some(
49
+ (p) =>
50
+ /^\.env(?:\.|$)/.test(p) ||
51
+ /^(auth\.json|credentials(?:\.json)?|id_rsa|id_ed25519)$/.test(p),
52
+ )
53
+ )
54
+ throw new Error('Credential files cannot be supplied as scan context or scope.');
55
+ }
56
+ async function checkedPath(root: string, path: string) {
57
+ validateRelative(path);
58
+ const canonical = await realpath(resolve(root, path));
59
+ if (!inside(root, canonical)) throw new Error('Path escapes the selected repository.');
60
+ validateRelative(relative(root, canonical) || '.');
61
+ return canonical;
62
+ }
63
+ async function contextInfo(root: string, path: string) {
64
+ const canonical = await checkedPath(root, path);
65
+ if (!['.md', '.txt', '.rst', '.json', '.yaml', '.yml'].includes(extname(path).toLowerCase()))
66
+ throw new Error(
67
+ 'Context files must be text documentation (.md, .txt, .rst, .json, .yaml, .yml).',
68
+ );
69
+ const file = await open(canonical, constants.O_RDONLY | constants.O_NOFOLLOW);
70
+ try {
71
+ const info = await file.stat();
72
+ if (!info.isFile() || info.size > 64 * 1024)
73
+ throw new Error('Each context file must be a regular file of at most 64 KiB.');
74
+ const bytes = Buffer.alloc(64 * 1024 + 1);
75
+ const result = await file.read(bytes, 0, bytes.length, 0);
76
+ const content = bytes.subarray(0, result.bytesRead);
77
+ if (content.length > 64 * 1024 || content.includes(0))
78
+ throw new Error('Context file must be bounded text.');
79
+ return {
80
+ path: relative(root, canonical).split(sep).join('/'),
81
+ bytes: content.length,
82
+ sha256: hash(content),
83
+ };
84
+ } finally {
85
+ await file.close();
86
+ }
87
+ }
88
+ function parseInputs(value: unknown): ScanInputs {
89
+ if (!value || typeof value !== 'object' || Array.isArray(value))
90
+ throw new Error('Scan inputs must be an object.');
91
+ const data = value as Record<string, unknown>;
92
+ const allowed = ['repository', 'scopePaths', 'scanPrompt', 'contextFiles', 'mode'];
93
+ for (const key of Object.keys(data))
94
+ if (!allowed.includes(key))
95
+ throw new Error(
96
+ `Unsupported scan input: ${key}. Approval, auth and SDK settings cannot come from model tool input.`,
97
+ );
98
+ for (const key of ['repository', 'scanPrompt'])
99
+ if (
100
+ data[key] !== undefined &&
101
+ (typeof data[key] !== 'string' ||
102
+ (data[key] as string).length > (key === 'scanPrompt' ? 8000 : 4096))
103
+ )
104
+ throw new Error(`Invalid ${key}.`);
105
+ for (const key of ['scopePaths', 'contextFiles'])
106
+ if (
107
+ data[key] !== undefined &&
108
+ (!Array.isArray(data[key]) ||
109
+ data[key].length > (key === 'scopePaths' ? 32 : 8) ||
110
+ data[key].some((p: unknown) => typeof p !== 'string' || p.length > 4096))
111
+ )
112
+ throw new Error(`Invalid ${key}.`);
113
+ if (data.mode !== undefined && data.mode !== 'standard' && data.mode !== 'deep')
114
+ throw new Error('mode must be standard or deep.');
115
+ return data as ScanInputs;
116
+ }
117
+ export async function prepareScan(
118
+ currentProject: string,
119
+ raw: unknown = {},
120
+ mock = false,
121
+ ): Promise<ScanPlan> {
122
+ const input = parseInputs(raw);
123
+ // Never let node:path fall back to the plugin process's working directory.
124
+ // A verified session location or an explicit absolute target must anchor the request.
125
+ const target = input.repository || '.';
126
+ if (!isAbsolute(target) && (!currentProject || !isAbsolute(currentProject)))
127
+ throw new Error('No local project is selected. Choose an absolute repository path explicitly.');
128
+ const repository = await realpath(isAbsolute(target) ? target : resolve(currentProject, target));
129
+ if (!(await stat(repository)).isDirectory()) throw new Error('Repository must be a directory.');
130
+ const home = await realpath(homedir());
131
+ if (inside(repository, home))
132
+ throw new Error(
133
+ 'Choose a specific project repository explicitly. Your home folder and its parent directories cannot be scan targets.',
134
+ );
135
+ const global = await loadGlobalSettings();
136
+ const settings = mergeSettings(global.settings, input.mode ? { scan: { mode: input.mode } } : {});
137
+ const merged = input;
138
+ const scopePaths: string[] = [];
139
+ for (const path of merged.scopePaths ?? [])
140
+ scopePaths.push(
141
+ relative(repository, await checkedPath(repository, path))
142
+ .split(sep)
143
+ .join('/') || '.',
144
+ );
145
+ const contextFiles = [];
146
+ for (const path of merged.contextFiles ?? [])
147
+ contextFiles.push(await contextInfo(repository, path));
148
+ if (contextFiles.reduce((n, f) => n + f.bytes, 0) > 128 * 1024)
149
+ throw new Error('Combined context is limited to 128 KiB.');
150
+ const scanPrompt = merged.scanPrompt ?? '';
151
+ const normalized = {
152
+ ...(input.mode ? { mode: input.mode } : {}),
153
+ repository,
154
+ scopePaths: [...new Set(scopePaths)],
155
+ scanPrompt,
156
+ contextFiles: [...new Set(contextFiles.map((f) => f.path))],
157
+ };
158
+ return {
159
+ settings,
160
+ globalConfigFile: global.path,
161
+ globalConfigDigest: global.digest,
162
+ id: randomUUID(),
163
+ ...normalized,
164
+ contextFiles,
165
+ expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
166
+ mock,
167
+ inputs: normalized,
168
+ };
169
+ }
170
+ export async function validatePlan(plan: ScanPlan) {
171
+ if (Date.now() >= Date.parse(plan.expiresAt))
172
+ throw new Error('Scan preview expired. Prepare and review it again.');
173
+ const current = await prepareScan(plan.repository, plan.inputs, plan.mock);
174
+ if (
175
+ current.repository !== plan.repository ||
176
+ current.globalConfigDigest !== plan.globalConfigDigest ||
177
+ JSON.stringify(current.settings) !== JSON.stringify(plan.settings) ||
178
+ JSON.stringify(current.contextFiles) !== JSON.stringify(plan.contextFiles) ||
179
+ JSON.stringify(current.scopePaths) !== JSON.stringify(plan.scopePaths)
180
+ )
181
+ throw new Error('Target or context changed after preview. Prepare and review it again.');
182
+ }
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { basename, resolve } from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { collectFiles, scanFile } from './scanner.mjs';
5
+
6
+ export function initialState(repository = '', instanceId = '') {
7
+ return {
8
+ instanceId,
9
+ status: 'idle',
10
+ scanId: null,
11
+ phase: 'Ready',
12
+ progress: { done: 0, total: 0 },
13
+ findings: [],
14
+ events: [],
15
+ error: null,
16
+ startedAt: null,
17
+ finishedAt: null,
18
+ repository,
19
+ engine: 'local-patterns (prototype)',
20
+ skipped: 0,
21
+ revision: 0,
22
+ };
23
+ }
24
+
25
+ export class ScanService {
26
+ #instanceId = randomUUID();
27
+ #state;
28
+ #listeners = new Set();
29
+ #controller;
30
+ #task;
31
+ #disposed = false;
32
+ constructor(root, { delayMs = 0 } = {}) {
33
+ this.root = resolve(root);
34
+ this.delayMs = Math.max(0, Math.min(delayMs, 2000));
35
+ this.#state = initialState(basename(this.root), this.#instanceId);
36
+ }
37
+ getState() {
38
+ return structuredClone(this.#state);
39
+ }
40
+ subscribe(listener) {
41
+ this.#listeners.add(listener);
42
+ return () => this.#listeners.delete(listener);
43
+ }
44
+ #emit(message) {
45
+ if (message) this.#state.events.push({ time: new Date().toISOString(), message });
46
+ this.#state.events = this.#state.events.slice(-100);
47
+ this.#state.revision++;
48
+ for (const listener of this.#listeners) {
49
+ try {
50
+ listener(this.getState());
51
+ } catch {
52
+ /* A broken view must not stop the scan. */
53
+ }
54
+ }
55
+ }
56
+ start() {
57
+ if (this.#disposed) throw new Error('Scan service is closed');
58
+ // Idempotent under repeated clicks; only one read-only scan per instance.
59
+ if (this.#state.status === 'running') return this.getState();
60
+ const revision = this.#state.revision;
61
+ this.#state = {
62
+ ...initialState(basename(this.root), this.#instanceId),
63
+ revision,
64
+ status: 'running',
65
+ scanId: randomUUID(),
66
+ phase: 'Discovering source files',
67
+ startedAt: new Date().toISOString(),
68
+ };
69
+ this.#controller = new AbortController();
70
+ this.#emit('Local scan started. No provider, credentials, or network calls.');
71
+ this.#task = this.#run(this.#controller.signal);
72
+ return this.getState();
73
+ }
74
+ async #run(signal) {
75
+ try {
76
+ const { base, files } = await collectFiles(this.root, signal);
77
+ this.#state.progress.total = files.length;
78
+ this.#emit(
79
+ `Found ${files.length} supported source files; hidden paths, dependencies, and symlinks excluded.`,
80
+ );
81
+ for (const path of files) {
82
+ signal.throwIfAborted();
83
+ if (this.delayMs) await delay(this.delayMs, undefined, { signal });
84
+ const result = await scanFile(base, path, signal);
85
+ this.#state.findings.push(...result.findings);
86
+ this.#state.progress.done++;
87
+ if (result.skipped) this.#state.skipped++;
88
+ this.#state.phase = `Checking files (${this.#state.progress.done}/${files.length})`;
89
+ this.#emit(
90
+ result.findings.length
91
+ ? `${result.findings.length} candidate finding(s) in ${result.findings[0].path}`
92
+ : undefined,
93
+ );
94
+ }
95
+ signal.throwIfAborted();
96
+ this.#state.status = 'completed';
97
+ this.#state.phase = 'Pattern scan complete';
98
+ this.#emit(
99
+ `Completed with ${this.#state.findings.length} candidate findings. Not a security clearance or Codex Security scan.`,
100
+ );
101
+ } catch (error) {
102
+ this.#state.status = signal.aborted ? 'cancelled' : 'error';
103
+ this.#state.phase = signal.aborted ? 'Cancelled; partial results retained' : 'Scan failed';
104
+ this.#state.error = signal.aborted ? null : String(error.message ?? error);
105
+ this.#emit(
106
+ signal.aborted
107
+ ? 'Scan cancelled. Partial findings are not a complete result.'
108
+ : `Scan failed: ${this.#state.error}`,
109
+ );
110
+ } finally {
111
+ this.#state.finishedAt = new Date().toISOString();
112
+ this.#emit();
113
+ }
114
+ }
115
+ cancel() {
116
+ if (this.#state.status === 'running') this.#controller.abort();
117
+ return this.getState();
118
+ }
119
+ async wait() {
120
+ await this.#task;
121
+ return this.getState();
122
+ }
123
+ async dispose() {
124
+ this.#disposed = true;
125
+ this.cancel();
126
+ await this.#task;
127
+ this.#listeners.clear();
128
+ }
129
+ }