# Git operations: read/write commands and destructive operations

allowlists:
  commands:
    # ── Git: read operations ──────────────────────────────────────
    - { command: "git status", trust: minimal }
    - { command: "git diff", trust: minimal }
    - { command: "git log", trust: minimal }
    - { command: "git show", trust: minimal }
    - { command: "git branch", trust: minimal }
    - { command: "git stash list", trust: minimal }
    - { command: "git remote", trust: minimal }
    - { command: "git tag", trust: minimal }
    - { command: "git rev-parse", trust: minimal }
    - { command: "git blame", trust: minimal }
    - { command: "git describe", trust: minimal }
    - { command: "git shortlog", trust: minimal }
    - { command: "git reflog", trust: minimal }
    - { command: "git ls-files", trust: minimal }
    - { command: "git ls-remote", trust: minimal }
    - { command: "git ls-tree", trust: minimal }
    - { command: "git cat-file", trust: minimal }
    - { command: "git grep", trust: minimal }
    - { command: "git archive", trust: minimal }
    - { command: "git check-ignore", trust: minimal }
    - { command: "git symbolic-ref", trust: minimal }
    - { command: "git show-ref", trust: minimal }
    - { command: "git diff-tree", trust: minimal }
    # ── Git: info/help commands ───────────────────────────────────
    - { command: "git --version", trust: minimal }
    - { command: "git -v", trust: minimal }
    - { command: "git help", trust: minimal }
    - { command: "git -h", trust: minimal }
    # ── Git: history/ref commands ─────────────────────────────────
    - { command: "git merge-base", trust: minimal }
    - { command: "git rev-list", trust: minimal }
    - { command: "git for-each-ref", trust: minimal }
    - { command: "git name-rev", trust: minimal }
    - { command: "git verify-pack", trust: minimal }
    - { command: "git fsck", trust: minimal }
    # ── Git: safe write operations ────────────────────────────────
    - { command: "git config", trust: standard, reason: "Reads or modifies local git configuration" }
    - { command: "git bisect", trust: standard, reason: "Binary searches commit history to find a bug" }
    - { command: "git add", trust: standard, reason: "Stages file changes for the next commit" }
    - { command: "git commit", trust: standard, reason: "Creates a new commit from staged changes" }
    - { command: "git fetch", trust: standard, reason: "Downloads objects and refs from a remote" }
    - { command: "git pull", trust: standard, reason: "Fetches and merges changes from a remote branch" }
    - { command: "git push", trust: full, reason: "Pushes local commits to a remote repository" }
    - { command: "git stash", trust: standard, reason: "Temporarily shelves uncommitted changes" }
    - { command: "git checkout", trust: standard, reason: "Switches branches or restores files" }
    - { command: "git switch", trust: standard, reason: "Switches to a different branch" }
    - { command: "git restore", trust: standard, reason: "Restores working tree files from a source" }
    - { command: "git merge", trust: standard, reason: "Merges another branch into the current branch" }
    - { command: "git cherry-pick", trust: standard, reason: "Applies changes from specific commits" }
    - { command: "git worktree", trust: standard, reason: "Manages multiple working trees" }
    - { command: "git clone", trust: standard, reason: "Clones a repository into a new directory" }
    - { command: "git init", trust: standard, reason: "Creates a new git repository" }
    # ── Git: safe file operations ────────────────────────────────────
    - { command: "git mv", trust: standard, reason: "Moves or renames a file tracked by git" }
    - { command: "git rm", trust: standard, reason: "Removes files from the working tree and index" }
    # ── Git: stash operations ────────────────────────────────────────
    - { command: "git stash pop", trust: standard, reason: "Applies and removes the top stashed changes" }
    - { command: "git stash apply", trust: standard, reason: "Applies stashed changes without removing them" }
    # ── Git: maintenance commands ──────────────────────────────────
    - { command: "git revert", trust: standard, reason: "Creates a new commit that undoes a previous commit" }
    - { command: "git gc", trust: full, reason: "Runs garbage collection on the repository" }

rules:
  # ============================================================
  # CRITICAL: Repo-corrupting operations
  # ============================================================
  - id: git-config-corrupting-core-flags
    level: critical
    match:
      command: git
      args:
        # Git config keys are case-insensitive (`CORE.BARE` ≡ `core.bare`).
        case_insensitive: true
        all_of: ["config"]
        any_of:
          - "core.bare"
          - "core.bare=**"
          - "core.worktree"
          - "core.worktree=**"
          - "core.repositoryformatversion"
          - "core.repositoryformatversion=**"
          - "core.hookspath"
          - "core.hookspath=**"
      flags:
        none_of: ["--get", "--get-all", "--get-regexp", "--get-color", "--list", "-l", "--unset", "--unset-all"]
    decision: deny
    reason: "Setting these core.* flags can corrupt the repo or redirect git hooks. Edit .git/config by hand if you genuinely need this."

  - id: git-c-corrupting-core-flags
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        any_of:
          - "core.bare=**"
          - "core.worktree=**"
          - "core.repositoryformatversion=**"
          - "core.hookspath=**"
          - "--config-env=core.bare=**"
          - "--config-env=core.worktree=**"
          - "--config-env=core.repositoryformatversion=**"
          - "--config-env=core.hookspath=**"
    decision: deny
    reason: "Setting core.* flags via `git -c key=value` or `git --config-env=key=ENVVAR` is the silent-mutation channel that motivated this rule. Edit .git/config by hand if you genuinely need this."

  # `git --config-env <KEY>=<ENVVAR> ...` (space form). The joined form is
  # caught by the per-rule `--config-env=KEY=...` patterns; the space form
  # is a separate argv shape ([config-env-flag, KEY=ENVVAR, ...]).
  - id: git-config-env-spaceform
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        all_of: ["--config-env"]
        # Match the KEY=ENVVAR pair against the same RCE-key list as the
        # joined form. Patterns intentionally elided to avoid duplication;
        # any `--config-env <key>=...` reaches this rule when key is on
        # the bypass list.
        any_of:
          - "core.sshcommand=**"
          - "core.editor=**"
          - "core.pager=**"
          - "help.browser=**"
          - "web.browser=**"
          - "trace2.configparams=**"
          - "trace2.envvars=**"
          - "trace2.eventtarget=**"
          - "trace2.normaltarget=**"
          - "trace2.perftarget=**"
          - "core.fsmonitor=**"
          - "core.gitproxy=**"
          - "core.askpass=**"
          - "core.alternaterefscommand=**"
          - "gc.recentobjectshook=**"
          - "core.hookspath=**"
          - "core.bare=**"
          - "core.worktree=**"
          - "core.repositoryformatversion=**"
          - "sequence.editor=**"
          - "filter.*.clean=**"
          - "filter.*.smudge=**"
          - "filter.*.process=**"
          - "diff.external=**"
          - "difftool.*.cmd=**"
          - "difftool.*.path=**"
          - "mergetool.*.path=**"
          - "mergetool.*.cmd=**"
          - "submodule.*.update=**"
          - "gpg.program=**"
          - "gpg.openpgp.program=**"
          - "gpg.ssh.program=**"
          - "gpg.ssh.defaultkeycommand=**"
          - "gpg.x509.program=**"
          - "credential.helper=**"
          - "credential.*://**"
          - "include.path=**"
          - "includeif.gitdir:**"
          - "includeif.gitdir/i:**"
          - "includeif.onbranch:**"
          - "includeif.hasconfig:**"
          - "protocol.ext.allow=**"
          - "protocol.file.allow=**"
          - "protocol.allow=**"
          - "safe.directory=**"
          - "safe.barerepository=**"
          - "uploadpack.packobjectshook=**"
          - "pager.*=**"
          - "alias.*=**"
          - "remote.*.uploadpack=**"
          - "remote.*.receivepack=**"
          - "remote.*.url=ext::**"
          - "remote.*.vcs=**"
          - "remote.*.proxy=**"
          - "url.*.insteadof=**"
          - "url.*.pushinsteadof=**"
          # URL-form base (`url.<scheme>://...` contains `/`, so the
          # `*` in `url.*.insteadof` cannot match the base segment).
          # Cover the full URL-base form via `url.*://**` which has `**`
          # at end-of-pattern and can freely match through the URL path.
          - "url.*://**"
          # man.<viewer>.{cmd,path} + browser.<browser>.{cmd,path} are
          # shell-evaluated by `git help`. Running `git help <topic>` with
          # these set executes the path/cmd as the man-viewer / browser.
          - "man.*.cmd=**"
          - "man.*.path=**"
          - "browser.*.cmd=**"
          - "browser.*.path=**"
          # Attribute-driven exec channels: diff.<driver>.{command,textconv}
          # and merge.<driver>.driver run external programs against files
          # matched in .gitattributes; interactive.diffFilter runs on add -p
          # / stash; trailer.<alias>.{cmd,command} runs on commit --trailer.
          - "diff.*.command=**"
          - "tar.*.command=**"
          - "diff.*.textconv=**"
          - "merge.*.driver=**"
          - "interactive.difffilter=**"
          - "trailer.*.cmd=**"
          - "trailer.*.command=**"
          # init.templateDir installs hooks copied to .git/hooks on
          # init/clone — same channel as core.hooksPath but via init.
          - "init.templatedir=**"
          # Git LFS custom transfer agents + extensions run external programs
          - "lfs.standalonetransferagent=**"
          - "lfs.customtransfer.*.path=**"
          - "lfs.extension.*.clean=**"
          - "lfs.extension.*.smudge=**"
          # send-email setters that run external programs
          - "sendemail.sendmailcmd=**"
          - "sendemail.smtpserver=**"
          - "sendemail.cccmd=**"
          - "sendemail.tocmd=**"
          - "sendemail.headercmd=**"
          - "imap.tunnel=**"
          - "http.sslverify=**"
          - "http.sslcainfo=**"
          - "http.sslcapath=**"
          - "http.sslversion=**"
          - "http.sslbackend=**"
          - "http.http://**"
          - "http.https://**"
    decision: deny
    reason: "git --config-env <KEY>=<ENVVAR> (space form) reads the value of <ENVVAR> and applies <KEY>=<value> for the invocation. Same channel as `-c key=value` and the joined `--config-env=key=ENVVAR` form. Deny when KEY is one of the documented program-substitution / repo-safety-bypass keys. Edit .git/config by hand if you genuinely need this."

  - id: git-c-rce-keys
    level: critical
    match:
      command: git
      args:
        # Git config section / variable names are case-insensitive
        # (`core.sshCommand` ≡ `CORE.SSHCOMMAND`); match without case folding.
        case_insensitive: true
        # Don't fire on `git config <key>` reads — the broad URL-base
        # pattern (`url.*://**`) and per-URL http patterns
        # (`http.{http,https}://**`) intentionally omit `=` because
        # `**` cannot cross `/` in mid-pattern positions to anchor to a
        # trailing `=value`. The persistent form has dedicated rules
        # (git-config-rce-keys-{spaceform,joinedform}) with proper
        # min_args + read-flag carve-outs. Use argv_first_not (which
        # checks ONLY argv[0]) instead of none_of so positional `config`
        # arguments later in argv — e.g. `git fetch config` where
        # `config` is a refspec/remote name — don't suppress the rule.
        argv_first_not:
          - "config"
        any_of:
          # core.* keys that substitute the program git invokes
          - "core.sshcommand=**"
          - "core.editor=**"
          - "core.pager=**"
          - "help.browser=**"
          - "web.browser=**"
          - "trace2.configparams=**"
          - "trace2.envvars=**"
          - "trace2.eventtarget=**"
          - "trace2.normaltarget=**"
          - "trace2.perftarget=**"
          - "core.fsmonitor=**"
          - "core.gitproxy=**"
          - "core.askpass=**"
          - "core.alternaterefscommand=**"
          - "gc.recentobjectshook=**"
          - "sequence.editor=**"
          # filter.<driver>.{clean,smudge,process} run on attribute-driven
          # files at add/checkout/normalize time
          - "filter.*.clean=**"
          - "filter.*.smudge=**"
          - "filter.*.process=**"
          # diff.external + difftool/mergetool cmd run on diff/merge
          - "diff.external=**"
          - "difftool.*.cmd=**"
          - "difftool.*.path=**"
          - "mergetool.*.path=**"
          - "mergetool.*.cmd=**"
          # submodule.<name>.update=!shell-cmd runs on submodule update
          - "submodule.*.update=!**"
          - "submodule.*.update='!**"
          - "submodule.*.update=\"!**"
          # gpg.* keys forge the signing program
          - "gpg.program=**"
          - "gpg.openpgp.program=**"
          - "gpg.ssh.program=**"
          - "gpg.ssh.defaultkeycommand=**"
          - "gpg.x509.program=**"
          # credential.helper is the documented credential-helper RCE channel
          - "credential.helper=**"
          # Per-URL credential settings: cover any scheme via `*://` (the
          # single-`*` matches the scheme name, which has no `/`; the trailing
          # `**` is at end-of-pattern so it freely matches any `://path`).
          - "credential.*://**"
          # include.path / includeIf.<cond>.path source an attacker-controlled
          # config file that can in turn set any of the keys above. Enumerate
          # the supported includeIf condition prefixes (same `**`-no-cross-`/`
          # caveat).
          - "include.path=**"
          - "includeif.gitdir:**"
          - "includeif.gitdir/i:**"
          - "includeif.onbranch:**"
          - "includeif.hasconfig:**"
          # protocol.{ext,file}.allow re-enables RCE clone protocols
          - "protocol.ext.allow=**"
          - "protocol.file.allow=**"
          - "protocol.allow=**"
          # safe.directory bypasses CVE-2022-24765 ownership-mismatch protection
          - "safe.directory=**"
          - "safe.barerepository=**"
          # server-side hook with arbitrary command
          - "uploadpack.packobjectshook=**"
          # pager.<cmd> runs an arbitrary command for that subcommand's output
          - "pager.*=**"
          # bang-aliases run a shell command (cover unquoted/single-/double-quoted)
          - "alias.*=!**"
          - "alias.*='!**"
          - "alias.*=\"!**"
          # remote.<name>.uploadpack / receivepack are the config-key
          # equivalents of `--upload-pack=` / `--receive-pack=`
          - "remote.*.uploadpack=**"
          - "remote.*.receivepack=**"
          # remote.<name>.url=ext::<cmd> is the documented RCE clone-URL
          # form (git protocol "ext" runs the value as a shell command)
          - "remote.*.url=ext::**"
          - "remote.*.vcs=**"
          - "remote.*.proxy=**"
          # url.<X>.{insteadOf,pushInsteadOf} silently rewrite URLs in
          # subsequent transport commands → can redirect the agent to an
          # attacker-controlled host
          - "url.*.insteadof=**"
          - "url.*.pushinsteadof=**"
          # URL-form base (`url.<scheme>://...` contains `/`, so the
          # `*` in `url.*.insteadof` cannot match the base segment).
          # Cover the full URL-base form via `url.*://**` which has `**`
          # at end-of-pattern and can freely match through the URL path.
          # The `none_of: ["config"]` above keeps this from firing on
          # `git config <key>` reads.
          - "url.*://**"
          # man.<viewer>.{cmd,path} + browser.<browser>.{cmd,path} are
          # shell-evaluated by `git help`.
          - "man.*.cmd=**"
          - "man.*.path=**"
          - "browser.*.cmd=**"
          - "browser.*.path=**"
          # Attribute-driven exec channels.
          - "diff.*.command=**"
          - "tar.*.command=**"
          - "diff.*.textconv=**"
          - "merge.*.driver=**"
          - "interactive.difffilter=**"
          - "trailer.*.cmd=**"
          - "trailer.*.command=**"
          # init.templateDir installs hooks copied to .git/hooks on
          # init/clone — same channel as core.hooksPath but via init.
          - "init.templatedir=**"
          # Git LFS custom transfer agents + extensions run external programs
          - "lfs.standalonetransferagent=**"
          - "lfs.customtransfer.*.path=**"
          - "lfs.extension.*.clean=**"
          - "lfs.extension.*.smudge=**"
          # send-email / imap setters that run external programs
          - "sendemail.sendmailcmd=**"
          - "sendemail.smtpserver=**"
          - "sendemail.cccmd=**"
          - "sendemail.tocmd=**"
          - "sendemail.headercmd=**"
          - "imap.tunnel=**"
          # --config-env=KEY=ENVVAR variants of all of the above
          - "--config-env=core.sshcommand=**"
          - "--config-env=core.editor=**"
          - "--config-env=core.pager=**"
          - "--config-env=help.browser=**"
          - "--config-env=web.browser=**"
          - "--config-env=trace2.configparams=**"
          - "--config-env=trace2.envvars=**"
          - "--config-env=trace2.eventtarget=**"
          - "--config-env=trace2.normaltarget=**"
          - "--config-env=trace2.perftarget=**"
          - "--config-env=core.fsmonitor=**"
          - "--config-env=core.gitproxy=**"
          - "--config-env=core.askpass=**"
          - "--config-env=sequence.editor=**"
          - "--config-env=filter.*.clean=**"
          - "--config-env=filter.*.smudge=**"
          - "--config-env=filter.*.process=**"
          - "--config-env=diff.external=**"
          - "--config-env=difftool.*.cmd=**"
          - "--config-env=difftool.*.path=**"
          - "--config-env=mergetool.*.path=**"
          - "--config-env=mergetool.*.cmd=**"
          - "--config-env=submodule.*.update=**"
          - "--config-env=gpg.program=**"
          - "--config-env=gpg.openpgp.program=**"
          - "--config-env=gpg.ssh.program=**"
          - "--config-env=gpg.ssh.defaultkeycommand=**"
          - "--config-env=gpg.x509.program=**"
          - "--config-env=credential.helper=**"
          - "--config-env=credential.*://**"
          - "--config-env=include.path=**"
          - "--config-env=includeif.gitdir:**"
          - "--config-env=includeif.gitdir/i:**"
          - "--config-env=includeif.onbranch:**"
          - "--config-env=includeif.hasconfig:**"
          - "--config-env=protocol.ext.allow=**"
          - "--config-env=protocol.file.allow=**"
          - "--config-env=protocol.allow=**"
          - "--config-env=safe.directory=**"
          - "--config-env=safe.barerepository=**"
          - "--config-env=uploadpack.packobjectshook=**"
          - "--config-env=pager.*=**"
          # `--config-env=alias.X=ENVVAR` reads the alias body from an env var
          # we can't introspect; deny any alias set via this channel.
          - "--config-env=alias.*=**"
          # R3 keys via --config-env joined form
          - "--config-env=remote.*.uploadpack=**"
          - "--config-env=remote.*.receivepack=**"
          - "--config-env=remote.*.url=**"
          - "--config-env=remote.*.vcs=**"
          - "--config-env=remote.*.proxy=**"
          - "--config-env=core.alternaterefscommand=**"
          - "--config-env=gc.recentobjectshook=**"
          - "--config-env=url.*.insteadof=**"
          - "--config-env=url.*.pushinsteadof=**"
          - "--config-env=url.*://**"
          - "--config-env=man.*.cmd=**"
          - "--config-env=man.*.path=**"
          - "--config-env=browser.*.cmd=**"
          - "--config-env=browser.*.path=**"
          - "--config-env=diff.*.command=**"
          - "--config-env=tar.*.command=**"
          - "--config-env=diff.*.textconv=**"
          - "--config-env=merge.*.driver=**"
          - "--config-env=interactive.difffilter=**"
          - "--config-env=trailer.*.cmd=**"
          - "--config-env=trailer.*.command=**"
          - "--config-env=init.templatedir=**"
          - "--config-env=lfs.standalonetransferagent=**"
          - "--config-env=lfs.customtransfer.*.path=**"
          - "--config-env=lfs.extension.*.clean=**"
          - "--config-env=lfs.extension.*.smudge=**"
          - "--config-env=sendemail.sendmailcmd=**"
          - "--config-env=sendemail.smtpserver=**"
          - "--config-env=sendemail.cccmd=**"
          - "--config-env=sendemail.tocmd=**"
          - "--config-env=sendemail.headercmd=**"
          - "--config-env=imap.tunnel=**"
    decision: deny
    reason: "Setting these git config keys via `-c` or `--config-env` substitutes the program git invokes (ssh / editor / pager / fsmonitor / gpg / credential helper / ext protocol / pager.<cmd>), sources an attacker-controlled include file, or bypasses repo-safety checks. Set them in .git/config by hand if you genuinely need this."

  # Split git config setters into two rules so the bare-key form
  # (`git config <key>`, no value) — which is a READ — does not get denied
  # while the space-set form (`git config <key> <value>`) and joined-set form
  # (`git config <key>=<value>`) both deny.
  - id: git-config-rce-keys-spaceform
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        # Require argv to have at least 3 tokens: [config, key, value]. This
        # excludes the read form `git config <key>` (argv len = 2) which is
        # how `git config <key>` (no value) is interpreted.
        min_args: 3
        all_of: ["config"]
        any_of:
          - "core.sshcommand"
          - "core.editor"
          - "core.pager"
          - "core.fsmonitor"
          - "core.gitproxy"
          - "core.askpass"
          - "sequence.editor"
          - "filter.*.clean"
          - "filter.*.smudge"
          - "filter.*.process"
          - "diff.external"
          - "difftool.*.cmd"
          - "difftool.*.path"
          - "difftool.*.path=**"
          - "mergetool.*.cmd"
          - "mergetool.*.path"
          - "mergetool.*.path=**"
          - "submodule.*.update"
          - "gpg.program"
          - "gpg.openpgp.program"
          - "gpg.ssh.program"
          - "gpg.ssh.defaultkeycommand"
          - "gpg.x509.program"
          - "credential.helper"
          - "credential.*://**"
          - "include.path"
          - "includeif.gitdir:**"
          - "includeif.gitdir/i:**"
          - "includeif.onbranch:**"
          - "includeif.hasconfig:**"
          - "protocol.ext.allow"
          - "protocol.file.allow"
          - "protocol.allow"
          - "protocol.allow=**"
          - "safe.directory"
          - "safe.barerepository"
          - "uploadpack.packobjectshook"
          - "pager.*"
          - "help.browser"
          - "help.browser=**"
          - "web.browser"
          - "web.browser=**"
          - "trace2.configparams"
          - "trace2.configparams=**"
          - "trace2.envvars"
          - "trace2.envvars=**"
          - "trace2.eventtarget"
          - "trace2.eventtarget=**"
          - "trace2.normaltarget"
          - "trace2.normaltarget=**"
          - "trace2.perftarget"
          - "trace2.perftarget=**"
          - "trace2.envvars=**"
          - "trace2.eventtarget=**"
          - "trace2.normaltarget=**"
          - "trace2.perftarget=**"
          - "remote.*.uploadpack"
          - "remote.*.uploadpack=**"
          - "remote.*.receivepack"
          - "remote.*.receivepack=**"
          - "remote.*.url"
          - "remote.*.url=ext::**"
          - "remote.*.vcs"
          - "remote.*.vcs=**"
          - "remote.*.proxy"
          - "remote.*.proxy=**"
          - "core.alternaterefscommand"
          - "core.alternaterefscommand=**"
          - "gc.recentobjectshook"
          - "gc.recentobjectshook=**"
          - "gc.recentobjectshook=**"
          - "url.*.insteadof"
          - "url.*.insteadof=**"
          - "url.*.pushinsteadof"
          - "url.*.pushinsteadof=**"
          # R3 Codex additions
          - "man.*.cmd"
          - "man.*.cmd=**"
          - "man.*.path"
          - "man.*.path=**"
          - "browser.*.cmd"
          - "browser.*.cmd=**"
          - "browser.*.path"
          - "browser.*.path=**"
          - "diff.*.command"
          - "diff.*.command=**"
          - "tar.*.command"
          - "tar.*.command=**"
          - "tar.*.command=**"
          - "diff.*.textconv"
          - "diff.*.textconv=**"
          - "merge.*.driver"
          - "merge.*.driver=**"
          - "interactive.difffilter"
          - "interactive.difffilter=**"
          - "trailer.*.cmd"
          - "trailer.*.cmd=**"
          - "trailer.*.command"
          - "trailer.*.command=**"
          - "init.templatedir"
          - "init.templatedir=**"
          - "lfs.standalonetransferagent"
          - "lfs.standalonetransferagent=**"
          - "lfs.customtransfer.*.path"
          - "lfs.customtransfer.*.path=**"
          - "lfs.extension.*.clean"
          - "lfs.extension.*.clean=**"
          - "lfs.extension.*.smudge"
          - "lfs.extension.*.smudge=**"
          - "sendemail.sendmailcmd"
          - "sendemail.sendmailcmd=**"
          - "sendemail.smtpserver"
          - "sendemail.smtpserver=**"
          - "sendemail.smtpserver=**"
          - "sendemail.cccmd"
          - "sendemail.cccmd=**"
          - "sendemail.tocmd"
          - "sendemail.tocmd=**"
          - "sendemail.headercmd"
          - "sendemail.headercmd=**"
          - "imap.tunnel"
          - "imap.tunnel=**"
      flags:
        # Reads and unsets must remain allowed.
        none_of: ["--get", "--get-all", "--get-regexp", "--get-color", "--list", "-l", "--unset", "--unset-all", "--remove-section"]
    decision: deny
    reason: "Persistently setting these git config keys substitutes the program git invokes (ssh / editor / pager / fsmonitor / askpass / gpg / filter / diff-external / submodule update / credential helper / ext protocol / pager.<cmd>), sources an attacker-controlled include file, or bypasses repo-safety checks. The persistent form survives across invocations. Edit .git/config by hand if you genuinely need this."

  - id: git-config-rce-keys-joinedform
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        all_of: ["config"]
        any_of:
          - "core.sshcommand=**"
          - "core.editor=**"
          - "core.pager=**"
          - "help.browser=**"
          - "web.browser=**"
          - "trace2.configparams=**"
          - "trace2.envvars=**"
          - "trace2.eventtarget=**"
          - "trace2.normaltarget=**"
          - "trace2.perftarget=**"
          - "core.fsmonitor=**"
          - "core.alternaterefscommand=**"
          - "gc.recentobjectshook=**"
          - "core.gitproxy=**"
          - "core.askpass=**"
          - "sequence.editor=**"
          - "filter.*.clean=**"
          - "filter.*.smudge=**"
          - "filter.*.process=**"
          - "diff.external=**"
          - "difftool.*.cmd=**"
          - "difftool.*.path=**"
          - "mergetool.*.path=**"
          - "mergetool.*.cmd=**"
          - "submodule.*.update=**"
          - "gpg.program=**"
          - "gpg.openpgp.program=**"
          - "gpg.ssh.program=**"
          - "gpg.ssh.defaultkeycommand=**"
          - "gpg.x509.program=**"
          - "credential.helper=**"
          - "credential.*://**"
          - "include.path=**"
          - "protocol.ext.allow=**"
          - "protocol.file.allow=**"
          - "protocol.allow=**"
          - "safe.directory=**"
          - "safe.barerepository=**"
          - "uploadpack.packobjectshook=**"
          - "pager.*=**"
          - "remote.*.uploadpack=**"
          - "remote.*.receivepack=**"
          - "remote.*.url=ext::**"
          - "remote.*.vcs=**"
          - "remote.*.proxy=**"
          - "url.*.insteadof=**"
          - "url.*.pushinsteadof=**"
          - "man.*.cmd=**"
          - "man.*.path=**"
          - "browser.*.cmd=**"
          - "browser.*.path=**"
          - "diff.*.command=**"
          - "tar.*.command=**"
          - "diff.*.textconv=**"
          - "merge.*.driver=**"
          - "interactive.difffilter=**"
          - "trailer.*.cmd=**"
          - "trailer.*.command=**"
          - "init.templatedir=**"
          - "lfs.standalonetransferagent=**"
          - "lfs.customtransfer.*.path=**"
          - "lfs.extension.*.clean=**"
          - "lfs.extension.*.smudge=**"
          - "sendemail.sendmailcmd=**"
          - "sendemail.smtpserver=**"
          - "sendemail.cccmd=**"
          - "sendemail.tocmd=**"
          - "sendemail.headercmd=**"
          - "imap.tunnel=**"
      flags:
        none_of: ["--get", "--get-all", "--get-regexp", "--get-color", "--list", "-l", "--unset", "--unset-all", "--remove-section"]
    decision: deny
    reason: "Persistently setting these git config keys via the joined `key=value` form substitutes the program git invokes or bypasses repo-safety checks. Edit .git/config by hand if you genuinely need this."

  - id: git-c-exfil-keys
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        any_of:
          # Global http.* exfiltration / redirect channels (distinct from
          # per-URL forms and from TLS verification disable). Setting these
          # via `-c` lets an attacker leak cookies / route through an evil
          # proxy / inject arbitrary headers / follow attacker-redirects.
          - "http.cookiefile=**"
          - "http.proxy=**"
          - "http.proxysslcainfo=**"
          - "http.proxyssluse=**"
          - "http.proxyauthmethod=**"
          - "http.extraheader=**"
          - "http.useragent=**"
          - "http.followredirects=**"
          - "--config-env=http.cookiefile=**"
          - "--config-env=http.proxy=**"
          - "--config-env=http.proxyauthmethod=**"
          - "--config-env=http.proxysslcainfo=**"
          - "--config-env=http.proxyssluse=**"
          - "--config-env=http.extraheader=**"
          - "--config-env=http.useragent=**"
          - "--config-env=http.followredirects=**"
    decision: deny
    reason: "Setting these global http.* keys via `-c` or `--config-env` reroutes git through an attacker-controlled proxy (http.proxy), injects HTTP headers (http.extraHeader), follows attacker redirects (http.followRedirects), or leaks cookies (http.cookieFile). Edit .git/config by hand if you genuinely need this."

  - id: git-config-exfil-keys
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        min_args: 3
        all_of: ["config"]
        any_of:
          - "http.cookiefile"
          - "http.cookiefile=**"
          - "http.proxy"
          - "http.proxy=**"
          - "http.proxysslcainfo"
          - "http.proxysslcainfo=**"
          - "http.proxyauthmethod"
          - "http.proxyauthmethod=**"
          - "http.proxyssluse"
          - "http.proxyssluse=**"
          - "http.extraheader"
          - "http.extraheader=**"
          - "http.useragent"
          - "http.useragent=**"
          - "http.followredirects"
          - "http.followredirects=**"
      flags:
        none_of: ["--get", "--get-all", "--get-regexp", "--get-color", "--list", "-l", "--unset", "--unset-all", "--remove-section"]
    decision: deny
    reason: "Persistently setting these global http.* keys reroutes git through an attacker-controlled proxy / injects HTTP headers / follows attacker redirects / leaks cookies. Edit .git/config by hand if you genuinely need this."

  - id: git-config-alias-bang
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        min_args: 3
        all_of:
          - "config"
          - "alias.*"
        # The bang prefix on the value (in a separate argv slot for the
        # space form) is what makes the alias execute a shell command.
        # Tree-sitter strips standalone shell quotes so the value comes
        # through as `!cmd`. glob-match treats a leading `!` as negation,
        # so escape it with a literal `\` (single-quoted in YAML so the
        # backslash passes through unchanged).
        any_of:
          - '\!**'
      flags:
        none_of: ["--get", "--get-all", "--get-regexp", "--get-color", "--list", "-l", "--unset", "--unset-all", "--remove-section"]
    decision: deny
    reason: "`git config alias.<name> '!shell-cmd'` persists an alias that runs an arbitrary shell command when invoked. Edit .git/config by hand if you genuinely need this."

  - id: git-env-rce-vars
    level: critical
    match:
      command: git
      env:
        case_insensitive: true
        any_of:
          # Direct program-substitution env vars
          - "GIT_SSH"
          - "GIT_SSH_COMMAND"
          - "GIT_ASKPASS"
          - "SSH_ASKPASS"
          - "GIT_EDITOR"
          - "GIT_SEQUENCE_EDITOR"
          - "GIT_PAGER"
          - "GIT_PROXY_COMMAND"
          - "GIT_EXTERNAL_DIFF"
          # GIT_CONFIG_COUNT/KEY_n/VALUE_n family is the env-var equivalent
          # of `-c key=value`; deny the whole family
          - "GIT_CONFIG_COUNT"
          - "GIT_CONFIG_KEY_*"
          - "GIT_CONFIG_VALUE_*"
          # GIT_CONFIG_PARAMETERS is the same key=value channel via env
          - "GIT_CONFIG_PARAMETERS"
          # Pointing git at attacker-controlled config files
          - "GIT_CONFIG_GLOBAL"
          - "GIT_CONFIG_SYSTEM"
          - "GIT_CONFIG"
          # TLS verification disable
          - "GIT_SSL_NO_VERIFY"
          # Run-time exec path redirection
          - "GIT_EXEC_PATH"
          # GIT_TEMPLATE_DIR installs attacker-controlled hooks on init/clone
          - "GIT_TEMPLATE_DIR"
          # GIT_ALLOW_PROTOCOL overrides protocol.allow — enables ext:: et al
          - "GIT_ALLOW_PROTOCOL"
          # Trace2 family exfiltrates config / env values to a target file
          - "GIT_TRACE2"
          - "GIT_TRACE2_EVENT"
          - "GIT_TRACE2_PERF"
          - "GIT_TRACE2_CONFIG_PARAMS"
          - "GIT_TRACE2_ENV_VARS"
          # TLS verification / trust-anchor env equivalents of http.ssl*
          - "GIT_SSL_CAINFO"
          - "GIT_SSL_CAPATH"
          - "GIT_SSL_VERSION"
    decision: deny
    reason: "Setting these GIT_* env vars on a git invocation substitutes the program git invokes (ssh / editor / pager / askpass / external-diff / proxy-command), reroutes git at attacker-controlled config files (GIT_CONFIG{,_GLOBAL,_SYSTEM}), or injects ad-hoc config (GIT_CONFIG_COUNT/KEY_n/VALUE_n). Set the safe equivalent in .git/config by hand if you genuinely need this."

  - id: git-transport-program-flags
    level: critical
    match:
      command: git
      args:
        any_of:
          - "--upload-pack=**"
          - "--receive-pack=**"
          - "--exec=**"
    decision: deny
    reason: "git --upload-pack/--receive-pack/--exec specify the program git runs (locally or on the remote for file:// / over fetch / over push). Arbitrary-program channel."

  - id: git-transport-program-flags-spaceform
    level: critical
    match:
      command: git
      args:
        # Space form `git fetch --upload-pack PROG` / `git push --receive-pack PROG`.
        # Require argv >= 3 to avoid matching the bare flag with no value.
        # NOTE: `--exec` (space form) intentionally not denied here — `git rebase
        # --exec "cmd"` is a legitimate (already-trust-gated) interactive flow,
        # while `git fetch --exec=PROG` joined-form is caught above. The
        # `git archive --exec` space-form RCE is caught by git-archive-exec-spaceform.
        min_args: 3
        any_of:
          - "--upload-pack"
          - "--receive-pack"
    decision: deny
    reason: "git --upload-pack / --receive-pack <prog> (space form) specifies the program git runs at the remote during fetch / push. Arbitrary-program channel."

  - id: git-archive-exec-spaceform
    level: critical
    match:
      command: git
      args:
        # `git archive --remote=<repo> --exec <cmd>` runs <cmd> locally as
        # the upload-archive side. Cannot reuse the general space-form
        # rule because `git rebase --exec "cmd"` is a legitimate
        # already-ask-gated flow with the same flag shape.
        all_of:
          - "archive"
          - "--exec"
    decision: deny
    reason: "git archive --exec <cmd> (space form) runs <cmd> locally during upload-archive. Arbitrary-program channel. Use the underlying tool directly if you genuinely need this."

  - id: git-c-tls-downgrade
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        # Same scoping as git-c-rce-keys: don't fire on `git config ...`
        # (the persistent form has dedicated git-config-tls-downgrade with
        # min_args + read-flag carve-outs). Use argv_first_not so a
        # positional `config` arg later in argv (e.g. `git fetch config`)
        # doesn't suppress the rule.
        argv_first_not:
          - "config"
        any_of:
          # Disabling TLS verification opens MITM. Per-URL forms covered.
          - "http.sslverify=false"
          - "http.sslverify=0"
          - "http.sslverify=no"
          - "http.sslverify=off"
          # Trust-anchor substitution (attacker pins their own CA / lowers
          # TLS version → MITM with valid-looking cert).
          - "http.sslcainfo=**"
          - "http.sslcapath=**"
          - "http.sslversion=**"
          - "http.sslbackend=**"
          # Per-URL http config: glob_match's `**` does not cross `/` in the
          # middle of a pattern, so we cannot pinpoint `.sslverify=false` after
          # an `http.<URL>.` prefix that contains `://` and `/`. Take the
          # broader posture of denying any per-URL http config set via `-c`.
          # An AI agent shouldn't be touching per-URL HTTP config unless the
          # user explicitly asked.
          - "http.http://**"
          - "http.https://**"
          - "--config-env=http.sslverify=**"
          - "--config-env=http.sslcainfo=**"
          - "--config-env=http.sslcapath=**"
          - "--config-env=http.sslversion=**"
          - "--config-env=http.sslbackend=**"
          - "--config-env=http.http://**"
          - "--config-env=http.https://**"
    decision: deny
    reason: "Disabling TLS verification (http.sslVerify=false / sslCAInfo / sslCAPath / sslVersion / sslBackend) — or pinning an attacker-controlled CA — enables MITM on git over HTTPS. Fix the cert chain rather than disabling verification."

  - id: git-config-tls-downgrade
    level: critical
    match:
      command: git
      args:
        case_insensitive: true
        min_args: 3
        all_of: ["config"]
        any_of:
          - "http.sslverify"
          - "http.sslcainfo"
          - "http.sslcainfo=**"
          - "http.sslcapath"
          - "http.sslcapath=**"
          - "http.sslversion"
          - "http.sslversion=**"
          - "http.sslbackend"
          - "http.sslbackend=**"
          # Per-URL persistent set: same broad posture as the -c rule.
          - "http.http://**"
          - "http.https://**"
      flags:
        none_of: ["--get", "--get-all", "--get-regexp", "--get-color", "--list", "-l", "--unset", "--unset-all", "--remove-section"]
    decision: deny
    reason: "Persistently disabling TLS verification (http.sslVerify / sslCAInfo / sslCAPath / sslVersion / sslBackend) — or pinning an attacker-controlled CA — enables MITM on git over HTTPS. Fix the cert chain rather than disabling verification."

  - id: git-config-edit
    level: critical
    match:
      command: git
      args:
        all_of: ["config"]
      flags:
        any_of: ["-e", "--edit"]
    decision: deny
    reason: "git config --edit opens .git/config in the editor; with an agent-controlled GIT_EDITOR this becomes a silent rewrite. Edit .git/config by hand if you genuinely need this."

  - id: redirect-write-git-admin
    level: critical
    match:
      redirect:
        op:
          any_of: [">", ">>", ">|", "<>"]
        target:
          any_of:
            - "**/.git/config"
            - "**/.git/HEAD"
            - "**/.git/refs/**"
            - "**/.git/packed-refs"
            - "**/.git/hooks/**"
            - "**/.git/index"
            - "**/.git/info/**"
            - "**/.git/objects/info/**"
    decision: deny
    reason: "Direct write to a git admin file (config / HEAD / refs / packed-refs / hooks / index / info / objects/info) can corrupt the repo, arm a malicious hook, or redirect git at an attacker-controlled object store. Edit by hand if you genuinely need this."

  - id: copy-overwrite-git-admin
    level: critical
    match:
      command:
        any_of: ["cp", "install", "ditto", "rsync", "mv", "ln", "scp", "curl", "wget", "tar", "bsdtar"]
      args:
        any_of:
          # Specific admin file paths
          - "**/.git/config"
          - "**/.git/HEAD"
          - "**/.git/refs/**"
          - "**/.git/packed-refs"
          - "**/.git/hooks/**"
          - "**/.git/index"
          - "**/.git/info/**"
          - "**/.git/objects/info/**"
          # Directory destinations (cp <src> .git, tar -C .git/, etc.)
          - ".git"
          - ".git/"
          - "**/.git"
          - "**/.git/"
          - ".git/hooks"
          - ".git/hooks/"
          - "**/.git/hooks"
          - "**/.git/hooks/"
          # Joined-flag forms: curl/wget --output=.git/X, --output-dir=.git/X
          - "--output=.git/**"
          - "--output=./.git/**"
          - "--output-dir=.git"
          - "--output-dir=.git/"
          - "--output-dir=.git/hooks"
          - "--output-dir=.git/hooks/"
          - "--output-dir=./.git/**"
          # wget --directory-prefix= joined-flag form
          - "--directory-prefix=.git"
          - "--directory-prefix=.git/"
          - "--directory-prefix=.git/**"
          # tar --directory= joined-flag form
          - "--directory=.git"
          - "--directory=.git/"
          - "--directory=.git/**"
    decision: deny
    reason: "Overwriting or extracting into a git admin path (config / HEAD / refs / packed-refs / hooks, or the .git directory itself) corrupts the repo or arms a malicious hook. Covers cp/install/ditto/rsync/mv/ln/scp/curl/wget/tar/bsdtar. Edit by hand if you genuinely need this."

  - id: git-filter-branch
    level: critical
    match:
      command: git
      args:
        any_of: ["filter-branch"]
    decision: deny
    reason: "git filter-branch rewrites history across a whole repo. Run it manually if you really need it."

  - id: git-filter-repo
    level: critical
    match:
      command: git
      args:
        any_of: ["filter-repo"]
    decision: deny
    reason: "git filter-repo rewrites history across a whole repo. Run it manually if you really need it."

  - id: git-filter-repo-direct
    level: critical
    match:
      command: git-filter-repo
    decision: deny
    reason: "git-filter-repo rewrites history across a whole repo. Run it manually if you really need it."

  - id: git-replace
    level: critical
    match:
      command: git
      args:
        any_of: ["replace"]
    decision: deny
    reason: "git replace manipulates the commit graph. Use revert/rebase instead, or run replace by hand if you genuinely need it."

  - id: git-symbolic-ref-write
    level: high
    match:
      command: git
      args:
        all_of: ["symbolic-ref"]
        any_of:
          - "refs/heads/*"
          - "refs/remotes/*"
          - "refs/tags/*"
    decision: ask
    reason: "git symbolic-ref with a target argument rewrites the symbolic reference (commonly HEAD). Read forms (no target) remain allowed."

  - id: git-update-ref-protected
    level: critical
    match:
      command: git
      args:
        all_of: ["update-ref"]
        any_of:
          - "main"
          - "master"
          - "refs/heads/main"
          - "refs/heads/master"
          - "refs/remotes/origin/main"
          - "refs/remotes/origin/master"
    decision: deny
    reason: "Moving main/master via git update-ref bypasses worktree-checkout safety. This is the exact pattern from the 2026-05-09 incident. Use checkout/merge/reset on the target branch instead, or run update-ref by hand if you genuinely need this."

  # ============================================================
  # HIGH: VCS destructive operations
  # ============================================================
  - id: git-update-ref
    level: high
    match:
      command: git
      args:
        any_of: ["update-ref"]
    decision: ask
    reason: "git update-ref bypasses worktree-checkout and lost-commit safety. Prefer git branch/checkout/reset/merge."

  - id: git-force-push-main
    level: high
    match:
      command: git
      flags:
        any_of: ["--force", "-f", "--force-with-lease", "--force-if-includes"]
      args:
        any_of: ["main", "master"]
    decision: ask
    reason: "Force pushing to main/master branch"

  - id: git-reset-hard
    level: high
    match:
      command: git
      args:
        any_of: ["--hard"]
    decision: ask
    reason: "git reset --hard discards uncommitted changes"

  - id: git-clean-force
    level: high
    match:
      command: git
      args:
        any_of: ["clean"]
      flags:
        any_of: ["-f", "--force"]
    decision: ask
    reason: "git clean -f permanently removes untracked files"

  - id: git-branch-delete-force
    level: high
    match:
      command: git
      args:
        any_of: ["branch"]
      flags:
        any_of: ["-D"]
    decision: ask
    reason: "Force deleting a git branch"

  - id: git-commit-amend
    level: high
    match:
      command: git
      args:
        any_of: ["commit"]
      flags:
        any_of: ["--amend"]
    decision: ask
    reason: "git commit --amend rewrites the previous commit"

  - id: git-remote-modify
    level: high
    match:
      command: git
      args:
        any_of: ["remote"]
      flags:
        any_of: ["add", "remove", "rm", "set-url", "rename"]
    decision: ask
    reason: "Modifying git remote configuration"

  - id: git-tag-delete
    level: high
    match:
      command: git
      args:
        any_of: ["tag"]
      flags:
        any_of: ["-d", "--delete"]
    decision: ask
    reason: "Deleting git tag"

  - id: git-push-delete
    level: high
    match:
      command: git
      args:
        any_of: ["push"]
      flags:
        any_of: ["--delete", "-d"]
    decision: ask
    reason: "Deleting remote branch or tag"

  - id: git-config-global
    level: high
    match:
      command: git
      args:
        any_of: ["config"]
      flags:
        any_of: ["--global", "--system"]
    decision: ask
    reason: "Modifying global/system git configuration"

  - id: git-stash-drop
    level: high
    match:
      command: git
      args:
        any_of: ["stash"]
      flags:
        any_of: ["drop", "clear"]
    decision: ask
    reason: "Dropping stashed changes (data loss)"

  - id: git-reflog-delete
    level: high
    match:
      command: git
      args:
        any_of: ["reflog"]
      flags:
        any_of: ["delete", "expire"]
    decision: ask
    reason: "Deleting reflog entries (history loss)"

  - id: git-gc-prune
    level: high
    match:
      command: git
      args:
        any_of: ["gc"]
      flags:
        starts_with: ["--prune"]
    decision: ask
    reason: "git gc with pruning can remove unreachable objects"

  - id: git-worktree-remove
    level: high
    match:
      command: git
      args:
        any_of: ["worktree"]
      flags:
        any_of: ["remove", "prune"]
    decision: ask
    reason: "Removing git worktree"

  - id: git-bisect-reset
    level: high
    match:
      command: git
      args:
        any_of: ["bisect"]
      flags:
        any_of: ["reset"]
    decision: ask
    reason: "Exiting bisect mode (changes HEAD)"

  - id: git-pull-force
    level: high
    match:
      command: git
      args:
        any_of: ["pull"]
      flags:
        any_of: ["--force", "-f"]
    decision: ask
    reason: "git pull --force can overwrite local changes"

  - id: git-rebase
    level: high
    match:
      command: git
      args:
        any_of: ["rebase"]
    decision: ask
    reason: "git rebase rewrites history and can cause data loss"

  - id: git-symbolic-ref-delete
    level: high
    match:
      command: git
      args:
        any_of: ["symbolic-ref"]
      flags:
        any_of: ["--delete", "-d"]
    decision: ask
    reason: "git symbolic-ref --delete removes symbolic references"

  - id: git-force-push-any
    level: high
    match:
      command: git
      flags:
        any_of: ["--force", "-f", "--force-with-lease", "--force-if-includes"]
      args:
        any_of: ["push"]
    decision: ask
    reason: "Force pushing (any branch)"

  # ============================================================
  # STRICT: Cautionary git operations
  # ============================================================
  - id: git-checkout-dot
    level: strict
    match:
      command: git
      args:
        any_of: ["checkout", "restore"]
      flags:
        any_of: [".", "--"]
    decision: ask
    reason: "Discarding all local changes"
