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
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS package

RUN apt-get update \
&& apt-get install --no-install-recommends --yes \
git \
python3 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /build/sdk/typescript

COPY sdk/typescript/package.json sdk/typescript/pnpm-lock.yaml ./
Expand Down
87 changes: 83 additions & 4 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -909,8 +909,8 @@ command manifest, `scan --schema --format json` for a command schema, and
`skills add` syncs agent skills; `mcp add` registers the CLI as an MCP server.
Start the server with `codex-security --mcp` (or
`npx --yes @openai/codex-security --mcp`). It uses stdin/stdout and exposes
`info` for read-only metadata and `scan` for security scans. For example, an
MCP client can launch it with:
`info` for read-only metadata, `scan` for security scans, and the application
command tools listed below. For example, an MCP client can launch it with:

```json
{
Expand All @@ -933,8 +933,9 @@ working directory. First check local inputs without starting a model:
```

Then call `scan` with `dryRun` omitted or false to run the scan. Standard,
Deep, path, and Git diff scans are supported. MCP does not support `patch`,
`patchSeverity`, or `createPr`; patching and other commands remain CLI-only.
Deep, path, and Git diff scans are supported. The `scan` tool does not accept
`patch`, `patchSeverity`, or `createPr`; use the separate `patch` tool after
scanning.

Scans run noninteractively with the same local credentials and `auth`
selection as the CLI. Sign in with `codex-security login` before starting the
Expand All @@ -956,6 +957,84 @@ partial artifacts remain available at the output directory. Canceled MCP
requests do not receive a result. This server is separate from the bundled
security plugin's MCP server used internally during scans.

The remaining application commands are also available as MCP tools:

| CLI command | MCP tool | Behavior |
| ---------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------- |
| `scan-components` | `scan-components` | Plan or scan explicit project components. |
| `bulk-scan` | `bulk-scan` | Run a resumable batch from an explicit repository CSV. |
| `scans list`, `scans show`, `scans logs` | `scans_list`, `scans_show`, `scans_logs` | Read saved scans and activity. |
| `scans rerun` | `scans_rerun` | Run a saved scan again. |
| `scans match`, `scans compare` | `scans_match`, `scans_compare` | Match findings and compare scans; may call models and persist matches. |
| `findings list` | `findings_list` | Read saved findings. |
| `findings false-positive` | `findings_false-positive` | Update finding triage and future scan feedback. |
| `import github` | `import_github` | Read GitHub code scanning alerts. |
| `export` | `export` | Export CSV, JSON, or SARIF to a file or tool output. |
| `validate`, `verify-fix` | `validate`, `verify-fix` | Validate findings or verify existing fixes. |
| `patch` | `patch` | Patch findings; optionally assess risk or create a draft PR. |
| `publish check`, `publish scan` | `publish_check`, `publish_scan` | Check publication inputs or publish findings to the documented destination. |
| `install-hook` | `install-hook` | Install the pre-commit scan hook. |
| `login status`, `logout` | `login`, `logout` | Inspect or remove the server's stored sign-in. |

These tools use `args` for positional arguments and `options` for named CLI
options, with camelCase names and unchanged CLI defaults. Variadic positional
arguments become arrays without the trailing `...`. For example:

```json
{ "args": { "scanId": "scan_example_001" } }
```

calls `scans_show`, and:

```json
{
"args": { "scanDir": "/path/to/completed-scan" },
"options": { "exportFormat": "csv", "output": "-" }
}
```

calls `export`. A `validate` call uses
`{ "args": { "findings": ["/path/to/finding.md"] } }`. Keeping `args` and
`options` separate preserves commands such as `publish scan`, whose positional
`scanDir` and repeated `--scan-dir` option have different meanings.

Set the optional outer `workingDirectory` field when the command should run in
a different repository. It defaults to the server's working directory;
relative values resolve from that directory. For example, call `validate` with
`{ "workingDirectory": "/path/to/repository", "args": { "findings": ["/path/to/finding.md"] } }`.
This sets only that command process's directory and is not a new CLI flag.
Relative paths in `CODEX_SECURITY_STATE_DIR`, `CODEX_HOME`, `CODEX_CLI_PATH`,
and `PYTHON` resolve from the server's directory, keeping its state, credentials,
and configured executables shared across command working directories.
A bare `PYTHON` name such as `python3` still uses `PATH`.

The new tools return `{ "exitCode": 0, "data": ... }` for structured CLI
results, including arrays. Commands that produce plain text or exported
content return `output` instead of `data`; successful commands with no stdout
return just their exit code. CLI diagnostics are included as `diagnostics`
and also sent to stderr. Nonzero exits set `isError`, include an `error`, and
retain any result or output. No output is silently truncated.

Each command runs as a separate CLI process with the selected working directory
and the server's environment, credentials, and filesystem access. Request cancellation or a
disconnect requests termination of that command's process tree and waits for the
CLI's own subprocess cleanup to finish. On Windows, termination uses the existing
process-tree termination behavior. Completed artifacts are retained, but cancellation
does not roll back file changes, credential changes, or external publication.

Supply explicit inputs instead of relying on terminal pickers: a repository CSV
for `bulk-scan`, scan identifiers/directories for publication, and component
selection for `scan-components`. As with the CLI, positional inputs beginning
with `-` cannot be passed as literal arguments; use a file for such finding text,
or prefix a relative file path with `./`. A later positional argument cannot be
supplied while an earlier one is omitted.

For login status, call `login` with `{ "args": { "action": "status" } }`.
Browser/device sign-in and credential entry must be completed with the CLI
before using the server; MCP transport input is never used as credential stdin.
Shell completions and the `mcp`/`skills` installation helpers remain CLI-only.
They configure the local client rather than execute security workflows.

## Containerized bulk scans

Create `repositories.csv` as described under [Bulk scans](#bulk-scans).
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ const distFiles = new Set(
"auth",
"bulk-scan-discovery",
"cli",
"cli-mcp-commands",
"cloud-publish",
"codex-prompt",
"component-plan",
Expand Down
86 changes: 78 additions & 8 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,35 @@ async function pluginFiles(directory) {
return files.sort();
}

async function smokeCliMcp(launcher, consumer) {
async function smokeCliMcp(launcher, consumer, completedScan) {
const repository = join(consumer, "mcp-repository");
await mkdir(repository);
await writeFile(
join(repository, "example.js"),
"export const example = 1;\n",
);
run("git", ["-c", "init.templateDir=", "init", "--quiet", repository], {
cwd: consumer,
});
run(
"git",
["config", "--local", "core.hooksPath", join(repository, ".git", "hooks")],
{
cwd: repository,
},
);
const manifest = JSON.parse(
run(process.execPath, [launcher, "--llms", "--format", "json"], {
cwd: consumer,
capture: true,
}),
);
const child = spawn(process.execPath, [launcher, "--mcp"], {
cwd: consumer,
env: {
...process.env,
CODEX_SECURITY_STATE_DIR: join(consumer, "mcp-state"),
CODEX_SECURITY_LINEAR_PROJECT: "",
},
stdio: "pipe",
timeout: PACKAGE_SMOKE_TIMEOUT_MS,
Expand Down Expand Up @@ -203,10 +220,12 @@ async function smokeCliMcp(launcher, consumer) {
});
send({ jsonrpc: "2.0", method: "notifications/initialized" });
const tools = await request(2, "tools/list", {});
assert.deepEqual(tools.tools.map((tool) => tool.name).sort(), [
"info",
"scan",
]);
assert.deepEqual(
tools.tools.map((tool) => tool.name).sort(),
manifest.commands
.map((command) => command.name.replaceAll(" ", "_"))
.sort(),
);
const info = await request(3, "tools/call", {
name: "info",
arguments: {},
Expand All @@ -219,6 +238,58 @@ async function smokeCliMcp(launcher, consumer) {
assert.notEqual(scan.isError, true, JSON.stringify(scan));
assert.equal(scan.structuredContent.exitCode, 0);
assert.equal(scan.structuredContent.data.dryRun, true);
const call = async (id, name, args) => {
const result = await request(id, "tools/call", { name, arguments: args });
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.equal(
result.structuredContent.exitCode,
0,
JSON.stringify(result),
);
return result.structuredContent;
};
const history = await call(5, "scans_list", { args: { repository } });
assert.deepEqual(history.data.scans, []);
const findings = await call(6, "findings_list", { args: { repository } });
assert.deepEqual(findings.data.findings, []);
const exported = await call(7, "export", {
args: { scanDir: completedScan },
options: { exportFormat: "csv", output: "-" },
});
assert.equal(
exported.output,
run(
process.execPath,
[
launcher,
"export",
completedScan,
"--export-format",
"csv",
"--output",
"-",
],
{
cwd: consumer,
capture: true,
},
),
);
const publication = await call(8, "publish_scan", {
args: { scanDir: completedScan },
options: { to: "linear", linearTeam: "team-example", dryRun: true },
});
assert.equal(publication.data.dryRun, true);
assert.equal(publication.data.scanId, "scan_example_001");
assert.equal(publication.data.counts.created, 0);
const installedHook = await call(9, "install-hook", {
args: { repository },
});
assert.equal(installedHook.data.failOnSeverity, "high");
assert.match(
await readFile(installedHook.data.hook, "utf8"),
/scan \. --working-tree/u,
);
child.stdin.end();
assert.equal((await closed)[0], 0, stderr);
} finally {
Expand Down Expand Up @@ -542,15 +613,14 @@ try {
assert.match(help, /Usage: codex-security\b/u);
assert.match(help, /\bpublish\b/u);

await smokeCliMcp(launcher, consumer);

const publicationScan = join(consumer, "publication-scan");
await cp(
join(installedRoot, "_bundled_plugin", "examples", "completed-scan"),
publicationScan,
{ recursive: true },
);
if (process.platform !== "win32") await chmod(publicationScan, 0o700);
await smokeCliMcp(launcher, consumer, publicationScan);
const publication = JSON.parse(
run(
process.execPath,
Expand Down Expand Up @@ -685,7 +755,7 @@ try {
await smokeNestedDeepScanWorker(installedRoot, consumer);

console.log(
`Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, credential locking, ${expectedPluginFiles.length} bundled plugin files, MCP initialization, bundled Codex version, and a nested worker without global codex.`,
`Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, credential locking, ${expectedPluginFiles.length} bundled plugin files, MCP scan/history/export/publication/hook calls, bundled Codex version, and a nested worker without global codex.`,
);
} finally {
await rm(consumer, {
Expand Down
Loading
Loading