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
Binary file modified sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000
Binary file not shown.
Binary file modified sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ description = "Capabilities for Git-backed Codex Security diff scans."
[[profiles.security_diff_scan.requirements]]
capability = "delegated_workers"
severity = "warn"
reason = "Large exhaustive diff scans use delegated workers when available; without them, the scan may need a narrower or parent-agent-only path."
reason = "Large diff scans may use discovery workers when available; otherwise the parent reviews every changed file."

[[profiles.security_diff_scan.requirements]]
capability = "goal_tools"
Expand Down
2 changes: 1 addition & 1 deletion sdk/typescript/_bundled_plugin/references/scan-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ A dirty checkout has `allowedKinds: ["git_worktree"]`: copy `requiredSnapshotDig

`targetId` identifies the stable repository or workspace. Prefer a digest of a sanitized canonical absolute remote URL when one exists. Otherwise use a digest of a stable local workspace identity. Never persist remote URL credentials, query parameters, fragments, or tokens.

For dirty worktrees and diffs, calculate `snapshotDigest` from a deterministic representation of the reviewed content, including staged changes and reviewed untracked files where applicable. For directory snapshots, hash a sorted relative-path and file-hash inventory of the reviewed scope. Encode the result as `codex-security-snapshot/v1:sha256:<64 lowercase hex characters>`.
For dirty worktrees and working-tree diffs, calculate `snapshotDigest` from a deterministic representation of the reviewed content, including staged changes and reviewed untracked files where applicable. For committed or revision-range diffs, derive it from the exact authoritative diff kind and immutable base/head revisions. For directory snapshots, hash a sorted relative-path and file-hash inventory of the reviewed scope. Encode the result as `codex-security-snapshot/v1:sha256:<64 lowercase hex characters>`.

## Finding Identity

Expand Down
103 changes: 102 additions & 1 deletion sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,88 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int:
inventory.seek(0)
rows = sorted(inventory)

return write_inventory(output, rows)


def generate_diff_in_scope_files(
repository: Path,
base: str,
head: str,
mode: str,
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, run_git_changed_paths
from rank_preview import (
DEFAULT_PREVIEW_BYTES,
TEXT_CODE_EXTENSIONS,
is_binary_sample,
preview_for,
)

rows: list[bytes] = []
try:
if mode == "local-patch":
changed = run_git_changed_paths(repository, [base])
untracked = subprocess.run(
["git", "-C", str(repository), "ls-files", "--others", "--exclude-standard", "-z"],
capture_output=True,
text=True,
check=True,
)
changed.extend(
(repository / relative, "A")
for relative in untracked.stdout.split("\0")
if relative
)
else:
changed = git_changed_paths(repository, base, head, mode)

for path, status in changed:
relative = path.relative_to(repository)
if path_is_excluded(relative) or path.suffix.lower() not in TEXT_CODE_EXTENSIONS:
continue
if status != "D":
if mode == "revisions":
contents = subprocess.run(
[
"git",
"-C",
str(repository),
"cat-file",
"blob",
f"{head}:{relative.as_posix()}",
],
capture_output=True,
check=True,
).stdout
if is_binary_sample(contents):
continue
elif (
path.is_symlink()
or not path.is_file()
or preview_for(path, DEFAULT_PREVIEW_BYTES)[1]
):
continue
relative_path = relative.as_posix()
if "\n" in relative_path or "\r" in relative_path:
raise InventoryError(
"Git changes contain a path that cannot fit in the file inventory"
)
rows.append(f"{relative_path}\n".encode())
except (OSError, subprocess.CalledProcessError) as error:
detail = getattr(error, "stderr", None)
if isinstance(detail, bytes):
detail = detail.decode("utf-8", errors="replace")
message = detail.strip() if isinstance(detail, str) and detail.strip() else str(error)
raise InventoryError(f"could not resolve the selected Git changes: {message}") from error

return write_inventory(output, sorted(set(rows)))


def write_inventory(output: Path, rows: list[bytes]) -> int:
"""Replace a complete inventory atomically, keeping failures from corrupting the old one."""
output.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
Expand All @@ -116,13 +198,32 @@ def main() -> None:
parser.add_argument("--repo", required=True, help="Repository root.")
parser.add_argument("--scope", required=True, help="File or directory within the repository.")
parser.add_argument("--out", required=True, help="Destination for the file inventory.")
parser.add_argument("--diff-base", help="Authoritative Git base for a changed-file inventory.")
parser.add_argument("--diff-head", default="HEAD", help="Authoritative Git head revision.")
parser.add_argument(
"--diff-mode",
choices=("revisions", "local-patch"),
default="revisions",
help="Use committed revisions or the current staged and unstaged patch.",
)
args = parser.parse_args()

try:
repository = resolve_repository(args.repo)
scope = resolve_scope(repository, args.scope)
output = resolve_output(args.out)
count = generate_in_scope_files(repository, scope, output)
if args.diff_base is None:
count = generate_in_scope_files(repository, scope, output)
elif scope not in (".", "./"):
raise InventoryError("--scope: diff scans must use the repository root")
else:
count = generate_diff_in_scope_files(
repository,
args.diff_base,
args.diff_head,
args.diff_mode,
output,
)
except (OSError, ValueError) as error:
print(f"generate_in_scope_files: {error}", file=sys.stderr)
raise SystemExit(2) from error
Expand Down
22 changes: 20 additions & 2 deletions sdk/typescript/_bundled_plugin/scripts/normalize_candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def normalize_locations(
)


def read_scope(path: Path, repo_root: Path) -> set[str]:
def read_scope(path: Path, repo_root: Path, *, allow_missing: bool = False) -> set[str]:
contents = path.read_bytes().decode("utf-8")
lines = contents.split("\n")
listed_rows = set(lines)
Expand Down Expand Up @@ -183,6 +183,19 @@ def is_scope_file(value: str) -> bool:
try:
relative, _ = relative_file(line, repo_root)
except (OSError, ValueError) as error:
if allow_missing and isinstance(error, FileNotFoundError):
candidate = PurePosixPath(line)
if candidate.is_absolute() or ".." in candidate.parts or "\0" in line:
raise ValueError(f"in-scope file row {number}: unsafe deleted path") from error
resolved = (repo_root / line).resolve(strict=False)
try:
relative = resolved.relative_to(repo_root).as_posix()
except ValueError as escaped:
raise ValueError(
f"in-scope file row {number}: path escapes repository"
) from escaped
scope.add(relative)
continue
raise ValueError(f"in-scope file row {number}: {error}") from error
scope.add(relative)
return scope
Expand Down Expand Up @@ -261,6 +274,11 @@ def main() -> None:
parser.add_argument("--out", required=True, help="Combined candidate JSONL output.")
parser.add_argument("--repo-root", required=True, help="Repository root for candidate paths.")
parser.add_argument("--in-scope-files", required=True, help="Repository-relative file list.")
parser.add_argument(
"--allow-missing-in-scope",
action="store_true",
help="Keep deleted Git paths in a diff inventory while validating existing candidate files.",
)
args = parser.parse_args()
try:
repo_root = Path(args.repo_root).expanduser().resolve(strict=True)
Expand All @@ -273,7 +291,7 @@ def main() -> None:
raise ValueError("--out: must not also be an input")
if output == scope_path:
raise ValueError("--out: must not replace --in-scope-files")
scope = read_scope(scope_path, repo_root)
scope = read_scope(scope_path, repo_root, allow_missing=args.allow_missing_in_scope)
line_counts: dict[Path, int] = {}
rows: list[dict[str, Any]] = []
for source in inputs:
Expand Down
12 changes: 8 additions & 4 deletions sdk/typescript/_bundled_plugin/skills/finding-discovery/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ Use the shared scan artifact path conventions in `../../references/scan-artifact

Read `../../references/security-guidance.md` and resolve the applicable policy before inspecting each source file. A delegated file-review worker must do the same before reading its assigned source.

### Compact Diff Workflow

When a running diff scan already supplies its file inventory through `list_codex_security_review_items`, review that inventory directly and record all candidates once with `record_codex_security_discovery_candidates`. Do not generate ranked worklists, per-finding ledgers, discovery receipts, or discovery reports. Skip the legacy workflow and artifact requirements below.

### Code Diff Workflow
If the scan target is for a targeted code-diff:
For a targeted code diff without an existing compact inventory:

- Read `../security-scan/references/scan-artifacts-and-ledger.md`.
- Generate `rank_input.jsonl` deterministically from changed source-like files with `<python_command> <plugin_dir>/scripts/generate_rank_input.py make-diff-rank-input --repo <repo_root> --base <base> --mode revisions --head <head> --out <discovery_dir>/rank_input.jsonl` for PR, commit, and branch diffs, or `<python_command> <plugin_dir>/scripts/generate_rank_input.py make-diff-rank-input --repo <repo_root> --base <base> --mode local-patch --out <discovery_dir>/rank_input.jsonl` for a local patch.
Expand Down Expand Up @@ -137,7 +141,7 @@ Otherwise, for each candidate include:
- taxonomy with CWE IDs when known
- enough evidence that a later reviewer can understand why the candidate is technically plausible before validation

For diff-scoped discovery, when candidates are emitted, create the per-finding directory from `../../references/scan-artifacts.md` and append one discovery receipt to that finding's candidate ledger. The ledger row should identify the candidate, scan scope, discovery status, affected locations, and the discovery artifact or evidence that produced it.
For legacy diff-scoped discovery without a compact inventory, when candidates are emitted, create the per-finding directory from `../../references/scan-artifacts.md` and append one discovery receipt to that finding's candidate ledger. The ledger row should identify the candidate, scan scope, discovery status, affected locations, and the discovery artifact or evidence that produced it.


## Hard Rules
Expand All @@ -146,8 +150,8 @@ For diff-scoped discovery, when candidates are emitted, create the per-finding d
- Focus on the actual changes, not the commit message.
- Stay anchored to the diff and the files it relies on for diff-scoped scans.
- Candidate discovery is about plausibility, not final severity.
- For diff-scoped discovery, do not emit an untracked candidate. Every candidate finding needs a stable candidate id and a discovery receipt in its candidate-ledger path from `../../references/scan-artifacts.md` so later validation and attack-path analysis can prove coverage for that exact finding.
- For legacy diff-scoped discovery without a compact inventory, do not emit an untracked candidate. Every candidate finding needs a stable candidate id and a discovery receipt in its candidate-ledger path from `../../references/scan-artifacts.md` so later validation and attack-path analysis can prove coverage for that exact finding.
- Do not add `relevant_lines` when no bug exists. For diff-scoped scans, add `relevant_lines` only when the bug overlaps the diff and those lines are relevant to the bug.
- Do not turn discovery into full validation or full severity calibration.
- Continue reviewing until no additional distinct plausible candidates remain.
- For diff-scoped discovery, save a final visible report using the finding discovery report path from `../../references/scan-artifacts.md`.
- For legacy diff-scoped discovery without a compact inventory, save a final visible report using the finding discovery report path from `../../references/scan-artifacts.md`.
Loading
Loading