From a18ed0e86e5cb03c48440acfc613ff940abf99c1 Mon Sep 17 00:00:00 2001 From: Curtis Gray Date: Tue, 1 Sep 2026 04:55:18 -0400 Subject: [PATCH] fix(providers): don't flag the global providers.json as project-local when cwd is $HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running graphify from the home directory makes the relative ./.graphify/providers.json resolve to ~/.graphify/providers.json itself, so the gate warned about 'ignoring' the user's own trusted global file — which it then loaded via the global path anyway. A false positive here trains users to export GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1 and disarm the gate for real. Treat a local path that is the same file as the global one as not-local: no warning, no double read. Stat errors resolve to 'distinct' so the gate never silently relaxes. Claude-Session: https://claude.ai/code/session_0115RXPvhNCHAnc7AFn1ud3H --- graphify/llm.py | 15 +++++++++++++-- tests/test_provider_registry.py | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/graphify/llm.py b/graphify/llm.py index 75f6378818..be38cbf723 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 0366c13ff2..c4037d19c5 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"