external-pr-scope.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. 'use strict';
  2. // Shared logic for letting a NON-MEMBER pull request stay open and be reviewed, scoped to
  3. // the contributor's own already-listed plugin repo. No maintained allowlist, no individuals.
  4. //
  5. // Trust model: we do NOT verify the submitter's identity. We trust the SOURCE REPO. A PR is
  6. // in scope only if it ADDS marketplace.json entries whose source.url is a repo that ALREADY
  7. // backs a live entry in this marketplace (derived from the base marketplace.json), pinned to
  8. // a commit in that repo. Because the repo is org-controlled and the SHA pins to a real commit
  9. // there, the shipped code is the org's code regardless of who opened the PR. Merge still
  10. // requires CI + a maintainer approval.
  11. //
  12. // Used by:
  13. // - close-external-prs.yml (skip the auto-close when in scope)
  14. // - external-pr-scope-guard.yml (required status check: fail a non-member PR that is out of scope)
  15. //
  16. // Security: evaluate() reads base + head marketplace.json as DATA via the API and parses them;
  17. // it never checks out or executes head code.
  18. const MARKETPLACE = '.claude-plugin/marketplace.json';
  19. function normalizeRepo(u) {
  20. return String(u || '').trim().toLowerCase()
  21. .replace(/^git\+/, '')
  22. .replace(/^https?:\/\//, '')
  23. .replace(/\.git$/, '')
  24. .replace(/\/+$/, '');
  25. }
  26. function pluginsByName(json) {
  27. const map = {};
  28. for (const p of (json && json.plugins) || []) { if (p && p.name) map[p.name] = p; }
  29. return map;
  30. }
  31. // Repos that already back a live entry, derived from the base marketplace.json.
  32. function liveReposOf(base) {
  33. const s = new Set();
  34. for (const name of Object.keys(base)) {
  35. const u = base[name] && base[name].source && base[name].source.url;
  36. if (!u) continue;
  37. const r = normalizeRepo(u);
  38. if (r.split('/').length >= 3) s.add(r); // host/org/repo
  39. }
  40. return s;
  41. }
  42. // Pure decision over an already-computed diff. Returns { ok, problems, added, removed, modified }.
  43. // before = plugins at the MERGE-BASE (what head forked from), after = plugins at HEAD,
  44. // liveRepos = repos already live on the current base branch. Diffing before->after (not
  45. // base-tip->head) isolates THIS PR's changes; a stale fork no longer shows main's later
  46. // additions as phantom removals.
  47. function analyze({ changedFiles, before, after, liveRepos }) {
  48. const problems = [];
  49. const off = changedFiles.filter(n => n !== MARKETPLACE);
  50. if (off.length) problems.push(`changes files other than ${MARKETPLACE}: ${off.join(', ')}`);
  51. const baseNames = new Set(Object.keys(before));
  52. const headNames = new Set(Object.keys(after));
  53. const removed = [...baseNames].filter(n => !headNames.has(n));
  54. const added = [...headNames].filter(n => !baseNames.has(n));
  55. const modified = [...headNames].filter(
  56. n => baseNames.has(n) && JSON.stringify(before[n]) !== JSON.stringify(after[n])
  57. );
  58. if (removed.length) problems.push(`removes existing entr${removed.length > 1 ? 'ies' : 'y'}: ${removed.join(', ')}`);
  59. if (modified.length) problems.push(`modifies existing entr${modified.length > 1 ? 'ies' : 'y'}: ${modified.join(', ')}`);
  60. if (!off.length && !added.length && !removed.length && !modified.length) {
  61. problems.push('makes no in-scope change (expected additions to marketplace.json)');
  62. }
  63. const unvalidated = [];
  64. for (const name of added) {
  65. // Command sources have no repo URL to validate; report them so a reviewer checks them by hand.
  66. if (after[name] && after[name].source && after[name].source.source === 'command') { unvalidated.push(name); continue; }
  67. const u = after[name] && after[name].source && after[name].source.url;
  68. if (!u) { problems.push(`added "${name}" has no source.url to validate`); continue; }
  69. const r = normalizeRepo(u);
  70. if (r.split('/').length < 3) { problems.push(`added "${name}" source.url ${u} is not a valid repo URL`); continue; }
  71. if (!liveRepos.has(r)) {
  72. problems.push(`added "${name}" points at ${u}, a repo with no existing live plugin in this marketplace`);
  73. }
  74. }
  75. return { ok: problems.length === 0, problems, added, removed, modified, unvalidated, liveRepoCount: liveRepos.size };
  76. }
  77. async function readPlugins(github, owner, repo, ref) {
  78. try {
  79. const { data } = await github.rest.repos.getContent({ owner, repo, ref, path: MARKETPLACE });
  80. return pluginsByName(JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')));
  81. } catch (e) {
  82. return null;
  83. }
  84. }
  85. // API wrapper used by both workflows. Fetches the diff + base/head marketplace.json, delegates to analyze().
  86. async function evaluate({ github, context }) {
  87. const pr = context.payload.pull_request;
  88. const owner = context.repo.owner, repo = context.repo.repo;
  89. const files = await github.paginate(github.rest.pulls.listFiles, {
  90. owner, repo, pull_number: pr.number, per_page: 100,
  91. });
  92. const changedFiles = files.map(f => f.filename);
  93. // Diff THIS PR's changes (merge-base -> head), not base-tip -> head, so a fork that is
  94. // behind main doesn't show main's later additions as phantom removals.
  95. let mergeBaseSha = pr.base.sha;
  96. try {
  97. const cmp = await github.rest.repos.compareCommits({ owner, repo, base: pr.base.sha, head: pr.head.sha });
  98. if (cmp && cmp.data && cmp.data.merge_base_commit && cmp.data.merge_base_commit.sha) {
  99. mergeBaseSha = cmp.data.merge_base_commit.sha;
  100. }
  101. } catch (e) { /* fall back to base.sha */ }
  102. const liveBase = await readPlugins(github, owner, repo, pr.base.sha); // current base branch (for "already live")
  103. const before = await readPlugins(github, owner, repo, mergeBaseSha); // what head forked from
  104. const after = await readPlugins(github, pr.head.repo.owner.login, pr.head.repo.name, pr.head.sha);
  105. if (liveBase === null || before === null || after === null) {
  106. return { ok: false, problems: ['could not read marketplace.json at base, merge-base, and/or head'], added: [], removed: [], modified: [] };
  107. }
  108. return analyze({ changedFiles, before, after, liveRepos: liveReposOf(liveBase) });
  109. }
  110. // Authors that are NOT subject to the external-contributor scope rules:
  111. // - the repo's own automation bot — its bump PRs legitimately MODIFY existing entries
  112. // (SHA bumps), which the additions-only external-contributor rule forbids; AND
  113. // - org members (write/admin).
  114. // Safe under pull_request_target: a fork PR cannot set its author to github-actions[bot]
  115. // (that login is only ever the org's own GITHUB_TOKEN workflow), and the member path is a
  116. // real permission lookup. Wrapped in try/catch because getCollaboratorPermissionLevel throws
  117. // for a non-collaborator/unknown user — without this, both callers would error the job rather
  118. // than fall through to scope evaluation.
  119. const EXEMPT_BOTS = new Set(['github-actions[bot]']);
  120. async function isExemptAuthor({ github, context }) {
  121. const author = context.payload.pull_request.user.login;
  122. if (EXEMPT_BOTS.has(author)) {
  123. return { exempt: true, reason: `${author} is the trusted automation bot` };
  124. }
  125. try {
  126. const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
  127. owner: context.repo.owner, repo: context.repo.repo, username: author,
  128. });
  129. if (['admin', 'write'].includes(data.permission)) {
  130. return { exempt: true, reason: `${author} is ${data.permission} (member)` };
  131. }
  132. } catch (e) {
  133. // not a collaborator / lookup failed → not exempt; fall through to scope evaluation
  134. }
  135. return { exempt: false };
  136. }
  137. module.exports = { normalizeRepo, liveReposOf, analyze, readPlugins, evaluate, isExemptAuthor, MARKETPLACE };