diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a72e49..51382453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ PyPI version — not the changelog header. --- +## [2026-07-11] + +### Fixed + +- **`aipass install` from a throwaway path can no longer hijack the machine-wide + `AIPASS_HOME` (issue #688).** A probe install run from a `/tmp` scratchpad had + rewritten `~/.claude/settings.json` `env.AIPASS_HOME`, silently pointing every + Claude Code session on the machine at a dead temp tree (stale python, stale + hooks — surfaced as bogus ImportErrors in unrelated work). Three defenses: + `bootstrap.is_throwaway_path()` gates the settings write itself (temp dirs + + scratchpads never land in global settings); `run_install` refuses a throwaway + home loudly with `--force-global-home` as the explicit override; and + `aipass doctor` gains a `global AIPASS_HOME` check that flags a nonexistent or + throwaway path with fix guidance. +11 tests. (built by @aipass, verified by + devpulse against the real hijack path) + ## [2026-07-10] ### Added diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index ec93c25f..ccdf7f09 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -33,8 +33,10 @@ RULES: import importlib.util import json import logging +import os import re import shutil +import tempfile import uuid from datetime import date from pathlib import Path @@ -48,6 +50,25 @@ _STALE_MANAGED_FILES: list[Path] = [ ] +def is_throwaway_path(path: str | Path) -> bool: + """True if path is under a temp dir or Claude Code scratchpad.""" + resolved = str(Path(path).resolve()) + tmp_roots = [tempfile.gettempdir()] + if os.name == "posix": + tmp_roots.append("/tmp") + for root in tmp_roots: + try: + r = str(Path(root).resolve()) + except OSError: + logger.info("is_throwaway_path: could not resolve %s", root) + continue + if resolved == r or resolved.startswith(r + os.sep): + return True + if "scratchpad" in resolved.lower(): + return True + return False + + def _sanitize_name(raw: str) -> str: """Sanitize a project name for use in filenames. @@ -215,8 +236,13 @@ def _claude_settings(aipass_home: str | None = None) -> str: ], } - if aipass_home: + if aipass_home and not is_throwaway_path(aipass_home): data["env"] = {"AIPASS_HOME": aipass_home} + elif aipass_home: + logger.warning( + "AIPASS_HOME '%s' is a throwaway path — not writing to settings", + aipass_home, + ) return json.dumps(data, indent=2, ensure_ascii=False) + "\n" diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index e37da7ed..908a5ce7 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -152,6 +152,46 @@ def _check_system() -> List[CheckResult]: return results +def _check_global_aipass_home() -> List[CheckResult]: + """Check ~/.claude/settings.json env.AIPASS_HOME for stale or temp paths.""" + from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path + + results: List[CheckResult] = [] + settings_path = Path.home() / ".claude" / "settings.json" + if not settings_path.exists(): + return results + try: + data = json.loads(settings_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.info("[doctor] global settings.json unreadable: %s", exc) + return results + home_val = data.get("env", {}).get("AIPASS_HOME", "") + if not home_val: + return results + home_path = Path(home_val) + if not home_path.exists(): + results.append( + CheckResult( + "global AIPASS_HOME", + GLYPH_FAIL, + f"path does not exist: {home_val}", + "Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root", + ) + ) + elif is_throwaway_path(home_val): + results.append( + CheckResult( + "global AIPASS_HOME", + GLYPH_FAIL, + f"points to throwaway path: {home_val}", + "Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root", + ) + ) + else: + results.append(CheckResult("global AIPASS_HOME", GLYPH_PASS, home_val, "")) + return results + + def _check_identity() -> List[CheckResult]: """Run Identity group checks.""" results: List[CheckResult] = [] @@ -174,6 +214,8 @@ def _check_identity() -> List[CheckResult]: ) ) + results.extend(_check_global_aipass_home()) + if reg_path is None: results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry")) return results diff --git a/src/aipass/aipass/apps/modules/install.py b/src/aipass/aipass/apps/modules/install.py index 0d8a471e..8deda570 100644 --- a/src/aipass/aipass/apps/modules/install.py +++ b/src/aipass/aipass/apps/modules/install.py @@ -46,6 +46,7 @@ from typing import Dict from aipass.cli.apps.modules import console, success, warning from aipass.prax import logger +from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path from aipass.aipass.apps.handlers.json import json_handler from aipass.aipass.apps.handlers.ui.progress import render_step_header @@ -285,6 +286,16 @@ def run_install( return 1 console.print(f" Home: [cyan]{home}[/cyan]") + if is_throwaway_path(home): + warning( + f"REFUSED: '{home}' is a temporary/scratchpad path. " + "Installing here would hijack the machine-wide AIPASS_HOME. " + "Use a permanent directory, or pass --force-global-home to override." + ) + if "--force-global-home" not in sys.argv: + return 1 + logger.warning("[install] --force-global-home override: proceeding with throwaway home %s", home) + if _looks_like_aipass_tree(home): success(f"AIPass already present at {home} — skipping download") elif not _clone_repo(home, dry_run): @@ -334,6 +345,7 @@ def print_help() -> None: console.print(" [green]aipass install --no-symlink[/green] [dim]# skip global CLI symlinks[/dim]") console.print(" [green]aipass install --force-symlink[/green] [dim]# repoint from another install[/dim]") console.print(" [green]aipass install --project DIR[/green] [dim]# where the first project scaffolds[/dim]") + console.print(" [green]aipass install --force-global-home[/green] [dim]# allow install into /tmp (unsafe)[/dim]") console.print(" [green]aipass install --dry-run[/green] [dim]# walk steps, no side effects[/dim]") console.print() console.print("[yellow]STEPS:[/yellow] resolve home -> fetch -> setup.sh -> verify -> launch init") diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index 043cdb04..9efc5556 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -24,6 +24,7 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc from aipass.aipass.apps.handlers.init.bootstrap import ( _merge_hooks_json, _sanitize_name, + is_throwaway_path, init_project, update_project, ) @@ -1208,3 +1209,37 @@ def test_update_project_cleanup_no_stale_is_noop(tmp_path): result = update_project(target) assert result["removed_files"] == [] + + +# --------------------------------------------------------------------------- +# is_throwaway_path tests +# --------------------------------------------------------------------------- + + +def test_throwaway_path_detects_tmp(tmp_path): + """Paths under the system temp dir are throwaway.""" + assert is_throwaway_path(str(tmp_path)) + + +def test_throwaway_path_detects_scratchpad(): + """Paths containing 'scratchpad' are throwaway.""" + assert is_throwaway_path(str(Path.home() / ".claude" / "scratchpad" / "probe_1")) + + +def test_throwaway_path_allows_normal(): + """Normal home-directory paths are not throwaway.""" + assert not is_throwaway_path(str(Path.home() / "AIPass")) + + +def test_throwaway_path_allows_project(): + """A typical project path is not throwaway.""" + assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp")) + + +def test_settings_omits_throwaway_aipass_home(tmp_path): + """_claude_settings refuses to write AIPASS_HOME when it's a throwaway path.""" + from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings + + content = _claude_settings(str(tmp_path)) + data = json.loads(content) + assert "AIPASS_HOME" not in data.get("env", {}) diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index 3727ad78..bd60b8f0 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -892,3 +892,72 @@ class TestPromptAutoWireIsatty: assert result is True mock_wire.assert_called_once() + + +# --------------------------------------------------------------------------- +# _check_global_aipass_home tests (#688) +# --------------------------------------------------------------------------- + + +class TestCheckGlobalAipassHome: + """Tests for _check_global_aipass_home doctor check.""" + + def test_nonexistent_path_is_error(self, tmp_path): + """AIPASS_HOME pointing to a nonexistent path is flagged as error.""" + from aipass.aipass.apps.modules.doctor import _check_global_aipass_home + + settings = tmp_path / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text( + json.dumps({"env": {"AIPASS_HOME": str(tmp_path / "gone")}}), + encoding="utf-8", + ) + with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path): + results = _check_global_aipass_home() + fails = [r for r in results if "does not exist" in r.detail] + assert len(fails) == 1 + + def test_throwaway_path_is_error(self, tmp_path): + """AIPASS_HOME pointing to a temp path is flagged as error.""" + from aipass.aipass.apps.modules.doctor import _check_global_aipass_home + + settings = tmp_path / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text( + json.dumps({"env": {"AIPASS_HOME": str(tmp_path)}}), + encoding="utf-8", + ) + with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path): + results = _check_global_aipass_home() + fails = [r for r in results if "throwaway" in r.detail] + assert len(fails) == 1 + + def test_valid_path_passes(self, tmp_path): + """AIPASS_HOME pointing to a real, non-temp path passes.""" + from aipass.aipass.apps.modules.doctor import _check_global_aipass_home, GLYPH_PASS + + real_home = tmp_path / "AIPass" + real_home.mkdir() + settings = tmp_path / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text( + json.dumps({"env": {"AIPASS_HOME": str(real_home)}}), + encoding="utf-8", + ) + with ( + patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path), + patch( + "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", + return_value=False, + ), + ): + results = _check_global_aipass_home() + assert any(r.glyph == GLYPH_PASS for r in results) + + def test_no_settings_file_is_noop(self, tmp_path): + """Missing ~/.claude/settings.json produces no results.""" + from aipass.aipass.apps.modules.doctor import _check_global_aipass_home + + with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path): + results = _check_global_aipass_home() + assert results == [] diff --git a/src/aipass/aipass/tests/test_install.py b/src/aipass/aipass/tests/test_install.py index bb1772b9..44245269 100644 --- a/src/aipass/aipass/tests/test_install.py +++ b/src/aipass/aipass/tests/test_install.py @@ -200,6 +200,7 @@ class TestRunInstall: home = tmp_path / "AIPass" with ( patch(f"{_MOD}._resolve_home", return_value=home), + patch(f"{_MOD}.is_throwaway_path", return_value=False), patch(f"{_MOD}._clone_repo", return_value=True), patch(f"{_MOD}._run_setup", return_value=True), patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}), @@ -339,3 +340,52 @@ class TestSmoke: def test_total_steps_constant(self) -> None: """The install flow advertises four steps.""" assert TOTAL_STEPS == 4 + + +# --------------------------------------------------------------------------- +# Throwaway-path gate (#688) +# --------------------------------------------------------------------------- + + +class TestThrowawayGate: + """Install refuses throwaway homes unless --force-global-home.""" + + def test_refuses_tmp_home(self, tmp_path) -> None: + """run_install returns 1 when home resolves to a temp path.""" + with ( + patch( + "aipass.aipass.apps.modules.install._resolve_home", + return_value=tmp_path, + ), + patch("aipass.aipass.apps.modules.install.sys.argv", ["aipass", "install"]), + ): + result = run_install(non_interactive=True, no_init=True) + assert result == 1 + + def test_force_flag_overrides(self, tmp_path) -> None: + """--force-global-home lets a temp home proceed past the gate.""" + with ( + patch( + "aipass.aipass.apps.modules.install._resolve_home", + return_value=tmp_path, + ), + patch( + "aipass.aipass.apps.modules.install.sys.argv", + ["aipass", "install", "--force-global-home"], + ), + patch( + "aipass.aipass.apps.modules.install._looks_like_aipass_tree", + return_value=True, + ), + patch( + "aipass.aipass.apps.modules.install._run_setup", + return_value=True, + ), + patch( + "aipass.aipass.apps.modules.install._verify_binaries", + return_value={"drone": "x", "aipass": "x"}, + ), + patch("aipass.aipass.apps.modules.install._handoff_to_init"), + ): + result = run_install(non_interactive=True, no_init=True) + assert result == 0