diff --git a/graphify/llm.py b/graphify/llm.py index 75f637881..be38cbf72 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -272,8 +272,19 @@ def _load_custom_providers() -> dict[str, dict]: # the user's own global ~/.graphify/providers.json stays trusted. local_path = _custom_providers_path(global_=False) global_path = _custom_providers_path(global_=True) + # Running from $HOME makes the relative project-local path resolve to the + # global file itself. That is the user's own trusted config, not config + # injected by a repo, so it is not "local": warning about it is a false + # positive that only teaches people to export the bypass. On any stat + # error, resolve to "distinct" so the gate never silently relaxes. + try: + local_is_distinct = local_path.is_file() and not ( + global_path.is_file() and local_path.samefile(global_path) + ) + except OSError: + local_is_distinct = 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: + if local_is_distinct 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.", @@ -281,7 +292,7 @@ def _load_custom_providers() -> dict[str, dict]: ) providers: dict[str, dict] = {} - paths = [local_path, global_path] if allow_local else [global_path] + paths = [local_path, global_path] if (allow_local and local_is_distinct) else [global_path] for path in paths: if path.is_file(): try: diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 0366c13ff..c4037d19c 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -109,6 +109,30 @@ def test_project_local_providers_loaded_with_optin(tmp_path, monkeypatch): assert "lab" in loaded +def test_local_path_that_is_the_global_file_is_not_local(tmp_path, monkeypatch, capsys): + """Running from $HOME, ./.graphify/providers.json IS ~/.graphify/providers.json. + + The user's own trusted global file must load without the project-local + warning — a false positive there only trains people to export the + GRAPHIFY_ALLOW_LOCAL_PROVIDERS bypass and disarm the F1 gate for real. + """ + shared = tmp_path / ".graphify" / "providers.json" + shared.parent.mkdir() + shared.write_text(json.dumps({ + "mine": {"base_url": "https://gateway.example/v1", "default_model": "m", "env_key": "K"} + }), encoding="utf-8") + + from graphify import llm + monkeypatch.setattr(llm, "_custom_providers_path", + lambda global_=True: shared if global_ else tmp_path / ".graphify" / "providers.json") + monkeypatch.setattr(llm, "BACKENDS", {**llm.BACKENDS}) + monkeypatch.delenv("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", raising=False) + + loaded = llm._load_custom_providers() + assert "mine" in loaded + assert "ignoring project-local" not in capsys.readouterr().err + + 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"