proxy-aiops 0.9.2__tar.gz → 0.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/.claude-plugin/plugin.json +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/.mcp.json +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/CHANGELOG.md +21 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/PKG-INFO +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/RELEASE_NOTES.md +3 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/openclaw.plugin.json +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/package.json +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/_common.py +40 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/analyze.py +4 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/certs.py +2 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/configcmd.py +4 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/overview.py +2 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/routes.py +4 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/services.py +4 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/pyproject.toml +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/server.json +2 -2
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/skills/proxy-aiops/references/agent-guardrails.md +14 -1
- proxy_aiops-0.9.4/tests/test_cli_audited.py +131 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/uv.lock +1 -1
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/.github/workflows/mcp-publish.yml +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/.github/workflows/publish.yml +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/.gitignore +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/Dockerfile +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/LICENSE +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/README.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/SECURITY.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/docs/VERIFICATION.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/glama.json +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/__init__.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/_shared.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/server.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/__init__.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/analysis.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/certs.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/configread.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/routes.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/services.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/status.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/traffic.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/undo.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/mcp_server/tools/writes.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/__init__.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/__init__.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/_root.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/doctor.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/init.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/secret.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/server.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/cli/undo.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/config.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/connection.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/doctor.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/__init__.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/audit.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/budget.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/decorators.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/outcome.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/paths.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/patterns.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/policy.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/sanitize.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/governance/undo.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/__init__.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/_util.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/analysis.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/certs.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/configread.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/overview.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/routes.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/services.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/status.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/traffic.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/ops/writes.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/platform.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/proxy_aiops/secretstore.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/skills/proxy-aiops/SKILL.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/skills/proxy-aiops/references/capabilities.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/skills/proxy-aiops/references/cli-reference.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/skills/proxy-aiops/references/setup-guide.md +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/smithery.yaml +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/conftest.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_analysis.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_cli_governed_exit.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_cli_reads.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_cli_secret.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_cli_writes.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_connection.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_doctor.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_dpapi_version.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_gov_audit.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_gov_decorators.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_gov_patterns.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_gov_policy.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_governance_persistence.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_init.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_int_fidelity.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_mcp_read_tools.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_ops_extra.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_optional_fields.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_platform.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_reads.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_secretstore.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_self_lockout.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_smoke.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_undo_executor.py +0 -0
- {proxy_aiops-0.9.2 → proxy_aiops-0.9.4}/tests/test_writes.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "proxy-aiops",
|
|
4
4
|
"displayName": "Proxy AIops",
|
|
5
|
-
"version": "0.9.
|
|
5
|
+
"version": "0.9.4",
|
|
6
6
|
"description": "Governed Traefik + Caddy + HAProxy ops: routes, upstreams, certs, 5xx RCA. 28 tools.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.9.4 — 2026-09-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `agent-guardrails.md` promised, in the table headed "what the tool enforces — do not
|
|
7
|
+
waste prompt budget on these", that all four RCAs "rank findings worst-first" with
|
|
8
|
+
"priority in the payload". `backend_health_rca` sorts its findings on an internal score
|
|
9
|
+
removed before return, and `route_conflict_analysis` is in the proxy's own evaluation
|
|
10
|
+
order — route priority, which is not returned, and which is match order rather than
|
|
11
|
+
severity. `error_rate_rca` and `cert_expiry_sweep` are genuinely checkable (`errorRatePct`
|
|
12
|
+
with a `critical`/`warning` `severity`, and `daysToExpiry`), and the row now says only
|
|
13
|
+
that. The copyable system prompt gained a line about not reading priority off position.
|
|
14
|
+
|
|
15
|
+
## v0.9.3 — 2026-09-15
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- CLI commands that read the engine without calling an MCP tool now write an
|
|
19
|
+
audit row, as every MCP call does: they run through the same `@governed_tool`
|
|
20
|
+
harness, budget and runaway guard included. Before, an operator's CLI reads
|
|
21
|
+
left no trace in `audit.db`, contrary to the documented guarantee that MCP
|
|
22
|
+
and CLI are audited alike. A test fails if any engine command escapes it.
|
|
23
|
+
|
|
3
24
|
## v0.9.2 — 2026-09-12
|
|
4
25
|
|
|
5
26
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: proxy-aiops
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: Governed AI-ops for Traefik + Caddy + HAProxy reverse proxies / load balancers: routes, services, upstream health, TLS cert expiry sweep, 5xx error-rate RCA, route conflict analysis, and governed writes (caddy config, haproxy server state/weight) with a built-in governance harness (audit, budget, undo, risk tiers)
|
|
5
5
|
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Release notes — proxy-aiops 0.5.0
|
|
2
2
|
|
|
3
|
+
> Historical — this file covers the 0.5.0 release only and is not updated.
|
|
4
|
+
> For every release since, see [CHANGELOG.md](CHANGELOG.md) and the GitHub releases.
|
|
5
|
+
|
|
3
6
|
Previous release: 0.4.0.
|
|
4
7
|
|
|
5
8
|
## BREAKING — the authorization layer is removed
|
|
@@ -153,3 +153,43 @@ def double_confirm(action: str, resource: str) -> None:
|
|
|
153
153
|
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
154
154
|
abort=True,
|
|
155
155
|
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def audited(fn: Callable) -> Callable:
|
|
159
|
+
"""Audit a CLI command that reaches the engine without calling an MCP tool.
|
|
160
|
+
|
|
161
|
+
MCP tools write their audit row through ``@governed_tool``; a CLI command that
|
|
162
|
+
called the ops layer directly used to write none, so an operator's CLI reads
|
|
163
|
+
left no trail. This runs the command through the same harness: one audit row per
|
|
164
|
+
invocation (tool = the command function, params = its options), plus the budget
|
|
165
|
+
and runaway guard. ``typer.Exit`` is how a command ends: exit code 0 is audited
|
|
166
|
+
``ok``, a non-zero code ``error``, and the exit is re-raised unchanged. Any other
|
|
167
|
+
exception is audited ``error`` and propagates to ``cli_errors`` as before.
|
|
168
|
+
"""
|
|
169
|
+
import functools as _functools
|
|
170
|
+
|
|
171
|
+
from proxy_aiops.governance import governed_tool
|
|
172
|
+
|
|
173
|
+
@_functools.wraps(fn)
|
|
174
|
+
def body(*args: Any, **kwargs: Any) -> Any:
|
|
175
|
+
try:
|
|
176
|
+
fn(*args, **kwargs)
|
|
177
|
+
except typer.Exit as exc:
|
|
178
|
+
code = exc.exit_code
|
|
179
|
+
outcome: dict[str, Any] = {"_exitCode": code}
|
|
180
|
+
if code:
|
|
181
|
+
outcome["error"] = f"command exited with code {code}"
|
|
182
|
+
return outcome
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
governed = governed_tool(risk_level="low")(body)
|
|
186
|
+
|
|
187
|
+
@_functools.wraps(fn)
|
|
188
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
189
|
+
result = governed(*args, **kwargs)
|
|
190
|
+
if isinstance(result, dict) and "_exitCode" in result:
|
|
191
|
+
raise typer.Exit(result["_exitCode"])
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
wrapper._is_audited_cli = True # type: ignore[attr-defined]
|
|
195
|
+
return wrapper
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from proxy_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
analyze_app = typer.Typer(
|
|
13
13
|
name="analyze",
|
|
@@ -18,6 +18,7 @@ analyze_app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
@analyze_app.command("health")
|
|
20
20
|
@cli_errors
|
|
21
|
+
@audited
|
|
21
22
|
def analyze_health(
|
|
22
23
|
service: Annotated[
|
|
23
24
|
str | None, typer.Option("--service", "-s", help="Filter by service/backend")
|
|
@@ -38,6 +39,7 @@ def analyze_health(
|
|
|
38
39
|
|
|
39
40
|
@analyze_app.command("errors")
|
|
40
41
|
@cli_errors
|
|
42
|
+
@audited
|
|
41
43
|
def analyze_errors(
|
|
42
44
|
error_rate_pct: Annotated[
|
|
43
45
|
float, typer.Option("--rate", help="5xx %% at/above which a service is flagged")
|
|
@@ -63,6 +65,7 @@ def analyze_errors(
|
|
|
63
65
|
|
|
64
66
|
@analyze_app.command("conflicts")
|
|
65
67
|
@cli_errors
|
|
68
|
+
@audited
|
|
66
69
|
def analyze_conflicts(target: TargetOption = None) -> None:
|
|
67
70
|
"""Route conflict & shadow analysis: shadowed / dead routes, redirect loops."""
|
|
68
71
|
from proxy_aiops.ops import analysis as analysis_ops
|
|
@@ -7,10 +7,11 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from proxy_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@cli_errors
|
|
14
|
+
@audited
|
|
14
15
|
def certs_cmd(
|
|
15
16
|
sweep: Annotated[
|
|
16
17
|
bool,
|
|
@@ -15,6 +15,7 @@ import typer
|
|
|
15
15
|
from proxy_aiops.cli._common import (
|
|
16
16
|
DryRunOption,
|
|
17
17
|
TargetOption,
|
|
18
|
+
audited,
|
|
18
19
|
checked,
|
|
19
20
|
cli_errors,
|
|
20
21
|
console,
|
|
@@ -33,6 +34,7 @@ config_app = typer.Typer(
|
|
|
33
34
|
|
|
34
35
|
@config_app.command("snapshot")
|
|
35
36
|
@cli_errors
|
|
37
|
+
@audited
|
|
36
38
|
def config_snapshot(target: TargetOption = None) -> None:
|
|
37
39
|
"""The live config tree / merged dynamic state (sanitised)."""
|
|
38
40
|
from proxy_aiops.ops import configread as ops
|
|
@@ -43,6 +45,7 @@ def config_snapshot(target: TargetOption = None) -> None:
|
|
|
43
45
|
|
|
44
46
|
@config_app.command("search")
|
|
45
47
|
@cli_errors
|
|
48
|
+
@audited
|
|
46
49
|
def config_search(
|
|
47
50
|
query: Annotated[str, typer.Argument(help="Substring to find in keys/values")],
|
|
48
51
|
target: TargetOption = None,
|
|
@@ -62,6 +65,7 @@ def config_search(
|
|
|
62
65
|
|
|
63
66
|
@config_app.command("get")
|
|
64
67
|
@cli_errors
|
|
68
|
+
@audited
|
|
65
69
|
def config_get(
|
|
66
70
|
path: Annotated[str, typer.Argument(help="Config path (e.g. apps/http/servers)")],
|
|
67
71
|
target: TargetOption = None,
|
|
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
-
from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
7
|
+
from proxy_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@cli_errors
|
|
11
|
+
@audited
|
|
11
12
|
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
13
|
"""One-shot summary: platform/version + route/service counts + upstream health."""
|
|
13
14
|
from proxy_aiops.ops import overview as ops
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from proxy_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
routes_app = typer.Typer(
|
|
13
13
|
name="routes",
|
|
@@ -19,6 +19,7 @@ routes_app = typer.Typer(
|
|
|
19
19
|
|
|
20
20
|
@routes_app.command("list")
|
|
21
21
|
@cli_errors
|
|
22
|
+
@audited
|
|
22
23
|
def routes_list(
|
|
23
24
|
host: Annotated[
|
|
24
25
|
str | None, typer.Option("--host", "-H", help="Filter by hostname")
|
|
@@ -34,6 +35,7 @@ def routes_list(
|
|
|
34
35
|
|
|
35
36
|
@routes_app.command("show")
|
|
36
37
|
@cli_errors
|
|
38
|
+
@audited
|
|
37
39
|
def routes_show(
|
|
38
40
|
name: Annotated[str, typer.Argument(help="Route name (from 'routes list')")],
|
|
39
41
|
target: TargetOption = None,
|
|
@@ -47,6 +49,7 @@ def routes_show(
|
|
|
47
49
|
|
|
48
50
|
@routes_app.command("find")
|
|
49
51
|
@cli_errors
|
|
52
|
+
@audited
|
|
50
53
|
def routes_find(
|
|
51
54
|
host: Annotated[str, typer.Argument(help="Hostname to match")],
|
|
52
55
|
path: Annotated[str, typer.Option("--path", "-p", help="Request path")] = "/",
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from proxy_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
services_app = typer.Typer(
|
|
13
13
|
name="services",
|
|
@@ -18,6 +18,7 @@ services_app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
@services_app.command("list")
|
|
20
20
|
@cli_errors
|
|
21
|
+
@audited
|
|
21
22
|
def services_list(target: TargetOption = None) -> None:
|
|
22
23
|
"""List services/backends with per-service server-up counts."""
|
|
23
24
|
from proxy_aiops.ops import services as ops
|
|
@@ -28,6 +29,7 @@ def services_list(target: TargetOption = None) -> None:
|
|
|
28
29
|
|
|
29
30
|
@services_app.command("show")
|
|
30
31
|
@cli_errors
|
|
32
|
+
@audited
|
|
31
33
|
def services_show(
|
|
32
34
|
name: Annotated[str, typer.Argument(help="Service name (from 'services list')")],
|
|
33
35
|
target: TargetOption = None,
|
|
@@ -41,6 +43,7 @@ def services_show(
|
|
|
41
43
|
|
|
42
44
|
@services_app.command("upstreams")
|
|
43
45
|
@cli_errors
|
|
46
|
+
@audited
|
|
44
47
|
def services_upstreams(
|
|
45
48
|
service: Annotated[
|
|
46
49
|
str | None, typer.Option("--service", "-s", help="Filter by service/backend")
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxy-aiops"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.4"
|
|
8
8
|
description = "Governed AI-ops for Traefik + Caddy + HAProxy reverse proxies / load balancers: routes, services, upstream health, TLS cert expiry sweep, 5xx error-rate RCA, route conflict analysis, and governed writes (caddy config, haproxy server state/weight) with a built-in governance harness (audit, budget, undo, risk tiers)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Proxy-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.9.
|
|
10
|
+
"version": "0.9.4",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "proxy-aiops",
|
|
15
|
-
"version": "0.9.
|
|
15
|
+
"version": "0.9.4",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -31,7 +31,7 @@ What the tool *does* guarantee is that you can always see what happened:
|
|
|
31
31
|
|---|---|
|
|
32
32
|
| "Don't invent a value when a field is missing" | Traefik, Caddy and HAProxy express the same concepts differently, so a field one platform has and another does not comes back as `null`, never as `""`. A Caddy route's `raw` rule string is `null` — Caddy matches on a match list and has no such string — rather than a misleading empty rule. |
|
|
33
33
|
| "Tell me if the output was cut off" | `search_config`, `traffic_stats` and `error_counters` return `{"matches"/"services": [...], "returned": N, "limit": L, "truncated": true/false}` — one convention across the repo. Truncation is measured (the config walk deliberately overshoots by one) and not guessed from the count reaching the cap. |
|
|
34
|
-
| "
|
|
34
|
+
| "Make it show the number it judged on" | `error_rate_rca` returns `errorRatePct`, `vsBaselineX` against the fleet baseline and a `severity` of `critical`/`warning` on every flagged service; `cert_expiry_sweep` returns `daysToExpiry` and a `bucket` per certificate, and orders by `daysToExpiry`. Both orderings are therefore checkable from the payload itself. |
|
|
35
35
|
| "Confirm before anything destructive" | `delete_config_path` and `load_config` require a `--dry-run`-able preview plus double confirmation at the CLI. Config writes capture the prior value so the undo token can restore it. |
|
|
36
36
|
| "Log what you did" | Every governed call is audited to `~/.proxy-aiops/audit.db` regardless of what the model says it did — and the CLI writes the same row the MCP path does, so there is no unaudited entry point. |
|
|
37
37
|
| "Don't get stuck retrying" | The runaway guard trips a circuit breaker if the same call is hammered in a tight loop — a stuck agent is stopped rather than left to burn calls and time. |
|
|
@@ -39,6 +39,15 @@ What the tool *does* guarantee is that you can always see what happened:
|
|
|
39
39
|
## What still needs a prompt
|
|
40
40
|
|
|
41
41
|
These are model-behaviour problems the harness cannot fix from the outside.
|
|
42
|
+
|
|
43
|
+
⚠️ **Two of the four orderings cannot be checked, for different reasons.**
|
|
44
|
+
`backend_health_rca` sorts its `findings` worst-first on an internal score that is removed
|
|
45
|
+
before the payload is returned, and its findings carry neither a `rank` nor a `severity`.
|
|
46
|
+
`route_conflict_analysis` is ordered by the proxy's own **evaluation order** (route priority,
|
|
47
|
+
which is not returned) — that is match order, not severity: a shadowed route appearing first
|
|
48
|
+
says nothing about how bad the shadowing is. `error_rate_rca` and `cert_expiry_sweep` are
|
|
49
|
+
different: they sort on `errorRatePct` and `daysToExpiry`, both of which are in the payload.
|
|
50
|
+
|
|
42
51
|
Copy this into your agent's system prompt:
|
|
43
52
|
|
|
44
53
|
```text
|
|
@@ -57,6 +66,10 @@ READING RESULTS
|
|
|
57
66
|
- Read the whole result before concluding. If a result contains a "truncated"
|
|
58
67
|
field that is true, say so and narrow the query instead of treating the
|
|
59
68
|
partial result as complete.
|
|
69
|
+
- `backend_health_rca` findings and `route_conflict_analysis` results are not ordered by
|
|
70
|
+
severity — the latter is in the proxy's match order. Weigh `backend_health_rca` findings on
|
|
71
|
+
their own `serversTotal`/`up`/`down`/`maint` counts. `route_conflict_analysis` results carry
|
|
72
|
+
no number at all — read `shadowedBy`, `reason` or `chain` and say which one you acted on.
|
|
60
73
|
- A null field means this platform does not express that concept, or did not
|
|
61
74
|
report it. Report it as "not available" — never infer it.
|
|
62
75
|
- An "unsupported" field is a capability statement about the platform, not an
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Every CLI command that reaches the engine is audited.
|
|
2
|
+
|
|
3
|
+
MCP tools write their audit row through ``@governed_tool``. CLI commands that called
|
|
4
|
+
the ops layer directly wrote none: on 2026-09-15 a read command of every tool in the
|
|
5
|
+
line, run in a scratch home, left no audit.db while the harness itself worked. Each
|
|
6
|
+
such command is now wrapped in ``audited`` (``cli/_common.py``); commands that call an
|
|
7
|
+
MCP tool are audited by that tool. This pins the helper's behaviour and that no
|
|
8
|
+
engine command escapes it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import inspect
|
|
15
|
+
import sqlite3
|
|
16
|
+
import textwrap
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
import typer
|
|
20
|
+
from typer.main import get_command
|
|
21
|
+
|
|
22
|
+
from proxy_aiops.cli import app
|
|
23
|
+
from proxy_aiops.cli._common import audited
|
|
24
|
+
|
|
25
|
+
pytestmark = pytest.mark.unit
|
|
26
|
+
|
|
27
|
+
#: Local-only commands: they never reach the engine (``undo`` goes through MCP tools).
|
|
28
|
+
EXEMPT = {"init", "doctor", "secret", "mcp", "undo"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaves() -> list[tuple[list[str], object]]:
|
|
32
|
+
found: list[tuple[list[str], object]] = []
|
|
33
|
+
|
|
34
|
+
def walk(cmd, path):
|
|
35
|
+
if hasattr(cmd, "list_commands"):
|
|
36
|
+
for name, sub in cmd.commands.items():
|
|
37
|
+
walk(sub, path + [name])
|
|
38
|
+
else:
|
|
39
|
+
found.append((path, cmd))
|
|
40
|
+
|
|
41
|
+
walk(get_command(app), [])
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _chain(fn):
|
|
46
|
+
seen = []
|
|
47
|
+
while fn is not None and fn not in seen:
|
|
48
|
+
seen.append(fn)
|
|
49
|
+
fn = getattr(fn, "__wrapped__", None)
|
|
50
|
+
return seen
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _calls_an_mcp_tool(fn) -> bool:
|
|
54
|
+
source = inspect.getsource(fn)
|
|
55
|
+
if "mcp_server" in source:
|
|
56
|
+
return True
|
|
57
|
+
names = set()
|
|
58
|
+
for node in ast.parse(open(inspect.getsourcefile(fn)).read()).body:
|
|
59
|
+
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("mcp_server"):
|
|
60
|
+
names |= {a.asname or a.name for a in node.names}
|
|
61
|
+
used = {n.id for n in ast.walk(ast.parse(textwrap.dedent(source))) if isinstance(n, ast.Name)}
|
|
62
|
+
return bool(names & used)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_every_engine_command_is_audited():
|
|
66
|
+
commands = [(path, cmd) for path, cmd in _leaves() if path and path[0] not in EXEMPT]
|
|
67
|
+
assert commands, "no commands found: the walker is broken, not the CLI clean"
|
|
68
|
+
missing = [
|
|
69
|
+
" ".join(path) for path, cmd in commands
|
|
70
|
+
if not any(getattr(f, "_is_audited_cli", False) for f in _chain(cmd.callback))
|
|
71
|
+
and not _calls_an_mcp_tool(inspect.unwrap(cmd.callback))
|
|
72
|
+
]
|
|
73
|
+
assert not missing, f"CLI commands that leave no audit row: {missing}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def home(tmp_path, monkeypatch):
|
|
78
|
+
import proxy_aiops.governance.audit as audit_mod
|
|
79
|
+
import proxy_aiops.governance.budget as budget_mod
|
|
80
|
+
|
|
81
|
+
monkeypatch.setenv("PROXY_AIOPS_HOME", str(tmp_path))
|
|
82
|
+
audit_mod.reset_engine()
|
|
83
|
+
budget_mod.reset_budget()
|
|
84
|
+
yield tmp_path
|
|
85
|
+
audit_mod.reset_engine()
|
|
86
|
+
budget_mod.reset_budget()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rows(home) -> list[tuple[str, str]]:
|
|
90
|
+
con = sqlite3.connect(home / "audit.db")
|
|
91
|
+
try:
|
|
92
|
+
return con.execute("SELECT tool, status FROM audit_log ORDER BY id").fetchall()
|
|
93
|
+
finally:
|
|
94
|
+
con.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_audited_records_success_exit_codes_and_exceptions(home):
|
|
98
|
+
@audited
|
|
99
|
+
def cmd_ok(target=None):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@audited
|
|
103
|
+
def cmd_exit_zero(target=None):
|
|
104
|
+
raise typer.Exit()
|
|
105
|
+
|
|
106
|
+
@audited
|
|
107
|
+
def cmd_exit_one(target=None):
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
@audited
|
|
111
|
+
def cmd_raises(target=None):
|
|
112
|
+
raise RuntimeError("engine unreachable")
|
|
113
|
+
|
|
114
|
+
cmd_ok()
|
|
115
|
+
with pytest.raises(typer.Exit) as zero:
|
|
116
|
+
cmd_exit_zero()
|
|
117
|
+
assert zero.value.exit_code == 0
|
|
118
|
+
with pytest.raises(typer.Exit) as one:
|
|
119
|
+
cmd_exit_one()
|
|
120
|
+
assert one.value.exit_code == 1
|
|
121
|
+
with pytest.raises(RuntimeError, match="engine unreachable"):
|
|
122
|
+
cmd_raises()
|
|
123
|
+
assert _rows(home) == [("cmd_ok", "ok"), ("cmd_exit_zero", "ok"),
|
|
124
|
+
("cmd_exit_one", "error"), ("cmd_raises", "error")]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_audited_keeps_the_command_signature_for_typer():
|
|
128
|
+
def cmd(limit: int = 5, target: str | None = None) -> None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
assert list(inspect.signature(audited(cmd)).parameters) == ["limit", "target"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|