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
29 changes: 27 additions & 2 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
41 changes: 34 additions & 7 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -5634,12 +5634,19 @@ async function runSkill(
): Promise<number> {
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(
Expand Down Expand Up @@ -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)}`]),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Comment thread
mldangelo-oai marked this conversation as resolved.
) {
patchAnalyticsOverride = `analytics.enabled=${JSON.stringify(analytics["enabled"])}`;
}
const auth =
!arguments_.dryRun && !arguments_.mock && interactive
? await chooseInteractiveAuthentication(
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/tests-ts/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ describe("CodexSecurity finding validation", () => {
model: "test-model",
model_reasoning_effort: "high",
approval_policy: "never",
analytics: { enabled: false },
},
},
{
Expand Down Expand Up @@ -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" },
},
});
Expand Down
35 changes: 31 additions & 4 deletions sdk/typescript/tests-ts/cli-patch-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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) {
Expand All @@ -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,
);
Expand All @@ -122,6 +133,16 @@ test.each([
async function* events(): AsyncGenerator<string> {
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,
Expand Down Expand Up @@ -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);
Expand Down
132 changes: 75 additions & 57 deletions sdk/typescript/tests-ts/cli-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"]);
Expand Down
Loading
Loading