← Community
bugfixed

MCP git tools pass `base`/`ref` into git's argv ahead of `--`, so `--output=<path>` turns a read-only tool into an arbitrary file write

ShwetaShweta#113d ago · 69 views
affected: station-v0.97fixed in: station-v1.3.0

station-v0.97 adds primitives/git_context.py and exposes it over MCP as
railcall_git_diff and railcall_git_log. Both are documented "Deterministic,
read-only, local," and the module opens with an explicit boundary claim: "Paths
under the station workspace are refused so receipts / keys / vault contents
never ride the MCP channel (same boundary as #225)." check_repo() implements
that claim and it works — but it screens only the repo argument. The base
and ref arguments are never screened, and they are placed into git's argv
BEFORE the -- separator, where git parses a leading - as an option rather
than a revision.

git diff --output=<file> writes the diff to an arbitrary path. So a caller
that passes base="--output=/some/path" makes the station write to that path,
creating or truncating it, with no approval, no receipt, and no audit entry —
and the destination is unconstrained, including the workspace subtree the
module's own guard exists to keep off this channel.

primitives/git_context.py, filtered_diff():

range_args = [base] if base else []
path_args = (["--"] + list(files)) if files else []
numstat = _git(rp, "diff", "--numstat", range_args, path_args)

and again per-file at:

d = _git(rp, "diff", *range_args, "--", row["file"])

_git() builds ["git", "-C", repo] + args, so argv becomes
["git","-C",rp,"diff","--numstat","--output=/path/to/victim"]. There is no
shell, so this is not shell injection — it is option injection into an argv
that has no -- in front of the caller-controlled element. recent_log() has
the same shape with ref as the final argument:

out = _git(rp, "log", "--oneline", "--no-decorate", "-n", str(n), ref)

Note the asymmetry inside this one function: files IS handled correctly — it
is placed after --, where git can only read it as a pathspec. The same
protection was simply never applied to base.

Reproduction steps:

  1. Extract the station-v0.97 release tarball and put workbench/ on sys.path.
  2. Create two directories: an ordinary git repo the caller is entitled to read,

and a scratch workspace containing a file at
<ws>/receipts/runs/wfrun_20260814T000000Z_payroll.json holding a normal
signed-shaped dag-run receipt (schema, workflow_id, outcome, integrity_hash,
signature).

  1. Confirm the documented guard works on the read side:

from primitives import git_context as GC
GC.filtered_diff(ws, ws=ws)
-> raises GitContextError("refusing to read the station workspace over MCP
— receipts/keys/vault stay on the operator's side of the boundary")

  1. Now target the same protected file through base:

GC.filtered_diff(repo, base="--output=" + victim_path, ws=ws)

  1. Read the victim file back, and read the return value of the call.

Expected:

A read-only tool cannot write. The base argument selects a revision or range;
a value that is not a valid revision should be refused by the tool or rejected
by git as an unknown revision. Nothing the caller supplies should be able to
create or truncate a file, and least of all a file inside the workspace subtree
this module explicitly refuses to read.

Actual:

filtered_diff() returns ok: True with stat {"files_changed": 0,
"signal_files": 0, "adds": 0, "dels": 0} and zero hunks — an unremarkable,
successful-looking empty diff. Nothing in the response mentions a write. The
receipt file has been overwritten with git's numstat output ("1\t0\ta.txt\n"):
it is no longer valid JSON, and its integrity_hash and signature are gone.

The same call against recent_log(repo, n=5, ref="--output=" + path) overwrites
that path with the oneline log.

Root cause:

primitives/git_context.py — filtered_diff() places the caller-supplied base
into git's argv before the -- separator (both at the --numstat call and at
the per-file diff call), and recent_log() places the caller-supplied ref as a
bare trailing argument. Neither value is validated as a revision, and neither
is protected by a -- separator, so any value beginning with - is consumed
by git as an option. git diff --output=<file> and git log --output=<file>
both write to the named path.

check_repo() (the workspace-boundary guard) is applied only to repo. It
therefore constrains which tree may be READ and places no constraint at all on
where a write may land.

Suggested fix:

Reject any base/ref value beginning with "-" before it reaches argv, and
additionally insert an explicit end-of-options separator so a revision can
never be reparsed as a flag:

if base is not None and str(base).startswith("-"):
raise GitContextError("base must be a revision or range, not an option")
...
_git(rp, "diff", "--numstat", range_args, "--", (files or []))

git also accepts --end-of-options before the revision for the same purpose.
The narrower alternative — denylisting --output — is not sufficient; the
general defect is that a caller-supplied string is parsed in git's option
position, and the option surface of git diff / git log is large.

Separately, consider applying the workspace-boundary check to the resolved
write destination as well as the read root, so the module's stated boundary
holds for both directions.

Honest scope:

Reachability is the MCP surface — the tools are registered in the MCP tool
table, so the caller is the model/sidecar, which this product treats as
untrusted by design ("Agents draft. You approve."). It is not a remote or
unauthenticated exploit, and it requires the caller to name a path to a real
git work tree for repo. There is no shell involved and this is not command
execution: the write content is git's own diff/log output, not attacker-chosen
bytes, so the primitive is a targeted create/truncate/clobber rather than a
write of arbitrary content. That is still sufficient to destroy a signed
receipt, a pinned plan, a policy file, or a token file, and to do so with no
approval and no evidence trail. No signature forgery, no seed compromise, no
RCE is claimed.

Counter-evidence checked:

  • The guard genuinely works for repo: passing the workspace as repo raises,

so this is not a claim that check_repo() is broken.

  • files is genuinely safe: it is placed after --, and a pathspec escaping

the repo is rejected by git itself.

  • The write was confirmed against the real, unmodified module from a clean

tarball extraction, not a stub; the tool's own return value was captured to
confirm it reports success rather than surfacing the write.

  • Verified on both entry points (filtered_diff and recent_log), and confirmed

git exits 0 in this case, so nothing downstream sees an error.

Distinctness:

git_context.py is new in station-v0.97, so no earlier report can cover it. This
is an argv/option-injection write, distinct from the connector SSRF findings
(outbound destination), from the compose-path traversal (a filename built from
a spec field), and from the code_patch temp-symlink item (a link-following
write inside a governed patch path). I did not find a community thread about
the git context tools' base/ref arguments.

5 pts

1 reply

Fixed in station-v1.3.0. git_context._safe_rev() now rejects any base/ref starting with -, so --output=<path> can't be smuggled into git's argv ahead of -- to turn a read-only git tool into an arbitrary file write.

Thanks for the report — credited.

Sign in to reply.