diff --git a/config/shared/hooks/README.md b/config/shared/hooks/README.md new file mode 100644 index 000000000..57347760c --- /dev/null +++ b/config/shared/hooks/README.md @@ -0,0 +1,21 @@ +# Shared agent GitHub guardrails + +`block-git-push.sh` and `block-gh-settings.sh` are shared `PreToolUse` hooks for Codex, Claude Code, Cursor, GitHub Copilot, and Grok. They accept the command from each client's supported JSON shape: + +- `.tool.input.command` +- `.tool_input.command` +- `.toolArgs.command` +- `.toolInput.command` +- `.command` + +Both hooks exit `0` when a command may proceed and exit `2` with a `BLOCKED by ...` diagnostic when it must stop. + +## Protected operations + +The push hook blocks explicit and implicit updates or deletions of `main`, `master`, and the cached remote default branch. It resolves upstream and push configuration, bulk pushes, force variants, and Git aliases without executing alias bodies. Direct pushes remain allowed for `shunkakinoki/wiki` and `shunkakinoki/gthq`. + +The settings hook blocks repository control-plane mutations through settings-oriented `gh` commands, REST or GraphQL API calls, and common direct HTTP clients. Read-only API calls and ordinary pull request, issue, review, and comment operations remain available. + +## Security boundary + +These hooks provide fast feedback and prevent common mistakes. They run with the same user permissions as the agent and can be bypassed, disabled, or avoided through an unsupported tool path. Restricted GitHub credentials and server-side branch rulesets are the authoritative controls; do not grant an agent an administrator credential because these hooks are installed. diff --git a/config/shared/hooks/block-gh-settings.sh b/config/shared/hooks/block-gh-settings.sh index 27dfc79b7..edf97ce66 100755 --- a/config/shared/hooks/block-gh-settings.sh +++ b/config/shared/hooks/block-gh-settings.sh @@ -1,36 +1,111 @@ #!/usr/bin/env bash -# block-gh-settings.sh — Shared hook for Claude Code + Codex + Copilot + Cursor -# Blocks gh CLI commands that modify GitHub repository settings. -# Exit 2 = block; works across all four agent hook protocols. +# Shared agent guardrail for GitHub repository control-plane mutations. +# This is an early warning only; restricted credentials and server-side +# rulesets are the authoritative enforcement boundary. -# Cursor on macOS launches GUI apps with a minimal PATH; self-bootstrap it -# so jq/gh are findable regardless of caller. +# GUI-launched agents can inherit a minimal PATH on macOS. export PATH="$HOME/.cargo/bin:/etc/profiles/per-user/shunkakinoki/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/usr/bin:/bin:${PATH:-}" set -euo pipefail -# Read tool input from stdin input=$(cat) - -# Extract command (works for Claude, Codex, and Copilot hook input formats) -command=$(echo "$input" | jq -r '.tool.input.command // .tool_input.command // .toolArgs.command // .toolInput.command // .command // empty' 2>/dev/null) +command=$(printf '%s' "$input" | jq -r '.tool.input.command // .tool_input.command // .toolArgs.command // .toolInput.command // .command // empty' 2>/dev/null) [[ -z $command ]] && exit 0 -# Block: gh repo -if echo "$command" | grep -qE 'gh\s+repo\s+(delete|rename|archive|transfer|edit)\b'; then - subcommand=$(echo "$command" | grep -oE 'gh\s+repo\s+(delete|rename|archive|transfer|edit)' | awk '{print $3}') - msg="'gh repo $subcommand' is blocked. Repo settings must be changed manually." - echo "BLOCKED by block-gh-settings.sh: $msg" >&2 +block_settings() { + local detail="$1" + printf "BLOCKED by block-gh-settings.sh: %s Repository settings must be changed manually.\n" "$detail" >&2 exit 2 +} + +is_control_plane_target() { + local candidate="$1" + local repo_prefix="(api/v3/)?repos/[^/[:space:]\"']+/[^/?[:space:]\"']+" + local protected_suffix="(rulesets|branches/[^/?[:space:]\"']+/protection|collaborators|teams|hooks|deploy_keys|keys|actions/(permissions|access|secrets|variables|cache/retention-limit|cache/storage-limit)|environments|pages|topics|vulnerability-alerts|automated-security-fixes|private-vulnerability-reporting|security-and-analysis|interaction-limits)" + + printf '%s\n' "$candidate" | grep -Eiq "${repo_prefix}([?[:space:]\"']|$)" && return 0 + printf '%s\n' "$candidate" | grep -Eiq "${repo_prefix}/${protected_suffix}([/?[:space:]\"']|$)" +} + +explicit_method() { + local candidate="$1" + local method + method=$(printf '%s\n' "$candidate" | sed -nE 's/.*(^|[[:space:]])(-X|--method)(=|[[:space:]]+)(GET|POST|PATCH|PUT|DELETE)([[:space:]]|$).*/\4/ip' | tail -1) + if [[ -z $method ]]; then + method=$(printf '%s\n' "$candidate" | sed -nE 's/.*(^|[[:space:]])-X(GET|POST|PATCH|PUT|DELETE)([[:space:]]|$).*/\2/ip' | tail -1) + fi + printf '%s' "${method^^}" +} + +has_implicit_body() { + local candidate="$1" + printf '%s\n' "$candidate" | grep -Eiq '(^|[[:space:]])(-f|-F|--field|--raw-field|--input)(=|[[:space:]])' +} + +if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])gh[[:space:]]+repo[[:space:]]+(delete|rename|archive|transfer|edit)([[:space:]]|$)'; then + block_settings "A mutating 'gh repo' command was requested." +fi + +if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])gh[[:space:]]+(secret|variable)[[:space:]]+(set|delete)([[:space:]]|$)'; then + block_settings "A GitHub secret or variable mutation was requested." +fi + +if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])gh[[:space:]]+repo[[:space:]]+deploy-key[[:space:]]+(add|delete)([[:space:]]|$)'; then + block_settings "A repository deploy-key mutation was requested." +fi + +if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])gh[[:space:]]+workflow[[:space:]]+(enable|disable)([[:space:]]|$)'; then + block_settings "A workflow settings mutation was requested." +fi + +if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])gh[[:space:]]+api[[:space:]]+([^;&|]*[[:space:]])?graphql([[:space:]]|$)'; then + if printf '%s\n' "$command" | grep -Eiq '(^|[^[:alnum:]_])mutation([^[:alnum:]_]|$)' || + printf '%s\n' "$command" | grep -Eiq '(^|[[:space:]])--input(=|[[:space:]])'; then + block_settings "A raw GraphQL mutation was requested." + fi fi -# Block: gh api -X PATCH|DELETE|PUT targeting /repos/ -if echo "$command" | grep -qE 'gh\s+api'; then - if echo "$command" | grep -qE '\-X\s+(PATCH|DELETE|PUT)' && echo "$command" | grep -qE '/repos/'; then - method=$(echo "$command" | grep -oE '\-X\s+(PATCH|DELETE|PUT)' | awk '{print $2}') - msg="'gh api -X $method /repos/...' is blocked. Repo API mutations must be done manually." - echo "BLOCKED by block-gh-settings.sh: $msg" >&2 - exit 2 +if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])gh[[:space:]]+api([[:space:]]|$)' && is_control_plane_target "$command"; then + method=$(explicit_method "$command") + if [[ -z $method ]] && has_implicit_body "$command"; then + method=POST + fi + if [[ $method =~ ^(POST|PATCH|PUT|DELETE)$ ]]; then + block_settings "A $method request to a repository control-plane endpoint was requested." + fi +fi + +if is_control_plane_target "$command"; then + http_method=$(explicit_method "$command") + + if printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])(http|https|xh)[[:space:]]+(POST|PATCH|PUT|DELETE)([[:space:]]|$)'; then + http_method=$(printf '%s\n' "$command" | sed -nE 's/.*(^|[;&|[:space:]])(http|https|xh)[[:space:]]+(POST|PATCH|PUT|DELETE)([[:space:]]|$).*/\3/ip' | tail -1) + fi + + if [[ -z $http_method ]]; then + http_method=$(printf '%s\n' "$command" | sed -nE 's/.*(^|[[:space:]])--request(=|[[:space:]]+)(POST|PATCH|PUT|DELETE)([[:space:]]|$).*/\3/ip' | tail -1) + fi + + if [[ -z $http_method ]] && + printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])curl([[:space:]]|$)' && + printf '%s\n' "$command" | grep -Eiq '(^|[[:space:]])(--data[^[:space:]]*|-d|--form|-F|--json|--upload-file|-T)(=|[[:space:]])'; then + http_method=POST + fi + + if [[ -z $http_method ]] && + printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])(http|https|xh)([[:space:]]|$)' && + printf '%s\n' "$command" | grep -Eq '(^|[[:space:]])[^[:space:]=]+(:=|=)[^[:space:]]+'; then + http_method=POST + fi + + if [[ -z $http_method ]] && + printf '%s\n' "$command" | grep -Eiq '(^|[;&|[:space:]])wget([[:space:]]|$)' && + printf '%s\n' "$command" | grep -Eiq '(^|[[:space:]])(--post-data|--post-file|--body-data)(=|[[:space:]])'; then + http_method=POST + fi + + if [[ $http_method =~ ^(POST|PATCH|PUT|DELETE)$ ]]; then + block_settings "A direct $http_method request to a repository control-plane endpoint was requested." fi fi diff --git a/config/shared/hooks/block-git-push.sh b/config/shared/hooks/block-git-push.sh index 1cf3b4797..c21f78284 100755 --- a/config/shared/hooks/block-git-push.sh +++ b/config/shared/hooks/block-git-push.sh @@ -1,46 +1,329 @@ #!/usr/bin/env bash -# block-git-push.sh - Shared hook for Claude Code + Codex + Copilot + Cursor -# Blocks git push to main/master unless repo is in the allowlist. -# Exit 2 = block; works across all four agent hook protocols. +# Shared agent guardrail for pushes to protected default branches. +# This is an early warning only; remote rulesets and restricted credentials are +# the authoritative enforcement boundary. -# Cursor on macOS launches GUI apps with a minimal PATH; self-bootstrap it -# so jq/git are findable regardless of caller. +# GUI-launched agents can inherit a minimal PATH on macOS. export PATH="$HOME/.cargo/bin:/etc/profiles/per-user/shunkakinoki/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/usr/bin:/bin:${PATH:-}" set -euo pipefail -# --- Allowlist: repos where push to main/master is permitted --- ALLOWED_REPOS=( "shunkakinoki/wiki" "shunkakinoki/gthq" ) -# Read tool input from stdin input=$(cat) - -# Extract command (works for Claude, Codex, and Copilot hook input formats) -command=$(echo "$input" | jq -r '.tool.input.command // .tool_input.command // .toolArgs.command // .toolInput.command // .command // empty' 2>/dev/null) +command=$(printf '%s' "$input" | jq -r '.tool.input.command // .tool_input.command // .toolArgs.command // .toolInput.command // .command // empty' 2>/dev/null) [[ -z $command ]] && exit 0 -# Only check git push commands -if ! echo "$command" | grep -qE 'git\s+push'; then - exit 0 -fi +repo=$(git remote get-url origin 2>/dev/null | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##' || true) +for allowed in "${ALLOWED_REPOS[@]}"; do + [[ $repo == "$allowed" ]] && exit 0 +done -# Check if pushing to main or master -if ! echo "$command" | grep -qE '\b(main|master)\b'; then - exit 0 -fi +protected_branches=(main master) +while IFS= read -r remote; do + [[ -z $remote ]] && continue + default_ref=$(git symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null || true) + [[ -z $default_ref ]] && continue + default_branch=${default_ref#"$remote"/} + protected_branches+=("$default_branch") +done < <(git remote 2>/dev/null || true) -# Check repo allowlist (owner/repo from git remote) -repo=$(git remote get-url origin 2>/dev/null | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##' || echo "") -for allowed in "${ALLOWED_REPOS[@]}"; do - if [[ $repo == "$allowed" ]]; then - exit 0 +is_protected_branch() { + local candidate="$1" + candidate=${candidate#+} + candidate=${candidate#refs/heads/} + candidate=${candidate#heads/} + candidate=${candidate%\'} + candidate=${candidate#\'} + candidate=${candidate%\"} + candidate=${candidate#\"} + + local protected + for protected in "${protected_branches[@]}"; do + [[ -n $protected && $candidate == "$protected" ]] && return 0 + done + return 1 +} + +current_branch() { + git symbolic-ref --quiet --short HEAD 2>/dev/null || true +} + +block_push() { + local destination="$1" + printf "BLOCKED by block-git-push.sh: Push to protected branch '%s' in '%s'. Use a feature branch + PR.\n" \ + "$destination" "${repo:-unknown repository}" >&2 + exit 2 +} + +check_destination() { + local destination="$1" + destination=${destination#refs/remotes/} + if [[ $destination == */* ]]; then + local possible_branch=${destination#*/} + if is_protected_branch "$possible_branch"; then + block_push "$possible_branch" + fi fi -done + if is_protected_branch "$destination"; then + block_push "$destination" + fi +} + +check_current_destination() { + local branch + branch=$(current_branch) + [[ -z $branch ]] && return 0 + + local push_ref + if push_ref=$(git rev-parse --abbrev-ref --symbolic-full-name '@{push}' 2>/dev/null) && + [[ -n $push_ref && $push_ref != '@{push}' ]]; then + check_destination "$push_ref" + return 0 + fi + + local push_default merge_ref + push_default=$(git config --get push.default 2>/dev/null || printf '%s' simple) + if [[ $push_default == upstream ]]; then + merge_ref=$(git config --get "branch.$branch.merge" 2>/dev/null || true) + if [[ -n $merge_ref ]]; then + check_destination "$merge_ref" + return 0 + fi + fi + + check_destination "$branch" +} + +check_refspec() { + local refspec="$1" + local delete_mode="$2" + refspec=${refspec#+} + refspec=${refspec%\'} + refspec=${refspec#\'} + refspec=${refspec%\"} + refspec=${refspec#\"} + + if [[ $delete_mode == true ]]; then + check_destination "$refspec" + return 0 + fi + + case "$refspec" in + HEAD | @ | '@{push}' | '@{upstream}') + check_current_destination + return 0 + ;; + esac + + if [[ $refspec == *:* ]]; then + check_destination "${refspec#*:}" + return 0 + fi + + check_destination "$refspec" +} + +implicit_remote() { + local branch + branch=$(current_branch) + if [[ -n $branch ]]; then + local configured + configured=$(git config --get "branch.$branch.pushRemote" 2>/dev/null || true) + [[ -n $configured ]] && printf '%s\n' "$configured" && return 0 + + configured=$(git config --get remote.pushDefault 2>/dev/null || true) + [[ -n $configured ]] && printf '%s\n' "$configured" && return 0 + + configured=$(git config --get "branch.$branch.remote" 2>/dev/null || true) + [[ -n $configured && $configured != . ]] && printf '%s\n' "$configured" && return 0 + fi + + if git remote get-url origin >/dev/null 2>&1; then + printf '%s\n' origin + fi +} + +check_implicit_push() { + local remote="$1" + [[ -z $remote ]] && remote=$(implicit_remote) + + if [[ -n $remote ]]; then + local configured_refspecs + configured_refspecs=$(git config --get-all "remote.$remote.push" 2>/dev/null || true) + if [[ -n $configured_refspecs ]]; then + while IFS= read -r refspec; do + [[ -n $refspec ]] && check_refspec "$refspec" false + done <<<"$configured_refspecs" + return 0 + fi + fi + + local push_default + push_default=$(git config --get push.default 2>/dev/null || printf '%s' simple) + case "$push_default" in + nothing) + return 0 + ;; + matching) + local protected + for protected in "${protected_branches[@]}"; do + if git show-ref --verify --quiet "refs/heads/$protected"; then + block_push "$protected" + fi + done + ;; + *) + check_current_destination + ;; + esac +} + +analyze_push_words() { + local start_index="$1" + local remote="" + local delete_mode=false + local saw_refspec=false + local skip_next=false + local index token + + for ((index = start_index; index < ${#words[@]}; index++)); do + token=${words[index]} + token=${token%;} + token=${token%\}} + token=${token#\{} + + if [[ $skip_next == true ]]; then + skip_next=false + continue + fi + + case "$token" in + --all | --mirror) + local protected + for protected in "${protected_branches[@]}"; do + if git show-ref --verify --quiet "refs/heads/$protected"; then + block_push "$protected" + fi + done + block_push "default branch" + ;; + --delete | -d) + delete_mode=true + continue + ;; + --receive-pack | --exec | --repo | --push-option | -o) + skip_next=true + continue + ;; + --receive-pack=* | --exec=* | --repo=* | --push-option=* | -o*) + continue + ;; + --*) + continue + ;; + -*) + continue + ;; + esac + + if [[ -z $remote ]]; then + remote=$token + continue + fi + + saw_refspec=true + check_refspec "$token" "$delete_mode" + done + + if [[ $saw_refspec == false ]]; then + check_implicit_push "$remote" + fi +} + +inspect_segment() { + local segment="$1" + local depth="$2" + ((depth > 8)) && return 0 + + read -r -a words <<<"$segment" + local index git_index=-1 subcommand_index=-1 token + for ((index = 0; index < ${#words[@]}; index++)); do + token=${words[index]} + token=${token%\}} + token=${token#\{} + if [[ $token == git || $token == */git ]]; then + git_index=$index + break + fi + done + ((git_index < 0)) && return 0 + + index=$((git_index + 1)) + while ((index < ${#words[@]})); do + token=${words[index]} + case "$token" in + -C | --git-dir | --work-tree | --namespace) + index=$((index + 2)) + continue + ;; + -C* | --git-dir=* | --work-tree=* | --namespace=* | --no-pager | --paginate | --literal-pathspecs | --no-literal-pathspecs | --glob-pathspecs | --noglob-pathspecs | --icase-pathspecs) + index=$((index + 1)) + continue + ;; + -*) + index=$((index + 1)) + continue + ;; + *) + subcommand_index=$index + break + ;; + esac + done + ((subcommand_index < 0)) && return 0 + + local subcommand=${words[subcommand_index]} + subcommand=${subcommand%;} + if [[ $subcommand == push ]]; then + analyze_push_words "$((subcommand_index + 1))" + return 0 + fi + + local alias_value + alias_value=$(git config --get "alias.$subcommand" 2>/dev/null || true) + [[ -z $alias_value ]] && return 0 + + local remainder="" + for ((index = subcommand_index + 1; index < ${#words[@]}; index++)); do + remainder+=" ${words[index]}" + done + + if [[ $alias_value == !* ]]; then + local alias_body=${alias_value#!} + if printf '%s\n' "$alias_body" | grep -Eq 'git[[:space:]]+push.*(current-branch|branch[[:space:]]+--show-current|symbolic-ref[^)]*HEAD|rev-parse[^)]*HEAD)'; then + local branch + branch=$(current_branch) + [[ -n $branch ]] && check_destination "$branch" + fi + inspect_command "$alias_body$remainder" "$((depth + 1))" + else + inspect_command "git $alias_value$remainder" "$((depth + 1))" + fi +} + +inspect_command() { + local candidate="$1" + local depth="${2:-0}" + local segment + while IFS= read -r segment; do + [[ -z $segment ]] && continue + inspect_segment "$segment" "$depth" + done < <(printf '%s\n' "$candidate" | sed -E 's/[;&|]+/\n/g') +} -# Block the push -msg="Push to main/master blocked in '$repo'. Use a feature branch + PR." -echo "BLOCKED by block-git-push.sh: $msg" >&2 -exit 2 +inspect_command "$command" 0 +exit 0 diff --git a/spec/agent_github_hook_wiring_spec.sh b/spec/agent_github_hook_wiring_spec.sh new file mode 100644 index 000000000..c25ea5bf4 --- /dev/null +++ b/spec/agent_github_hook_wiring_spec.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +Describe 'shared GitHub guardrail wiring' + +verify_wiring() { + local config hook + for config in \ + config/codex/hooks.json \ + config/claude/settings.json \ + config/cursor/hooks.json \ + config/copilot/config.json \ + config/grok/plugin/hooks/hooks.json; do + for hook in block-git-push.sh block-gh-settings.sh; do + if ! grep -Fq "config/shared/hooks/$hook" "$config"; then + printf 'missing %s in %s\n' "$hook" "$config" >&2 + return 1 + fi + done + done +} + +It 'keeps both hooks registered for Codex Claude Cursor Copilot and Grok' +When call verify_wiring +The status should be success +End +End diff --git a/spec/block_gh_settings_spec.sh b/spec/block_gh_settings_spec.sh index b479429a2..e83bc6738 100644 --- a/spec/block_gh_settings_spec.sh +++ b/spec/block_gh_settings_spec.sh @@ -36,6 +36,24 @@ When run bash "$SCRIPT" The status should be success End +It 'allows a pull request API mutation' +Data '{"tool_input": {"command": "gh api -X POST /repos/owner/repo/pulls -f title=test"}}' +When run bash "$SCRIPT" +The status should be success +End + +It 'allows an issue comment API mutation' +Data '{"tool_input": {"command": "gh api --method POST /repos/owner/repo/issues/1/comments -f body=test"}}' +When run bash "$SCRIPT" +The status should be success +End + +It 'allows a GraphQL query' +Data '{"tool_input": {"command": "gh api graphql -f query=queryViewer"}}' +When run bash "$SCRIPT" +The status should be success +End + End Describe 'blocked gh repo subcommands' @@ -100,6 +118,147 @@ The status should eq 2 The stderr should include 'BLOCKED' End +It 'blocks gh api --method DELETE' +Data '{"tool_input": {"command": "gh api --method DELETE /repos/owner/repo/rulesets/1"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks gh api --method=PATCH' +Data '{"tool_input": {"command": "gh api --method=PATCH /repos/owner/repo -f visibility=private"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks compact -XDELETE' +Data '{"tool_input": {"command": "gh api -XDELETE /repos/owner/repo/hooks/1"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks an implicit POST from a field' +Data '{"tool_input": {"command": "gh api /repos/owner/repo/rulesets -f name=unsafe"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks an implicit POST from an input body' +Data '{"tool_input": {"command": "gh api /repos/owner/repo/actions/permissions --input payload.json"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +End + +Describe 'GraphQL mutations' + +It 'blocks an inline mutation' +Data '{"tool_input": {"command": "gh api graphql -f query=\"mutation UpdateRepository { updateRepository }\""}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks a GraphQL input file because it may contain a mutation' +Data '{"tool_input": {"command": "gh api graphql --input mutation.json"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End +End + +Describe 'settings-oriented commands' + +It 'blocks repository secret changes' +Data '{"tool_input": {"command": "gh secret set TOKEN"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks repository variable changes' +Data '{"tool_input": {"command": "gh variable delete FLAG"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks deploy-key changes' +Data '{"tool_input": {"command": "gh repo deploy-key add key.pub"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks workflow disablement' +Data '{"tool_input": {"command": "gh workflow disable build.yml"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End +End + +Describe 'direct HTTP clients' + +It 'blocks curl method mutations' +Data '{"tool_input": {"command": "curl -X PATCH https://api.github.com/repos/owner/repo -d visibility=private"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks curl implicit POSTs' +Data '{"tool_input": {"command": "curl -d name=test https://api.github.com/repos/owner/repo/rulesets"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks curl long-form request methods' +Data '{"tool_input": {"command": "curl --request PATCH https://api.github.com/repos/owner/repo --json {}"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks HTTPie mutations' +Data '{"tool_input": {"command": "http DELETE https://api.github.com/repos/owner/repo/hooks/1"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks xh mutations' +Data '{"tool_input": {"command": "xh PUT https://api.github.com/repos/owner/repo/actions/permissions"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks HTTPie implicit POSTs' +Data '{"tool_input": {"command": "http https://api.github.com/repos/owner/repo/topics names:=[]"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks wget mutations against GitHub Enterprise' +Data '{"tool_input": {"command": "wget --method=DELETE https://github.example/api/v3/repos/owner/repo/rulesets/1"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'allows direct issue comments' +Data '{"tool_input": {"command": "curl -X POST https://api.github.com/repos/owner/repo/issues/1/comments -d body=test"}}' +When run bash "$SCRIPT" +The status should be success +End End Describe 'codex input format' @@ -111,6 +270,13 @@ The status should eq 2 The stderr should include 'BLOCKED' End +It 'blocks nested Codex tool input' +Data '{"tool": {"input": {"command": "gh repo edit --enable-wiki"}}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + End Describe 'copilot input format' @@ -122,6 +288,13 @@ The status should eq 2 The stderr should include 'BLOCKED' End +It 'blocks camel-case tool input' +Data '{"toolInput": {"command": "gh repo edit --enable-wiki"}}' +When run bash "$SCRIPT" +The status should eq 2 +The stderr should include 'BLOCKED' +End + End Describe 'edge cases' diff --git a/spec/block_git_push_spec.sh b/spec/block_git_push_spec.sh index 06778c591..7b630f82f 100644 --- a/spec/block_git_push_spec.sh +++ b/spec/block_git_push_spec.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2329 +# shellcheck disable=SC2016,SC2329 Describe 'block-git-push.sh' SCRIPT="$PWD/config/shared/hooks/block-git-push.sh" @@ -7,12 +7,20 @@ SCRIPT="$PWD/config/shared/hooks/block-git-push.sh" setup() { TEMP_REPO=$(mktemp -d) git -C "$TEMP_REPO" init -q + git -C "$TEMP_REPO" config commit.gpgSign false + git -C "$TEMP_REPO" config user.email agent@example.com + git -C "$TEMP_REPO" config user.name Agent + git -C "$TEMP_REPO" commit --allow-empty -q -m init git -C "$TEMP_REPO" remote add origin "https://github.com/someorg/somerepo.git" } setup_allowed() { TEMP_REPO=$(mktemp -d) git -C "$TEMP_REPO" init -q + git -C "$TEMP_REPO" config commit.gpgSign false + git -C "$TEMP_REPO" config user.email agent@example.com + git -C "$TEMP_REPO" config user.name Agent + git -C "$TEMP_REPO" commit --allow-empty -q -m init git -C "$TEMP_REPO" remote add origin "https://github.com/shunkakinoki/wiki.git" } @@ -42,6 +50,12 @@ Data '{"tool_input": {"command": "git push origin feat/my-branch"}}' When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" The status should be success End + +It 'allows pushing main to a feature destination' +Data '{"tool_input": {"command": "git push origin main:feat/snapshot"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should be success +End End Describe 'blocked pushes' @@ -74,6 +88,163 @@ When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" The status should eq 2 The stderr should include 'BLOCKED' End + +It 'blocks a bare push from main' +Data '{"tool_input": {"command": "git push"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks HEAD from main' +Data '{"tool_input": {"command": "git push origin HEAD"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks an explicit HEAD destination' +Data '{"tool_input": {"command": "git push origin +HEAD:refs/heads/main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks deleting main' +Data '{"tool_input": {"command": "git push origin --delete main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks a deletion refspec for main' +Data '{"tool_input": {"command": "git push origin :main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks pushing all branches when main exists' +Data '{"tool_input": {"command": "git push origin --all"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks mirroring refs' +Data '{"tool_input": {"command": "git push --mirror origin"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks a feature source targeting main' +Data '{"tool_input": {"command": "git push origin feat/work:main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End +End + +Describe 'implicit destinations' +Before 'setup' + +It 'blocks an upstream configured to main' +git -C "$TEMP_REPO" switch -q -c feat/upstream +git -C "$TEMP_REPO" config branch.feat/upstream.remote origin +git -C "$TEMP_REPO" config branch.feat/upstream.merge refs/heads/main +git -C "$TEMP_REPO" config push.default upstream +Data '{"tool_input": {"command": "git push"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks a remote push refspec targeting main' +git -C "$TEMP_REPO" switch -q -c feat/refspec +git -C "$TEMP_REPO" config remote.origin.push HEAD:refs/heads/main +Data '{"tool_input": {"command": "git push origin"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks the locally cached remote default branch' +git -C "$TEMP_REPO" switch -q -c trunk +git -C "$TEMP_REPO" update-ref refs/remotes/origin/trunk HEAD +git -C "$TEMP_REPO" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk +Data '{"tool_input": {"command": "git push origin HEAD"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks push.default matching when a protected branch exists' +git -C "$TEMP_REPO" switch -q -c feat/matching +git -C "$TEMP_REPO" config push.default matching +Data '{"tool_input": {"command": "git push origin"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'allows a bare push from a feature branch without an upstream' +git -C "$TEMP_REPO" switch -q -c feat/no-upstream +Data '{"tool_input": {"command": "git push"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should be success +End + +It 'allows a bare push from detached HEAD because Git will reject it' +git -C "$TEMP_REPO" checkout -q --detach +Data '{"tool_input": {"command": "git push"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should be success +End +End + +Describe 'git aliases' +Before 'setup' + +It 'blocks a push alias targeting main' +git -C "$TEMP_REPO" config alias.pushy 'push --force-with-lease' +Data '{"tool_input": {"command": "git pushy origin main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks a nested push alias' +git -C "$TEMP_REPO" config alias.pushy 'push --force-with-lease' +git -C "$TEMP_REPO" config alias.ship pushy +Data '{"tool_input": {"command": "git ship origin main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks a shell alias containing a bare push' +git -C "$TEMP_REPO" config alias.put '!git commit --all && git push' +Data '{"tool_input": {"command": "git put"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks the publish shell alias when it expands the current main branch' +git -C "$TEMP_REPO" config alias.publish '!f() { git push --set-upstream "${1:-origin}" "$(git current-branch)"; }; f' +Data '{"tool_input": {"command": "git publish"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'allows a push alias targeting a feature branch' +git -C "$TEMP_REPO" config alias.publish-feature 'push origin feat/publish' +Data '{"tool_input": {"command": "git publish-feature"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should be success +End End Describe 'allowed repos' @@ -101,6 +272,13 @@ When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" The status should eq 2 The stderr should include 'BLOCKED' End + +It 'blocks nested Codex tool input' +Data '{"tool": {"input": {"command": "git push origin main"}}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End End Describe 'copilot input format' @@ -112,6 +290,13 @@ When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" The status should eq 2 The stderr should include 'BLOCKED' End + +It 'blocks camel-case tool input' +Data '{"toolInput": {"command": "git push origin main"}}' +When run bash -c "cd '$TEMP_REPO' && bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End End Describe 'edge cases'