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
5 changes: 4 additions & 1 deletion plugins/codex-security/mcp-app/helpers-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { validatePatchRiskAssessmentCommand } from "./src/helpers/validate-patch
import { deepReviewInputCommand } from "./src/helpers/deep-review-input";
import { rankShardsCommand } from "./src/helpers/rank-shards";
import { rankPoolCommand } from "./src/helpers/rank-pool";
import { bindRepoScopesCommand } from "./src/helpers/bind-repo-scopes";

let commandLine = process.argv.slice(2);
if (process.platform === "win32") {
Expand Down Expand Up @@ -55,9 +56,11 @@ if (command === "resolve-security-md") {
command === "validate-rank-pool"
) {
process.exitCode = rankPoolCommand(command, args, posixHome);
} else if (command === "bind-repo-scopes") {
process.exitCode = bindRepoScopesCommand(args, posixHome);
} else {
console.error(
"Usage: launch_codex_security_mcp[.cmd] --helper <resolve-security-md | normalize-candidates | validate-patch-risk-assessment | copy-deep-review-input | select-deep-review-input | make-rank-shards | validate-rank-shard | merge-rank-outputs | make-rank-pool-plan | validate-rank-worker | validate-rank-pool> [options]",
"Usage: launch_codex_security_mcp[.cmd] --helper <resolve-security-md | normalize-candidates | validate-patch-risk-assessment | copy-deep-review-input | select-deep-review-input | make-rank-shards | validate-rank-shard | merge-rank-outputs | make-rank-pool-plan | validate-rank-worker | validate-rank-pool | bind-repo-scopes> [options]",
);
process.exitCode = 2;
}
84 changes: 84 additions & 0 deletions plugins/codex-security/mcp-app/src/helpers/bind-repo-scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { decodeUtf8 } from "./utf8";
import { readFile, writeFile } from "./helper-files";
import { object, parseJson, stringifyJson } from "./python-json";
import {
ArgumentError,
argumentsFor,
print,
worklistPath,
} from "./rank-worklists";

const read = (path: string) => parseJson(decodeUtf8(readFile(path)));

export function bindRepoScopesCommand(
args: string[],
posixHome = process.env.HOME,
): number {
const required = ["scopes-file", "manifest", "coverage"];
const usage =
"usage: launch_codex_security_mcp[.cmd] --helper bind-repo-scopes [-h] --scopes-file PATH --manifest PATH --coverage PATH";
try {
const values = argumentsFor(args, required);
if (values.help) {
print(
`${usage}\n\nCopy SDK scoped-path targets into the unsealed manifest and coverage documents.\n\noptions:\n -h, --help show this help message and exit\n${required.map((name) => ` --${name} PATH`).join("\n")}`,
);
return 0;
}
const scopesPath = worklistPath(values["scopes-file"] as string, posixHome);
let scopes: unknown;
try {
scopes = read(scopesPath);
} catch {
throw new Error(`Unable to read scopes file: ${scopesPath}`);
}
if (
!Array.isArray(scopes) ||
scopes.length === 0 ||
scopes.some((scope: unknown) => typeof scope !== "string" || !scope)
)
throw new Error(
`Scopes file must contain a non-empty JSON string array: ${scopesPath}`,
);
const manifestPath = worklistPath(values.manifest as string, posixHome);
const coveragePath = worklistPath(values.coverage as string, posixHome);
let manifest: unknown, coverage: unknown, scope: unknown;
try {
manifest = read(manifestPath);
coverage = read(coveragePath);
if (!object(manifest) || !object(coverage) || !object(manifest.scan))
throw new Error("expected JSON objects");
scope = manifest.scan.scope;
if (!object(scope))
throw new Error("manifest.scan.scope must be an object");
} catch {
throw new Error("Unable to bind requested scopes into the scan contract");
}
scope.includePaths = scopes;
coverage.includePaths = scopes;
for (const [path, value] of [
[manifestPath, manifest],
[coveragePath, coverage],
] as const) {
const contents = stringifyJson(value) + "\n";
writeFile(path, [
Buffer.from(
process.platform === "win32"
? contents.replaceAll("\n", "\r\n")
: contents,
),
]);
}
print(`Bound ${scopes.length} requested scopes into the scan contract`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
print(
error instanceof ArgumentError
? `${usage}\nbind-repo-scopes: error: ${message}`
: message,
true,
);
return error instanceof ArgumentError ? 2 : 1;
}
}
41 changes: 37 additions & 4 deletions plugins/codex-security/mcp-app/src/helpers/python-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,43 @@ export function object(value: unknown): value is Row {
);
}
export function objectEntries(value: Row): [string, unknown][] {
return (keyOrder.get(value) ?? Object.keys(value)).map((key) => [
key,
value[key],
]);
const keys = new Set([...(keyOrder.get(value) ?? []), ...Object.keys(value)]);
return [...keys].map((key) => [key, value[key]]);
}

// json.dumps(..., ensure_ascii=True, indent=2), including parsed number types.
export function stringifyJson(value: unknown): string {
const quote = (text: string) =>
JSON.stringify(text).replace(
/[\u007f-\uffff]/g,
(character) =>
Comment thread
kmbroai marked this conversation as resolved.
`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
);
function encode(item: unknown, depth: number): string {
if (typeof item === "string") return quote(item);
if (item instanceof JsonFloat) {
const number = Number(item.source);
if (Number.isNaN(number)) return "NaN";
if (!Number.isFinite(number))
return number < 0 ? "-Infinity" : "Infinity";
return pythonRepr(item);
}
if (typeof item === "bigint") return String(item);
if (Array.isArray(item) || object(item)) {
const array = Array.isArray(item);
const entries = array
? item.map((child) => encode(child, depth + 1))
: objectEntries(item).map(
([key, child]) => `${quote(key)}: ${encode(child, depth + 1)}`,
);
const [open, close] = array ? ["[", "]"] : ["{", "}"];
if (entries.length === 0) return open + close;
const prefix = " ".repeat(depth + 1);
return `${open}\n${prefix}${entries.join(`,\n${prefix}`)}\n${" ".repeat(depth)}${close}`;
}
return JSON.stringify(item);
}
return encode(value, 0);
}

export class JsonSyntaxError extends Error {}
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex-security/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The `native-windows` workflow builds x64 and arm64 with MSVC and a static CRT. I
node --expose-gc plugins/codex-security/native/proof-windows.mjs python plugins/codex-security/scripts
```

The build also compiles the test-only `windows-wide-launcher` Rust example. It starts a Node proof child with lone surrogates in arguments, environment values, and its working directory. That child checks complete directory iteration, distinct surrogate and replacement-character files, canonical paths, bounded reads, output truncation, and recursive long paths through the typed adapter. A Rust file guard with sharing disabled remains open while the child enumerates its name; an explicit data read fails with a sharing violation. Attribute-only access is not blocked by Windows file sharing. Root-normalization tables run on the same matrix. The launcher cleans up the wide fixtures and is never included in the uploaded or bundled native payloads.
The build also compiles the test-only `windows-wide-launcher` Rust example. It starts a Node proof child with lone surrogates in arguments, environment values, and its working directory. That child checks complete directory iteration, distinct surrogate and replacement-character files, canonical paths, bounded reads, output truncation, and recursive long paths through the typed adapter. A Rust file guard with sharing disabled remains open while the child enumerates its name; an explicit data read fails with a sharing violation. Attribute-only access is not blocked by Windows file sharing. Root-normalization tables run on the same matrix. Scope binding also reads raw requested-scope and contract paths, preserves ordered JSON and unrelated hash fields, truncates both outputs, and leaves documents unchanged when validation fails. The launcher cleans up the wide fixtures and is never included in the uploaded or bundled native payloads.

## Package inputs

Expand Down
75 changes: 74 additions & 1 deletion plugins/codex-security/native/examples/windows-wide-launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,80 @@ fn main() -> std::io::Result<()> {
String::from_utf8_lossy(&complete.stderr)
)));
}
println!("{{\"policyHelperRawPaths\":true,\"candidateHelperRawPaths\":true,\"assessmentHelperRawPaths\":true,\"deepReviewHelperRawPaths\":true,\"rankShardHelperRawPaths\":true,\"rankPoolHelperRawPaths\":true,\"directoryIdentity\":true}}");
let scopes_name = raw("requested-", 0xd800);
let manifest_name = raw("manifest-", 0xdc80);
let coverage_name = raw("coverage-", 0xdfff);
let scopes_path = repo.join(&scopes_name);
let manifest_path = repo.join(&manifest_name);
let coverage_path = repo.join(&coverage_name);
let scope_contents = r#"["src","\udfff","src"]"#;
fs::write(&scopes_path, scope_contents)?;
for prefix in ["requested-", "manifest-", "coverage-"] {
fs::write(repo.join(raw(prefix, 0xfffd)), "replacement scope sentinel")?;
}
let manifest_before =
r#"{"scan":{"scope":{"includePaths":["old"],"excludePaths":[]}},"sha256":"unchanged"}"#;
let coverage_before = r#"{"includePaths":["old"],"excludePaths":[]}"#;
let manifest_after = concat!(
"{\r\n \"scan\": {\r\n \"scope\": {\r\n \"includePaths\": [\r\n",
" \"src\",\r\n \"\\udfff\",\r\n \"src\"\r\n ],\r\n",
" \"excludePaths\": []\r\n }\r\n },\r\n \"sha256\": \"unchanged\"\r\n}\r\n",
);
let coverage_after = concat!(
"{\r\n \"includePaths\": [\r\n \"src\",\r\n \"\\udfff\",\r\n",
" \"src\"\r\n ],\r\n \"excludePaths\": []\r\n}\r\n",
);
for prefix in [None, Some("~"), Some(".")] {
fs::write(
&manifest_path,
format!("{manifest_before}{}", " ".repeat(512)),
)?;
fs::write(
&coverage_path,
format!("{coverage_before}{}", " ".repeat(512)),
)?;
let argument = |name: &OsString| match prefix {
Some(prefix) => Path::new(prefix).join(name),
None => repo.join(name),
};
let bind_args = [
"--scopes-file".into(),
argument(&scopes_name),
"--manifest".into(),
argument(&manifest_name),
"--coverage".into(),
argument(&coverage_name),
];
let bound = shard_command("bind-repo-scopes", &bind_args)?;
if !bound.status.success()
|| !bound.stderr.is_empty()
|| bound.stdout != b"Bound 3 requested scopes into the scan contract\r\n"
|| fs::read(&manifest_path)? != manifest_after.as_bytes()
|| fs::read(&coverage_path)? != coverage_after.as_bytes()
|| fs::read(&scopes_path)? != scope_contents.as_bytes()
{
return Err(io::Error::other(format!(
"Wide scope binding failed: {}",
String::from_utf8_lossy(&bound.stderr)
)));
}
fs::write(&coverage_path, "{")?;
let invalid = shard_command("bind-repo-scopes", &bind_args)?;
if invalid.status.code() != Some(1)
|| !invalid.stdout.is_empty()
|| invalid.stderr != b"Unable to bind requested scopes into the scan contract\r\n"
|| fs::read(&manifest_path)? != manifest_after.as_bytes()
|| fs::read(&coverage_path)? != b"{"
{
return Err(io::Error::other("Invalid wide scope contract was changed"));
}
}
for prefix in ["requested-", "manifest-", "coverage-"] {
if fs::read(repo.join(raw(prefix, 0xfffd)))? != b"replacement scope sentinel" {
return Err(io::Error::other("Scope binding changed a replacement path"));
}
}
println!("{{\"policyHelperRawPaths\":true,\"candidateHelperRawPaths\":true,\"assessmentHelperRawPaths\":true,\"deepReviewHelperRawPaths\":true,\"rankShardHelperRawPaths\":true,\"rankPoolHelperRawPaths\":true,\"bindScopesHelperRawPaths\":true,\"directoryIdentity\":true}}");
Ok(())
}

Expand Down
38 changes: 0 additions & 38 deletions plugins/codex-security/scripts/generate_rank_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,6 @@ def parse_args() -> argparse.Namespace:
)
scoped.add_argument("--out", required=True, help="Output scoped-source-input.jsonl path.")

bind = subparsers.add_parser(
"bind-repo-scopes",
help="Copy SDK scoped-path targets into the unsealed manifest and coverage documents.",
)
bind.add_argument("--scopes-file", required=True, help="JSON array of requested scopes.")
bind.add_argument("--manifest", required=True, help="Unsealed scan-manifest.json path.")
bind.add_argument("--coverage", required=True, help="Unsealed coverage.json path.")

diff = subparsers.add_parser(
"make-diff-rank-input",
help="Create rank_input.jsonl from Git changed source-like files.",
Expand Down Expand Up @@ -405,34 +397,6 @@ def make_repo_scope_input(args: argparse.Namespace) -> None:
print(f"Wrote {len(rows)} scoped paths to {output}")


def bind_repo_scopes(args: argparse.Namespace) -> None:
scopes = load_scopes_file(Path(args.scopes_file).expanduser())
manifest_path = Path(args.manifest).expanduser()
coverage_path = Path(args.coverage).expanduser()
try:
manifest: object = json.loads(manifest_path.read_text(encoding="utf-8"))
coverage: object = json.loads(coverage_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or not isinstance(coverage, dict):
raise ValueError("expected JSON objects")
scan = manifest.get("scan")
if not isinstance(scan, dict):
raise ValueError("manifest.scan must be an object")
scope = scan.get("scope")
if not isinstance(scope, dict):
raise ValueError("manifest.scan.scope must be an object")
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
raise SystemExit("Unable to bind requested scopes into the scan contract") from exc
scope["includePaths"] = scopes
coverage["includePaths"] = scopes
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8"
)
coverage_path.write_text(
json.dumps(coverage, ensure_ascii=True, indent=2) + "\n", encoding="utf-8"
)
print(f"Bound {len(scopes)} requested scopes into the scan contract")


def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, str]]:
result = subprocess.run(
[
Expand Down Expand Up @@ -555,8 +519,6 @@ def main() -> None:
make_repo_rank_input(args)
elif args.command == "make-repo-scope-input":
make_repo_scope_input(args)
elif args.command == "bind-repo-scopes":
bind_repo_scopes(args)
elif args.command == "make-diff-rank-input":
make_diff_rank_input(args)
else:
Expand Down
Loading
Loading