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
13 changes: 11 additions & 2 deletions plugins/codex-security/mcp-app/helpers-main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { closeSync, readFileSync } from "node:fs";
import { resolveSecurityMdCommand } from "./src/helpers/resolve-security-md";
import { decodePosixBytes } from "./src/helpers/posix-path";
import { windowsBinding } from "./src/native";
import { normalizeCandidatesCommand } from "./src/helpers/normalize-candidates";
import { validatePatchRiskAssessmentCommand } from "./src/helpers/validate-patch-risk-assessment";

let commandLine = process.argv.slice(2);
if (process.platform === "win32") {
Expand All @@ -14,8 +17,10 @@ if (commandLine[0] === "--helper") {
if (process.platform === "win32") {
commandLine = commandLine.slice(1);
} else {
const encoded = readFileSync(3, "ascii");
closeSync(3);
const [homeSet, home, ...args] = decodePosixBytes(
Buffer.from(commandLine[1] ?? "", "hex"),
Buffer.from(encoded.trim(), "hex"),
)
.split("\0")
.slice(0, -1);
Expand All @@ -26,9 +31,13 @@ if (commandLine[0] === "--helper") {
const [command, ...args] = commandLine;
if (command === "resolve-security-md") {
process.exitCode = resolveSecurityMdCommand(args, posixHome);
} else if (command === "normalize-candidates") {
process.exitCode = normalizeCandidatesCommand(args, posixHome);
} else if (command === "validate-patch-risk-assessment") {
process.exitCode = validatePatchRiskAssessmentCommand(args);
} else {
console.error(
"Usage: launch_codex_security_mcp[.cmd] --helper resolve-security-md [options]",
"Usage: launch_codex_security_mcp[.cmd] --helper <resolve-security-md | normalize-candidates | validate-patch-risk-assessment> [options]",
);
process.exitCode = 2;
}
17 changes: 5 additions & 12 deletions plugins/codex-security/mcp-app/src/artifact-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
type SchemaDocument
} from "./artifact-schema-loader.js";
import { candidateSchemaV1 } from "./deep-scan/artifact-contracts.js";
import { missingPythonHelperMessage, resolvePythonCommand } from "./python_command.js";

const execFileAsync = promisify(execFile);
const discoveryComponents = ["artifacts", "02_discovery"] as const;
Expand Down Expand Up @@ -137,7 +136,7 @@ export async function recordCodexSecurityDiscoveryCandidates(
const inventoryComponents = [...discoveryComponents, "in_scope_files.txt"];
const candidateComponents = [...discoveryComponents, "candidate_ledger.jsonl"];

// Verify the inventory is a context-bound regular file before passing it to Python.
// Verify the inventory is a context-bound regular file before normalization.
await readArtifactText(context, inventoryComponents, "discovery review inventory");
const inventoryPath = await artifactDestination(
context,
Expand All @@ -161,12 +160,12 @@ export async function recordCodexSecurityDiscoveryCandidates(
mode: 0o600
});

const pythonCommand = context.pythonCommand ?? await resolvePythonCommand();
try {
await execFileAsync(
pythonCommand,
process.execPath,
[
join(pluginRoot, "scripts", "normalize_candidates.py"),
join(pluginRoot, "mcp", "helpers.mjs"),
"normalize-candidates",
"--input",
temporaryInput,
"--out",
Expand All @@ -184,7 +183,7 @@ export async function recordCodexSecurityDiscoveryCandidates(
}
);
} catch (error) {
throw discoveryNormalizationError(error, pythonCommand, [
throw discoveryNormalizationError(error, [
[temporaryInput, "candidate input"],
[temporaryDirectory, "private candidate input"],
[inventoryPath, "the assigned review inventory"],
Expand Down Expand Up @@ -226,14 +225,8 @@ export async function listCodexSecurityCandidates(

function discoveryNormalizationError(
error: unknown,
pythonCommand: string,
privateValues: Array<readonly [string, string]>
): Error {
const pythonMessage = missingPythonHelperMessage(error, pythonCommand);
if (pythonMessage) {
return new Error(`${discoveryLabel}: ${pythonMessage}`, { cause: error });
}

const stderr = error && typeof error === "object" && "stderr" in error
? error.stderr
: undefined;
Expand Down
173 changes: 173 additions & 0 deletions plugins/codex-security/mcp-app/src/helpers/contract-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { JsonFloat, object, objectEntries, pythonRepr } from "./python-json";

type SchemaType =
| "array"
| "boolean"
| "integer"
| "number"
| "object"
| "string"
| "null";
export interface ContractSchema {
$ref?: string;
type?: SchemaType | SchemaType[];
const?: unknown;
enum?: unknown[];
minLength?: number;
pattern?: string;
minimum?: number;
maximum?: number;
minItems?: number;
maxItems?: number;
uniqueItems?: boolean;
items?: ContractSchema;
required?: string[];
minProperties?: number;
properties?: Record<string, ContractSchema>;
additionalProperties?: boolean | ContractSchema;
[key: string]: unknown;
}

const numeric = (value: unknown): value is number | bigint | JsonFloat =>
typeof value === "number" ||
typeof value === "bigint" ||
value instanceof JsonFloat;
const number = (value: number | bigint | JsonFloat) =>
value instanceof JsonFloat ? Number(value.source) : value;

function equal(left: unknown, right: unknown): boolean {
if (numeric(left) && numeric(right)) {
const a = number(left),
b = number(right);
if (typeof a === typeof b) return a === b;
const integer = typeof a === "bigint" ? a : b;
const floating = typeof a === "number" ? a : (b as number);
return (
Number.isFinite(floating) &&
Number.isInteger(floating) &&
integer === BigInt(floating)
);
}
return left === right;
}

function matches(value: unknown, expected: SchemaType): boolean {
switch (expected) {
case "array":
return Array.isArray(value);
case "boolean":
return typeof value === "boolean";
case "integer":
return (
typeof value === "bigint" ||
(typeof value === "number" && Number.isInteger(value))
);
case "number":
return numeric(value);
case "object":
return object(value);
case "string":
return typeof value === "string";
case "null":
return value === null;
}
}

/** The assessment schema uses the same structural rules as the scan contract. */
export function validateAgainstSchema(
value: unknown,
schema: ContractSchema,
context: string,
root: ContractSchema = schema,
): void {
const fail: (message: string) => never = (message) => {
throw new Error(`${context}: ${message}`);
};
if (schema.$ref !== undefined) {
const reference = schema.$ref;
if (typeof reference !== "string")
fail("schema reference must be a string");
let target: unknown = root;
if (reference !== "#") {
if (!reference.startsWith("#/"))
fail(`unsupported schema reference ${pythonRepr(reference)}`);
for (const part of reference.slice(2).split("/")) {
const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
if (!object(target) || !Object.hasOwn(target, key))
fail(`unresolved schema reference ${pythonRepr(reference)}`);
target = target[key];
}
}
if (!object(target))
fail(`schema reference ${pythonRepr(reference)} is not an object`);
validateAgainstSchema(value, target as ContractSchema, context, root);
}
const expected = schema.type;
if (Array.isArray(expected)) {
if (!expected.some((type) => matches(value, type)))
fail(`does not match schema type ${pythonRepr(expected)}`);
} else if (typeof expected === "string" && !matches(value, expected)) {
fail(`expected schema type ${expected}`);
}
if (Object.hasOwn(schema, "const") && !equal(value, schema.const))
fail(`expected ${pythonRepr(schema.const)}`);
if (schema.enum && !schema.enum.some((candidate) => equal(value, candidate)))
fail(`unsupported value ${pythonRepr(value)}`);
if (typeof value === "string") {
if (schema.minLength && Array.from(value).length < schema.minLength)
fail("string is too short");
if (
schema.pattern !== undefined &&
!new RegExp(`^(?:${schema.pattern})$(?![\\s\\S])`, "u").test(value)
)
fail("string does not match schema pattern");
}
if (numeric(value)) {
if (schema.minimum !== undefined && number(value) < schema.minimum)
fail("value is below schema minimum");
if (schema.maximum !== undefined && number(value) > schema.maximum)
fail("value is above schema maximum");
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems)
fail("array has too few items");
if (schema.maxItems !== undefined && value.length > schema.maxItems)
fail("array has too many items");
if (schema.items)
value.forEach((item, index) =>
validateAgainstSchema(
item,
schema.items!,
`${context}[${index}]`,
root,
),
);
if (schema.uniqueItems === true && new Set(value).size !== value.length)
fail("array items must be unique");
}
if (object(value)) {
for (const key of schema.required ?? [])
if (!Object.hasOwn(value, key))
throw new Error(`${context}.${key}: missing required schema property`);
if (
schema.minProperties !== undefined &&
Object.keys(value).length < schema.minProperties
)
fail("object has too few properties");
for (const [key, item] of objectEntries(value)) {
const child = Object.hasOwn(schema.properties ?? {}, key)
? schema.properties![key]
: undefined;
if (child) validateAgainstSchema(item, child, `${context}.${key}`, root);
else if (schema.additionalProperties === false)
throw new Error(`${context}.${key}: unexpected schema property`);
else if (object(schema.additionalProperties))
validateAgainstSchema(
item,
schema.additionalProperties,
`${context}.${key}`,
root,
);
}
}
}
11 changes: 11 additions & 0 deletions plugins/codex-security/mcp-app/src/helpers/helper-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { windowsBinding } from "../native";
import { widePath, windowsFileSystem } from "../../../native/windows-files.mjs";
import { encodePosixPath } from "./posix-path";

export function readFile(path: string | number): Buffer {
if (typeof path === "number") return readFileSync(path);
return process.platform === "win32"
? windowsFileSystem(windowsBinding()).readFile(widePath(path))
: readFileSync(encodePosixPath(path));
}
Loading
Loading