diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 6b3b70ae8..08be42dc9 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -70,6 +70,10 @@ Pass literal text or a JSON-serializable object as `finding`, not a file path. Validation uses the client's settings and credentials without changing repository files or adding a scan to history. +To disable Codex usage analytics and built-in metrics, create the client with +`new CodexSecurity({ codexOverrides: { analytics: { enabled: false } } })`. +This setting also applies to scans run by the same client. + Results include `disposition` (`reportable`, `suppressed`, `not_applicable`, or `deferred`), a Markdown `report`, `threadId`, and evidence `outputDir`. `reportable` may rely on static analysis; `deferred` means insufficient evidence. @@ -516,8 +520,29 @@ or `features.plugins` are rejected, including in profiles. Multi-agent v2 must stay enabled: `agents.max_threads` and `features.multi_agent_v2.enabled=false` are rejected. -`validate` and `patch` accept `--effort` and the `model` and -`model_reasoning_effort` keys in `--codex`, but no other runtime overrides. +`validate`, `patch`, and `verify-fix` accept `--effort` and the `model`, +`model_reasoning_effort`, and `analytics.enabled` keys in `--codex`, but no +other runtime overrides. + +Use `--codex 'analytics.enabled=false'` to disable Codex usage analytics and +built-in metrics for a command: + +```bash +npx @openai/codex-security validate "Candidate finding" --codex 'analytics.enabled=false' +npx @openai/codex-security patch "Security issue" --codex 'analytics.enabled=false' +npx @openai/codex-security verify-fix "Security issue" --codex 'analytics.enabled=false' +``` + +The same setting works for `scan` and `bulk-scan`. An explicit setting is +preserved when `scan --patch` starts remediation and when +`patch --assess-patch-risk` starts its follow-up assessment. Boolean `true` +is also accepted; omitting the setting preserves the command's existing +configuration and Codex defaults. Validation continues to ignore user +configuration, while patching and verification retain their existing ambient +configuration and project-trust behavior. + +This setting does not control explicitly configured OpenTelemetry log or trace +exporters, authentication, integrations, or CLI update checks. See [Local security model](#local-security-model) for approval and filesystem restrictions. diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index d803ed425..6db8772ac 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3763,7 +3763,7 @@ export async function main( .array(optionValue("--codex")) .default([]) .describe( - 'Repeat TOML model="gpt-5.6-terra" or model_reasoning_effort="high" only.', + 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), async run({ options }) { @@ -3819,7 +3819,7 @@ export async function main( .array(optionValue("--codex")) .default([]) .describe( - 'Repeat TOML model="gpt-5.6-terra" or model_reasoning_effort="high" only.', + 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), output: z.record(z.string(), z.unknown()).optional(), @@ -4042,7 +4042,7 @@ export async function main( .array(optionValue("--codex")) .default([]) .describe( - 'Repeat TOML model="gpt-5.6-terra" or model_reasoning_effort="high" only.', + 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), output: z.record(z.string(), z.unknown()).optional(), @@ -5634,12 +5634,19 @@ async function runSkill( ): Promise { const overrides = parseCodexOverrides(codexOverrides, undefined, effort); if ( - Object.keys(overrides).some( - (key) => key !== "model" && key !== "model_reasoning_effort", + Object.entries(overrides).some( + ([key, value]) => + key !== "model" && + key !== "model_reasoning_effort" && + !( + key === "analytics" && + isJsonObject(value) && + Object.keys(value).every((key) => key === "enabled") + ), ) ) { throw new CodexSecurityError( - "Validation and patching only support model and model_reasoning_effort overrides.", + "Skill commands only support model, model_reasoning_effort, and analytics.enabled overrides.", ); } const { model, reasoningEffort } = scanModelConfiguration( @@ -5794,6 +5801,12 @@ async function runSkill( `model=${JSON.stringify(model)}`, "--config", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`, + ...codexOverrides + .filter( + (value) => + value.startsWith("analytics.") || value.startsWith("analytics="), + ) + .flatMap((value) => ["--config", value]), ...(options.provider === undefined ? [] : ["--config", `model_provider=${JSON.stringify(options.provider)}`]), @@ -6444,6 +6457,7 @@ async function executeScan( let effectiveReasoningEffort = DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort; let providerOptions: SkillRunOptions = {}; + let patchAnalyticsOverride: string | undefined; let selectedAuthentication: ScanAuthentication | null = null; let repository = ""; let failed = false; @@ -6479,6 +6493,14 @@ async function executeScan( ({ model: effectiveModel, reasoningEffort: effectiveReasoningEffort } = scanModelConfiguration(effectiveConfiguration)); const provider = scanModelProvider(effectiveConfiguration); + const analytics = effectiveConfiguration["analytics"]; + if ( + analytics !== undefined && + isJsonObject(analytics) && + analytics["enabled"] !== undefined + ) { + patchAnalyticsOverride = `analytics.enabled=${JSON.stringify(analytics["enabled"])}`; + } const auth = !arguments_.dryRun && !arguments_.mock && interactive ? await chooseInteractiveAuthentication( @@ -7079,7 +7101,12 @@ async function executeScan( try { patches = await runFindingPatches( selected, - [`model=${JSON.stringify(effectiveModel)}`], + [ + `model=${JSON.stringify(effectiveModel)}`, + ...(patchAnalyticsOverride === undefined + ? [] + : [patchAnalyticsOverride]), + ], effectiveReasoningEffort as ScanReasoningEffort, errorOutput, dependencies, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 6d39f3690..ea79d8d0a 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -365,6 +365,7 @@ describe("CodexSecurity finding validation", () => { model: "test-model", model_reasoning_effort: "high", approval_policy: "never", + analytics: { enabled: false }, }, }, { @@ -451,6 +452,7 @@ describe("CodexSecurity finding validation", () => { model: "test-model", model_reasoning_effort: "high", features: { plugins: false }, + analytics: { enabled: false }, responses_api_metadata: { codex_security_surface: "sdk" }, }, }); diff --git a/sdk/typescript/tests-ts/cli-patch-trust.test.ts b/sdk/typescript/tests-ts/cli-patch-trust.test.ts index 7a40577ca..8ac158d40 100644 --- a/sdk/typescript/tests-ts/cli-patch-trust.test.ts +++ b/sdk/typescript/tests-ts/cli-patch-trust.test.ts @@ -36,6 +36,7 @@ test.each([ await mkdir(repository); execFileSync("git", ["init", "--quiet", repository]); await writeCodexConfig(join(repository, ".codex", "config.toml"), { + analytics: { enabled: true }, mcp_servers: { synthetic: { command: process.execPath, @@ -48,6 +49,7 @@ test.each([ }); const configPath = join(codexHome, "config.toml"); await writeCodexConfig(configPath, { + analytics: { enabled: true }, model: "synthetic-model", model_provider: "synthetic", model_providers: { @@ -76,7 +78,13 @@ test.each([ }); const child = spawn( resolveCodexCommand({}).command, - ["app-server", "--disable", "plugins"], + [ + "app-server", + "--disable", + "plugins", + "--config", + "analytics.enabled=false", + ], { cwd: repository, env: { @@ -94,6 +102,8 @@ test.each([ child.stderr.resume(); const closed = once(child, "close"); let servers: string[] | undefined; + let analyticsEnabled: boolean | undefined; + let inspectedThreadId: string | undefined; const input = new Writable({ final(callback) { if (child.stdin.writableEnded) { @@ -106,11 +116,12 @@ test.each([ const request = JSON.parse(chunk.toString()); // Inspect the native task without making a model request. if (request.method === "turn/start") { + inspectedThreadId = request.params.threadId; child.stdin.write( `${JSON.stringify({ - id: 5, - method: "mcpServerStatus/list", - params: { threadId: request.params.threadId }, + id: 6, + method: "config/read", + params: { cwd: repository }, })}\n`, callback, ); @@ -122,6 +133,16 @@ test.each([ async function* events(): AsyncGenerator { for await (const line of createInterface({ input: child.stdout })) { const event = JSON.parse(line); + if (event.id === 6) { + analyticsEnabled = event.result?.config?.analytics?.enabled; + child.stdin.write( + `${JSON.stringify({ + id: 5, + method: "mcpServerStatus/list", + params: { threadId: inspectedThreadId }, + })}\n`, + ); + } if (event.id === 5) { servers = event.result?.data.map( (server: { name: string }) => server.name, @@ -150,6 +171,12 @@ test.each([ : {}), }); expect(await closed).toEqual([0, null]); + expect(analyticsEnabled).toBe( + mode === "conflicting-user-server" ? undefined : false, + ); + expect( + parseToml(await readFile(configPath, "utf8"))["analytics"], + ).toEqual({ enabled: true }); expect( parseToml(await readFile(configPath, "utf8"))["projects"], ).toEqual(projects); diff --git a/sdk/typescript/tests-ts/cli-patch.test.ts b/sdk/typescript/tests-ts/cli-patch.test.ts index f1f8dc0c2..ef22a1d7f 100644 --- a/sdk/typescript/tests-ts/cli-patch.test.ts +++ b/sdk/typescript/tests-ts/cli-patch.test.ts @@ -198,10 +198,17 @@ describe("scan and patch workflow", () => { await writeFile(join(repository, "app.ts"), "original\nuser change\n"); const outcome = await runWorkflow( - ["patch", "Synthetic issue", "--assess-patch-risk"], + [ + "patch", + "Synthetic issue", + "--assess-patch-risk", + "--codex", + "analytics.enabled=false", + ], { currentDirectory: repository, - onCodex: async (_args, output) => { + onCodex: async (args, output) => { + expect(args).toContain("analytics.enabled=false"); if ( output?.appServer?.prompt.includes( "$codex-security:assess-patch-risk", @@ -459,63 +466,74 @@ describe("scan and patch workflow", () => { } }); - test("patches selected scan findings in the scanned repository and returns JSON", async () => { - const result = resultWithFindings(["critical", "high", "medium", "low"]); - const invocations: Array<{ - args: readonly string[]; - directory: string | undefined; - prompt: string | undefined; - }> = []; - const patched: Finding[] = []; - const outcome = await runWorkflow( - [ - "scan", - "../other/repository", - "--patch", - "--patch-severity", - "high", - "--fail-on-severity", - "high", - "--json", - ], - { - result, - onCodex: (args, output) => { - invocations.push({ - args, - directory: output?.appServer?.directory, - prompt: output?.appServer?.prompt, - }); - patched.push(...completePatches(args, output)); - return 0; + test.each([false, true])( + "patches selected scan findings with analytics.enabled=%p in the scanned repository and returns JSON", + async (analyticsEnabled) => { + const result = resultWithFindings(["critical", "high", "medium", "low"]); + const invocations: Array<{ + args: readonly string[]; + directory: string | undefined; + prompt: string | undefined; + }> = []; + const patched: Finding[] = []; + const outcome = await runWorkflow( + [ + "scan", + "../other/repository", + "--patch", + "--codex", + `analytics.enabled=${analyticsEnabled}`, + "--codex", + "features.goals=false", + "--patch-severity", + "high", + "--fail-on-severity", + "high", + "--json", + ], + { + result, + onCodex: (args, output) => { + invocations.push({ + args, + directory: output?.appServer?.directory, + prompt: output?.appServer?.prompt, + }); + patched.push(...completePatches(args, output)); + return 0; + }, }, - }, - ); - - expect(outcome.exitCode).toBe(0); - expect(patched.map(({ occurrenceId }) => occurrenceId)).toEqual([ - "occ_1", - "occ_2", - ]); - expect(invocations).toHaveLength(2); - for (const invocation of invocations) { - expect(invocation.args[0]).toBe("app-server"); - expect(invocation.directory).toBe( - resolve(CURRENT_REPOSITORY, "../other/repository"), ); - expect(invocation.prompt).toContain("Return exactly one JSON object"); - } - expect(JSON.parse(outcome.stdout)).toMatchObject({ - manifest: result.manifest, - findings: result.findings, - patchSeverity: "high", - patches: [ - { occurrenceId: "occ_1", status: "verified" }, - { occurrenceId: "occ_2", status: "verified" }, - ], - }); - expect(outcome.stderr).toContain("Patching 2 confirmed findings..."); - }); + + expect(outcome.exitCode).toBe(0); + expect(patched.map(({ occurrenceId }) => occurrenceId)).toEqual([ + "occ_1", + "occ_2", + ]); + expect(invocations).toHaveLength(2); + for (const invocation of invocations) { + expect(invocation.args[0]).toBe("app-server"); + expect(invocation.args).toContain( + `analytics.enabled=${analyticsEnabled}`, + ); + expect(invocation.args).not.toContain("features.goals=false"); + expect(invocation.directory).toBe( + resolve(CURRENT_REPOSITORY, "../other/repository"), + ); + expect(invocation.prompt).toContain("Return exactly one JSON object"); + } + expect(JSON.parse(outcome.stdout)).toMatchObject({ + manifest: result.manifest, + findings: result.findings, + patchSeverity: "high", + patches: [ + { occurrenceId: "occ_1", status: "verified" }, + { occurrenceId: "occ_2", status: "verified" }, + ], + }); + expect(outcome.stderr).toContain("Patching 2 confirmed findings..."); + }, + ); test("continues with separate patch tasks when one finding fails", async () => { const result = resultWithFindings(["critical", "high", "medium"]); diff --git a/sdk/typescript/tests-ts/cli-skills.test.ts b/sdk/typescript/tests-ts/cli-skills.test.ts index 6a9c036a7..252f8d15f 100644 --- a/sdk/typescript/tests-ts/cli-skills.test.ts +++ b/sdk/typescript/tests-ts/cli-skills.test.ts @@ -140,6 +140,7 @@ describe("CLI skill commands", () => { expect(help.text()).toContain("--codex "); expect(help.text()).toContain('model="gpt-5.6-terra"'); expect(help.text()).toContain('model_reasoning_effort="high"'); + expect(help.text()).toContain("analytics.enabled=false"); expect(help.text()).not.toContain("--provider"); } } finally { @@ -837,6 +838,89 @@ describe("CLI skill commands", () => { } }); + test.each(["validate", "patch", "verify-fix"] as const)( + "passes explicit analytics settings to %s", + async (command) => { + for (const override of [ + "analytics.enabled=false", + "analytics.enabled=true", + "analytics={enabled=false}", + ]) { + let invocation: readonly string[] = []; + const stderr = capture(); + expect( + await main( + [ + command, + "Synthetic finding", + "--effort", + "high", + "--codex", + override, + ], + capture().stream, + stderr.stream, + dependencies({ + onCodex: (args, output) => { + invocation = args; + if (command === "verify-fix") { + output?.stdout.write( + JSON.stringify({ + results: [ + { + id: "finding-1", + status: "fixed", + evidence: "The original issue no longer reproduces.", + }, + ], + }), + ); + } + return 0; + }, + }), + ), + stderr.text(), + ).toBe(0); + expect(invocation).toContain(override); + expect(invocation).toContain('model_reasoning_effort="high"'); + } + + for (const override of [ + 'model_provider="synthetic"', + "features.goals=false", + "analytics.unrelated=false", + "analytics.enabled=false", + ]) { + let started = false; + const stderr = capture(); + expect( + await main( + [ + command, + "Synthetic finding", + "--codex", + override, + ...(override === "analytics.enabled=false" + ? ["--codex", "analytics.enabled=true"] + : []), + ], + capture().stream, + stderr.stream, + dependencies({ + onCodex: () => { + started = true; + return 0; + }, + }), + ), + ).toBe(2); + expect(stderr.text()).toContain("codex-security:"); + expect(started).toBe(false); + } + }, + ); + test("selects reasoning effort directly for validation and patching", async () => { for (const command of ["validate", "patch"] as const) { let invocation: readonly string[] = [];