#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:
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user