opencode-codex-security 0.1.0 → 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.
- package/README.md +30 -32
- package/dist/gui-experimental.cjs +8 -8
- package/docs/GLOBAL-CONFIG.md +82 -6
- package/docs/PUBLISHING.md +40 -29
- package/docs/RELEASING.md +5 -5
- package/docs/SDK.md +21 -9
- package/package.json +2 -1
- package/src/core/codex-adapter.ts +29 -19
- package/src/core/credential-guard.ts +47 -0
- package/src/core/planned-scan.ts +32 -1
- package/src/core/provider-config.ts +254 -0
- package/src/core/scan-plan.ts +3 -2
- package/src/core/sdk-settings.ts +135 -11
- package/src/core/security-operations.ts +16 -16
- package/src/operation-tools.ts +9 -3
- package/src/plan-view.ts +16 -1
- package/src/plugin.ts +5 -1
- package/src/security-operations-panel.tsx +6 -3
- package/src/security-panel.tsx +6 -5
- package/src/security-tui-operations.tsx +3 -2
- package/src/tui.tsx +3 -2
|
@@ -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
|
+
}
|
package/src/core/scan-plan.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadGlobalSettings,
|
|
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 =
|
|
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) ||
|
package/src/core/sdk-settings.ts
CHANGED
|
@@ -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
|
-
? [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
? `
|
|
275
|
-
: '
|
|
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:
|
|
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:
|
|
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
|
-
:
|
|
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 {
|
package/src/operation-tools.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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: {
|
|
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
|
|
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
|
|
468
|
-
|
|
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
|