diff --git a/README.md b/README.md index 5ad7f2ef..b53d793f 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,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 5e49f8e9..c87f8848 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -118,20 +118,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`, @@ -335,6 +336,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. +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. + ### Scan project components `scan --path` runs one scan across the selected paths. Use `scan-components` 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 d93b913b..413c7b9e 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -71,6 +71,7 @@ import { import { AuthenticationRequiredError, CodexSecurityError, + ConfigurationError, IncompleteScanError, OutputDirectoryError, errorMessage, @@ -175,6 +176,7 @@ interface PreparedRuntime { } interface PreparedSession { + safetyIdentifier?: string; runtime: PreparedRuntime; runtimeHome: string; effectiveConfig: JsonObject; @@ -205,6 +207,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[]; @@ -375,6 +379,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/"; @@ -426,7 +431,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 validate(options: ValidationOptions): Promise { @@ -1797,7 +1804,7 @@ export class CodexSecurity { apiKey, sessionConfig, } = session; - const environment = { + const environment: ProcessEnvironment = { ...pluginExecutionEnvironment( python, withoutCodexHome( @@ -1810,6 +1817,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. @@ -1848,6 +1862,7 @@ export class CodexSecurity { options: Pick< ScanOptions, | "auth" + | "safetyIdentifier" | "expectedPluginVersion" | "onAuthentication" | "onWarning" @@ -1993,6 +2008,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, @@ -2010,6 +2037,7 @@ export class CodexSecurity { checkOpen(); return { runtime, + safetyIdentifier: options.safetyIdentifier, runtimeHome, effectiveConfig, preflightConfig, @@ -2121,6 +2149,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) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 867245fb..c6ea0dec 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -211,6 +211,7 @@ const EXPORT_DEFAULT_OUTPUTS = { } as const; const VALUE_OPTIONS = new Set([ "--auth", + "--safety-identifier", "--path", "--component", "--components-file", @@ -844,6 +845,7 @@ export function resolveCliPath(directory: string, value: string): string { interface ScanArguments extends DeepScanOptions { auth?: ScanAuthMode; + safetyIdentifier?: string; verbose?: boolean; repository?: string; paths: string[]; @@ -935,6 +937,7 @@ const findingVerificationSchema = z.object({ type FindingVerification = z.infer; interface SkillRunOptions { + safetyIdentifier?: string; directory?: string; findings?: readonly Finding[]; findingInstructions?: Readonly>; @@ -2249,6 +2252,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([]) @@ -2423,6 +2431,7 @@ export async function main( const outcome = await runScan( { auth: options.auth, + safetyIdentifier: options.safetyIdentifier, verbose: options.verbose, repository: args.repository, paths: options.path, @@ -4703,6 +4712,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 ? [] : [ @@ -5479,6 +5494,7 @@ async function executeScan( security = dependencies.createSecurity(config); const options: ScanOptions = { auth, + safetyIdentifier: arguments_.safetyIdentifier, target, knowledgeBasePaths: arguments_.knowledgeBasePaths, ...prompts, @@ -5947,6 +5963,7 @@ async function executeScan( dependencies, { ...providerOptions, + safetyIdentifier: arguments_.safetyIdentifier, environment, findingInstructions: patchSelection?.instructions, }, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 45d84809..7f1d7090 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -442,6 +442,86 @@ describe("CodexSecurity finding validation", () => { }); 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), + persistentCredentialHome: true, + environment: runtimeEnvironment, + }), + resolvePluginPython: async () => "/managed/python", + 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("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:", @@ -1558,6 +1638,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 fbf7520e..504e52a0 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 4473945d..acb2c30d 100644 --- a/sdk/typescript/tests-ts/scan-comparison.test.ts +++ b/sdk/typescript/tests-ts/scan-comparison.test.ts @@ -187,6 +187,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);