external-pr-scope.js 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. 'use strict';
  2. // Shared logic for the external-PR allowlist (keyed on source org, not on individuals).
  3. //
  4. // A pull request opened by a non-member is "in scope" only if it ADDS plugin entries to
  5. // .claude-plugin/marketplace.json whose source.url is under an allowlisted prefix
  6. // (.github/external-pr-allowed-sources.json) and changes nothing else — no other files,
  7. // no removals, no edits to existing entries.
  8. //
  9. // Used by:
  10. // - close-external-prs.yml (skip the auto-close when in scope)
  11. // - external-pr-scope-guard.yml (required status check: fail a non-member PR that is out of scope)
  12. //
  13. // Security: evaluate() reads the head marketplace.json as DATA via the API and parses it;
  14. // it never checks out or executes head code. The allowlist + this script are read from the
  15. // trusted base checkout.
  16. const fs = require('fs');
  17. const MARKETPLACE = '.claude-plugin/marketplace.json';
  18. function normalizeUrl(u) {
  19. return String(u).trim().toLowerCase()
  20. .replace(/^git\+/, '')
  21. .replace(/^https?:\/\//, '')
  22. .replace(/\.git$/, '')
  23. .replace(/\/+$/, ''); // no trailing slash; matching adds the boundary
  24. }
  25. function loadAllowed(allowlistPath) {
  26. const j = JSON.parse(fs.readFileSync(allowlistPath, 'utf8'));
  27. return (j.allowed_sources || []).map(normalizeUrl);
  28. }
  29. function pluginsByName(json) {
  30. const map = {};
  31. for (const p of (json && json.plugins) || []) { if (p && p.name) map[p.name] = p; }
  32. return map;
  33. }
  34. function sourceAllowed(url, allowed) {
  35. const n = normalizeUrl(url);
  36. if (n.split('/').length < 3) return false; // require a real host/org/repo path
  37. // Boundary-safe: exact repo, or strictly under the allowed prefix.
  38. return allowed.some(a => n === a || n.startsWith(a + '/'));
  39. }
  40. // Pure decision over an already-computed diff. Returns { ok, problems, added, removed, modified }.
  41. function analyze({ changedFiles, base, head, allowed }) {
  42. const problems = [];
  43. const off = changedFiles.filter(n => n !== MARKETPLACE);
  44. if (off.length) problems.push(`changes files other than ${MARKETPLACE}: ${off.join(', ')}`);
  45. const baseNames = new Set(Object.keys(base));
  46. const headNames = new Set(Object.keys(head));
  47. const removed = [...baseNames].filter(n => !headNames.has(n));
  48. const added = [...headNames].filter(n => !baseNames.has(n));
  49. const modified = [...headNames].filter(
  50. n => baseNames.has(n) && JSON.stringify(base[n]) !== JSON.stringify(head[n])
  51. );
  52. if (removed.length) problems.push(`removes existing entr${removed.length > 1 ? 'ies' : 'y'}: ${removed.join(', ')}`);
  53. if (modified.length) problems.push(`modifies existing entr${modified.length > 1 ? 'ies' : 'y'}: ${modified.join(', ')}`);
  54. if (!off.length && !added.length && !removed.length && !modified.length) {
  55. problems.push('makes no in-scope change (expected additions to marketplace.json)');
  56. }
  57. for (const name of added) {
  58. const url = head[name] && head[name].source && head[name].source.url;
  59. if (!url) { problems.push(`added "${name}" has no source.url to validate`); continue; }
  60. if (!sourceAllowed(url, allowed)) {
  61. problems.push(`added "${name}" points at ${url}, outside the allowed sources`);
  62. }
  63. }
  64. return { ok: problems.length === 0, problems, added, removed, modified };
  65. }
  66. async function readPlugins(github, owner, repo, ref) {
  67. try {
  68. const { data } = await github.rest.repos.getContent({ owner, repo, ref, path: MARKETPLACE });
  69. return pluginsByName(JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')));
  70. } catch (e) {
  71. return null;
  72. }
  73. }
  74. // API wrapper used by both workflows. Fetches the diff and delegates to analyze().
  75. async function evaluate({ github, context, allowlistPath }) {
  76. const pr = context.payload.pull_request;
  77. const owner = context.repo.owner, repo = context.repo.repo;
  78. const allowed = loadAllowed(allowlistPath);
  79. if (!allowed.length) return { ok: false, problems: ['allowed_sources is empty'], added: [], removed: [], modified: [] };
  80. const files = await github.paginate(github.rest.pulls.listFiles, {
  81. owner, repo, pull_number: pr.number, per_page: 100,
  82. });
  83. const changedFiles = files.map(f => f.filename);
  84. const base = await readPlugins(github, owner, repo, pr.base.sha);
  85. const head = await readPlugins(github, pr.head.repo.owner.login, pr.head.repo.name, pr.head.sha);
  86. if (base === null || head === null) {
  87. return { ok: false, problems: ['could not read marketplace.json at base and/or head'], added: [], removed: [], modified: [] };
  88. }
  89. return analyze({ changedFiles, base, head, allowed });
  90. }
  91. module.exports = { normalizeUrl, sourceAllowed, analyze, readPlugins, evaluate, MARKETPLACE };