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
26 changes: 26 additions & 0 deletions git-guards/scripts/git-permission-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 19 additions & 2 deletions git-guards/scripts/test_gh_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions git-guards/scripts/test_graphql_guidance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading