{"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":31,"sourceCode":"import { execFileSync } from 'node:child_process';\nimport { existsSync, mkdtempSync, mkdirSync, rmSync, cpSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';\nimport { 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';\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":13,"sourceCodeEnd":49,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/plugin-install.mjs#L13-L49","documentation":"Thrown by parseRepoArg() in plugin-install.mjs when a plugin source argument is not a safe GitHub URL. After normalizing owner/repo shorthand and stripping a trailing .git, the value must match the strict GITHUB_URL_RE (https://github.com/<owner>/<repo>). This blocks git protocol/file schemes, other hosts, and malformed inputs before any clone.","triggerScenarios":"Passing a git@github.com:... SSH URL; an https:// bitbucket/giteab/gitlab URL; a file:/// or git:// URL; a URL with a port, userinfo, query, or fragment that breaks the strict regex; a raw local path.","commonSituations":"User copy-pasted the SSH clone URL from GitHub instead of the HTTPS one; attempting to install a plugin hosted outside GitHub (not supported); a malformed clipboard copy introduced extra characters.","solutions":["Use the HTTPS URL form: https://github.com/<owner>/career-ops-plugin-<name>.","Or pass the owner/repo shorthand: <owner>/career-ops-plugin-<name>.","Avoid SSH (git@github.com:...) and any non-github.com host.","Re-copy the URL fresh from the GitHub repo page."],"exampleFix":"// before\nparseRepoArg('git@github.com:acme/career-ops-plugin-x.git');\n// after\nparseRepoArg('acme/career-ops-plugin-x');","handlingStrategy":"validation","validationCode":"const GITHUB_URL_RE = /^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+?(?:\\.git)?$/;\nfunction isSafeGithubRepo(s) {\n  let url = s;\n  if (/^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+$/.test(s)) url = `https://github.com/${s}`;\n  url = url.replace(/\\.git$/, '');\n  return GITHUB_URL_RE.test(url);\n}","typeGuard":"/** Narrows a string to a safe owner/repo or https github.com URL form. */\nfunction isSafeGithubRepo(s) {\n  if (typeof s !== 'string') return false;\n  let url = s;\n  if (/^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+$/.test(s)) url = `https://github.com/${s}`;\n  url = url.replace(/\\.git$/, '');\n  return /^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+$/.test(url);\n}","tryCatchPattern":null,"preventionTips":["Always copy the HTTPS clone URL from GitHub, never the SSH one.","Validate the URL with parseRepoArg() before wiring it into install automation.","Only GitHub is supported; reject other hosts at the UX boundary."],"tags":["security","url-validation","plugins","github","supply-chain"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}