From ffd08042595e43e710911f363f4ae7de903ec678 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:32:21 -0400 Subject: [PATCH] fix(git-guards): close gh api/graphql bypass of the pr-comment deny DENY_GH blocks `gh pr comment` because top-level issue/PR comments cannot be resolved or tracked, but the REST and GraphQL equivalents were not covered: `gh api .../issues/{n}/comments -X POST` and a `gh api graphql` addComment mutation both create the same unresolvable comment. Add a DENY_GH_REGEX entry matching a POST (any flag order/spelling, relative or absolute URL) to repos/{owner}/{repo}/issues/{n}/comments, while leaving GET reads and the sanctioned pulls/{n}/comments and pulls/comments/{id} review-thread endpoints untouched. Deny the GraphQL addComment(input: ...) mutation outright in the existing graphql inspection path, pointing at the same resolve-pr-threads workflows. Assisted-by: Claude:claude-opus-5 Claude-Session: https://claude.ai/code/session_01DnTJjGY6C116W98Hsb9T5q --- git-guards/scripts/git-permission-guard.py | 26 +++++++++++++++++++++ git-guards/scripts/test_gh_guard.py | 21 +++++++++++++++-- git-guards/scripts/test_graphql_guidance.py | 24 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/git-guards/scripts/git-permission-guard.py b/git-guards/scripts/git-permission-guard.py index 2df59307..46404737 100755 --- a/git-guards/scripts/git-permission-guard.py +++ b/git-guards/scripts/git-permission-guard.py @@ -110,6 +110,19 @@ (r"api\b(?=.*(?:-X|--method)\s+(?:PUT|PATCH|DELETE))(?=.*\b(?:rulesets|branches/[^/]+/protection)\b)", "modifies repository branch protection or rulesets directly", "Manage branch protections through the GitHub web interface instead."), + # REST equivalent of the DENY_GH "pr comment" block: POSTing to an issue's + # /comments collection creates the same unresolvable top-level comment. + # Requires the POST method (a GET listing comments stays allowed) and the + # issues/{n}/comments shape specifically, so pulls/{n}/comments and + # pulls/comments/{id} (the sanctioned review-thread endpoints) are unaffected. + (r"api\b(?=.*(?:-X\s+|--method[\s=])POST\b)(?=.*\brepos/[^\s/]+/[^\s/]+/issues/\d+/comments\b)", + "creates a top-level issue/PR comment that cannot be resolved or tracked", + ( + "For code review feedback, you MUST use review threads (line-specific, resolvable comments) instead.\n" + "Use the documented thread workflows for creating review comments, replying, and resolving threads:\n" + " - github-workflows/skills/resolve-pr-threads/graphql-queries.md\n" + " - github-workflows/skills/resolve-pr-threads/rest-api-patterns.md" + )), ] # Maps incorrect GraphQL mutation names to (correct_name, example_command). @@ -407,6 +420,19 @@ def check_graphql_guidance(command: str) -> None: Allows the command to proceed (it will fail naturally) while showing the correct pattern inline so Claude can self-correct immediately. """ + # addComment is the GraphQL equivalent of the REST issues/{n}/comments POST + # and the DENY_GH "pr comment" entry: it creates the same unresolvable + # top-level comment, on any subject (issue, PR, commit, gist). Unlike the + # WRONG_MUTATIONS below, this mutation is real and would succeed, so it is + # a hard deny rather than corrective guidance. + if re.search(r"\baddComment\s*\(\s*input\s*:", command): + deny( + "This command creates a top-level issue/PR comment via the GraphQL API that cannot be " + "resolved or tracked. For code review feedback, you MUST use review threads instead:\n" + " - github-workflows/skills/resolve-pr-threads/graphql-queries.md\n" + " - github-workflows/skills/resolve-pr-threads/rest-api-patterns.md" + ) + warnings = [] # Detection 1 - Shell $variable expansion (excluding --jq content) diff --git a/git-guards/scripts/test_gh_guard.py b/git-guards/scripts/test_gh_guard.py index bf2f0981..be3416a6 100755 --- a/git-guards/scripts/test_gh_guard.py +++ b/git-guards/scripts/test_gh_guard.py @@ -84,8 +84,25 @@ def check(label: str, cmd: str, expected_decision: str) -> bool: # Regression: Safe gh api graphql queries mentioning rulesets must not be blocked all_pass &= check("gh api graphql rulesets query safe", "gh api graphql --raw-field query='query { repository(name: \"repo\", owner: \"owner\") { rulesets(first: 10) { totalCount } } }'", "silent_allow") -# Regression: API calls with -f body containing "rulesets" in text must NOT be blocked -all_pass &= check("gh api comment with rulesets in body", "gh api repos/owner/repo/issues/42/comments -X POST -f body='See the rulesets docs for context'", "silent_allow") +# Regression: a -f body value must not defeat the deny below (kept allow-only +# text is incidental; the deny fires on the POST + issues/{n}/comments shape) +all_pass &= check("gh api comment with rulesets in body", "gh api repos/owner/repo/issues/42/comments -X POST -f body='See the rulesets docs for context'", "deny") + +# DENY: REST equivalent of `gh pr comment` - POSTing to issues/{n}/comments +# creates the same unresolvable top-level comment `gh pr comment` is denied for. +all_pass &= check("gh api issue comment POST deny", "gh api repos/owner/repo/issues/42/comments -X POST -f body='hello'", "deny") +all_pass &= check("gh api issue comment POST deny, flags before path", "gh api -X POST repos/owner/repo/issues/42/comments -f body='hello'", "deny") +all_pass &= check("gh api issue comment POST deny, --method flag", "gh api repos/owner/repo/issues/42/comments --method POST -f body='hello'", "deny") +all_pass &= check("gh api issue comment POST deny, --method= flag", "gh api repos/owner/repo/issues/42/comments --method=POST -f body='hello'", "deny") +all_pass &= check("gh api issue comment POST deny, absolute URL", "gh api https://api.github.com/repos/owner/repo/issues/42/comments -X POST -f body='hello'", "deny") +all_pass &= check("gh api issue comment POST deny, leading slash", "gh api /repos/owner/repo/issues/42/comments -X POST -f body='hello'", "deny") + +# Safe: a GET (no method flag, gh api's default) of the same path must still be allowed +all_pass &= check("gh api issue comments GET safe", "gh api repos/owner/repo/issues/42/comments", "silent_allow") + +# Safe: the sanctioned review-thread endpoints must remain unaffected +all_pass &= check("gh api pulls comments POST safe", "gh api repos/owner/repo/pulls/42/comments -X POST -f body='hello'", "silent_allow") +all_pass &= check("gh api pulls comment reply POST safe", "gh api repos/owner/repo/pulls/comments/123/replies -X POST -f body='hello'", "silent_allow") print() print("ALL TESTS PASSED" if all_pass else "SOME TESTS FAILED") diff --git a/git-guards/scripts/test_graphql_guidance.py b/git-guards/scripts/test_graphql_guidance.py index 9bb7aba5..414a6caa 100755 --- a/git-guards/scripts/test_graphql_guidance.py +++ b/git-guards/scripts/test_graphql_guidance.py @@ -109,6 +109,30 @@ def check(label: str, cmd: str, expected_decision: str, expected_fragments: list ["MULTI-LINE QUERY"], ) +# 9: addComment mutation - GraphQL equivalent of `gh pr comment` / the REST +# issues/{n}/comments POST, denied outright (not correctable - unlike +# WRONG_MUTATIONS, this mutation name is real and would succeed) +all_pass &= check( + "addComment mutation deny", + "gh api graphql --raw-field query='mutation { addComment(input: {subjectId: \"PR_kwABC\", body: \"hi\"}) { commentEdge { node { id } } } }'", + "deny", + ["top-level issue/PR comment", "resolve-pr-threads"], +) + +# 10: addComment mutation with -f flag variant - still denied +all_pass &= check( + "addComment mutation deny, -f flag", + "gh api graphql -f query='mutation { addComment(input: {subjectId: \"PR_kwABC\", body: \"hi\"}) { commentEdge { node { id } } } }'", + "deny", +) + +# 11: addPullRequestReviewThreadReply (sanctioned) must stay unaffected +all_pass &= check( + "addPullRequestReviewThreadReply unaffected", + "gh api graphql --raw-field query='mutation { addPullRequestReviewThreadReply(input: {pullRequestReviewThreadId: \"abc\", body: \"hi\"}) { comment { id } } }'", + "silent_allow", +) + print() print("ALL TESTS PASSED" if all_pass else "SOME TESTS FAILED") sys.exit(0 if all_pass else 1)