From 460d99c6362f877823c6dfd29a979d20881b83f5 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:40:02 +0200 Subject: [PATCH] feat(hooks): require human approval before git push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Copilot preToolUse command hook that forces an explicit, interactive approval before the agent runs any `git push`. Local commits stay fully rewritable (amend/reset/rebase) until pushed, so `git push` is the first irreversible step to a remote — the right place for a human checkpoint. The hook reads the tool invocation on stdin and, when a shell tool contains a `git push`, emits `permissionDecision: "ask"` so the CLI prompts the user; all other tool calls fall through untouched. It always exits 0 because preToolUse command hooks are fail-closed (a non-zero exit would deny the call). Ships enabled; disable with SKIP_PUSH_APPROVAL=true. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .apm/hooks/git-push-approval.json | 13 +++ .../require-push-approval.sh | 89 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 .apm/hooks/git-push-approval.json create mode 100755 scripts/git-push-approval/require-push-approval.sh diff --git a/.apm/hooks/git-push-approval.json b/.apm/hooks/git-push-approval.json new file mode 100644 index 0000000..30f9029 --- /dev/null +++ b/.apm/hooks/git-push-approval.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "${PLUGIN_ROOT}/scripts/git-push-approval/require-push-approval.sh", + "cwd": ".", + "timeoutSec": 10 + } + ] + } +} diff --git a/scripts/git-push-approval/require-push-approval.sh b/scripts/git-push-approval/require-push-approval.sh new file mode 100755 index 0000000..4c2764d --- /dev/null +++ b/scripts/git-push-approval/require-push-approval.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Git Push Approval Hook +# Forces an explicit, interactive human approval before the AI coding agent runs +# any `git push`. A commit is entirely local and rewritable (amend / reset / +# rebase) until it is pushed, so `git push` is the first irreversible step that +# publishes work to a remote — the right place for a human checkpoint. +# +# Runs as a Copilot `preToolUse` command hook. It reads the tool invocation as +# JSON on stdin and, when the tool is a shell command that contains a `git push`, +# emits a `permissionDecision: "ask"` object so the CLI prompts the user to +# approve. For every other tool call it emits no decision and lets the normal +# permission flow proceed. +# +# IMPORTANT: `preToolUse` command hooks are fail-closed — a non-zero exit (or a +# crash) denies the tool call. This script therefore always exits 0 and only +# ever upgrades the decision to "ask"; it never blocks unrelated tools. +# +# Environment variables: +# SKIP_PUSH_APPROVAL - "true" to disable the guardrail entirely (default: unset) +# +# See docs: https://docs.github.com/en/copilot/reference/hooks-reference + +set -uo pipefail + +# Escape hatch: allow operators to turn the guardrail off for a session. +if [[ "${SKIP_PUSH_APPROVAL:-}" == "true" ]]; then + exit 0 +fi + +# Read the tool invocation payload from stdin. +INPUT="$(cat)" + +# Extract the tool name and its arguments, tolerating both the camelCase payload +# (toolName / toolArgs) and the VS Code compatible payload (tool_name / tool_input). +TOOL_NAME="" +TOOL_ARGS="" + +if command -v jq &>/dev/null; then + TOOL_NAME="$(printf '%s' "${INPUT}" | jq -r '.toolName // .tool_name // empty' 2>/dev/null || printf '')" + TOOL_ARGS="$(printf '%s' "${INPUT}" | jq -r ' + (.toolArgs // .tool_input // empty) as $a + | if ($a | type) == "string" then $a + elif ($a | type) == "object" then ($a.command // $a.cmd // $a.script // ($a | tojson)) + else ($a | tojson) end' 2>/dev/null || printf '')" +fi + +# Fallback when jq is unavailable or produced nothing: scan the raw payload. +if [[ -z "${TOOL_NAME}" ]]; then + TOOL_NAME="$(printf '%s' "${INPUT}" | + grep -oiE '"tool_?[nN]ame"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | + sed -E 's/.*:[[:space:]]*"//; s/"$//' || printf '')" +fi +if [[ -z "${TOOL_ARGS}" ]]; then + TOOL_ARGS="${INPUT}" +fi + +# Only shell-execution tools can run `git push`. Gate on the tool name so that, +# for example, editing a document that merely mentions "git push" is unaffected. +# An empty/unknown tool name falls through to scanning, erring toward asking. +is_shell_tool() { + case "${1,,}" in + bash | powershell | shell | "") return 0 ;; + *) return 1 ;; + esac +} + +# Match `git ... push` within a single command: `git push`, `git push origin main`, +# `git -C /repo push`, `/usr/bin/git --no-pager push`. Intervening tokens may be +# flags or values but must not cross a command separator (; & |), so unrelated +# commands like `git status && npm run push` do not trigger a prompt. The trailing +# boundary accepts any non-word character (or end of string) so the match still +# holds when scanning JSON-embedded command text (e.g. `git push"`). +GIT_PUSH_REGEX='(^|[^[:alnum:]_])git([[:space:]]+[^[:space:];&|]+)*[[:space:]]+push([^[:alnum:]_]|$)' + +if is_shell_tool "${TOOL_NAME}" && printf '%s' "${TOOL_ARGS}" | grep -qiE "${GIT_PUSH_REGEX}"; then + REASON="git push publishes commits to a remote and is irreversible, unlike local commits (which can still be amended, reset, or rebased). This repository's guardrail requires explicit human approval for every push. Set SKIP_PUSH_APPROVAL=true to disable it." + # Emit exactly one decision object. jq keeps the reason safely JSON-escaped; + # the printf fallback covers environments without jq. + if command -v jq &>/dev/null; then + jq -cn --arg reason "${REASON}" '{permissionDecision: "ask", permissionDecisionReason: $reason}' + else + printf '{"permissionDecision":"ask","permissionDecisionReason":"%s"}\n' \ + "git push is irreversible and requires explicit human approval. Set SKIP_PUSH_APPROVAL=true to disable." + fi +fi + +# Always succeed: fail-closed hooks would otherwise deny unrelated tool calls. +exit 0