opencode-codex-security 0.1.1 → 0.1.2

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.
@@ -0,0 +1,254 @@
1
+ import { open, realpath } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { parse } from 'smol-toml';
7
+
8
+ export interface ProviderSettings {
9
+ id: string;
10
+ baseUrl: string;
11
+ envKey: string;
12
+ wireApi: 'responses';
13
+ }
14
+ export interface ProviderSource {
15
+ path: string;
16
+ profile?: string;
17
+ /** Hash of the allowlisted selection only. Never hash credentials or the full source file. */
18
+ digest: string;
19
+ }
20
+ export class ProviderConfigurationError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'ProviderConfigurationError';
24
+ }
25
+ }
26
+ const fail = (message: string): never => {
27
+ throw new ProviderConfigurationError(message);
28
+ };
29
+ export const record = (value: unknown): value is Record<string, unknown> =>
30
+ value !== null && typeof value === 'object' && !Array.isArray(value);
31
+ const reserved = new Set([
32
+ 'openai',
33
+ 'ollama',
34
+ 'lmstudio',
35
+ 'amazon-bedrock',
36
+ 'amazon-bedrock-runtime',
37
+ 'gcp',
38
+ '__proto__',
39
+ 'prototype',
40
+ 'constructor',
41
+ ]);
42
+ export function validateProvider(raw: unknown): ProviderSettings {
43
+ if (
44
+ !record(raw) ||
45
+ Object.keys(raw).some((key) => !['id', 'baseUrl', 'envKey', 'wireApi'].includes(key))
46
+ )
47
+ return fail(
48
+ 'Provider accepts only id, baseUrl, envKey, and wireApi. Inline credentials, headers, and auth commands are unsupported.',
49
+ );
50
+ const { id, baseUrl, envKey, wireApi } = raw;
51
+ if (
52
+ typeof id !== 'string' ||
53
+ !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) ||
54
+ reserved.has(id.toLowerCase())
55
+ )
56
+ return fail('Use a custom provider ID, not a built-in provider or reserved name.');
57
+ if (typeof envKey !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(envKey))
58
+ return fail('Provider envKey must be an environment-variable name, never a key value.');
59
+ if (wireApi !== 'responses')
60
+ return fail('Provider wireApi must be responses. Chat Completions is unsupported.');
61
+ if (typeof baseUrl !== 'string' || baseUrl.length > 2048 || /[\s\x00-\x1f\x7f\\]/.test(baseUrl))
62
+ return fail('Provider baseUrl must be a credential-free HTTPS URL.');
63
+ let url: URL;
64
+ try {
65
+ url = new URL(baseUrl);
66
+ } catch {
67
+ return fail('Provider baseUrl must be a credential-free HTTPS URL.');
68
+ }
69
+ // Keep URLs safe to show in approval plans. No userinfo, query, fragment, opaque
70
+ // schemes, encoded components, or token-bearing path segments are supported.
71
+ if (
72
+ url.protocol !== 'https:' ||
73
+ !url.hostname ||
74
+ url.username ||
75
+ url.password ||
76
+ url.search ||
77
+ url.hash ||
78
+ /[%@]/.test(baseUrl) ||
79
+ !/^\/[A-Za-z0-9/_.-]*$/.test(url.pathname) ||
80
+ url.pathname
81
+ .split('/')
82
+ .some((part) => part.length > 64 || /^(?:sk-|Bearer|token[=:]|key[=:])/i.test(part))
83
+ )
84
+ return fail(
85
+ 'Provider baseUrl must be HTTPS without credentials, query parameters, fragments, or encoded paths.',
86
+ );
87
+ return { id, baseUrl: url.href.replace(/\/$/, ''), envKey, wireApi };
88
+ }
89
+ export function validateConfigFile(value: unknown): asserts value is string {
90
+ if (
91
+ typeof value !== 'string' ||
92
+ value.length > 4096 ||
93
+ /[\x00-\x1f\x7f]/.test(value) ||
94
+ (!isAbsolute(value) && !value.startsWith('~/')) ||
95
+ !value.endsWith('.toml')
96
+ )
97
+ fail('codex.configFile must be an absolute or ~/ path to a TOML file.');
98
+ }
99
+ /** Standard Codex location; call only after explicit import opt-in. No file or credential reads. */
100
+ export function defaultCodexConfigFile(
101
+ environment: { CODEX_HOME?: string } = process.env,
102
+ home = homedir(),
103
+ ): string {
104
+ const configured = environment.CODEX_HOME;
105
+ let path: string;
106
+ try {
107
+ if (configured !== undefined && configured !== '' && !isAbsolute(configured)) throw new Error();
108
+ const directory =
109
+ configured === undefined || configured === '' ? join(home, '.codex') : configured;
110
+ // Check the raw directory before path normalization can erase a control
111
+ // character segment or turn a relative value into an accepted ~/ path.
112
+ validateConfigFile(`${directory}/config.toml`);
113
+ path = join(directory, 'config.toml');
114
+ validateConfigFile(path);
115
+ } catch {
116
+ return fail(
117
+ 'CODEX_HOME must be an absolute directory path without control characters. Leave it unset or empty to use ~/.codex/config.toml.',
118
+ );
119
+ }
120
+
121
+ return path;
122
+ }
123
+ export function validateProfile(value: unknown): asserts value is string {
124
+ if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value))
125
+ fail('codex.profile must be a simple named profile in the selected TOML file.');
126
+ }
127
+ /** Explicit opt-in only. Nothing here resolves ambient config, authentication, or SDK profiles. */
128
+ export async function readProviderConfiguration(configFile: string, profile?: string) {
129
+ validateConfigFile(configFile);
130
+ if (profile !== undefined) validateProfile(profile);
131
+ const requested = configFile.startsWith('~/') ? join(homedir(), configFile.slice(2)) : configFile;
132
+ let content: string;
133
+ let path: string;
134
+ try {
135
+ const file = await open(
136
+ requested,
137
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
138
+ );
139
+ try {
140
+ const info = await file.stat();
141
+ if (!info.isFile() || info.size > 256 * 1024) throw new Error();
142
+ const bytes = Buffer.alloc(256 * 1024 + 1);
143
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
144
+ if (bytesRead > 256 * 1024 || bytes.subarray(0, bytesRead).includes(0)) throw new Error();
145
+ content = bytes.subarray(0, bytesRead).toString('utf8');
146
+ path = await realpath(requested);
147
+ } finally {
148
+ await file.close();
149
+ }
150
+ } catch {
151
+ return fail(
152
+ 'Cannot read the selected Codex config. Use a regular, nonsymlink TOML file of at most 256 KiB.',
153
+ );
154
+ }
155
+ let root: Record<string, unknown>;
156
+ try {
157
+ root = parse(content);
158
+ } catch {
159
+ return fail(
160
+ 'Cannot parse the selected Codex TOML config. Parser details are withheld because they may contain credentials.',
161
+ );
162
+ }
163
+ const selectedName = profile ?? root.profile;
164
+ let selected: Record<string, unknown> = root;
165
+ let profileFields: Record<string, unknown> = {};
166
+ if (selectedName !== undefined) {
167
+ validateProfile(selectedName);
168
+ if (
169
+ !record(root.profiles) ||
170
+ !Object.hasOwn(root.profiles, selectedName) ||
171
+ !record(root.profiles[selectedName])
172
+ )
173
+ return fail('The selected Codex profile is missing or invalid.');
174
+ profileFields = root.profiles[selectedName];
175
+ selected = { ...root, ...profileFields };
176
+ }
177
+ const id = selected.model_provider;
178
+ if (typeof id !== 'string')
179
+ return fail(
180
+ 'The selected Codex config must define its custom model_provider and model_providers entry.',
181
+ );
182
+ if (
183
+ (root.model_providers !== undefined && !record(root.model_providers)) ||
184
+ (profileFields.model_providers !== undefined && !record(profileFields.model_providers))
185
+ )
186
+ return fail('The selected Codex provider table must be an object.');
187
+ const rootProviders = record(root.model_providers) ? root.model_providers : {};
188
+ const profileProviders = record(profileFields.model_providers)
189
+ ? profileFields.model_providers
190
+ : {};
191
+ // Inherit only the selected provider's fields, never whole profile/runtime configuration.
192
+ const inherited = Object.hasOwn(rootProviders, id) ? rootProviders[id] : undefined;
193
+ const override = Object.hasOwn(profileProviders, id) ? profileProviders[id] : undefined;
194
+ if (
195
+ (!record(inherited) && !record(override)) ||
196
+ (inherited !== undefined && !record(inherited)) ||
197
+ (override !== undefined && !record(override))
198
+ )
199
+ return fail(
200
+ 'The selected Codex config must define its custom model_provider and model_providers entry.',
201
+ );
202
+ const native = { ...(record(inherited) ? inherited : {}), ...(record(override) ? override : {}) };
203
+ const ignoredHints = [
204
+ 'env_key_instructions',
205
+ 'request_max_retries',
206
+ 'stream_max_retries',
207
+ 'stream_idle_timeout_ms',
208
+ ];
209
+ if (
210
+ Object.keys(native).some(
211
+ (key) =>
212
+ ![
213
+ 'name',
214
+ 'base_url',
215
+ 'env_key',
216
+ 'wire_api',
217
+ 'requires_openai_auth',
218
+ ...ignoredHints,
219
+ ].includes(key),
220
+ ) ||
221
+ (native.requires_openai_auth !== undefined && native.requires_openai_auth !== false)
222
+ )
223
+ return fail(
224
+ 'The selected provider uses unsupported options. Only Responses routing with env_key and requires_openai_auth=false is supported; headers, inline tokens, auth commands, and OAuth are not imported.',
225
+ );
226
+ const provider = validateProvider({
227
+ id,
228
+ baseUrl: native.base_url,
229
+ envKey: native.env_key,
230
+ wireApi: native.wire_api === undefined ? 'responses' : native.wire_api,
231
+ });
232
+ const settings = {
233
+ provider,
234
+ ...(selected.model === undefined ? {} : { model: selected.model }),
235
+ ...(selected.model_reasoning_effort === undefined
236
+ ? {}
237
+ : { reasoningEffort: selected.model_reasoning_effort }),
238
+ ...(selected.daybreak === undefined ? {} : { daybreak: selected.daybreak }),
239
+ };
240
+ // Only known non-secret configuration reaches the digest or callers. Validate
241
+ // model/reasoning/daybreak in sdk-settings before exposing this selection.
242
+ return { settings, path, profile: selectedName };
243
+ }
244
+ export function providerSource(
245
+ path: string,
246
+ profile: string | undefined,
247
+ settings: unknown,
248
+ ): ProviderSource {
249
+ return {
250
+ path,
251
+ ...(profile === undefined ? {} : { profile }),
252
+ digest: createHash('sha256').update(JSON.stringify(settings)).digest('hex'),
253
+ };
254
+ }
@@ -1,4 +1,4 @@
1
- import { loadGlobalSettings, mergeSettings, type ResolvedSdkSettings } from './sdk-settings.ts';
1
+ import { loadGlobalSettings, resolveSettings, type ResolvedSdkSettings } from './sdk-settings.ts';
2
2
  import { realpath, stat, open } from 'node:fs/promises';
3
3
  import { constants } from 'node:fs';
4
4
  import { homedir } from 'node:os';
@@ -133,7 +133,7 @@ export async function prepareScan(
133
133
  'Choose a specific project repository explicitly. Your home folder and its parent directories cannot be scan targets.',
134
134
  );
135
135
  const global = await loadGlobalSettings();
136
- const settings = mergeSettings(global.settings, input.mode ? { scan: { mode: input.mode } } : {});
136
+ const settings = await resolveSettings(global.settings, input.mode);
137
137
  const merged = input;
138
138
  const scopePaths: string[] = [];
139
139
  for (const path of merged.scopePaths ?? [])
@@ -173,6 +173,7 @@ export async function validatePlan(plan: ScanPlan) {
173
173
  const current = await prepareScan(plan.repository, plan.inputs, plan.mock);
174
174
  if (
175
175
  current.repository !== plan.repository ||
176
+ current.globalConfigFile !== plan.globalConfigFile ||
176
177
  current.globalConfigDigest !== plan.globalConfigDigest ||
177
178
  JSON.stringify(current.settings) !== JSON.stringify(plan.settings) ||
178
179
  JSON.stringify(current.contextFiles) !== JSON.stringify(plan.contextFiles) ||
@@ -2,10 +2,27 @@ import { readFile, lstat } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
+ import type { CodexSecurityConfig } from '@openai/codex-security';
6
+ import {
7
+ readProviderConfiguration,
8
+ defaultCodexConfigFile,
9
+ providerSource,
10
+ validateProvider,
11
+ validateConfigFile,
12
+ validateProfile,
13
+ ProviderConfigurationError,
14
+ type ProviderSettings,
15
+ type ProviderSource,
16
+ } from './provider-config.ts';
5
17
  export interface CodexSettings {
6
18
  model?: string;
7
19
  reasoningEffort?: string;
8
20
  daybreak?: boolean;
21
+ auth?: 'chatgpt' | 'api-key';
22
+ provider?: ProviderSettings;
23
+ configFile?: string;
24
+ useCodexConfig?: boolean;
25
+ profile?: string;
9
26
  }
10
27
  export interface ScanSettings {
11
28
  mode?: 'standard' | 'deep';
@@ -20,11 +37,14 @@ export interface SdkSettings {
20
37
  scan?: ScanSettings;
21
38
  }
22
39
  export interface ResolvedSdkSettings {
23
- codex: Required<CodexSettings>;
40
+ codex: Required<Pick<CodexSettings, 'model' | 'reasoningEffort' | 'daybreak' | 'auth'>> & {
41
+ provider?: ProviderSettings;
42
+ source?: ProviderSource;
43
+ };
24
44
  scan: ScanSettings & { mode: 'standard' | 'deep'; maxCostUsd: number };
25
45
  }
26
46
  export const defaultSettings: ResolvedSdkSettings = {
27
- codex: { model: 'gpt-5.6-sol', reasoningEffort: 'xhigh', daybreak: false },
47
+ codex: { model: 'gpt-5.6-sol', reasoningEffort: 'xhigh', daybreak: false, auth: 'chatgpt' },
28
48
  scan: { mode: 'standard', maxCostUsd: 1 },
29
49
  };
30
50
  export function validateSettings(raw: unknown): SdkSettings {
@@ -38,13 +58,29 @@ export function validateSettings(raw: unknown): SdkSettings {
38
58
  throw new Error(`${section} settings must be an object.`);
39
59
  const allowed =
40
60
  section === 'codex'
41
- ? ['model', 'reasoningEffort', 'daybreak']
61
+ ? [
62
+ 'model',
63
+ 'reasoningEffort',
64
+ 'daybreak',
65
+ 'auth',
66
+ 'provider',
67
+ 'configFile',
68
+ 'useCodexConfig',
69
+ 'profile',
70
+ ]
42
71
  : ['mode', 'maxCostUsd', 'maxTimeMinutes', 'cyberAccessProgram', 'workers', 'subagents'];
43
72
  for (const [key, val] of Object.entries(group)) {
44
73
  if (!allowed.includes(key))
45
74
  throw new Error(
46
- `Unsupported ${section} setting: ${key}. Credentials and arbitrary runtime overrides are not accepted.`,
75
+ `Unsupported ${section} setting. Credentials and arbitrary runtime overrides are not accepted.`,
47
76
  );
77
+ if (key === 'auth' && val !== 'chatgpt' && val !== 'api-key')
78
+ throw new ProviderConfigurationError('codex.auth must be chatgpt or api-key.');
79
+ if (key === 'useCodexConfig' && typeof val !== 'boolean')
80
+ throw new ProviderConfigurationError('codex.useCodexConfig must be true or false.');
81
+ if (key === 'provider') validateProvider(val);
82
+ if (key === 'configFile') validateConfigFile(val);
83
+ if (key === 'profile') validateProfile(val);
48
84
  if (
49
85
  key === 'model' &&
50
86
  (typeof val !== 'string' || !val.trim() || val.length > 200 || /[\x00-\x1f]/.test(val))
@@ -94,7 +130,12 @@ export async function loadGlobalSettings(
94
130
  throw new Error('Global config must be a regular JSON file of at most 32 KiB.');
95
131
  const content = await readFile(path);
96
132
  if (content.length > 32768) throw new Error('Global config too large.');
97
- const raw = JSON.parse(content.toString('utf8'));
133
+ let raw: unknown;
134
+ try {
135
+ raw = JSON.parse(content.toString('utf8'));
136
+ } catch {
137
+ throw new Error('Global config is not valid JSON. Parser details are withheld.');
138
+ }
98
139
  if (
99
140
  !raw ||
100
141
  typeof raw !== 'object' ||
@@ -113,11 +154,94 @@ export async function loadGlobalSettings(
113
154
  throw error;
114
155
  }
115
156
  }
116
- export function mergeSettings(...sources: SdkSettings[]): ResolvedSdkSettings {
117
- const result = structuredClone(defaultSettings);
118
- for (const source of sources) {
119
- Object.assign(result.codex, source.codex);
120
- Object.assign(result.scan, source.scan);
157
+ /** Resolve only user-owned global settings; model tool inputs cannot select routing. */
158
+ export async function resolveSettings(
159
+ source: SdkSettings,
160
+ mode?: 'standard' | 'deep',
161
+ ): Promise<ResolvedSdkSettings> {
162
+ validateSettings(source);
163
+ const explicit = source.codex ?? {};
164
+ if (explicit.useCodexConfig === false && explicit.configFile)
165
+ throw new ProviderConfigurationError(
166
+ 'codex.configFile cannot be combined with useCodexConfig=false.',
167
+ );
168
+ const importing = explicit.useCodexConfig === true || explicit.configFile !== undefined;
169
+ if (importing && explicit.provider)
170
+ throw new ProviderConfigurationError(
171
+ 'Choose existing Codex config import or codex.provider, not both.',
172
+ );
173
+ if (explicit.profile && !importing)
174
+ throw new ProviderConfigurationError(
175
+ 'codex.profile requires useCodexConfig=true or codex.configFile.',
176
+ );
177
+ // Explicit paths take precedence without even consulting ignored CODEX_HOME.
178
+ const imported = importing
179
+ ? await readProviderConfiguration(
180
+ explicit.configFile ?? defaultCodexConfigFile(),
181
+ explicit.profile,
182
+ )
183
+ : undefined;
184
+ const selected = imported ? validateSettings({ codex: imported.settings }).codex! : {};
185
+ const {
186
+ configFile: _configFile,
187
+ useCodexConfig: _useCodexConfig,
188
+ profile: _profile,
189
+ ...overrides
190
+ } = explicit;
191
+ const codex = { ...defaultSettings.codex, ...selected, ...overrides };
192
+ if (codex.provider) {
193
+ codex.provider = validateProvider(codex.provider);
194
+ if (explicit.auth === 'chatgpt')
195
+ throw new ProviderConfigurationError(
196
+ 'Custom providers require api-key authentication; ChatGPT credentials will not be forwarded.',
197
+ );
198
+ codex.auth = 'api-key';
199
+ if (
200
+ codex.daybreak ||
201
+ (source.scan?.cyberAccessProgram && source.scan.cyberAccessProgram !== 'standard')
202
+ )
203
+ throw new ProviderConfigurationError(
204
+ 'Custom-provider Daybreak is not supported by this plugin yet. Disable daybreak and use the standard access program.',
205
+ );
206
+ } else if (codex.auth !== 'chatgpt') {
207
+ throw new ProviderConfigurationError(
208
+ 'API-key authentication requires an explicit custom provider with an envKey.',
209
+ );
121
210
  }
122
- return result;
211
+ if (imported) codex.source = providerSource(imported.path, imported.profile, selected);
212
+ return { codex, scan: { ...defaultSettings.scan, ...source.scan, ...(mode ? { mode } : {}) } };
213
+ }
214
+
215
+ /** Build the entire routing override from validated, non-secret fields. Never pass a profile. */
216
+ export function sdkConfiguration(settings?: ResolvedSdkSettings): CodexSecurityConfig {
217
+ const codex = settings?.codex;
218
+ const provider = codex?.provider ? validateProvider(codex.provider) : undefined;
219
+ if (provider && codex?.auth !== 'api-key')
220
+ throw new ProviderConfigurationError('Custom providers require api-key authentication.');
221
+ return {
222
+ codexOverrides: {
223
+ model_provider: provider?.id ?? 'openai',
224
+ ...(codex
225
+ ? {
226
+ model: codex.model,
227
+ model_reasoning_effort: codex.reasoningEffort,
228
+ daybreak: codex.daybreak,
229
+ }
230
+ : {}),
231
+ ...(provider
232
+ ? {
233
+ analytics: { enabled: false },
234
+ model_providers: {
235
+ [provider.id]: {
236
+ name: provider.id,
237
+ base_url: provider.baseUrl,
238
+ env_key: provider.envKey,
239
+ wire_api: provider.wireApi,
240
+ requires_openai_auth: false,
241
+ },
242
+ },
243
+ }
244
+ : {}),
245
+ },
246
+ };
123
247
  }
@@ -11,8 +11,12 @@ import type {
11
11
  } from '@openai/codex-security';
12
12
  import { prepareScan, validatePlan, type ScanPlan } from './scan-plan.ts';
13
13
  import { safeReportText } from './report-safety.ts';
14
+ import { sdkConfiguration } from './sdk-settings.ts';
15
+ import { ProviderConfigurationError } from './provider-config.ts';
16
+ import { unsupportedPricingMessage } from './codex-adapter.ts';
14
17
  import {
15
- requireDedicatedSecurityCredentials,
18
+ requireSdkCredentials,
19
+ ProviderCredentialsRequiredError,
16
20
  DedicatedSecurityCredentialsRequiredError,
17
21
  } from './credential-guard.ts';
18
22
 
@@ -102,7 +106,7 @@ type Entry = {
102
106
  };
103
107
  };
104
108
  const realClient: SecurityOperationsFactory = async (config) => {
105
- await requireDedicatedSecurityCredentials();
109
+ await requireSdkCredentials(config);
106
110
  const { CodexSecurity } = await import('@openai/codex-security');
107
111
  return new CodexSecurity(config);
108
112
  };
@@ -271,8 +275,8 @@ export class SecurityOperationsService {
271
275
  costLimitEnforced: input.kind === 'policy',
272
276
  costNotice:
273
277
  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.',
278
+ ? `Model inference uses an estimated USD ${scan.settings.scan.maxCostUsd} SDK limit, not a guaranteed billing cap.`
279
+ : 'Model inference has no SDK cost limit for validation in version 0.9.1. The scan budget does not apply.',
276
280
  };
277
281
  const entry: Entry = {
278
282
  controller: new AbortController(),
@@ -358,19 +362,12 @@ export class SecurityOperationsService {
358
362
  const observing = () => !signal.aborted && active(entry.state);
359
363
  try {
360
364
  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
- });
365
+ client = await this.#factory(sdkConfiguration(plan.settings));
369
366
  signal.throwIfAborted();
370
367
  const context = plan.contextFiles.map((file) => resolve(plan.repository, file.path));
371
368
  if (plan.kind === 'policy') {
372
369
  const options: SecurityPolicyOptions = {
373
- auth: 'chatgpt',
370
+ auth: plan.settings.codex.auth,
374
371
  path: plan.path,
375
372
  knowledgeBasePaths: context,
376
373
  maxCostUsd: plan.settings.scan.maxCostUsd,
@@ -409,7 +406,7 @@ export class SecurityOperationsService {
409
406
  const options: ValidationOptions = {
410
407
  repositoryPath: plan.repository,
411
408
  finding: plan.finding!,
412
- auth: 'chatgpt',
409
+ auth: plan.settings.codex.auth,
413
410
  knowledgeBasePaths: context,
414
411
  signal,
415
412
  ...(plan.settings.scan.cyberAccessProgram
@@ -438,9 +435,12 @@ export class SecurityOperationsService {
438
435
  entry.state.result = null;
439
436
  entry.state.error = signal.aborted
440
437
  ? null
441
- : error instanceof DedicatedSecurityCredentialsRequiredError
438
+ : error instanceof DedicatedSecurityCredentialsRequiredError ||
439
+ error instanceof ProviderCredentialsRequiredError ||
440
+ error instanceof ProviderConfigurationError
442
441
  ? error.message
443
- : 'SDK operation failed. Review local SDK diagnostics and runtime prerequisites. Raw output is not exposed.';
442
+ : (unsupportedPricingMessage(error) ??
443
+ 'SDK operation failed. Review local SDK diagnostics and runtime prerequisites. Raw output is not exposed.');
444
444
  } finally {
445
445
  this.#rejectQuestion(entry, new Error('Operation ended before this question was answered.'));
446
446
  try {
@@ -1,3 +1,4 @@
1
+ import { describeInferenceRoute } from './plan-view.ts';
1
2
  import { safeFinding } from './core/report-safety.ts';
2
3
  import type { Plugin } from '@opencode/plugin';
3
4
  import { createHash } from 'node:crypto';
@@ -34,6 +35,7 @@ const summary = (s: SecurityOperationState) => ({
34
35
  startedAt: s.startedAt,
35
36
  finishedAt: s.finishedAt,
36
37
  costNotice: s.plan.costNotice,
38
+ inferenceRoute: describeInferenceRoute(s.plan.settings),
37
39
  });
38
40
  export function registerOperationTools(
39
41
  editor: Editor,
@@ -90,7 +92,7 @@ export function registerOperationTools(
90
92
  model: state.plan.settings.codex.model,
91
93
  path: state.plan.path,
92
94
  contextFiles: state.plan.contextFiles.map((f) => f.path),
93
- note: 'Prepared only. Show target and cost notice before starting. Policy will not be applied automatically.',
95
+ note: 'Prepared only. Show target, inference route and cost notice before starting. Policy will not be applied automatically.',
94
96
  }),
95
97
  };
96
98
  },
@@ -112,7 +114,7 @@ export function registerOperationTools(
112
114
  ...summary(state),
113
115
  finding: f.finding,
114
116
  model: state.plan.settings.codex.model,
115
- note: 'Prepared only. Separate uncapped-validation authorization is required.',
117
+ note: 'Prepared only. Show inference route. Separate uncapped-validation authorization is required.',
116
118
  }),
117
119
  };
118
120
  },
@@ -146,6 +148,8 @@ export function registerOperationTools(
146
148
  path: state.plan.path ?? null,
147
149
  contextFiles: state.plan.contextFiles.map((f) => f.path),
148
150
  costNotice: state.plan.costNotice,
151
+ inferenceRoute: describeInferenceRoute(state.plan.settings),
152
+ settings: JSON.stringify(state.plan.settings),
149
153
  },
150
154
  });
151
155
  context.signal.throwIfAborted();
@@ -212,11 +216,13 @@ export function registerOperationTools(
212
216
  args.operationId,
213
217
  args.questionId,
214
218
  createHash('sha256').update(args.answer).digest('hex'),
219
+ JSON.stringify(state.plan.settings),
215
220
  ],
216
221
  metadata: {
217
222
  questions: state.question.questions,
218
223
  answer: args.answer,
219
- recipient: 'OpenAI via the already-authorized Security policy operation',
224
+ recipient: `${describeInferenceRoute(state.plan.settings)} via the already-authorized Security policy operation`,
225
+ settings: JSON.stringify(state.plan.settings),
220
226
  },
221
227
  });
222
228
  context.signal.throwIfAborted();
package/src/plan-view.ts CHANGED
@@ -1,7 +1,22 @@
1
+ import type { ResolvedSdkSettings } from './core/sdk-settings';
1
2
  import type { ScanState } from './types';
2
3
  import type { ScanInputs, ScanPlan } from './core/scan-plan';
4
+ /** Public route details only: never resolve credentials or read configuration in the UI. */
5
+ export function describeInferenceRoute(settings: ResolvedSdkSettings): string {
6
+ const { provider, auth, source, model } = settings.codex;
7
+ const destination = provider ? `${provider.id} (${provider.baseUrl})` : 'OpenAI';
8
+ const authentication =
9
+ auth === 'api-key'
10
+ ? `API key from environment variable ${provider?.envKey ?? 'OPENAI_API_KEY'} (name only)`
11
+ : 'dedicated ChatGPT sign-in';
12
+ const configuration = source
13
+ ? `; config ${source.path}${source.profile ? `, profile ${source.profile}` : ''}, SHA-256 ${source.digest}`
14
+ : '';
15
+ return `${destination}; auth: ${authentication}; model: ${model}${configuration}${provider ? '; gateway upstream routing is not verified' : ''}`;
16
+ }
17
+
3
18
  export function describePlan(plan: ScanPlan): string {
4
- return `${plan.mock ? 'MOCK SDK TEST: no model calls or uploads.\n' : ''}Repository: ${plan.repository}\nScope: ${plan.scopePaths.join(', ') || 'whole repository'}\nScan prompt:\n${plan.scanPrompt || '(none)'}\nContext files: ${plan.contextFiles.map((f) => `${f.path} (${f.bytes} bytes, SHA-256 ${f.sha256})`).join('; ') || '(none)'}\nSDK settings: model ${plan.settings.codex.model}, reasoning ${plan.settings.codex.reasoningEffort}, Daybreak ${plan.settings.codex.daybreak ? 'on' : 'off'}; mode ${plan.settings.scan.mode} (${plan.inputs.mode ? 'per-scan choice' : 'global default'}), access program ${plan.settings.scan.cyberAccessProgram ?? 'Codex default'}, estimated cost stop USD ${plan.settings.scan.maxCostUsd}. Account/model/Daybreak access is not verified by these settings.\nGlobal config: ${plan.globalConfigFile}${plan.globalConfigDigest ? '' : ' (not present)'}\nOnly this explicit prompt and these files are added as context; conversation history is not copied. Scoped paths guide the scan, not a filesystem sandbox.\n${plan.mock ? 'Synthetic SDK responses only.' : `Repository source and listed context go to OpenAI via dedicated ChatGPT sign-in. Local results. USD ${plan.settings.scan.maxCostUsd} estimated stop threshold, not a hard cap; in-flight usage may exceed it.`}`;
19
+ return `${plan.mock ? 'MOCK SDK TEST: no model calls or uploads.\n' : ''}Repository: ${plan.repository}\nScope: ${plan.scopePaths.join(', ') || 'whole repository'}\nScan prompt:\n${plan.scanPrompt || '(none)'}\nContext files: ${plan.contextFiles.map((f) => `${f.path} (${f.bytes} bytes, SHA-256 ${f.sha256})`).join('; ') || '(none)'}\nSDK settings: model ${plan.settings.codex.model}, reasoning ${plan.settings.codex.reasoningEffort}, Daybreak ${plan.settings.codex.daybreak ? 'on' : 'off'}; mode ${plan.settings.scan.mode} (${plan.inputs.mode ? 'per-scan choice' : 'global default'}), access program ${plan.settings.scan.cyberAccessProgram ?? 'Codex default'}, estimated cost stop USD ${plan.settings.scan.maxCostUsd}. Account/model/Daybreak access is not verified by these settings.\nGlobal config: ${plan.globalConfigFile}${plan.globalConfigDigest ? '' : ' (not present)'}\nOnly this explicit prompt and these files are added as context; conversation history is not copied. Scoped paths guide the scan, not a filesystem sandbox.\n${plan.mock ? 'Synthetic SDK responses only.' : `Repository source and listed context go to ${describeInferenceRoute(plan.settings)}. Local results. USD ${plan.settings.scan.maxCostUsd} estimated stop threshold, not a hard cap; in-flight usage may exceed it.`}`;
5
20
  }
6
21
 
7
22
  /** A UI choice affects only the next preview, never the global file. */
package/src/plugin.ts CHANGED
@@ -165,7 +165,11 @@ export function createSecurityPlugin(
165
165
  source: { type: 'tool', messageID: context.messageID, id: context.id },
166
166
  action: 'codex_security_scan',
167
167
  resources: [plan.repository, JSON.stringify(plan.settings)],
168
- metadata: { preview: describePlan(plan), planId },
168
+ metadata: {
169
+ preview: describePlan(plan),
170
+ planId,
171
+ settings: JSON.stringify(plan.settings),
172
+ },
169
173
  });
170
174
  context.signal.throwIfAborted();
171
175
  const state = await service.start({ planId, approveOpenAIInference: true });
@@ -1,3 +1,4 @@
1
+ import { describeInferenceRoute } from './plan-view';
1
2
  import { createEffect, createMemo, createSignal, For, Show, on, onCleanup } from 'solid-js';
2
3
  import { createStore, reconcile } from 'solid-js/store';
3
4
  import type {
@@ -350,7 +351,8 @@ export function SecurityOperationsPanel(props: {
350
351
  <>
351
352
  <p>
352
353
  Repository source{op().kind === 'validation' ? ', the finding,' : ''} and
353
- selected context will be sent to OpenAI.
354
+ selected context will be sent to{' '}
355
+ {describeInferenceRoute(op().plan.settings)}.
354
356
  </p>
355
357
  <p>
356
358
  <strong>{op().plan.costNotice}</strong>
@@ -464,8 +466,9 @@ export function SecurityOperationsPanel(props: {
464
466
  onInput={(e) => setAnswer(e.currentTarget.value)}
465
467
  />
466
468
  <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
+ Answer each question, or say what is unknown. Your answer goes to the
470
+ already-approved route: {describeInferenceRoute(op().plan.settings)}. Do not
471
+ include secrets.
469
472
  </span>
470
473
  </label>
471
474
  <button