fix(install): validate APM_LIB_DIR before rm -rf to prevent data loss (closes #1690)#1694
Conversation
…closes microsoft#1690) Add four safety guards that run before the installer deletes APM_LIB_DIR: 1. Absolute-path guard: reject relative paths 2. Suffix guard: path must end with /apm or /lib/apm 3. Blocklist guard: reject shared system directories ($HOME/.local/share, /usr, /opt, /tmp, /) 4. Marker-file guard: require evidence of a prior APM install before deleting an existing non-empty directory Also fix the broken "Installation" link in the Quickstart page and document the new APM_LIB_DIR requirements in the installation docs. Guards are extracted into apm_lib_dir_validate() with sentinel markers so they can be tested directly from a shell-source harness without running the full installer. 32 regression tests cover the 4 guards, the reported incident, safe default paths, and sentinel invariants.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a safety validation layer for APM_LIB_DIR in install.sh to prevent destructive deletes on broad/shared directories (regression for issue #1690), plus a pytest-based harness that sources the exact shell function for verification and updates docs to reflect the new constraints.
Changes:
- Added
apm_lib_dir_validate()ininstall.shwith 4 guards (absolute path, suffix, blocklist, marker-file) and a refusal flow on unsafe paths. - Added pytest regression tests that extract and execute the sentinel-bounded validator block directly from
install.sh, plus a localconftest.pyto avoid unrelated autouse fixtures. - Updated installation docs/quickstart to document/point to the safety constraints.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/install/test_install_safety.py | New regression tests that source the exact install.sh validator block and assert return codes for safety cases. |
| tests/unit/install/conftest.py | Overrides unrelated autouse fixtures to keep shell-script safety tests independent of Python CLI setup. |
| install.sh | Introduces APM_LIB_DIR validation function (sentinel-bounded for tests) and refusal messaging prior to rm -rf. |
| docs/src/content/docs/quickstart.mdx | Updates the “Installation” link target. |
| docs/src/content/docs/getting-started/installation.md | Documents APM_LIB_DIR safety validation behavior and adds an “Important” callout. |
| if ! apm_lib_dir_validate "$APM_LIB_DIR"; then | ||
| _rc=$? | ||
| echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}" | ||
| echo -e "${RED}║ REFUSING: APM_LIB_DIR=\"$APM_LIB_DIR\"${NC}" | ||
| echo -e "${RED}╠══════════════════════════════════════════════════════════════╣${NC}" | ||
| case $_rc in | ||
| 11) echo -e "${RED}║ APM_LIB_DIR must be an absolute path.${NC}\n${RED}║ Relative paths are not accepted for safety.${NC}" ;; | ||
| 12) echo -e "${RED}║ APM_LIB_DIR must end with /apm or /lib/apm.${NC}\n${RED}║ This prevents accidental deletion of non-APM data.${NC}\n${RED}║ Example: APM_LIB_DIR=\$HOME/.local/lib/apm${NC}" ;; | ||
| 13) echo -e "${RED}║ This path is a shared system directory. Installing here${NC}\n${RED}║ would delete non-APM data.${NC}\n${RED}║ Use a dedicated APM directory (e.g. /usr/local/lib/apm).${NC}" ;; | ||
| 14) echo -e "${RED}║ This directory exists but does not appear to be a${NC}\n${RED}║ previous APM installation. Refusing to delete it.${NC}\n${RED}║ If you are sure, remove it manually first:${NC}\n${RED}║ rm -rf \"$APM_LIB_DIR\"${NC}" ;; | ||
| esac | ||
| echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}" | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
Addressed in afa54c4 — removed local, fixed _rc capture, added begin sentinel assertion, switched to pytest.main().
| apm_lib_dir_validate() { | ||
| local _lib_dir="$1" |
There was a problem hiding this comment.
Addressed in afa54c4 — removed local, fixed _rc capture, added begin sentinel assertion, switched to pytest.main().
| local _lib_dir_real | ||
| _lib_dir_real="$(readlink -f "$_lib_dir" 2>/dev/null || realpath "$_lib_dir" 2>/dev/null || echo "$_lib_dir")" | ||
|
|
||
| local _safe=true | ||
| while IFS= read -r _dir; do | ||
| [ -z "$_dir" ] && continue | ||
| local _dir_real |
There was a problem hiding this comment.
Addressed in afa54c4 — removed local, fixed _rc capture, added begin sentinel assertion, switched to pytest.main().
| text = INSTALL_SH.read_text(encoding="utf-8") | ||
| match = SENTINEL_END.search(text) | ||
| assert match is not None, "INSTALL_SAFETY_END sentinel missing in install.sh" | ||
| start = SENTINEL_BEGIN.search(text).end() | ||
| end = match.start() | ||
| block = text[start:end] |
There was a problem hiding this comment.
Addressed in afa54c4 — removed local, fixed _rc capture, added begin sentinel assertion, switched to pytest.main().
| if __name__ == "__main__": | ||
| unittest.main() |
There was a problem hiding this comment.
Addressed in afa54c4 — removed local, fixed _rc capture, added begin sentinel assertion, switched to pytest.main().
- Fix _rc=$? capturing negated exit code (now stores validator's actual rc) - Remove local keyword usage for POSIX sh compatibility (curl ... | sh) - Add SENTINEL_BEGIN assertion in test harness - Fix __main__ block to use pytest instead of unittest
Fold shepherd review feedback for issue microsoft#1690 by creating a writable derived lib parent before choosing sudo, rechecking safety immediately before deletion, and writing an APM ownership marker after install. Extend regression coverage and docs for the user-local path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fold final panel coverage and docs feedback by adding a regression trap for the lib-parent preparation failure path and aligning APM_LIB_DIR suffix wording with the actual shell guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Shepherd-driver advisoryPanel stance: ship_with_followups. In-scope follow-ups were folded into this PR; no out-of-scope deferrals remain. Folded in this run
Copilot signals reviewed
Regression-trap evidence (mutation-break gate)
Lint and local validation
CI
Mergeability status
Ready for maintainer review once the pending CLA/status check resolves. |
|
@microsoft-github-policy-service agree |
Fold shepherd review feedback by replacing installer box-drawing output with printable ASCII and trimming duplicated APM_LIB_DIR docs while keeping the regression harness wording accurate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 0 | Shell guard extraction is narrow and testable; no architecture concern remains. |
| CLI Logging Expert | 0 | 1 | 0 | New refusal output now uses printable ASCII instead of box-drawing glyphs. |
| DevX UX Expert | 0 | 0 | 0 | Safe paths still work and failures are actionable. |
| Supply Chain Security Expert | 0 | 0 | 0 | The destructive path is guarded before deletion with regression coverage. |
| OSS Growth Hacker | 0 | 0 | 0 | The quickstart link and install safety docs protect the first-run funnel. |
| Doc Writer | 0 | 1 | 0 | The troubleshooting callout now avoids repeating the env-var table. |
| Test Coverage Expert | 0 | 0 | 0 | 43 installer safety tests passed, including broad-path and marker-file traps. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top follow-ups
None. The in-scope follow-ups surfaced by the panel were folded in this pass.
Folded in this run
- (panel) Replaced new installer box-drawing refusal output with printable ASCII and converted the existing banner to match -- resolved in
4383c2c4. - (panel) Trimmed duplicate
APM_LIB_DIRtroubleshooting prose and kept the callout focused on marker-file/no-sudo behavior -- resolved in4383c2c4.
Copilot signals reviewed
- No inline
copilot-pull-request-reviewer[bot]comments were present on the latest diff after shepherd re-fetch.
Regression-trap evidence (mutation-break gate)
tests/unit/install/test_install_safety.py::TestReportedIncident::test_reproduces_reported_issue-- deleted the suffix guard fromapm_lib_dir_validate; test FAILED as expected (assert 13 == 12); guard restored.
Lint contract
uv run --extra dev ruff check src/ tests/ and uv run --extra dev ruff format --check src/ tests/ both passed before push.
CI
gh pr checks 1694 --repo microsoft/apm reports 13 successful, 1 skipped, 0 failing, 0 pending checks on 4383c2c4 after 1 CI approval/recovery iteration.
Mergeability status
Captured from gh pr view 1694 --json mergeable,mergeStateStatus,statusCheckRollup immediately after the last push of this run.
| PR | head SHA | CEO stance | iters | folds | defers | Copilot rounds | CI | mergeable | mergeStateStatus | notes |
|---|---|---|---|---|---|---|---|---|---|---|
| #1694 | 4383c2c |
ship_now | 1 | 2 | 0 | 2 | green | MERGEABLE | BLOCKED | pending required review |
Convergence
1 outer iteration; 2 Copilot fetch rounds. Final panel stance: ship_now.
Ready for maintainer review.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
…arency) Sync main commits #1700, #1676, #1694, #1710. The #1700 feature (surface installed hook actions during install) wove display-payload tracking through code my refactor extracted into sibling helpers; resolve two conflicts by keeping the extracted structure and porting #1700 semantics: - services.py: accept the extracted _log_per_kind_results() call; move _log_hook_display_payloads into services_integrate.py (avoids circular import) and re-export it from services.py; emit hook summaries inside _log_per_kind_results. - hook_integrator._integrate_merged_hooks: accept extracted _merge_hook_file_entries / _write_merged_config; thread per-file display data out via a new optional capture_entries kwarg on _merge_hook_file_entries; build display payloads after _write_merged_config finalizes (post _apm_source strip). - Keep hook_integrator.py <=800 by relocating _iter_hook_entries / _summarize_command / _build_display_payload to hook_transforms.py and _parse_hook_json to hook_merge.py as thin delegators. Shadow gate green: ruff, ruff format, pylint R0801 10.00/10 (EXIT=0), auth-signals, import smoke, 3178 tests pass. All files within 800-line guard (hook_integrator.py 799, hook_merge.py 766, hook_transforms.py 601, services.py 690, services_integrate.py 307). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Adds four safety guards that run before
install.shdeletesAPM_LIB_DIR, fix the broken Quickstart Installation link, and document the new requirements. 32 pytest regression tests included.Problem
install.shexposesAPM_LIB_DIRas an environment override and unconditionally runsrm -rf "$APM_LIB_DIR". A user who setAPM_LIB_DIR=$HOME/.local/sharewhile installing to$HOME/.local/binlost unrelated application data (Atuin's local history DB). The Quickstart'sInstallationlink is also a 404:./getting-started/installation/resolves to/apm/quickstart/getting-started/installation/instead of/apm/getting-started/installation/.Closes #1690.
Approach
Four guards, ordered so the cheapest checks fire first:
/./apm/apmor/lib/apm$HOME/.local/share(the reported incident)readlink -f/realpathto catch symlinks)$HOME,/usr,/opt,/tmp,/apm/apm.cmd/VERSION/.apm-installedmust be presentThe blocklist checks for exact path matches only (resolved). A path like
$HOME/apmis allowed because the user has explicitly named an APM-specific directory. Suffix guard is the primary defence against accidentally broad paths.Implementation
install.sh: Extracted guards intoapm_lib_dir_validate()withINSTALL_SAFETY_BEGIN/INSTALL_SAFETY_ENDsentinel markers so tests can source the function without running the full installer.tests/unit/install/test_install_safety.py: 32 regression tests that source the extracted function in a fresh bash subprocess. No Python build dependencies needed —conftest.pyshadows the autouse fixtures fromtests/conftest.py.Trade-offs
/home/user/apmpasses (suffix guard alone). A prefix-blocklist (e.g., "any child of $HOME") would accidentally reject$HOME/.local/lib/apm, which is the documented safe path. Future hardening could add a separate prefix blacklist.readlinklacks-f. Falls back torealpath, then raw path. Suffix guard still blocks the reported incident on macOS regardless.How to test