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
93 changes: 87 additions & 6 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,74 @@

set -eu

if [ "${1:-}" = bulk-scan ]; then
case "${2:-}" in
--help|-h)
# Global options may precede the command, and scan options may precede its CSV.
# Classify positional input without consuming arguments or mistaking option values for a CSV.
bulk_scan_command=
bulk_scan_input=
bulk_scan_metadata=
bulk_scan_positional_only=
expects_option_value=

for argument do
if [ "$bulk_scan_command" = yes ] && [ "$bulk_scan_positional_only" = yes ]; then
if [ -z "$bulk_scan_input" ]; then
bulk_scan_input=$argument
fi
continue
fi

case "$argument" in
--help|-h|--llms|--llms-full|--schema|--version)
bulk_scan_metadata=yes
continue
Comment thread
mldangelo-oai marked this conversation as resolved.
;;
esac

if [ "$expects_option_value" = yes ]; then
expects_option_value=
continue
fi

if [ "$bulk_scan_command" != yes ]; then
case "$argument" in
bulk-scan)
bulk_scan_command=yes
;;
--filter-output|--format|--token-limit|--token-offset)
expects_option_value=yes
;;
--filter-output=*|--format=*|--token-limit=*|--token-offset=*|\
--full-output|--json|--token-count|--)
;;
*)
break
;;
esac
continue
fi

case "$argument" in
--)
bulk_scan_positional_only=yes
;;
--output-dir|--workers|--mode|--model|--effort|--max-attempts|\
--plugin-path|--python|--codex|--filter-output|--format|\
--token-limit|--token-offset)
expects_option_value=yes
;;
""|-*)
-*)
;;
*)
if [ -z "$bulk_scan_input" ]; then
bulk_scan_input=$argument
fi
;;
esac
done

if [ "$bulk_scan_command" = yes ] && [ "$bulk_scan_metadata" != yes ]; then
case "$bulk_scan_input" in
"")
printf '%s\n' 'codex-security: bulk-scan requires a repository CSV; interactive discovery is not supported in this image.' >&2
exit 2
;;
Expand All @@ -24,7 +87,9 @@ if [ "${1:-}" = bulk-scan ]; then
landlock_override=
expects_codex_override=
for argument do
if [ "$expects_codex_override" = yes ]; then
if [ "$argument" = -- ]; then
break
elif [ "$expects_codex_override" = yes ]; then
case "$argument" in
features.use_legacy_landlock=true)
landlock_override=enabled
Expand All @@ -51,7 +116,23 @@ if [ "${1:-}" = bulk-scan ]; then
done

if [ "$landlock_override" != enabled ]; then
set -- "$@" --codex features.use_legacy_landlock=true
if [ "$bulk_scan_positional_only" = yes ]; then
arguments_remaining=$#
landlock_inserted=
while [ "$arguments_remaining" -gt 0 ]; do
argument=$1
shift
if [ "$argument" = -- ] && [ "$landlock_inserted" != yes ]; then
set -- "$@" --codex features.use_legacy_landlock=true "$argument"
landlock_inserted=yes
else
set -- "$@" "$argument"
fi
arguments_remaining=$((arguments_remaining - 1))
done
else
set -- "$@" --codex features.use_legacy_landlock=true
fi
fi
fi
fi
Expand Down
10 changes: 10 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ npx @openai/codex-security scan /path/to/repository --max-cost 5
npx @openai/codex-security install-hook
npx @openai/codex-security bulk-scan
npx @openai/codex-security bulk-scan --model gpt-5.6-terra --effort high
npx @openai/codex-security bulk-scan --workers 4 --mode deep --max-attempts 3
npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --workers 4
npx @openai/codex-security scans list /path/to/repository
npx @openai/codex-security scans list --scan-root /path/outside/repository/results
Expand Down Expand Up @@ -395,6 +396,11 @@ Private checkouts reuse your GitHub CLI sign-in without changing your global Git
configuration. The selected repositories are saved to
`<output-dir>/repositories.csv` for review or resumption.

Interactive discovery accepts the same `--workers`, `--mode`, `--max-attempts`,
`--model`, `--effort`, `--plugin-path`, `--python`, and `--codex` settings as
CSV-driven scans. It prompts for the output directory; `--output-dir` is only
valid when a repository CSV is supplied.

To use an existing repository list or run in CI, pass a CSV with required `id`,
`repository`, and `revision` columns. Revisions must be full commit hashes;
optional `scope` and `mode` columns narrow individual scans:
Expand Down Expand Up @@ -544,6 +550,10 @@ device login remains in `state/`. For unattended scans, set `OPENAI_API_KEY`
or `CODEX_API_KEY` instead. Set `GH_TOKEN` or `GITHUB_TOKEN` for private
GitHub repositories.

The container accepts the repository CSV before or after bulk-scan options.
Interactive repository discovery remains disabled, including when global CLI
options appear before `bulk-scan`.

On Ubuntu hosts that restrict unprivileged user namespaces, an administrator
can install the optional, narrowly scoped AppArmor profile once:

Expand Down
23 changes: 2 additions & 21 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,28 +1292,9 @@ export async function main(
let outputDir: string;
let githubHost: string | undefined;
if (args.input === undefined) {
let optionIndex = 1;
while (optionIndex < argv.length) {
const argument = argv[optionIndex]!;
if (
argument === "--model" ||
argument === "--effort" ||
argument === "--codex"
) {
optionIndex += 2;
} else if (
argument.startsWith("--model=") ||
argument.startsWith("--effort=") ||
argument.startsWith("--codex=")
) {
optionIndex += 1;
} else {
break;
}
}
if (argv[0] !== "bulk-scan" || optionIndex !== argv.length) {
if (options.outputDir !== undefined) {
throw new Error(
"Run 'codex-security bulk-scan [--model MODEL] [--effort EFFORT] [--codex KEY=VALUE]' to discover repositories, or provide a CSV and --output-dir.",
"--output-dir can only be used with a repository CSV; omit it to choose an output directory interactively.",
);
}
const wizard = await runBulkScanWizard(
Expand Down
170 changes: 170 additions & 0 deletions sdk/typescript/tests-ts/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { delimiter, join, normalize } from "node:path";
import { Writable } from "node:stream";
import { fileURLToPath, pathToFileURL } from "node:url";
import { stripVTControlCharacters } from "node:util";
import { Octokit } from "@octokit/core";
import { describe, expect, test } from "bun:test";
import type {
CodexSecurityConfig,
Expand Down Expand Up @@ -746,6 +747,127 @@ describe("CLI", () => {
}
});

test("forwards interactive bulk-scan options through repository discovery", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-security-cli-discovery-"));
const configurations: CodexSecurityConfig[] = [];
let githubClients = 0;

try {
const stdout = capture();
const stderr = capture();
expect(
await main(
[
"bulk-scan",
"--workers=2",
"--mode",
"deep",
"--max-attempts=3",
"--plugin-path",
"./plugin",
"--python=python3",
"--model",
"gpt-5.6-terra",
"--effort",
"high",
"--codex",
"features.goals=true",
],
stdout.stream,
stderr.stream,
dependencies({
currentDirectory: root,
bulkScan: {
prompt: {
isInteractive: () => true,
write: () => {},
confirm: async () => true,
input: async () => "results",
select: async <Value extends string>(
_question: string,
options: readonly { label: string; value: Value }[],
) => options[0]!.value,
},
now: () => Date.parse("2026-08-01T00:00:00.000Z"),
currentDirectory: () => root,
createGitHub: async () => {
githubClients += 1;
return new Octokit({
auth: "test-token",
request: {
fetch: async (resource: Parameters<typeof fetch>[0]) => {
const path = new URL(
resource instanceof Request ? resource.url : resource,
).pathname;
if (path === "/user/orgs") return Response.json([]);
if (path === "/user") {
return Response.json({ login: "acme" });
}
if (path !== "/graphql") {
throw new Error(`Unexpected GitHub request: ${path}`);
}
return Response.json({
data: {
repositoryOwner: {
repositories: {
nodes: ["one", "two", "three"].map(
(name, index) => ({
nameWithOwner: `acme/${name}`,
pushedAt: "2026-07-30T00:00:00.000Z",
defaultBranchRef: {
target: {
oid: String(index + 1).repeat(40),
},
},
}),
),
pageInfo: {
hasNextPage: false,
endCursor: null,
},
},
},
},
});
},
},
});
},
},
onConfig: (config) => {
configurations.push(config);
throw new Error("Stop before checking out repositories.");
},
}),
),
).toBe(2);

expect(githubClients).toBe(1);
expect(configurations).toHaveLength(2);
for (const configuration of configurations) {
expect(configuration).toMatchObject({
pluginPath: "./plugin",
pythonPath: "python3",
codexOverrides: {
features: { goals: true },
model: "gpt-5.6-terra",
model_reasoning_effort: "high",
},
});
}

const manifest = JSON.parse(
await readFile(join(root, "results", "manifest.json"), "utf8"),
) as { tasks: { mode: string }[] };
expect(manifest.tasks).toHaveLength(3);
expect(manifest.tasks.every(({ mode }) => mode === "deep")).toBe(true);
expect(stdout.text()).toBe("");
expect(stderr.text()).toContain("Stop before checking out repositories.");
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("requires a terminal for interactive bulk scans", async () => {
for (const argv of [
["bulk-scan"],
Expand All @@ -756,6 +878,35 @@ describe("CLI", () => {
["bulk-scan", "--codex", 'model_reasoning_effort="high"'],
["bulk-scan", '--codex=model_reasoning_effort="high"'],
["bulk-scan", "--model", "gpt-5.6-terra", "--effort", "high"],
["bulk-scan", "--workers", "8"],
["bulk-scan", "--workers=8"],
["bulk-scan", "--mode", "deep"],
["bulk-scan", "--mode=deep"],
["bulk-scan", "--max-attempts", "3"],
["bulk-scan", "--max-attempts=3"],
["bulk-scan", "--plugin-path", "./plugin"],
["bulk-scan", "--plugin-path=./plugin"],
["bulk-scan", "--python", "python3"],
["bulk-scan", "--python=python3"],
[
"bulk-scan",
"--workers",
"8",
"--mode",
"deep",
"--max-attempts",
"3",
"--plugin-path",
"./plugin",
"--python",
"python3",
"--model",
"gpt-5.6-terra",
"--effort",
"high",
],
["--format", "toon", "bulk-scan", "--workers", "8"],
["--format=toon", "bulk-scan", "--workers=8"],
] as const) {
const stdout = capture();
const stderr = capture();
Expand Down Expand Up @@ -791,6 +942,25 @@ describe("CLI", () => {
expect(stdout.text()).toBe("");
});

test("lets the discovery wizard choose its output directory", async () => {
for (const argv of [
["bulk-scan", "--output-dir", "results"],
["bulk-scan", "--output-dir=results"],
["--format", "toon", "bulk-scan", "--output-dir=results"],
] as const) {
const stdout = capture();
const stderr = capture();

expect(
await main(argv, stdout.stream, stderr.stream, dependencies()),
).toBe(2);
expect(stderr.text()).toContain(
"--output-dir can only be used with a repository CSV",
);
expect(stdout.text()).toBe("");
}
});

test("exposes only typed, read-only SDK metadata over MCP", () => {
const child = spawnSync(
process.execPath,
Expand Down
Loading
Loading