diff --git a/src/aipass/aipass/docs/probe_hygiene.md b/src/aipass/aipass/docs/probe_hygiene.md new file mode 100644 index 00000000..8223cd7a --- /dev/null +++ b/src/aipass/aipass/docs/probe_hygiene.md @@ -0,0 +1,42 @@ +# Probe Hygiene SOP + +Standard operating procedure for throwaway test installs of AIPass. + +## Principle + +Temporary environments are used, then deleted, gone. Nothing permanent may ever point at a temp path. + +## Rules + +- Install probes and throwaway test installs live ONLY in throwaway directories (system temp dir, `/tmp`, Claude Code scratchpad dirs). +- Used = deleted = GONE. Delete the probe directory immediately after the test completes. +- NOTHING permanent may ever point at a temporary path: no global settings (`~/.claude/settings.json` `env.AIPASS_HOME`), no symlinks, no registry entries. +- `aipass install` now refuses throwaway homes automatically. The `--force-global-home` flag is the explicit unsafe override, for probe use only. +- `aipass doctor` now detects a hijacked global `AIPASS_HOME` (nonexistent or temp path) and flags it as an error with fix guidance. + +## What the defenses do + +1. **`is_throwaway_path()`** (bootstrap.py) — detects paths under `tempfile.gettempdir()`, `/tmp` (POSIX), or containing `scratchpad`. Shared gate used by both install and bootstrap. +2. **`run_install()` gate** (install.py) — refuses to proceed when the resolved home is throwaway. Prints a loud `REFUSED` message with guidance. `--force-global-home` overrides. +3. **`_claude_settings()` gate** (bootstrap.py) — refuses to write `env.AIPASS_HOME` into project settings when the detected home is throwaway. Defense-in-depth behind the install gate. +4. **`_check_global_aipass_home()`** (doctor.py) — reads `~/.claude/settings.json` and flags `env.AIPASS_HOME` pointing at a nonexistent or throwaway path as an error. + +## Correct probe workflow + +```bash +# 1. Create throwaway dir +cd /tmp && mkdir aipass_probe && cd aipass_probe + +# 2. Run the probe (install will refuse — this is correct) +aipass install --here +# → REFUSED: '/tmp/aipass_probe' is a temporary/scratchpad path. + +# 3. If you genuinely need a temp install (testing only): +aipass install --here --force-global-home + +# 4. IMMEDIATELY after testing, delete the probe +rm -rf /tmp/aipass_probe + +# 5. Verify global settings are clean +aipass doctor +``` diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index 9efc5556..8d793c66 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -278,8 +278,12 @@ def test_init_project_claude_settings_content(tmp_path): assert "deny" in data["permissions"] -def test_init_project_settings_no_hooks(tmp_path): +def test_init_project_settings_no_hooks(tmp_path, monkeypatch): """.claude/settings.json has no hooks — all hooks fire from provider level.""" + monkeypatch.setattr( + "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", + lambda _: False, + ) target = tmp_path / "proj" target.mkdir() @@ -451,8 +455,12 @@ def test_update_project_return_dict_structure(tmp_path): assert isinstance(result["skipped_files"], list) -def test_update_project_already_current_after_init(tmp_path): +def test_update_project_already_current_after_init(tmp_path, monkeypatch): """Running update immediately after init reports all managed files as already current.""" + monkeypatch.setattr( + "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", + lambda _: False, + ) target = tmp_path / "proj" target.mkdir() init_project(target, project_name="fresh") @@ -463,8 +471,12 @@ def test_update_project_already_current_after_init(tmp_path): assert len(result["already_current"]) >= 5 -def test_update_project_idempotent(tmp_path): +def test_update_project_idempotent(tmp_path, monkeypatch): """Running update twice in a row produces no changes on second run.""" + monkeypatch.setattr( + "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", + lambda _: False, + ) target = tmp_path / "proj" target.mkdir() init_project(target, project_name="idem") @@ -571,8 +583,12 @@ def test_init_project_returns_aipass_home(tmp_path): assert result["aipass_home"] is None or isinstance(result["aipass_home"], str) -def test_init_project_settings_has_aipass_home_when_detected(tmp_path): +def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch): """When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME.""" + monkeypatch.setattr( + "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", + lambda _: False, + ) target = tmp_path / "proj" target.mkdir() @@ -598,8 +614,12 @@ def test_update_project_returns_aipass_home(tmp_path): assert result["aipass_home"] is None or isinstance(result["aipass_home"], str) -def test_update_project_adds_aipass_home_if_missing(tmp_path): +def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch): """update_project injects AIPASS_HOME into settings.json if env section is absent.""" + monkeypatch.setattr( + "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", + lambda _: False, + ) target = tmp_path / "proj" target.mkdir() init_project(target, project_name="addenv") diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index 545a2b69..5d3213b6 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -261,6 +261,11 @@ def _bypass_preflight(): class TestRunInit: + @pytest.fixture(autouse=True) + def _isolate_cwd(self, tmp_path, monkeypatch): + """Avoid _guard_init rejecting the real cwd when it has .trinity/.""" + monkeypatch.chdir(tmp_path) + def _patch_all_stages(self): """Context manager that patches all 10 stage functions to no-ops.""" stage_names = [