{"record":{"id":"bc2b6fec859974a3","repo":"santifer/career-ops","slug":"refusing-non-github-unsafe-repo-url-arg-expec","errorCode":null,"errorMessage":"refusing non-GitHub/unsafe repo URL: ${arg} (expected https://github.com/<owner>/<repo>)","messagePattern":"refusing non-GitHub/unsafe repo URL: (.+?) \\(expected https://github\\.com/<owner>/<repo>\\)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugin-install.mjs","lineNumber":33,"sourceCode":"import { tmpdir } from 'node:os';\nimport path from 'node:path';\nimport { validateManifest } from './plugins/_engine.mjs';\nimport { hashPluginTree } from './plugins/_lock.mjs';\nimport { auditPlugin } from './plugin-audit.mjs';\nimport { validateFlags } from './lib/cli-flags.mjs';\nimport { isMainModule } from './lib/is-main-module.mjs';\n\nconst GITHUB_URL_RE = /^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+?(?:\\.git)?$/;\nconst NAME_RE = /^career-ops-plugin-([a-z0-9][a-z0-9-]*)$/;\nconst SHA_RE = /^[0-9a-f]{40}$/;\nconst MIN_FILES = ['manifest.json', 'index.mjs', 'README.md', 'LICENSE'];\n\n/** Normalize `owner/repo` | full URL into a validated github URL + the plugin id. */\nexport function parseRepoArg(arg) {\n  let url = arg;\n  if (/^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+$/.test(arg)) url = `https://github.com/${arg}`;\n  url = url.replace(/\\.git$/, '');\n  if (!GITHUB_URL_RE.test(url)) throw new Error(`refusing non-GitHub/unsafe repo URL: ${arg} (expected https://github.com/<owner>/<repo>)`);\n  const repoName = url.split('/').pop() || '';\n  const m = NAME_RE.exec(repoName);\n  if (!m) throw new Error(`repo must be named \"career-ops-plugin-<name>\" (got \"${repoName}\")`);\n  return { url, id: m[1] };\n}\n\n/** Clone the EXACT pinned SHA into a fresh temp dir. Returns the temp dir path. */\nexport function safeClone(url, sha) {\n  if (!SHA_RE.test(sha || '')) throw new Error(`a 40-hex commit --sha is required (got ${JSON.stringify(sha)})`);\n  const dir = mkdtempSync(path.join(tmpdir(), 'co-plugin-'));\n  const git = (...args) => execFileSync('git', ['-c', 'protocol.ext.allow=never', '-c', 'protocol.file.allow=never', ...args], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 120_000 });\n  try {\n    git('-C', dir, 'init', '-q');\n    git('-C', dir, 'remote', 'add', 'origin', '--', url);\n    git('-C', dir, 'fetch', '--depth', '1', '--no-tags', '-q', 'origin', sha);\n    git('-C', dir, 'checkout', '-q', 'FETCH_HEAD');\n    rmSync(path.join(dir, '.git'), { recursive: true, force: true }); // drop VCS metadata (and any hooks)\n    return dir;","sourceCodeStart":15,"sourceCodeEnd":51,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/plugin-install.mjs#L15-L51","documentation":"parseRepoArg normalizes an `owner/repo` shorthand or a full URL into a GitHub HTTPS URL, then validates it against GITHUB_URL_RE. Anything that does not resolve to https://github.com/<owner>/<repo> is refused with this message to prevent installing plugins from arbitrary or malicious hosts (a supply-chain / SSRF guard).","triggerScenarios":"installFromRepo/parseRepoArg called with a GitLab or Bitbucket URL, an ssh:// or git@ form, a URL with extra path segments, a localhost/file:// URL, or a bare repo name without owner/.","commonSituations":"User copies a clone URL in ssh form (git@github.com:owner/repo.git) from GitHub's UI; typo like http:// (not https) with extra segments; pointing at an internal mirror; passing a raw repo slug with slashes in the wrong place.","solutions":["Pass `owner/repo` shorthand or the canonical https://github.com/<owner>/<repo> URL","Convert ssh URLs: git@github.com:owner/repo.git → https://github.com/owner/repo","Strip extra path suffixes (/tree/main, /issues) — only owner/repo is accepted","Check for typos in scheme and host (must be https and github.com)"],"exampleFix":"// before\nawait installFromRepo(root, { url: 'git@github.com:acme/career-ops-plugin-demo.git', sha });\n// after\nawait installFromRepo(root, { url: 'acme/career-ops-plugin-demo', sha }); // or 'https://github.com/acme/career-ops-plugin-demo'","handlingStrategy":"validation","validationCode":"const ok = (a) => /^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+$/.test(a) || /^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(\\.git)?$/.test(a.replace(/\\.git$/, ''));","typeGuard":"const isGithubRepoArg = (a) => typeof a === 'string' && (a.includes('/') ? /^https:\\/\\/github\\.com\\/[^/]+\\/[^/]+$/.test(a.replace(/\\.git$/, '')) : /^[^/]+\\/[^/]+$/.test(a));","tryCatchPattern":"try { const { url, id } = parseRepoArg(arg); } catch (e) { if (e.message.startsWith('refusing non-GitHub/unsafe repo URL')) { console.error('Use owner/repo or https://github.com/owner/repo'); } else throw e; }","preventionTips":["Always pass owner/repo shorthand or the plain https://github.com/owner/repo URL","Convert ssh clone URLs before passing them","Strip /tree/<branch> and similar UI suffixes from copied URLs","Treat the refusal as a security feature — do not try to bypass it"],"tags":["security","validation","url","github"],"backgroundTag":"invalid-url-format","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}