Hardening git as a wrapped tool
Operator notes for safely exposing git through wraptool
This page describes git-specific concerns that affect any operator wrapping git through wraptool. The configuration knobs discussed here live in gitconfig and the environment given to the wrapped git process; wraptool itself does not interpret them today.
Why git deserves its own page
Most tools wraptool exposes are essentially a verb plus arguments. git is different on three axes:
- Repos contain executable configuration. A repository’s
.git/config,.gitattributes, and.gitmodulescan configure programs thatgitwill execute on routine commands such asstatus,checkout,add, orcommit. Cloning, opening, or even auto-discovering an untrusted tree therefore yields arbitrary code execution unless the surrounding environment is hardened. - Auto-discovery walks parent directories. Running
gitfrom anywhere inside a working tree causes it to walk up the directory hierarchy looking for.git. A hostile parent dir (or a “buried bare repo” anywhere on that path) can capture the invocation. - Hooks fire on many local-looking operations.
post-checkout,post-merge,post-rewrite, filter drivers, diff drivers, and merge drivers are not suppressed by--no-verify. Hooks run in the environment of the calling process.
For a tool wrapper that may run in a host environment holding SSH keys, GPG keys, kubeconfigs, and cloud credentials, those three properties combine into a serious threat surface.
Split execution model
You do not need wraptool to mediate every git invocation. Many operations are local-only and can run perfectly well inside the agent’s unprivileged container (which holds no secrets). Restrict wraptool’s surface to the operations that actually need host credentials.
Run in the container (no wraptool needed)
These touch only the local working tree. They are still affected by hostile repo configuration (see below), but the blast radius is limited to the container.
status · log · show · diff · add · rm · mv · commit (unsigned) · branch · tag (lightweight) · reset · stash · cherry-pick · revert · rebase (non-interactive, no --exec) · format-patch · apply · bisect · reflog · gc · fsck · cat-file · rev-parse · ls-files · ls-tree
Run on the host through wraptool (need credentials)
These need ssh, gcloud, GitHub tokens, or other host-only secrets.
clone · fetch · pull · push · ls-remote · remote update · submodule update --remote · submodule sync · archive --remote=... · bundle fetch · lfs fetch|pull|push
“Local-only” — but with caveats
These look local but can still execute attacker-controlled commands through .gitattributes filters, hooks, signature programs, or the configured editor/pager. Treat them with the same caution as any other code-execution path:
| Operation | Vector |
|---|---|
checkout, switch, restore, read-tree, merge, apply, add, archive |
filter.<driver>.{clean,smudge,process} and diff.<driver>.textconv via .gitattributes |
commit, merge, rebase, checkout, am |
post-* hooks (not blocked by --no-verify) |
commit -S, tag -s, log --show-signature, merge --verify-signatures |
gpg.program, gpg.ssh.program |
rebase -i, commit --amend, tag -a, git config --edit |
core.editor, sequence.editor |
log, diff, show, blame |
core.pager |
submodule init / update |
submodule.<name>.update = !cmd (gated by submodule.update.command policy) |
fetch from a partial clone |
lazy fetches honour the source repo’s config and hooks |
help |
man.viewer, help.browser |
The split is a policy boundary, not a preference
The three lists above are not advice about where each command feels more natural to run. They divide the subcommands that genuinely need host credentials from those that do not, and only policy enforces that division.
A subcommand that needs no host credentials but is allowed anyway costs nothing to remove and gives an attacker a foothold for free. checkout and switch are the clearest case. Both run filter.<driver>.clean, .smudge, and .process whenever a repository’s .gitattributes selects a driver. The command-scope settings below close the hook path through core.hooksPath and the fsmonitor path through core.fsmonitor=false, but neither suppresses filter drivers, and no single wildcard disables them. Allowing checkout on the host therefore reopens a code-execution path that the environment recipe does not address, in exchange for a capability the agent already holds inside its own container.
Write the boundary into policy:
- Allow on the host only the subcommands from the transport list above.
- Add an explicit
denyentry for the local subcommands, so the intent survives a later edit that adds an allow rule. Deny is evaluated first and takes absolute precedence over allow. - Leave the rest to the container’s own
git, which operates on the same worktree and holds no credentials.
The worked example under Hardened environment for wraptool applies exactly this split: it allows fetch, push, and ls-remote, and denies pull, submodule, config, credential, and the remote subcommands that rewrite a destination.
The common drift from this page is a policy that accumulated checkout, switch, or branch over time, usually because a local operation was easier to allow than to move into the container. Neither the environment recipe below nor the protocol allowlist detects that drift. Confirm what is actually exposed with the wraptool_discover tool, which reports the allowed subcommands as the agent sees them, rather than reading the configuration and assuming the two agree.
Threats from a hostile working tree
Buried bare repositories
A repository can contain another bare repository as a subdirectory. Default-configured git discovers it during parent-walking and honours its config — including core.fsmonitor, hooks, and filter drivers. The attack vector and its mitigation are described in detail by Justin Steven’s 2022 advisory on buried bare repos.
Mitigation: Set safe.bareRepository = explicit. git will then refuse to operate on bare repos discovered via traversal; only repos named explicitly via --git-dir or GIT_DIR are honoured. Available since Git 2.38 (2022-10-03). See git-config(1) under safe.bareRepository.
core.fsmonitor as a code-execution trigger
When core.fsmonitor is set to a string, git executes it on status, add, commit, diff, and other working-tree-touching commands. A hostile .git/config setting this value yields immediate RCE on the next git status — which IDEs and shell prompts often run automatically.
There is no CVE against upstream Git for this — the project considers the configuration “working as intended”. Several CVEs have been filed against downstream tools that auto-invoke git on untrusted paths (VS Code: CVE-2021-43891; JetBrains: CVE-2022-24345; fish: CVE-2022-20001).
Mitigation: Set core.fsmonitor=false at command scope, not merely in global config. Use git -c core.fsmonitor=false <cmd> or the GIT_CONFIG_COUNT recipe below. Git 2.35.1 and older may treat false as a hook pathname, so require a current patched Git version.
Hook execution
core.hooksPath overrides the default $GIT_DIR/hooks directory. A hostile repo can point it at a path it controls. A command-scope override can instead point it at an empty read-only directory, where git silently finds no executables. There is no GIT_HOOKS_PATH environment variable; the supported override is -c core.hooksPath=... or a config entry.
Note that --no-verify only suppresses pre-commit, commit-msg, pre-merge-commit, pre-push, and p4-changelist. It does not suppress post-checkout, post-merge, post-rewrite, filter drivers, or any of the diff/merge drivers.
Mitigation:
git -c core.hooksPath=/etc/wraptool/empty-hooks <cmd>A global core.hooksPath is insufficient because local config has higher precedence. The command-scope setting must be mandatory and unavailable to the caller.
See githooks(5) and git-config(1).
safe.directory ownership check
Since Git 2.35.2 (the fix for CVE-2022-24765), git refuses to operate on a repository whose directory is not owned by the current effective UID, unless the path is listed in safe.directory. The literal * disables the check globally. safe.directory is only honored from protected system, global, or command config (never from a per-repo .git/config), so a hostile repo cannot whitelist itself.
For wraptool deployments that run git as a service user against repositories owned by a different UID, you must whitelist the working tree paths explicitly in the hardened global config.
References: NVD CVE-2022-24765, GHSA-j342-m5hw-rr3v (CVE-2022-29187 bypass).
Protocol allowlist
The git-remote-ext helper runs arbitrary shell when a URL of the form ext::sh -c "..." is fetched. Default-deny is in place since the CVE-2017-1000117 family of submodule URL exploits, gated by:
protocol.allow— default policy.protocol.<name>.allow— per-protocol override.- The
GIT_PROTOCOL_FROM_USER=1distinction: for indirect operations (submodule init, recursive fetch) the protocol must be explicitly allow-listed; user-typed top-level commands implicitly carry user trust.
Do not relax protocol.ext.allow=always or protocol.file.allow=always. See git-remote-ext(1) and the protocol.allow entry in git-config(1).
Config keys that can execute code
Treat any of these as a code-execution channel when present in .git/config of an untrusted repository:
| Key | Triggered by |
|---|---|
core.sshCommand |
any remote op over ssh |
core.editor, sequence.editor |
commit, rebase -i, tag -a, config --edit |
core.pager, pager.<cmd> |
any pager-using command |
core.askPass (and GIT_ASKPASS) |
credential prompt |
core.fsmonitor |
status, add, commit, … |
credential.helper |
credential prompt; !cmd runs shell |
alias.<name> (when value starts with !) |
the alias |
gpg.program, gpg.ssh.program, gpg.<format>.program |
signing / verification |
diff.<driver>.command, diff.<driver>.textconv, diff.external |
gated by .gitattributes |
merge.<driver>.driver |
gated by .gitattributes |
filter.<driver>.clean, filter.<driver>.smudge, filter.<driver>.process |
gated by .gitattributes; fires on checkout, add, archive |
submodule.<name>.update = !cmd |
submodule update (constrained by submodule.update.command policy since 2018) |
url.<base>.insteadOf |
not direct exec; can redirect to ext:: if protocol allow permits |
include.path, includeIf.*.path |
not direct exec; pulls in additional config from arbitrary paths |
interactive.diffFilter |
interactive patch operations |
mergetool.<tool>.cmd, difftool.<tool>.cmd |
explicit merge/diff tool invocation |
tar.<format>.command |
archive with the named format |
gc.recentObjectsHook |
garbage collection and cruft processing |
uploadpack.packObjectsHook |
server-side only; modern git ignores it when read from per-repo config |
This list is intentionally not exhaustive. Git adds new integrations over time, and arbitrary driver names selected by .gitattributes cannot be disabled with one wildcard setting. Use the transport-only split above instead of treating this table as a complete denylist.
Config precedence matters
Git reads system, global, local, worktree, then command-scope configuration. Later values normally win; multi-valued settings may accumulate. Consequently, a curated global config isolates the wrapped process from the developer’s normal preferences, but it does not override a malicious .git/config.
Security invariants such as core.hooksPath and core.fsmonitor must be set at command scope. Git provides two mechanisms:
- fixed
git -c name=valuearguments before the subcommand; GIT_CONFIG_COUNTplus indexedGIT_CONFIG_KEY_nandGIT_CONFIG_VALUE_nenvironment variables.
The environment form fits wraptool’s current tools.<name>.env model. Explicit command-line git -c still has higher precedence, so policy must never put caller-controlled -c, --config-env, -C, --git-dir, or --work-tree before the allowed subcommand.
Hardened environment for wraptool
Use a dedicated HOME and global config to remove ambient host preferences. Keep settings that Git only accepts from protected config there, such as safe.bareRepository. Put settings that must defeat repository-local config in command scope through GIT_CONFIG_COUNT.
Create an empty, read-only hooks directory and an operator-owned Git HOME. For example, the global config can contain non-executable defaults:
# /etc/wraptool/git-home/.gitconfig
[safe]
bareRepository = explicit
directory = /var/lib/wraptool/workThen configure wrapped execution. This SSH-oriented example permits only transport operations and applies security-sensitive values at command scope:
tools:
git:
binary: /usr/bin/git
timeout: 60s
env:
HOME: /etc/wraptool/git-home
GIT_CONFIG_NOSYSTEM: "1"
GIT_CONFIG_GLOBAL: /etc/wraptool/git-home/.gitconfig
GIT_PAGER: cat
GIT_TERMINAL_PROMPT: "0"
GIT_ASKPASS: /usr/bin/false
SSH_ASKPASS: /usr/bin/false
GIT_EDITOR: /usr/bin/false
GIT_SEQUENCE_EDITOR: /usr/bin/false
# This profile is SSH-only. HTTPS needs separate hardening below.
GIT_ALLOW_PROTOCOL: ssh
GIT_SSH_COMMAND: /usr/local/libexec/wraptool-git-ssh
# Command scope: overrides local and worktree config.
GIT_CONFIG_COUNT: "14"
GIT_CONFIG_KEY_0: safe.bareRepository
GIT_CONFIG_VALUE_0: explicit
GIT_CONFIG_KEY_1: core.hooksPath
GIT_CONFIG_VALUE_1: /etc/wraptool/empty-hooks
GIT_CONFIG_KEY_2: core.fsmonitor
GIT_CONFIG_VALUE_2: "false"
GIT_CONFIG_KEY_3: maintenance.auto
GIT_CONFIG_VALUE_3: "false"
GIT_CONFIG_KEY_4: gc.auto
GIT_CONFIG_VALUE_4: "0"
GIT_CONFIG_KEY_5: credential.helper
GIT_CONFIG_VALUE_5: ""
GIT_CONFIG_KEY_6: push.gpgSign
GIT_CONFIG_VALUE_6: "false"
GIT_CONFIG_KEY_7: transfer.fsckObjects
GIT_CONFIG_VALUE_7: "true"
GIT_CONFIG_KEY_8: fetch.fsckObjects
GIT_CONFIG_VALUE_8: "true"
GIT_CONFIG_KEY_9: receive.fsckObjects
GIT_CONFIG_VALUE_9: "true"
GIT_CONFIG_KEY_10: fetch.recurseSubmodules
GIT_CONFIG_VALUE_10: "false"
GIT_CONFIG_KEY_11: push.recurseSubmodules
GIT_CONFIG_VALUE_11: "false"
GIT_CONFIG_KEY_12: submodule.recurse
GIT_CONFIG_VALUE_12: "false"
GIT_CONFIG_KEY_13: core.alternateRefsCommand
GIT_CONFIG_VALUE_13: /usr/bin/false
allow:
- subcommand: [fetch]
flags: ["--prune"]
deny_flags: ["--help", "--upload-pack", "--exec", "--recurse-submodules"]
arg_constraints:
positional_max: 2
- subcommand: [push]
flags: ["--set-upstream"]
deny_flags: ["--help", "--force", "--force-with-lease", "--receive-pack", "--exec", "--recurse-submodules", "--signed"]
arg_constraints:
positional_max: 2
- subcommand: [ls-remote]
flags: ["--heads", "--tags", "--refs", "--symref", "--exit-code"]
deny_flags: ["--help"]
arg_constraints:
positional_max: 1
deny:
- subcommand: [pull]
- subcommand: [submodule]
- subcommand: [config]
- subcommand: [credential]
- subcommand: [remote, set-url]
- subcommand: [remote, add]Notes:
GIT_CONFIG_NOSYSTEM=1skips/etc/gitconfig.GIT_CONFIG_GLOBAL=<path>skips both~/.gitconfigand$XDG_CONFIG_HOME/git/configin favour of the curated file. Setting it to/dev/nullskips global config entirely.GIT_TERMINAL_PROMPT=0preventsgitfrom blocking on a credential prompt; failed auth becomes a fast error instead of a hang.GIT_PAGER=catoverrides repository pager configuration. MCP output remains unchanged.--helpis denied because Git may invoke configured man or browser viewers; ordinary-husage output remains available.GIT_ALLOW_PROTOCOL=sshoverrides existing protocol configuration and excludesext,file, HTTP(S), native unauthenticatedgit, and custom helpers.- An empty command-scope
credential.helperresets helpers accumulated from lower-precedence config. For HTTPS, add one trusted absolute helper as the next indexed value and increaseGIT_CONFIG_COUNT. core.hooksPathpoints to an existing empty read-only directory. Do not point it back into the project.core.fsmonitor=falserequires modern Git. Git 2.35.1 and older may interpretfalseas a hook pathname; keep every wrapped Git installation patched.- Command-scope recursion settings disable ordinary defaults, but a per-submodule
submodule.<name>.fetchRecurseSubmodulessetting can still take priority. Strict fetch isolation requires an operator-owned launcher that always injects--no-recurse-submodules; current wraptool policy cannot force a flag to be present. core.alternateRefsCommand=/usr/bin/falseprevents repository config from selecting a shell command to enumerate refs from object alternates. Repositories that depend on such a command may fail and should be handled in the container.--upload-packand--receive-packare explicit denies because they let the client name an arbitrary executable on the server side — harmless for typical clients, but unnecessary attack surface. Localremote.<name>.uploadpackandremote.<name>.receivepackcan request the same behavior, so the SSH transport wrapper must permit only expected Git service commands.
Wraptool currently limits positional argument count, not positional content. A caller can therefore supply force (+src:dst) or deletion (:dst) refspecs, and repository config can set remote.<name>.mirror=true. deny_flags does not neutralize those forms. Protect important branches on the remote server. If client-side prevention is also required, expose push through an operator-owned launcher that fixes the remote, rejects force/delete refspecs, and overrides that remote’s mirror, upload-pack, and receive-pack settings. Do not describe the generic policy above as a complete non-destructive push boundary.
Wraptool applies tools.git.env to normal tool calls, but current introspection runs the binary without that curated environment. Wraptool also cannot inject fixed arguments before every subcommand or constrain positional remote URLs. Run the server from a trusted directory, keep its ambient Git configuration trusted, and treat this recipe as defense in depth until built-in Git hardening uses one launch path for execution and introspection.
Constrain the remote destination
Protocol restriction does not constrain hostnames. A malicious project can change remote.origin.url or use url.*.insteadOf to redirect an allowed fetch or push.
For SSH, make GIT_SSH_COMMAND an operator-owned wrapper, not merely an unrestricted ssh -F invocation. It should accept only approved host/user patterns, use fixed identities and strict host-key checking, reject ProxyCommand and agent forwarding, and permit only expected Git service commands.
Do not add https to the SSH recipe’s protocol list. Repository config can select client certificates and keys, cookie files, proxies, extra headers, and per-URL TLS behavior. A separate HTTPS profile must reset repository credential helpers and override every file- and credential-bearing HTTP setting, then add one trusted helper scoped to approved origins. If those invariants cannot be enforced reliably, prefer an authenticated host transport proxy over giving repository-aware Git direct credential access.
Hooks, validation, and Git LFS
Disabling host-side hooks means project pre-push checks do not run through wraptool. Run validation explicitly in the container or rely on CI before the wrapped push. This makes project code execution visible and keeps it away from host credentials.
Git LFS needs special handling: its normal pre-push hook uploads LFS objects. Skipping it can push commits whose LFS objects are absent remotely. Either:
- provide an operator-managed read-only hooks directory containing only a trusted LFS
pre-pushhook that invokes an absolutegit-lfsbinary; or - expose a separate constrained
git lfs pushcapability.
Do not restore the project’s general hooks directory merely to support LFS.
CVE awareness
Wrappers do not make git itself secure. Keep the wrapped git binary patched. The advisories below are worth tracking because each one ends in code execution on a clone or local operation against a hostile tree:
| CVE | Fixed in | One-liner |
|---|---|---|
| CVE-2022-24765 | 2.35.2 | Bare-repo discovery in foreign-owned dir; introduced safe.directory. |
| CVE-2022-29187 | 2.30.5 / 2.37.1 | Bypass of CVE-2022-24765 for root in self-owned dir. |
| CVE-2022-39253 | 2.30.6 / 2.38.1 | Symlinks in --local clone leak files into clone. |
| CVE-2024-32002 | 2.39.4 / 2.40.2 / 2.41.1 / 2.42.2 / 2.43.4 / 2.44.1 / 2.45.1 | Submodule writes into .git/hooks via symlink on case-insensitive FS → RCE on recursive clone. |
| CVE-2024-32004 | same fixed set | Crafted local clone triggers hooks → RCE on multi-user host. |
| CVE-2024-32020 | same fixed set | Local clone hardlinks across UIDs → other user can rewrite objects. |
| CVE-2024-32021 | same fixed set | Local-clone symlink TOCTOU hardlinks arbitrary readable files. |
| CVE-2024-32465 | same fixed set | .zip-archived clone bypasses CVE-2024-32002/4 fixes. |
| CVE-2024-50349 / 52006 | 2.48.1 | Credential-helper ANSI/CR injection. |
| CVE-2025-48384 | 2.43.7 / 2.44.4 / 2.45.4 / 2.46.4 / 2.47.3 / 2.48.2 / 2.49.1 / 2.50.1 | CR-mismatch in config read vs write → checkout to wrong path → RCE on recursive clone. Listed in CISA KEV (Aug 2025), actively exploited. |
| CVE-2025-48385 / 48386 | same fixed set | Bundle URI validation bypass; Wincred buffer overflow (Windows). |
| CVE-2023-25652 / 29007 | 2.40.1 | git apply --reject arbitrary write; submodule URL config injection → RCE. |
Aggregated upstream announcements live at the GitHub blog “Git security vulnerabilities announced” series.
Scope: what wraptool does and does not enforce
Today, wraptool enforces policy and credential isolation at the command boundary. It does not:
- inspect repository configuration before launching
git, - reject
.git/configcontainingcore.fsmonitor, - run static analysis on
.gitattributes, - prevent
gitfrom auto-discovering a bare repository in a parent directory of the working tree, - apply mandatory Git command-scope configuration of its own,
- constrain remote destination hosts, or
- apply the configured tool environment to introspection.
The fifth point has one deliberate exception. The starter config that wraptool up and wraptool serve scaffold on first run ships GIT_CONFIG_COUNT and the indexed GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n pairs in the git tool’s env, pinning core.hooksPath at an empty directory and core.fsmonitor to false. Those are command scope, so they outrank a hostile .git/config, and they close the hook and fsmonitor execution channels for the wrapped git pull that the scaffold allows.
That is configuration, not enforcement, and the difference decides who is covered:
- A configuration written before this shipped does not have the settings.
EnsureConfigFilenever overwrites an existing file, so upgrading wraptool does not add them to a config you already have — copy them in by hand. - An operator can delete them. Nothing re-adds them and nothing checks for them at load.
- An explicit
git -c key=valuestill outranks them, which is why-c,--config-env,-C,--git-dir, and--work-treemust never appear in an allow rule’s flags. - They close the hook and fsmonitor channels only — not
.gitattributesfilter and merge drivers, and not bare-repository discovery.
The split execution model is the primary boundary. The environment recipe closes known execution channels for normal wrapped calls, but is not proof against every Git integration or future vulnerability. Built-in enforcement is planned in design/git-execution-hardening.md.
References
git-config(1)— authoritative reference for every setting named on this page.git(1)security section — upstream trust model for configuration, hooks, and untrusted.gitdirectories.githooks(5)— which events fire which hooks and when.gitattributes(5)— filters, external diff drivers, and merge drivers selected by tracked files.gitcredentials(7)— helper command execution and empty-value helper reset.git-remote-ext(1)— whyprotocol.ext.allowmatters.- Justin Steven, “Buried bare repos and
core.fsmonitorvarious abuses” (2022) — in-depth writeup of the bare-repo +fsmonitorattack chain. - GitHub Blog: Git security vulnerabilities announced (series) — upstream advisories for each batch of CVEs.
- NVD CVE lookups:
https://nvd.nist.gov/vuln/detail/CVE-YYYY-NNNNN.