Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison e510665574 fix: harden extensible gateway doctor (JSON purity, core-check shadowing, repair validation)
- Keep `gateway doctor --json` stdout a single valid JSON document by
  suppressing advisory registry warnings during discovery/execution, fixing
  the CI JSONDecodeError under Click's stderr-merging test runner.
- Protect mandatory `core/gateway/*` checks: register them as `protected`
  before plugin discovery so an installed `praisonai.health_checks` extension
  can no longer shadow required auth-token / config-version validation (P1).
- Validate that a check's `repair` returns `HealthRepairResult`/None and drop
  malformed values so `--fix` reports the faulty extension instead of raising
  AttributeError while rendering (P2).
- Add regression tests for protected-ID shadowing, malformed repair isolation,
  and JSON purity when the registry warns.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-13 12:44:24 +00:00
4 changed files with 192 additions and 11 deletions

No files matched your search

@@ -20,19 +20,39 @@ class HealthCheckRegistry:
def __init__(self) -> None:
self._checks: Dict[str, HealthCheckProtocol] = {}
self._protected: set = set()
self._plugins_loaded = False
def register_check(self, check: HealthCheckProtocol) -> None:
def register_check(
self, check: HealthCheckProtocol, *, protected: bool = False
) -> None:
"""Register a health check.
``protected=True`` marks the ID as owned by a trusted first-party
surface (for example the gateway's mandatory ``core/gateway/*``
checks). A later registration — including a discovered
``praisonai.health_checks`` plugin — can NOT silently replace a
protected ID, so an installed extension can never shadow a required
security/config validation.
"""
check_id = getattr(check, "id", None) or getattr(check, "check_id", None)
if not callable(getattr(check, "detect", None)):
raise TypeError("Check must provide detect(context)")
if not isinstance(check_id, str) or "/" not in check_id:
raise ValueError("Health check IDs must be namespaced (for example channel/name)")
if check_id in self._checks:
if check_id in self._protected and not protected:
warnings.warn(
f"Ignoring health check {check_id!r}: this ID is reserved by a "
"built-in check and cannot be overridden"
)
return
if check_id in self._checks and check_id not in self._protected:
warnings.warn(
f"Duplicate health check ID {check_id!r} - replacing existing check"
)
self._checks[check_id] = check
if protected:
self._protected.add(check_id)
def get_checks(self) -> List[HealthCheckProtocol]:
if not self._plugins_loaded:
@@ -76,6 +96,13 @@ class HealthCheckRegistry:
if fix and findings and callable(repair_fn):
try:
repair = repair_fn(context, findings)
if repair is not None and not isinstance(
repair, HealthRepairResult
):
raise TypeError(
"Check repair must return HealthRepairResult or None, "
f"got {type(repair).__name__}"
)
residual = list(check.detect(context))
except Exception as exc:
residual = list(findings) + [Finding(
@@ -84,6 +111,10 @@ class HealthCheckRegistry:
message=f"Health check repair failed: {exc}",
context={"error": str(exc)},
)]
# Drop any malformed repair value so rendering (to_dict) can
# never dereference fields on a non-HealthRepairResult.
if not isinstance(repair, HealthRepairResult):
repair = None
results.append(HealthCheckResult(
check_id=check_id,
findings=findings,
@@ -1,5 +1,7 @@
"""Tests for extensible runtime health checks."""
import warnings
from praisonaiagents.runtime.doctor_protocol import Finding
from praisonaiagents.runtime.health_check import HealthRepairResult
from praisonaiagents.runtime.health_registry import HealthCheckRegistry
@@ -91,3 +93,87 @@ def test_registry_accepts_canonical_id_attribute():
assert registry.get_check_ids() == ["plugin/canonical"]
assert registry.run({})[0].check_id == "plugin/canonical"
def test_protected_check_cannot_be_shadowed():
"""A plugin re-using a protected core ID must not override it (Greptile P1)."""
class CoreCheck:
check_id = "core/gateway/auth-token"
def detect(self, context):
return []
class ImpostorPlugin:
check_id = "core/gateway/auth-token"
def detect(self, context):
return [Finding(
rule_id=self.check_id,
severity="error",
message="should never run",
)]
registry = HealthCheckRegistry()
registry._plugins_loaded = True
core = CoreCheck()
registry.register_check(core, protected=True)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
registry.register_check(ImpostorPlugin())
assert registry.get_check_ids() == ["core/gateway/auth-token"]
assert registry._checks["core/gateway/auth-token"] is core
assert any("reserved" in str(w.message) for w in caught)
def test_protected_check_replaceable_by_protected_owner():
"""The owning surface may re-register its own protected check idempotently."""
class CoreCheck:
check_id = "core/gateway/auth-token"
def detect(self, context):
return []
registry = HealthCheckRegistry()
registry._plugins_loaded = True
first = CoreCheck()
second = CoreCheck()
registry.register_check(first, protected=True)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
registry.register_check(second, protected=True)
assert registry._checks["core/gateway/auth-token"] is second
assert not caught
def test_malformed_repair_result_is_isolated():
"""A repair returning a non-HealthRepairResult must not crash rendering (P2)."""
class BadRepair:
check_id = "plugin/bad-repair"
def detect(self, context):
return [Finding(
rule_id=self.check_id,
severity="error",
message="broken",
)]
def repair(self, context, findings):
return {"changed": True} # wrong type on purpose
registry = HealthCheckRegistry()
registry._plugins_loaded = True
registry.register_check(BadRepair())
result = registry.run({}, fix=True)[0]
assert result.error is not None
assert "HealthRepairResult" in result.error
assert result.repair is None
# to_dict must not raise on a malformed repair value.
payload = result.to_dict()
assert payload["repair"] is None
assert payload["repaired"] is False
@@ -895,18 +895,40 @@ class _GatewayConfigVersionHealthCheck:
def _run_gateway_health_checks(config_path: str, *, fix: bool, dry_run: bool):
"""Run built-in and third-party checks through one shared lifecycle."""
"""Run built-in and third-party checks through one shared lifecycle.
The mandatory ``core/gateway/*`` checks (auth-token strength, config-version
migration) are registered as ``protected`` BEFORE any
``praisonai.health_checks`` plugin is discovered, so an installed extension
can never claim a canonical gateway ID and shadow the required
security/config validation (Greptile P1). ``register_check`` is idempotent
for a protected ID, so repeated ``doctor`` invocations do not re-warn.
"""
import warnings
from praisonaiagents.runtime.health_registry import get_health_check_registry
registry = get_health_check_registry()
existing = set(registry.get_check_ids())
for check in (_GatewaySecretHealthCheck(), _GatewayConfigVersionHealthCheck()):
if check.check_id not in existing:
registry.register_check(check)
return registry.run(
{"config_path": config_path, "dry_run": dry_run},
fix=fix,
)
# Advisory registry warnings (plugin-discovery failures, a plugin trying to
# shadow a protected ID) must NOT leak onto stderr here: under some Click
# versions the CLI test runner merges stderr into stdout, which corrupts the
# ``doctor --json`` document into an unparseable payload. The findings the
# operator needs are already surfaced structurally in the JSON/plain report,
# so silence the transient warnings around discovery + execution.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# Register the mandatory built-ins as protected FIRST. Plugin discovery
# runs lazily inside registry.run(); protecting the IDs here means a
# later plugin using the same ID is refused instead of overriding it.
for check in (
_GatewaySecretHealthCheck(),
_GatewayConfigVersionHealthCheck(),
):
registry.register_check(check, protected=True)
return registry.run(
{"config_path": config_path, "dry_run": dry_run},
fix=fix,
)
def _health_result(results, check_id):
@@ -60,3 +60,45 @@ def test_gateway_doctor_runs_repairs_and_revalidates_registered_check(
)
assert plugin["repaired"] is True
assert plugin["residual_findings"] == []
def test_gateway_doctor_json_stays_pure_when_registry_warns(monkeypatch, tmp_path):
"""Registry warnings (e.g. plugin discovery) must not corrupt --json output.
Under some Click versions the CLI runner merges stderr into stdout, so a
stray ``warnings.warn`` during health-check discovery would break
``json.loads`` (the CI JSONDecodeError). The doctor must keep stdout as a
single valid JSON document regardless.
"""
import warnings as _warnings
typer_testing = pytest.importorskip("typer.testing")
from praisonaiagents.gateway.config import GATEWAY_CONFIG_VERSION
import praisonaiagents.runtime.health_registry as health_registry
monkeypatch.setattr(health_registry, "_default_registry", None)
def _noisy_discovery(self):
_warnings.warn("Failed to load health check 'demo': boom")
monkeypatch.setattr(
health_registry.HealthCheckRegistry,
"_load_entry_points",
_noisy_discovery,
)
cfg = tmp_path / "gateway.yaml"
cfg.write_text(
f"config_version: {GATEWAY_CONFIG_VERSION}\nchannels: {{}}\n",
encoding="utf-8",
)
from praisonai_bot.cli.commands.gateway import app
result = typer_testing.CliRunner().invoke(
app, ["doctor", "--config", str(cfg), "--json"]
)
assert result.exit_code == 0, result.stdout
payload = json.loads(result.stdout)
assert payload["health"]["checksRun"] == 2