From 3002cd2b2aa34b8af7405ad191a98df830bb457e Mon Sep 17 00:00:00 2001 From: Tiff Huang Date: Tue, 22 Sep 2026 16:50:39 -0400 Subject: [PATCH] Guard agent tasks against PR merges and pin Claude actions --- .github/workflows/agent-task.yml | 57 ++++++++++++++++++++++++++++++-- .github/workflows/pr-review.yml | 2 +- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-task.yml b/.github/workflows/agent-task.yml index 879aa3d..1a88aac 100644 --- a/.github/workflows/agent-task.yml +++ b/.github/workflows/agent-task.yml @@ -32,6 +32,58 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 1 + - name: Install merge guard + run: | + mkdir -p /tmp/agent-task-guard + cat > /tmp/agent-task-guard/guard.py <<'PYTHON_GUARD' + #!/usr/bin/env python3 + """PreToolUse guard for accidental PR merges in agent-task jobs. + + This is a command guardrail, not a security boundary against a hostile agent with + write access or credentials. Enforce hard restrictions with GitHub branch rules. + """ + import json + import re + import sys + + + def blocks(command): + # Inspect the whole shell input, including chained commands and substitutions. + # Overblocking commands that merely mention a merge is preferable to merging. + if re.search(r"\bgh\s+pr\s+merge\b", command, re.I): + return True + if not re.search(r"\bgh\s+api\b", command, re.I): + return False + if re.search(r"(?:/repos/|\brepos/)[^\s'\";]+/pulls/\d+/merge\b", command, re.I): + return True + if re.search(r"\b(?:mergePullRequest|enablePullRequestAutoMerge|enqueuePullRequest)\b", command, re.I): + return True + # A GraphQL query can also be loaded from a file, stdin, or shell expansion. + # Deny GraphQL writes rather than trying to interpret arbitrary shell input. + if re.search(r"\bgh\s+api\s+graphql\b", command, re.I): + return bool(re.search(r"\bmutation\b|--input\b|\$\(|<|@", command, re.I) or re.search(r"(?:^|\s)-F(?:\s|=)", command)) + return False + + + def main(): + try: + data = json.load(sys.stdin) + command = data["tool_input"]["command"] + if not isinstance(command, str): + raise ValueError("Missing Bash command") + except (ValueError, KeyError, TypeError): + print("Cannot inspect Bash command; blocked by merge guard", file=sys.stderr) + return 2 + if data.get("tool_name") == "Bash" and blocks(command): + print("PR merges are disabled for agent-task jobs", file=sys.stderr) + return 2 + return 0 + + + if __name__ == "__main__": + sys.exit(main()) + PYTHON_GUARD + chmod +x /tmp/agent-task-guard/guard.py - name: Get plugin marketplace token id: marketplace_auth run: | @@ -55,7 +107,7 @@ jobs: echo "::add-mask::${MARKETPLACE_TOKEN}" echo "token=${MARKETPLACE_TOKEN}" >> "$GITHUB_OUTPUT" - - uses: anthropics/claude-code-action@v1 + - uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507 env: GH_TOKEN: ${{ github.token }} with: @@ -67,8 +119,9 @@ jobs: show_full_output: true claude_args: | --model claude-opus-5-5 + --settings '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/tmp/agent-task-guard/guard.py","timeout":10}]}]}}' --allowedTools "Read,Skill,Glob,Grep,LSP,WebFetch,WebSearch,Write,Edit,Bash(git:*),Bash(gh pr:*),Bash(gh api:*),Bash(jq:*)${{ inputs.extra_allowed_tools != '' && format(',{0}', inputs.extra_allowed_tools) || '' }}" - --disallowedTools "ScheduleWakeup" + --disallowedTools "ScheduleWakeup,Bash(gh pr merge:*)" --append-system-prompt "You are running as a one-shot GitHub Actions job. There is no future turn, resumption, or wakeup — once you finish responding, this process ends permanently." - name: Upload agent result if: always() diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 80fdea1..79f3877 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -68,7 +68,7 @@ jobs: echo "::add-mask::${MARKETPLACE_TOKEN}" echo "token=${MARKETPLACE_TOKEN}" >> "$GITHUB_OUTPUT" - - uses: anthropics/claude-code-action@v1 + - uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507 with: allowed_bots: "*" anthropic_federation_rule_id: ${{ inputs.anthropic_federation_rule_id }}