# Security & hardening guide
These wrappers shell out to the real `git` / `jj` / `gh` — so the threats are the
CLI's, not a reimplemented protocol's: a caller-supplied string that smuggles a
flag into argv, and a repository you didn't create whose hooks and config run
arbitrary code the moment you touch it. Two layers answer those, both **on by
default or one call away**:
- **Injection guards** — automatic, in every typed method. Nothing to opt into.
- **`Git::hardened()`** — one constructor for the untrusted-repo case.
Pre-validation at your input boundary (the [newtypes](#validating-newtypes-eager-at-your-input-boundary))
is the optional third layer, for failing fast on bad input *before* it reaches a
method.
---
## Injection guards (automatic)
Every exposed positional argument — branch/tag/bookmark names, revisions,
revsets, ranges, remotes, operation ids, clone/fetch endpoints — is checked
**before anything spawns**: a value that is empty or begins with `-` is refused,
because `git`/`jj` would parse a leading-`-` string as a *flag* rather than the
name you meant. That is the whole attack: a caller string like
`--upload-pack=/bin/evil` in a remote slot, or `--config=core.pager=…` in a
revset slot, would otherwise run an arbitrary program. The guard makes the
smuggle impossible at the argv level, so it holds regardless of how the value
reached you.
A rejected argument surfaces as a spawn-side **`processkit::Error::Spawn`** —
the same variant a missing binary produces — carrying the program name and an
`InvalidInput` IO source describing the rejected value. (It is raised *instead*
of spawning, not by the child.)
```rust,ignore
# use vcs_git::{Git, GitApi};
# async fn demo(git: &Git, repo: &std::path::Path) {
// A caller-supplied branch name that starts with `-`:
let err = git.checkout(repo, "--upload-pack=/bin/evil").await.unwrap_err();
assert!(matches!(err, processkit::Error::Spawn { .. })); // never spawned
# }
```
What is **not** guarded, by design:
- **Flag-value slots** (`-m <msg>`, `--branch <b>`, `-r <revset>`) — the CLI
itself rejects a dash-value there with a clear error rather than misparsing it.
- **Filesystem path arguments** — `--`-separated pathspecs, worktree paths,
clone destinations. These are typed `Path` and caller-trusted; git's `--`
separator keeps even a `-dash.txt` literal.
- **The `run` / `run_raw` escape hatches** — you build the whole argv, so you own
its safety.
One hard rule on top: **never compose commands through a shell**
(`sh -c "git … | grep …"`) — that reopens the entire injection surface the
guards close. If output composition is ever genuinely needed, processkit 0.7's
`Command::pipe` chains commands in one kill-on-close group with no shell in
between; until then, parse in-process like the wrappers do.
## Validating newtypes (eager, at your input boundary)
The guards above run inside each method. When you take a name or revision from a
UI, bot, or agent and want to reject it *at the boundary* — before it flows
through your code — validate it up front with a newtype. These are **optional**:
method signatures stay `&str` and guard internally either way; the newtypes are
for early, explicit validation, not a required wrapper.
When to reach for one — a short decision note:
- **`&str` straight through** when the value is program-internal (a constant, a
name you just listed from the repo): the in-method guard at the spawn edge is
the only check needed.
- **`RefName` / `RevSpec` / `RevsetExpr`** when the value crosses a trust
boundary *early* and an invalid one should fail with context at intake (an
HTTP handler, an MCP/agent tool argument, config parsing) rather than three
layers down at spawn time — validate once, then pass `.as_str()` everywhere.
`vcs-git`:
```text
pub fn RefName::new(name: impl Into<String>) -> Result<Self> // signature shape
pub fn RevSpec::new(rev: impl Into<String>) -> Result<Self>
```
- **`RefName`** follows the load-bearing core of `git check-ref-format`:
non-empty; no leading `-` or `.`; no `..`; no control characters or space; none
of `~ ^ : ? * [ \`; no trailing `/` or `.lock`.
- **`RevSpec`** is deliberately minimal — git's revision grammar is too rich to
validate here — so it only guarantees non-empty and no leading `-`, matching
the internal guard.
`vcs-jj`:
- **`RevsetExpr`** mirrors `RevSpec`: non-empty, no leading `-`. (jj's revset
grammar is likewise too rich to validate further.)
```rust,ignore
use vcs_git::RefName;
# fn demo() -> Result<(), processkit::Error> {
let name = RefName::new("feature/login")?; // Ok — validated once, here
assert!(RefName::new("-x").is_err()); // leading `-`
assert!(RefName::new("a..b").is_err()); // `..`
assert!(RefName::new("").is_err()); // empty
// Pass the inner &str to any method:
# async fn use_it(git: &vcs_git::Git, repo: &std::path::Path, name: RefName)
# -> Result<(), processkit::Error> {
git.checkout(repo, name.as_str()).await?;
# Ok(()) }
# Ok(()) }
```
A rejected newtype returns the same `Error::Spawn { program, source }` shape the
in-method guard uses — so a value that passes `RefName::new` will never be
rejected later for flag-shape.
## `Git::hardened()`
Running `git` inside a repository you didn't create is **arbitrary code
execution by default**: git fires that repo's hooks and honours its config on
ordinary commands. The hardened profile neutralises that, applying the same
settings to **every** command the client runs:
- **Disables hooks** — `core.hooksPath=/dev/null`, pinned through git's
env-based config (`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`;
verified to suppress hooks on Windows too) — and `core.fsmonitor=false` (a
config-driven daemon launch).
- **Scrubs repo-redirecting `GIT_*` variables** so a poisoned parent environment
can't point a command at another repository: `GIT_DIR`, `GIT_WORK_TREE`,
`GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`,
`GIT_NAMESPACE`, `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_PARAMETERS`,
`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`.
- **Skips system config** (`GIT_CONFIG_NOSYSTEM=1`) and keeps terminal prompts
off everywhere (`GIT_TERMINAL_PROMPT=0`).
```rust,ignore
use vcs_git::Git;
let git = Git::hardened(); // == Git::new().harden()
// Every command this client runs carries the profile above.
```
It is chainable, so it composes with a runner in tests
(`Git::with_runner(rec).harden()`) and with a deadline
(`Git::hardened().default_timeout(…)`).
What it does **not** do: sandbox the git binary itself, or stop the repo's
*content* from being malicious.
**jj needs no equivalent.** jj has no repo-local hooks, and its config comes from
the user/repo TOML files jj itself trusts — there is deliberately no
`Jj::hardened()`. In a **colocated** repo the risk lives entirely on the git
side (git hooks fire only when *git* commands run there), so harden the `Git`
client you point at it and leave `Jj` plain.
## Untrusted file content
The conflicted-file parsers treat their input as arbitrary bytes: `vcs_git::conflict`
and `vcs_jj::conflict` turn marker soup into structured regions and **never
panic** on malformed or hostile input — a bad file is an `Error::Parse`, not a
crash. This is property-tested for panic-freedom on arbitrary input, alongside a
byte-exact `render(parse(x)) == x` roundtrip. See the [conflicts guide](https://docs.rs/vcs-git/latest/vcs_git/guide/conflicts/).
## See also
- [git guide](https://docs.rs/vcs-git/latest/vcs_git/guide/) — the full `GitApi` surface and the hardened profile in context.
- [jj guide](https://docs.rs/vcs-jj/latest/vcs_jj/guide/) — why there is no `Jj::hardened()`, and the colocated-repo story.
- [Process model & errors](https://docs.rs/vcs-core/latest/vcs_core/guide/process_model/) — `Error::Spawn` and the other
variants the guards raise, plus containment and observability.