diff --git a/graphify/__main__.py b/graphify/__main__.py index 8a7e851..543779d 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -2368,6 +2368,10 @@ def main() -> None: if not base_url or not default_model or not env_key: print("Error: --base-url, --default-model, and --env-key are required.", file=sys.stderr) sys.exit(1) + from graphify.llm import provider_base_url_ok + if not provider_base_url_ok(base_url, name): + print(f"Error: refusing to add provider with unsafe base_url {base_url!r}.", file=sys.stderr) + sys.exit(1) global_path.parent.mkdir(parents=True, exist_ok=True) existing = {} if global_path.is_file(): diff --git a/graphify/llm.py b/graphify/llm.py index c67014b..1619ad8 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -125,18 +125,75 @@ def _custom_providers_path(global_: bool = True) -> Path: return Path(".graphify") / "providers.json" +def provider_base_url_ok(base_url: str, name: str, *, warn: bool = True) -> bool: + """Structural safety check for a custom-provider base_url. + + A custom provider receives the full corpus plus the user's API key, so its + base_url is an exfiltration channel. We deliberately do NOT run the ingest + SSRF guard here: that blocks private/internal IPs, which would wrongly reject + legitimate on-prem corporate LLM gateways. Instead we reject non-http(s) + schemes outright and warn loudly when the corpus would leave over plaintext + http to a non-loopback host. The primary control against trusting injected + config is the GRAPHIFY_ALLOW_LOCAL_PROVIDERS gate on project-local files. + """ + from urllib.parse import urlparse + try: + parsed = urlparse(base_url) + except Exception: + if warn: + print(f"[graphify] WARNING: provider {name!r} has an unparseable base_url; ignoring.", file=sys.stderr) + return False + if parsed.scheme not in ("http", "https"): + if warn: + print( + f"[graphify] WARNING: provider {name!r} base_url scheme {parsed.scheme!r} is not " + "http/https; ignoring.", + file=sys.stderr, + ) + return False + host = (parsed.hostname or "").lower() + is_loopback = host in ("localhost", "127.0.0.1", "::1") or host.startswith("127.") + if warn and parsed.scheme == "http" and not is_loopback: + print( + f"[graphify] WARNING: provider {name!r} sends your corpus to {host!r} over plaintext " + "http. Use https unless this is a trusted local endpoint.", + file=sys.stderr, + ) + return True + + def _load_custom_providers() -> dict[str, dict]: + # A project-local ./.graphify/providers.json travels with a cloned or shared + # repo and defines where the corpus + API key are sent, so loading it + # silently is a corpus/key exfiltration vector. Require an explicit opt-in; + # the user's own global ~/.graphify/providers.json stays trusted. + local_path = _custom_providers_path(global_=False) + global_path = _custom_providers_path(global_=True) + allow_local = os.environ.get("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", "").strip().lower() in ("1", "true", "yes") + if local_path.is_file() and not allow_local: + print( + f"[graphify] WARNING: ignoring project-local {local_path} (custom providers control " + "where your corpus and API key are sent). Set GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1 to load it.", + file=sys.stderr, + ) + providers: dict[str, dict] = {} - for path in (_custom_providers_path(global_=False), _custom_providers_path(global_=True)): + paths = [local_path, global_path] if allow_local else [global_path] + for path in paths: if path.is_file(): try: data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): for name, cfg in data.items(): - if isinstance(name, str) and isinstance(cfg, dict) and name not in BACKENDS: - if "pricing" not in cfg: - cfg = dict(cfg, pricing={"input": 0.0, "output": 0.0}) - providers[name] = cfg + if not (isinstance(name, str) and isinstance(cfg, dict)): + continue + if name in BACKENDS or name in providers: + continue + if not provider_base_url_ok(str(cfg.get("base_url", "")), name): + continue + if "pricing" not in cfg: + cfg = dict(cfg, pricing={"input": 0.0, "output": 0.0}) + providers[name] = cfg except Exception: pass return providers diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index bbf082c..0366c13 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -69,6 +69,75 @@ def test_custom_provider_cannot_shadow_builtin(tmp_path): assert "claude" not in loaded +def test_project_local_providers_ignored_without_optin(tmp_path, monkeypatch, capsys): + """A project-local ./.graphify/providers.json is NOT loaded by default (F1). + + It travels with a cloned/shared repo and controls where the corpus + API key + are sent, so loading it silently is an exfiltration vector. + """ + local = tmp_path / "local.json" + local.write_text(json.dumps({ + "evil": {"base_url": "https://attacker.example/v1", "default_model": "m", "env_key": "K"} + }), encoding="utf-8") + missing_global = tmp_path / "global.json" # does not exist + + from graphify import llm + monkeypatch.setattr(llm, "_custom_providers_path", + lambda global_=True: missing_global if global_ else local) + monkeypatch.delenv("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", raising=False) + + loaded = llm._load_custom_providers() + assert "evil" not in loaded + assert "ignoring project-local" in capsys.readouterr().err + + +def test_project_local_providers_loaded_with_optin(tmp_path, monkeypatch): + """With explicit opt-in the project-local file is honoured (F1).""" + local = tmp_path / "local.json" + local.write_text(json.dumps({ + "lab": {"base_url": "https://lab.internal/v1", "default_model": "m", "env_key": "K"} + }), encoding="utf-8") + missing_global = tmp_path / "global.json" + + from graphify import llm + monkeypatch.setattr(llm, "_custom_providers_path", + lambda global_=True: missing_global if global_ else local) + monkeypatch.setattr(llm, "BACKENDS", {**llm.BACKENDS}) + monkeypatch.setenv("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", "1") + + loaded = llm._load_custom_providers() + assert "lab" in loaded + + +def test_non_http_provider_base_url_rejected(tmp_path, monkeypatch): + """A provider whose base_url uses a non-http(s) scheme is skipped on load (F1).""" + providers_file = tmp_path / "providers.json" + providers_file.write_text(json.dumps({ + "sneaky": {"base_url": "file:///etc/passwd", "default_model": "m", "env_key": "K"} + }), encoding="utf-8") + + from graphify import llm + monkeypatch.setattr(llm, "_custom_providers_path", + lambda global_=True: providers_file if global_ else tmp_path / "local.json") + monkeypatch.setattr(llm, "BACKENDS", {**llm.BACKENDS}) + + loaded = llm._load_custom_providers() + assert "sneaky" not in loaded + + +def test_provider_base_url_ok_scheme_and_warnings(capsys): + """provider_base_url_ok rejects bad schemes and warns on plaintext-http egress (F1).""" + from graphify import llm + assert llm.provider_base_url_ok("https://api.example/v1", "ok") is True + assert llm.provider_base_url_ok("http://localhost:11434/v1", "local") is True + assert llm.provider_base_url_ok("file:///etc/passwd", "bad") is False + assert llm.provider_base_url_ok("gopher://x/", "bad2") is False + capsys.readouterr() + # plaintext http to a non-loopback host loads but warns + assert llm.provider_base_url_ok("http://example.com/v1", "plain") is True + assert "plaintext" in capsys.readouterr().err + + def test_detect_backend_custom_provider_after_builtins(monkeypatch): """Custom providers appear after all built-ins in detect_backend() priority.""" from graphify import llm