Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Marketplace installs now materialize catalog-only LSP and MCP metadata
without requiring a package manifest in the downloaded source
(by @lkshrk, #2709).
- `apm doctor` now reports malformed project `executables` configuration as an
actionable informational warning instead of omitting the check. (#2719)
- `apm doctor` now reports malformed project `executables` or deprecated
`allowExecutables` configuration as an actionable informational warning
instead of omitting the check. (#2719)
- `apm doctor` now reports malformed project executable-trust configuration
under either `executables` or the deprecated `allowExecutables` key as an
actionable informational warning instead of omitting the check. (#2719)

## [0.29.0] - 2026-08-30

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/cli/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ seconds before timing out.
| Authentication | APM's credential resolver finds a token for `github.com`. The resolver can use environment variables, the GitHub CLI, or a git credential helper. A missing token means unauthenticated rate limits apply. | No |
| Marketplace config | The `marketplace:` block in `apm.yml`, or legacy `marketplace.yml`, can be parsed when present. | No |
| Marketplace authoring | Configured output formats, duplicate package names, and version alignment are reported when marketplace config is present. | No |
| Executable trust | In an APM project, reports local allows overridden by organization policy and points to `apm policy explain`. | No |
| Executable trust | In an APM project, reports malformed executable-trust configuration under either `executables` or the deprecated `allowExecutables` key, naming the offending block in `apm.yml`. Local allows overridden by organization policy point to `apm policy explain`. | No |

The GitHub CLI is a possible credential source; `apm doctor` does not require
it or report its installation as a separate check.
Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set
| `apm marketplace outdated` | Report upgradable plugins, range-aware; respects `tag_pattern` and common monorepo tag layouts | `--offline`, `--include-prerelease`, `-v` |
| `apm marketplace check` | Validate the `marketplace:` block and verify refs resolve | `--offline`, `-v` |
| `apm marketplace audit NAME` | Supply-chain audit for plugin deps; local string sources are contained in the registered root | `--strict` (CI exit-1 on bypasses, skipped sources, verification errors, or no verified plugins), `-v` |
| `apm doctor` | Diagnose git, network, auth, marketplace config readiness, and (when a `marketplace:` block is present) **format coverage** -- which output profiles are configured vs. supported, so producers can spot easy reach wins (e.g. add `codex: {}` to also publish for Codex consumers). GitHub CLI is one auth source, not a separate check. All marketplace-specific rows are informational and never affect exit code. | `-v` |
| `apm doctor` | Diagnose git, network, auth, marketplace config readiness, and (when a `marketplace:` block is present) **format coverage** -- which output profiles are configured vs. supported, so producers can spot easy reach wins (e.g. add `codex: {}` to also publish for Codex consumers). The executable-trust row names malformed configuration under either `executables` or the deprecated `allowExecutables` key and reports local allows overridden by org policy. GitHub CLI is one auth source, not a separate check. Informational rows never affect exit code. | `-v` |
| `apm marketplace package add <source>` | Add a plugin entry to `marketplace.plugins` (source accepts `owner/repo` or `./path`) | `--name`, `--version`, `--ref` (mutable refs auto-resolved to SHA), `-d`/`--description`, `-s`/`--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease`, `--no-verify` |
| `apm marketplace package set <name>` | Update fields on an existing plugin entry | `--version`, `--ref` (mutable refs auto-resolved to SHA), `--description`, `--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease` |
| `apm marketplace package remove <name>` | Remove a plugin entry from `marketplace.plugins` | `--yes` |
Expand Down
4 changes: 3 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ content-hash binding in this release: an org `executables.enforce` rung is
accepted but fail-safe degrades to `recommend` (allowed, still overridable by a
deny). Inspect the deciding layer for one package with `apm policy explain
<pkg>`, and surface fleet-wide layer conflicts (packages allowed locally but
denied by org policy) with `apm doctor`.
denied by org policy) with `apm doctor`. The same doctor row reports a malformed
project executable-trust configuration under either `executables` or the
deprecated `allowExecutables` key and names the configuration to fix.

## Plugin bin/ deployment governance (deprecated alias)

Expand Down
32 changes: 32 additions & 0 deletions scripts/lint-architecture-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,38 @@ if [ "$diagnostic_ascii_status" -ne 0 ]; then
echo "$diagnostic_ascii_output"
violations=$((violations + 1))
fi
doctor_status_output=$(python3 - <<'PY'
import ast
from pathlib import Path

source = Path("src/apm_cli/commands/marketplace/__init__.py").read_text(encoding="utf-8")
tree = ast.parse(source)
function = next(
node
for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "_doctor_status_icon"
)
raw_symbols = {"[!]", "[x]", "[i]", "[+]"}
literal_symbols = {
node.value
for node in ast.walk(function)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
}
uses_owner = any(
isinstance(node, ast.Name) and node.id == "STATUS_SYMBOLS"
for node in ast.walk(function)
)
if literal_symbols & raw_symbols or not uses_owner:
print("doctor status symbols must use utils/console.py::STATUS_SYMBOLS")
raise SystemExit(1)
PY
)
doctor_status_status=$?
if [ "$doctor_status_status" -ne 0 ]; then
echo "[x] Doctor status symbols must use utils/console.py::STATUS_SYMBOLS"
echo "$doctor_status_output"
violations=$((violations + 1))
fi

echo "[*] AC13: Git ref transport selection authority"
semver_transport_router="src/apm_cli/install/helpers/ref_reuse.py"
Expand Down
24 changes: 11 additions & 13 deletions src/apm_cli/commands/marketplace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from ...marketplace.ref_resolver import RefResolver, RemoteRef
from ...marketplace.semver import SemVer, parse_semver, satisfies_range
from ...marketplace.yml_schema import load_marketplace_yml
from ...utils.console import STATUS_SYMBOLS
from ...utils.path_security import (
PathTraversalError,
decode_url_path_segments,
Expand Down Expand Up @@ -1305,17 +1306,19 @@ def __init__(self, name, passed, detail, informational=False):
self.informational = informational


def _doctor_status_icon(check: _DoctorCheck) -> str:
"""Return the status symbol for a doctor check."""
if not check.passed:
return STATUS_SYMBOLS["warning"] if check.informational else STATUS_SYMBOLS["error"]
return STATUS_SYMBOLS["info"] if check.informational else STATUS_SYMBOLS["check"]


def _render_doctor_table(logger, checks):
"""Render the doctor results table."""
console = _get_console()
if not console:
for c in checks:
if c.informational:
icon = "[i]"
elif c.passed:
icon = "[+]"
else:
icon = "[x]"
icon = _doctor_status_icon(c)
logger.tree_item(f" {icon} {c.name}: {c.detail}")
return

Expand All @@ -1333,13 +1336,8 @@ def _render_doctor_table(logger, checks):
table.add_column("Detail", style="white")

for c in checks:
if c.informational:
icon = "[i]"
elif c.passed:
icon = "[+]"
else:
icon = "[x]"
table.add_row(c.name, Text(icon), c.detail)
icon = _doctor_status_icon(c)
table.add_row(c.name, Text(icon), Text(c.detail))

console.print()
console.print(table)
Expand Down
30 changes: 26 additions & 4 deletions src/apm_cli/commands/marketplace/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
load_marketplace_from_apm_yml,
load_marketplace_yml,
)
from ...utils.diagnostics import printable_ascii_text
from . import (
_DoctorCheck,
_find_duplicate_names,
Expand All @@ -28,9 +29,10 @@ def _executable_trust_drift_check(

Flags packages whose project/user *allow* is overridden by the org
*deny* ceiling -- a governance conflict an admin should reconcile. Best
effort and informational: any failure to resolve degrades to ``None`` so
doctor never hangs or hard-fails on policy discovery. Points the operator
at ``apm policy explain <pkg>`` for the per-package detail.
effort and informational: policy-discovery failures degrade to ``None`` so
doctor never hangs or hard-fails. Invalid project executable configuration
is reported so the operator can repair ``apm.yml``. Points the operator at
``apm policy explain <pkg>`` for per-package detail.
"""
apm_path = project_root / "apm.yml"
if not apm_path.is_file():
Expand All @@ -42,15 +44,35 @@ def _executable_trust_drift_check(
LAYER_PROJECT_ALLOW,
LAYER_USER_ALLOW,
build_exec_trust_context,
parse_project_executables,
resolve_exec_decision,
)
from ...utils.yaml_io import load_yaml
from ..approve import load_org_policy, scan_installed_executable_packages

data = load_yaml(apm_path)
project_data = data if isinstance(data, dict) else {}
policy = load_org_policy(project_root, logger=logger)
except Exception:
return None

try:
parse_project_executables(project_data)
except ValueError as exc:
error_detail = printable_ascii_text(str(exc))
config_key = (
"allowExecutables" if error_detail.startswith("allowExecutables") else "executables"
)
return _DoctorCheck(
name="executable trust",
passed=False,
detail=f"Invalid executables block: {error_detail}. Fix '{config_key}' in apm.yml.",
informational=True,
)

try:
ctx = build_exec_trust_context(
policy=load_org_policy(project_root, logger=logger),
policy=policy,
project_data=project_data,
)
except Exception:
Expand Down
26 changes: 26 additions & 0 deletions tests/integration/marketplace/test_doctor_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,32 @@ def test_no_traceback(self):
assert "Traceback" not in result.output


def test_malformed_executable_key_is_safely_rendered(tmp_path: Path, monkeypatch):
"""Doctor renders project-controlled parser details as literal ASCII."""
monkeypatch.setattr(
"apm_cli.config.CONFIG_FILE",
str(tmp_path / ".apm" / "config.json"),
)
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
Path("apm.yml").write_text(
"name: t\n"
"version: 0.0.1\n"
"executables:\n"
" allow:\n"
' "caf\\u00e9[link=https://evil.example]click[/link]": bogus\n',
encoding="utf-8",
)
with patch("subprocess.run", side_effect=_fake_git_ok):
result = runner.invoke(doctor, [], catch_exceptions=False)

assert result.exit_code == 0
assert "executable trust" in result.output
assert "caf?" in result.output
assert "Fix 'executables' in apm.yml" in result.output
assert "\x1b]8;" not in result.output


class TestDoctorGitNotFound:
"""When git is not on PATH, doctor exits 1."""

Expand Down
25 changes: 25 additions & 0 deletions tests/integration/test_architecture_authorities.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ def test_install_request_defaults_have_single_owner() -> None:
)


def test_doctor_status_symbols_use_console_owner() -> None:
"""Doctor must consume the canonical console status vocabulary."""
root = Path(__file__).parents[2]
source = (root / "src/apm_cli/commands/marketplace/__init__.py").read_text(encoding="utf-8")
guard = (root / "scripts/lint-architecture-boundaries.sh").read_text(encoding="utf-8")
tree = ast.parse(source)
function = next(
node
for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "_doctor_status_icon"
)
raw_symbols = {"[!]", "[x]", "[i]", "[+]"}
literal_symbols = {
node.value
for node in ast.walk(function)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
}

assert not literal_symbols & raw_symbols
assert any(
isinstance(node, ast.Name) and node.id == "STATUS_SYMBOLS" for node in ast.walk(function)
)
assert "Doctor status symbols must use utils/console.py::STATUS_SYMBOLS" in guard


def test_uninstall_reintegration_routes_through_the_deployable_source_plan() -> None:
"""Uninstall rebuild must not recreate a direct, unscanned write path."""
root = Path(__file__).parents[2]
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/commands/test_marketplace_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,79 @@ def test_gate_disabled_is_informational_pass(self, tmp_path) -> None:
assert check.informational is True
assert "disabled" in check.detail.lower()

@pytest.mark.parametrize(
"malformed_block",
["executables: bogus\n", "executables:\n - bogus\n"],
)
def test_malformed_executables_block_is_reported(self, tmp_path, malformed_block: str) -> None:
from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check

(tmp_path / "apm.yml").write_text(
f"name: t\nversion: 0.0.1\n{malformed_block}",
encoding="utf-8",
)

check = _executable_trust_drift_check(tmp_path)

assert check is not None
assert check.name == "executable trust"
assert check.passed is False
assert check.informational is True
assert check.detail == (
"Invalid executables block: executables must be a mapping with 'allow' "
"and/or 'deny' keys. Fix 'executables' in apm.yml."
)

def test_malformed_project_detail_is_printable_ascii(self, tmp_path) -> None:
from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check

(tmp_path / "apm.yml").write_text(
"name: t\nversion: 0.0.1\nexecutables:\n allow:\n cafe\u0301: bogus\n",
encoding="utf-8",
)

check = _executable_trust_drift_check(tmp_path)

assert check is not None
assert check.passed is False
assert check.detail.isascii()
assert check.detail.isprintable()
assert "executables.allow['cafe?']" in check.detail

def test_malformed_deprecated_alias_names_alias_in_remediation(self, tmp_path) -> None:
from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check

(tmp_path / "apm.yml").write_text(
"name: t\nversion: 0.0.1\nallowExecutables:\n - bogus\n",
encoding="utf-8",
)

check = _executable_trust_drift_check(tmp_path)

assert check is not None
assert check.passed is False
assert "Fix 'allowExecutables' in apm.yml" in check.detail

def test_malformed_user_config_is_not_attributed_to_project(
self, tmp_path, monkeypatch
) -> None:
from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check

config_path = tmp_path / "config.json"
config_path.write_text(
'{"executables": {"allow": "bogus"}}',
encoding="utf-8",
)
monkeypatch.setattr("apm_cli.config.CONFIG_FILE", str(config_path))
(tmp_path / "apm.yml").write_text(
"name: t\nversion: 0.0.1\nexecutables: {}\n",
encoding="utf-8",
)

check = _executable_trust_drift_check(tmp_path)

assert check is None

def test_no_conflict_passes(self, tmp_path) -> None:
from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check
from apm_cli.policy.schema import ApmPolicy
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/marketplace/test_marketplace_commands_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,42 @@ def test_informational_check_shows_i_icon(self) -> None:
call_arg = logger.tree_item.call_args[0][0]
assert "[i]" in call_arg

def test_failed_informational_check_shows_warning_icon(self) -> None:
from apm_cli.commands.marketplace import _render_doctor_table

logger = MagicMock()
check = self._make_check(passed=False, informational=True)
with patch("apm_cli.commands.marketplace._get_console", return_value=None):
_render_doctor_table(logger, [check])
call_arg = logger.tree_item.call_args[0][0]
assert "[!]" in call_arg

def test_doctor_status_icon_uses_canonical_console_vocabulary(self) -> None:
from apm_cli.commands.marketplace import STATUS_SYMBOLS, _doctor_status_icon

check = self._make_check(passed=False, informational=True)
with patch.dict(STATUS_SYMBOLS, {"warning": "[canonical-warning]"}):
assert _doctor_status_icon(check) == "[canonical-warning]"

def test_rich_detail_is_rendered_as_literal_text(self) -> None:
from io import StringIO

from rich.console import Console

from apm_cli.commands.marketplace import _render_doctor_table

logger = MagicMock()
output = StringIO()
console = Console(file=output, force_terminal=True, width=200)
check = self._make_check()
check.detail = "[link=https://example.com]click[/link]"
with patch("apm_cli.commands.marketplace._get_console", return_value=console):
_render_doctor_table(logger, [check])

rendered = output.getvalue()
assert "[link=https://example.com]click[/link]" in rendered
assert "\x1b]8;" not in rendered

def test_failed_check_shows_x_icon(self) -> None:
from apm_cli.commands.marketplace import _render_doctor_table

Expand Down