Files
Andrey Kumanyaev 344cd8d446 Refuse a git revision that git would read as an option
Caller-supplied revisions reach git as positional argv elements —
`git diff <base>...HEAD`, `git log <ref>`, `git blame -p <ref>`. argv
carries no quoting, so a value beginning with "-" is not a ref: git parses
it as a flag, and the diff family accepts `--output=<path>`, which
redirects the command's output into a file of the caller's choosing.

The refs come from MCP tool arguments — `base` / `base_ref` on the twelve
review, diff and contract tools, `branch` on the enrichers — so they are
attacker-controlled whenever the agent driving the session is. Nothing
validated them; the only ref allowlist in the tree is wired to in-memory
overlay branch names, which never reach git.

Measured, so the fix is not sold as more than it is. The diff path builds
`<ref>...HEAD` as one token, so git writes a file carrying a literal
"...HEAD" suffix: an existing file is created beside, not truncated —
arbitrary file creation in an existing directory, carrying the real diff
output. The bare-token sites have no suffix and do truncate, though
`enrich_churn` happens to be shielded today by an unrelated `rev-parse
--verify` pre-gate on the same value. Git-level options like --git-dir are
not reachable: git accepts those only before the subcommand.

ValidateRef refuses a leading "-", plus whitespace and control characters
that no legal revision contains. It is a denylist because legitimate
revision expressions are much broader than ref names — HEAD~3, a...b,
v1.2^{commit}, HEAD:path — and an allowlist tight enough to be safe would
reject real input. `--end-of-options` was the alternative and was rejected:
git >= 2.24 only, its accepted position differs per subcommand, and it
would not cover the sites that build argv differently.

The test asserts the refusal and separately demonstrates the behaviour
being guarded, so it fails if git ever stops honouring the option.
2026-08-08 20:50:36 +02:00
..