Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions config/shared/hooks/README.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 96 additions & 21 deletions config/shared/hooks/block-gh-settings.sh
Original file line number Diff line number Diff line change
@@ -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 <destructive-subcommand>
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."
Comment on lines +79 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does HTTPie accept a lowercase request method such as http delete https://example.com`?`

💡 Result:

Yes, HTTPie accepts lowercase request methods such as http delete https://example.com [1][2]. While HTTPie documentation typically displays methods in uppercase (e.g., GET, POST, DELETE), the CLI tool is case-insensitive regarding the request method argument and will correctly process lowercase input [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | grep -Fx 'config/shared/hooks/block-gh-settings.sh' || true

echo "== file outline/stat =="
wc -l config/shared/hooks/block-gh-settings.sh
ast-grep outline config/shared/hooks/block-gh-settings.sh || true

echo "== relevant lines =="
sed -n '1,130p' config/shared/hooks/block-gh-settings.sh

echo "== deterministic guard behavior for lowercase method assignments =="
python3 - <<'PY'
import re

def explicit_method(command, verbose=False):
    method = None
    if verbose and 'http' in command or 'httpie' in command:
        pass
    return method

def current_block_analysis(command):
    # mirrors the assignment paths enough to show stored value before final regex
    match = re.search(r'(^|[;&|[:space:]])http([[:space:]]+)', command, re.I)
    if match:
        rest = re.split(r'[\s;|&]+', command[match.end():], maxsplit=1)[0] if command[match.end():] else ''
        http_method = rest.upper()
    else:
        http_method = None

    if re.search(r'(^|[;&|[:space:]])https([[:space:]]+)', command, re.I):
        rest = re.split(r'[\s;|&]+', command[match.end():] if match else command, maxsplit=1)[0] if ((match and command[match.end():]) or (not match)) else ''
        http_method = rest.upper()
    else:
        pass

    # lines 82/86 from source if explicit_method returned None and command matches.
    m = re.search(r'.*(^|[;&|[:space:]])(http|https|xh)[[:space:]]+([A-Za-z]+)([[:space:]]|$).*', command, re.I)
    if not http_method and m:
        http_method = m.group(3)
    # line 86 if still empty via curl --request; omit because current examples use implicit lowercase.

    if re.search(r'(^|[;&|[:space:]])curl([[:space:]]|$)', command, re.I) and re.search(r'(^|[[:space:]])(--data[^[:space:]]*|-d|--form|-F|--json|--upload-file|-T)(=|[[:space:]])', command, re.I):
        if not http_method:
            http_method = 'POST'

    if re.search(r'(^|[;&|[:space:]])(http|https|xh)([[:space:]]|$)', command, re.I) and re.search(r'(^|[[:space:]])[^[:space:]=]+(:=|=)[^[:space:]]+', command):
        if not http_method:
            http_method = 'POST'

    if re.search(r'(^|[;&|[:space:]])wget([[:space:]]|$)', command, re.I) and re.search(r'(^|[[:space:]])(--post-data|--post-file|--body-data)(=|[[:space:]])', command, re.I):
        if not http_method:
            http_method = 'POST'

    blocked = False
    if http_method and re.search(r'^(POST|PATCH|PUT|DELETE)$', http_method):
        blocked = True
    return http_method, blocked

for cmd in [
    "http delete https://api.github.com/repos/owner/repo/hooks/1",
    "http DELETE https://api.github.com/repos/owner/repo/hooks/1",
    "curl --request delete https://api.github.com/repos/owner/repo",
    "curl --request delete -d x https://api.github.com/repos/owner/repo",
]:
    print(cmd, current_block_analysis(cmd))
PY

Repository: shunkakinoki/dotfiles

Length of output: 6484


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic extraction from source =="
python3 - <<'PY'
import re

source = open('config/shared/hooks/block-gh-settings.sh').read()

def explicit_method(command):
    method = None
    for pattern in (
        r'.*(^|[[:space:]])(-X|--method)(=|[[:space:]]+)(GET|POST|PATCH|PUT|DELETE)([[:space:]]|$).*',
        r'.*(^|[[:space:]])-X(GET|POST|PATCH|PUT|DELETE)([[:space:]]|$).*',
    ):
        m = re.search(pattern, command, re.I)
        if m:
            method = m.group(4) or m.group(2)
    return (method or '').upper()

def command_stores_raw_lowercase(command):
    # Returns True if the HTTPie/https/xh capture path at lines 82/86 runs
    # and captures a lowercase method before the final guard.
    if not re.search(r'(^|[;&|[:space:]])http_method=\(explicit_method "$command"\)', command, re.I):
        pass
    m = re.search(r'.*(^|[;&|[:space:]])(http|https|xh)[[:space:]]+([^[:space:]]+)([[:space:]]|$).*', command, re.I)
    if m:
        return (m.group(3) != m.group(3).upper())
    return False

def final_block_stops_http_method(command):
    http_method = explicit_method(command)
    # The guard only runs for lines 82-86 because the script assigns
    # explicit_method first and then the sed capture only when command contains http|https|xh.
    if re.search(r'(^|[;&|[:space:]])http_method=\(explicit_method "$command"\)', source, re.I):
        pass
    if re.search(r'(^|[;&|[:space:]])(http|https|xh)[[:space:]]+([A-Za-z]+)([[:space:]]|$)', command, re.I):
        m = re.search(r'.*(^|[;&|[:space:]])(http|https|xh)[[:space:]]+([A-Za-z]+)([[:space:]]|$).*', command, re.I)
        if m:
            http_method = m.group(3)
    if re.search(r'^(POST|PATCH|PUT|DELETE)$', http_method):
        return True
    return False

for cmd in [
    "http delete https://api.github.com/repos/owner/repo/hooks/1",
    "http DELETE https://api.github.com/repos/owner/repo/hooks/1",
    "curl --request delete https://api.github.com/repos/owner/repo",
    "curl --request delete -d x https://api.github.com/repos/owner/repo",
]:
    print("cmd:", cmd)
    print("explicit_method:", explicit_method(cmd))
    print("sed captures lowercase:", command_stores_raw_lowercase(cmd))
    print("final block matches current source:", final_block_stops_http_method(cmd))
PY

Repository: shunkakinoki/dotfiles

Length of output: 1326


Uppercase the HTTP method before the final comparison.

The HTTPie/https/xh capture path assigns the raw sed capture, so lowercase methods like http delete ..., curl --request delete ..., or --request delete are not matched by the final ^(POST|PATCH|PUT|DELETE)$ guard. Normalize the method once before that comparison.

🛡️ Proposed fix
-  if [[ $http_method =~ ^(POST|PATCH|PUT|DELETE)$ ]]; then
+  http_method=${http_method^^}
+  if [[ $http_method =~ ^(POST|PATCH|PUT|DELETE)$ ]]; then
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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."
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
http_method=${http_method^^}
if [[ $http_method =~ ^(POST|PATCH|PUT|DELETE)$ ]]; then
block_settings "A direct $http_method request to a repository control-plane endpoint was requested."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/shared/hooks/block-gh-settings.sh` around lines 79 - 108, Normalize
http_method to uppercase after all method-detection branches and before the
final ^(POST|PATCH|PUT|DELETE)$ comparison in the command classification flow.
Ensure captured lowercase methods from HTTPie/https/xh and --request options are
converted while preserving the existing blocking behavior.

fi
fi

Expand Down
Loading
Loading