-
Notifications
You must be signed in to change notification settings - Fork 2k
303 lines (283 loc) · 16.4 KB
/
Copy pathclaude-bc-risk-router.yml
File metadata and controls
303 lines (283 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
name: bc-risk-router
# To make verify-bc-check-comment actually BLOCK merging, add it as a required
# status check in Settings -> Branches -> branch protection rules for master and dev.
#
# Security model:
# bc-scanner — pull_request, fork context, no secrets, does the checkout + file detection.
# bc-risk-router — workflow_run (base-repo context, write token) reads the artifact and
# posts the gating comment. No checkout ever happens here.
on:
workflow_run:
workflows: [ "bc-scanner" ]
types: [ completed ]
issue_comment:
types: [ created, edited, deleted ]
env:
BC_SIGNOFF_MIN_CHARS: 60
jobs:
post-reminder-comment:
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read # download artifact from the scanner run
issues: write
pull-requests: write
steps:
- name: Download scan result
id: download
continue-on-error: true # gracefully handles skipped scanner jobs (e.g. draft PRs)
uses: actions/download-artifact@v4
with:
name: bc-scan-result
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Post reminder comment (once per PR)
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
env:
BC_SIGNOFF_MIN_CHARS: ${{ env.BC_SIGNOFF_MIN_CHARS }}
with:
script: |
const fs = require('fs');
const changedContent = fs.readFileSync('changed-files.txt', 'utf8').trim();
if (!changedContent) {
core.info('No hot-path files changed; skipping.');
return;
}
const changedFiles = changedContent.split('\n').filter(Boolean);
const prNumber = parseInt(fs.readFileSync('pr-number.txt', 'utf8').trim(), 10);
const base = fs.readFileSync('base-ref.txt', 'utf8').trim();
const marker = '<!-- bc-risk-router:reminder -->';
const comments = await github.paginate(github.rest.issues.listComments, {
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
if (comments.some(c => c.body.includes(marker))) return;
// Build the prompt as an array to avoid multi-line template literal
// indentation problems inside this YAML block scalar.
const promptLines = [
'```',
`You are auditing a Trust Wallet Core PR against origin/${base}`,
'for **backward-compatibility risk against persisted user data and wire formats**.',
'Find cases where this PR will reject, mis-parse, or mishandle inputs that older',
'versions of our software already wrote to disk, to backups, or onto the network.',
'',
'**Trust Wallet Core domain context:**',
'- src/Keystore/ — JSON keystore files (user encrypted keys/mnemonics).',
' These live in iCloud, Google Drive, and manual exports.',
' A parse failure = wallet inaccessible = user cannot access funds.',
'- src/proto/*.proto — Protobuf SigningInput/SigningOutput wire formats.',
' Field numbers are permanent; reuse or removal silently corrupts binary data.',
'- include/TrustWalletCore/TW*.h — Public C ABI.',
' Removing/renaming TW* functions or changing enum values breaks compiled bindings.',
'- registry.json — coin metadata (SLIP44, derivation path, curve, address encoding).',
' Changing any of these re-derives different addresses for all existing wallets.',
'',
"Do NOT trust the PR description's framing.",
"'Just a security fix' / 'stricter validation' is exactly the framing that hides this class of bug.",
'Read docs/bc-footguns.md first.',
'',
'Walk these 5 steps. Cite file:line and commit SHA everywhere:',
'',
'1. Classify: tightening validation? Parsing change? Exception type change?',
" Removing 'if missing use default'? Moving validation into a constructor?",
'2. Historical baseline. For each tightened rule:',
' - Could a prior version have PRODUCED data that violates the new rule? (cite SHA)',
' - Was there a partial migration that may not have completed for all users?',
' - Does the format ever leave the device (backup, export, sync)?',
'3. Concrete failure scenarios: old version -> action -> where stored -> code path',
' (file:line) -> user symptom -> blast radius. No hand-waving.',
'4. Red-flag checklist (yes/no + file:line evidence):',
' - New throw on a read/load/decode/import path?',
" - Removed an 'if missing use default' branch?",
' - New length/range/enum check on a >1-year-old field?',
' - Constructor changed from lenient parse to parse+validate?',
' - Changed which exception type a public API throws?',
' - Format ever in backup/export/sync?',
" - Prior PR shipped a 'regenerate on next user action' partial migration? (cite SHA)",
' - Proto: field number reused or removed? Enum value renumbered/removed?',
' - Keystore: JSON key renamed/removed/made required without default fallback?',
' - Registry: slip44, curve, or address-encoding field changed?',
'5. Mitigations: accept legacy at read + normalize on write (preferred);',
" gate strict check behind 'newly created' flag; one-time migration with clear UX;",
' or apply tightening to write paths only.',
'',
'Output a markdown report: Verdict (SAFE/RISK/BLOCKER) at top, then steps 1-5,',
"then a 'Suggested PR comment' block ([bc-check: Pass|Mitigated|N/A] + reasoning),",
"then a 'Suggested addition to docs/bc-footguns.md' block (or 'none').",
'```',
];
const prompt = promptLines.join('\n');
const body = [
marker,
'⚠️ **Backward-compatibility check needed**',
'',
`This PR touches persistence-sensitive files: ${changedFiles.map(f => `\`${f}\``).join(', ')}.`,
'',
'Post a comment with one of:',
'- `[bc-check: Pass]` — audit run, **Verdict: SAFE**. Must include audit output.',
'- `[bc-check: Mitigated]` — audit found RISK or BLOCKER; **you fixed it in code**. Must include the post-fix audit output.',
'- `[bc-check: Risk-Accepted]` — audit found RISK or BLOCKER; **investigation confirms blast radius is effectively zero** (no user data in the wild can trigger it). Must include audit output + explicit evidence (who confirmed it, what data or reasoning).',
"- `[bc-check: N/A]` — scanner fired on a file with **zero BC relevance** (comment edit, test fixture, renamed variable). **Invalid if the audit found a real risk.**",
'',
`Each token must be accompanied by >=${process.env.BC_SIGNOFF_MIN_CHARS} chars of reasoning. The reasoning must be fresh — posted or edited at or after the HEAD commit.`,
'',
"**Why audit evidence is required for Pass / Mitigated:** humans don't reliably ask the right BC question on every PR. AI being in the loop is the whole point of this gate. The bot does not judge whether your reasoning is *correct* — reviewers do, like any other code review.",
'',
`Things worth thinking about: could a previous version have written data this PR's new check would now reject? Was there a partial migration ("regenerate on next user action") that may not have completed for all users? Does this format live in iCloud / Google Drive backup, exported files, or sync payloads? See \`docs/bc-footguns.md\` for known cases.`,
'',
`<details><summary>Changed files (${changedFiles.length})</summary>\n\n${changedFiles.map(f => `- \`${f}\``).join('\n')}\n\n</details>`,
'',
'<details><summary>Copy this audit prompt into Claude Code on this branch (or run the <code>/bc-check</code> skill) for a structured analysis you can paste into your sign-off</summary>',
'',
prompt,
'',
'</details>',
'',
'Merge is blocked by `verify-bc-check-comment` until a tagged comment is posted.',
].join('\n');
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
verify-bc-check-comment:
# No `needs` — runs independently on both workflow_run and issue_comment events.
# Uses the Checks API to post a check run directly onto pr.head.sha, so the result
# always lands on the right commit regardless of which event triggered this job.
runs-on: ubuntu-latest
if: always()
permissions:
checks: write
contents: read
pull-requests: read
issues: read
steps:
- name: Verify sign-off token
uses: actions/github-script@v7
env:
BC_SIGNOFF_MIN_CHARS: ${{ env.BC_SIGNOFF_MIN_CHARS }}
with:
script: |
let prNumber, pr;
if (context.payload.issue && context.payload.issue.pull_request) {
prNumber = context.payload.issue.number;
const { data } = await github.rest.pulls.get({
owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber
});
pr = data;
} else if (context.payload.workflow_run) {
const prs = context.payload.workflow_run.pull_requests;
if (!prs || prs.length === 0) {
// Fork PRs: workflow_run.pull_requests is empty — look up by head SHA.
const headSha = context.payload.workflow_run.head_sha;
const headOwner = context.payload.workflow_run.head_repository.owner.login;
const headBranch = context.payload.workflow_run.head_branch;
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${headOwner}:${headBranch}`,
});
pr = openPRs.find(p => p.head.sha === headSha);
if (!pr) {
core.info('Could not resolve PR from workflow_run; skipping.');
return;
}
prNumber = pr.number;
} else {
prNumber = prs[0].number;
const { data } = await github.rest.pulls.get({
owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber
});
pr = data;
}
} else {
core.info('Not a PR-related event; skipping.');
return;
}
// Posts a check run pinned to the PR head commit. Works from any trigger event —
// issue_comment-triggered runs are not automatically associated with a PR commit
// by GitHub, so this explicit API call is the only way to update the PR check.
async function postCheck(conclusion, title, summary) {
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'verify-bc-check-comment',
head_sha: pr.head.sha,
status: 'completed',
conclusion,
output: { title, summary },
});
}
if (pr.draft) {
core.info('PR is a draft; skipping.');
return;
}
if (!['master', 'dev'].includes(pr.base.ref)) {
core.info(`PR targets '${pr.base.ref}'; BC gate only applies to master and dev. Skipping.`);
await postCheck('success', 'Not applicable', `BC gate only applies to master and dev; this PR targets '${pr.base.ref}'.`);
return;
}
const comments = await github.paginate(github.rest.issues.listComments, {
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const reminderMarker = '<!-- bc-risk-router:reminder -->';
const hasReminder = comments.some(c => c.body.includes(reminderMarker));
if (!hasReminder) {
core.info('No BC-risk reminder posted; nothing to verify.');
await postCheck('success', 'No hot-path files changed', 'This PR does not touch persistence-sensitive files; no BC audit required.');
return;
}
// Sign-off must be at least as recent as the HEAD commit, otherwise
// it was made against a stale diff (e.g. before a fix push or before
// additional changes requested by a reviewer).
const { data: headCommit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: pr.head.sha,
});
const headCommitTime = new Date(headCommit.commit.committer.date);
const minChars = parseInt(process.env.BC_SIGNOFF_MIN_CHARS || '60', 10);
const tokenRegex = /\[bc-check:\s*(Pass|Mitigated|Risk-Accepted|N\/A)\]/i;
// Pass, Mitigated, and Risk-Accepted all require audit output.
// N/A does not, but is invalid when the audit contains a real risk verdict.
const auditEvidenceRegex = /(^|\n)#\s*BC-risk audit\b|(^|\n)\s*Verdict\s*:/i;
const riskVerdictRegex = /Verdict\s*[:\-]\s*(RISK|BLOCKER)\b/i;
const signoff = comments.find(c => {
const tokenMatch = c.body.match(tokenRegex);
if (!tokenMatch) return false;
const stripped = c.body.replace(tokenRegex, '').replace(/<!--[\s\S]*?-->/g, '').trim();
if (stripped.length < minChars) return false;
const verdict = tokenMatch[1].toLowerCase();
// All tokens except N/A require audit evidence.
if (verdict !== 'n/a' && !auditEvidenceRegex.test(c.body)) return false;
// N/A is invalid when the comment's own audit shows a real risk.
if (verdict === 'n/a' && riskVerdictRegex.test(c.body)) return false;
// updated_at lets the author edit an existing comment after pushing
// a fix instead of posting a brand-new one.
const commentTime = new Date(c.updated_at);
return commentTime >= headCommitTime;
});
if (!signoff) {
const msg =
'No fresh, evidence-backed sign-off found. Required:\n' +
' * Token: `[bc-check: Pass|Mitigated|Risk-Accepted|N/A]`\n' +
` * Token + reasoning >= ${minChars} chars.\n` +
' * Posted or edited at or after the HEAD commit.\n' +
' * Pass / Mitigated / Risk-Accepted: must include audit output (a `# BC-risk audit ...` header or a `Verdict:` line).\n' +
' * Risk-Accepted: must also include explicit evidence that blast radius is effectively zero.\n' +
" * N/A is only valid when the change has zero BC relevance; rejected if the audit shows a RISK or BLOCKER verdict.\n" +
'If you pushed new commits after a previous sign-off, edit the existing comment (any edit counts as a refresh) so it reflects the current diff.';
await postCheck('failure', 'BC sign-off required', msg);
core.setFailed('BC sign-off required — see the verify-bc-check-comment check on this PR.');
return;
}
await postCheck('success', 'BC sign-off verified', `Sign-off by @${signoff.user.login} is valid and current (posted at ${signoff.updated_at}).`);
core.info(`Found fresh sign-off by @${signoff.user.login} at ${signoff.updated_at}.`);