From 320b1a59dc3f175b94f5bcae24e0206235377c68 Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 9 Sep 2026 13:34:29 +0800 Subject: [PATCH 1/7] feat(ci): acquire exact pull heads as data --- scripts/repo_sentinel_acquire.py | 244 +++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 scripts/repo_sentinel_acquire.py diff --git a/scripts/repo_sentinel_acquire.py b/scripts/repo_sentinel_acquire.py new file mode 100644 index 0000000..314da36 --- /dev/null +++ b/scripts/repo_sentinel_acquire.py @@ -0,0 +1,244 @@ +"""Acquire one pull-request head as data in a fresh verifier-owned Git database.""" + +from __future__ import annotations + +import math +import os +import re +import stat +import subprocess +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.parse import urlsplit + +from repo_sentinel_reader import ReaderLimits, Snapshot, read_snapshot + + +class AcquisitionRefused(ValueError): + """Acquisition failed; the message is a fixed code without remote or path data.""" + + +@dataclass(frozen=True) +class AcquisitionLimits: + timeout_seconds: float = 60.0 + max_repository_bytes: int = 64 * 1024 * 1024 + + def __post_init__(self) -> None: + if ( + type(self.timeout_seconds) not in (int, float) + or not math.isfinite(self.timeout_seconds) + or self.timeout_seconds <= 0 + or type(self.max_repository_bytes) is not int + or self.max_repository_bytes <= 0 + ): + raise AcquisitionRefused("invalid_limits") + + +@dataclass(frozen=True) +class AcquiredSnapshot: + snapshot: Snapshot + source_ref: str + repository: Path = field(repr=False) + + +def _regular_directory(path: Path) -> None: + info = path.lstat() + reparse = getattr(info, "st_file_attributes", 0) & getattr( + stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0 + ) + if reparse or not stat.S_ISDIR(info.st_mode): + raise AcquisitionRefused("unsafe_scratch_root") + + +def _remote_argument(remote: str | Path) -> str: + if isinstance(remote, Path): + try: + resolved = remote.resolve(strict=True) + except OSError: + raise AcquisitionRefused("invalid_remote") from None + if not resolved.is_dir(): + raise AcquisitionRefused("invalid_remote") + return resolved.as_uri() + if type(remote) is not str or re.search(r"[\x00-\x20\x7f]", remote): + raise AcquisitionRefused("invalid_remote") + try: + parsed = urlsplit(remote) + except ValueError: + raise AcquisitionRefused("invalid_remote") from None + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise AcquisitionRefused("invalid_remote") + return remote + + +def _environment() -> dict[str, str]: + environment = { + key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_") + } + environment.update( + GIT_CONFIG_NOSYSTEM="1", + GIT_CONFIG_GLOBAL=os.devnull, + GIT_TERMINAL_PROMPT="0", + GIT_NO_LAZY_FETCH="1", + ) + return environment + + +def _git_arguments(*arguments: str) -> list[str]: + return [ + "git", + "--no-replace-objects", + "-c", "protocol.allow=never", + "-c", "protocol.https.allow=always", + "-c", "protocol.file.allow=always", + "-c", "http.followRedirects=false", + "-c", "gc.auto=0", + "-c", "maintenance.auto=false", + *arguments, + ] + + +def _run( + cwd: Path, + arguments: list[str], + deadline: float, + refusal: str, + *, + capture: bool = False, +) -> bytes: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AcquisitionRefused("time_limit") + try: + result = subprocess.run( + arguments, + cwd=cwd, + env=_environment(), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE if capture else subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=remaining, + check=False, + ) + except subprocess.TimeoutExpired: + raise AcquisitionRefused("time_limit") from None + except OSError: + raise AcquisitionRefused("git_unavailable") from None + if result.returncode: + raise AcquisitionRefused(refusal) + output = result.stdout or b"" + if len(output) > 256: + raise AcquisitionRefused("invalid_git_output") + return output + + +def _repository_size(root: Path, limit: int) -> int: + total = 0 + try: + for directory, names, files in os.walk(root, followlinks=False): + base = Path(directory) + entries = [*((name, True) for name in names), *((name, False) for name in files)] + for name, directory_entry in entries: + info = (base / name).lstat() + reparse = getattr(info, "st_file_attributes", 0) & getattr( + stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0 + ) + expected = stat.S_ISDIR if directory_entry else stat.S_ISREG + if reparse or not expected(info.st_mode): + raise AcquisitionRefused("unsafe_git_database") + if not directory_entry: + total += info.st_size + if total > limit: + return total + except OSError: + raise AcquisitionRefused("filesystem_io_failed") from None + return total + + +@contextmanager +def acquire_pull_snapshot( + remote: str | Path, + pull_number: int, + expected_head_oid: str, + scratch_root: Path, + *, + acquisition_limits: AcquisitionLimits = AcquisitionLimits(), + reader_limits: ReaderLimits = ReaderLimits(), +) -> Iterator[AcquiredSnapshot]: + """Yield a verified pull head and fresh database, then remove the database.""" + if type(pull_number) is not int or not 0 < pull_number <= 2_147_483_647: + raise AcquisitionRefused("invalid_pull_number") + if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", expected_head_oid): + raise AcquisitionRefused("invalid_head_oid") + algorithm = "sha1" if len(expected_head_oid) == 40 else "sha256" + remote_argument = _remote_argument(remote) + deadline = time.monotonic() + acquisition_limits.timeout_seconds + source_ref = f"refs/pull/{pull_number}/head" + target_ref = "refs/repo-sentinel/acquired-head" + temporary: TemporaryDirectory[str] | None = None + try: + try: + _regular_directory(scratch_root) + temporary = TemporaryDirectory( + prefix="repo-sentinel-acquire-", dir=scratch_root.resolve() + ) + root = Path(temporary.name) + template = root / "empty-template" + template.mkdir(mode=0o700) + repository = root / "objects.git" + _run( + root, + _git_arguments( + "init", "--bare", "--quiet", f"--object-format={algorithm}", + f"--template={template}", str(repository), + ), + deadline, + "init_failed", + ) + _run( + repository, + _git_arguments( + "fetch", "--quiet", "--depth=1", "--no-tags", + "--no-recurse-submodules", "--no-write-fetch-head", "--", + remote_argument, f"+{source_ref}:{target_ref}", + ), + deadline, + "fetch_failed", + ) + if ( + _repository_size(repository, acquisition_limits.max_repository_bytes) + > acquisition_limits.max_repository_bytes + ): + raise AcquisitionRefused("repository_byte_limit") + refs = _run( + repository, + _git_arguments( + "for-each-ref", "--format=%(refname)%00%(objectname)", "refs/" + ), + deadline, + "head_unavailable", + capture=True, + ) + expected = f"{target_ref}\0{expected_head_oid}\n".encode("ascii") + if refs != expected: + raise AcquisitionRefused("head_mismatch") + snapshot = read_snapshot(repository, expected_head_oid, limits=reader_limits) + except OSError: + raise AcquisitionRefused("filesystem_io_failed") from None + yield AcquiredSnapshot(snapshot, source_ref, repository) + finally: + if temporary is not None: + try: + temporary.cleanup() + except OSError: + raise AcquisitionRefused("cleanup_failed") from None From bfc404d318c4be51924a17f6b6e1ced219244e3b Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 9 Sep 2026 13:34:54 +0800 Subject: [PATCH 2/7] test(ci): verify pull acquisition refusal boundaries --- tests/test_repo_sentinel_acquire.py | 239 ++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/test_repo_sentinel_acquire.py diff --git a/tests/test_repo_sentinel_acquire.py b/tests/test_repo_sentinel_acquire.py new file mode 100644 index 0000000..f8b395c --- /dev/null +++ b/tests/test_repo_sentinel_acquire.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import subprocess +import sys +import time +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import repo_sentinel_acquire as acquisition # noqa: E402 +from repo_sentinel_reader import ReaderLimits, ReaderRefused # noqa: E402 + + +class AcquisitionTests(unittest.TestCase): + def repository(self, algorithm: str = "sha1") -> tuple[Path, Path]: + temporary = TemporaryDirectory(prefix="acquisition-contract-") + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + remote = root / "remote.git" + scratch = root / "scratch" + scratch.mkdir() + self.git(root, "init", "--bare", "--quiet", f"--object-format={algorithm}", remote) + return remote, scratch + + def git( + self, root: Path, *args: str | Path, data: bytes | None = None + ) -> bytes: + return subprocess.run( + ["git", *(str(arg) for arg in args)], + cwd=root, + input=data, + capture_output=True, + check=True, + ).stdout.strip() + + def object(self, remote: Path, kind: str, body: bytes) -> str: + return self.git( + remote, "hash-object", "--literally", "-w", "-t", kind, "--stdin", data=body + ).decode() + + def tree(self, remote: Path, entries: list[tuple[bytes, bytes, str]]) -> str: + raw = b"".join( + mode + b" " + name + b"\0" + bytes.fromhex(oid) + for mode, name, oid in entries + ) + return self.object(remote, "tree", raw) + + def commit(self, remote: Path, tree: str, label: str = "fixture") -> str: + body = ( + f"tree {tree}\nauthor Fixture 0 +0000\n" + f"committer Fixture 0 +0000\n\n{label}\n" + ) + return self.object(remote, "commit", body.encode()) + + def fixture(self, algorithm: str = "sha1") -> tuple[Path, Path, str]: + remote, scratch = self.repository(algorithm) + trap = self.object(remote, "blob", b"raise RuntimeError('must remain data')\n") + evidence = self.object(remote, "blob", b"evidence\0bytes\r\n") + child = self.tree(remote, [(b"100755", b"trap.py", trap)]) + tree = self.tree( + remote, + [(b"100644", b"evidence.bin", evidence), (b"40000", b"nested", child)], + ) + head = self.commit(remote, tree) + self.git(remote, "update-ref", "refs/pull/7/head", head) + return remote, scratch, head + + def acquire( + self, + remote: Path, + scratch: Path, + head: str, + **kwargs: object, + ): + return acquisition.acquire_pull_snapshot(remote, 7, head, scratch, **kwargs) + + def test_exact_head_is_read_as_data_in_both_object_formats(self) -> None: + for algorithm in ("sha1", "sha256"): + with self.subTest(algorithm=algorithm): + remote, scratch, head = self.fixture(algorithm) + marker = scratch.parent / "target-ran" + with self.acquire(remote, scratch, head) as result: + database = result.repository + self.assertTrue(database.is_dir()) + self.assertEqual(result.source_ref, "refs/pull/7/head") + self.assertEqual(result.snapshot.commit_oid, head) + self.assertEqual( + [item.path for item in result.snapshot.files], + ["evidence.bin", "nested/trap.py"], + ) + self.assertEqual(result.snapshot.files[0].data, b"evidence\0bytes\r\n") + refs = self.git( + database, "for-each-ref", "--format=%(refname)", "refs/" + ).decode() + self.assertEqual(refs, "refs/repo-sentinel/acquired-head") + self.assertNotIn(str(scratch), repr(result)) + self.assertFalse(marker.exists()) + self.assertFalse(database.exists()) + self.assertEqual(list(scratch.iterdir()), []) + + def test_unrequested_refs_are_not_imported(self) -> None: + remote, scratch, head = self.fixture() + extra = self.commit(remote, self.tree(remote, []), "unrequested") + self.git(remote, "update-ref", "refs/heads/unrequested", extra) + self.git(remote, "update-ref", "refs/tags/unrequested", extra) + with self.acquire(remote, scratch, head) as result: + refs = self.git( + result.repository, "for-each-ref", "--format=%(refname)", "refs/" + ).decode() + self.assertEqual(refs, "refs/repo-sentinel/acquired-head") + objects = self.git( + result.repository, "cat-file", "--batch-check", data=(extra + "\n").encode() + ) + self.assertEqual(objects.decode(), f"{extra} missing") + + def test_moved_ref_and_wrong_expected_head_are_refused(self) -> None: + remote, scratch, original = self.fixture() + moved = self.commit(remote, self.tree(remote, []), "moved") + self.git(remote, "update-ref", "refs/pull/7/head", moved) + with self.assertRaisesRegex(acquisition.AcquisitionRefused, "^head_mismatch$"): + with self.acquire(remote, scratch, original): + self.fail("moved ref reached the reader") + self.assertEqual(list(scratch.iterdir()), []) + + def test_missing_pull_ref_is_a_sanitized_fetch_refusal(self) -> None: + remote, scratch, head = self.fixture() + with self.assertRaisesRegex(acquisition.AcquisitionRefused, "^fetch_failed$"): + with acquisition.acquire_pull_snapshot(remote, 8, head, scratch): + self.fail("missing ref reached the reader") + self.assertEqual(list(scratch.iterdir()), []) + + def test_reader_refusal_creates_no_surviving_database(self) -> None: + remote, scratch, head = self.fixture() + with self.assertRaises(ReaderRefused): + with self.acquire( + remote, scratch, head, reader_limits=ReaderLimits(max_files=1) + ): + self.fail("incomplete snapshot reached the consumer") + self.assertEqual(list(scratch.iterdir()), []) + + def test_repository_byte_limit_refuses_and_cleans_up(self) -> None: + remote, scratch, head = self.fixture() + limits = acquisition.AcquisitionLimits(max_repository_bytes=1) + with self.assertRaisesRegex( + acquisition.AcquisitionRefused, "^repository_byte_limit$" + ): + with self.acquire(remote, scratch, head, acquisition_limits=limits): + self.fail("over-budget database reached the reader") + self.assertEqual(list(scratch.iterdir()), []) + + def test_timeout_kills_fetch_and_cleans_up(self) -> None: + remote, scratch, head = self.fixture() + original = subprocess.Popen + + def stall_fetch(args: list[str], **kwargs: object) -> subprocess.Popen: + if "fetch" in args: + args = [sys.executable, "-c", "import time; time.sleep(30)"] + return original(args, **kwargs) + + started = time.monotonic() + with patch("repo_sentinel_acquire.subprocess.Popen", side_effect=stall_fetch): + with self.assertRaisesRegex(acquisition.AcquisitionRefused, "^time_limit$"): + with self.acquire( + remote, + scratch, + head, + acquisition_limits=acquisition.AcquisitionLimits(timeout_seconds=0.1), + ): + self.fail("timed-out fetch reached the reader") + self.assertLess(time.monotonic() - started, 5) + self.assertEqual(list(scratch.iterdir()), []) + + def test_consumer_exception_is_preserved_and_database_is_removed(self) -> None: + remote, scratch, head = self.fixture() + with self.assertRaisesRegex(RuntimeError, "consumer failed"): + with self.acquire(remote, scratch, head) as result: + database = result.repository + raise RuntimeError("consumer failed") + self.assertFalse(database.exists()) + self.assertEqual(list(scratch.iterdir()), []) + + def test_invalid_identifiers_and_remote_forms_are_refused(self) -> None: + remote, scratch, head = self.fixture() + for number in (0, -1, True, "7", 2_147_483_648): + with self.subTest(number=number), self.assertRaisesRegex( + acquisition.AcquisitionRefused, "^invalid_pull_number$" + ): + with acquisition.acquire_pull_snapshot(remote, number, head, scratch): + self.fail("invalid pull number reached Git") + for oid in (head.upper(), head[:12], "HEAD", "0" * 41, "-" * len(head)): + with self.subTest(oid=oid), self.assertRaisesRegex( + acquisition.AcquisitionRefused, "^invalid_head_oid$" + ): + with acquisition.acquire_pull_snapshot(remote, 7, oid, scratch): + self.fail("invalid OID reached Git") + for value in ( + "http://example.com/repo.git", + "ssh://example.com/repo.git", + "https://user:secret@example.com/repo.git", + "https://example.com/repo.git?ref=x", + "https://example.com/repo.git#fragment", + "https://example.com/repo git", + "-upload-pack=trap", + ): + with self.subTest(remote=value), self.assertRaisesRegex( + acquisition.AcquisitionRefused, "^invalid_remote$" + ): + with acquisition.acquire_pull_snapshot(value, 7, head, scratch): + self.fail("invalid remote reached Git") + + def test_scratch_symlink_is_refused_without_touching_target(self) -> None: + remote, scratch, head = self.fixture() + alias = scratch.parent / "alias" + try: + alias.symlink_to(scratch, target_is_directory=True) + except OSError: + self.skipTest("directory symlink creation unavailable") + with self.assertRaisesRegex( + acquisition.AcquisitionRefused, "^unsafe_scratch_root$" + ): + with self.acquire(remote, alias, head): + self.fail("scratch alias reached Git") + self.assertEqual(list(scratch.iterdir()), []) + + def test_invalid_limits_are_refused(self) -> None: + for value in (0, -1, float("nan"), float("inf"), True, "60", None): + with self.subTest(value=value), self.assertRaisesRegex( + acquisition.AcquisitionRefused, "^invalid_limits$" + ): + acquisition.AcquisitionLimits(timeout_seconds=value) + with self.assertRaisesRegex(acquisition.AcquisitionRefused, "^invalid_limits$"): + acquisition.AcquisitionLimits(max_repository_bytes=True) + + +if __name__ == "__main__": + unittest.main() From b0cbe8054b88d649a2cda387b7691eb58b02456b Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 9 Sep 2026 13:36:04 +0800 Subject: [PATCH 3/7] docs(ci): define exact pull acquisition boundary --- docs/repo-sentinel-baseline-review.md | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/repo-sentinel-baseline-review.md b/docs/repo-sentinel-baseline-review.md index 5586a1e..6adf1de 100644 --- a/docs/repo-sentinel-baseline-review.md +++ b/docs/repo-sentinel-baseline-review.md @@ -321,6 +321,54 @@ execution, PR-ref acquisition, and workflow activation remain unwired. Rollback restores the prior reader/materializer contract; there is no persisted output format or repository-setting migration. +### Exact Pull-Head Acquisition + +`acquire_pull_snapshot(remote, pull_number, expected_head_oid, scratch_root)` +creates a fresh bare object database below a caller-owned scratch directory. It +fetches only `refs/pull//head` into one private ref, requires that ref to +equal the expected full lowercase SHA-1 or SHA-256 OID, and calls the existing +bounded reader before yielding `AcquiredSnapshot`. No checkout is created. + +Network remotes are restricted to credential-free HTTPS URLs without query or +fragment data. A `Path` remote exists only for caller-controlled local fixtures. +Git runs with inherited `GIT_*` variables and global/system configuration +removed, replacement lookup disabled, redirects disabled, protocol selection +restricted, automatic maintenance disabled, and terminal prompting disabled. +The fetch is depth one, writes no `FETCH_HEAD`, imports no tags or submodules, +and uses an explicit force refspec into the fresh database. + +The only resulting ref must be `refs/repo-sentinel/acquired-head` at the exact +expected OID. A moved or missing PR ref, an unexpected ref set, an object-format +mismatch, fetch failure, or reader refusal cannot yield a snapshot. The reader +then independently validates raw commit, tree and blob identities and admission +limits; acquisition does not replace those checks. + +The default acquisition timeout is 60 seconds for initialization, fetch and ref +verification. The reader retains its separate timeout. A 64 MiB repository-size +check runs after fetch; it makes an over-budget result fail closed but is not a +hard transport or peak-disk quota because Git may exceed it before the fetch +returns. A trusted Git executable and enough scratch capacity for that interval +remain preconditions. + +Normal exit, setup refusal and consumer exceptions remove the fresh database. +Cleanup failure is explicit and may leave residual files for the scratch owner. +Tests cover SHA-1/SHA-256 acquisition, exact raw bytes, unrequested refs, ref +movement, missing refs, input validation, reader refusal, timeout, repository +budget, symlinked scratch input and lifecycle cleanup. + +A read-only HTTPS probe acquired PR #15 head `2f7b7a9` with the expected commit +and tree, then the reader refused `unsupported_path`. Eight of that public tree's +242 regular-file paths contain characters outside the current reader subset, +including ampersands, apostrophes, exclamation marks, an en dash and a curly +apostrophe. This is correct fail-closed propagation and a concrete compatibility +blocker for workflow activation. Path admission needs a separate decision; this +acquisition layer must not rewrite names or convert refusal into a clean result. + +This helper is not connected to a workflow or scanner invocation. It does not +add `pull_request_target`, secrets, caches, Check API writes, permissions or +repository enforcement. Rollback removes the acquisition helper/tests/docs; +the merged reader and materializer remain independently usable. + ## Relationship To Issue #5 This record closed [issue #5](https://github.com/stacknil/sec-writeups-public/issues/5) From 69d552bb606344ca42b74dccd663f02a5caa7fa1 Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 9 Sep 2026 13:41:31 +0800 Subject: [PATCH 4/7] test(ci): preserve fetched path refusal --- tests/test_repo_sentinel_acquire.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_repo_sentinel_acquire.py b/tests/test_repo_sentinel_acquire.py index f8b395c..b991662 100644 --- a/tests/test_repo_sentinel_acquire.py +++ b/tests/test_repo_sentinel_acquire.py @@ -141,6 +141,17 @@ def test_reader_refusal_creates_no_surviving_database(self) -> None: self.fail("incomplete snapshot reached the consumer") self.assertEqual(list(scratch.iterdir()), []) + def test_unsupported_fetched_path_preserves_reader_refusal(self) -> None: + remote, scratch, _ = self.fixture() + blob = self.object(remote, "blob", b"data") + tree = self.tree(remote, [(b"100644", b"Command & Carol.md", blob)]) + head = self.commit(remote, tree, "unsupported path") + self.git(remote, "update-ref", "refs/pull/7/head", head) + with self.assertRaisesRegex(ReaderRefused, "^unsupported_path$"): + with self.acquire(remote, scratch, head): + self.fail("reader refusal became a successful acquisition") + self.assertEqual(list(scratch.iterdir()), []) + def test_repository_byte_limit_refuses_and_cleans_up(self) -> None: remote, scratch, head = self.fixture() limits = acquisition.AcquisitionLimits(max_repository_bytes=1) From 565d9c3e4ad16dee23bfe874817f612b6f25e769 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 11 Sep 2026 12:48:02 +0800 Subject: [PATCH 5/7] test(ci): update acquisition refusal fixture --- tests/test_repo_sentinel_acquire.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_repo_sentinel_acquire.py b/tests/test_repo_sentinel_acquire.py index b991662..730f406 100644 --- a/tests/test_repo_sentinel_acquire.py +++ b/tests/test_repo_sentinel_acquire.py @@ -141,13 +141,13 @@ def test_reader_refusal_creates_no_surviving_database(self) -> None: self.fail("incomplete snapshot reached the consumer") self.assertEqual(list(scratch.iterdir()), []) - def test_unsupported_fetched_path_preserves_reader_refusal(self) -> None: + def test_invalid_utf8_fetched_path_preserves_reader_refusal(self) -> None: remote, scratch, _ = self.fixture() blob = self.object(remote, "blob", b"data") - tree = self.tree(remote, [(b"100644", b"Command & Carol.md", blob)]) - head = self.commit(remote, tree, "unsupported path") + tree = self.tree(remote, [(b"100644", b"invalid-\xff.md", blob)]) + head = self.commit(remote, tree, "invalid UTF-8 path") self.git(remote, "update-ref", "refs/pull/7/head", head) - with self.assertRaisesRegex(ReaderRefused, "^unsupported_path$"): + with self.assertRaisesRegex(ReaderRefused, "^unsupported_path_encoding$"): with self.acquire(remote, scratch, head): self.fail("reader refusal became a successful acquisition") self.assertEqual(list(scratch.iterdir()), []) From 9668476bf82ebb796bf5d0468c60ad008ba6a086 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 11 Sep 2026 12:48:07 +0800 Subject: [PATCH 6/7] docs(ci): record post-path-contract probe --- docs/repo-sentinel-baseline-review.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/repo-sentinel-baseline-review.md b/docs/repo-sentinel-baseline-review.md index 6adf1de..82bccef 100644 --- a/docs/repo-sentinel-baseline-review.md +++ b/docs/repo-sentinel-baseline-review.md @@ -356,13 +356,24 @@ Tests cover SHA-1/SHA-256 acquisition, exact raw bytes, unrequested refs, ref movement, missing refs, input validation, reader refusal, timeout, repository budget, symlinked scratch input and lifecycle cleanup. -A read-only HTTPS probe acquired PR #15 head `2f7b7a9` with the expected commit -and tree, then the reader refused `unsupported_path`. Eight of that public tree's -242 regular-file paths contain characters outside the current reader subset, -including ampersands, apostrophes, exclamation marks, an en dash and a curly -apostrophe. This is correct fail-closed propagation and a concrete compatibility -blocker for workflow activation. Path admission needs a separate decision; this -acquisition layer must not rewrite names or convert refusal into a clean result. +Before the path-contract merge, a read-only HTTPS probe acquired the exact PR +#15 head `2f7b7a9bef43715141086b0d79bacbe67a178288` and tree +`960dc6c6496f1260f6fab74b64f26408024cd5fb`, then the reader refused +`unsupported_path`. Eight of that public tree's 242 regular-file paths were +outside the former portable ASCII reader subset. That historical result remains +evidence that acquisition propagated downstream reader refusal without +rewriting names, omitting files or yielding a partial snapshot. + +After the path contract merged in PR #17 at +`d8e30ba019247a21b9d42e1c1d52900a1f1de623`, the same exact-head probe traversed +acquisition, the logical-path reader and the portable materializer. All 242 +paths, modes and blob OIDs were preserved with exact file bytes, totalling +2,260,062 bytes. The SHA-256 manifest over each ordered +`path NUL mode NUL oid NUL sha256(data)` record was +`ad39acf89d0826ce2651ed14d12e143d7f0a8b5cb6b2eb219b46895688f45d0a`. +Both the bare acquisition database and materialized target were removed after +their contexts exited. Repository content was handled only as data and was not +executed; acquisition remained checkout-free. This helper is not connected to a workflow or scanner invocation. It does not add `pull_request_target`, secrets, caches, Check API writes, permissions or From b4c45e0ed9b99829027de765ce35dd0c94cbac4f Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 12 Sep 2026 18:29:11 +0800 Subject: [PATCH 7/7] security: disable inherited Git askpass helpers --- docs/repo-sentinel-baseline-review.md | 6 ++- scripts/repo_sentinel_acquire.py | 7 +++- tests/test_repo_sentinel_acquire.py | 57 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/repo-sentinel-baseline-review.md b/docs/repo-sentinel-baseline-review.md index 82bccef..3f39e2d 100644 --- a/docs/repo-sentinel-baseline-review.md +++ b/docs/repo-sentinel-baseline-review.md @@ -332,8 +332,10 @@ bounded reader before yielding `AcquiredSnapshot`. No checkout is created. Network remotes are restricted to credential-free HTTPS URLs without query or fragment data. A `Path` remote exists only for caller-controlled local fixtures. Git runs with inherited `GIT_*` variables and global/system configuration -removed, replacement lookup disabled, redirects disabled, protocol selection -restricted, automatic maintenance disabled, and terminal prompting disabled. +removed. Terminal prompting is disabled, inherited Git/SSH askpass helpers are +neutralized, and `SSH_ASKPASS_REQUIRE` cannot force a parent helper. Replacement +lookup and redirects are disabled, protocol selection is restricted, and +automatic maintenance is disabled. The fetch is depth one, writes no `FETCH_HEAD`, imports no tags or submodules, and uses an explicit force refspec into the fresh database. diff --git a/scripts/repo_sentinel_acquire.py b/scripts/repo_sentinel_acquire.py index 314da36..bae82e9 100644 --- a/scripts/repo_sentinel_acquire.py +++ b/scripts/repo_sentinel_acquire.py @@ -83,12 +83,17 @@ def _remote_argument(remote: str | Path) -> str: def _environment() -> dict[str, str]: environment = { - key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_") + key: value + for key, value in os.environ.items() + if not key.upper().startswith("GIT_") + and key.upper() != "SSH_ASKPASS_REQUIRE" } environment.update( GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, GIT_TERMINAL_PROMPT="0", + GIT_ASKPASS="", + SSH_ASKPASS="", GIT_NO_LAZY_FETCH="1", ) return environment diff --git a/tests/test_repo_sentinel_acquire.py b/tests/test_repo_sentinel_acquire.py index 730f406..34777df 100644 --- a/tests/test_repo_sentinel_acquire.py +++ b/tests/test_repo_sentinel_acquire.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess import sys import time @@ -15,6 +16,23 @@ class AcquisitionTests(unittest.TestCase): + def askpass_helper(self, root: Path, marker: Path) -> Path: + if os.name == "nt": + helper = root / "askpass.cmd" + helper.write_text( + '@echo off\r\necho invoked>>"%ASKPASS_MARKER%"\r\necho dummy\r\n', + encoding="ascii", + ) + else: + helper = root / "askpass.sh" + helper.write_text( + '#!/bin/sh\nprintf "invoked\\n" >> "$ASKPASS_MARKER"\n' + 'printf "dummy\\n"\n', + encoding="ascii", + ) + helper.chmod(0o700) + return helper + def repository(self, algorithm: str = "sha1") -> tuple[Path, Path]: temporary = TemporaryDirectory(prefix="acquisition-contract-") self.addCleanup(temporary.cleanup) @@ -101,6 +119,45 @@ def test_exact_head_is_read_as_data_in_both_object_formats(self) -> None: self.assertFalse(database.exists()) self.assertEqual(list(scratch.iterdir()), []) + def test_inherited_askpass_helpers_cannot_supply_credentials(self) -> None: + with TemporaryDirectory(prefix="askpass-contract-") as directory: + root = Path(directory) + marker = root / "calls.txt" + helper = self.askpass_helper(root, marker) + parent_environment = { + "ASKPASS_MARKER": str(marker), + "GIT_ASKPASS": str(helper), + "SSH_ASKPASS": str(helper), + "SSH_ASKPASS_REQUIRE": "force", + } + with patch.dict(os.environ, parent_environment, clear=False): + environment = acquisition._environment() + result = subprocess.run( + ["git", "-c", "credential.helper=", "credential", "fill"], + cwd=root, + input=b"protocol=https\nhost=example.com\n\n", + env=environment, + capture_output=True, + timeout=10, + check=False, + ) + + calls = ( + marker.read_text(encoding="ascii").splitlines() + if marker.exists() + else [] + ) + self.assertEqual(calls, []) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(b"username=dummy", result.stdout) + self.assertNotIn(b"password=dummy", result.stdout) + self.assertEqual(environment["GIT_TERMINAL_PROMPT"], "0") + self.assertEqual(environment["GIT_ASKPASS"], "") + self.assertEqual(environment["SSH_ASKPASS"], "") + self.assertNotIn( + "SSH_ASKPASS_REQUIRE", {key.upper() for key in environment} + ) + def test_unrequested_refs_are_not_imported(self) -> None: remote, scratch, head = self.fixture() extra = self.commit(remote, self.tree(remote, []), "unrequested")