Skip to content

Commit 9c9ea17

Browse files
Ricky-GsakunaharindaCopilot
committed
fix: align empty policy readiness behavior
Return not-ready responses for policy sets without enabled rules, expose effective rule counts and load warnings, and make policy-server readiness registration explicit. Update tests, changelog, and repository spell-check configuration. Signed-off-by: Ricky Gummadi <ricky.gummadi@outlook.com> Co-authored-by: sakunaharinda <sakunaj1996@gmail.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: sakunaharinda <sakunaj1996@gmail.com>
1 parent 3df2110 commit 9c9ea17

10 files changed

Lines changed: 176 additions & 133 deletions

File tree

.cspell-repo-terms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,7 @@ pytest
887887
pytestmark
888888
pythonhosted
889889
pythonstartup
890+
readyz
890891
pyupgrade
891892
pyyaml
892893
qualname

.cspell.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@
6767
"tomli",
6868
"fastapi",
6969
"starlette",
70-
"readyz",
7170
"structlog",
7271
"scipy",
7372
"chromadb",

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ venv/
2020
htmlcov/
2121
.coverage
2222
*.cover
23-
.DS_Store
2423

2524
# Node
2625
node_modules/

agent-governance-python/agent-mesh/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
115115

116116
### Fixed
117117

118+
- **Empty policy sets are visible to readiness probes.** The policy server and
119+
governance sidecar now return `503 Not Ready` when no enabled policy rules
120+
are loaded. Policy and generation status responses expose `effective_rules`
121+
and `load_warnings` so operators can distinguish an empty policy set from a
122+
healthy deployment.
118123
- **Pending-message batch isolation.** A single malformed entry in a relay-supplied
119124
`pending_messages` batch no longer aborts the drain; the failure is surfaced
120125
through the error handler and the remaining queued messages are still delivered.

agent-governance-python/agent-mesh/src/agentmesh/governance/trust_policy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def from_yaml(cls, path: str | Path) -> "TrustPolicy":
161161
A fully-constructed ``TrustPolicy`` instance.
162162
"""
163163
path = Path(path)
164-
with open(path, "r") as f:
164+
with open(path, "r", encoding="utf-8") as f:
165165
data = yaml.safe_load(f)
166166
return cls(**data)
167167

@@ -173,7 +173,7 @@ def to_yaml(self, path: str | Path) -> None:
173173
"""
174174
path = Path(path)
175175
data = self.model_dump(mode="json")
176-
with open(path, "w") as f:
176+
with open(path, "w", encoding="utf-8") as f:
177177
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
178178

179179

agent-governance-python/agent-mesh/src/agentmesh/server/__init__.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
_start_time: float = 0.0
2424

2525

26-
def create_base_app(component: str, description: str) -> FastAPI:
26+
def create_base_app(
27+
component: str,
28+
description: str,
29+
*,
30+
include_readyz: bool = True,
31+
) -> FastAPI:
2732
"""Create a FastAPI app with standard health/metrics endpoints."""
2833
global _start_time
2934
_start_time = time.monotonic()
@@ -40,9 +45,11 @@ def create_base_app(component: str, description: str) -> FastAPI:
4045
async def healthz() -> dict[str, str]:
4146
return {"status": "ok", "component": component}
4247

43-
@app.get("/readyz", tags=["health"])
44-
async def readyz() -> dict[str, str]:
45-
return {"status": "ready", "component": component}
48+
if include_readyz:
49+
50+
@app.get("/readyz", tags=["health"])
51+
async def readyz() -> dict[str, str]:
52+
return {"status": "ready", "component": component}
4653

4754
@app.get("/metrics", tags=["observability"])
4855
async def metrics() -> PlainTextResponse:

agent-governance-python/agent-mesh/src/agentmesh/server/policy_server.py

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
app = create_base_app(
3030
"policy-server",
3131
"Evaluates governance policies against agent actions.",
32+
include_readyz=False,
3233
)
3334

3435
POLICY_DIR = os.getenv("AGENTMESH_POLICY_DIR", "/etc/agentmesh/policies")
@@ -38,73 +39,70 @@
3839
_trust_policies: list[TrustPolicy] = []
3940
_trust_evaluator: PolicyEvaluator | None = None
4041
_loaded_count: int = 0
42+
_effective_rule_count: int = 0
4143
_load_warnings: list[str] = []
4244

4345

44-
# Replace the generic readiness route so an empty policy set is visible to
45-
# Kubernetes and operators instead of being reported as ready.
46-
app.router.routes = [
47-
route for route in app.router.routes if getattr(route, "path", None) != "/readyz"
48-
]
49-
50-
5146
@app.get("/readyz", tags=["health"], response_model=None)
52-
async def readyz() -> JSONResponse | dict[str, object]:
47+
async def readyz() -> JSONResponse:
5348
payload = {
54-
"status": "ready" if _loaded_count > 0 else "not-ready",
49+
"status": "ready" if _effective_rule_count > 0 else "not-ready",
5550
"component": "policy-server",
5651
"total_loaded": _loaded_count,
52+
"effective_rules": _effective_rule_count,
5753
"policy_dir": POLICY_DIR,
5854
"load_warnings": list(_load_warnings),
5955
}
60-
if _loaded_count == 0:
56+
if _effective_rule_count == 0:
6157
return JSONResponse(status_code=503, content=payload)
62-
return payload
58+
return JSONResponse(content=payload)
6359

6460

6561
def _validate_load_warnings() -> None:
66-
"""Record a warning when a load completes without any policies."""
62+
"""Record a warning when a load completes without effective rules."""
6763
global _load_warnings
6864

6965
_load_warnings = []
70-
if _loaded_count == 0:
66+
if _effective_rule_count == 0:
7167
warning = (
72-
f"Startup validation: no policies loaded from {POLICY_DIR}; "
73-
"all evaluations will be denied by default until policies are loaded."
68+
f"Policy load validation: no effective rules loaded from {POLICY_DIR}; "
69+
"readiness remains blocked until an enabled policy rule is loaded."
7470
)
7571
logger.warning(warning)
7672
_load_warnings.append(warning)
7773

7874

7975
def _load_policies() -> None:
8076
"""Load all YAML/JSON policy files from POLICY_DIR."""
81-
global _engine, _trust_policies, _trust_evaluator, _loaded_count
77+
global _engine, _trust_policies, _trust_evaluator, _loaded_count, _effective_rule_count
8278

8379
policy_path = Path(POLICY_DIR)
84-
if not policy_path.exists():
85-
logger.warning("Policy directory %s does not exist", POLICY_DIR)
86-
if _loaded_count == 0:
87-
_validate_load_warnings()
88-
return
80+
if not policy_path.is_dir():
81+
raise RuntimeError(
82+
f"Policy directory {POLICY_DIR} does not exist or is not a directory; "
83+
"refusing to load an undefined policy set"
84+
)
8985

9086
# Load into locals first; assign globals only after all files succeed.
9187
# A failed reload (POST /api/v1/policy/reload) must not leave a
9288
# partially loaded engine live (#3536 review feedback).
9389
local_engine = PolicyEngine()
94-
local_trust: list = []
90+
local_trust: list[TrustPolicy] = []
9591
governance_count = 0
92+
effective_rule_count = 0
9693
errors: list[tuple[str, Exception]] = []
9794

9895
for f in sorted(policy_path.glob("*.yaml")):
9996
gov_exc = None
10097
try:
101-
local_engine.load_yaml(f.read_text())
98+
policy = local_engine.load_yaml(f.read_text(encoding="utf-8"))
10299
governance_count += 1
100+
effective_rule_count += sum(rule.enabled for rule in policy.rules)
103101
logger.info("Loaded governance policy: %s", f.name)
104102
except Exception as ge:
105103
gov_exc = ge
106104
try:
107-
tp = TrustPolicy.from_yaml(f.read_text())
105+
tp = TrustPolicy.from_yaml(f)
108106
local_trust.append(tp)
109107
logger.info("Loaded trust policy: %s", f.name)
110108
except Exception:
@@ -114,8 +112,9 @@ def _load_policies() -> None:
114112

115113
for f in sorted(policy_path.glob("*.json")):
116114
try:
117-
local_engine.load_json(f.read_text())
115+
policy = local_engine.load_json(f.read_text(encoding="utf-8"))
118116
governance_count += 1
117+
effective_rule_count += sum(rule.enabled for rule in policy.rules)
119118
except Exception as exc:
120119
errors.append((f.name, exc))
121120

@@ -136,6 +135,9 @@ def _load_policies() -> None:
136135
_trust_evaluator = PolicyEvaluator(_trust_policies) if _trust_policies else None
137136

138137
_loaded_count = governance_count + len(_trust_policies)
138+
_effective_rule_count = effective_rule_count + sum(
139+
len(policy.rules) for policy in _trust_policies
140+
)
139141
logger.info(
140142
"Loaded %d governance + %d trust policies",
141143
governance_count,
@@ -231,6 +233,7 @@ async def list_policies() -> dict[str, Any]:
231233
"""List all loaded policies."""
232234
return {
233235
"total_loaded": _loaded_count,
236+
"effective_rules": _effective_rule_count,
234237
"trust_policies": len(_trust_policies),
235238
"policy_dir": POLICY_DIR,
236239
"load_warnings": list(_load_warnings),
@@ -244,6 +247,7 @@ async def reload_policies() -> dict[str, Any]:
244247
return {
245248
"status": "reloaded",
246249
"total_loaded": _loaded_count,
250+
"effective_rules": _effective_rule_count,
247251
"trust_policies": len(_trust_policies),
248252
"load_warnings": list(_load_warnings),
249253
}

agent-governance-python/agent-mesh/src/agentmesh/server/sidecar.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from typing import Any, Literal
2626

2727
from fastapi import FastAPI
28-
from fastapi.responses import PlainTextResponse
28+
from fastapi.responses import JSONResponse, PlainTextResponse
2929
from pydantic import BaseModel, ConfigDict, Field
3030

3131
from agentmesh.governance.policy import PolicyEngine as _PolicyEngine
@@ -59,24 +59,30 @@ async def health() -> dict[str, str]:
5959
"""Liveness probe."""
6060
return {"status": "ok", "component": "governance-sidecar"}
6161

62-
@app.get("/ready", tags=["health"])
63-
async def ready() -> dict[str, Any]:
64-
"""Readiness probe. Reports loaded policy count."""
65-
return {
66-
"status": "ready",
62+
def _readiness_response() -> JSONResponse:
63+
generation = _policy_state[1]
64+
payload: dict[str, Any] = {
65+
"status": "ready" if generation.effective_rules > 0 else "not-ready",
6766
"component": "governance-sidecar",
68-
**_policy_state[1].model_dump(exclude={"files"}),
67+
**generation.model_dump(exclude={"files"}),
6968
}
69+
status_code = 200 if generation.effective_rules > 0 else 503
70+
return JSONResponse(status_code=status_code, content=payload)
71+
72+
@app.get("/ready", tags=["health"], response_model=None)
73+
async def ready() -> JSONResponse:
74+
"""Readiness probe. Reports loaded policy count."""
75+
return _readiness_response()
7076

7177
@app.get("/healthz", tags=["health"])
7278
async def healthz() -> dict[str, str]:
7379
"""Kubernetes-style liveness probe."""
7480
return {"status": "ok", "component": "governance-sidecar"}
7581

76-
@app.get("/readyz", tags=["health"])
77-
async def readyz() -> dict[str, str]:
82+
@app.get("/readyz", tags=["health"], response_model=None)
83+
async def readyz() -> JSONResponse:
7884
"""Kubernetes-style readiness probe."""
79-
return {"status": "ready", "component": "governance-sidecar"}
85+
return _readiness_response()
8086

8187
# ── Metrics endpoint ─────────────────────────────────────────────
8288

@@ -203,17 +209,18 @@ class PolicyFileLoad(BaseModel):
203209

204210

205211
class PolicyLoadGeneration(BaseModel):
206-
"""Immutable manifest of a completed load; counts refer to files, not unique names."""
212+
"""Immutable manifest of a completed load."""
207213

208214
model_config = ConfigDict(frozen=True)
209215
policy_set_id: str
210216
policy_set_status: Literal["complete", "degraded", "rejected", "not_loaded"]
211217
policies_discovered: int
212218
policies_loaded: int
219+
effective_rules: int = 0
213220
policies_failed: int
214221
directory_status: Literal["available", "unavailable", "not_loaded"]
215222
files: tuple[PolicyFileLoad, ...]
216-
startup_warnings: tuple[str, ...] = ()
223+
load_warnings: tuple[str, ...] = ()
217224

218225

219226
# ── Internal state ───────────────────────────────────────────────────
@@ -226,6 +233,7 @@ class PolicyLoadGeneration(BaseModel):
226233
policy_set_status="not_loaded",
227234
policies_discovered=0,
228235
policies_loaded=0,
236+
effective_rules=0,
229237
policies_failed=0,
230238
directory_status="not_loaded",
231239
files=(),
@@ -241,6 +249,7 @@ def _load_policies() -> PolicyLoadGeneration:
241249

242250
_policy_dir = os.getenv("AGT_POLICY_DIR", "/etc/agt/policies")
243251
engine = PolicyEngine()
252+
loaded_policies: dict[str, Any] = {}
244253

245254
policy_path = Path(_policy_dir)
246255
files = []
@@ -261,7 +270,8 @@ def _load_policies() -> PolicyLoadGeneration:
261270
try:
262271
content = f.read_bytes()
263272
digest = hashlib.sha256(content).hexdigest()
264-
loader(content.decode("utf-8"))
273+
policy = loader(content.decode("utf-8"))
274+
loaded_policies[policy.name] = policy
265275
except Exception as exc:
266276
error_type = type(exc).__name__
267277
logger.warning("Skipped policy %r: %s", f.name, error_type)
@@ -280,6 +290,10 @@ def _load_policies() -> PolicyLoadGeneration:
280290
}
281291
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
282292
failed = sum(entry.status == "failed" for entry in files)
293+
effective_rules = sum(
294+
sum(rule.enabled for rule in policy.rules)
295+
for policy in loaded_policies.values()
296+
)
283297

284298
# Fail-closed (#3536 review): when files fail, publish the generation
285299
# as 'degraded' so evaluate_policy can deny based on policies_failed.
@@ -296,24 +310,25 @@ def _load_policies() -> PolicyLoadGeneration:
296310
if failed or directory_status == "unavailable":
297311
policy_set_status = "degraded"
298312

299-
startup_warnings: tuple[str, ...] = ()
300-
if len(files) - failed == 0:
313+
load_warnings: tuple[str, ...] = ()
314+
if effective_rules == 0:
301315
warning = (
302-
f"Startup validation: no policies loaded from {_policy_dir}; "
303-
"all evaluations will be denied by default until policies are loaded."
316+
f"Policy load validation: no effective rules loaded from {_policy_dir}; "
317+
"readiness remains blocked until an enabled policy rule is loaded."
304318
)
305319
logger.warning(warning)
306-
startup_warnings = (warning,)
320+
load_warnings = (warning,)
307321

308322
generation = PolicyLoadGeneration(
309323
policy_set_id="sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
310324
policy_set_status=policy_set_status,
311325
policies_discovered=len(files),
312326
policies_loaded=len(files) - failed,
327+
effective_rules=effective_rules,
313328
policies_failed=failed,
314329
directory_status=directory_status,
315330
files=tuple(files),
316-
startup_warnings=startup_warnings,
331+
load_warnings=load_warnings,
317332
)
318333
serialized = generation.model_dump_json()
319334
_policy_state = (engine, generation)

0 commit comments

Comments
 (0)