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.
- package/CONTRIBUTING.md +15 -0
- package/DISCLOSURE +19 -0
- package/LICENSE +202 -0
- package/README.md +103 -2
- package/SECURITY.md +22 -0
- package/dist/gui-experimental.cjs +17 -0
- package/docs/GLOBAL-CONFIG.md +64 -0
- package/docs/MULTI-SCAN.md +62 -0
- package/docs/PUBLISHING.md +71 -0
- package/docs/RELEASING.md +53 -0
- package/docs/SDK.md +36 -0
- package/fixtures/vulnerable/docs/ARCHITECTURE.md +2 -0
- package/fixtures/vulnerable/src/accounts.js +4 -0
- package/fixtures/vulnerable/src/diagnostics.js +5 -0
- package/fixtures/vulnerable/src/download.js +5 -0
- package/fixtures/vulnerable/src/profile.js +4 -0
- package/package.json +79 -4
- package/src/core/codex-adapter.ts +430 -0
- package/src/core/credential-guard.ts +83 -0
- package/src/core/planned-scan.ts +316 -0
- package/src/core/report-safety.ts +41 -0
- package/src/core/result-summary.ts +512 -0
- package/src/core/run-history.ts +107 -0
- package/src/core/run-telemetry.ts +359 -0
- package/src/core/scan-plan.ts +182 -0
- package/src/core/scan-service.mjs +129 -0
- package/src/core/scanner.mjs +129 -0
- package/src/core/sdk-settings.ts +123 -0
- package/src/core/security-operations.ts +598 -0
- package/src/gui.tsx +102 -0
- package/src/operation-tools.ts +247 -0
- package/src/plan-view.ts +47 -0
- package/src/plugin.ts +346 -0
- package/src/rpc.ts +124 -0
- package/src/scan-display.ts +284 -0
- package/src/security-operations-panel.tsx +551 -0
- package/src/security-panel.tsx +1003 -0
- package/src/security-styles.ts +10 -0
- package/src/security-tui-operations.tsx +343 -0
- package/src/state-order.ts +21 -0
- package/src/tui.tsx +630 -0
- package/src/types.ts +84 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
CodexSecurity,
|
|
6
|
+
CodexSecurityConfig,
|
|
7
|
+
SecurityPolicyDraft,
|
|
8
|
+
SecurityPolicyOptions,
|
|
9
|
+
ValidationOptions,
|
|
10
|
+
ValidationResult,
|
|
11
|
+
} from '@openai/codex-security';
|
|
12
|
+
import { prepareScan, validatePlan, type ScanPlan } from './scan-plan.ts';
|
|
13
|
+
import { safeReportText } from './report-safety.ts';
|
|
14
|
+
import {
|
|
15
|
+
requireDedicatedSecurityCredentials,
|
|
16
|
+
DedicatedSecurityCredentialsRequiredError,
|
|
17
|
+
} from './credential-guard.ts';
|
|
18
|
+
|
|
19
|
+
export type SecurityOperationKind = 'policy' | 'validation';
|
|
20
|
+
export type SecurityOperationStatus =
|
|
21
|
+
'prepared' | 'running' | 'awaiting_answers' | 'completed' | 'cancelled' | 'error';
|
|
22
|
+
export interface SecurityOperationInputs {
|
|
23
|
+
kind: SecurityOperationKind;
|
|
24
|
+
repository?: string;
|
|
25
|
+
/** Existing repository-relative directory. Policy generation only. */
|
|
26
|
+
path?: string;
|
|
27
|
+
contextFiles?: string[];
|
|
28
|
+
/** Literal finding text or a JSON object, never a filename. Validation only. */
|
|
29
|
+
finding?: string | Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface SecurityOperationPlan extends ScanPlan {
|
|
32
|
+
kind: SecurityOperationKind;
|
|
33
|
+
path?: string;
|
|
34
|
+
finding?: string;
|
|
35
|
+
/** Policy has an estimated SDK limit, not a guaranteed billing cap. Validation has none. */
|
|
36
|
+
costLimitEnforced: boolean;
|
|
37
|
+
costNotice: string;
|
|
38
|
+
}
|
|
39
|
+
export interface SecurityOperationQuestion {
|
|
40
|
+
id: string;
|
|
41
|
+
questions: string[];
|
|
42
|
+
createdAt: string;
|
|
43
|
+
}
|
|
44
|
+
export type SecurityOperationResult =
|
|
45
|
+
| {
|
|
46
|
+
kind: 'policy';
|
|
47
|
+
content: string;
|
|
48
|
+
reviewNotes: string[];
|
|
49
|
+
targetPath: string;
|
|
50
|
+
applied: false;
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
kind: 'validation';
|
|
55
|
+
disposition: ValidationResult['disposition'];
|
|
56
|
+
report: string;
|
|
57
|
+
truncated: boolean;
|
|
58
|
+
};
|
|
59
|
+
export interface SecurityOperationState {
|
|
60
|
+
operationId: string;
|
|
61
|
+
kind: SecurityOperationKind;
|
|
62
|
+
repository: string;
|
|
63
|
+
status: SecurityOperationStatus;
|
|
64
|
+
phase: string;
|
|
65
|
+
plan: SecurityOperationPlan;
|
|
66
|
+
question: SecurityOperationQuestion | null;
|
|
67
|
+
answeredQuestionCount: number;
|
|
68
|
+
result: SecurityOperationResult | null;
|
|
69
|
+
estimatedCostUsd?: number;
|
|
70
|
+
error: string | null;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
startedAt: string | null;
|
|
73
|
+
finishedAt: string | null;
|
|
74
|
+
revision: number;
|
|
75
|
+
}
|
|
76
|
+
export type SecurityOperationsClient = Pick<CodexSecurity, 'generatePolicy' | 'validate' | 'close'>;
|
|
77
|
+
export type SecurityOperationsFactory = (
|
|
78
|
+
config?: CodexSecurityConfig,
|
|
79
|
+
) => SecurityOperationsClient | Promise<SecurityOperationsClient>;
|
|
80
|
+
export interface SecurityOperationsOptions {
|
|
81
|
+
createClient?: SecurityOperationsFactory;
|
|
82
|
+
mock?: boolean;
|
|
83
|
+
}
|
|
84
|
+
export interface SecurityOperationApproval {
|
|
85
|
+
operationId: string;
|
|
86
|
+
/** Trusted UI/host permission decision. Never expose approval booleans as model input. */
|
|
87
|
+
approveOpenAIInference: true;
|
|
88
|
+
/** Required separately because SDK 0.9.1 validation has no maxCostUsd support. */
|
|
89
|
+
approveUncappedValidation?: true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Entry = {
|
|
93
|
+
state: SecurityOperationState;
|
|
94
|
+
controller: AbortController;
|
|
95
|
+
starting?: Promise<SecurityOperationState>;
|
|
96
|
+
task?: Promise<void>;
|
|
97
|
+
pending?: {
|
|
98
|
+
id: string;
|
|
99
|
+
resolve: (answer: string | undefined) => void;
|
|
100
|
+
reject: (error: Error) => void;
|
|
101
|
+
cleanup: () => void;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const realClient: SecurityOperationsFactory = async (config) => {
|
|
105
|
+
await requireDedicatedSecurityCredentials();
|
|
106
|
+
const { CodexSecurity } = await import('@openai/codex-security');
|
|
107
|
+
return new CodexSecurity(config);
|
|
108
|
+
};
|
|
109
|
+
const active = (state: SecurityOperationState) =>
|
|
110
|
+
state.status === 'running' || state.status === 'awaiting_answers';
|
|
111
|
+
const textLimit = 48_000;
|
|
112
|
+
|
|
113
|
+
function parseInputs(raw: unknown): SecurityOperationInputs {
|
|
114
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
115
|
+
throw new Error('Operation inputs must be an object.');
|
|
116
|
+
const value = raw as Record<string, unknown>;
|
|
117
|
+
for (const key of Object.keys(value))
|
|
118
|
+
if (!['kind', 'repository', 'path', 'contextFiles', 'finding'].includes(key))
|
|
119
|
+
throw new Error(
|
|
120
|
+
'Unsupported operation input. Approval, authentication, and SDK settings cannot come from model input.',
|
|
121
|
+
);
|
|
122
|
+
if (value.kind !== 'policy' && value.kind !== 'validation')
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Choose policy or validation. Patch and fix verification require a separate isolated workspace.',
|
|
125
|
+
);
|
|
126
|
+
if (value.kind === 'policy' && value.finding !== undefined)
|
|
127
|
+
throw new Error('Finding input is only supported for validation.');
|
|
128
|
+
if (value.kind === 'validation' && value.path !== undefined)
|
|
129
|
+
throw new Error(
|
|
130
|
+
'Validation reviews the selected repository. Policy path is not a validation scope.',
|
|
131
|
+
);
|
|
132
|
+
if (
|
|
133
|
+
value.path !== undefined &&
|
|
134
|
+
(typeof value.path !== 'string' || !value.path || value.path.length > 4096)
|
|
135
|
+
)
|
|
136
|
+
throw new Error('Invalid policy directory.');
|
|
137
|
+
// The caller may reuse or mutate its input object while path checks await I/O.
|
|
138
|
+
// Capture it now rather than letting later edits change the operation kind/scope.
|
|
139
|
+
return structuredClone(value) as unknown as SecurityOperationInputs;
|
|
140
|
+
}
|
|
141
|
+
function findingText(value: SecurityOperationInputs['finding']): string {
|
|
142
|
+
let text: string | undefined;
|
|
143
|
+
if (typeof value === 'string') text = value;
|
|
144
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
145
|
+
try {
|
|
146
|
+
text = JSON.stringify(value);
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error('Finding must be JSON serializable.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!text?.trim() || Buffer.byteLength(text) > 32 * 1024)
|
|
152
|
+
throw new Error('Validation requires literal finding text or an object of at most 32 KiB.');
|
|
153
|
+
if (safeReportText(text) !== text)
|
|
154
|
+
throw new Error(
|
|
155
|
+
'Remove credential-like values and control characters from the finding before preparing validation.',
|
|
156
|
+
);
|
|
157
|
+
return text;
|
|
158
|
+
}
|
|
159
|
+
async function checkPolicyDirectory(plan: ScanPlan) {
|
|
160
|
+
const directory = resolve(plan.repository, plan.scopePaths[0] || '.');
|
|
161
|
+
if (!(await stat(directory)).isDirectory())
|
|
162
|
+
throw new Error('Policy path must be an existing directory.');
|
|
163
|
+
// SDK policy generation discovers the enclosing Git root. Do not silently broaden
|
|
164
|
+
// the approved repository, or cross a nested worktree boundary.
|
|
165
|
+
for (let current = directory; ; current = dirname(current)) {
|
|
166
|
+
try {
|
|
167
|
+
await lstat(join(current, '.git'));
|
|
168
|
+
if (current !== plan.repository)
|
|
169
|
+
throw new Error(
|
|
170
|
+
'Select the Git repository root as repository and its component as policy path.',
|
|
171
|
+
);
|
|
172
|
+
break;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
175
|
+
}
|
|
176
|
+
if (dirname(current) === current) break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function checkRepositoryPath(repository: string) {
|
|
180
|
+
if (
|
|
181
|
+
repository
|
|
182
|
+
.split(/[\\/]/)
|
|
183
|
+
.some(
|
|
184
|
+
(part) =>
|
|
185
|
+
['.git', '.ssh', '.aws', '.codex', '.runtime', 'node_modules'].includes(part) ||
|
|
186
|
+
/^\.env(?:\.|$)/.test(part),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
throw new Error('Credential, runtime, and dependency directories cannot be operation targets.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Memory-only, project-bound operation coordinator. Preparation never creates the SDK.
|
|
194
|
+
* Each immutable preview can start once. Auth/paid execution belongs to the trusted host.
|
|
195
|
+
* Generated policies remain drafts; this class never applies a file or publishes findings.
|
|
196
|
+
*/
|
|
197
|
+
export class SecurityOperationsService {
|
|
198
|
+
readonly maxConcurrent = 2;
|
|
199
|
+
#project: string;
|
|
200
|
+
#factory: SecurityOperationsFactory;
|
|
201
|
+
#mock: boolean;
|
|
202
|
+
#disposed = false;
|
|
203
|
+
#entries = new Map<string, Entry>();
|
|
204
|
+
#listeners = new Set<(state: SecurityOperationState) => void>();
|
|
205
|
+
constructor(project: string, options: SecurityOperationsOptions = {}) {
|
|
206
|
+
this.#project = project; // prepareScan validates the anchor; never guess process.cwd().
|
|
207
|
+
this.#factory = options.createClient ?? realClient;
|
|
208
|
+
this.#mock = options.mock ?? false;
|
|
209
|
+
if (this.#mock && !options.createClient)
|
|
210
|
+
throw new Error('Mock operations require an injected mock SDK client.');
|
|
211
|
+
}
|
|
212
|
+
#entry(operationId: string) {
|
|
213
|
+
const entry = this.#entries.get(operationId);
|
|
214
|
+
if (!entry) throw new Error('Unknown security operation.');
|
|
215
|
+
return entry;
|
|
216
|
+
}
|
|
217
|
+
getState(operationId: string): SecurityOperationState {
|
|
218
|
+
return structuredClone(this.#entry(operationId).state);
|
|
219
|
+
}
|
|
220
|
+
list(): SecurityOperationState[] {
|
|
221
|
+
return [...this.#entries.values()].map((entry) => structuredClone(entry.state)).reverse();
|
|
222
|
+
}
|
|
223
|
+
subscribe(listener: (state: SecurityOperationState) => void) {
|
|
224
|
+
this.#listeners.add(listener);
|
|
225
|
+
return () => {
|
|
226
|
+
this.#listeners.delete(listener);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
#emit(entry: Entry) {
|
|
230
|
+
entry.state.revision++;
|
|
231
|
+
for (const listener of this.#listeners) {
|
|
232
|
+
try {
|
|
233
|
+
listener(structuredClone(entry.state));
|
|
234
|
+
} catch {
|
|
235
|
+
/* Isolate views. */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async prepare(raw: SecurityOperationInputs): Promise<SecurityOperationState> {
|
|
240
|
+
if (this.#disposed) throw new Error('Security operations service is closed.');
|
|
241
|
+
const input = parseInputs(raw);
|
|
242
|
+
const finding = input.kind === 'validation' ? findingText(input.finding) : undefined;
|
|
243
|
+
const scan = await prepareScan(
|
|
244
|
+
this.#project,
|
|
245
|
+
{
|
|
246
|
+
...(input.repository === undefined ? {} : { repository: input.repository }),
|
|
247
|
+
contextFiles: input.contextFiles ?? [],
|
|
248
|
+
...(input.kind === 'policy' ? { scopePaths: [input.path ?? '.'] } : {}),
|
|
249
|
+
},
|
|
250
|
+
this.#mock,
|
|
251
|
+
);
|
|
252
|
+
checkRepositoryPath(scan.repository);
|
|
253
|
+
if (input.kind === 'policy') await checkPolicyDirectory(scan);
|
|
254
|
+
if (this.#disposed) throw new Error('Security operations service is closed.');
|
|
255
|
+
// Keep bounded per-session state. Never evict running operations or outstanding questions.
|
|
256
|
+
for (const [id, entry] of this.#entries)
|
|
257
|
+
if (
|
|
258
|
+
!active(entry.state) &&
|
|
259
|
+
!entry.starting &&
|
|
260
|
+
Date.parse(entry.state.plan.expiresAt) <= Date.now()
|
|
261
|
+
)
|
|
262
|
+
this.#entries.delete(id);
|
|
263
|
+
if (this.#entries.size >= 50)
|
|
264
|
+
throw new Error(
|
|
265
|
+
'Too many recent operations. Wait for previews to expire or open a fresh session.',
|
|
266
|
+
);
|
|
267
|
+
const plan: SecurityOperationPlan = {
|
|
268
|
+
...scan,
|
|
269
|
+
kind: input.kind,
|
|
270
|
+
...(input.kind === 'policy' ? { path: scan.scopePaths[0] } : { finding }),
|
|
271
|
+
costLimitEnforced: input.kind === 'policy',
|
|
272
|
+
costNotice:
|
|
273
|
+
input.kind === 'policy'
|
|
274
|
+
? `OpenAI model inference uses an estimated USD ${scan.settings.scan.maxCostUsd} SDK limit, not a guaranteed billing cap.`
|
|
275
|
+
: 'OpenAI model inference has no SDK cost limit for validation in version 0.9.1. The scan budget does not apply.',
|
|
276
|
+
};
|
|
277
|
+
const entry: Entry = {
|
|
278
|
+
controller: new AbortController(),
|
|
279
|
+
state: {
|
|
280
|
+
operationId: scan.id,
|
|
281
|
+
kind: input.kind,
|
|
282
|
+
repository: scan.repository,
|
|
283
|
+
plan,
|
|
284
|
+
status: 'prepared',
|
|
285
|
+
phase: 'Prepared; review and authorize model inference',
|
|
286
|
+
question: null,
|
|
287
|
+
answeredQuestionCount: 0,
|
|
288
|
+
result: null,
|
|
289
|
+
error: null,
|
|
290
|
+
createdAt: new Date().toISOString(),
|
|
291
|
+
startedAt: null,
|
|
292
|
+
finishedAt: null,
|
|
293
|
+
revision: 0,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
this.#entries.set(scan.id, entry);
|
|
297
|
+
this.#emit(entry);
|
|
298
|
+
return this.getState(scan.id);
|
|
299
|
+
}
|
|
300
|
+
async start(approval: SecurityOperationApproval): Promise<SecurityOperationState> {
|
|
301
|
+
if (this.#disposed) throw new Error('Security operations service is closed.');
|
|
302
|
+
if (approval?.approveOpenAIInference !== true)
|
|
303
|
+
throw new Error('Prepare and authorize this exact operation before model inference.');
|
|
304
|
+
const entry = this.#entry(approval.operationId);
|
|
305
|
+
if (entry.state.kind === 'validation' && approval.approveUncappedValidation !== true)
|
|
306
|
+
throw new Error(
|
|
307
|
+
'Explicit approval required: SDK validation has no cost limit; the scan budget does not apply.',
|
|
308
|
+
);
|
|
309
|
+
if (entry.starting) return entry.starting;
|
|
310
|
+
if (active(entry.state)) return this.getState(approval.operationId);
|
|
311
|
+
if (entry.state.status !== 'prepared')
|
|
312
|
+
throw new Error('This operation is already used. Prepare and authorize a new operation.');
|
|
313
|
+
if (
|
|
314
|
+
[...this.#entries.values()].filter((other) => other.starting || active(other.state)).length >=
|
|
315
|
+
this.maxConcurrent
|
|
316
|
+
)
|
|
317
|
+
throw new Error('Two operations are already active. Wait or cancel one.');
|
|
318
|
+
entry.starting = this.#start(entry);
|
|
319
|
+
try {
|
|
320
|
+
return await entry.starting;
|
|
321
|
+
} finally {
|
|
322
|
+
entry.starting = undefined;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async #start(entry: Entry): Promise<SecurityOperationState> {
|
|
326
|
+
try {
|
|
327
|
+
await validatePlan(entry.state.plan);
|
|
328
|
+
checkRepositoryPath(entry.state.plan.repository);
|
|
329
|
+
if (entry.state.kind === 'policy') await checkPolicyDirectory(entry.state.plan);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!entry.controller.signal.aborted) {
|
|
332
|
+
entry.state.status = 'error';
|
|
333
|
+
entry.state.phase = 'Preview invalid; prepare again';
|
|
334
|
+
entry.state.error =
|
|
335
|
+
'Target, context, or settings changed, or the preview expired. Prepare and authorize a new operation.';
|
|
336
|
+
entry.state.finishedAt = new Date().toISOString();
|
|
337
|
+
this.#emit(entry);
|
|
338
|
+
}
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
if (this.#disposed || entry.controller.signal.aborted)
|
|
342
|
+
return this.getState(entry.state.operationId);
|
|
343
|
+
entry.state.status = 'running';
|
|
344
|
+
entry.state.phase =
|
|
345
|
+
entry.state.kind === 'policy'
|
|
346
|
+
? 'Generating security policy draft'
|
|
347
|
+
: 'Validating finding with source evidence';
|
|
348
|
+
entry.state.startedAt = new Date().toISOString();
|
|
349
|
+
this.#emit(entry);
|
|
350
|
+
entry.task = this.#run(entry);
|
|
351
|
+
return this.getState(entry.state.operationId);
|
|
352
|
+
}
|
|
353
|
+
async #run(entry: Entry) {
|
|
354
|
+
const { plan } = entry.state;
|
|
355
|
+
const { signal } = entry.controller;
|
|
356
|
+
let client: SecurityOperationsClient | undefined;
|
|
357
|
+
let outcome: SecurityOperationStatus = 'error';
|
|
358
|
+
const observing = () => !signal.aborted && active(entry.state);
|
|
359
|
+
try {
|
|
360
|
+
signal.throwIfAborted();
|
|
361
|
+
client = await this.#factory({
|
|
362
|
+
codexOverrides: {
|
|
363
|
+
model_provider: 'openai',
|
|
364
|
+
model: plan.settings.codex.model,
|
|
365
|
+
model_reasoning_effort: plan.settings.codex.reasoningEffort,
|
|
366
|
+
daybreak: plan.settings.codex.daybreak,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
signal.throwIfAborted();
|
|
370
|
+
const context = plan.contextFiles.map((file) => resolve(plan.repository, file.path));
|
|
371
|
+
if (plan.kind === 'policy') {
|
|
372
|
+
const options: SecurityPolicyOptions = {
|
|
373
|
+
auth: 'chatgpt',
|
|
374
|
+
path: plan.path,
|
|
375
|
+
knowledgeBasePaths: context,
|
|
376
|
+
maxCostUsd: plan.settings.scan.maxCostUsd,
|
|
377
|
+
signal,
|
|
378
|
+
onStage: (stage) => {
|
|
379
|
+
if (!observing()) return;
|
|
380
|
+
const phases = {
|
|
381
|
+
architecture: 'Mapping architecture',
|
|
382
|
+
threat_model: 'Building threat model',
|
|
383
|
+
policy: 'Drafting security policy',
|
|
384
|
+
};
|
|
385
|
+
if (Object.hasOwn(phases, stage)) {
|
|
386
|
+
entry.state.phase = phases[stage];
|
|
387
|
+
this.#emit(entry);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
onCost: (cost) => {
|
|
391
|
+
if (observing() && Number.isFinite(cost.estimatedUsd) && cost.estimatedUsd >= 0) {
|
|
392
|
+
entry.state.estimatedCostUsd = cost.estimatedUsd;
|
|
393
|
+
this.#emit(entry);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
answerQuestions: (questions, questionSignal) =>
|
|
397
|
+
this.#ask(entry, questions, questionSignal),
|
|
398
|
+
};
|
|
399
|
+
const result = await client.generatePolicy(plan.repository, options);
|
|
400
|
+
signal.throwIfAborted();
|
|
401
|
+
entry.state.result = this.#policyResult(plan, result);
|
|
402
|
+
if (
|
|
403
|
+
result.cost &&
|
|
404
|
+
Number.isFinite(result.cost.estimatedUsd) &&
|
|
405
|
+
result.cost.estimatedUsd >= 0
|
|
406
|
+
)
|
|
407
|
+
entry.state.estimatedCostUsd = result.cost.estimatedUsd;
|
|
408
|
+
} else {
|
|
409
|
+
const options: ValidationOptions = {
|
|
410
|
+
repositoryPath: plan.repository,
|
|
411
|
+
finding: plan.finding!,
|
|
412
|
+
auth: 'chatgpt',
|
|
413
|
+
knowledgeBasePaths: context,
|
|
414
|
+
signal,
|
|
415
|
+
...(plan.settings.scan.cyberAccessProgram
|
|
416
|
+
? { cyberAccessProgram: plan.settings.scan.cyberAccessProgram }
|
|
417
|
+
: {}),
|
|
418
|
+
};
|
|
419
|
+
const result = await client.validate(options);
|
|
420
|
+
signal.throwIfAborted();
|
|
421
|
+
if (
|
|
422
|
+
!['reportable', 'suppressed', 'not_applicable', 'deferred'].includes(
|
|
423
|
+
result.disposition,
|
|
424
|
+
) ||
|
|
425
|
+
typeof result.report !== 'string'
|
|
426
|
+
)
|
|
427
|
+
throw new Error('Invalid validation result.');
|
|
428
|
+
entry.state.result = {
|
|
429
|
+
kind: 'validation',
|
|
430
|
+
disposition: result.disposition,
|
|
431
|
+
report: safeReportText(result.report),
|
|
432
|
+
truncated: result.report.length > textLimit,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
outcome = 'completed';
|
|
436
|
+
} catch (error) {
|
|
437
|
+
outcome = signal.aborted ? 'cancelled' : 'error';
|
|
438
|
+
entry.state.result = null;
|
|
439
|
+
entry.state.error = signal.aborted
|
|
440
|
+
? null
|
|
441
|
+
: error instanceof DedicatedSecurityCredentialsRequiredError
|
|
442
|
+
? error.message
|
|
443
|
+
: 'SDK operation failed. Review local SDK diagnostics and runtime prerequisites. Raw output is not exposed.';
|
|
444
|
+
} finally {
|
|
445
|
+
this.#rejectQuestion(entry, new Error('Operation ended before this question was answered.'));
|
|
446
|
+
try {
|
|
447
|
+
await client?.close();
|
|
448
|
+
} catch {
|
|
449
|
+
/* Never surface potentially sensitive SDK errors. */
|
|
450
|
+
}
|
|
451
|
+
if (signal.aborted) {
|
|
452
|
+
outcome = 'cancelled';
|
|
453
|
+
entry.state.error = null;
|
|
454
|
+
entry.state.result = null;
|
|
455
|
+
}
|
|
456
|
+
entry.state.status = outcome;
|
|
457
|
+
entry.state.phase =
|
|
458
|
+
outcome === 'completed'
|
|
459
|
+
? plan.kind === 'policy'
|
|
460
|
+
? 'Security policy draft ready; not applied'
|
|
461
|
+
: 'Validation assessment ready'
|
|
462
|
+
: outcome === 'cancelled'
|
|
463
|
+
? 'Cancelled; results incomplete'
|
|
464
|
+
: 'Security operation failed';
|
|
465
|
+
entry.state.finishedAt = new Date().toISOString();
|
|
466
|
+
this.#emit(entry);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
#policyResult(plan: SecurityOperationPlan, draft: SecurityPolicyDraft): SecurityOperationResult {
|
|
470
|
+
const expectedPath = resolve(plan.repository, plan.path || '.', 'SECURITY.md');
|
|
471
|
+
if (
|
|
472
|
+
draft.repository !== plan.repository ||
|
|
473
|
+
resolve(draft.targetPath) !== expectedPath ||
|
|
474
|
+
typeof draft.content !== 'string' ||
|
|
475
|
+
!Array.isArray(draft.reviewNotes)
|
|
476
|
+
)
|
|
477
|
+
throw new Error('Policy draft did not match approved target.');
|
|
478
|
+
return {
|
|
479
|
+
kind: 'policy',
|
|
480
|
+
content: safeReportText(draft.content),
|
|
481
|
+
reviewNotes: draft.reviewNotes.slice(0, 100).map((note) => safeReportText(note, 2000)),
|
|
482
|
+
targetPath: expectedPath,
|
|
483
|
+
applied: false,
|
|
484
|
+
truncated: draft.content.length > textLimit || draft.reviewNotes.length > 100,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
#ask(
|
|
488
|
+
entry: Entry,
|
|
489
|
+
questions: readonly string[],
|
|
490
|
+
signal: AbortSignal,
|
|
491
|
+
): Promise<string | undefined> {
|
|
492
|
+
if (entry.controller.signal.aborted || signal.aborted)
|
|
493
|
+
return Promise.reject(new Error('Operation cancelled.'));
|
|
494
|
+
if (entry.pending) return Promise.reject(new Error('A question batch is already pending.'));
|
|
495
|
+
if (
|
|
496
|
+
!Array.isArray(questions) ||
|
|
497
|
+
questions.length > 12 ||
|
|
498
|
+
questions.some((question) => typeof question !== 'string' || question.length > 8000)
|
|
499
|
+
)
|
|
500
|
+
return Promise.reject(new Error('Invalid question batch.'));
|
|
501
|
+
if (!questions.length) return Promise.resolve(undefined);
|
|
502
|
+
return new Promise((resolveAnswer, reject) => {
|
|
503
|
+
const id = randomUUID();
|
|
504
|
+
const abort = () => this.#rejectQuestion(entry, new Error('Operation cancelled.'));
|
|
505
|
+
const cleanup = () => {
|
|
506
|
+
signal.removeEventListener('abort', abort);
|
|
507
|
+
entry.controller.signal.removeEventListener('abort', abort);
|
|
508
|
+
};
|
|
509
|
+
entry.pending = { id, resolve: resolveAnswer, reject, cleanup };
|
|
510
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
511
|
+
entry.controller.signal.addEventListener('abort', abort, { once: true });
|
|
512
|
+
entry.state.question = {
|
|
513
|
+
id,
|
|
514
|
+
questions: questions.map((question) => safeReportText(question, 8000)),
|
|
515
|
+
createdAt: new Date().toISOString(),
|
|
516
|
+
};
|
|
517
|
+
entry.state.status = 'awaiting_answers';
|
|
518
|
+
entry.state.phase = 'Waiting for repository owner clarification';
|
|
519
|
+
this.#emit(entry);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
#rejectQuestion(entry: Entry, error: Error) {
|
|
523
|
+
const pending = entry.pending;
|
|
524
|
+
if (!pending) return;
|
|
525
|
+
entry.pending = undefined;
|
|
526
|
+
entry.state.question = null;
|
|
527
|
+
pending.cleanup();
|
|
528
|
+
pending.reject(error);
|
|
529
|
+
}
|
|
530
|
+
answer(input: {
|
|
531
|
+
operationId: string;
|
|
532
|
+
questionId: string;
|
|
533
|
+
answer: string;
|
|
534
|
+
}): SecurityOperationState {
|
|
535
|
+
const entry = this.#entry(input.operationId);
|
|
536
|
+
if (
|
|
537
|
+
this.#disposed ||
|
|
538
|
+
entry.controller.signal.aborted ||
|
|
539
|
+
entry.state.status !== 'awaiting_answers' ||
|
|
540
|
+
!entry.pending ||
|
|
541
|
+
entry.pending.id !== input.questionId
|
|
542
|
+
)
|
|
543
|
+
throw new Error('This question is no longer pending for this operation.');
|
|
544
|
+
if (
|
|
545
|
+
typeof input.answer !== 'string' ||
|
|
546
|
+
!input.answer.trim() ||
|
|
547
|
+
Buffer.byteLength(input.answer) > 16 * 1024
|
|
548
|
+
)
|
|
549
|
+
throw new Error('Answer must be nonempty text of at most 16 KiB. State unknowns explicitly.');
|
|
550
|
+
if (safeReportText(input.answer) !== input.answer)
|
|
551
|
+
throw new Error('Remove credential-like values and control characters from your answer.');
|
|
552
|
+
const pending = entry.pending;
|
|
553
|
+
const count = entry.state.question?.questions.length ?? 0;
|
|
554
|
+
entry.pending = undefined;
|
|
555
|
+
pending.cleanup();
|
|
556
|
+
entry.state.question = null;
|
|
557
|
+
entry.state.answeredQuestionCount += count;
|
|
558
|
+
entry.state.status = 'running';
|
|
559
|
+
entry.state.phase = 'Continuing with supplied clarification';
|
|
560
|
+
// Answers are passed to this SDK call only; no answer transcript is retained in state.
|
|
561
|
+
pending.resolve(input.answer.trim());
|
|
562
|
+
this.#emit(entry);
|
|
563
|
+
return this.getState(input.operationId);
|
|
564
|
+
}
|
|
565
|
+
cancel(operationId: string): SecurityOperationState {
|
|
566
|
+
const entry = this.#entry(operationId);
|
|
567
|
+
if (entry.state.status === 'prepared' || active(entry.state)) {
|
|
568
|
+
entry.controller.abort(new Error('Operation cancelled.'));
|
|
569
|
+
this.#rejectQuestion(entry, new Error('Operation cancelled.'));
|
|
570
|
+
entry.state.question = null;
|
|
571
|
+
entry.state.result = null;
|
|
572
|
+
entry.state.phase = 'Cancellation requested';
|
|
573
|
+
if (entry.state.status === 'prepared') {
|
|
574
|
+
entry.state.status = 'cancelled';
|
|
575
|
+
entry.state.finishedAt = new Date().toISOString();
|
|
576
|
+
}
|
|
577
|
+
this.#emit(entry);
|
|
578
|
+
}
|
|
579
|
+
return this.getState(operationId);
|
|
580
|
+
}
|
|
581
|
+
async wait(operationId: string): Promise<SecurityOperationState> {
|
|
582
|
+
const entry = this.#entry(operationId);
|
|
583
|
+
await entry.starting;
|
|
584
|
+
await entry.task;
|
|
585
|
+
return this.getState(operationId);
|
|
586
|
+
}
|
|
587
|
+
async dispose() {
|
|
588
|
+
this.#disposed = true;
|
|
589
|
+
for (const id of this.#entries.keys()) this.cancel(id);
|
|
590
|
+
await Promise.all(
|
|
591
|
+
[...this.#entries.values()].map(async (entry) => {
|
|
592
|
+
await entry.starting?.catch(() => {});
|
|
593
|
+
await entry.task;
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
this.#listeners.clear();
|
|
597
|
+
}
|
|
598
|
+
}
|
package/src/gui.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Command, Panel, Plugin } from '@opencode/plugin/gui';
|
|
2
|
+
import { createMemo, createSignal, onCleanup, Show } from 'solid-js';
|
|
3
|
+
import { Security } from './rpc';
|
|
4
|
+
import { localProjectDirectory } from './plan-view';
|
|
5
|
+
import { SecurityPanel } from './security-panel';
|
|
6
|
+
|
|
7
|
+
export default Plugin.define({
|
|
8
|
+
setup(ctx) {
|
|
9
|
+
const tab = { id: 'security', title: 'Security', tabbable: true };
|
|
10
|
+
ctx.add(Panel, {
|
|
11
|
+
id: 'security',
|
|
12
|
+
region: 'side',
|
|
13
|
+
width: 580,
|
|
14
|
+
list: () => [tab],
|
|
15
|
+
render(props) {
|
|
16
|
+
const directory = createMemo(() => localProjectDirectory(props.session));
|
|
17
|
+
return (
|
|
18
|
+
<Show
|
|
19
|
+
when={directory()}
|
|
20
|
+
keyed
|
|
21
|
+
fallback={
|
|
22
|
+
<section style={{ padding: '24px' }}>
|
|
23
|
+
<h2>Security</h2>
|
|
24
|
+
<p>Open a local project session to choose a scan target.</p>
|
|
25
|
+
<p>
|
|
26
|
+
No repository is selected. Remote and unknown workspaces cannot start local scans.
|
|
27
|
+
</p>
|
|
28
|
+
</section>
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
{(directory) => {
|
|
32
|
+
// Keep every RPC scoped to this mounted local session. Remounting cancels view polling,
|
|
33
|
+
// never a scan; stale callbacks cannot approve a plan for a different workspace.
|
|
34
|
+
const location = { directory };
|
|
35
|
+
const [capabilities, setCapabilities] = createSignal<{
|
|
36
|
+
policy: boolean;
|
|
37
|
+
validation: boolean;
|
|
38
|
+
questionAnswers: boolean;
|
|
39
|
+
}>();
|
|
40
|
+
let disposed = false;
|
|
41
|
+
onCleanup(() => {
|
|
42
|
+
disposed = true;
|
|
43
|
+
});
|
|
44
|
+
void ctx.client
|
|
45
|
+
.rpc(Security)
|
|
46
|
+
.capabilities({}, { location })
|
|
47
|
+
.then((value) => {
|
|
48
|
+
const flags = value as unknown as {
|
|
49
|
+
policy: boolean;
|
|
50
|
+
validation: boolean;
|
|
51
|
+
questionAnswers: boolean;
|
|
52
|
+
};
|
|
53
|
+
if (!disposed && (flags.policy || flags.validation)) setCapabilities(flags);
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
/* Scan controls remain usable if extra capabilities are unavailable. */
|
|
57
|
+
});
|
|
58
|
+
return (
|
|
59
|
+
<SecurityPanel
|
|
60
|
+
directory={directory}
|
|
61
|
+
operations={
|
|
62
|
+
capabilities()
|
|
63
|
+
? {
|
|
64
|
+
capabilities: capabilities(),
|
|
65
|
+
list: () => ctx.client.rpc(Security).operationList({}, { location }),
|
|
66
|
+
prepare: (input) =>
|
|
67
|
+
ctx.client.rpc(Security).operationPrepare(input, { location }),
|
|
68
|
+
start: (input) =>
|
|
69
|
+
ctx.client.rpc(Security).operationStart(input, { location }),
|
|
70
|
+
answer: (input) =>
|
|
71
|
+
ctx.client.rpc(Security).operationAnswer(input, { location }),
|
|
72
|
+
cancel: (input) =>
|
|
73
|
+
ctx.client.rpc(Security).operationCancel(input, { location }),
|
|
74
|
+
}
|
|
75
|
+
: undefined
|
|
76
|
+
}
|
|
77
|
+
port={{
|
|
78
|
+
list: () => ctx.client.rpc(Security).list({}, { location }),
|
|
79
|
+
prepare: (input) => ctx.client.rpc(Security).prepare(input, { location }),
|
|
80
|
+
start: (input) => ctx.client.rpc(Security).start(input, { location }),
|
|
81
|
+
cancel: (input) => ctx.client.rpc(Security).cancel(input, { location }),
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}}
|
|
86
|
+
</Show>
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
ctx.add(Command, {
|
|
91
|
+
id: 'open',
|
|
92
|
+
title: 'Security: open scans',
|
|
93
|
+
group: 'Security',
|
|
94
|
+
slash: { name: 'security' },
|
|
95
|
+
run: () => {
|
|
96
|
+
const session = ctx.sessions.current();
|
|
97
|
+
if (session) ctx.layout.open(`${ctx.id}:security`, session);
|
|
98
|
+
else ctx.toast.show({ title: 'Open a local project session to view Security' });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
});
|