#688 aipass: install from throwaway path can no longer hijack machine-wide AIPASS_HOME. Root cause: probe 'aipass install' from a /tmp scratchpad rewrote ~/.claude/settings.json env.AIPASS_HOME -> every CC session machine-wide ran a dead temp tree (stale python+hooks, bogus ImportErrors). 3 defenses: bootstrap.is_throwaway_path() gates the settings write (tempdir/tmp/scratchpad never written to global settings); run_install refuses throwaway home LOUDLY, --force-global-home = explicit override (+help text); doctor _check_global_aipass_home() flags nonexistent/throwaway env.AIPASS_HOME with fix guidance. +11 tests. Built by @aipass; devpulse verified: diffs read, is_throwaway_path live-caught the actual hijack path, doctor check live-green against repointed settings, 201/201 changed-file tests in real env. Suite deltas root-caused environmental: 3 init_flow fails = pre-existing cwd-dependence (pass from repo root), 5 clean-copy fails = the gate working as designed under a scratchpad extraction.

This commit is contained in:
AIOSAI
2026-07-11 00:12:26 -07:00
parent f72515e7bc
commit 22b4577ec1
7 changed files with 251 additions and 1 deletions
+16
View File
@@ -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
@@ -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"
+42
View File
@@ -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
+12
View File
@@ -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")
+35
View File
@@ -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", {})
+69
View File
@@ -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 == []
+50
View File
@@ -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