proxy-aiops 0.9.2__tar.gz → 0.9.3__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.
Files changed (106) hide show
  1. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/.claude-plugin/plugin.json +1 -1
  2. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/.mcp.json +1 -1
  3. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/CHANGELOG.md +9 -0
  4. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/PKG-INFO +1 -1
  5. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/openclaw.plugin.json +1 -1
  6. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/package.json +1 -1
  7. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/_common.py +40 -0
  8. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/analyze.py +4 -1
  9. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/certs.py +2 -1
  10. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/configcmd.py +4 -0
  11. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/overview.py +2 -1
  12. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/routes.py +4 -1
  13. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/services.py +4 -1
  14. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/pyproject.toml +1 -1
  15. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/server.json +2 -2
  16. proxy_aiops-0.9.3/tests/test_cli_audited.py +131 -0
  17. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/uv.lock +1 -1
  18. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/.github/workflows/mcp-publish.yml +0 -0
  19. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/.github/workflows/publish.yml +0 -0
  20. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/.gitignore +0 -0
  21. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/Dockerfile +0 -0
  22. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/LICENSE +0 -0
  23. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/README.md +0 -0
  24. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/RELEASE_NOTES.md +0 -0
  25. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/SECURITY.md +0 -0
  26. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/docs/VERIFICATION.md +0 -0
  27. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/glama.json +0 -0
  28. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/__init__.py +0 -0
  29. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/_shared.py +0 -0
  30. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/server.py +0 -0
  31. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/__init__.py +0 -0
  32. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/analysis.py +0 -0
  33. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/certs.py +0 -0
  34. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/configread.py +0 -0
  35. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/routes.py +0 -0
  36. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/services.py +0 -0
  37. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/status.py +0 -0
  38. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/traffic.py +0 -0
  39. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/undo.py +0 -0
  40. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/mcp_server/tools/writes.py +0 -0
  41. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/__init__.py +0 -0
  42. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/__init__.py +0 -0
  43. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/_root.py +0 -0
  44. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/doctor.py +0 -0
  45. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/init.py +0 -0
  46. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/secret.py +0 -0
  47. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/server.py +0 -0
  48. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/cli/undo.py +0 -0
  49. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/config.py +0 -0
  50. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/connection.py +0 -0
  51. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/doctor.py +0 -0
  52. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/__init__.py +0 -0
  53. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/audit.py +0 -0
  54. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/budget.py +0 -0
  55. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/decorators.py +0 -0
  56. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/outcome.py +0 -0
  57. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/paths.py +0 -0
  58. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/patterns.py +0 -0
  59. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/policy.py +0 -0
  60. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/sanitize.py +0 -0
  61. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/governance/undo.py +0 -0
  62. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/__init__.py +0 -0
  63. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/_util.py +0 -0
  64. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/analysis.py +0 -0
  65. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/certs.py +0 -0
  66. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/configread.py +0 -0
  67. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/overview.py +0 -0
  68. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/routes.py +0 -0
  69. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/services.py +0 -0
  70. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/status.py +0 -0
  71. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/traffic.py +0 -0
  72. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/ops/writes.py +0 -0
  73. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/platform.py +0 -0
  74. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/proxy_aiops/secretstore.py +0 -0
  75. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/skills/proxy-aiops/SKILL.md +0 -0
  76. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/skills/proxy-aiops/references/agent-guardrails.md +0 -0
  77. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/skills/proxy-aiops/references/capabilities.md +0 -0
  78. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/skills/proxy-aiops/references/cli-reference.md +0 -0
  79. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/skills/proxy-aiops/references/setup-guide.md +0 -0
  80. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/smithery.yaml +0 -0
  81. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/conftest.py +0 -0
  82. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_analysis.py +0 -0
  83. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_cli_governed_exit.py +0 -0
  84. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_cli_reads.py +0 -0
  85. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_cli_secret.py +0 -0
  86. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_cli_writes.py +0 -0
  87. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_connection.py +0 -0
  88. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_doctor.py +0 -0
  89. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_dpapi_version.py +0 -0
  90. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_gov_audit.py +0 -0
  91. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_gov_decorators.py +0 -0
  92. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_gov_patterns.py +0 -0
  93. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_gov_policy.py +0 -0
  94. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_governance_persistence.py +0 -0
  95. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_init.py +0 -0
  96. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_int_fidelity.py +0 -0
  97. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_mcp_read_tools.py +0 -0
  98. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_ops_extra.py +0 -0
  99. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_optional_fields.py +0 -0
  100. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_platform.py +0 -0
  101. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_reads.py +0 -0
  102. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_secretstore.py +0 -0
  103. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_self_lockout.py +0 -0
  104. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_smoke.py +0 -0
  105. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/tests/test_undo_executor.py +0 -0
  106. {proxy_aiops-0.9.2 → proxy_aiops-0.9.3}/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.2",
5
+ "version": "0.9.3",
6
6
  "description": "Governed Traefik + Caddy + HAProxy ops: routes, upstreams, certs, 5xx RCA. 28 tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "proxy-aiops==0.9.2",
8
+ "proxy-aiops==0.9.3",
9
9
  "proxy-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.9.3 — 2026-09-15
4
+
5
+ ### Fixed
6
+ - CLI commands that read the engine without calling an MCP tool now write an
7
+ audit row, as every MCP call does: they run through the same `@governed_tool`
8
+ harness, budget and runaway guard included. Before, an operator's CLI reads
9
+ left no trace in `audit.db`, contrary to the documented guarantee that MCP
10
+ and CLI are audited alike. A test fails if any engine command escapes it.
11
+
3
12
  ## v0.9.2 — 2026-09-12
4
13
 
5
14
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proxy-aiops
3
- Version: 0.9.2
3
+ Version: 0.9.3
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
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "proxy-aiops==0.9.2",
14
+ "proxy-aiops==0.9.3",
15
15
  "proxy-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/proxy-aiops",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Governed Traefik + Caddy + HAProxy ops: routes, upstreams, certs, 5xx RCA. 28 tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -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.2"
7
+ version = "0.9.3"
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.2",
10
+ "version": "0.9.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "proxy-aiops",
15
- "version": "0.9.2",
15
+ "version": "0.9.3",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -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"]
@@ -529,7 +529,7 @@ wheels = [
529
529
 
530
530
  [[package]]
531
531
  name = "proxy-aiops"
532
- version = "0.9.2"
532
+ version = "0.9.3"
533
533
  source = { editable = "." }
534
534
  dependencies = [
535
535
  { name = "cryptography" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes