From dcb4f4654989456bcf7b5e408cead0add1e649c0 Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Tue, 25 Aug 2026 06:21:41 +0530 Subject: [PATCH 1/4] fix: resolve the admin policy dir's ProgramData from the environment _get_policy_scripts_dir() hardcoded C:\ProgramData for the Windows admin policy tier. ProgramData is not a fixed path on Windows -- it's stored in the registry as an unexpanded %SystemDrive%\ProgramData and only equals C:\ProgramData when the system drive is C:. On a machine whose system drive differs, the admin tier silently loads nothing: _load_scripts_from_dir() returns [] for a missing directory with no warning, so the tier documented as the trust anchor contributes zero scripts. Resolve from the PROGRAMDATA environment variable, which Windows always populates, falling back to the C: default when unset (matches _get_user_apm_yml's existing APM_HOME pattern beside it). --- src/apm_cli/core/lifecycle_scripts.py | 12 ++++++++++-- tests/unit/core/test_lifecycle_scripts.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/core/lifecycle_scripts.py b/src/apm_cli/core/lifecycle_scripts.py index d73ca35f0f..7aff8f97a7 100644 --- a/src/apm_cli/core/lifecycle_scripts.py +++ b/src/apm_cli/core/lifecycle_scripts.py @@ -344,10 +344,18 @@ def parse_project_script_file(path: Path) -> list[ScriptEntry]: def _get_policy_scripts_dir() -> Path: - """Return the platform-specific policy scripts directory.""" + """Return the platform-specific policy scripts directory. + + ``ProgramData`` is not a fixed ``C:`` path on Windows -- it's stored in + the registry as an unexpanded ``%SystemDrive%\\ProgramData`` and only + equals ``C:\\ProgramData`` when the system drive is ``C:``. Resolving it + from the environment (which Windows always populates) instead of a + literal keeps the admin policy tier readable on a machine whose system + drive differs (apm#2684). + """ system = platform.system() if system == "Windows": - return Path(r"C:\ProgramData\APM\policy.d") + return Path(os.environ.get("PROGRAMDATA", r"C:\ProgramData")) / "APM" / "policy.d" return Path("/etc/apm/policy.d") diff --git a/tests/unit/core/test_lifecycle_scripts.py b/tests/unit/core/test_lifecycle_scripts.py index 77efff7e4c..3fe87e9aa2 100644 --- a/tests/unit/core/test_lifecycle_scripts.py +++ b/tests/unit/core/test_lifecycle_scripts.py @@ -16,6 +16,7 @@ PackageInfo, ScriptEntry, _entries_from_lifecycle_map, + _get_policy_scripts_dir, build_runner_from_context, discover_scripts, parse_apm_yml_lifecycle, @@ -153,6 +154,22 @@ def test_parse_project_script_file_is_alias(self, tmp_path: Path) -> None: assert parse_project_script_file(path) == parse_apm_yml_lifecycle(path, "project") +class TestGetPolicyScriptsDir: + def test_windows_honours_programdata_env_var(self, monkeypatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("PROGRAMDATA", r"D:\ProgramData") + assert _get_policy_scripts_dir() == Path(r"D:\ProgramData") / "APM" / "policy.d" + + def test_windows_falls_back_to_c_drive_when_unset(self, monkeypatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.delenv("PROGRAMDATA", raising=False) + assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d" + + def test_non_windows_uses_etc(self, monkeypatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Linux") + assert _get_policy_scripts_dir() == Path("/etc/apm/policy.d") + + class TestDiscoverScripts: def test_discovers_from_project_file(self, tmp_path: Path) -> None: _write_yaml( From 2984d656ae28e8d7b56300abeb65c95409398f9a Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Wed, 26 Aug 2026 18:14:20 +0530 Subject: [PATCH 2/4] Type the monkeypatch fixture per repo convention --- tests/unit/core/test_lifecycle_scripts.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/core/test_lifecycle_scripts.py b/tests/unit/core/test_lifecycle_scripts.py index 3fe87e9aa2..d2b9f153ff 100644 --- a/tests/unit/core/test_lifecycle_scripts.py +++ b/tests/unit/core/test_lifecycle_scripts.py @@ -6,6 +6,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest import yaml from apm_cli.core.lifecycle_scripts import ( @@ -155,17 +156,17 @@ def test_parse_project_script_file_is_alias(self, tmp_path: Path) -> None: class TestGetPolicyScriptsDir: - def test_windows_honours_programdata_env_var(self, monkeypatch) -> None: + def test_windows_honours_programdata_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("platform.system", lambda: "Windows") monkeypatch.setenv("PROGRAMDATA", r"D:\ProgramData") assert _get_policy_scripts_dir() == Path(r"D:\ProgramData") / "APM" / "policy.d" - def test_windows_falls_back_to_c_drive_when_unset(self, monkeypatch) -> None: + def test_windows_falls_back_to_c_drive_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("platform.system", lambda: "Windows") monkeypatch.delenv("PROGRAMDATA", raising=False) assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d" - def test_non_windows_uses_etc(self, monkeypatch) -> None: + def test_non_windows_uses_etc(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("platform.system", lambda: "Linux") assert _get_policy_scripts_dir() == Path("/etc/apm/policy.d") From cb21db51b3490d087d025e5be7e679513a570a9a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 31 Aug 2026 09:03:31 +0200 Subject: [PATCH 3/4] fix: harden ProgramData policy discovery Reject empty and relative policy roots, exercise discovery in the Windows compatibility gate, and align governance documentation. Addresses the panel security, test coverage, and documentation follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 ++ .../docs/enterprise/lifecycle-scripts.md | 12 +++-- .../.apm/skills/apm-usage/commands.md | 2 +- src/apm_cli/core/lifecycle_scripts.py | 15 +++--- tests/unit/core/test_lifecycle_scripts.py | 46 ++++++++++++++++++- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f70b75a38..9bb67de983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Windows admin lifecycle policies now resolve from `%ProgramData%` instead of + assuming `C:\ProgramData`, while retaining the historical fallback. + (by @lukiod; closes #2684) (#2686) - Marketplace installs now materialize catalog-only LSP and MCP metadata without requiring a package manifest in the downloaded source (by @lkshrk, #2709). diff --git a/docs/src/content/docs/enterprise/lifecycle-scripts.md b/docs/src/content/docs/enterprise/lifecycle-scripts.md index 5427f9188d..59c98bbe68 100644 --- a/docs/src/content/docs/enterprise/lifecycle-scripts.md +++ b/docs/src/content/docs/enterprise/lifecycle-scripts.md @@ -31,8 +31,10 @@ Scripts are defined in three tiers. The **project tier** uses the repository `ap manifest under a top-level `lifecycle:` key. The **user tier** uses `~/.apm/apm.yml` (or `$APM_HOME/apm.yml`) under the same `lifecycle:` key. The **admin** tier uses `/etc/apm/policy.d/*.json` on POSIX systems, or -`C:\ProgramData\APM\policy.d\*.json` on Windows. It is suited for -machine- and fleet-managed deployment. +`%ProgramData%\APM\policy.d\*.json` on Windows. `%ProgramData%` normally +expands to `C:\ProgramData`; APM uses that default if the value is missing or +is not an absolute Windows path. The tier is suited for machine- and +fleet-managed deployment. ## Supported events @@ -168,7 +170,7 @@ disabled; the global kill switches below suppress all lifecycle scripts. | Priority | Path | Who controls | Format | |--------------|-----------------------------------------------------------------------------|------------------|--------| -| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`
Windows: `C:\ProgramData\APM\policy.d\*.json` | Platform/IT team | JSON | +| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`
Windows: `%ProgramData%\APM\policy.d\*.json` | Platform/IT team | JSON | | 2 | `~/.apm/apm.yml` | Individual user | YAML | | 3 | `apm.yml` `lifecycle:` | Project | YAML | @@ -199,7 +201,7 @@ POST body. Lifecycle scripts from different sources are subject to different trust rules: - **Policy scripts** (`/etc/apm/policy.d/*.json` on POSIX systems or - `C:\ProgramData\APM\policy.d\*.json` on Windows) -- controlled by + `%ProgramData%\APM\policy.d\*.json` on Windows) -- controlled by your platform/IT team. Run without any consent gate; they cannot be individually disabled by the developer. `APM_NO_SCRIPTS=1` suppresses all lifecycle-script tiers for that run. @@ -234,7 +236,7 @@ policy directory to track which packages are actively used: Create `analytics.json` in the platform admin directory: - POSIX: `/etc/apm/policy.d/analytics.json` -- Windows: `C:\ProgramData\APM\policy.d\analytics.json` +- Windows: `%ProgramData%\APM\policy.d\analytics.json` ```json { diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index ffc90c4de7..26c9779f3a 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -182,7 +182,7 @@ If the install cache has not been warmed (e.g. a fresh checkout before the first | `apm lifecycle trust` | Trust `apm.yml` `lifecycle:` at its current contents so project scripts run on install | -- | | `apm lifecycle untrust` | Revoke trust for `apm.yml` `lifecycle:`; project scripts will stop running | -- | -Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (POSIX: `/etc/apm/policy.d/*.json`; Windows: `C:\ProgramData\APM\policy.d\*.json`; JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation. +Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (POSIX: `/etc/apm/policy.d/*.json`; Windows: `%ProgramData%\APM\policy.d\*.json`; JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation. ## Distribution diff --git a/src/apm_cli/core/lifecycle_scripts.py b/src/apm_cli/core/lifecycle_scripts.py index 7aff8f97a7..8bb73cd756 100644 --- a/src/apm_cli/core/lifecycle_scripts.py +++ b/src/apm_cli/core/lifecycle_scripts.py @@ -38,7 +38,7 @@ import threading from dataclasses import asdict, dataclass, field from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -346,16 +346,15 @@ def parse_project_script_file(path: Path) -> list[ScriptEntry]: def _get_policy_scripts_dir() -> Path: """Return the platform-specific policy scripts directory. - ``ProgramData`` is not a fixed ``C:`` path on Windows -- it's stored in - the registry as an unexpanded ``%SystemDrive%\\ProgramData`` and only - equals ``C:\\ProgramData`` when the system drive is ``C:``. Resolving it - from the environment (which Windows always populates) instead of a - literal keeps the admin policy tier readable on a machine whose system - drive differs (apm#2684). + Windows normally supplies an absolute ``ProgramData`` environment value. + Fall back to its historical default when that value is missing or unsafe. """ system = platform.system() if system == "Windows": - return Path(os.environ.get("PROGRAMDATA", r"C:\ProgramData")) / "APM" / "policy.d" + program_data = os.environ.get("PROGRAMDATA") + if not program_data or not PureWindowsPath(program_data).is_absolute(): + program_data = r"C:\ProgramData" + return Path(program_data) / "APM" / "policy.d" return Path("/etc/apm/policy.d") diff --git a/tests/unit/core/test_lifecycle_scripts.py b/tests/unit/core/test_lifecycle_scripts.py index d2b9f153ff..214180ef54 100644 --- a/tests/unit/core/test_lifecycle_scripts.py +++ b/tests/unit/core/test_lifecycle_scripts.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from pathlib import Path from unittest.mock import MagicMock, patch @@ -155,17 +156,60 @@ def test_parse_project_script_file_is_alias(self, tmp_path: Path) -> None: assert parse_project_script_file(path) == parse_apm_yml_lifecycle(path, "project") +@pytest.mark.windows_compat class TestGetPolicyScriptsDir: def test_windows_honours_programdata_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("platform.system", lambda: "Windows") monkeypatch.setenv("PROGRAMDATA", r"D:\ProgramData") assert _get_policy_scripts_dir() == Path(r"D:\ProgramData") / "APM" / "policy.d" - def test_windows_falls_back_to_c_drive_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_windows_falls_back_to_c_drive_when_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setattr("platform.system", lambda: "Windows") monkeypatch.delenv("PROGRAMDATA", raising=False) assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d" + @pytest.mark.parametrize("program_data", ["", "relative"]) + def test_windows_falls_back_when_programdata_is_unsafe( + self, + monkeypatch: pytest.MonkeyPatch, + program_data: str, + ) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("PROGRAMDATA", program_data) + assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d" + + def test_windows_discovers_policy_scripts_from_programdata( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + if os.name == "nt": + program_data = tmp_path / "ProgramData" + program_data_value = str(program_data) + else: + program_data_value = r"D:\ProgramData" + program_data = tmp_path / program_data_value + monkeypatch.chdir(tmp_path) + script_file = program_data / "APM" / "policy.d" / "admin.json" + script_file.parent.mkdir(parents=True) + script_file.write_text( + json.dumps( + { + "version": 1, + "scripts": {"post-install": [{"type": "command", "command": "echo admin"}]}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("PROGRAMDATA", program_data_value) + + entries = discover_scripts(project_root=str(tmp_path / "project")) + + assert [(entry.source, entry.command) for entry in entries] == [("policy", "echo admin")] + def test_non_windows_uses_etc(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("platform.system", lambda: "Linux") assert _get_policy_scripts_dir() == Path("/etc/apm/policy.d") From b9e9ec40e7ad250551a229a8904b23c63db80e65 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 31 Aug 2026 09:15:27 +0200 Subject: [PATCH 4/4] docs: make policy path descriptions portable Describe the admin policy tier without a POSIX-only path so the module-level contract matches Windows ProgramData discovery. Addresses the final Python architect follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/core/lifecycle_scripts.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/apm_cli/core/lifecycle_scripts.py b/src/apm_cli/core/lifecycle_scripts.py index 8bb73cd756..569f8492a1 100644 --- a/src/apm_cli/core/lifecycle_scripts.py +++ b/src/apm_cli/core/lifecycle_scripts.py @@ -4,7 +4,7 @@ update, and uninstall operations. Scripts are configured in well-known locations discovered from three tiers: -1. Policy -- /etc/apm/policy.d/*.json (admin-owned, JSON drop-ins, unchanged) +1. Policy -- platform-specific admin directory (JSON drop-ins, unchanged) 2. User -- ~/.apm/apm.yml (or $APM_HOME/apm.yml) lifecycle: key 3. Project -- apm.yml lifecycle: key (repo root) @@ -314,9 +314,9 @@ def parse_apm_yml_lifecycle_with_fingerprint( def parse_script_file(path: Path, source: str = "project") -> list[ScriptEntry]: """Parse a single JSON script file into a list of ScriptEntry. - Used for JSON-backed sources such as the admin policy tier - (/etc/apm/policy.d/*.json). Returns an empty list if the file is - malformed or uses an unsupported version. + Used for JSON-backed sources such as the platform-specific admin policy + tier. Returns an empty list if the file is malformed or uses an unsupported + version. """ try: with open(path, encoding="utf-8") as f: @@ -392,7 +392,7 @@ def discover_scripts( """Discover and merge scripts from all three sources. Load order (all additive, policy first): - 1. Policy -- /etc/apm/policy.d/*.json (directory, JSON) + 1. Policy -- platform-specific admin directory (JSON) 2. User -- ~/.apm/apm.yml (or $APM_HOME/apm.yml) lifecycle: key 3. Project -- apm.yml lifecycle: key (repo root) """