Skip to content
Closed
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
2 changes: 1 addition & 1 deletion sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-security",
"version": "0.1.76",
"version": "0.1.77",
"description": "Codex Security workflows for security scans, analysis, and investigation.",
"author": {
"name": "OpenAI"
Expand Down
45 changes: 38 additions & 7 deletions sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,23 @@ def generate_diff_in_scope_files(
) -> int:
"""Reuse the existing diff selection without generating previews or duplicate worklists."""
sys.path.insert(0, str(Path(__file__).resolve().parent))
from generate_rank_input import git_changed_paths, path_is_excluded
from finalize_scan_contract import scan_root_identity
from generate_rank_input import (
changed_path_parent_is_within_target,
git_changed_paths,
path_is_excluded,
preview_for_changed_path,
)
from rank_preview import (
DEFAULT_PREVIEW_BYTES,
TEXT_CODE_EXTENSIONS,
is_binary_sample,
preview_for,
)
from workbench_target import git_blob_bytes

rows: list[bytes] = []
try:
root_identity = scan_root_identity(repository)[1] if mode != "revisions" else None
changed = (
committed_changed_paths(repository, base, head)
if mode == "revisions"
Expand Down Expand Up @@ -206,6 +212,19 @@ def generate_diff_in_scope_files(

for path, status in eligible:
relative = path.relative_to(repository)
if mode != "revisions":
try:
within_target = changed_path_parent_is_within_target(
path, repository
)
except (OSError, RuntimeError) as error:
raise InventoryError(
"could not inspect a changed Git working-tree path"
) from error
if not within_target:
raise InventoryError(
"changed Git working-tree paths must stay inside the selected target"
)
if status != "D":
if mode == "revisions":
contents = revision_blobs[relative]
Expand All @@ -215,12 +234,24 @@ def generate_diff_in_scope_files(
)
if is_binary_sample(contents):
continue
elif (
path.is_symlink()
or not path.is_file()
or preview_for(path, DEFAULT_PREVIEW_BYTES)[1]
):
elif path.is_symlink() or not path.is_file():
continue
else:
try:
_, is_binary = preview_for_changed_path(
path,
repository,
DEFAULT_PREVIEW_BYTES,
expected_root_identity=root_identity,
)
except (FileNotFoundError, PermissionError):
continue
except (OSError, RuntimeError, ValueError) as error:
raise InventoryError(
"changed Git working-tree paths must stay inside the selected target"
) from error
if is_binary:
continue
relative_path = relative.as_posix()
if "\n" in relative_path or "\r" in relative_path:
raise InventoryError(
Expand Down
155 changes: 148 additions & 7 deletions sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
from __future__ import annotations

import argparse
import errno
import hashlib
import json
import os
import re
import stat
import subprocess
import sys
from collections import Counter
Expand All @@ -37,9 +39,17 @@

# Some plugin hosts launch Python with safe-path isolation enabled.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from finalize_scan_contract import (
_descriptor_relative_reads_available,
_open_scan_local_directory,
_open_verified_scan_directory,
_windows_scan_local_files,
scan_root_identity,
)
from rank_preview import (
DEFAULT_PREVIEW_BYTES,
TEXT_CODE_EXTENSIONS,
is_binary_sample,
preview_for,
preview_for_bytes,
)
Expand Down Expand Up @@ -293,6 +303,121 @@ def path_is_excluded(path: Path) -> bool:
return path.name.endswith((".min.js", ".map"))


def changed_path_parent_is_within_target(path: Path, target: Path) -> bool:
"""Resolve the nearest existing parent without dereferencing the changed leaf."""
target = target.resolve(strict=True)
candidate = path.parent
while True:
try:
candidate.lstat()
except (FileNotFoundError, NotADirectoryError):
parent = candidate.parent
if parent == candidate:
return False
candidate = parent
continue
break

resolved = candidate.resolve(strict=True)
if resolved.is_relative_to(target):
return True
for ancestor in (resolved, *resolved.parents):
try:
if ancestor.samefile(target):
return True
except OSError:
continue
return False


def _open_windows_changed_path_descriptor(
target: Path, relative_path: Path, expected_root_identity: tuple[int, int] | None
) -> int:
"""Preserve ordinary missing-leaf behavior without relaxing parent checks."""

expected_path = target / relative_path
try:
return _windows_scan_local_files().open_read_fd(
target,
relative_path.as_posix(),
"changed Git working-tree path",
expected_root_identity=expected_root_identity,
)
except OSError as error:
if error.filename is None or os.path.normcase(
os.path.normpath(os.fspath(error.filename))
) != os.path.normcase(os.path.normpath(os.fspath(expected_path))):
raise
if error.errno in {errno.ENOENT, 3}:
raise FileNotFoundError(
error.errno, error.strerror, error.filename
) from error
if error.errno in {errno.EACCES, 5, 32, 33}:
raise PermissionError(error.errno, error.strerror, error.filename) from error
raise


def preview_for_changed_path(
path: Path,
target: Path,
preview_bytes: int,
*,
expected_root_identity: tuple[int, int] | None,
) -> tuple[str, bool]:
"""Bind working-tree reads to the checked repository and parent identities."""

try:
relative_parent = path.parent.resolve(strict=True).relative_to(target)
except (FileNotFoundError, PermissionError) as error:
raise ValueError("changed Git working-tree parent became unavailable") from error
descriptor: int | None = None
try:
if os.name == "nt":
descriptor = _open_windows_changed_path_descriptor(
target, relative_parent / path.name, expected_root_identity
)
elif _descriptor_relative_reads_available():
root_descriptor = _open_verified_scan_directory(target, expected_root_identity)
try:
try:
parent_descriptor = _open_scan_local_directory(
root_descriptor, relative_parent.parts, create=False
)
Comment on lines +383 to +385

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind each checked parent identity to the later read

When a non-symlink parent is exchanged after changed_path_parent_is_within_target returns—by renaming repo/src aside and moving an unrelated same-filesystem directory into repo/src—this opens the replacement because only the root identity is verified and O_NOFOLLOW accepts the new ordinary directory. I reproduced this on POSIX and the generated rank preview contained the replacement directory's external marker. The fresh evidence beyond the prior junction and repository-root coverage is that a normal-directory parent swap bypasses both controls; retain or record the checked parent's identity and verify it while traversing the descriptor chain.

AGENTS.md reference: sdk/typescript/AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

except (FileNotFoundError, PermissionError) as error:
raise ValueError(
"changed Git working-tree parent became unavailable"
) from error
try:
descriptor = os.open(
path.name,
os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0),
dir_fd=parent_descriptor,
)
finally:
os.close(parent_descriptor)
finally:
os.close(root_descriptor)

if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise OSError("changed Git working-tree path is not a regular file")
Comment on lines +401 to +402

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip non-regular leaves instead of aborting the scan

When a selected regular file is replaced by a directory after the caller's path.is_file() check—possible during a concurrent checkout or code-generation step—this branch raises OSError, which both generators translate into a confinement violation and terminate the entire scan. Before this change, preview_for caught the resulting directory open/read error and skipped the unavailable entry; the fresh unhandled churn case beyond the earlier missing/read fixes is this regular-to-directory transition. Treat this verified non-regular leaf as a skipped entry while retaining parent and reparse-point validation.

AGENTS.md reference: AGENTS.md:L21-L24

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed at 059e7b9d against base 97e89179: replacing the selected regular file with a directory immediately after is_file() succeeds, with root and parent unchanged, makes ranking/inventory exit 1/2; the base exits 0 and skips the entry. The fstat check at lines 401–402 raises before the new stream-error catch, and the callers turn it into a confinement error.

Please treat this verified non-regular leaf as a skipped entry while retaining root/parent checks and symlink/reparse rejection. This also existed at 8f35a487; it is not caused by the EIO fix. Verified with synthetic real-filesystem swaps on macOS.

else:
raise OSError("changed Git working-tree input requires secure file operations")

try:
with os.fdopen(descriptor, "rb") as source:
descriptor = None
sample = source.read(4096)
if is_binary_sample(sample):
return "", True
data = sample + source.read()
except OSError:
return "", True
return preview_for_bytes(path, data, preview_bytes)
finally:
if descriptor is not None:
os.close(descriptor)


def windows_stream_component(path: Path) -> str | None:
"""Return the first NTFS alternate-data-stream component."""

Expand Down Expand Up @@ -684,6 +809,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None:
if not repo.is_dir():
raise SystemExit(f"Repo path not found: {repo}")

root_identity = scan_root_identity(repo)[1] if args.mode != "revisions" else None
changed = [
(path, status)
for path, status in git_changed_paths(repo, args.base, args.head, args.mode)
Expand All @@ -708,6 +834,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None:
rows: list[JsonRow] = []
for path, status in changed:
rel = path.relative_to(repo)
if args.mode != "revisions":
Comment thread
mldangelo-oai marked this conversation as resolved.
try:
within_target = changed_path_parent_is_within_target(path, repo)
except (OSError, RuntimeError) as error:
raise SystemExit(
"Could not inspect a changed Git working-tree path."
) from error
if not within_target:
raise SystemExit(
"Changed Git working-tree paths must stay inside the selected target."
)

if status == "D":
preview = ""
Expand All @@ -724,13 +861,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None:
preview = ""
Comment thread
mldangelo-oai marked this conversation as resolved.
elif path.is_file():
try:
path.resolve(strict=True).relative_to(repo)
except (OSError, ValueError):
preview = ""
else:
preview, is_binary = preview_for(path, args.preview_bytes)
if is_binary:
continue
preview, is_binary = preview_for_changed_path(
path, repo, args.preview_bytes, expected_root_identity=root_identity
)
except (FileNotFoundError, PermissionError):
continue
except (OSError, RuntimeError, ValueError) as error:
raise SystemExit(
"Changed Git working-tree paths must stay inside the selected target."
) from error
if is_binary:
continue
else:
preview = ""
rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview})
Expand Down
15 changes: 13 additions & 2 deletions sdk/typescript/_bundled_plugin/scripts/windows_scan_local_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,22 @@ def scan_root_identity(scan_dir: Path) -> tuple[Path, tuple[int, int]]:
return root_path, (metadata.st_dev, metadata.st_ino)


def open_read_fd(scan_dir: Path, relative_path: str, context: str) -> int:
def open_read_fd(
scan_dir: Path,
relative_path: str,
context: str,
*,
expected_root_identity: tuple[int, int] | None = None,
) -> int:
"""Open a verified regular file and return an owned binary read descriptor."""

try:
with _locked_parent(scan_dir, relative_path, create=False) as (parent_path, leaf_name):
with _locked_parent(
scan_dir,
relative_path,
create=False,
expected_root_identity=expected_root_identity,
) as (parent_path, leaf_name):
path = parent_path / leaf_name
handle = _create_file(
path,
Expand Down
2 changes: 1 addition & 1 deletion sdk/typescript/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions(
export const VERSION = PACKAGE_VERSIONS.package;
export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk;
export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable;
export const BUNDLED_PLUGIN_VERSION = "0.1.76" as const;
export const BUNDLED_PLUGIN_VERSION = "0.1.77" as const;

const PACKAGE_NAME = "@openai/codex-security";

Expand Down
Loading
Loading