From dd8f7055885d86f6a4e08e987a0abf11cbb49a57 Mon Sep 17 00:00:00 2001 From: Ian Webster <269041055+ianw-oai@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:23:10 -0700 Subject: [PATCH 1/2] Add safety identifiers to scans --- README.md | 4 + sdk/typescript/README.md | 58 +++++-- sdk/typescript/_bundled_plugin/.mcp.json | 1 + sdk/typescript/src/api.ts | 98 ++++++++++-- sdk/typescript/src/cli.ts | 17 ++ sdk/typescript/src/scan-comparison.ts | 1 + sdk/typescript/tests-ts/api.test.ts | 146 ++++++++++++++++++ sdk/typescript/tests-ts/cli-patch.test.ts | 23 +++ sdk/typescript/tests-ts/cli.test.ts | 19 +++ .../tests-ts/scan-comparison.test.ts | 2 + 10 files changed, 338 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 2b9f7aaf..6e25177b 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ Scan history is stored in the Codex Security workbench state directory. If that directory cannot be written, set `CODEX_SECURITY_STATE_DIR` to a writable directory outside the repository. +Applications can attribute API-key scans to an end user with +`scan --safety-identifier ID` or the SDK's per-run `safetyIdentifier` option. +See [Safety ID setup and runtime requirements](sdk/typescript/README.md#attribute-scans-to-end-users). + `findings list [repository]` shows open findings across a repository's scans and identifies findings not confirmed in its latest scan. diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index f9e87c45..8bd335f9 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -75,20 +75,21 @@ Pass runtime configuration to the `CodexSecurity` constructor: Pass scan configuration to `security.run(repository, options)` or `security.preflight(repository, options)`: -| Option | Description | -| ----------------------- | ------------------------------------------------------------------------------------- | -| `auth` | Select `"auto"`, `"chatgpt"`, or `"api-key"`. | -| `target` | Select a repository, repository-relative paths, committed diff, or working-tree diff. | -| `mode` | Select `"standard"` or `"deep"`; deep mode supports repositories and paths. | -| `knowledgeBasePaths` | Add architecture documents, security policies, threat models, or directories. | -| `outputDir` | Choose an artifact directory outside the enclosing Git worktree. | -| `archiveExisting` | Archive results already in `outputDir` before starting a scan. | -| `maxCostUsd` | Stop after the estimated model cost exceeds a positive USD amount. | -| `maxTimeHours` | Limit deep-scan discovery to a positive number of hours, up to 96. | -| `failureSeverity` | Record a finding-severity policy in the saved scan recipe. | -| `parentScanId` | Link a rerun to an existing parent scan. | -| `expectedPluginVersion` | Require the original plugin version when replaying a scan. | -| `signal` | Cancel a scan with an `AbortSignal`. | +| Option | Description | +| ----------------------- | ------------------------------------------------------------------------------------------ | +| `auth` | Select `"auto"`, `"chatgpt"`, or `"api-key"`. | +| `safetyIdentifier` | Stable hashed end-user ID for this scan's model requests; requires API-key authentication. | +| `target` | Select a repository, repository-relative paths, committed diff, or working-tree diff. | +| `mode` | Select `"standard"` or `"deep"`; deep mode supports repositories and paths. | +| `knowledgeBasePaths` | Add architecture documents, security policies, threat models, or directories. | +| `outputDir` | Choose an artifact directory outside the enclosing Git worktree. | +| `archiveExisting` | Archive results already in `outputDir` before starting a scan. | +| `maxCostUsd` | Stop after the estimated model cost exceeds a positive USD amount. | +| `maxTimeHours` | Limit deep-scan discovery to a positive number of hours, up to 96. | +| `failureSeverity` | Record a finding-severity policy in the saved scan recipe. | +| `parentScanId` | Link a rerun to an existing parent scan. | +| `expectedPluginVersion` | Require the original plugin version when replaying a scan. | +| `signal` | Cancel a scan with an `AbortSignal`. | Progress and lifecycle callbacks are `onAuthentication`, `onCost`, `onOutputArchived`, `onOutputDirReady`, `onScanStarted`, @@ -290,6 +291,35 @@ Repeat `--knowledge-base PATH` for multiple files or directories; `bulk-scan` shares them with every repository. Directories are searched recursively for Markdown, text, PDF, and Word (`.docx`) files. +### Attribute scans to end users + +For an application that serves multiple users, pass the originating user's +stable hashed ID on each scan: + +```ts +await security.run("/path/to/repository", { + auth: "api-key", + safetyIdentifier: hashedUserId, +}); +``` + +```bash +codex-security scan /path/to/repository --auth api-key --safety-identifier hashed-user-id +``` + +The ID must contain 1–64 characters and cannot be blank or contain NUL. +Do not use an email address or other personal data. The ID is passed to Codex +for the scan, nested workers, retries, and follow-up work. It is not saved in +shared configuration or the scan recipe. Supply it again for a later rerun. +Concurrent SDK clients can use different IDs without changing `process.env`. + +This requires a Codex runtime with native `--safety-identifier` support and a +plugin that forwards it to workers. The current bundled runtime does not yet +support this option. Use `CODEX_CLI_PATH` to select a compatible build. +Unsupported runtimes or plugins fail before the scan starts. Omitting the +option leaves ordinary scans unchanged. Preflight checks the ID's format, +but does not check runtime support or authentication. + ### Configure deep scans For `scan --mode deep`, `--workers` limits concurrent discovery workers, diff --git a/sdk/typescript/_bundled_plugin/.mcp.json b/sdk/typescript/_bundled_plugin/.mcp.json index dfea7dda..5ef47005 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -8,6 +8,7 @@ "CODEX_HOME", "CODEX_SQLITE_HOME", "CODEX_API_KEY", + "CODEX_SAFETY_IDENTIFIER", "CODEX_BROWSER_USE_NODE_PATH", "CODEX_CLI_PATH", "CODEX_ELECTRON_RESOURCES_PATH", diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a06e4804..acc06b1d 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -61,6 +61,7 @@ import { import { AuthenticationRequiredError, CodexSecurityError, + ConfigurationError, IncompleteScanError, OutputDirectoryError, errorMessage, @@ -113,6 +114,7 @@ import { resolveCodexCommand, resolvePluginPath, resolvePluginPython, + runCodexCommand, runWorkbench, setCodexSecurityCredentialLogout, type CodexCommand, @@ -165,6 +167,7 @@ interface PreparedRuntime { } interface PreparedSession { + safetyIdentifier?: string; runtime: PreparedRuntime; runtimeHome: string; effectiveConfig: JsonObject; @@ -195,6 +198,8 @@ export interface DeepScanOptions { export interface ScanOptions extends DeepScanOptions { auth?: ScanAuthMode; + /** Stable, privacy-preserving end-user ID for this scan's model requests. */ + safetyIdentifier?: string; target?: ScanTarget; mode?: ScanMode; knowledgeBasePaths?: string[]; @@ -326,6 +331,7 @@ interface ClientDependencies { prepareOutputDir?: typeof prepareOutputDir; repositoryRevision?: typeof repositoryRevision; resolveCodexCommand?: () => CodexCommand; + runCodexCommand?: typeof runCodexCommand; runWorkbench?: typeof runWorkbench; matchFindings?: typeof matchScanFindings; } @@ -336,6 +342,7 @@ const DEFAULT_DEPENDENCIES: ClientDependencies = { }; const SCAN_PERMISSION_PROFILE = "codex_security_scan"; +const SAFETY_IDENTIFIER_ENV = "CODEX_SAFETY_IDENTIFIER"; const PERSONAL_TRUSTED_ACCESS_URL = "https://chatgpt.com/cyber"; const ORGANIZATIONAL_TRUSTED_ACCESS_URL = "https://openai.com/form/enterprise-trusted-access-for-cyber/"; @@ -387,7 +394,9 @@ export class CodexSecurity { repository: string, options: ScanOptions = {}, ): Promise { - return await this.#trackOperation(() => this.#run(repository, options)); + return await this.#trackOperation(() => + this.#run(repository, { ...options }), + ); } public async preflight( @@ -1561,7 +1570,7 @@ export class CodexSecurity { apiKey, sessionConfig, } = session; - const environment = { + const environment: ProcessEnvironment = { ...pluginExecutionEnvironment( python, withoutCodexHome( @@ -1574,6 +1583,13 @@ export class CodexSecurity { CODEX_HOME: runtime.codexHome, ...runtimePaths, }; + for (const name of Object.keys(environment)) { + if (name.toUpperCase() === SAFETY_IDENTIFIER_ENV) + delete environment[name]; + } + if (session.safetyIdentifier !== undefined) { + environment[SAFETY_IDENTIFIER_ENV] = session.safetyIdentifier; + } const sdkCodexConfig = { ...sessionConfig }; // Projects and permissions already live in generated TOML files; the SDK // cannot safely encode their path and selector keys as dotted overrides. @@ -1612,6 +1628,7 @@ export class CodexSecurity { options: Pick< ScanOptions, | "auth" + | "safetyIdentifier" | "expectedPluginVersion" | "onAuthentication" | "onWarning" @@ -1690,6 +1707,32 @@ export class CodexSecurity { } const runtimeHome = await realpath(runtime.codexHome); requireOutputOutsideRepository(protectedRoot, runtimeHome, "runtime"); + if (options.safetyIdentifier !== undefined) { + const help = await ( + this.#dependencies.runCodexCommand ?? runCodexCommand + )( + this.#codexCommand(), + ["exec", "--help"], + runtime.environment, + undefined, + signal, + ); + if (!help.success || !help.stdout.includes("--safety-identifier")) { + throw new ConfigurationError( + "This Codex runtime does not support safetyIdentifier. Use a runtime with native --safety-identifier support through CODEX_CLI_PATH.", + ); + } + if ( + !(await pluginForwardsEnvironment( + runtime.plugin.installedRoot, + SAFETY_IDENTIFIER_ENV, + )) + ) { + throw new ConfigurationError( + "This Codex Security plugin does not forward safetyIdentifier to workers. Update the plugin.", + ); + } + } const sessionConfig = scanRuntimeCodexConfig( effectiveConfig, stateDirectory, @@ -1757,6 +1800,18 @@ export class CodexSecurity { options.auth, modelProvider, ); + if ( + options.safetyIdentifier !== undefined && + authentication.method !== "api_key" && + !( + authentication.method === "stored_credentials" && + authentication.credentialType === "api_key" + ) + ) { + throw new ConfigurationError( + "safetyIdentifier requires API-key authentication.", + ); + } notifyObserver( "onAuthentication", options.onAuthentication, @@ -1774,6 +1829,7 @@ export class CodexSecurity { checkOpen(); return { runtime, + safetyIdentifier: options.safetyIdentifier, runtimeHome, effectiveConfig, preflightConfig, @@ -1873,7 +1929,10 @@ export class CodexSecurity { ); runtime.deepScanConfigPath = runtime.bootstrapWorkspace !== undefined && - (await pluginSupportsIsolatedDeepScanConfig(runtime.plugin.pluginRoot)) + (await pluginForwardsEnvironment( + runtime.plugin.pluginRoot, + DEEP_SCAN_CONFIG_PATH_ENVIRONMENT, + )) ? join(runtime.bootstrapWorkspace, "deep-scan-config.toml") : undefined; runtime.effectiveConfig = mergedConfig; @@ -1885,6 +1944,18 @@ export class CodexSecurity { signal?: AbortSignal, ): Promise { deepScanOptions(options); + const identifier = options.safetyIdentifier; + if ( + identifier !== undefined && + (typeof identifier !== "string" || + identifier.trim().length === 0 || + identifier.includes("\0") || + [...identifier].length > 64) + ) { + throw new ConfigurationError( + "safetyIdentifier must contain 1 to 64 characters, must not be blank, and must not contain NUL.", + ); + } if ( options.maxCostUsd !== undefined && (!Number.isFinite(options.maxCostUsd) || options.maxCostUsd <= 0) @@ -2001,8 +2072,9 @@ export class CodexSecurity { environment: withoutCodexHome(processEnvironment), signal, }); - const deepScanConfigPath = (await pluginSupportsIsolatedDeepScanConfig( + const deepScanConfigPath = (await pluginForwardsEnvironment( plugin.pluginRoot, + DEEP_SCAN_CONFIG_PATH_ENVIRONMENT, )) ? join(bootstrapWorkspace, "deep-scan-config.toml") : undefined; @@ -3200,27 +3272,19 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { return result; } -async function pluginSupportsIsolatedDeepScanConfig( +async function pluginForwardsEnvironment( pluginRoot: string, + variable: string, ): Promise { - let configuration: unknown; try { - configuration = JSON.parse( + const configuration = JSON.parse( await readFile(join(pluginRoot, ".mcp.json"), "utf8"), ); + const environment = configuration?.mcpServers?.["codex-security"]?.env_vars; + return Array.isArray(environment) && environment.includes(variable); } catch { return false; } - if (!isRecord(configuration)) return false; - const servers = configuration["mcpServers"]; - if (!isRecord(servers)) return false; - const server = servers["codex-security"]; - if (!isRecord(server)) return false; - const environment = server["env_vars"]; - return ( - Array.isArray(environment) && - environment.includes(DEEP_SCAN_CONFIG_PATH_ENVIRONMENT) - ); } function throwIfAborted(signal?: AbortSignal, scanDir = ""): void { diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index d8a2e9d0..3a470a02 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -210,6 +210,7 @@ const EXPORT_DEFAULT_OUTPUTS = { } as const; const VALUE_OPTIONS = new Set([ "--auth", + "--safety-identifier", "--path", "--knowledge-base", "--scan-prompt-file", @@ -827,6 +828,7 @@ export function resolveCliPath(directory: string, value: string): string { interface ScanArguments extends DeepScanOptions { auth?: ScanAuthMode; + safetyIdentifier?: string; verbose?: boolean; repository?: string; paths: string[]; @@ -917,6 +919,7 @@ const findingVerificationSchema = z.object({ type FindingVerification = z.infer; interface SkillRunOptions { + safetyIdentifier?: string; directory?: string; findings?: readonly Finding[]; findingInstructions?: Readonly>; @@ -2221,6 +2224,11 @@ export async function main( .boolean() .default(false) .describe("Print scan diagnostics to stderr."), + safetyIdentifier: optionValue("--safety-identifier") + .optional() + .describe( + "Stable hashed end-user ID for this scan's model requests (1–64 characters).", + ), path: z .array(optionValue("--path")) .default([]) @@ -2390,6 +2398,7 @@ export async function main( const outcome = await runScan( { auth: options.auth, + safetyIdentifier: options.safetyIdentifier, verbose: options.verbose, repository: args.repository, paths: options.path, @@ -4404,6 +4413,12 @@ async function runSkill( ...(verify ? ["--config", 'approvals_reviewer="auto_review"'] : []), "--config", 'responses_api_metadata.codex_security_surface="cli"', + ...(options.safetyIdentifier === undefined + ? [] + : [ + "--config", + `safety_identifier=${JSON.stringify(options.safetyIdentifier)}`, + ]), ...(appServer ? [] : [ @@ -5179,6 +5194,7 @@ async function executeScan( security = dependencies.createSecurity(config); const options: ScanOptions = { auth, + safetyIdentifier: arguments_.safetyIdentifier, target, knowledgeBasePaths: arguments_.knowledgeBasePaths, ...prompts, @@ -5647,6 +5663,7 @@ async function executeScan( dependencies, { ...providerOptions, + safetyIdentifier: arguments_.safetyIdentifier, environment, findingInstructions: patchSelection?.instructions, }, diff --git a/sdk/typescript/src/scan-comparison.ts b/sdk/typescript/src/scan-comparison.ts index 60caca54..8e24c1b5 100644 --- a/sdk/typescript/src/scan-comparison.ts +++ b/sdk/typescript/src/scan-comparison.ts @@ -103,6 +103,7 @@ export async function matchScanFindingsInternal( const codex = options.codex ?? new Codex({ + codexPathOverride: resolveCodexCommand(options.environment).command, env: await comparisonEnvironment( options.environment, accountStatus, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 10d656c4..9741c07e 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -69,6 +69,12 @@ type ScanObserverName = Parameters< const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); +const SAFETY_IDENTIFIER_HELP = { + success: true, + exitCode: 0, + stdout: "--safety-identifier", + stderr: "", +}; const { cleanup, copyCompletedScan, temporaryDirectory } = createApiTestFixtures(); afterEach(cleanup); @@ -180,6 +186,135 @@ async function writeUsageSession( } describe("CodexSecurity orchestration", () => { + test("isolates per-run safety identifiers across clients and clears them on reuse", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "home"); + await mkdir(repository); + await mkdir(codexHome); + const runtimeEnvironment = { codex_safety_identifier: "ambient-user" }; + const received: Array = []; + const clients = [0, 1].map( + () => + new TestClient( + {}, + { + environment: { OPENAI_API_KEY: "synthetic-key" }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: runtimeEnvironment, + }), + resolvePluginPython: async () => "/managed/python", + runCodexCommand: async () => SAFETY_IDENTIFIER_HELP, + createCodex: (options) => { + received.push(options); + throw new Error("captured scan"); + }, + }, + ), + ); + try { + await Promise.all( + clients.map((client, i) => + expect( + client.run(repository, { + safetyIdentifier: `synthetic-user-${i}`, + outputDir: join(root, `scan-${i}`), + }), + ).rejects.toThrow("captured scan"), + ), + ); + expect( + received + .map((options) => options.env?.["CODEX_SAFETY_IDENTIFIER"]) + .sort(), + ).toEqual(["synthetic-user-0", "synthetic-user-1"]); + for (const safetyIdentifier of ["next-user", undefined]) { + await expect( + clients[0]!.run(repository, { + safetyIdentifier, + outputDir: join(root, safetyIdentifier ?? "unset"), + }), + ).rejects.toThrow("captured scan"); + } + expect( + received + .slice(2) + .map((options) => options.env?.["CODEX_SAFETY_IDENTIFIER"]), + ).toEqual(["next-user", undefined]); + expect(runtimeEnvironment).toEqual({ + codex_safety_identifier: "ambient-user", + }); + expect( + received.every( + (options) => options.config?.["safety_identifier"] === undefined, + ), + ).toBe(true); + } finally { + await Promise.all(clients.map((client) => client.close())); + } + }); + + test.each(["", " ", "a".repeat(65), "id\0suffix"])( + "rejects invalid safety identifier %j before preparing the runtime", + async (identifier) => { + const client = new TestClient({}, {}); + await expect( + client.run("/unused", { safetyIdentifier: identifier }), + ).rejects.toThrow("safetyIdentifier must"); + await client.close(); + }, + ); + + test.each([ + [false, true, "runtime does not support"], + [true, false, "does not forward"], + ] as const)( + "rejects unsupported Safety IDs (runtime: %s, plugin: %s)", + async (supported, forwards, message) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "home"); + await mkdir(repository); + await mkdir(codexHome); + await writeFile( + join(root, ".mcp.json"), + JSON.stringify({ + mcpServers: { + "codex-security": { + env_vars: forwards ? ["CODEX_SAFETY_IDENTIFIER"] : [], + }, + }, + }), + ); + const runtime = preparedRuntime(codexHome); + runtime.plugin.installedRoot = root; + const client = new TestClient( + {}, + { + prepareRuntime: async () => runtime, + runCodexCommand: async (_command, args) => { + expect(args).toEqual(["exec", "--help"]); + return { + ...SAFETY_IDENTIFIER_HELP, + stdout: supported ? SAFETY_IDENTIFIER_HELP.stdout : "", + }; + }, + }, + ); + try { + await expect( + client.run(repository, { + safetyIdentifier: "synthetic-user", + outputDir: join(root, "scan"), + }), + ).rejects.toThrow(message); + } finally { + await client.close(); + } + }, + ); + test("distinguishes local workbench and database errors from model transport failures", () => { for (const message of [ "sqlite3.OperationalError: unable to open database file\nwith closing(connect()) as connection:", @@ -1273,6 +1408,7 @@ describe("CodexSecurity orchestration", () => { { environment: {}, prepareRuntime: async () => preparedRuntime(codexHome), + runCodexCommand: async () => SAFETY_IDENTIFIER_HELP, resolvePluginPython: async () => "/managed/python", repositoryRevision: async () => null, createCodex: () => ({ @@ -1296,6 +1432,16 @@ describe("CodexSecurity orchestration", () => { ).rejects.toThrow("scan did not start"); expect(selectedAuthentication).toEqual(expected); expect(JSON.stringify(selectedAuthentication)).not.toContain("SYNTHETIC"); + await expect( + client.run(repository, { + safetyIdentifier: "synthetic-user", + outputDir: join(root, "attributed-scan"), + }), + ).rejects.toThrow( + "credentialType" in expected && expected.credentialType === "api_key" + ? "scan did not start" + : "safetyIdentifier requires API-key authentication", + ); await client.close(); } }); diff --git a/sdk/typescript/tests-ts/cli-patch.test.ts b/sdk/typescript/tests-ts/cli-patch.test.ts index 77798cef..36f759f8 100644 --- a/sdk/typescript/tests-ts/cli-patch.test.ts +++ b/sdk/typescript/tests-ts/cli-patch.test.ts @@ -225,6 +225,29 @@ describe("scan and patch workflow", () => { join(STATE_DIRECTORY, "codex-home"), ); + const attributed = await runWorkflow( + [ + "scan", + "--patch", + "--auth", + "api-key", + "--safety-identifier", + "synthetic-user", + "--json", + ], + { + result, + environment: { OPENAI_API_KEY: "synthetic-key" }, + onCodex: (args, output) => { + invocation = args; + completePatches(args, output); + return 0; + }, + }, + ); + expect(attributed.exitCode).toBe(0); + expect(invocation).toContain('safety_identifier="synthetic-user"'); + const provider = await runWorkflow( [ "scan", diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index ea5cd984..3fc35921 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -90,6 +90,25 @@ async function multiscanInventory(root: string): Promise { } describe("CLI", () => { + test("passes the safety identifier as a per-scan option", async () => { + let options: unknown; + const stderr = capture(); + expect( + await main( + ["scan", "--safety-identifier", "synthetic-user", ".", "--json"], + capture().stream, + stderr.stream, + dependencies({ + onTurn: (_repository, value) => { + options = value; + }, + }), + ), + ).toBe(0); + expect(options).toMatchObject({ safetyIdentifier: "synthetic-user" }); + expect(stderr.text()).not.toContain("synthetic-user"); + }); + test("exposes Incur help, schemas, manifests, and completions", async () => { const root = capture(); const stderr = capture(); diff --git a/sdk/typescript/tests-ts/scan-comparison.test.ts b/sdk/typescript/tests-ts/scan-comparison.test.ts index fe015482..68255588 100644 --- a/sdk/typescript/tests-ts/scan-comparison.test.ts +++ b/sdk/typescript/tests-ts/scan-comparison.test.ts @@ -93,6 +93,8 @@ describe("semantic scan comparison", () => { CODEX_SECURITY_STATE_DIR: stateDirectory, CODEX_SECURITY_SCAN_ID: "scan", CODEX_HOME: "/provider-home", + CODEX_CLI_PATH: "/compatible-codex", + CODEX_SAFETY_IDENTIFIER: "synthetic-user", FIREWORKS_API_KEY: "provider-key", }; expect(await comparisonEnvironment(provider, account)).toEqual(provider); From c8c7323c2d6479735b3247c64be93a906f1bc881 Mon Sep 17 00:00:00 2001 From: Ian Webster <269041055+ianw-oai@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:18:41 -0700 Subject: [PATCH 2/2] Remove safety identifier compatibility checks --- sdk/typescript/README.md | 6 +-- sdk/typescript/src/api.ts | 54 ++++++++------------------- sdk/typescript/tests-ts/api.test.ts | 58 +---------------------------- 3 files changed, 19 insertions(+), 99 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 8bd335f9..57d4a3e1 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -316,9 +316,9 @@ Concurrent SDK clients can use different IDs without changing `process.env`. This requires a Codex runtime with native `--safety-identifier` support and a plugin that forwards it to workers. The current bundled runtime does not yet support this option. Use `CODEX_CLI_PATH` to select a compatible build. -Unsupported runtimes or plugins fail before the scan starts. Omitting the -option leaves ordinary scans unchanged. Preflight checks the ID's format, -but does not check runtime support or authentication. +The SDK does not check runtime or plugin compatibility; older versions may +omit the identifier. Omitting the option leaves ordinary scans unchanged. +Preflight checks the ID's format; authentication is checked when the scan starts. ### Configure deep scans diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index acc06b1d..42de4a46 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -114,7 +114,6 @@ import { resolveCodexCommand, resolvePluginPath, resolvePluginPython, - runCodexCommand, runWorkbench, setCodexSecurityCredentialLogout, type CodexCommand, @@ -331,7 +330,6 @@ interface ClientDependencies { prepareOutputDir?: typeof prepareOutputDir; repositoryRevision?: typeof repositoryRevision; resolveCodexCommand?: () => CodexCommand; - runCodexCommand?: typeof runCodexCommand; runWorkbench?: typeof runWorkbench; matchFindings?: typeof matchScanFindings; } @@ -1707,32 +1705,6 @@ export class CodexSecurity { } const runtimeHome = await realpath(runtime.codexHome); requireOutputOutsideRepository(protectedRoot, runtimeHome, "runtime"); - if (options.safetyIdentifier !== undefined) { - const help = await ( - this.#dependencies.runCodexCommand ?? runCodexCommand - )( - this.#codexCommand(), - ["exec", "--help"], - runtime.environment, - undefined, - signal, - ); - if (!help.success || !help.stdout.includes("--safety-identifier")) { - throw new ConfigurationError( - "This Codex runtime does not support safetyIdentifier. Use a runtime with native --safety-identifier support through CODEX_CLI_PATH.", - ); - } - if ( - !(await pluginForwardsEnvironment( - runtime.plugin.installedRoot, - SAFETY_IDENTIFIER_ENV, - )) - ) { - throw new ConfigurationError( - "This Codex Security plugin does not forward safetyIdentifier to workers. Update the plugin.", - ); - } - } const sessionConfig = scanRuntimeCodexConfig( effectiveConfig, stateDirectory, @@ -1929,10 +1901,7 @@ export class CodexSecurity { ); runtime.deepScanConfigPath = runtime.bootstrapWorkspace !== undefined && - (await pluginForwardsEnvironment( - runtime.plugin.pluginRoot, - DEEP_SCAN_CONFIG_PATH_ENVIRONMENT, - )) + (await pluginSupportsIsolatedDeepScanConfig(runtime.plugin.pluginRoot)) ? join(runtime.bootstrapWorkspace, "deep-scan-config.toml") : undefined; runtime.effectiveConfig = mergedConfig; @@ -2072,9 +2041,8 @@ export class CodexSecurity { environment: withoutCodexHome(processEnvironment), signal, }); - const deepScanConfigPath = (await pluginForwardsEnvironment( + const deepScanConfigPath = (await pluginSupportsIsolatedDeepScanConfig( plugin.pluginRoot, - DEEP_SCAN_CONFIG_PATH_ENVIRONMENT, )) ? join(bootstrapWorkspace, "deep-scan-config.toml") : undefined; @@ -3272,19 +3240,27 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { return result; } -async function pluginForwardsEnvironment( +async function pluginSupportsIsolatedDeepScanConfig( pluginRoot: string, - variable: string, ): Promise { + let configuration: unknown; try { - const configuration = JSON.parse( + configuration = JSON.parse( await readFile(join(pluginRoot, ".mcp.json"), "utf8"), ); - const environment = configuration?.mcpServers?.["codex-security"]?.env_vars; - return Array.isArray(environment) && environment.includes(variable); } catch { return false; } + if (!isRecord(configuration)) return false; + const servers = configuration["mcpServers"]; + if (!isRecord(servers)) return false; + const server = servers["codex-security"]; + if (!isRecord(server)) return false; + const environment = server["env_vars"]; + return ( + Array.isArray(environment) && + environment.includes(DEEP_SCAN_CONFIG_PATH_ENVIRONMENT) + ); } function throwIfAborted(signal?: AbortSignal, scanDir = ""): void { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9741c07e..af721eb5 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -69,12 +69,6 @@ type ScanObserverName = Parameters< const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); -const SAFETY_IDENTIFIER_HELP = { - success: true, - exitCode: 0, - stdout: "--safety-identifier", - stderr: "", -}; const { cleanup, copyCompletedScan, temporaryDirectory } = createApiTestFixtures(); afterEach(cleanup); @@ -202,10 +196,10 @@ describe("CodexSecurity orchestration", () => { environment: { OPENAI_API_KEY: "synthetic-key" }, prepareRuntime: async () => ({ ...preparedRuntime(codexHome), + persistentCredentialHome: true, environment: runtimeEnvironment, }), resolvePluginPython: async () => "/managed/python", - runCodexCommand: async () => SAFETY_IDENTIFIER_HELP, createCodex: (options) => { received.push(options); throw new Error("captured scan"); @@ -266,55 +260,6 @@ describe("CodexSecurity orchestration", () => { }, ); - test.each([ - [false, true, "runtime does not support"], - [true, false, "does not forward"], - ] as const)( - "rejects unsupported Safety IDs (runtime: %s, plugin: %s)", - async (supported, forwards, message) => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const codexHome = join(root, "home"); - await mkdir(repository); - await mkdir(codexHome); - await writeFile( - join(root, ".mcp.json"), - JSON.stringify({ - mcpServers: { - "codex-security": { - env_vars: forwards ? ["CODEX_SAFETY_IDENTIFIER"] : [], - }, - }, - }), - ); - const runtime = preparedRuntime(codexHome); - runtime.plugin.installedRoot = root; - const client = new TestClient( - {}, - { - prepareRuntime: async () => runtime, - runCodexCommand: async (_command, args) => { - expect(args).toEqual(["exec", "--help"]); - return { - ...SAFETY_IDENTIFIER_HELP, - stdout: supported ? SAFETY_IDENTIFIER_HELP.stdout : "", - }; - }, - }, - ); - try { - await expect( - client.run(repository, { - safetyIdentifier: "synthetic-user", - outputDir: join(root, "scan"), - }), - ).rejects.toThrow(message); - } finally { - await client.close(); - } - }, - ); - test("distinguishes local workbench and database errors from model transport failures", () => { for (const message of [ "sqlite3.OperationalError: unable to open database file\nwith closing(connect()) as connection:", @@ -1408,7 +1353,6 @@ describe("CodexSecurity orchestration", () => { { environment: {}, prepareRuntime: async () => preparedRuntime(codexHome), - runCodexCommand: async () => SAFETY_IDENTIFIER_HELP, resolvePluginPython: async () => "/managed/python", repositoryRevision: async () => null, createCodex: () => ({