Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ Environment API keys apply to the current scan; only `login --with-api-key`
saves them. Pass Codex access tokens on stdin to `login --with-access-token`.
Access-token environment variables are not scan API keys.

SDK callers can select native command authentication through
`codexOverrides.model_providers.<id>.auth` and `model_provider` (including a
selected `profile`). Scans, comparisons, and deduplication reviews preserve
that selection without requiring an API key or replacing it with a stored
login. Codex executes the helper and renews its token. Helper paths and relative
`auth.cwd` values resolve from the supplied `CODEX_HOME` (default `~/.codex`),
not the source checkout; an absolute `auth.cwd` is preserved. Comparisons and
reviews also honor the selected command provider in that home's `config.toml`.
Configuration is passed to Codex for validation, including profile support.

For other inference providers:

```bash
Expand Down Expand Up @@ -369,6 +379,9 @@ combined report.

`--max-cost` applies per component, excluding planning and matching.
`--model` and `--effort` also apply to matching; `--auth` applies throughout.
Planning and matching reject an ambient command provider that conflicts with
explicit `--auth chatgpt` or `--auth api-key`. A command provider explicitly
selected through SDK `codexOverrides` retains its authentication configuration.
Use `--knowledge-base`, `--scan-prompt-file`, and `--post-scan-prompt-file` as for
bulk scans.

Expand Down Expand Up @@ -1469,6 +1482,17 @@ migration that also imports known associations from stored scan occurrences.
New scan findings retain their target associations when indexed locally.
The server entrypoint selects the concrete
embedder and store, so either can be replaced independently.

For renewable embeddings credentials, import `OpenAiFindingEmbedder`,
`SqliteFindingsStore`, and `startFindingsServer` from
`@openai/codex-security/server`. The embedder's first argument accepts a static
key or `() => string | Promise<string>`. It calls the callback before every
HTTP batch, including subsequent calls to `embed`; callers own token acquisition.
Pass `fetch` as the second argument and
`process.env.CODEX_SECURITY_EMBEDDINGS_URL || undefined` as the third to use
the same full endpoint URL and default as `codex-security serve`. Importing the
server API does not start a listener.

The local workflow lives under `src/deduplication/`. `FindingDeduplicator`
receives a candidate API client and a `DeduplicationReviewer`, keeping grouping
separate from HTTP and model transport. `CodexDeduplicationReviewer` owns prompts
Expand Down
5 changes: 5 additions & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./server": {
"types": "./dist/server/api.d.ts",
"import": "./dist/server/api.js",
"default": "./dist/server/api.js"
},
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ const distFiles = new Set(
"scan-logs",
"scan-sessions",
"server/index",
"server/api",
"deduplication/codex-review",
"deduplication/checkpointed-review",
"deduplication/deduplication",
Expand Down
18 changes: 18 additions & 0 deletions sdk/typescript/scripts/fixtures/package-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ import {
type ValidationOptions,
type ValidationResult,
} from "@openai/codex-security";
import {
OpenAiFindingEmbedder,
SqliteFindingsStore,
startFindingsServer,
} from "@openai/codex-security/server";

export async function findingsServer(getApiKey: () => Promise<string>) {
return await startFindingsServer({
store: new SqliteFindingsStore(),
embeddings: new OpenAiFindingEmbedder(
getApiKey,
fetch,
process.env["CODEX_SECURITY_EMBEDDINGS_URL"] || undefined,
),
host: "127.0.0.1",
port: 0,
});
}

export async function publishCustom(
scanDir: string,
Expand Down
10 changes: 10 additions & 0 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,16 @@ try {
{ cwd: consumer },
);

run(
process.execPath,
[
"--input-type=module",
"--eval",
`const sdk = await import(${JSON.stringify(`${packageManifest.name}/server`)}); for (const name of ["OpenAiFindingEmbedder", "SqliteFindingsStore", "startFindingsServer"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + ".");`,
],
{ cwd: consumer },
);

await cp(
join(packageRoot, "scripts", "fixtures", "package-consumer.ts"),
join(consumer, "consumer.ts"),
Expand Down
82 changes: 64 additions & 18 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { z } from "incur";
import {
accountStatus,
configuredCodexHome,
CodexLoginHandle,
loginApiKey as persistApiKey,
logout as codexLogout,
Expand All @@ -49,7 +50,10 @@ import {
import {
EXTERNAL_CODEX_PROVIDERS,
isExternalModelProvider,
hasCommandAuth,
mergedCodexConfig,
modelProviderConfigOverride,
resolveCommandAuthConfig,
scanApprovalPolicy,
scanModelConfiguration,
scanModelProvider,
Expand Down Expand Up @@ -297,6 +301,7 @@ export const SCAN_AUTH_MODES = ["auto", "chatgpt", "api-key"] as const;
export type ScanAuthMode = (typeof SCAN_AUTH_MODES)[number];

export type ScanAuthentication =
| { method: "command"; verified: false }
| {
method: "api_key";
source:
Expand Down Expand Up @@ -733,6 +738,7 @@ export class CodexSecurity {
this.#dependencies.environment,
options.auth,
modelProvider,
hasCommandAuth(configuration),
),
...model,
...(typeof modelProvider === "string" ? { modelProvider } : {}),
Expand Down Expand Up @@ -2001,11 +2007,18 @@ export class CodexSecurity {
apiKey,
sessionConfig,
} = session;
const commandAuth = hasCommandAuth(sessionConfig);
const environment: ProcessEnvironment = {
...pluginExecutionEnvironment(
python,
withoutCodexHome(
selectedScanEnvironment(runtime.environment, auth, modelProvider),
selectedScanEnvironment(
commandAuth
? withoutOpenAiApiKeys(runtime.environment)
: runtime.environment,
auth,
modelProvider,
),
),
),
...(externalProvider === null
Expand All @@ -2026,6 +2039,7 @@ export class CodexSecurity {
// cannot safely encode their path and selector keys as dotted overrides.
delete sdkCodexConfig["projects"];
delete sdkCodexConfig["permissions"];
if (commandAuth) delete sdkCodexConfig["model_providers"];
const configuredResponsesMetadata = isRecord(
sdkCodexConfig["responses_api_metadata"],
)
Expand All @@ -2036,9 +2050,7 @@ export class CodexSecurity {
undefined
? undefined
: this.#codexCommand().command;
let sdkEnvironment = definedEnvironment(
selectedScanEnvironment(environment, "chatgpt"),
);
let sdkEnvironment = definedEnvironment(withoutOpenAiApiKeys(environment));
if (process.platform === "win32" && codexPathOverride === undefined) {
codexPathOverride = environment["CODEX_CLI_PATH"]!;
sdkEnvironment = bundledCodexSdkEnvironment(
Expand All @@ -2051,6 +2063,9 @@ export class CodexSecurity {
? {}
: { codexPathOverride: executablePathForSpawn(codexPathOverride) }),
...(externalProvider !== null || apiKey === null ? {} : { apiKey }),
...(commandAuth
? { configOverrides: modelProviderConfigOverride(sessionConfig) }
: {}),
env: sdkEnvironment,
config: {
...(sdkCodexConfig as NonNullable<CodexOptions["config"]>),
Expand Down Expand Up @@ -2084,15 +2099,21 @@ export class CodexSecurity {
throwIfAborted(signal);
};
try {
const requestedConfig = await mergedCodexConfig(this.config);
const requestedConfig = resolveCommandAuthConfig(
await mergedCodexConfig(this.config),
configuredCodexHome(this.#dependencies.environment),
);
const commandAuth = hasCommandAuth(requestedConfig);
const modelProvider = scanModelProvider(requestedConfig);
const externalProvider = isExternalModelProvider(modelProvider)
? EXTERNAL_CODEX_PROVIDERS[modelProvider]
: null;
const externalProvider =
!commandAuth && isExternalModelProvider(modelProvider)
? EXTERNAL_CODEX_PROVIDERS[modelProvider]
: null;
let authentication = scanAuthentication(
this.#dependencies.environment,
options.auth,
modelProvider,
commandAuth,
);
const apiKey =
authentication.method === "api_key"
Expand All @@ -2104,7 +2125,9 @@ export class CodexSecurity {
);
}
const scanEnvironment = selectedScanEnvironment(
this.#dependencies.environment,
commandAuth
? withoutOpenAiApiKeys(this.#dependencies.environment)
: this.#dependencies.environment,
options.auth,
modelProvider,
);
Expand Down Expand Up @@ -2199,6 +2222,7 @@ export class CodexSecurity {
if (
!runtime.credentialsAvailable &&
apiKey === null &&
!commandAuth &&
authentication.method !== "aws_credentials"
) {
throw new AuthenticationRequiredError(
Expand All @@ -2207,12 +2231,13 @@ export class CodexSecurity {
"OPENAI_API_KEY or CODEX_API_KEY for CI.",
);
}
authentication = await runtimeScanAuthentication(
this.#dependencies.environment,
runtime.codexHome,
options.auth,
modelProvider,
);
if (!commandAuth)
authentication = await runtimeScanAuthentication(
this.#dependencies.environment,
runtime.codexHome,
options.auth,
modelProvider,
);
if (
options.safetyIdentifier !== undefined &&
authentication.method !== "api_key" &&
Expand Down Expand Up @@ -2448,7 +2473,9 @@ export class CodexSecurity {
? undefined
: scanModelProvider(requestedConfig);
const processEnvironment = selectedScanEnvironment(
this.#dependencies.environment,
requestedConfig !== undefined && hasCommandAuth(requestedConfig)
? withoutOpenAiApiKeys(this.#dependencies.environment)
: this.#dependencies.environment,
auth,
modelProvider,
);
Expand Down Expand Up @@ -2494,6 +2521,7 @@ export class CodexSecurity {
? join(bootstrapWorkspace, "deep-scan-config.toml")
: undefined;
const credentialsAvailable =
hasCommandAuth(mergedConfig) ||
isExternalModelProvider(modelProvider) ||
modelProvider === "amazon-bedrock"
? false
Expand Down Expand Up @@ -3262,12 +3290,14 @@ export function scanAuthentication(
environment: ProcessEnvironment,
auth: ScanAuthMode = "auto",
modelProvider?: unknown,
commandAuth = false,
): ScanAuthentication {
if (!SCAN_AUTH_MODES.includes(auth)) {
throw new TypeError(
"Scan authentication mode must be auto, chatgpt, or api-key.",
);
}
if (commandAuth) return { method: "command", verified: false };
if (modelProvider === "amazon-bedrock") {
const sources = [
"AWS_BEARER_TOKEN_BEDROCK",
Expand Down Expand Up @@ -3362,9 +3392,8 @@ export function selectedScanEnvironment(
return environment;
}
return Object.fromEntries(
Object.entries(environment).filter(([name]) => {
Object.entries(withoutOpenAiApiKeys(environment)).filter(([name]) => {
const key = name.toUpperCase();
if (key === "OPENAI_API_KEY" || key === "CODEX_API_KEY") return false;
if (key === "OPENROUTER_API_KEY" || key === "FIREWORKS_API_KEY") {
return (
!bedrockProvider &&
Expand All @@ -3376,6 +3405,17 @@ export function selectedScanEnvironment(
);
}

function withoutOpenAiApiKeys(
environment: ProcessEnvironment,
): ProcessEnvironment {
return Object.fromEntries(
Object.entries(environment).filter(
([name]) =>
!["OPENAI_API_KEY", "CODEX_API_KEY"].includes(name.toUpperCase()),
),
);
}

function notifyObserver<Arguments extends unknown[]>(
observerName: ScanObserverName,
observer: ((...args: Arguments) => void) | undefined,
Expand Down Expand Up @@ -3569,6 +3609,12 @@ function sharedCredentialCodexConfig(
if (Object.hasOwn(config, key)) shared[key] = structuredClone(config[key]!);
}
const modelProvider = scanModelProvider(config);
if (hasCommandAuth(config)) {
for (const key of ["profile", "profiles"]) {
if (Object.hasOwn(config, key))
shared[key] = structuredClone(config[key]!);
}
}
if (typeof modelProvider === "string" && modelProvider.length > 0) {
shared["model_provider"] = modelProvider;
const providers = config["model_providers"];
Expand Down
53 changes: 52 additions & 1 deletion sdk/typescript/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,66 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { isIP } from "node:net";
import { PluginBootstrapError } from "./errors.js";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { parse } from "smol-toml";
import type { JsonObject } from "./config.js";
import { CodexSecurityError, PluginBootstrapError } from "./errors.js";
import {
executablePathForSpawn,
expandHome,
runCodexCommand,
type CodexCommand,
type ProcessEnvironment,
} from "./runtime.js";

const LOGIN_CHILD_TERMINATION_GRACE_MS = 1_000;

/** @internal */
export function environmentEntry(
environment: ProcessEnvironment,
requested: string,
): string | undefined {
const exact = environment[requested];
if (exact !== undefined || process.platform !== "win32") return exact;
const upper = requested.toUpperCase();
return Object.entries(environment).find(
([name]) => name.toUpperCase() === upper,
)?.[1];
}

/** @internal */
export function configuredCodexHome(environment: ProcessEnvironment): string {
return resolve(
expandHome(
environmentEntry(environment, "CODEX_HOME")?.trim() ||
join(homedir(), ".codex"),
environment,
),
);
}

/** @internal */
export async function readCodexHomeConfig(
environment: ProcessEnvironment,
signal?: AbortSignal,
): Promise<JsonObject> {
try {
return parse(
await readFile(join(configuredCodexHome(environment), "config.toml"), {
encoding: "utf8",
signal,
}),
) as JsonObject;
} catch (error) {
signal?.throwIfAborted();
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw new CodexSecurityError(
"Could not read the configured Codex provider.",
);
}
}

export interface LoginResult {
success: boolean;
exitCode: number | null;
Expand Down
Loading
Loading