Hardening git as a wrapped tool

Operator notes for safely exposing git through wraptool

Note

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:

  1. Repos contain executable configuration. A repository’s .git/config, .gitattributes, and .gitmodules can configure programs that git will execute on routine commands such as status, checkout, add, or commit. Cloning, opening, or even auto-discovering an untrusted tree therefore yields arbitrary code execution unless the surrounding environment is hardened.
  2. Auto-discovery walks parent directories. Running git from 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.
  3. 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.

TipGenerally safe inside 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.

WarningWrap these through wraptool

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 deny entry 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.

WarningAudit the deployed policy, not only this recipe

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=1 distinction: 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=value arguments before the subcommand;
  • GIT_CONFIG_COUNT plus indexed GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n environment 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/work

Then 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=1 skips /etc/gitconfig.
  • GIT_CONFIG_GLOBAL=<path> skips both ~/.gitconfig and $XDG_CONFIG_HOME/git/config in favour of the curated file. Setting it to /dev/null skips global config entirely.
  • GIT_TERMINAL_PROMPT=0 prevents git from blocking on a credential prompt; failed auth becomes a fast error instead of a hang.
  • GIT_PAGER=cat overrides repository pager configuration. MCP output remains unchanged.
  • --help is denied because Git may invoke configured man or browser viewers; ordinary -h usage output remains available.
  • GIT_ALLOW_PROTOCOL=ssh overrides existing protocol configuration and excludes ext, file, HTTP(S), native unauthenticated git, and custom helpers.
  • An empty command-scope credential.helper resets helpers accumulated from lower-precedence config. For HTTPS, add one trusted absolute helper as the next indexed value and increase GIT_CONFIG_COUNT.
  • core.hooksPath points to an existing empty read-only directory. Do not point it back into the project.
  • core.fsmonitor=false requires modern Git. Git 2.35.1 and older may interpret false as a hook pathname; keep every wrapped Git installation patched.
  • Command-scope recursion settings disable ordinary defaults, but a per-submodule submodule.<name>.fetchRecurseSubmodules setting 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/false prevents 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-pack and --receive-pack are explicit denies because they let the client name an arbitrary executable on the server side — harmless for typical clients, but unnecessary attack surface. Local remote.<name>.uploadpack and remote.<name>.receivepack can request the same behavior, so the SSH transport wrapper must permit only expected Git service commands.
ImportantPush policy still needs server protection

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.

WarningInterim protection, not a complete profile

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-push hook that invokes an absolute git-lfs binary; or
  • expose a separate constrained git lfs push capability.

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/config containing core.fsmonitor,
  • run static analysis on .gitattributes,
  • prevent git from 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. EnsureConfigFile never 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=value still outranks them, which is why -c, --config-env, -C, --git-dir, and --work-tree must never appear in an allow rule’s flags.
  • They close the hook and fsmonitor channels only — not .gitattributes filter 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

Back to top