@@ -1830,3 +1830,195 @@ def test_init_no_privacy_warning_with_no_llm_flag(ai_dialogue_corpus: Path, tmp_
18301830 assert (
18311831 "EXTERNAL API" not in out
18321832 ), f"Privacy warning fired on --no-llm path — should not have. Got: { out !r} "
1833+
1834+
1835+ # ─────────────────────────────────────────────────────────────────────────
1836+ # Consent gate for stray env-fallback API keys (issue #26).
1837+ #
1838+ # The #1224 warning is informational — init keeps going. That's
1839+ # "warning theater" if a user wasn't paying attention. #26 adds a
1840+ # blocking [y/N] prompt when the api_key was acquired via env fallback
1841+ # (OPENAI_API_KEY / ANTHROPIC_API_KEY) AND the endpoint is external.
1842+ # Explicit --llm-api-key (api_key_source == "flag") = user opted in.
1843+ # --accept-external-llm bypasses for CI / non-interactive.
1844+ # ─────────────────────────────────────────────────────────────────────────
1845+
1846+
1847+ def _external_env_provider ():
1848+ """Build a fake provider matching the 'stray env-fallback API key
1849+ pointed at external endpoint' scenario — the case #26 must gate."""
1850+ p = MagicMock ()
1851+ p .check_available .return_value = (True , "ok" )
1852+ p .is_external_service = True
1853+ p .api_key_source = "env"
1854+ p .classify .return_value = MagicMock (text = '{"classifications": []}' )
1855+ return p
1856+
1857+
1858+ def test_init_blocks_with_consent_prompt_when_api_key_from_env (
1859+ ai_dialogue_corpus : Path , tmp_path : Path , capsys
1860+ ):
1861+ """When provider is external AND api_key_source=='env' AND
1862+ --accept-external-llm is NOT set, cmd_init MUST call input() to
1863+ block on user consent. No bypass = blocking prompt."""
1864+ from mempalace .cli import cmd_init
1865+
1866+ palace = tmp_path / "palace"
1867+ args = _init_args (ai_dialogue_corpus )
1868+ fake_provider = _external_env_provider ()
1869+
1870+ with (
1871+ patch ("mempalace.cli.MempalaceConfig" , return_value = _stub_cfg (palace )),
1872+ patch ("mempalace.cli.get_provider" , return_value = fake_provider ),
1873+ patch ("mempalace.cli._maybe_run_mine_after_init" ),
1874+ patch ("mempalace.room_detector_local.detect_rooms_local" ),
1875+ patch ("builtins.input" , return_value = "y" ) as mock_input ,
1876+ ):
1877+ cmd_init (args )
1878+
1879+ assert mock_input .called , (
1880+ "Stray env-fallback api_key + external endpoint MUST trigger a "
1881+ "blocking consent prompt. input() was never called."
1882+ )
1883+
1884+
1885+ def test_init_consent_prompt_y_proceeds_with_llm (ai_dialogue_corpus : Path , tmp_path : Path , capsys ):
1886+ """If user types 'y' at the consent prompt, init proceeds with the
1887+ LLM — provider.classify() is invoked during Pass 0 / refinement."""
1888+ from mempalace .cli import cmd_init
1889+
1890+ palace = tmp_path / "palace"
1891+ args = _init_args (ai_dialogue_corpus )
1892+ fake_provider = _external_env_provider ()
1893+
1894+ with (
1895+ patch ("mempalace.cli.MempalaceConfig" , return_value = _stub_cfg (palace )),
1896+ patch ("mempalace.cli.get_provider" , return_value = fake_provider ),
1897+ patch ("mempalace.cli._maybe_run_mine_after_init" ),
1898+ patch ("mempalace.room_detector_local.detect_rooms_local" ),
1899+ patch ("builtins.input" , return_value = "y" ),
1900+ ):
1901+ cmd_init (args )
1902+
1903+ assert fake_provider .classify .called , (
1904+ "After 'y' consent, the LLM provider must be used. "
1905+ "classify() was never called — gate dropped llm_provider on the floor."
1906+ )
1907+
1908+
1909+ def test_init_consent_prompt_n_falls_back_to_heuristic (
1910+ ai_dialogue_corpus : Path , tmp_path : Path , capsys
1911+ ):
1912+ """If user types 'n' (or anything not 'y'), init drops the LLM and
1913+ falls back to heuristics-only — provider.classify() must NOT run."""
1914+ from mempalace .cli import cmd_init
1915+
1916+ palace = tmp_path / "palace"
1917+ args = _init_args (ai_dialogue_corpus )
1918+ fake_provider = _external_env_provider ()
1919+
1920+ with (
1921+ patch ("mempalace.cli.MempalaceConfig" , return_value = _stub_cfg (palace )),
1922+ patch ("mempalace.cli.get_provider" , return_value = fake_provider ),
1923+ patch ("mempalace.cli._maybe_run_mine_after_init" ),
1924+ patch ("mempalace.room_detector_local.detect_rooms_local" ),
1925+ patch ("builtins.input" , return_value = "n" ),
1926+ ):
1927+ cmd_init (args )
1928+
1929+ assert not fake_provider .classify .called , (
1930+ "Declined consent ('n') must drop the provider — classify() "
1931+ "should never be invoked when the user said no."
1932+ )
1933+
1934+
1935+ def test_init_no_consent_prompt_when_api_key_from_flag (
1936+ ai_dialogue_corpus : Path , tmp_path : Path , capsys
1937+ ):
1938+ """Explicit --llm-api-key means user already opted in. The consent
1939+ prompt MUST NOT fire when api_key_source == 'flag', even if the
1940+ endpoint is external."""
1941+ from mempalace .cli import cmd_init
1942+
1943+ palace = tmp_path / "palace"
1944+ args = _init_args (ai_dialogue_corpus , llm_api_key = "sk-explicit" )
1945+ fake_provider = MagicMock ()
1946+ fake_provider .check_available .return_value = (True , "ok" )
1947+ fake_provider .is_external_service = True
1948+ fake_provider .api_key_source = "flag" # explicit flag = no gate
1949+ fake_provider .classify .return_value = MagicMock (text = '{"classifications": []}' )
1950+
1951+ with (
1952+ patch ("mempalace.cli.MempalaceConfig" , return_value = _stub_cfg (palace )),
1953+ patch ("mempalace.cli.get_provider" , return_value = fake_provider ),
1954+ patch ("mempalace.cli._maybe_run_mine_after_init" ),
1955+ patch ("mempalace.room_detector_local.detect_rooms_local" ),
1956+ patch ("builtins.input" ) as mock_input ,
1957+ ):
1958+ cmd_init (args )
1959+
1960+ assert not mock_input .called , (
1961+ "Explicit --llm-api-key (api_key_source='flag') must NOT trigger "
1962+ "the consent prompt. User already opted in by passing the flag."
1963+ )
1964+
1965+
1966+ def test_init_accept_external_llm_flag_bypasses_consent_prompt (
1967+ ai_dialogue_corpus : Path , tmp_path : Path , capsys
1968+ ):
1969+ """--accept-external-llm is the non-interactive bypass for CI. With
1970+ the flag set, the consent prompt MUST NOT fire even when the
1971+ api_key came from env-fallback."""
1972+ from mempalace .cli import cmd_init
1973+
1974+ palace = tmp_path / "palace"
1975+ args = _init_args (ai_dialogue_corpus , accept_external_llm = True )
1976+ fake_provider = _external_env_provider ()
1977+
1978+ with (
1979+ patch ("mempalace.cli.MempalaceConfig" , return_value = _stub_cfg (palace )),
1980+ patch ("mempalace.cli.get_provider" , return_value = fake_provider ),
1981+ patch ("mempalace.cli._maybe_run_mine_after_init" ),
1982+ patch ("mempalace.room_detector_local.detect_rooms_local" ),
1983+ patch ("builtins.input" ) as mock_input ,
1984+ ):
1985+ cmd_init (args )
1986+
1987+ assert not mock_input .called , (
1988+ "--accept-external-llm must bypass the consent prompt for "
1989+ "non-interactive / CI use. input() was called anyway."
1990+ )
1991+ assert (
1992+ fake_provider .classify .called
1993+ ), "With --accept-external-llm, init must proceed with the LLM."
1994+
1995+
1996+ def test_init_no_consent_prompt_when_endpoint_is_local (
1997+ ai_dialogue_corpus : Path , tmp_path : Path , capsys
1998+ ):
1999+ """Stray env-fallback api_key on a LOCAL endpoint (e.g. LM Studio
2000+ on localhost with OPENAI_API_KEY in shell env) must NOT trigger the
2001+ prompt. Nothing leaves the machine — no consent needed."""
2002+ from mempalace .cli import cmd_init
2003+
2004+ palace = tmp_path / "palace"
2005+ args = _init_args (ai_dialogue_corpus )
2006+ fake_provider = MagicMock ()
2007+ fake_provider .check_available .return_value = (True , "ok" )
2008+ fake_provider .is_external_service = False # localhost / LAN — no leak
2009+ fake_provider .api_key_source = "env" # stray key, but URL is local
2010+ fake_provider .classify .return_value = MagicMock (text = '{"classifications": []}' )
2011+
2012+ with (
2013+ patch ("mempalace.cli.MempalaceConfig" , return_value = _stub_cfg (palace )),
2014+ patch ("mempalace.cli.get_provider" , return_value = fake_provider ),
2015+ patch ("mempalace.cli._maybe_run_mine_after_init" ),
2016+ patch ("mempalace.room_detector_local.detect_rooms_local" ),
2017+ patch ("builtins.input" ) as mock_input ,
2018+ ):
2019+ cmd_init (args )
2020+
2021+ assert not mock_input .called , (
2022+ "Local endpoint (is_external_service=False) must NOT trigger the "
2023+ "consent prompt regardless of api_key_source. Nothing leaves the box."
2024+ )
0 commit comments