{"record":{"id":"7874eb7561c34b17","repo":"odysseus-dev/odysseus","slug":"context-must-stay-on-github-got-host-or-unkno","errorCode":null,"errorMessage":"{context} must stay on GitHub (got {host or 'unknown host'})","messagePattern":"(.+?) must stay on GitHub \\(got (.+?)\\)","errorType":"validation","errorClass":"SkillImportError","httpStatus":null,"severity":"error","filePath":"services/memory/skill_importer.py","lineNumber":40,"sourceCode":"ALLOWED_SUFFIXES = (\n    \".md\", \".txt\", \".json\", \".yaml\", \".yml\", \".py\", \".sh\", \".toml\",\n    \".js\", \".ts\", \".css\", \".html\", \".xml\", \".csv\",\n)\nTEXT_NAMES = {\"skill.md\", \"license\", \"license.md\", \"readme.md\"}\n_GITHUB_HOSTS = frozenset({\n    \"github.com\", \"www.github.com\", \"api.github.com\", \"raw.githubusercontent.com\",\n})\n_SKILLS_SH_HOSTS = frozenset({\"skills.sh\", \"www.skills.sh\"})\n\n\ndef _github_host(url: str) -> str:\n    return (urlparse(str(url)).hostname or \"\").lower()\n\n\ndef _assert_github_url(url: str, *, context: str = \"URL\") -> None:\n    host = _github_host(url)\n    if host not in _GITHUB_HOSTS:\n        raise SkillImportError(\n            f\"{context} must stay on GitHub (got {host or 'unknown host'})\"\n        )\n\n\n@dataclass\nclass ResolvedSource:\n    owner: str\n    repo: str\n    ref: str\n    path: str  # directory or file path inside repo (no leading slash)\n\n\nclass SkillImportError(ValueError):\n    pass\n\n\ndef _safe_relpath(rel: str) -> str:\n    rel = (rel or \"\").replace(\"\\\\\", \"/\").strip().lstrip(\"/\")","sourceCodeStart":22,"sourceCodeEnd":58,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/services/memory/skill_importer.py#L22-L58","documentation":"Raised by _assert_github_url in services/memory/skill_importer.py when a URL used during skill import is not hosted on one of the allowlisted GitHub hosts (github.com, www.github.com, api.github.com, raw.githubusercontent.com). The importer is SSRF-hardened: every hop must stay on GitHub, and any other hostname (or no hostname at all) is rejected with the offending host included in the message.","triggerScenarios":"A skills.sh redirect chain that lands on a non-GitHub host; a SKILL.md or bundle URL pointing at gitlab.com, a personal CDN, or an attacker-controlled domain; a malformed URL (e.g. 'github.com owner/repo' with a space, or 'not a url') whose urlparse hostname is empty, yielding 'unknown host'; a redirect Location header with a relative/host-less target at a point where the raw value is validated.","commonSituations":"Users pasting a GitLab or generic git host link into skill import; skills.sh changing its redirect target; redirect chains that pass through an intermediate tracker domain; URLs pasted with leading whitespace or line breaks that break hostname parsing.","solutions":["Re-import using a canonical URL on a supported host: https://github.com/<owner>/<repo>/tree/<ref>/<skill-path>, https://raw.githubusercontent.com/..., or https://skills.sh/...","Inspect the exact host in the error message — 'unknown host' means the URL string is malformed, so re-copy it without whitespace/quotes.","If the URL came from a redirect, fetch it manually (curl -I) and check each Location hop; the importer requires every hop on GitHub.","If you genuinely need a non-GitHub source, that is unsupported by design — vendor the skill into a GitHub repo first."],"exampleFix":"# before\nimport_skill(\"https://gitlab.com/foo/bar-skill\")\nSkillImportError: URL must stay on GitHub (got gitlab.com)\n\n# after\nimport_skill(\"https://github.com/foo/bar/tree/main/skills/bar\")","handlingStrategy":"validation","validationCode":"from urllib.parse import urlparse\n\n_GITHUB_HOSTS = {'github.com', 'www.github.com', 'api.github.com', 'raw.githubusercontent.com'}\n\ndef assert_github(url: str) -> None:\n    host = (urlparse(str(url)).hostname or '').lower()\n    if host not in _GITHUB_HOSTS:\n        raise ValueError(f'not a GitHub URL: {host or \"(no host — malformed URL)\"}')","typeGuard":"def is_github_url(url: str) -> bool:\n    return (urlparse(str(url)).hostname or '').lower() in _GITHUB_HOSTS","tryCatchPattern":"try:\n    src = parse_skill_source(url)\nexcept SkillImportError as e:\n    if 'must stay on GitHub' in str(e):\n        # show the user the supported URL formats instead of retrying\n        raise UserFacingError('Paste a github.com or skills.sh URL, e.g. https://github.com/owner/repo/tree/main/skills/name') from e\n    raise","preventionTips":["In the import UI, validate the hostname client-side before submission.","Trim/strip pasted URLs and reject empty hosts early so users see 'malformed URL' rather than 'unknown host'.","When following redirects in your own fetchers, re-assert the allowlist on every hop, as this importer does."],"tags":["security","ssrf","url-validation","github","skill-import"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}