Original flaw
Earlier flawGit command keyword options could smuggle unsafe dash-prefixed value tokens.
Sink: GitPython unsafe-option checks for fetch, pull, push and clone that compare only keyword option names
Originally written by Santos Gallegos
How AI contributed: Incomplete remediationPublished Jul 24, 2026 in
gitpython-developers/gitpythonAll 8 findings in this repository
The shared option-safety check in git/cmd.py built its candidate tokens from kwarg keys alone, so a string value passed to a single-character keyword could be split by split_single_char_options into a standalone git option and slip past the check while still reaching the command line. The fix expands _option_candidates to include those values and adds tests covering upload-pack and output arguments.
Case facts
Only the highlighted steps are this advisory. The first step is the earlier flaw the AI tried, and failed, to close.
Original flaw
Earlier flawSink: GitPython unsafe-option checks for fetch, pull, push and clone that compare only keyword option names
Originally written by Santos Gallegos
This advisoryCVE-2026-73625 (GHSA-R9MR-M37C-5FR3)
AI tried to fix this
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Did not reject dash-prefixed values associated with otherwise allowed options.
Incomplete AI-assisted fix · ChatGPT/Codex
Fixed again
This is the patch that actually stops the same attack path.
@@ -1071,7 +1071,10 @@ def fetch( Git.check_unsafe_protocols(ref) if not allow_unsafe_options:- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options)+ Git.check_unsafe_options(+ options=Git._option_candidates([], kwargs),+ unsafe_options=self.unsafe_git_fetch_options,+ ) proc = self.repo.git.fetch( "--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs@@ -1125,7 +1128,10 @@ def pull( Git.check_unsafe_protocols(ref) if not allow_unsafe_options:- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options)+ Git.check_unsafe_options(+ options=Git._option_candidates([], kwargs),+ unsafe_options=self.unsafe_git_pull_options,+ ) proc = self.repo.git.pull( "--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs@@ -1198,7 +1204,10 @@ def push( Git.check_unsafe_protocols(ref) if not allow_unsafe_options:- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options)+ Git.check_unsafe_options(+ options=Git._option_candidates([], kwargs),+ unsafe_options=self.unsafe_git_push_options,+ ) proc = self.repo.git.push( "--",@@ -161,6 +161,20 @@ class Repo: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt """ + unsafe_git_archive_options = [+ # Allows arbitrary command execution through the remote git-upload-archive command.+ "--exec",+ # Writes output to a caller-controlled filesystem path.+ "--output",+ "-o",+ ]++ unsafe_git_revision_options = [+ # This option allows output to be written to arbitrary files before revision parsing.+ "--output",+ "-o",+ ]+ # Invariants config_level: ConfigLevels_Tup = ("system", "user", "global", "repository") """Represents the configuration level of a configuration file."""@@ -775,6 +789,7 @@ def iter_commits( self, rev: Union[str, Commit, "SymbolicReference", None] = None, paths: Union[PathLike, Sequence[PathLike]] = "",+ allow_unsafe_options: bool = False, **kwargs: Any, ) -> Iterator[Commit]: """An iterator of :class:`~git.objects.commit.Commit` objects representing the@@ -792,6 +807,9 @@ def iter_commits( Arguments to be passed to :manpage:`git-rev-list(1)`. Common ones are ``max_count`` and ``skip``. + :param allow_unsafe_options:+ Allow unsafe options in the revision argument, like ``--output``.+ :note: To receive only commits between two named revisions, use the ``"revA...revB"`` revision specifier.@@ -802,7 +820,18 @@ def iter_commits( if rev is None: rev = self.head.commit - return Commit.iter_items(self, rev, paths, **kwargs)+ if not allow_unsafe_options:+ Git.check_unsafe_options(+ options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options+ )++ return Commit.iter_items(+ self,+ rev,+ paths,+ allow_unsafe_options=allow_unsafe_options,+ **kwargs,+ ) def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]: R"""Find the closest common ancestor for the given revision@@ -1079,7 +1108,9 @@ def active_branch(self) -> Head: ) return active_branch - def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]:+ def blame_incremental(+ self, rev: str | HEAD | None, file: str, allow_unsafe_options: bool = False, **kwargs: Any+ ) -> Iterator["BlameEntry"]: """Iterator for blame information for the given file at the given revision. Unlike :meth:`blame`, this does not return the actual file's contents, only a@@ -1090,6 +1121,9 @@ def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> uncommitted changes. Otherwise, anything successfully parsed by :manpage:`git-rev-parse(1)` is a valid option. + :param allow_unsafe_options:+ Allow unsafe options in revision argument, like ``--output``.+ :return: Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the commit to blame for the line, and range indicates a span of line numbers in@@ -1098,6 +1132,10 @@ def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> If you combine all line number ranges outputted by this command, you should get a continuous range spanning all line numbers in the file. """+ if not allow_unsafe_options:+ Git.check_unsafe_options(+ options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options+ ) data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs)@@ -86,6 +86,12 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): # INVARIANTS default_encoding = "UTF-8" + # Options to :manpage:`git-rev-list(1)` that can overwrite files.+ unsafe_git_rev_options = [+ "--output",+ "-o",+ ]+ type: Literal["commit"] = "commit" __slots__ = (@@ -302,6 +308,7 @@ def iter_items( repo: "Repo", rev: Union[str, "Commit", "SymbolicReference"], paths: Union[PathLike, Sequence[PathLike]] = "",+ allow_unsafe_options: bool = False, **kwargs: Any, ) -> Iterator["Commit"]: R"""Find all commits matching the given criteria.@@ -330,6 +337,11 @@ def iter_items( raise ValueError("--pretty cannot be used as parsing expects single sha's only") # END handle pretty + if not allow_unsafe_options:+ Git.check_unsafe_options(+ options=Git._option_candidates([rev], kwargs), unsafe_options=cls.unsafe_git_rev_options+ )+ # Use -- in all cases, to prevent possibility of ambiguous arguments. # See https://github.com/gitpython-developers/GitPython/issues/264. @@ -1039,11 +1039,18 @@ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[s option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-") ] if kwargs:+ split_single_char_options = kwargs.get("split_single_char_options", True) for key, value in kwargs.items(): values = value if isinstance(value, (list, tuple)) else (value,) if any(value is True or (value is not False and value is not None) for value in values): key = str(key) options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}")+ if len(key) == 1 and split_single_char_options:+ options.extend(+ str(value)+ for value in values+ if value is not True and value not in (False, None) and str(value).startswith("-")+ ) return options AutoInterrupt: TypeAlias = _AutoInterrupt@@ -214,6 +214,23 @@ def test_option_candidates_ignore_untransformed_kwargs(self): self.assertEqual(options, ["--max-count"]) + def test_option_candidates_include_split_single_char_option_values(self):+ cases = [+ ({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]),+ ({"g": ("safe", "--out=target")}, ["-g", "--out=target"], ["--output"]),+ ]++ for kwargs, candidates, unsafe_options in cases:+ self.assertEqual(Git._option_candidates(kwargs=kwargs), candidates)+ with self.assertRaises(UnsafeOptionError):+ Git.check_unsafe_options(options=candidates, unsafe_options=unsafe_options)++ self.assertEqual(self.git.transform_kwargs(n="--upload-pack=helper"), ["-n", "--upload-pack=helper"])++ unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False}+ self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"])+ self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"])+ _shell_cases = ( # value_in_call, value_from_class, expected_popen_arg (None, False, False),AI-assisted change 74f7c407112b8bcc7c96f8d9c7329f9eb0d3e2e809d687bbba7ce45b528d3d79 · Fix 3f95d3563cdce315c4cafde343db5289b955dfa2deb7a21c85a5a6927b24e47b