"""Unit tests for the egress policy module (Stage 1a).""" from __future__ import annotations from unittest.mock import patch import pytest from huggingface_hub import _CACHED_NO_EXIST from local_deep_research.security.egress.policy import ( Decision, EgressContext, EgressScope, MAX_DENIED_FETCHES_PER_RUN, PolicyDeniedError, context_from_snapshot, evaluate_engine, evaluate_embeddings, evaluate_llm_endpoint, evaluate_url, filter_engines_by_egress, resolve_run_primary_engine, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_ctx( scope: EgressScope = EgressScope.BOTH, primary: str = "arxiv", require_local_llm: bool = False, require_local_embeddings: bool = False, local_hostnames=(), ) -> EgressContext: return EgressContext( scope=scope, primary_engine=primary, require_local_llm=require_local_llm, require_local_embeddings=require_local_embeddings, local_hostnames=tuple(local_hostnames), ) # --------------------------------------------------------------------------- # context_from_snapshot # --------------------------------------------------------------------------- def test_default_scope_constant_matches_registry(): """DEFAULT_EGRESS_SCOPE (the code-side fallback every reader imports) must equal the registered default in defaults/default_settings.json. This single test replaces scattered fallback-string assertions and is what prevents the code/registry drift ('both' vs 'adaptive') that motivated this fix from recurring.""" import json from local_deep_research.defaults import DEFAULTS_DIR from local_deep_research.security.egress.policy import ( DEFAULT_EGRESS_SCOPE, ) path = DEFAULTS_DIR / "default_settings.json" assert path.exists() with open(path, encoding="utf-8-sig") as f: registry = json.load(f) assert registry["policy.egress_scope"]["value"] == DEFAULT_EGRESS_SCOPE # The constant must also be a valid enum member (sanity). assert EgressScope(DEFAULT_EGRESS_SCOPE) def test_context_from_snapshot_defaults_to_adaptive(): """A missing policy.egress_scope falls back to the REGISTERED default (adaptive), matching what users with a settings DB already get: a public primary resolves PUBLIC_ONLY, a meta-picker primary resolves BOTH (the permissive pre-policy behavior).""" ctx = context_from_snapshot({}, primary_engine="arxiv") assert ctx.scope == EgressScope.PUBLIC_ONLY assert ctx.require_local_llm is False assert ctx.require_local_embeddings is False assert ctx.local_hostnames == () ctx_meta = context_from_snapshot({}, primary_engine="auto") assert ctx_meta.scope == EgressScope.BOTH def _scope_snapshot(scope, tool): return { "policy.egress_scope": {"value": scope}, "search.tool": {"value": tool}, } def test_adaptive_stray_auto_primary_resolves_to_both(): # "auto" is no longer a registered engine (meta-pickers were removed); a # stray value left in the DB is unclassifiable and falls through to BOTH. ctx = context_from_snapshot( _scope_snapshot("adaptive", "auto"), primary_engine="auto" ) assert ctx.scope == EgressScope.BOTH assert ctx.require_local_llm is False def test_adaptive_public_primary_resolves_to_public_only(): ctx = context_from_snapshot( _scope_snapshot("adaptive", "arxiv"), primary_engine="arxiv" ) assert ctx.scope == EgressScope.PUBLIC_ONLY # public scope does NOT force local inference assert ctx.require_local_llm is False def test_adaptive_private_primary_resolves_to_private_only_and_forces_local(): # 'library' is the always-private aggregate engine. ctx = context_from_snapshot( _scope_snapshot("adaptive", "library"), primary_engine="library" ) assert ctx.scope == EgressScope.PRIVATE_ONLY # private primary under adaptive must force local inference (coupling) assert ctx.require_local_llm is True assert ctx.require_local_embeddings is True def test_adaptive_falls_back_to_both_on_classification_error(): # An unknown concrete engine can't be classified → BOTH (permissive # fallback, never a hard fail). ctx = context_from_snapshot( _scope_snapshot("adaptive", "totally_unknown_engine"), primary_engine="totally_unknown_engine", ) assert ctx.scope == EgressScope.BOTH def test_public_collection_allowed_under_public_only_via_metadata(): """A collection flagged public classifies as a public engine: allowed under PUBLIC_ONLY, and (mirror) a private one is denied.""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY, primary="collection_abc") pub = evaluate_engine( "collection_abc", ctx, settings_snapshot={"policy.egress_scope": {"value": "public_only"}}, metadata={"is_public": True}, ) assert pub.allowed is True priv = evaluate_engine( "collection_abc", ctx, settings_snapshot={"policy.egress_scope": {"value": "public_only"}}, metadata={"is_public": False}, ) assert priv.allowed is False assert priv.reason == "scope_mismatch_public_only" def test_private_collection_allowed_under_private_only_via_metadata(): ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY, primary="collection_abc") priv = evaluate_engine( "collection_abc", ctx, settings_snapshot={"policy.egress_scope": {"value": "private_only"}}, metadata={"is_public": False}, ) assert priv.allowed is True # A collection is ALWAYS a local KB, so a PUBLIC collection is ALSO usable # under PRIVATE_ONLY (is_public is additive, not exclusive — the content is # local regardless; the flag only ADDS public-scope/cloud eligibility). pub = evaluate_engine( "collection_abc", ctx, settings_snapshot={"policy.egress_scope": {"value": "private_only"}}, metadata={"is_public": True}, ) assert pub.allowed is True def test_public_collection_is_local_and_public_every_scope(): """A public collection is classified (is_public=True, is_local=True): it is allowed under PUBLIC_ONLY, PRIVATE_ONLY and BOTH. A private collection is local-only (excluded from PUBLIC_ONLY).""" pub_meta = {"is_public": True} priv_meta = {"is_public": False} for scope in ( EgressScope.PUBLIC_ONLY, EgressScope.PRIVATE_ONLY, EgressScope.BOTH, ): ctx = make_ctx(scope=scope, primary="collection_abc") assert evaluate_engine( "collection_abc", ctx, settings_snapshot={}, metadata=pub_meta ).allowed, f"public collection should be allowed under {scope}" # Private collection: denied only under PUBLIC_ONLY. ctx_pub = make_ctx(scope=EgressScope.PUBLIC_ONLY, primary="collection_abc") assert not evaluate_engine( "collection_abc", ctx_pub, settings_snapshot={}, metadata=priv_meta ).allowed for scope in (EgressScope.PRIVATE_ONLY, EgressScope.BOTH): ctx = make_ctx(scope=scope, primary="collection_abc") assert evaluate_engine( "collection_abc", ctx, settings_snapshot={}, metadata=priv_meta ).allowed, f"private collection should be allowed under {scope}" def test_collection_without_metadata_defaults_private_via_db_lookup(): """Without metadata, evaluate_engine resolves the collection's is_public from the DB; a lookup failure fails closed to private (local).""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY, primary="collection_xyz") # No DB available in this unit test → _resolve_collection_is_public # returns False (private) → allowed under PRIVATE_ONLY. decision = evaluate_engine( "collection_xyz", ctx, settings_snapshot={"policy.egress_scope": {"value": "private_only"}}, ) assert decision.allowed is True def test_context_from_snapshot_reads_nested_value_dicts(): snapshot = { "policy.egress_scope": {"value": "strict"}, "llm.require_local_endpoint": {"value": True}, } ctx = context_from_snapshot(snapshot, primary_engine="arxiv") assert ctx.scope == EgressScope.STRICT assert ctx.require_local_llm is True def test_context_strict_with_stray_meta_name_builds_strict_context(): # Meta-pickers were removed: STRICT + a stray "auto" primary no longer # raises ValueError at context construction. The context stays STRICT and # the stray engine itself is denied downstream (engine_unknown). snapshot = {"policy.egress_scope": "strict"} ctx = context_from_snapshot(snapshot, primary_engine="auto") assert ctx.scope == EgressScope.STRICT decision = evaluate_engine("auto", ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "engine_unknown" def test_context_unknown_scope_raises_policy_denied(): # N8 (Round 6): unknown scope is fail-closed rather than silently # falling back to BOTH. Silent fallback would mask config corruption # and effectively disable the policy whenever the saved value was # tampered with or migrated incorrectly. with pytest.raises(PolicyDeniedError) as excinfo: context_from_snapshot( {"policy.egress_scope": "nonsense"}, primary_engine="arxiv" ) assert excinfo.value.decision.reason == "unknown_egress_scope" def test_context_string_false_coerces_correctly(): # Type-confusion guard: the string "false" must not be truthy. ctx = context_from_snapshot( {"llm.require_local_endpoint": "false"}, primary_engine="arxiv" ) assert ctx.require_local_llm is False def test_context_string_true_coerces_to_true(): ctx = context_from_snapshot( {"llm.require_local_endpoint": "true"}, primary_engine="arxiv" ) assert ctx.require_local_llm is True # --------------------------------------------------------------------------- # evaluate_engine — STRICT semantics # --------------------------------------------------------------------------- def test_evaluate_engine_strict_primary_match(): ctx = make_ctx(scope=EgressScope.STRICT, primary="arxiv") decision = evaluate_engine("arxiv", ctx, settings_snapshot={}) assert decision.allowed assert decision.reason == "allowed" def test_evaluate_engine_strict_non_primary_denied(): ctx = make_ctx(scope=EgressScope.STRICT, primary="arxiv") decision = evaluate_engine("pubmed", ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "strict_not_primary" def test_evaluate_engine_strict_with_removed_meta_engine(): # The removed auto/meta/parallel names can never be permitted under STRICT. ctx = make_ctx(scope=EgressScope.STRICT, primary="arxiv") decision = evaluate_engine("auto", ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "strict_not_primary" # --------------------------------------------------------------------------- # evaluate_engine — public/private bucket # --------------------------------------------------------------------------- def test_evaluate_engine_public_only_blocks_local_engine(): ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) # paperless is is_local=True with url_setting; with no URL in snapshot # the static is_local flag applies, so PUBLIC_ONLY rejects it. decision = evaluate_engine("paperless", ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "scope_mismatch_public_only" def test_evaluate_engine_private_only_blocks_public_engine(): ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) decision = evaluate_engine("arxiv", ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "scope_mismatch_private_only" def test_evaluate_engine_both_allows_public(): ctx = make_ctx(scope=EgressScope.BOTH) decision = evaluate_engine("arxiv", ctx, settings_snapshot={}) assert decision.allowed def test_evaluate_engine_both_allows_private(): ctx = make_ctx(scope=EgressScope.BOTH) decision = evaluate_engine("paperless", ctx, settings_snapshot={}) assert decision.allowed def test_evaluate_engine_no_snapshot_fails_closed(): ctx = make_ctx() decision = evaluate_engine("arxiv", ctx, settings_snapshot=None) assert not decision.allowed assert decision.reason == "no_snapshot" def test_evaluate_engine_unknown_name_fails_closed(): ctx = make_ctx() decision = evaluate_engine( "totally_made_up_engine", ctx, settings_snapshot={} ) assert not decision.allowed assert decision.reason == "engine_unknown" # --------------------------------------------------------------------------- # evaluate_engine — newly-classified engines # --------------------------------------------------------------------------- def test_evaluate_engine_github_is_public(): """Regression: github engine must be explicitly classified is_public=True.""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) decision = evaluate_engine("github", ctx, settings_snapshot={}) assert decision.allowed def test_evaluate_engine_paperless_is_local(): """Regression: paperless engine must be classified is_local=True.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) # Without a url_setting value, the static is_local flag applies. decision = evaluate_engine("paperless", ctx, settings_snapshot={}) assert decision.allowed # --------------------------------------------------------------------------- # evaluate_engine — dynamic URL classification # --------------------------------------------------------------------------- def test_evaluate_engine_searxng_localhost_denied_under_private_only(): """SearXNG is is_public=True regardless of where it's hosted — a local SearXNG still queries the internet, so PRIVATE_ONLY must deny it.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) snapshot = { "search.engine.web.searxng.default_params.instance_url": "http://localhost:8080" } decision = evaluate_engine("searxng", ctx, settings_snapshot=snapshot) assert not decision.allowed assert decision.reason == "scope_mismatch_private_only" def test_evaluate_engine_searxng_localhost_allowed_under_public_only(): """SearXNG is is_public=True, so PUBLIC_ONLY allows it even when hosted on localhost — the engine-selection gate uses static flags.""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) snapshot = { "search.engine.web.searxng.default_params.instance_url": "http://localhost:8080" } decision = evaluate_engine("searxng", ctx, settings_snapshot=snapshot) assert decision.allowed def test_evaluate_engine_searxng_remote_classified_public(): """SearXNG pointed at a public host should fail PRIVATE_ONLY.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) snapshot = { "search.engine.web.searxng.default_params.instance_url": "https://searx.example.com" } # Patch DNS so the public hostname doesn't actually resolve over the network # during tests. with patch( "local_deep_research.security.egress.policy._classify_host", return_value=False, ): decision = evaluate_engine("searxng", ctx, settings_snapshot=snapshot) assert not decision.allowed # --------------------------------------------------------------------------- # Engine nature classification — static class flags are authoritative # --------------------------------------------------------------------------- # Engines that can be loaded in CI (no optional deps needed). _PUBLIC_ENGINES = [ "brave", "ddg", "exa", "github", "google_pse", "guardian", "gutenberg", "mojeek", "nasa_ads", "openalex", "openlibrary", "pubchem", "pubmed", "scaleserp", "searxng", "semantic_scholar", "serpapi", "serper", "stackexchange", "tavily", "wayback", "wikinews", "wikipedia", "zenodo", ] _LOCAL_ENGINES = [ "paperless", ] @pytest.mark.parametrize("engine_name", _PUBLIC_ENGINES) def test_public_engine_denied_under_private_only(engine_name): """Every is_public=True engine must be denied under PRIVATE_ONLY, regardless of its configured URL. Engine nature (queries the internet) is determined by the Python class flag, not by where the engine happens to be hosted.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) decision = evaluate_engine(engine_name, ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "scope_mismatch_private_only" @pytest.mark.parametrize("engine_name", _PUBLIC_ENGINES) def test_public_engine_allowed_under_public_only(engine_name): """Every is_public=True engine must pass PUBLIC_ONLY.""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) decision = evaluate_engine(engine_name, ctx, settings_snapshot={}) assert decision.allowed @pytest.mark.parametrize("engine_name", _PUBLIC_ENGINES) def test_public_engine_allowed_under_both(engine_name): """Every is_public=True engine must pass BOTH.""" ctx = make_ctx(scope=EgressScope.BOTH) decision = evaluate_engine(engine_name, ctx, settings_snapshot={}) assert decision.allowed @pytest.mark.parametrize("engine_name", _LOCAL_ENGINES) def test_local_engine_allowed_under_private_only(engine_name): """Every is_local=True engine must pass PRIVATE_ONLY.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) decision = evaluate_engine(engine_name, ctx, settings_snapshot={}) assert decision.allowed @pytest.mark.parametrize("engine_name", _LOCAL_ENGINES) def test_local_engine_denied_under_public_only(engine_name): """Every is_local=True engine (without is_public) must be denied under PUBLIC_ONLY.""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) decision = evaluate_engine(engine_name, ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "scope_mismatch_public_only" @pytest.mark.parametrize("engine_name", _LOCAL_ENGINES) def test_local_engine_allowed_under_both(engine_name): """Every is_local=True engine must pass BOTH.""" ctx = make_ctx(scope=EgressScope.BOTH) decision = evaluate_engine(engine_name, ctx, settings_snapshot={}) assert decision.allowed def test_searxng_local_url_still_public_nature(): """SearXNG with a localhost URL must still be classified as public — it proxies to internet search engines regardless of where it's hosted.""" ctx_pub = make_ctx(scope=EgressScope.PUBLIC_ONLY) ctx_priv = make_ctx(scope=EgressScope.PRIVATE_ONLY) snapshot = { "search.engine.web.searxng.default_params.instance_url": "http://localhost:8080" } assert evaluate_engine( "searxng", ctx_pub, settings_snapshot=snapshot ).allowed assert not evaluate_engine( "searxng", ctx_priv, settings_snapshot=snapshot ).allowed def test_paperless_local_url_still_local_nature(): """Paperless with a localhost URL is is_local=True — allowed under PRIVATE_ONLY, denied under PUBLIC_ONLY.""" ctx_pub = make_ctx(scope=EgressScope.PUBLIC_ONLY) ctx_priv = make_ctx(scope=EgressScope.PRIVATE_ONLY) snapshot = { "search.engine.web.paperless.default_params.api_url": "http://localhost:8930" } assert not evaluate_engine( "paperless", ctx_pub, settings_snapshot=snapshot ).allowed assert evaluate_engine( "paperless", ctx_priv, settings_snapshot=snapshot ).allowed def test_paperless_public_host_denied_under_private_only(): """Fail-up URL override: a local-nature engine whose configured URL points at a PUBLIC host is reclassified public — querying it sends the user's queries off the box, so PRIVATE_ONLY denies it at selection time (not just at the audit-hook socket net).""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) snapshot = { # Public literal IP so classification needs no real DNS in CI. "search.engine.web.paperless.default_params.api_url": "http://93.184.216.34:8930" } decision = evaluate_engine("paperless", ctx, settings_snapshot=snapshot) assert not decision.allowed assert decision.reason == "scope_mismatch_private_only" def test_paperless_public_host_allowed_under_public_only(): """The fail-up reclassification makes a remote-hosted local-data engine eligible under PUBLIC_ONLY (pre-static-flags behavior).""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) snapshot = { "search.engine.web.paperless.default_params.api_url": "http://93.184.216.34:8930" } decision = evaluate_engine("paperless", ctx, settings_snapshot=snapshot) assert decision.allowed def test_paperless_public_host_adaptive_resolves_public_only(): """ADAPTIVE with a remote-hosted Paperless primary must NOT resolve to PRIVATE_ONLY (which would imply 'nothing leaves the box' while every query goes to a public host).""" snap = { "policy.egress_scope": {"value": "adaptive"}, "search.tool": {"value": "paperless"}, "search.engine.web.paperless.default_params.api_url": "http://93.184.216.34:8930", } ctx = context_from_snapshot(snap, primary_engine="paperless") assert ctx.scope == EgressScope.PUBLIC_ONLY def test_searxng_adaptive_resolves_to_public_only(): """ADAPTIVE with SearXNG (is_public=True) as primary must resolve to PUBLIC_ONLY even when the instance URL points to localhost.""" snap = { "policy.egress_scope": {"value": "adaptive"}, "search.tool": {"value": "searxng"}, "search.engine.web.searxng.default_params.instance_url": "http://localhost:8080", } ctx = context_from_snapshot(snap, primary_engine="searxng") assert ctx.scope == EgressScope.PUBLIC_ONLY assert ctx.require_local_llm is False def test_paperless_adaptive_resolves_to_private_only(): """ADAPTIVE with Paperless (is_local=True) as primary must resolve to PRIVATE_ONLY and force local inference.""" snap = { "policy.egress_scope": {"value": "adaptive"}, "search.tool": {"value": "paperless"}, } ctx = context_from_snapshot(snap, primary_engine="paperless") assert ctx.scope == EgressScope.PRIVATE_ONLY assert ctx.require_local_llm is True assert ctx.require_local_embeddings is True # --------------------------------------------------------------------------- # filter_engines_by_egress # --------------------------------------------------------------------------- def test_filter_engines_removes_public_under_private_only(): """filter_engines_by_egress must strip all public engines under PRIVATE_ONLY.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) result = filter_engines_by_egress( ["wikipedia", "github", "searxng", "paperless"], ctx, settings_snapshot={}, ) assert result == ["paperless"] def test_filter_engines_removes_local_under_public_only(): """filter_engines_by_egress must strip local engines under PUBLIC_ONLY.""" ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY) result = filter_engines_by_egress( ["wikipedia", "github", "searxng", "paperless"], ctx, settings_snapshot={}, ) assert "paperless" not in result assert "wikipedia" in result def test_filter_engines_keeps_all_under_both(): """filter_engines_by_egress keeps everything under BOTH.""" ctx = make_ctx(scope=EgressScope.BOTH) result = filter_engines_by_egress( ["wikipedia", "paperless"], ctx, settings_snapshot={}, ) assert "wikipedia" in result assert "paperless" in result def test_filter_engines_unknown_engine_kept(): """Names unknown to the static registry are KEPT by the advisory pre-filter: they may be retriever-backed or dynamically injected engines that the factory PEP evaluates via its own path. The pre-filter must never be stricter than the enforcement point it fronts — the factory still denies anything truly disallowed.""" ctx = make_ctx(scope=EgressScope.BOTH) result = filter_engines_by_egress( ["wikipedia", "totally_made_up"], ctx, settings_snapshot={}, ) assert result == ["wikipedia", "totally_made_up"] def test_filter_engines_strict_keeps_only_primary_and_unknown(): """Under STRICT, non-primary registry engines are stripped (strict_not_primary, matching the factory); the primary survives. NB: under STRICT an unknown name is also stripped — the STRICT gate fires before the registry lookup, exactly as it does in the factory.""" ctx = make_ctx(scope=EgressScope.STRICT, primary="paperless") result = filter_engines_by_egress( ["paperless", "wikipedia", "totally_made_up"], ctx, settings_snapshot={}, ) assert result == ["paperless"] def test_filter_candidates_helper_strips_by_snapshot_scope(): """filter_candidates_by_egress does the full snapshot plumbing: scope/primary extraction, context build, filter.""" from local_deep_research.security.egress.policy import ( filter_candidates_by_egress, ) snap = { "policy.egress_scope": {"value": "private_only"}, "search.tool": {"value": "paperless"}, } result = filter_candidates_by_egress( ["wikipedia", "paperless", "totally_made_up"], snap ) assert "wikipedia" not in result assert "paperless" in result assert "totally_made_up" in result def test_filter_candidates_helper_noop_without_snapshot_or_under_both(): from local_deep_research.security.egress.policy import ( filter_candidates_by_egress, ) names = ["wikipedia", "paperless"] assert filter_candidates_by_egress(names, None) == names assert filter_candidates_by_egress(names, {}) == names snap = {"policy.egress_scope": {"value": "both"}} assert filter_candidates_by_egress(names, snap) == names def test_filter_candidates_helper_failopen_on_corrupt_scope(): """A corrupted scope string must not break engine selection — the helper returns the list unchanged (the unrecognized value falls out of the scope gate before any context is built) and the factory PEP remains the enforcement point.""" from local_deep_research.security.egress.policy import ( filter_candidates_by_egress, ) names = ["wikipedia", "paperless"] snap = {"policy.egress_scope": {"value": "garbage_scope"}} assert filter_candidates_by_egress(names, snap) == names def test_filter_engines_strips_scope_denials_keeps_unknown(): """Under PRIVATE_ONLY a public engine is stripped (active scope denial) while an unknown name survives for the factory to judge.""" ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY) result = filter_engines_by_egress( ["wikipedia", "totally_made_up", "paperless"], ctx, settings_snapshot={}, ) assert "wikipedia" not in result assert "totally_made_up" in result assert "paperless" in result # --------------------------------------------------------------------------- # evaluate_llm_endpoint # --------------------------------------------------------------------------- def test_evaluate_llm_endpoint_no_local_requirement_allows_cloud(): ctx = make_ctx(require_local_llm=False) decision = evaluate_llm_endpoint("openai", ctx, settings_snapshot={}) assert decision.allowed def test_evaluate_llm_endpoint_require_local_blocks_cloud_providers(): ctx = make_ctx(require_local_llm=True) for provider in ("openai", "anthropic", "google", "openrouter"): decision = evaluate_llm_endpoint(provider, ctx, settings_snapshot={}) assert not decision.allowed, ( f"{provider} should be blocked under require_local_llm" ) def test_evaluate_llm_endpoint_local_ollama_allowed(): ctx = make_ctx(require_local_llm=True) decision = evaluate_llm_endpoint("ollama", ctx, settings_snapshot={}) # No URL override → assumes localhost default. assert decision.allowed def test_evaluate_llm_endpoint_user_registered_llm_allowed(): """A user-registered in-process LLM (programmatic API ``llms={...}``) must be allowed under require_local_llm — it has no endpoint to classify and the audit hook backstops stray sockets. Regression test for the mock-LLM example breaking after ADAPTIVE retriever-primary runs began resolving to PRIVATE_ONLY.""" from local_deep_research.llm.llm_registry import ( register_llm, unregister_llm, ) ctx = make_ctx(require_local_llm=True) register_llm("egress_test_mock_llm", lambda **kwargs: None) try: decision = evaluate_llm_endpoint( "egress_test_mock_llm", ctx, settings_snapshot={} ) assert decision.allowed assert decision.reason == "user_registered_llm" finally: unregister_llm("egress_test_mock_llm") def test_evaluate_llm_endpoint_registered_name_shadowing_cloud_still_blocked(): """Registering a custom LLM under a built-in cloud name must NOT bypass the cloud-provider gate.""" from local_deep_research.llm.llm_registry import ( get_llm_from_registry, register_llm, unregister_llm, ) ctx = make_ctx(require_local_llm=True) original = get_llm_from_registry("openai") register_llm("openai", lambda **kwargs: None) try: decision = evaluate_llm_endpoint("openai", ctx, settings_snapshot={}) assert not decision.allowed assert decision.reason == "provider_cloud_only" finally: # Restore the auto-registered built-in entry rather than leaving # the registry polluted for later tests in this process. if original is not None: register_llm("openai", original) else: unregister_llm("openai") def test_evaluate_llm_endpoint_unregistered_unknown_provider_still_blocked(): """An unknown provider that is NOT in the registry keeps failing closed with provider_url_unset.""" ctx = make_ctx(require_local_llm=True) decision = evaluate_llm_endpoint( "totally_unknown_provider", ctx, settings_snapshot={} ) assert not decision.allowed assert decision.reason == "provider_url_unset" def test_evaluate_llm_endpoint_ollama_pointed_remote_blocked(): ctx = make_ctx(require_local_llm=True) snapshot = {"llm.ollama.url": "https://remote-ollama.example.com"} with patch( "local_deep_research.security.egress.policy._classify_host", return_value=False, ): decision = evaluate_llm_endpoint( "ollama", ctx, settings_snapshot=snapshot ) assert not decision.allowed # --------------------------------------------------------------------------- # evaluate_embeddings # --------------------------------------------------------------------------- def test_evaluate_embeddings_no_requirement_allows_openai(): ctx = make_ctx(require_local_embeddings=False) decision = evaluate_embeddings("openai", ctx, settings_snapshot={}) assert decision.allowed def test_evaluate_embeddings_require_local_allows_sentence_transformers(): ctx = make_ctx(require_local_embeddings=True) decision = evaluate_embeddings( "sentence_transformers", ctx, settings_snapshot={} ) assert decision.allowed def test_evaluate_embeddings_require_local_blocks_openai_cloud(): ctx = make_ctx(require_local_embeddings=True) decision = evaluate_embeddings("openai", ctx, settings_snapshot={}) assert not decision.allowed def test_evaluate_embeddings_openai_with_local_base_url_allowed(): ctx = make_ctx(require_local_embeddings=True) snapshot = {"embeddings.openai.base_url": "http://localhost:1234/v1"} decision = evaluate_embeddings("openai", ctx, settings_snapshot=snapshot) assert decision.allowed # --------------------------------------------------------------------------- # evaluate_url # --------------------------------------------------------------------------- def test_evaluate_url_rejects_dangerous_scheme(): ctx = make_ctx() assert not evaluate_url("javascript:alert(1)", ctx).allowed assert not evaluate_url("data:text/html,