postgres-aiops 0.10.2__tar.gz → 0.10.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 (111) hide show
  1. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.claude-plugin/plugin.json +1 -1
  2. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.mcp.json +1 -1
  3. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/CHANGELOG.md +9 -0
  4. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/PKG-INFO +1 -1
  5. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/openclaw.plugin.json +1 -1
  6. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/package.json +1 -1
  7. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/_common.py +40 -0
  8. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/activity.py +4 -1
  9. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/analyze.py +4 -0
  10. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/index.py +5 -0
  11. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/overview.py +2 -1
  12. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/query.py +3 -0
  13. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/replication.py +4 -1
  14. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/server.py +6 -1
  15. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/table.py +4 -0
  16. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/pyproject.toml +1 -1
  17. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/server.json +2 -2
  18. postgres_aiops-0.10.3/tests/test_cli_audited.py +131 -0
  19. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/uv.lock +1 -1
  20. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.github/workflows/mcp-publish.yml +0 -0
  21. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.github/workflows/publish.yml +0 -0
  22. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.gitignore +0 -0
  23. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/Dockerfile +0 -0
  24. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/LICENSE +0 -0
  25. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/README.md +0 -0
  26. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/RELEASE_NOTES.md +0 -0
  27. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/SECURITY.md +0 -0
  28. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/docs/VERIFICATION.md +0 -0
  29. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/glama.json +0 -0
  30. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/__init__.py +0 -0
  31. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/_shared.py +0 -0
  32. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/server.py +0 -0
  33. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/__init__.py +0 -0
  34. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/activity.py +0 -0
  35. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/analysis.py +0 -0
  36. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/indexes.py +0 -0
  37. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/queries.py +0 -0
  38. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/remediation.py +0 -0
  39. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/replication.py +0 -0
  40. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/server.py +0 -0
  41. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/tables.py +0 -0
  42. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/undo.py +0 -0
  43. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/__init__.py +0 -0
  44. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/__init__.py +0 -0
  45. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/_root.py +0 -0
  46. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/doctor.py +0 -0
  47. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/init.py +0 -0
  48. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/remediate.py +0 -0
  49. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/secret.py +0 -0
  50. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/undo.py +0 -0
  51. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/config.py +0 -0
  52. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/connection.py +0 -0
  53. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/doctor.py +0 -0
  54. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/__init__.py +0 -0
  55. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/audit.py +0 -0
  56. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/budget.py +0 -0
  57. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/decorators.py +0 -0
  58. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/outcome.py +0 -0
  59. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/paths.py +0 -0
  60. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/patterns.py +0 -0
  61. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/policy.py +0 -0
  62. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/sanitize.py +0 -0
  63. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/undo.py +0 -0
  64. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/__init__.py +0 -0
  65. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/_util.py +0 -0
  66. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/activity.py +0 -0
  67. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/analysis.py +0 -0
  68. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/indexes.py +0 -0
  69. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/overview.py +0 -0
  70. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/queries.py +0 -0
  71. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/remediation.py +0 -0
  72. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/replication.py +0 -0
  73. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/server.py +0 -0
  74. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/tables.py +0 -0
  75. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/secretstore.py +0 -0
  76. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/SKILL.md +0 -0
  77. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
  78. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/capabilities.md +0 -0
  79. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/cli-reference.md +0 -0
  80. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/setup-guide.md +0 -0
  81. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/smithery.yaml +0 -0
  82. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/conftest.py +0 -0
  83. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_analysis.py +0 -0
  84. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_governed_exit.py +0 -0
  85. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_reads.py +0 -0
  86. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_remediate.py +0 -0
  87. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_secret.py +0 -0
  88. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_writes.py +0 -0
  89. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_config.py +0 -0
  90. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_connection.py +0 -0
  91. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_connection_lost.py +0 -0
  92. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_connection_more.py +0 -0
  93. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_doctor.py +0 -0
  94. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_audit.py +0 -0
  95. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_decorators.py +0 -0
  96. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_patterns.py +0 -0
  97. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_policy.py +0 -0
  98. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_governance_persistence.py +0 -0
  99. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_init.py +0 -0
  100. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_mcp_tools.py +0 -0
  101. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_ops_more.py +0 -0
  102. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_optional_fields.py +0 -0
  103. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_reads.py +0 -0
  104. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_secretstore.py +0 -0
  105. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_secretstore_more.py +0 -0
  106. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_self_lockout.py +0 -0
  107. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_smoke.py +0 -0
  108. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_truncation.py +0 -0
  109. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_undo_executor.py +0 -0
  110. {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_undo_replay.py +0 -0
  111. {postgres_aiops-0.10.2 → postgres_aiops-0.10.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": "postgres-aiops",
4
4
  "displayName": "Postgres AIops",
5
- "version": "0.10.2",
5
+ "version": "0.10.3",
6
6
  "description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "postgres-aiops==0.10.2",
8
+ "postgres-aiops==0.10.3",
9
9
  "postgres-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.10.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.10.2 — 2026-09-12
4
13
 
5
14
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: postgres-aiops
3
- Version: 0.10.2
3
+ Version: 0.10.3
4
4
  Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis 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
- "postgres-aiops==0.10.2",
14
+ "postgres-aiops==0.10.3",
15
15
  "postgres-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/postgres-aiops",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -180,3 +180,43 @@ def double_confirm(action: str, resource: str) -> None:
180
180
  f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
181
181
  abort=True,
182
182
  )
183
+
184
+
185
+ def audited(fn: Callable) -> Callable:
186
+ """Audit a CLI command that reaches the engine without calling an MCP tool.
187
+
188
+ MCP tools write their audit row through ``@governed_tool``; a CLI command that
189
+ called the ops layer directly used to write none, so an operator's CLI reads
190
+ left no trail. This runs the command through the same harness: one audit row per
191
+ invocation (tool = the command function, params = its options), plus the budget
192
+ and runaway guard. ``typer.Exit`` is how a command ends: exit code 0 is audited
193
+ ``ok``, a non-zero code ``error``, and the exit is re-raised unchanged. Any other
194
+ exception is audited ``error`` and propagates to ``cli_errors`` as before.
195
+ """
196
+ import functools as _functools
197
+
198
+ from postgres_aiops.governance import governed_tool
199
+
200
+ @_functools.wraps(fn)
201
+ def body(*args: Any, **kwargs: Any) -> Any:
202
+ try:
203
+ fn(*args, **kwargs)
204
+ except typer.Exit as exc:
205
+ code = exc.exit_code
206
+ outcome: dict[str, Any] = {"_exitCode": code}
207
+ if code:
208
+ outcome["error"] = f"command exited with code {code}"
209
+ return outcome
210
+ return None
211
+
212
+ governed = governed_tool(risk_level="low")(body)
213
+
214
+ @_functools.wraps(fn)
215
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
216
+ result = governed(*args, **kwargs)
217
+ if isinstance(result, dict) and "_exitCode" in result:
218
+ raise typer.Exit(result["_exitCode"])
219
+ return None
220
+
221
+ wrapper._is_audited_cli = True # type: ignore[attr-defined]
222
+ return wrapper
@@ -7,7 +7,7 @@ from typing import Annotated
7
7
 
8
8
  import typer
9
9
 
10
- from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
10
+ from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
11
11
 
12
12
  activity_app = typer.Typer(
13
13
  name="activity",
@@ -18,6 +18,7 @@ activity_app = typer.Typer(
18
18
 
19
19
  @activity_app.command("list")
20
20
  @cli_errors
21
+ @audited
21
22
  def activity_list(
22
23
  state: Annotated[str | None, typer.Option("--state", help="Filter by state")] = None,
23
24
  target: TargetOption = None,
@@ -31,6 +32,7 @@ def activity_list(
31
32
 
32
33
  @activity_app.command("long")
33
34
  @cli_errors
35
+ @audited
34
36
  def activity_long(
35
37
  min_seconds: Annotated[int, typer.Option("--min-seconds", help="Minimum age")] = 60,
36
38
  target: TargetOption = None,
@@ -44,6 +46,7 @@ def activity_long(
44
46
 
45
47
  @activity_app.command("locks")
46
48
  @cli_errors
49
+ @audited
47
50
  def activity_locks(target: TargetOption = None) -> None:
48
51
  """List held/awaited locks."""
49
52
  from postgres_aiops.ops import activity as ops
@@ -8,6 +8,7 @@ import typer
8
8
 
9
9
  from postgres_aiops.cli._common import (
10
10
  TargetOption,
11
+ audited,
11
12
  cli_errors,
12
13
  get_connection,
13
14
  print_result,
@@ -22,6 +23,7 @@ analyze_app = typer.Typer(
22
23
 
23
24
  @analyze_app.command("slow-query")
24
25
  @cli_errors
26
+ @audited
25
27
  def analyze_slow_query(
26
28
  explain_sql: Annotated[str | None, typer.Option("--explain", help="SQL to EXPLAIN")] = None,
27
29
  limit: Annotated[int, typer.Option("--limit", help="Statements to consider")] = 20,
@@ -41,6 +43,7 @@ def analyze_slow_query(
41
43
 
42
44
  @analyze_app.command("bloat-vacuum")
43
45
  @cli_errors
46
+ @audited
44
47
  def analyze_bloat_vacuum(
45
48
  limit: Annotated[int, typer.Option("--limit", help="Tables to consider")] = 50,
46
49
  target: TargetOption = None,
@@ -58,6 +61,7 @@ def analyze_bloat_vacuum(
58
61
 
59
62
  @analyze_app.command("blocking")
60
63
  @cli_errors
64
+ @audited
61
65
  def analyze_blocking(target: TargetOption = None) -> None:
62
66
  """Build the blocking-lock chain and name the root blocker."""
63
67
  from postgres_aiops.ops import activity, analysis
@@ -9,6 +9,7 @@ import typer
9
9
 
10
10
  from postgres_aiops.cli._common import (
11
11
  TargetOption,
12
+ audited,
12
13
  cli_errors,
13
14
  console,
14
15
  get_connection,
@@ -24,6 +25,7 @@ index_app = typer.Typer(
24
25
 
25
26
  @index_app.command("unused")
26
27
  @cli_errors
28
+ @audited
27
29
  def index_unused(target: TargetOption = None) -> None:
28
30
  """Non-unique, non-primary indexes with zero scans."""
29
31
  from postgres_aiops.ops import indexes as ops
@@ -34,6 +36,7 @@ def index_unused(target: TargetOption = None) -> None:
34
36
 
35
37
  @index_app.command("missing")
36
38
  @cli_errors
39
+ @audited
37
40
  def index_missing(target: TargetOption = None) -> None:
38
41
  """Tables with heavy sequential scans (missing-index hints)."""
39
42
  from postgres_aiops.ops import indexes as ops
@@ -44,6 +47,7 @@ def index_missing(target: TargetOption = None) -> None:
44
47
 
45
48
  @index_app.command("bloat")
46
49
  @cli_errors
50
+ @audited
47
51
  def index_bloat(
48
52
  limit: Annotated[int, typer.Option("--limit", help="Indexes to inspect")] = 50,
49
53
  target: TargetOption = None,
@@ -57,6 +61,7 @@ def index_bloat(
57
61
 
58
62
  @index_app.command("invalid")
59
63
  @cli_errors
64
+ @audited
60
65
  def index_invalid(target: TargetOption = None) -> None:
61
66
  """Invalid and duplicate indexes."""
62
67
  from postgres_aiops.ops import indexes as ops
@@ -4,10 +4,11 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
 
7
- from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
7
+ from postgres_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 cluster health: version, connections, long queries, bloat, replication."""
13
14
  from postgres_aiops.ops import overview as ops
@@ -10,6 +10,7 @@ import typer
10
10
  from postgres_aiops.cli._common import (
11
11
  DryRunOption,
12
12
  TargetOption,
13
+ audited,
13
14
  checked,
14
15
  cli_errors,
15
16
  console,
@@ -28,6 +29,7 @@ query_app = typer.Typer(
28
29
 
29
30
  @query_app.command("top")
30
31
  @cli_errors
32
+ @audited
31
33
  def query_top(
32
34
  order_by: Annotated[
33
35
  str, typer.Option("--order-by", help="total_time|mean_time|calls|rows|io")
@@ -44,6 +46,7 @@ def query_top(
44
46
 
45
47
  @query_app.command("explain")
46
48
  @cli_errors
49
+ @audited
47
50
  def query_explain(
48
51
  sql: Annotated[str, typer.Argument(help="A single SQL statement to EXPLAIN")],
49
52
  analyze: Annotated[
@@ -6,7 +6,7 @@ import json
6
6
 
7
7
  import typer
8
8
 
9
- from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
9
+ from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
10
10
 
11
11
  repl_app = typer.Typer(
12
12
  name="repl",
@@ -17,6 +17,7 @@ repl_app = typer.Typer(
17
17
 
18
18
  @repl_app.command("status")
19
19
  @cli_errors
20
+ @audited
20
21
  def repl_status(target: TargetOption = None) -> None:
21
22
  """Connected standbys and replay lag."""
22
23
  from postgres_aiops.ops import replication as ops
@@ -27,6 +28,7 @@ def repl_status(target: TargetOption = None) -> None:
27
28
 
28
29
  @repl_app.command("slots")
29
30
  @cli_errors
31
+ @audited
30
32
  def repl_slots(target: TargetOption = None) -> None:
31
33
  """Replication slots (inactive slots flagged)."""
32
34
  from postgres_aiops.ops import replication as ops
@@ -37,6 +39,7 @@ def repl_slots(target: TargetOption = None) -> None:
37
39
 
38
40
  @repl_app.command("wal")
39
41
  @cli_errors
42
+ @audited
40
43
  def repl_wal(target: TargetOption = None) -> None:
41
44
  """WAL position, level and archiver health."""
42
45
  from postgres_aiops.ops import replication as ops
@@ -7,7 +7,7 @@ from typing import Annotated
7
7
 
8
8
  import typer
9
9
 
10
- from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
10
+ from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
11
11
 
12
12
  server_app = typer.Typer(
13
13
  name="server",
@@ -18,6 +18,7 @@ server_app = typer.Typer(
18
18
 
19
19
  @server_app.command("version")
20
20
  @cli_errors
21
+ @audited
21
22
  def server_version(target: TargetOption = None) -> None:
22
23
  """Server version, uptime and recovery state."""
23
24
  from postgres_aiops.ops import server as ops
@@ -28,6 +29,7 @@ def server_version(target: TargetOption = None) -> None:
28
29
 
29
30
  @server_app.command("settings")
30
31
  @cli_errors
32
+ @audited
31
33
  def server_settings(
32
34
  pattern: Annotated[str | None, typer.Argument(help="Name substring filter")] = None,
33
35
  target: TargetOption = None,
@@ -41,6 +43,7 @@ def server_settings(
41
43
 
42
44
  @server_app.command("extensions")
43
45
  @cli_errors
46
+ @audited
44
47
  def server_extensions(target: TargetOption = None) -> None:
45
48
  """Installed extensions."""
46
49
  from postgres_aiops.ops import server as ops
@@ -51,6 +54,7 @@ def server_extensions(target: TargetOption = None) -> None:
51
54
 
52
55
  @server_app.command("databases")
53
56
  @cli_errors
57
+ @audited
54
58
  def server_databases(target: TargetOption = None) -> None:
55
59
  """Databases with owner, encoding and size."""
56
60
  from postgres_aiops.ops import server as ops
@@ -61,6 +65,7 @@ def server_databases(target: TargetOption = None) -> None:
61
65
 
62
66
  @server_app.command("roles")
63
67
  @cli_errors
68
+ @audited
64
69
  def server_roles(target: TargetOption = None) -> None:
65
70
  """Roles and their attributes."""
66
71
  from postgres_aiops.ops import server as ops
@@ -8,6 +8,7 @@ import typer
8
8
 
9
9
  from postgres_aiops.cli._common import (
10
10
  TargetOption,
11
+ audited,
11
12
  cli_errors,
12
13
  get_connection,
13
14
  print_result,
@@ -22,6 +23,7 @@ table_app = typer.Typer(
22
23
 
23
24
  @table_app.command("sizes")
24
25
  @cli_errors
26
+ @audited
25
27
  def table_sizes(
26
28
  limit: Annotated[int, typer.Option("--limit", help="Tables to return")] = 20,
27
29
  target: TargetOption = None,
@@ -35,6 +37,7 @@ def table_sizes(
35
37
 
36
38
  @table_app.command("bloat")
37
39
  @cli_errors
40
+ @audited
38
41
  def table_bloat(
39
42
  limit: Annotated[int, typer.Option("--limit", help="Tables to inspect")] = 50,
40
43
  target: TargetOption = None,
@@ -48,6 +51,7 @@ def table_bloat(
48
51
 
49
52
  @table_app.command("autovacuum")
50
53
  @cli_errors
54
+ @audited
51
55
  def table_autovacuum(
52
56
  limit: Annotated[int, typer.Option("--limit", help="Tables to inspect")] = 50,
53
57
  target: TargetOption = None,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "postgres-aiops"
7
- version = "0.10.2"
7
+ version = "0.10.3"
8
8
  description = "Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis 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/Postgres-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.10.2",
10
+ "version": "0.10.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.10.2",
15
+ "version": "0.10.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 postgres_aiops.cli import app
23
+ from postgres_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 postgres_aiops.governance.audit as audit_mod
79
+ import postgres_aiops.governance.budget as budget_mod
80
+
81
+ monkeypatch.setenv("POSTGRES_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"]
@@ -492,7 +492,7 @@ wheels = [
492
492
 
493
493
  [[package]]
494
494
  name = "postgres-aiops"
495
- version = "0.10.2"
495
+ version = "0.10.3"
496
496
  source = { editable = "." }
497
497
  dependencies = [
498
498
  { name = "cryptography" },
File without changes