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
153 changes: 150 additions & 3 deletions sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
import os
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -67,8 +68,23 @@ 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", "--glob", "!.git/**", "--", scope]
"""Atomically inventory visible files and ignored files tracked by Git."""
command = [
"rg",

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 Disable ripgrep configuration when building inventories

When RIPGREP_CONFIG_PATH names a config containing --follow, an untrusted checkout's symlink to a regular file outside the repository is emitted into this inventory and can expose that host file to scan workers. This reproduces independently of the earlier Git-supplement issue: the entry comes directly from ripgrep because its inherited config remains enabled. The inspected rg --help states that --follow traverses symbolic links and that --no-config prevents reading RIPGREP_CONFIG_PATH; add the latter so user configuration cannot alter this security boundary.

Useful? React with 👍 / 👎.

"--no-config",
"--files",
"--hidden",
"--no-require-git",

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 Confine ignore discovery to the requested repository

When a non-Git snapshot is located beneath an unrelated parent .gitignore, that parent can silently remove files from this inventory; for example, a parent rule snapshot/hidden.py causes --repo snapshot --scope . to record only the other files. This is distinct from the earlier non-Git issue about honoring the snapshot's own ignore file: the inspected rg --help states that ripgrep ascends parent directories for ignore rules by default, so content outside --repo is controlling scan coverage. Pass --no-ignore-parent so only ignore rules within the requested repository affect the inventory.

Useful? React with 👍 / 👎.

"--no-ignore-parent",

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 Preserve repository excludes for nested scopes

When a scoped scan targets a subdirectory, this flag also prevents ripgrep from applying the repository's .git/info/exclude, while the manual recovery below restores only .gitignore, .ignore, and .rgignore. I reproduced a sub/hidden.py that git check-ignore -v attributes to .git/info/exclude: a whole-repository inventory omitted it, but --scope sub included it and exited successfully. The inspected rg --help confirms that --no-ignore-parent suppresses applicable parent-directory rules, so preserve repository-local Git excludes explicitly or private ignored files can be sent to scan workers.

Useful? React with 👍 / 👎.

"--no-ignore-global",
Comment on lines +75 to +79

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 Security: Disable automatic loading of symlinked ignore files

On Unix, when a contributor can commit a symlinked .ignore, .rgignore, or .gitignore and an operator starts Deep Scan, ripgrep still auto-loads it: --no-config and the is_symlink() check do not disable automatic ignore discovery. A tracked .ignore -> /dev/zero made this helper exceed a 2-second timeout, while rg reached approximately 96 MB RSS after 0.5 seconds; the parent --no-ignore behavior returned immediately. Disable automatic discovery and load only contained regular ignore files, with a subprocess resource bound as defense in depth.

Useful? React with 👍 / 👎.

"--glob",
"!.git/**",
]
for name in (".gitignore", ".ignore", ".rgignore"):
ignore = repository / name
if ignore.is_file() and not ignore.is_symlink():
command.extend(["--ignore-file", str(ignore)])
Comment on lines +83 to +86

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 Disable automatic loading of symlinked ignore files

When a non-Git snapshot contains a root .gitignore, .ignore, or .rgignore symlink, this check prevents only the explicit --ignore-file argument; ripgrep still discovers and follows the symlink automatically. In a reproduced snapshot where .ignore pointed outside the target to a file containing hidden.py, the helper silently omitted the in-scope hidden.py, so outside-target filesystem contents can control scan coverage despite the apparent symlink guard. Disable automatic loading for these files and explicitly supply only validated regular ignore files, including any supported nested rules, or reject symlinked ignore files.

Useful? React with 👍 / 👎.

command.extend(["--", scope])
with tempfile.TemporaryFile(mode="w+b") as inventory:
try:
result = subprocess.run(
Expand All @@ -89,7 +105,138 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int:
raise InventoryError(message)

inventory.seek(0)
rows = sorted(inventory)
rows = set(inventory)

environment = os.environ.copy()
for name in (
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_DIR",
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
"GIT_INDEX_FILE",
"GIT_NAMESPACE",
"GIT_OBJECT_DIRECTORY",
"GIT_WORK_TREE",
):
environment.pop(name, None)
environment["GIT_LITERAL_PATHSPECS"] = "1"

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 Clear incompatible Git pathspec environment flags

When the scanner inherits GIT_GLOB_PATHSPECS=1 or GIT_ICASE_PATHSPECS=1, forcing GIT_LITERAL_PATHSPECS=1 leaves mutually incompatible global pathspec modes enabled. Git then aborts every repository inventory at ls-files with fatal: global 'literal' pathspec setting is incompatible with all other global pathspec settings, even for scope .. Remove the incompatible pathspec environment variables before selecting literal semantics so a user's Git environment cannot prevent scans from starting.

Useful? React with 👍 / 👎.

environment["LC_ALL"] = "C"
git = [
"git",
"-c",
"core.fsmonitor=false",
"-c",
f"core.excludesFile={os.devnull}",
"--literal-pathspecs",
]
try:
worktree = subprocess.run(
[*git, "rev-parse", "--is-inside-work-tree"],
Comment on lines +134 to +135

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 Require the Git top level to equal --repo

When a selected non-Git snapshot is nested beneath an unrelated Git working tree, this probe returns true and the later git ls-files --others --exclude-standard query applies the ancestor repository's exclusions; the local git ls-files -h describes that flag as adding the standard Git exclusions. I reproduced an ancestor .gitignore rule for snapshot/hidden.py causing that file to be removed by the allowed intersection even though ripgrep now uses --no-ignore-parent. Fresh evidence beyond the earlier ripgrep parent-ignore report is that this later Git classification reintroduces the ancestor rule; verify rev-parse --show-toplevel equals repository before enabling Git filtering, as the classification in workbench_target.py already does.

Useful? React with 👍 / 👎.

cwd=repository,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
check=False,
Comment on lines +134 to +140

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 Bound Git probes against blocking config includes

When the selected repository's local .git/config includes a FIFO or another blocking file, this newly added Git probe waits indefinitely because it has no timeout or resource bound. For example, an [include] path = /path/to/fifo entry causes the exact rev-parse invocation here to block before producing output, so setup for a scan of repository metadata that is not trusted never completes. Run these Git inspections with a finite timeout or otherwise prevent blocking config includes from stalling inventory generation.

Useful? React with 👍 / 👎.

)
except OSError as error:
if (repository / ".git").exists():
raise InventoryError(f"could not inspect Git worktree: {error}") from error
worktree = None

if worktree is not None and worktree.returncode:
detail = worktree.stderr.decode("utf-8", errors="replace").strip()
if worktree.returncode == 128 and "not a git repository" in detail.lower():
worktree = None
Comment on lines +149 to +150

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 Make non-repository detection locale independent

On hosts with Git translation catalogs and a non-English LC_MESSAGES, git rev-parse localizes this fatal diagnostic, so a supported non-Git directory snapshot no longer matches the English substring and the helper exits with an inventory error instead of scanning it. Force a stable locale for the Git subprocesses or distinguish the non-repository result without parsing localized stderr.

Useful? React with 👍 / 👎.

else:
message = f"git rev-parse exited with status {worktree.returncode}"
if detail:
message = f"{message}: {detail}"
raise InventoryError(message)

if worktree is not None and worktree.stdout.strip() == b"true":
prefix = b"./" if scope == "." or scope.startswith("./") else b""
listed: list[bytes] = []
for arguments in (["--cached"], ["--others", "--exclude-standard"]):

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 Keep global Git excludes out of the inventory filter

When the scanning account configures core.excludesFile with a rule matching an untracked source file, git ls-files --others --exclude-standard omits that file and the allowed intersection later deletes it from ripgrep's output, even though ripgrep was deliberately invoked with --no-ignore-global. For example, a global *.py rule makes an untracked source.py disappear while the helper succeeds with incomplete coverage; the inspected git ls-files -h describes this option as adding the standard Git exclusions. Disable the global excludes source for this Git query while retaining repository-local ignore and .git/info/exclude behavior.

Useful? React with 👍 / 👎.

try:
result = subprocess.run(
[*git, "ls-files", *arguments, "-z", "--", scope],

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 Preserve scopes below embedded Git worktrees

When --scope names a directory below an embedded Git worktree, such as nested/src, this outer-repository git ls-files ... -- nested/src query returns neither the descendant files nor the nested/ boundary entry that is returned for a whole-repository query. Consequently allowed and nested_worktrees are empty and the later intersection removes every file found by ripgrep, producing a successful zero-file scoped scan. Detect the enclosing nested-worktree boundary independently of the descendant pathspec or enumerate the nested worktree for such scopes.

Useful? React with 👍 / 👎.

cwd=repository,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
check=False,
)
except OSError as error:
raise InventoryError(f"could not list repository files: {error}") from error
if result.returncode:
detail = result.stderr.decode("utf-8", errors="replace").strip()
message = f"git ls-files exited with status {result.returncode}"
if detail:
message = f"{message}: {detail}"
raise InventoryError(message)
listed.append(result.stdout)

def normalized(path: bytes) -> bytes:
return path.replace(b"\\", b"/") if os.name == "nt" else path

allowed = {
normalized(prefix + relative)
for collection in listed
for relative in collection.split(b"\0")
if relative
}
nested_worktrees = tuple(path for path in allowed if path.endswith(b"/"))
explicitly_ignored = False
if scope not in (".", "./"):
ignored_environment = environment.copy()
ignored_environment.pop("GIT_LITERAL_PATHSPECS", None)
explicit_path = scope if scope.startswith("./") else f"./{scope}"
try:
ignored = subprocess.run(
[*git[:-1], "check-ignore", "--quiet", "--no-index", "--", explicit_path],
cwd=repository,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=ignored_environment,
check=False,
)
except OSError as error:
raise InventoryError(f"could not inspect scoped Git ignores: {error}") from error
if ignored.returncode not in (0, 1):
detail = ignored.stderr.decode("utf-8", errors="replace").strip()
message = f"git check-ignore exited with status {ignored.returncode}"
if detail:
message = f"{message}: {detail}"
raise InventoryError(message)
explicitly_ignored = ignored.returncode == 0

if not explicitly_ignored:
rows = {
row
for row in rows
if (path := normalized(row.rstrip(b"\r\n"))) in allowed

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 Strip only the record newline from ripgrep paths

On Unix, an untracked visible file whose name ends with a carriage return is silently removed because rstrip(b"\r\n") strips both the output delimiter and the carriage return that belongs to the filename, so the resulting key no longer matches the NUL-delimited git ls-files entry. I reproduced a repository containing only visible\r: ripgrep emitted ./visible\r\n, Git emitted visible\r\0, and the helper reported zero files. This filename form is intentionally handled by normalize_candidates.py::read_scope, so remove only the actual line delimiter rather than all trailing CR/LF bytes.

Useful? React with 👍 / 👎.

or any(path.startswith(worktree) for worktree in nested_worktrees)
}
Comment on lines +218 to +220

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 Preserve scopes that traverse in-repository symlinks

When a relative scope is a symlink to a directory inside the repository, ripgrep emits paths using the requested alias but git ls-files reports only the canonical target paths (or the symlink itself), so this intersection removes every descendant. I reproduced a tracked d/a with linkdir -> d: --scope linkdir previously inventoried linkdir/a but now succeeds with zero files. Either reject symlinked directory scopes during resolution or reconcile the Git results using the resolved scope so an accepted scoped scan cannot silently become empty.

Useful? React with 👍 / 👎.

recorded = {normalized(row.rstrip(b"\r\n")) for row in rows}

for relative in listed[0].split(b"\0"):
if not relative:
continue
candidate = repository / os.fsdecode(relative)
if candidate.is_symlink() or not candidate.is_file():
continue
Comment on lines +226 to +228

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 Security: Recurse into ignored tracked submodules

When a whole-target Deep Scan contains an initialized submodule matched by an ignore rule, recurse into the submodule instead of dropping its gitlink. In a fixture where the superproject tracks module and ignores module/, git ls-files --ignored returns only module; candidate.is_file() is false, so module/security.py disappears while setup succeeds. Workers receive this truncated inventory, which can still back deep_repository coverage. Fresh evidence beyond the prior tracked-file reports is that this file is tracked in the nested submodule index, which the root-only recovery never queries. Enumerate initialized submodule indexes with the same containment checks.

Useful? React with 👍 / 👎.

try:
candidate.resolve(strict=True).relative_to(repository)
except (OSError, ValueError):
continue
relative_path = prefix + relative
key = normalized(relative_path)
if key not in recorded:
rows.add(relative_path + b"\n")
recorded.add(key)

rows = sorted(rows)

output.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
Expand Down
3 changes: 2 additions & 1 deletion sdk/typescript/tests-ts/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ describe("plugin runtime preparation", () => {
join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"),
"utf8",
);
expect(generator).toContain('"--no-ignore"');
expect(generator).not.toContain('"--no-ignore"');
expect(generator).toContain('"--cached"');
return;
}

Expand Down
Loading
Loading