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: {