Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ Scan history is stored in the Codex Security workbench state directory. If that
directory cannot be written, set `CODEX_SECURITY_STATE_DIR` to a writable
directory outside the repository.

Applications can attribute API-key scans to an end user with
`scan --safety-identifier ID` or the SDK's per-run `safetyIdentifier` option.
See [Safety ID setup and runtime requirements](sdk/typescript/README.md#attribute-scans-to-end-users).

`findings list [repository]` shows open findings across a repository's scans
and identifies findings not confirmed in its latest scan.

Expand Down
58 changes: 44 additions & 14 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,21 @@ Pass runtime configuration to the `CodexSecurity` constructor:
Pass scan configuration to `security.run(repository, options)` or
`security.preflight(repository, options)`:

| Option | Description |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `auth` | Select `"auto"`, `"chatgpt"`, or `"api-key"`. |
| `target` | Select a repository, repository-relative paths, committed diff, or working-tree diff. |
| `mode` | Select `"standard"` or `"deep"`; deep mode supports repositories and paths. |
| `knowledgeBasePaths` | Add architecture documents, security policies, threat models, or directories. |
| `outputDir` | Choose an artifact directory outside the enclosing Git worktree. |
| `archiveExisting` | Archive results already in `outputDir` before starting a scan. |
| `maxCostUsd` | Stop after the estimated model cost exceeds a positive USD amount. |
| `maxTimeHours` | Limit deep-scan discovery to a positive number of hours, up to 96. |
| `failureSeverity` | Record a finding-severity policy in the saved scan recipe. |
| `parentScanId` | Link a rerun to an existing parent scan. |
| `expectedPluginVersion` | Require the original plugin version when replaying a scan. |
| `signal` | Cancel a scan with an `AbortSignal`. |
| Option | Description |
| ----------------------- | ------------------------------------------------------------------------------------------ |
| `auth` | Select `"auto"`, `"chatgpt"`, or `"api-key"`. |
| `safetyIdentifier` | Stable hashed end-user ID for this scan's model requests; requires API-key authentication. |
| `target` | Select a repository, repository-relative paths, committed diff, or working-tree diff. |
| `mode` | Select `"standard"` or `"deep"`; deep mode supports repositories and paths. |
| `knowledgeBasePaths` | Add architecture documents, security policies, threat models, or directories. |
| `outputDir` | Choose an artifact directory outside the enclosing Git worktree. |
| `archiveExisting` | Archive results already in `outputDir` before starting a scan. |
| `maxCostUsd` | Stop after the estimated model cost exceeds a positive USD amount. |
| `maxTimeHours` | Limit deep-scan discovery to a positive number of hours, up to 96. |
| `failureSeverity` | Record a finding-severity policy in the saved scan recipe. |
| `parentScanId` | Link a rerun to an existing parent scan. |
| `expectedPluginVersion` | Require the original plugin version when replaying a scan. |
| `signal` | Cancel a scan with an `AbortSignal`. |

Progress and lifecycle callbacks are `onAuthentication`, `onCost`,
`onOutputArchived`, `onOutputDirReady`, `onScanStarted`,
Expand Down Expand Up @@ -335,6 +336,35 @@ Repeat `--knowledge-base PATH` for multiple files or directories; `bulk-scan`
shares them with every repository. Directories are searched recursively for
Markdown, text, PDF, and Word (`.docx`) files.

### Attribute scans to end users

For an application that serves multiple users, pass the originating user's
stable hashed ID on each scan:

```ts
await security.run("/path/to/repository", {
auth: "api-key",
safetyIdentifier: hashedUserId,
});
```

```bash
codex-security scan /path/to/repository --auth api-key --safety-identifier hashed-user-id
```

The ID must contain 1–64 characters and cannot be blank or contain NUL.
Do not use an email address or other personal data. The ID is passed to Codex
for the scan, nested workers, retries, and follow-up work. It is not saved in
shared configuration or the scan recipe. Supply it again for a later rerun.
Concurrent SDK clients can use different IDs without changing `process.env`.

This requires a Codex runtime with native `--safety-identifier` support and a
plugin that forwards it to workers. The current bundled runtime does not yet
support this option. Use `CODEX_CLI_PATH` to select a compatible build.
The SDK does not check runtime or plugin compatibility; older versions may
omit the identifier. Omitting the option leaves ordinary scans unchanged.
Preflight checks the ID's format; authentication is checked when the scan starts.

### Scan project components

`scan --path` runs one scan across the selected paths. Use `scan-components`
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/_bundled_plugin/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"CODEX_HOME",
"CODEX_SQLITE_HOME",
"CODEX_API_KEY",
"CODEX_SAFETY_IDENTIFIER",
"CODEX_BROWSER_USE_NODE_PATH",
"CODEX_CLI_PATH",
"CODEX_ELECTRON_RESOURCES_PATH",
Expand Down
44 changes: 42 additions & 2 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
import {
AuthenticationRequiredError,
CodexSecurityError,
ConfigurationError,
IncompleteScanError,
OutputDirectoryError,
errorMessage,
Expand Down Expand Up @@ -175,6 +176,7 @@ interface PreparedRuntime {
}

interface PreparedSession {
safetyIdentifier?: string;
runtime: PreparedRuntime;
runtimeHome: string;
effectiveConfig: JsonObject;
Expand Down Expand Up @@ -205,6 +207,8 @@ export interface DeepScanOptions {

export interface ScanOptions extends DeepScanOptions {
auth?: ScanAuthMode;
/** Stable, privacy-preserving end-user ID for this scan's model requests. */
safetyIdentifier?: string;
target?: ScanTarget;
mode?: ScanMode;
knowledgeBasePaths?: string[];
Expand Down Expand Up @@ -375,6 +379,7 @@ const DEFAULT_DEPENDENCIES: ClientDependencies = {
};

const SCAN_PERMISSION_PROFILE = "codex_security_scan";
const SAFETY_IDENTIFIER_ENV = "CODEX_SAFETY_IDENTIFIER";
const PERSONAL_TRUSTED_ACCESS_URL = "https://chatgpt.com/cyber";
const ORGANIZATIONAL_TRUSTED_ACCESS_URL =
"https://openai.com/form/enterprise-trusted-access-for-cyber/";
Expand Down Expand Up @@ -426,7 +431,9 @@ export class CodexSecurity {
repository: string,
options: ScanOptions = {},
): Promise<ScanResult> {
return await this.#trackOperation(() => this.#run(repository, options));
return await this.#trackOperation(() =>
this.#run(repository, { ...options }),
);
}

public async validate(options: ValidationOptions): Promise<ValidationResult> {
Expand Down Expand Up @@ -1797,7 +1804,7 @@ export class CodexSecurity {
apiKey,
sessionConfig,
} = session;
const environment = {
const environment: ProcessEnvironment = {
...pluginExecutionEnvironment(
python,
withoutCodexHome(
Expand All @@ -1810,6 +1817,13 @@ export class CodexSecurity {
CODEX_HOME: runtime.codexHome,
...runtimePaths,
};
for (const name of Object.keys(environment)) {
if (name.toUpperCase() === SAFETY_IDENTIFIER_ENV)
delete environment[name];
}
if (session.safetyIdentifier !== undefined) {
environment[SAFETY_IDENTIFIER_ENV] = session.safetyIdentifier;
}
const sdkCodexConfig = { ...sessionConfig };
// Projects and permissions already live in generated TOML files; the SDK
// cannot safely encode their path and selector keys as dotted overrides.
Expand Down Expand Up @@ -1848,6 +1862,7 @@ export class CodexSecurity {
options: Pick<
ScanOptions,
| "auth"
| "safetyIdentifier"
| "expectedPluginVersion"
| "onAuthentication"
| "onWarning"
Expand Down Expand Up @@ -1993,6 +2008,18 @@ export class CodexSecurity {
options.auth,
modelProvider,
);
if (
options.safetyIdentifier !== undefined &&
authentication.method !== "api_key" &&
!(
authentication.method === "stored_credentials" &&
authentication.credentialType === "api_key"
)
) {
throw new ConfigurationError(
"safetyIdentifier requires API-key authentication.",
);
}
notifyObserver(
"onAuthentication",
options.onAuthentication,
Expand All @@ -2010,6 +2037,7 @@ export class CodexSecurity {
checkOpen();
return {
runtime,
safetyIdentifier: options.safetyIdentifier,
runtimeHome,
effectiveConfig,
preflightConfig,
Expand Down Expand Up @@ -2121,6 +2149,18 @@ export class CodexSecurity {
signal?: AbortSignal,
): Promise<LocalScanInputs> {
deepScanOptions(options);
const identifier = options.safetyIdentifier;
if (
identifier !== undefined &&
(typeof identifier !== "string" ||
identifier.trim().length === 0 ||
identifier.includes("\0") ||
[...identifier].length > 64)
) {
throw new ConfigurationError(
"safetyIdentifier must contain 1 to 64 characters, must not be blank, and must not contain NUL.",
);
}
if (
options.maxCostUsd !== undefined &&
(!Number.isFinite(options.maxCostUsd) || options.maxCostUsd <= 0)
Expand Down
17 changes: 17 additions & 0 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ const EXPORT_DEFAULT_OUTPUTS = {
} as const;
const VALUE_OPTIONS = new Set([
"--auth",
"--safety-identifier",
"--path",
"--component",
"--components-file",
Expand Down Expand Up @@ -844,6 +845,7 @@ export function resolveCliPath(directory: string, value: string): string {

interface ScanArguments extends DeepScanOptions {
auth?: ScanAuthMode;
safetyIdentifier?: string;
verbose?: boolean;
repository?: string;
paths: string[];
Expand Down Expand Up @@ -935,6 +937,7 @@ const findingVerificationSchema = z.object({
type FindingVerification = z.infer<typeof findingVerificationSchema>;

interface SkillRunOptions {
safetyIdentifier?: string;
directory?: string;
findings?: readonly Finding[];
findingInstructions?: Readonly<Record<string, string>>;
Expand Down Expand Up @@ -2249,6 +2252,11 @@ export async function main(
.boolean()
.default(false)
.describe("Print scan diagnostics to stderr."),
safetyIdentifier: optionValue("--safety-identifier")
.optional()
.describe(
"Stable hashed end-user ID for this scan's model requests (1–64 characters).",
),
path: z
.array(optionValue("--path"))
.default([])
Expand Down Expand Up @@ -2423,6 +2431,7 @@ export async function main(
const outcome = await runScan(
{
auth: options.auth,
safetyIdentifier: options.safetyIdentifier,
verbose: options.verbose,
repository: args.repository,
paths: options.path,
Expand Down Expand Up @@ -4703,6 +4712,12 @@ async function runSkill(
...(verify ? ["--config", 'approvals_reviewer="auto_review"'] : []),
"--config",
'responses_api_metadata.codex_security_surface="cli"',
...(options.safetyIdentifier === undefined
? []
: [
"--config",
`safety_identifier=${JSON.stringify(options.safetyIdentifier)}`,
]),
...(appServer
? []
: [
Expand Down Expand Up @@ -5479,6 +5494,7 @@ async function executeScan(
security = dependencies.createSecurity(config);
const options: ScanOptions = {
auth,
safetyIdentifier: arguments_.safetyIdentifier,
target,
knowledgeBasePaths: arguments_.knowledgeBasePaths,
...prompts,
Expand Down Expand Up @@ -5947,6 +5963,7 @@ async function executeScan(
dependencies,
{
...providerOptions,
safetyIdentifier: arguments_.safetyIdentifier,
environment,
findingInstructions: patchSelection?.instructions,
},
Expand Down
90 changes: 90 additions & 0 deletions sdk/typescript/tests-ts/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,86 @@ describe("CodexSecurity finding validation", () => {
});

describe("CodexSecurity orchestration", () => {
test("isolates per-run safety identifiers across clients and clears them on reuse", async () => {
const root = await temporaryDirectory();
const repository = join(root, "repository");
const codexHome = join(root, "home");
await mkdir(repository);
await mkdir(codexHome);
const runtimeEnvironment = { codex_safety_identifier: "ambient-user" };
const received: Array<CodexOptions> = [];
const clients = [0, 1].map(
() =>
new TestClient(
{},
{
environment: { OPENAI_API_KEY: "synthetic-key" },
prepareRuntime: async () => ({
...preparedRuntime(codexHome),
persistentCredentialHome: true,
environment: runtimeEnvironment,
}),
resolvePluginPython: async () => "/managed/python",
createCodex: (options) => {
received.push(options);
throw new Error("captured scan");
},
},
),
);
try {
await Promise.all(
clients.map((client, i) =>
expect(
client.run(repository, {
safetyIdentifier: `synthetic-user-${i}`,
outputDir: join(root, `scan-${i}`),
}),
).rejects.toThrow("captured scan"),
),
);
expect(
received
.map((options) => options.env?.["CODEX_SAFETY_IDENTIFIER"])
.sort(),
).toEqual(["synthetic-user-0", "synthetic-user-1"]);
for (const safetyIdentifier of ["next-user", undefined]) {
await expect(
clients[0]!.run(repository, {
safetyIdentifier,
outputDir: join(root, safetyIdentifier ?? "unset"),
}),
).rejects.toThrow("captured scan");
}
expect(
received
.slice(2)
.map((options) => options.env?.["CODEX_SAFETY_IDENTIFIER"]),
).toEqual(["next-user", undefined]);
expect(runtimeEnvironment).toEqual({
codex_safety_identifier: "ambient-user",
});
expect(
received.every(
(options) => options.config?.["safety_identifier"] === undefined,
),
).toBe(true);
} finally {
await Promise.all(clients.map((client) => client.close()));
}
});

test.each(["", " ", "a".repeat(65), "id\0suffix"])(
"rejects invalid safety identifier %j before preparing the runtime",
async (identifier) => {
const client = new TestClient({}, {});
await expect(
client.run("/unused", { safetyIdentifier: identifier }),
).rejects.toThrow("safetyIdentifier must");
await client.close();
},
);

test("distinguishes local workbench and database errors from model transport failures", () => {
for (const message of [
"sqlite3.OperationalError: unable to open database file\nwith closing(connect()) as connection:",
Expand Down Expand Up @@ -1558,6 +1638,16 @@ describe("CodexSecurity orchestration", () => {
).rejects.toThrow("scan did not start");
expect(selectedAuthentication).toEqual(expected);
expect(JSON.stringify(selectedAuthentication)).not.toContain("SYNTHETIC");
await expect(
client.run(repository, {
safetyIdentifier: "synthetic-user",
outputDir: join(root, "attributed-scan"),
}),
).rejects.toThrow(
"credentialType" in expected && expected.credentialType === "api_key"
? "scan did not start"
: "safetyIdentifier requires API-key authentication",
);
await client.close();
}
});
Expand Down
Loading
Loading