From 92261852fccbf87a115095ccd5f1c0a403d0e5ed Mon Sep 17 00:00:00 2001 From: mldangelo Date: Tue, 1 Sep 2026 00:14:01 -0700 Subject: [PATCH] fix(cli): improve init diagnostics and starter output Refusing to overwrite is init's advertised behavior, so surface it as a sentence naming the file instead of a raw EEXIST errno. Write the starter with default permissions. The file holds no credentials and is meant to be committed, so 0600 only broke shared checkouts and containers running as another user. JSON starters cannot carry the commented settings a YAML starter shows. Print that guidance instead of leaving the file's only content a $schema key, and point at the YAML template. Search upward from the starter for an installed @openai/codex-security so hoisted workspaces get an editor hint that resolves, falling back to the invocation directory when nothing is installed yet. --- docs/project-configuration.md | 7 +- sdk/typescript/src/cli.ts | 23 ++-- sdk/typescript/src/project-config.ts | 106 ++++++++++++------ .../tests-ts/cli-project-config.test.ts | 81 ++++++++++++- 4 files changed, 168 insertions(+), 49 deletions(-) diff --git a/docs/project-configuration.md b/docs/project-configuration.md index 81fa26b8d..307e095b7 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -32,8 +32,11 @@ codex-security info -c codex-security.yaml --json `init [file]` defaults to `codex-security.yaml`, refuses to overwrite an existing file, and accepts `.yaml`, `.yml`, or `.json`. YAML starters show current defaults as comments so future releases can still update defaults you have not overridden. -The editor hint is relative to the chosen file and expects the package to be -installed in the invocation directory's `node_modules`. +The editor hint is relative to the chosen file and points at the nearest +installed `@openai/codex-security`, searching upward from that file so hoisted +workspaces resolve. With nothing installed yet, it falls back to the invocation +directory's `node_modules`. JSON starters carry only `$schema`, so `init` +prints the settings guidance that YAML keeps in comments. For a project with a `src` directory: diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index b5f0a1d07..c2caf1fee 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -4400,20 +4400,25 @@ export async function main( }), output: z.object({ path: z.string() }).optional(), async run({ args }) { + const file = args.file ?? "codex-security.yaml"; + let path = file; try { const directory = dependencies.currentDirectory(); - const path = resolveCliPath( - directory, - args.file ?? "codex-security.yaml", - ); - await writeFile(path, projectConfigStarter(path, directory), { - flag: "wx", - mode: 0o600, - }); + path = resolveCliPath(directory, file); + const starter = projectConfigStarter(path, directory); + // Tracked configuration, so let the umask decide who can read it. + await writeFile(path, starter.contents, { flag: "wx" }); + for (const note of starter.notes) errorOutput.write(`${note}\n`); return { path }; } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write( + `codex-security: ${ + (error as NodeJS.ErrnoException).code === "EEXIST" + ? `${path} already exists. Edit it, or select it with --config ${file}.` + : errorMessage(error) + }\n`, + ); } }, }) diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index e853bcbee..db42b34a7 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -1,8 +1,10 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, extname, isAbsolute, + join, relative, resolve, sep, @@ -173,53 +175,83 @@ function projectConfigExtension(path: string): string { return extension; } +const SCHEMA_MODULE_PATH = join( + "node_modules", + "@openai", + "codex-security", + "schemas", + "project-config.schema.json", +); + +/** Prefer an installed schema so hoisted workspaces get a resolvable hint. */ +function installedSchemaPath(from: string, fallback: string): string { + for (let directory = from; ; directory = dirname(directory)) { + const candidate = join(directory, SCHEMA_MODULE_PATH); + if (existsSync(candidate)) return candidate; + if (dirname(directory) === directory) return fallback; + } +} + +export interface ProjectConfigStarter { + contents: string; + /** Guidance a format cannot carry inline, written to stderr. */ + notes: readonly string[]; +} + export function projectConfigStarter( path: string, directory = process.cwd(), -): string { - const schemaPath = resolve( - directory, - "node_modules/@openai/codex-security/schemas/project-config.schema.json", - ); - const relativeSchema = relative( - dirname(resolve(directory, path)), - schemaPath, +): ProjectConfigStarter { + const fileDirectory = dirname(resolve(directory, path)); + const schemaPath = installedSchemaPath( + fileDirectory, + resolve(directory, SCHEMA_MODULE_PATH), ); + const relativeSchema = relative(fileDirectory, schemaPath); const schema = isAbsolute(relativeSchema) ? pathToFileURL(schemaPath).href : `${relativeSchema.startsWith(".") ? "" : "./"}${relativeSchema .split(sep) .join("/")}`; if (projectConfigExtension(path) === ".json") - return `${JSON.stringify({ $schema: schema }, null, 2)}\n`; - return [ - "# This file is trusted like CLI options. Keep it outside untrusted inputs.", - `$schema: ${schema}`, - "", - "# Uncomment the settings you want to override. Defaults remain unpinned.", - `# auth: ${DEFAULT_SCAN_AUTH}`, - "# scan:", - `# mode: ${DEFAULT_SCAN_MODE}`, - "# scope:", - "# paths: [src] # Relative to each selected repository.", - "# knowledge_base: [] # Paths relative to this file.", - "# instructions_file: instructions.md", - "# validation_file: validation.md # Standard mode only.", - "# deep: # Used when mode is deep.", - ...DEEP_SCAN_SETTINGS.map( - ([name, , key]) => `# ${key}: ${DEFAULT_DEEP_SCAN_SETTINGS[name]}`, - ), - "# codex:", - `# model: ${DEFAULT_CODEX_CONFIG["model"]}`, - `# model_reasoning_effort: ${DEFAULT_CODEX_CONFIG["model_reasoning_effort"]}`, - "# limits:", - "# max_cost_usd_per_scan: 10 # Optional limit per scan attempt.", - "# policy:", - "# fail_on_severity: high # Omitted by default (report only).", - "# output:", - "# directory: ../scan-results # Outside the selected repositories.", - "", - ].join("\n"); + return { + contents: `${JSON.stringify({ $schema: schema }, null, 2)}\n`, + notes: [ + "JSON starters cannot carry comments describing the available settings.", + "Run codex-security init codex-security.yaml for a commented template.", + ], + }; + return { + contents: [ + "# This file is trusted like CLI options. Keep it outside untrusted inputs.", + `$schema: ${schema}`, + "", + "# Uncomment the settings you want to override. Defaults remain unpinned.", + `# auth: ${DEFAULT_SCAN_AUTH}`, + "# scan:", + `# mode: ${DEFAULT_SCAN_MODE}`, + "# scope:", + "# paths: [src] # Relative to each selected repository.", + "# knowledge_base: [] # Paths relative to this file.", + "# instructions_file: instructions.md", + "# validation_file: validation.md # Standard mode only.", + "# deep: # Used when mode is deep.", + ...DEEP_SCAN_SETTINGS.map( + ([name, , key]) => `# ${key}: ${DEFAULT_DEEP_SCAN_SETTINGS[name]}`, + ), + "# codex:", + `# model: ${DEFAULT_CODEX_CONFIG["model"]}`, + `# model_reasoning_effort: ${DEFAULT_CODEX_CONFIG["model_reasoning_effort"]}`, + "# limits:", + "# max_cost_usd_per_scan: 10 # Optional limit per scan attempt.", + "# policy:", + "# fail_on_severity: high # Omitted by default (report only).", + "# output:", + "# directory: ../scan-results # Outside the selected repositories.", + "", + ].join("\n"), + notes: [], + }; } function requireProjectConfig( diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index a1d6a9cda..dd79358b3 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -4,6 +4,7 @@ import { readFile, realpath, rm, + stat, symlink, writeFile, } from "node:fs/promises"; @@ -79,11 +80,89 @@ test.each([ $schema: `${modules}/@openai/codex-security/schemas/project-config.schema.json`, }); const contents = await readFile(path, "utf8"); - expect(await main(args, capture().stream, capture().stream, deps)).toBe(2); + const refused = capture(); + expect(await main(args, capture().stream, refused.stream, deps)).toBe(2); expect(await readFile(path, "utf8")).toBe(contents); + expect(refused.text()).toContain(`${path} already exists.`); + expect(refused.text()).not.toContain("EEXIST"); }, ); +test("init leaves starter permissions to the umask", async () => { + const input = await fixture({}); + const output = capture(); + const deps = dependencies({ + currentDirectory: input.root, + onConfig: () => { + throw new Error("No runtime for init"); + }, + }); + expect( + await main(["init", "--json"], output.stream, capture().stream, deps), + ).toBe(0); + // Tracked configuration should match an ordinary write, not a private file. + const reference = join(input.root, "reference.yaml"); + await writeFile(reference, ""); + expect((await stat(join(input.root, "codex-security.yaml"))).mode).toBe( + (await stat(reference)).mode, + ); +}); + +test("init explains the settings a JSON starter cannot carry inline", async () => { + const input = await fixture({}); + const notes = capture(); + expect( + await main( + ["init", "starter.json", "--json"], + capture().stream, + notes.stream, + dependencies({ + currentDirectory: input.root, + onConfig: () => { + throw new Error("No runtime for init"); + }, + }), + ), + ).toBe(0); + expect(notes.text()).toContain("cannot carry comments"); + expect(notes.text()).toContain("init codex-security.yaml"); +}); + +test("init points the editor hint at an installed schema above the file", async () => { + const input = await fixture({}); + const installed = join( + input.root, + "node_modules", + "@openai", + "codex-security", + "schemas", + ); + await mkdir(installed, { recursive: true }); + await writeFile(join(installed, "project-config.schema.json"), "{}"); + const nested = join(input.root, "packages", "app"); + await mkdir(nested, { recursive: true }); + expect( + await main( + ["init", "packages/app/codex-security.yaml", "--json"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.root, + onConfig: () => { + throw new Error("No runtime for init"); + }, + }), + ), + ).toBe(0); + // Hoisted workspaces resolve upward instead of emitting a broken sibling path. + expect( + (await readProjectConfig(join(nested, "codex-security.yaml"))).input, + ).toEqual({ + $schema: + "../../node_modules/@openai/codex-security/schemas/project-config.schema.json", + }); +}); + test("info resolves a config and its sources without a target, prompt reads, or runtime", async () => { const input = await fixture({ scan: {