Security Agent #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Security Agent | |
| # Triggers Cursor's Security Cloud Agent via API in two modes: | |
| # 1. PR mode — Dependabot/dep-labeled PR → workflow waits + posts report | |
| # 2. Cron mode — weekly Mondays 06:00 UTC, sandbox creates fix PRs/issues | |
| # (Cursor sandbox can create new PRs/issues but cannot comment on existing | |
| # PRs, so PR-mode requires the workflow to post the report itself.) | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, labeled] | |
| schedule: | |
| - cron: "0 6 * * 1" | |
| workflow_dispatch: | |
| jobs: | |
| # Gate the agent on CURSOR_API_KEY being present. Secrets can't be used in | |
| # job-level `if:`, so we surface presence as an output here and skip cleanly | |
| # (neutral, not failed) on repos that haven't set the key. | |
| guard: | |
| name: Check Cursor API key | |
| runs-on: ubuntu-latest | |
| outputs: | |
| has_key: ${{ steps.check.outputs.has_key }} | |
| steps: | |
| - id: check | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| run: | | |
| if [ -n "$CURSOR_API_KEY" ]; then | |
| echo "has_key=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_key=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice title=Cursor agent skipped::CURSOR_API_KEY is not set on this repo — skipping. Set it with: gh secret set CURSOR_API_KEY --repo <owner>/<repo>" | |
| fi | |
| pr-mode: | |
| name: Run Security Agent (PR mode) | |
| needs: guard | |
| if: > | |
| needs.guard.outputs.has_key == 'true' && | |
| github.event_name == 'pull_request' && ( | |
| github.event.pull_request.user.login == 'dependabot[bot]' || | |
| contains(github.event.pull_request.labels.*.name, 'dependencies') | |
| ) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Trigger Cursor agent | |
| id: trigger | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| REPO: ${{ github.repository }} | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login }} | |
| PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ', ') }} | |
| PR_HEAD: ${{ github.event.pull_request.head.ref }} | |
| run: | | |
| set -euo pipefail | |
| PROMPT=$(jq -Rs \ | |
| --arg repo "$REPO" \ | |
| --arg mode "dependabot-pr" \ | |
| --arg url "$PR_URL" \ | |
| --arg author "$PR_AUTHOR" \ | |
| --arg labels "$PR_LABELS" \ | |
| ' | |
| gsub("\\{\\{repo\\}\\}"; $repo) | |
| | gsub("\\{\\{mode\\}\\}"; $mode) | |
| | gsub("\\{\\{pr.url\\}\\}"; $url) | |
| | gsub("\\{\\{pr.author\\}\\}"; $author) | |
| | gsub("\\{\\{pr.labels\\}\\}"; $labels) | |
| ' .cursor/agent-prompts/security.md) | |
| PAYLOAD=$(jq -n \ | |
| --arg prompt "$PROMPT" \ | |
| --arg repo "$REPO" \ | |
| --arg ref "$PR_HEAD" \ | |
| '{ | |
| prompt: { text: $prompt }, | |
| source: { repository: ("github.com/" + $repo), ref: $ref }, | |
| target: { autoCreatePr: false, openAsCursorGithubApp: true }, | |
| model: "composer-2.5" | |
| }') | |
| response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ | |
| -H "Authorization: Bearer $CURSOR_API_KEY" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$PAYLOAD") | |
| echo "$response" | jq . | |
| AGENT_ID=$(echo "$response" | jq -er '.id') | |
| echo "agent_id=$AGENT_ID" >> "$GITHUB_OUTPUT" | |
| - name: Wait for agent to finish | |
| id: wait | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| AGENT_ID: ${{ steps.trigger.outputs.agent_id }} | |
| run: | | |
| set -euo pipefail | |
| for i in $(seq 1 30); do | |
| agent_status=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID" \ | |
| -H "Authorization: Bearer $CURSOR_API_KEY" | jq -r '.status') | |
| echo "[$i/30] status: $agent_status" | |
| if [[ "$agent_status" != "RUNNING" && "$agent_status" != "CREATING" ]]; then | |
| echo "final_status=$agent_status" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| sleep 30 | |
| done | |
| echo "final_status=TIMEOUT" >> "$GITHUB_OUTPUT" | |
| - name: Post agent report + label | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| AGENT_ID: ${{ steps.trigger.outputs.agent_id }} | |
| AGENT_STATUS: ${{ steps.wait.outputs.final_status }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| REPORT=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID/conversation" \ | |
| -H "Authorization: Bearer $CURSOR_API_KEY" \ | |
| | jq -r '[.messages[] | select(.type == "assistant_message") | .text] | last') | |
| { | |
| echo "## Security review (Cursor Security agent — IaC)" | |
| echo "" | |
| echo "**Agent run:** [\`$AGENT_ID\`](https://cursor.com/agents/$AGENT_ID)" | |
| echo "**Status:** \`$AGENT_STATUS\`" | |
| echo "" | |
| echo "$REPORT" | |
| } > /tmp/sec-comment.md | |
| gh pr comment "$PR" --repo "$REPO" --body-file /tmp/sec-comment.md | |
| if echo "$REPORT" | grep -qiE "safe to merge"; then | |
| gh pr edit "$PR" --repo "$REPO" --add-label auto-merge-safe || true | |
| elif echo "$REPORT" | grep -qiE "needs.human|risky|breaking"; then | |
| gh pr edit "$PR" --repo "$REPO" --add-label needs-human || true | |
| fi | |
| cron-mode: | |
| name: Run Security Agent (weekly cron) | |
| needs: guard | |
| if: > | |
| needs.guard.outputs.has_key == 'true' && | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Trigger Cursor agent | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| PROMPT=$(jq -Rs \ | |
| --arg repo "$REPO" \ | |
| --arg mode "weekly-cron" \ | |
| --arg empty "" \ | |
| ' | |
| gsub("\\{\\{repo\\}\\}"; $repo) | |
| | gsub("\\{\\{mode\\}\\}"; $mode) | |
| | gsub("\\{\\{pr.url\\}\\}"; $empty) | |
| | gsub("\\{\\{pr.author\\}\\}"; $empty) | |
| | gsub("\\{\\{pr.labels\\}\\}"; $empty) | |
| ' .cursor/agent-prompts/security.md) | |
| PAYLOAD=$(jq -n \ | |
| --arg prompt "$PROMPT" \ | |
| --arg repo "$REPO" \ | |
| '{ | |
| prompt: { text: $prompt }, | |
| source: { repository: ("github.com/" + $repo), ref: "main" }, | |
| target: { autoCreatePr: true, openAsCursorGithubApp: true }, | |
| model: "composer-2.5" | |
| }') | |
| response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ | |
| -H "Authorization: Bearer $CURSOR_API_KEY" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$PAYLOAD") | |
| echo "$response" | jq . | |
| echo "$response" | jq -e '.id' >/dev/null |