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:
- Extract the station-v0.97 release tarball and put workbench/ on sys.path.
- 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).
- 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")
- Now target the same protected file through
base:
GC.filtered_diff(repo, base="--output=" + victim_path, ws=ws)
- 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 asreporaises,
so this is not a claim that check_repo() is broken.
filesis 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.