Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5dc0f1c
fix(git): pin trusted executables across scan hosts
mldangelo-oai Aug 15, 2026
9be47a6
fix(git): preserve sanitized and inherited executable paths
mldangelo-oai Aug 15, 2026
49000ab
fix(git): anchor trusted tools to the scanned repository
mldangelo-oai Aug 15, 2026
78123a7
fix(git): reject repository aliases by filesystem identity
mldangelo-oai Aug 15, 2026
71ba0ab
Merge commit '5d1afcd312933121e36dc892dcb12e8d2e3e1de3' into mdangelo…
mldangelo-oai Aug 16, 2026
15a97ef
fix(git): reuse trusted lookup and preserve platform defaults
mldangelo-oai Aug 16, 2026
357b437
fix(git): bind optional tools with platform-aware environments
mldangelo-oai Aug 16, 2026
b701818
fix(git): tolerate unavailable historical targets
mldangelo-oai Aug 16, 2026
743e32f
fix(runtime): stage packaged ripgrep for local installs
mldangelo-oai Aug 16, 2026
c0142c5
fix(api): preserve explicit Git disable bindings
mldangelo-oai Aug 16, 2026
62bfcd1
fix(runtime): reject canonical Windows batch targets
mldangelo-oai Aug 16, 2026
b83d18e
test: preserve Python shim startup environment
mldangelo-oai Aug 16, 2026
632d3ea
Merge current main into trusted executable binding
mldangelo-oai Aug 17, 2026
ab4e036
fix(sdk): preserve explicit tool selections
mldangelo-oai Aug 17, 2026
38d9956
Merge main session setup into trusted tool bindings
mldangelo-oai Aug 17, 2026
b2fa38c
fix(sdk): carry explicit Git bindings through target validation
mldangelo-oai Aug 17, 2026
2a365a0
fix(sdk): exclude internal helpers from public declarations
mldangelo-oai Aug 17, 2026
1fa7620
test(sdk): preserve output boundaries when Git is disabled
mldangelo-oai Aug 17, 2026
72ff479
Merge Windows executable discovery into trusted Git selection
mldangelo-oai Aug 17, 2026
e9afbd9
fix(git): align trusted tool bindings across scan hosts
mldangelo-oai Aug 18, 2026
516111a
Merge main into trusted Git executable binding
mldangelo-oai Aug 18, 2026
30a94f4
fix(git): honor selected executable for bulk checkouts
mldangelo-oai Aug 18, 2026
633f0e8
fix(multiscan): exclude local source roots from Git selection
mldangelo-oai Aug 18, 2026
cff2531
fix(multiscan): protect all campaign source roots
mldangelo-oai Aug 18, 2026
5896809
fix(git): protect repositories supplying scan inputs
mldangelo-oai Aug 18, 2026
bd9ca6c
test: check shared credential lock after both scans start
mldangelo-oai Aug 18, 2026
fa12ba4
fix(git): retain input roots behind dangling links
mldangelo-oai Aug 18, 2026
6fe9d9d
fix(git): protect scan outputs and isolate cyclic inputs
mldangelo-oai Aug 18, 2026
24189bc
fix: preserve deliberate local Git selections
mldangelo-oai Aug 18, 2026
83ab753
fix(multiscan): isolate unavailable input roots
mldangelo-oai Aug 18, 2026
76b9e90
fix(git): handle host path compatibility
mldangelo-oai Aug 18, 2026
0552695
fix: align tool locations with scan inputs
mldangelo-oai Aug 18, 2026
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.21",
"version": "0.1.29",
"description": "Codex Security workflows for security scans, analysis, and investigation.",
"author": {
"name": "OpenAI"
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/_bundled_plugin/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"AWS_CONTAINER_AUTHORIZATION_TOKEN",
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
"PYTHON",
"CODEX_SECURITY_GIT",
"CODEX_SECURITY_RG",
"CODEX_SECURITY_KNOWLEDGE_BASE",
"CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH",
"CODEX_SECURITY_SCAN_ROOT",
Expand Down
36 changes: 14 additions & 22 deletions sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import tempfile
from pathlib import Path

# Some plugin hosts launch Python with safe-path isolation enabled.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from workbench_target import git_command, ripgrep_command


class InventoryError(ValueError):
"""Raised when the repository, scope, or inventory cannot be used safely."""
Expand Down Expand Up @@ -70,7 +74,6 @@ def resolve_output(value: str) -> Path:
def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int:
"""Atomically write the exact ripgrep inventory sorted as ``LC_ALL=C``."""
command = [
"rg",
"--files",
"--hidden",
"--no-ignore",
Expand All @@ -83,13 +86,7 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int:
]
with tempfile.TemporaryFile(mode="w+b") as inventory:
try:
result = subprocess.run(
command,
cwd=repository,
stdout=inventory,
stderr=subprocess.PIPE,
check=False,
)
result = ripgrep_command(repository, *command, stdout=inventory)
except OSError as error:
raise InventoryError(f"could not run ripgrep: {error}") from error

Expand All @@ -107,20 +104,16 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int:


def committed_changed_paths(repository: Path, base: str, head: str) -> list[tuple[Path, str]]:
result = subprocess.run(
[
"git",
"-C",
str(repository),
"diff",
"--raw",
"-z",
"--diff-filter=ACMRD",
f"{base}..{head}",
],
capture_output=True,
check=True,
result = git_command(
repository,
"diff",
"--raw",
"-z",
"--diff-filter=ACMRD",
f"{base}..{head}",
text=False,
)
result.check_returncode()
fields = result.stdout.split(b"\0")
changed: list[tuple[Path, str]] = []
index = 0
Expand All @@ -146,7 +139,6 @@ def generate_diff_in_scope_files(
output: Path,
) -> 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 rank_preview import (
DEFAULT_PREVIEW_BYTES,
Expand Down
43 changes: 22 additions & 21 deletions sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import json
import os
import re
import subprocess
import sys
from collections import Counter
from collections.abc import Callable
Expand All @@ -43,7 +42,12 @@
preview_for,
preview_for_bytes,
)
from workbench_target import git_blob_bytes, git_directory_snapshot_paths
from workbench_target import (
git_blob_bytes,
git_command,
git_directory_snapshot_paths,
ripgrep_command,
)

EXCLUDED_DIRS = {
".cache",
Expand Down Expand Up @@ -521,7 +525,6 @@ def make_repo_scope_input(args: argparse.Namespace) -> None:
candidates = git_candidates
else:
command = [
"rg",
"--files",
"--hidden",
"--no-require-git",
Expand All @@ -532,7 +535,7 @@ def make_repo_scope_input(args: argparse.Namespace) -> None:
str(scope_path.relative_to(repo)),
]
try:
result = subprocess.run(command, cwd=repo, capture_output=True, check=False)
result = ripgrep_command(repo, *command)
except OSError as exc:
ignore_names = (".gitignore", ".ignore", ".rgignore")
ancestors = (scope_path, *scope_path.parents)
Expand Down Expand Up @@ -608,21 +611,16 @@ def bind_repo_scopes(args: argparse.Namespace) -> None:


def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, str]]:
result = subprocess.run(
[
"git",
"-C",
str(repo),
"diff",
"--name-status",
"-z",
"--diff-filter=ACMRD",
*diff_args,
],
check=True,
capture_output=True,
result = git_command(
repo,
"diff",
"--name-status",
"-z",
"--diff-filter=ACMRD",
*diff_args,
text=True,
)
result.check_returncode()
fields = result.stdout.split("\0")
if fields and not fields[-1]:
fields.pop()
Expand All @@ -646,12 +644,15 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple
if mode == "local-patch":
unstaged = run_git_changed_paths(repo, [base])
staged = run_git_changed_paths(repo, ["--cached", base])
untracked = subprocess.run(
["git", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z"],
capture_output=True,
untracked = git_command(
repo,
"ls-files",
"--others",
"--exclude-standard",
"-z",
text=True,
check=True,
)
untracked.check_returncode()
combined = dict(staged)
combined.update(unstaged)
combined.update(
Expand Down
176 changes: 160 additions & 16 deletions sdk/typescript/_bundled_plugin/scripts/workbench_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import errno
import hashlib
import os
import shutil
Expand All @@ -11,7 +12,7 @@
import subprocess
import sys
from pathlib import Path
from typing import Any
from typing import IO, Any

# Some plugin hosts launch Python with safe-path isolation enabled.
sys.path.insert(0, str(Path(__file__).resolve().parent))
Expand Down Expand Up @@ -120,6 +121,146 @@ def _read_sized_nul_field(
return output[offset:end], end + 1


def _protected_git_root(target: Path) -> Path | None:
"""Return the outermost repository root, or None for a stale target."""
try:
root = target.resolve(strict=True)
if not stat.S_ISDIR(root.stat().st_mode):
return None
for ancestor in (root, *root.parents):
try:
(ancestor / ".git").lstat()
except FileNotFoundError:
continue
root = ancestor
except (FileNotFoundError, NotADirectoryError, RuntimeError):
# pathlib raised RuntimeError for symlink loops before Python 3.13.
return None
except OSError as error:
if error.errno != errno.ELOOP:
raise
return None
return root


def _inside_protected_git_root(candidate: Path, root: Path) -> bool:
return candidate.is_relative_to(root) or (
len(candidate.parts) >= len(root.parts)
and Path(*candidate.parts[: len(root.parts)]).samefile(root)
)


def _is_native_executable(candidate: Path, canonical: Path) -> bool:
windows = sys.platform == "win32"
return (
canonical.is_file()
and (
not windows
or (
candidate.suffix.lower() in {".exe", ".com"}
and canonical.suffix.lower() not in {".bat", ".cmd"}
)
)
and os.access(canonical, os.F_OK if windows else os.X_OK)
)


def _trusted_executable(
target: Path,
environment: dict[str, str],
name: str,
) -> str | None:
root = _protected_git_root(target)
if root is None:
return None
setting = f"CODEX_SECURITY_{name.upper()}"
configured = environment.get(setting)
if configured is not None:
if not configured:
return None
candidate = Path(configured)
if not candidate.is_absolute():
raise SystemExit(f"{setting} must name an absolute trusted executable.")
if sys.platform == "win32" and not os.path.splitext(candidate.name)[1]:
candidate = Path(f"{candidate}.exe")
try:
canonical = candidate.resolve(strict=True)
if _inside_protected_git_root(canonical, root) or any(
_inside_protected_git_root(ancestor.resolve(strict=True), root)
for ancestor in candidate.parents
):
raise SystemExit(f"{setting} must stay outside the protected repository.")
except (OSError, RuntimeError) as error:
raise SystemExit(f"{setting} does not name an available executable.") from error
if not _is_native_executable(candidate, canonical):
raise SystemExit(f"{setting} does not name an available executable.")
return configured

entries: list[str] = []
executable: str | None = None
names = (f"{name}.exe", f"{name}.com") if sys.platform == "win32" else (name,)
if sys.platform == "win32":
path_keys = sorted(key for key in environment if key.upper() == "PATH")
if path_keys:
path = environment[path_keys[0]]
for key in path_keys:
del environment[key]
environment["PATH"] = path
for entry in os.get_exec_path(environment):
if sys.platform == "win32" and entry.startswith('"') and entry.endswith('"'):
entry = entry[1:-1]
if not entry:
continue
try:
directory = Path(entry).resolve(strict=True)
if _inside_protected_git_root(directory, root):
continue
except (OSError, RuntimeError):
continue
candidate: str | None = None
safe = True
for name in names:
path = directory / name
try:
canonical = path.resolve(strict=True)
if _inside_protected_git_root(canonical, root):
safe = False
break
except (OSError, RuntimeError):
continue
if _is_native_executable(path, canonical):
candidate = candidate or str(path)
if not safe:
continue
executable = executable or candidate
entries.append(str(directory))
environment["PATH"] = os.pathsep.join(entries)
return executable


def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | None:
return _trusted_executable(target, environment, "git")


def ripgrep_command(
target: Path,
*args: str,
stdout: IO[bytes] | int = subprocess.PIPE,
) -> subprocess.CompletedProcess[bytes]:
environment = os.environ.copy()
executable = _trusted_executable(target, environment, "rg")
if executable is None:
raise FileNotFoundError("ripgrep is not available on a trusted PATH.")
return subprocess.run(
[executable, *args],
cwd=target,
stdout=stdout,
stderr=subprocess.PIPE,
env=environment,
check=False,
)


def git_command(
target: Path,
*args: str,
Expand All @@ -134,25 +275,28 @@ def git_command(
for name in GIT_REPOSITORY_ENVIRONMENT:
environment.pop(name, None)
environment["GIT_LITERAL_PATHSPECS"] = "1"
executable = _trusted_git_executable(target, environment)
# Repository-local config is untrusted; fsmonitor may name an executable hook.
command = ["git", "-c", "core.fsmonitor=false", "-C", str(target)]
command = [executable or "git", "-c", "core.fsmonitor=false", "-C", str(target)]
if git_dir is not None and work_tree is not None:
command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)])
full_command = [*command, *args]
try:
return subprocess.run(
full_command,
check=False,
capture_output=True,
env=environment,
text=text,
input=input_data,
)
except FileNotFoundError:
# Git is optional for Codebase scans. Treat an unavailable executable like
# any other failed Git probe so the target falls back to a directory snapshot.
empty_output = "" if text else b""
return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output)
if executable is not None:
try:
return subprocess.run(
full_command,
check=False,
capture_output=True,
env=environment,
text=text,
input=input_data,
)
except FileNotFoundError:
pass
# Git is optional for Codebase scans. Treat an unavailable executable like
# any other failed Git probe so the target falls back to a directory snapshot.
empty_output = "" if text else b""
return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output)


def update_digest_field(digest: Any, label: bytes, value: bytes) -> None:
Expand Down
Loading
Loading