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
105 changes: 105 additions & 0 deletions git-guards/scripts/git-permission-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@
)),
]

# Maps incorrect GraphQL mutation names to (correct_name, example_command).
# Based on log analysis: addPullRequestReviewComment (711 failures),
# resolvePullRequestReviewThread (162 failures).
WRONG_MUTATIONS = {
"addPullRequestReviewComment": (
"addPullRequestReviewThreadReply",
(
"gh api graphql --raw-field query='"
"mutation { addPullRequestReviewThreadReply(input: {"
"pullRequestReviewThreadId: \"THREAD_ID\", body: \"reply text\""
"}) { comment { id } } }'"
),
),
"resolvePullRequestReviewThread": (
"resolveReviewThread",
(
"gh api graphql --raw-field query='"
"mutation { resolveReviewThread(input: {"
"threadId: \"THREAD_ID\""
"}) { thread { id isResolved } } }'"
),
),
}


def deny(reason: str) -> None:
"""Output deny decision and exit."""
Expand All @@ -96,6 +120,83 @@ def ask(command: str, risk: str) -> None:
sys.exit(0)


def allow_with_guidance(reason: str) -> None:
"""Allow command but show guidance for self-correction."""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
}
}))
sys.exit(0)


def _strip_jq_content(command: str) -> str:
"""Remove --jq '...' and --jq "..." content to avoid false positives."""
command = re.sub(r"--jq\s+'[^']*'", "", command)
command = re.sub(r'--jq\s+"[^"]*"', "", command)
command = re.sub(r"--jq\s+\S+", "", command)
return command


def check_graphql_guidance(command: str) -> None:
"""Detect known gh api graphql failure patterns and emit corrective guidance.

Allows the command to proceed (it will fail naturally) while showing the
correct pattern inline so Claude can self-correct immediately.
"""
warnings = []

# Detection 1 - Shell $variable expansion (excluding --jq content)
command_no_jq = _strip_jq_content(command)
if re.search(r"\$[a-zA-Z]", command_no_jq):
warnings.append(
"SHELL VARIABLE EXPANSION: $variable in GraphQL queries is expanded by the shell before\n"
"gh receives it, causing syntax errors. Use --raw-field with inline values instead:\n"
"\n"
" WRONG: gh api graphql -f query='mutation { ... threadId: $threadId }'\n"
" CORRECT: gh api graphql --raw-field query='mutation { ... threadId: \"ACTUAL_ID\" }'"
)

# Detection 2 - Wrong mutation names
for wrong_name, (correct_name, example) in WRONG_MUTATIONS.items():
if re.search(fr"\b{re.escape(wrong_name)}\b\s*\(", command):
warnings.append(
f"WRONG MUTATION NAME: '{wrong_name}' does not exist in the GitHub GraphQL API.\n"
f"Use '{correct_name}' instead.\n"
f"\n"
f" Example: {example}"
)

# Detection 3 - -f query= or -F query= flags (Go template processing)
if re.search(r"\s-[fF]\s+query=", command):
warnings.append(
"WRONG FLAG: -f/-F applies Go template processing which causes variable expansion.\n"
"Use --raw-field for GraphQL queries:\n"
"\n"
" WRONG: gh api graphql -f query='...'\n"
" CORRECT: gh api graphql --raw-field query='...'"
)

# Detection 4 - Multi-line indicators (trailing backslash or literal \n,
# excluding false positives like \node, \name, \null, \number)
if command.endswith("\\") or re.search(r"\\n(?![aouei])", command):
warnings.append(
"MULTI-LINE QUERY: GraphQL queries must be on a single line.\n"
"Trailing backslashes and \\n sequences break gh api graphql.\n"
"\n"
" WRONG: gh api graphql --raw-field query=' \\\n"
" mutation { ... }'\n"
" CORRECT: gh api graphql --raw-field query='mutation { ... }'"
)

if warnings:
header = "GRAPHQL GUIDANCE: This command has known failure patterns. Correct before retrying:\n\n"
body = "\n\n".join(f"[{i + 1}] {w}" for i, w in enumerate(warnings))
allow_with_guidance(header + body)


def main():
# Parse input
try:
Expand Down Expand Up @@ -158,6 +259,10 @@ def main():
if tokens and sub_tokens[:len(tokens)] == tokens:
deny(reason)

# Check GraphQL guidance (allow with corrective warnings)
if is_gh and sub_tokens[:2] == ["api", "graphql"]:
check_graphql_guidance(command)

# Check ASK patterns - use word boundaries to avoid false matches
# (e.g., "merge" shouldn't match "emergency")
patterns = ASK_GIT if is_git else ASK_GH
Expand Down
114 changes: 114 additions & 0 deletions git-guards/scripts/test_graphql_guidance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Test script for graphql guidance detection in git-permission-guard.py."""

import json
import subprocess
import sys
from pathlib import Path

SCRIPT = Path(__file__).parent / "git-permission-guard.py"


def run(cmd: str) -> dict:
inp = json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}})
result = subprocess.run(
["python3", str(SCRIPT)],
input=inp, capture_output=True, text=True
)
if result.stdout.strip():
return json.loads(result.stdout.strip())
return {}


def check(label: str, cmd: str, expected_decision: str, expected_fragments: list[str] | None = None):
out = run(cmd)
if not out:
actual = "silent_allow"
reason = ""
else:
actual = out["hookSpecificOutput"]["permissionDecision"]
reason = out["hookSpecificOutput"]["permissionDecisionReason"]

ok = actual == expected_decision
if ok and expected_fragments:
for frag in expected_fragments:
if frag not in reason:
ok = False
print(f"FAIL [{label}]: missing '{frag}' in reason")
print(f" Reason: {reason[:400]}")
break

status = "PASS" if ok else "FAIL"
print(f"{status} [{label}]: decision={actual}")
if not ok and not expected_fragments:
print(f" Expected: {expected_decision}, Got: {actual}")
print(f" Reason: {reason[:300]}")
return ok


all_pass = True

# 1: shell variable + wrong flag
all_pass &= check(
"shell var + -f flag",
"gh api graphql -f query='query { viewer { login $owner } }'",
"allow",
["SHELL VARIABLE EXPANSION", "WRONG FLAG"],
)

# 2: wrong mutation addPullRequestReviewComment
all_pass &= check(
"wrong mutation: addPullRequestReviewComment",
"gh api graphql --raw-field query='mutation { addPullRequestReviewComment(input: {}) { id } }'",
"allow",
["WRONG MUTATION NAME", "addPullRequestReviewThreadReply"],
)

# 3: wrong mutation resolvePullRequestReviewThread
all_pass &= check(
"wrong mutation: resolvePullRequestReviewThread",
"gh api graphql --raw-field query='mutation { resolvePullRequestReviewThread(input: {}) { thread { id } } }'",
"allow",
["WRONG MUTATION NAME", "resolveReviewThread"],
)

# 4: correct mutation - silent allow
all_pass &= check(
"correct mutation resolveReviewThread",
'gh api graphql --raw-field query=\'mutation { resolveReviewThread(input: {threadId: "abc"}) { thread { id } } }\'',
"silent_allow",
)

# 5: gh pr list - silent allow
all_pass &= check(
"gh pr list unaffected",
"gh pr list",
"silent_allow",
)

# 6: combined - wrong flag + wrong mutation + shell var
all_pass &= check(
"combined: -f flag + wrong mutation + shell var",
"gh api graphql -f query='mutation { addPullRequestReviewComment(input: { body: \"$threadId\" }) { id } }'",
"allow",
["SHELL VARIABLE EXPANSION", "WRONG MUTATION NAME", "WRONG FLAG"],
)

# 7: --jq false positive (no trigger)
all_pass &= check(
"jq false positive check",
"gh api graphql --raw-field query='{ viewer { login } }' --jq '.$var'",
"silent_allow",
)

# 8: multi-line with trailing backslash
all_pass &= check(
"multi-line trailing backslash",
"gh api graphql --raw-field query='mutation { resolveReviewThread(input: {threadId: \"abc\"}) { thread { id isResolved } } }' \\",
"allow",
["MULTI-LINE QUERY"],
)

print()
print("ALL TESTS PASSED" if all_pass else "SOME TESTS FAILED")
sys.exit(0 if all_pass else 1)
Loading