From 232eee3becf85690f543e10a9fe57000e02a9f1d Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:20:19 -0500 Subject: [PATCH 1/3] feat(git-guards): add GraphQL command guidance for known failure patterns Adds allow-with-guidance detection to git-permission-guard.py for gh api graphql commands. When a command matches a known failure pattern (shell variable expansion, wrong mutation names, -f/-F flags, multi-line queries), the hook allows the command to proceed but injects corrective guidance via permissionDecisionReason so Claude can self-correct. Detects 4 patterns based on log analysis of 1,400+ failures: - Shell $variable expansion (125 occurrences) - Wrong mutation names: addPullRequestReviewComment (711) and resolvePullRequestReviewThread (162) - -f/-F query= flags that cause Go template variable expansion - Multi-line query indicators (trailing backslash, literal \n) Also adds test_graphql_guidance.py covering all 8 verification cases. (claude) --- git-guards/scripts/git-permission-guard.py | 105 ++++++++++++++++++ git-guards/scripts/test_graphql_guidance.py | 114 ++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 git-guards/scripts/test_graphql_guidance.py diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 22131a5..6f77a6d 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -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.""" @@ -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 wrong_name in 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: @@ -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 diff --git a/git-guards/scripts/test_graphql_guidance.py b/git-guards/scripts/test_graphql_guidance.py new file mode 100644 index 0000000..9bb7aba --- /dev/null +++ b/git-guards/scripts/test_graphql_guidance.py @@ -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) From 3db588f78f5fe7f03deeb67d2018124d52365932 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:32:38 -0500 Subject: [PATCH 2/3] fix(git-guards): use word-boundary regex for mutation name detection Replace simple substring `in` check with regex `\b{name}\b\s*\(` to avoid false positives when a wrong mutation name appears in a comment or string literal within the GraphQL query rather than as an actual mutation call. (claude) --- git-guards/scripts/git-permission-guard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 6f77a6d..23aeef0 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -161,7 +161,7 @@ def check_graphql_guidance(command: str) -> None: # Detection 2 - Wrong mutation names for wrong_name, (correct_name, example) in WRONG_MUTATIONS.items(): - if wrong_name in command: + 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" From 2bbb4cfeee75fb97206eba0005156d0773d3527e Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:55:32 -0500 Subject: [PATCH 3/3] fix(git-guards): make test_graphql_guidance.py executable CI validates all scripts in scripts/ have executable permission. (claude) --- git-guards/scripts/test_graphql_guidance.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 git-guards/scripts/test_graphql_guidance.py diff --git a/git-guards/scripts/test_graphql_guidance.py b/git-guards/scripts/test_graphql_guidance.py old mode 100644 new mode 100755