From 03bfe3f25da06141415cfa37e635935267974e52 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:14:19 +0000 Subject: [PATCH] ci: add cyclops audit workflow --- .changelog/cyclops-audit-workflow.md | 5 + .github/workflows/cyclops-audit.yml | 286 +++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 .changelog/cyclops-audit-workflow.md create mode 100644 .github/workflows/cyclops-audit.yml diff --git a/.changelog/cyclops-audit-workflow.md b/.changelog/cyclops-audit-workflow.md new file mode 100644 index 0000000..152f360 --- /dev/null +++ b/.changelog/cyclops-audit-workflow.md @@ -0,0 +1,5 @@ +--- +wallet-cli: none +--- + +Add the Cyclops audit trigger workflow for pull request security reviews. diff --git a/.github/workflows/cyclops-audit.yml b/.github/workflows/cyclops-audit.yml new file mode 100644 index 0000000..ef73783 --- /dev/null +++ b/.github/workflows/cyclops-audit.yml @@ -0,0 +1,286 @@ +name: cyclops-audit + +on: + pull_request: + types: [labeled] + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + publish-label: + if: github.event_name == 'pull_request' && (github.event.label.name == 'cyclops' || github.event.label.name == 'agentic-audit') + runs-on: ubuntu-latest + steps: + - name: Publish event + run: | + set -euo pipefail + + printf '%s' '${{ secrets.EVENTS_KEY }}' > "${RUNNER_TEMP}/key" + printf '%s' '${{ secrets.EVENTS_CERT }}' > "${RUNNER_TEMP}/cert" + + # EVENTS_ARGS may contain additional curl arguments, not just a URL. + curl -sf -o /dev/null -X POST ${{ secrets.EVENTS_ARGS }} \ + -H "Content-Type: application/json" \ + --key "${RUNNER_TEMP}/key" \ + --cert "${RUNNER_TEMP}/cert" \ + -d '{ + "repository": "${{ github.repository }}", + "event": "pr_audit", + "data": { + "pr_number": ${{ github.event.pull_request.number }}, + "sha": "${{ github.event.pull_request.head.sha }}" + } + }' + + cyclops-audit: + if: >- + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + ( + startsWith(github.event.comment.body, 'cyclops audit') || + startsWith(github.event.comment.body, '@decofe cyclops audit') || + startsWith(github.event.comment.body, 'derek audit') + ) + runs-on: ubuntu-latest + steps: + - name: Check commenter permission + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const allowed = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const commenterAssociation = context.payload.comment.author_association; + if (!allowed.has(commenterAssociation)) { + core.setFailed(`@${context.payload.comment.user.login} is not allowed to trigger Cyclops audits (${commenterAssociation})`); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (!allowed.has(pr.author_association)) { + core.setFailed(`PR author @${pr.user.login} is not allowed to trigger Cyclops audits (${pr.author_association})`); + } + + - name: Parse arguments + id: args + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const usage = [ + '**Usage:** `cyclops audit [fast] [iterations=N] [hours=N] [config=pr-review.yaml] ', + '[models="anthropic/claude-opus-4-7,openai/gpt-5.5"] [run-label=LABEL] ', + '[dry-run] [note="per-run audit guidance"]`', + ].join(''); + const body = context.payload.comment.body.trim(); + const prefix = /^(?:@decofe\s+)?(?:cyclops\s+audit|derek\s+audit)\b/i; + const args = body.replace(prefix, '').trim(); + const parts = []; + const argRegex = /(\S+?[=:]"[^"]*"|\S+?[=:]'[^']*'|\S+?[=:]\S+|\S+)/g; + let match; + while ((match = argRegex.exec(args)) !== null) parts.push(match[1]); + + const defaults = { + config: '', + iterations: '', + hours: '', + models: '', + 'run-label': '', + 'dry-run': 'false', + note: '', + }; + const intArgs = new Set(['iterations', 'hours']); + const stringArgs = new Set(['config', 'models', 'run-label', 'note']); + const boolArgs = new Set(['dry-run']); + const unknown = []; + const invalid = []; + + for (const part of parts) { + if (part === 'fast') { + defaults.iterations = '1'; + continue; + } + + const eq = part.indexOf('='); + const colon = part.indexOf(':'); + const sep = eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon); + if (sep === -1) { + if (boolArgs.has(part)) { + defaults[part] = 'true'; + } else { + unknown.push(part); + } + continue; + } + + const key = part.slice(0, sep); + let value = part.slice(sep + 1); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + if (intArgs.has(key)) { + if (!/^[1-9]\d*$/.test(value)) { + invalid.push(`\`${key}=${value}\` (must be a positive integer)`); + } else { + defaults[key] = value; + } + } else if (boolArgs.has(key)) { + if (value === 'true' || value === 'false') { + defaults[key] = value; + } else { + invalid.push(`\`${key}=${value}\` (must be true or false)`); + } + } else if (stringArgs.has(key)) { + if (!value) { + invalid.push(`\`${key}=\` (must not be empty)`); + } else { + defaults[key] = value; + } + } else { + unknown.push(key); + } + } + + const errors = []; + if (unknown.length) errors.push(`Unknown argument(s): \`${unknown.join('`, `')}\``); + if (invalid.length) errors.push(`Invalid value(s): ${invalid.join(', ')}`); + if (errors.length) { + const msg = `Invalid cyclops audit command\n\n${errors.join('\n')}\n\n${usage}`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: msg, + }); + core.setFailed(msg); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + + const data = { + pr_number: context.issue.number, + sha: pr.head.sha, + source: 'comment', + actor: context.payload.comment.user.login, + comment_id: context.payload.comment.id, + dry_run: defaults['dry-run'] === 'true', + }; + if (defaults.config) data.config = defaults.config; + if (defaults.iterations) data.max_iterations = Number(defaults.iterations); + if (defaults.hours) data.max_hours = Number(defaults.hours); + if (defaults.models) data.models = defaults.models; + if (defaults['run-label']) data.run_label = defaults['run-label']; + if (defaults.note) data.audit_note_b64 = Buffer.from(defaults.note, 'utf8').toString('base64'); + + const payload = { + repository: `${context.repo.owner}/${context.repo.repo}`, + event: 'pr_audit', + data, + }; + + const summaryParts = [ + defaults.config ? `config: \`${defaults.config}\`` : 'config: `default`', + defaults.iterations ? `iterations: \`${defaults.iterations}\`` : 'iterations: `default`', + defaults.hours ? `hours: \`${defaults.hours}\`` : 'hours: `default`', + ]; + if (defaults.models) summaryParts.push(`models: \`${defaults.models}\``); + if (defaults['run-label']) summaryParts.push(`run-label: \`${defaults['run-label']}\``); + if (defaults['dry-run'] === 'true') summaryParts.push('dry-run: `true`'); + if (defaults.note) { + const note = defaults.note.replace(/`/g, "'").slice(0, 160); + summaryParts.push(`note: \`${note}${defaults.note.length > 160 ? '...' : ''}\``); + } + + core.setOutput('actor', context.payload.comment.user.login); + core.setOutput('payload-b64', Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')); + core.setOutput('summary', `**Config:** ${summaryParts.join(', ')}`); + core.setOutput('dry-run', defaults['dry-run']); + + - name: Acknowledge request + id: ack + uses: actions/github-script@v7 + env: + ACTOR: ${{ steps.args.outputs.actor }} + SUMMARY: ${{ steps.args.outputs.summary }} + with: + github-token: ${{ github.token }} + script: | + try { + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + } catch (error) { + core.warning(`Could not add acknowledgement reaction: ${error.message}`); + } + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `cc @${process.env.ACTOR}\n\nCyclops audit queued. [View workflow run](${runUrl})\n\n${process.env.SUMMARY}`, + }); + core.setOutput('comment-id', String(comment.id)); + + - name: Publish Cyclops audit event + id: publish + continue-on-error: true + env: + PAYLOAD_B64: ${{ steps.args.outputs.payload-b64 }} + run: | + set -euo pipefail + + printf '%s' '${{ secrets.EVENTS_KEY }}' > "${RUNNER_TEMP}/key" + printf '%s' '${{ secrets.EVENTS_CERT }}' > "${RUNNER_TEMP}/cert" + printf '%s' "$PAYLOAD_B64" | base64 --decode > "${RUNNER_TEMP}/pr-audit-event.json" + + # EVENTS_ARGS may contain additional curl arguments, not just a URL. + curl -sf -o /dev/null -X POST ${{ secrets.EVENTS_ARGS }} \ + -H "Content-Type: application/json" \ + --key "${RUNNER_TEMP}/key" \ + --cert "${RUNNER_TEMP}/cert" \ + -d @"${RUNNER_TEMP}/pr-audit-event.json" + + - name: Update status + if: always() + uses: actions/github-script@v7 + env: + COMMENT_ID: ${{ steps.ack.outputs.comment-id }} + PUBLISH_OUTCOME: ${{ steps.publish.outcome }} + ACTOR: ${{ steps.args.outputs.actor }} + SUMMARY: ${{ steps.args.outputs.summary }} + with: + github-token: ${{ github.token }} + script: | + if (!process.env.COMMENT_ID) return; + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const success = process.env.PUBLISH_OUTCOME === 'success'; + const body = success + ? `cc @${process.env.ACTOR}\n\nCyclops audit event published. [View workflow run](${runUrl})\n\n${process.env.SUMMARY}` + : `cc @${process.env.ACTOR}\n\nCyclops audit event failed to publish. [View workflow run](${runUrl})\n\n${process.env.SUMMARY}`; + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: Number(process.env.COMMENT_ID), + body, + }); + if (!success) core.setFailed('Failed to publish pr_audit event');