Skip to content
Open
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
7 changes: 5 additions & 2 deletions docs/project-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
23 changes: 14 additions & 9 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
}
},
})
Expand Down
106 changes: 69 additions & 37 deletions sdk/typescript/src/project-config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import {
dirname,
extname,
isAbsolute,
join,
relative,
resolve,
sep,
Expand Down Expand Up @@ -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(
Expand Down
81 changes: 80 additions & 1 deletion sdk/typescript/tests-ts/cli-project-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
readFile,
realpath,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
Expand Down Expand Up @@ -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: {
Expand Down