postgres-aiops 0.1.1__tar.gz → 0.2.1__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 (83) hide show
  1. postgres_aiops-0.2.1/.coverage +0 -0
  2. postgres_aiops-0.2.1/.github/workflows/mcp-publish.yml +23 -0
  3. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/CHANGELOG.md +38 -0
  4. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/PKG-INFO +1 -1
  5. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/remediation.py +19 -5
  6. postgres_aiops-0.2.1/postgres_aiops/__init__.py +14 -0
  7. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/_common.py +1 -1
  8. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/init.py +38 -0
  9. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/query.py +7 -4
  10. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/__init__.py +1 -1
  11. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/audit.py +1 -1
  12. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/budget.py +1 -1
  13. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/decorators.py +11 -3
  14. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/policy.py +24 -5
  15. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/sanitize.py +10 -4
  16. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/_util.py +1 -1
  17. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/remediation.py +30 -0
  18. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/secretstore.py +3 -1
  19. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/pyproject.toml +2 -1
  20. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/server.json +2 -2
  21. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/skills/postgres-aiops/SKILL.md +1 -0
  22. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/conftest.py +9 -0
  23. postgres_aiops-0.2.1/tests/test_cli_writes.py +83 -0
  24. postgres_aiops-0.2.1/tests/test_doctor.py +184 -0
  25. postgres_aiops-0.2.1/tests/test_governance_persistence.py +170 -0
  26. postgres_aiops-0.2.1/tests/test_init.py +122 -0
  27. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/test_smoke.py +9 -2
  28. postgres_aiops-0.2.1/tests/test_undo_replay.py +66 -0
  29. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/uv.lock +160 -1
  30. postgres_aiops-0.1.1/postgres_aiops/__init__.py +0 -9
  31. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/.github/workflows/publish.yml +0 -0
  32. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/.gitignore +0 -0
  33. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/LICENSE +0 -0
  34. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/README.md +0 -0
  35. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/RELEASE_NOTES.md +0 -0
  36. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/SECURITY.md +0 -0
  37. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/__init__.py +0 -0
  38. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/_shared.py +0 -0
  39. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/server.py +0 -0
  40. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/__init__.py +0 -0
  41. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/activity.py +0 -0
  42. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/analysis.py +0 -0
  43. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/indexes.py +0 -0
  44. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/queries.py +0 -0
  45. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/replication.py +0 -0
  46. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/server.py +0 -0
  47. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/mcp_server/tools/tables.py +0 -0
  48. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/__init__.py +0 -0
  49. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/_root.py +0 -0
  50. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/activity.py +0 -0
  51. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/analyze.py +0 -0
  52. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/doctor.py +0 -0
  53. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/index.py +0 -0
  54. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/overview.py +0 -0
  55. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/remediate.py +0 -0
  56. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/replication.py +0 -0
  57. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/secret.py +0 -0
  58. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/server.py +0 -0
  59. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/cli/table.py +0 -0
  60. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/config.py +0 -0
  61. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/connection.py +0 -0
  62. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/doctor.py +0 -0
  63. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/paths.py +0 -0
  64. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/patterns.py +0 -0
  65. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/governance/undo.py +0 -0
  66. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/__init__.py +0 -0
  67. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/activity.py +0 -0
  68. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/analysis.py +0 -0
  69. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/indexes.py +0 -0
  70. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/overview.py +0 -0
  71. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/queries.py +0 -0
  72. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/replication.py +0 -0
  73. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/server.py +0 -0
  74. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/postgres_aiops/ops/tables.py +0 -0
  75. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/capabilities.md +0 -0
  76. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/cli-reference.md +0 -0
  77. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/setup-guide.md +0 -0
  78. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/smithery.yaml +0 -0
  79. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/test_analysis.py +0 -0
  80. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/test_connection.py +0 -0
  81. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/test_reads.py +0 -0
  82. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/test_secretstore.py +0 -0
  83. {postgres_aiops-0.1.1 → postgres_aiops-0.2.1}/tests/test_writes.py +0 -0
Binary file
@@ -0,0 +1,23 @@
1
+ name: mcp-publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ permissions:
9
+ id-token: write
10
+ contents: read
11
+
12
+ jobs:
13
+ publish-mcp:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Install mcp-publisher
18
+ run: |
19
+ curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
20
+ - name: Login to MCP Registry (GitHub OIDC)
21
+ run: ./mcp-publisher login github-oidc
22
+ - name: Publish server.json
23
+ run: ./mcp-publisher publish
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.1 — 2026-07-16
4
+
5
+ ### Fixed
6
+ - **`secrets.enc` now follows `POSTGRES_AIOPS_HOME`** (secretstore hardcoded the real
7
+ home directory; config/audit/undo already relocated — found in live verification).
8
+ - **Audit fidelity**: failures sanitized into `{"error": ...}` results by the MCP error
9
+ layer are now audited as `status=error` (they previously read as `ok`, hiding failed
10
+ attempts from exception reports), and no undo is recorded for a call that failed.
11
+ - Undo replay fix: `create_index` accepts a `definition` (captured `pg_get_indexdef` statement, shape-validated), making `drop_index`'s undo descriptor replayable.
12
+
13
+ ### Tests
14
+ - `doctor` and the `init` wizard are now fully covered (previously ~10–20%); plus a
15
+ regression test for the sanitized-failure audit status.
16
+
17
+ ## v0.2.0 — 2026-07-13
18
+
19
+ Security-hardening release from a line-wide code review.
20
+
21
+ ### Changed (behavior)
22
+ - **Secure by default**: with no `rules.yaml`, high/critical operations now require a
23
+ named approver (`POSTGRES_AUDIT_APPROVED_BY`). A fresh install no longer allows
24
+ destructive writes unattended; `init` seeds a starter `rules.yaml` you can edit,
25
+ and an operator-authored rules file is honoured as-is.
26
+ - `__version__` is now single-sourced from package metadata (the previous release
27
+ self-reported a stale version string).
28
+ - Sanitize docs no longer overstate scope: it strips control/format characters and
29
+ truncates; semantic prompt-injection resistance must come from the consuming agent.
30
+
31
+ ### Fixed
32
+ - CLI `query reset` now executes through the governed MCP twin — the last CLI write that bypassed audit/undo recording.
33
+
34
+ ### Tests
35
+ - Governance persistence is now tested against REAL `audit.db`/`undo.db` files
36
+ (write → audit row + inverse undo row with captured prior state).
37
+ - The CLI confirmed-write path (dry-run / double-confirm / governed execution) is
38
+ covered end-to-end.
39
+ - `pytest-cov` added to the dev dependencies.
40
+
3
41
  ## v0.1.1
4
42
 
5
43
  - Fix: `POSTGRES_AIOPS_HOME` now also relocates `config.yaml` (was hardcoded to `~/.postgres-aiops`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postgres-aiops
3
- Version: 0.1.1
3
+ Version: 0.2.1
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 <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
@@ -162,12 +162,13 @@ def run_analyze(table: str, dry_run: bool = False, target: Optional[str] = None)
162
162
  @governed_tool(risk_level="medium", undo=_create_index_undo)
163
163
  @tool_errors("dict")
164
164
  def create_index(
165
- table: str,
166
- columns: list[str],
165
+ table: Optional[str] = None,
166
+ columns: Optional[list[str]] = None,
167
167
  name: Optional[str] = None,
168
168
  unique: bool = False,
169
169
  concurrently: bool = False,
170
170
  method: Optional[str] = None,
171
+ definition: Optional[str] = None,
171
172
  dry_run: bool = False,
172
173
  target: Optional[str] = None,
173
174
  ) -> dict:
@@ -175,21 +176,34 @@ def create_index(
175
176
 
176
177
  Supports CONCURRENTLY (non-blocking build). The created name is returned so
177
178
  the harness records an undo that drops exactly this index. Pass dry_run=True
178
- to preview.
179
+ to preview. Alternatively pass ``definition`` (a captured pg_get_indexdef
180
+ statement — this is how drop_index's undo descriptor replays) INSTEAD of
181
+ table/columns.
179
182
 
180
183
  Args:
181
- table: Table to index (optionally schema-qualified).
182
- columns: Column names to index.
184
+ table: Table to index (optionally schema-qualified). Required unless
185
+ ``definition`` is given.
186
+ columns: Column names to index. Required unless ``definition`` is given.
183
187
  name: Index name (auto-generated from table+columns when omitted).
184
188
  unique: Create a UNIQUE index.
185
189
  concurrently: Build with CONCURRENTLY (no table lock).
186
190
  method: Index method — btree/hash/gist/gin/brin/spgist (default btree).
191
+ definition: A full CREATE [UNIQUE] INDEX statement to execute verbatim
192
+ (shape-validated). Mutually exclusive with table/columns.
187
193
  dry_run: If True, preview without creating.
188
194
  target: Target name from config; omit for the default.
189
195
  """
196
+ if definition and (table or columns):
197
+ raise ValueError("Pass either definition OR table+columns, not both.")
198
+ if not definition and not (table and columns):
199
+ raise ValueError("create_index requires table+columns (or a definition).")
190
200
  conn = _get_connection(target)
191
201
  if dry_run:
202
+ if definition:
203
+ return {"dryRun": True, "wouldExecute": definition}
192
204
  return {"dryRun": True, "wouldCreate": {"table": table, "columns": columns, "name": name}}
205
+ if definition:
206
+ return ops.create_index_from_definition(conn, definition)
193
207
  return ops.create_index(
194
208
  conn, table, columns, name=name, unique=unique,
195
209
  concurrently=concurrently, method=method,
@@ -0,0 +1,14 @@
1
+ """postgres-aiops — governed PostgreSQL DBA operations for AI agents.
2
+
3
+ Standalone and self-contained: the governance harness (audit, token budget,
4
+ undo-token recording, graduated risk tiers, output sanitize) is bundled under
5
+ ``postgres_aiops.governance`` — this package has no external skill-family
6
+ dependency. Preview: not yet full-coverage.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("postgres-aiops")
13
+ except PackageNotFoundError: # running from an uninstalled source tree
14
+ __version__ = "0.0.0+unknown"
@@ -48,7 +48,7 @@ def cli_errors(fn: Callable) -> Callable:
48
48
  return wrapper
49
49
 
50
50
 
51
- def get_connection(target: str | None, config_path: Path | None = None):
51
+ def get_connection(target: str | None, config_path: Path | None = None) -> tuple[Any, Any]:
52
52
  """Return a (conn, config) tuple for the given target."""
53
53
  from postgres_aiops.config import load_config
54
54
  from postgres_aiops.connection import ConnectionManager
@@ -22,8 +22,45 @@ from postgres_aiops.config import (
22
22
  DEFAULT_SSLMODE,
23
23
  DEFAULT_USER,
24
24
  )
25
+ from postgres_aiops.governance.paths import ops_path
25
26
  from postgres_aiops.secretstore import SecretStore, resolve_master_password
26
27
 
28
+ # Starter policy: keeps the secure-by-default gate (high/critical writes need a
29
+ # named approver) explicit and editable, and shows the other rule kinds.
30
+ DEFAULT_RULES_YAML = """\
31
+ # postgres-aiops policy rules — hot-reloaded on change (no restart needed).
32
+ # Kinds: deny rules, maintenance_window, risk_tiers (graduated autonomy).
33
+
34
+ risk_tiers:
35
+ - name: high-risk-requires-approver
36
+ tier: dual
37
+ min_risk_level: high
38
+ reason: >-
39
+ High/critical writes need a named human approver — set
40
+ POSTGRES_AUDIT_APPROVED_BY (and POSTGRES_AUDIT_RATIONALE) before the call.
41
+
42
+ # deny:
43
+ # - name: no-prod-drops
44
+ # operations: ["drop_*"]
45
+ # environments: ["production"]
46
+ # reason: "Drops in production go through change management."
47
+
48
+ # maintenance_window:
49
+ # start: "22:00"
50
+ # end: "06:00"
51
+ """
52
+
53
+
54
+ def _write_default_rules() -> None:
55
+ """Seed a starter rules.yaml (only when none exists) so the policy layer
56
+ is explicit from day one; never overwrites an operator-authored file."""
57
+ rules_path = ops_path("rules.yaml")
58
+ if rules_path.exists():
59
+ return
60
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
61
+ rules_path.write_text(DEFAULT_RULES_YAML, "utf-8")
62
+ console.print(f"[green]✓ Wrote default policy rules:[/] {rules_path}")
63
+
27
64
 
28
65
  def _load_existing_targets() -> list[dict]:
29
66
  if not CONFIG_FILE.exists():
@@ -101,6 +138,7 @@ def init_cmd() -> None:
101
138
  if not typer.confirm("\nAdd another target?", default=False):
102
139
  break
103
140
 
141
+ _write_default_rules()
104
142
  console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
105
143
  console.print(
106
144
  "[dim]Tip: export POSTGRES_AIOPS_MASTER_PASSWORD=... in your shell profile "
@@ -59,12 +59,15 @@ def query_explain(
59
59
  @query_app.command("reset")
60
60
  @cli_errors
61
61
  def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> None:
62
- """Reset pg_stat_statements accumulators (irreversible; dry-run + confirm)."""
63
- from postgres_aiops.ops import queries as ops
62
+ """Reset pg_stat_statements accumulators (irreversible; dry-run + confirm).
63
+
64
+ Real execution is delegated to the ``@governed_tool``-wrapped MCP function
65
+ so the reset is audited on the same governance path as MCP calls.
66
+ """
67
+ from mcp_server.tools import queries as gov
64
68
 
65
69
  if dry_run:
66
70
  dry_run_print(operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
67
71
  return
68
72
  double_confirm("reset pg_stat_statements on", "this target")
69
- conn, _ = get_connection(target)
70
- console.print_json(json.dumps(ops.reset_query_stats(conn)))
73
+ console.print_json(json.dumps(gov.reset_query_stats(target=target)))
@@ -8,7 +8,7 @@ any external skill family — this package is its own copy of the harness:
8
8
  and undo-token recording.
9
9
  - unified SQLite audit log under ``~/.postgres-aiops/`` (override with
10
10
  ``POSTGRES_AIOPS_HOME``).
11
- - ``sanitize`` — prompt-injection defense for API-returned text.
11
+ - ``sanitize`` — control-character stripping + truncation for API-returned text.
12
12
 
13
13
  State lives under ``ops_home()`` (default ``~/.postgres-aiops``).
14
14
  """
@@ -1,4 +1,4 @@
1
- """Unified audit logging engine — all Endpoint skills write to a single SQLite database.
1
+ """Unified audit logging engine — all PostgreSQL skills write to a single SQLite database.
2
2
 
3
3
  Replaces 7 per-skill JSON Lines audit loggers with one shared ``~/.postgres-aiops/audit.db``.
4
4
  Framework-agnostic: works with Claude, Codex, local agents, or any MCP client.
@@ -43,7 +43,7 @@ _DEFAULT_RUNAWAY_WINDOW_SEC = 120
43
43
  class BudgetExceeded(Exception):
44
44
  """Raised when a tool call would exceed a budget / runaway limit.
45
45
 
46
- Subclasses nothing Endpoint-specific; the decorator wraps it into a
46
+ Subclasses nothing PostgreSQL-specific; the decorator wraps it into a
47
47
  PolicyResult so it shares the existing denial audit path. Kept as its own
48
48
  type (not raw PolicyDenied) so callers can catch budget stops distinctly.
49
49
  """
@@ -1,4 +1,4 @@
1
- """The ``@governed_tool`` decorator — mandatory wrapper for all Endpoint MCP tool functions.
1
+ """The ``@governed_tool`` decorator — mandatory wrapper for all PostgreSQL MCP tool functions.
2
2
 
3
3
  Responsibilities:
4
4
  1. Pre-check: evaluate policy rules (deny, maintenance window)
@@ -58,7 +58,7 @@ def governed_tool(
58
58
  sensitive_params: list[str] | None = None,
59
59
  undo: Any = None,
60
60
  ) -> Any:
61
- """Decorator for all Endpoint MCP tool functions.
61
+ """Decorator for all PostgreSQL MCP tool functions.
62
62
 
63
63
  Can be used with or without arguments::
64
64
 
@@ -317,6 +317,8 @@ def _record_undo(state: _CallState, result: Any) -> None:
317
317
  """
318
318
  if state.undo is None:
319
319
  return
320
+ if isinstance(result, dict) and result.get("error"):
321
+ return # sanitized failure — no change happened, so no inverse to record
320
322
  try:
321
323
  descriptor = state.undo(state.safe_params, result)
322
324
  except Exception: # noqa: BLE001 — undo computation must not fail the call
@@ -356,6 +358,12 @@ def _capture_error(state: _CallState, exc: Exception) -> None:
356
358
 
357
359
  def _finalize(state: _CallState) -> None:
358
360
  """Audit + circuit-breaker bookkeeping. Runs in the wrapper's finally."""
361
+ # A sanitized failure (@tool_errors converts exceptions into {"error": ...}
362
+ # dicts BEFORE this harness sees them) must not be audited as success —
363
+ # compliance exception reports are built from this status.
364
+ if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
365
+ state.status = "error"
366
+
359
367
  duration = int((time.time() - state.start) * 1000)
360
368
 
361
369
  # Accumulate wall-time toward the cumulative time budget (best-effort).
@@ -365,7 +373,7 @@ def _finalize(state: _CallState) -> None:
365
373
  pass
366
374
 
367
375
  # timeout_seconds is advisory: exceeding it logs a warning, no hard
368
- # cancellation (cancelling mid-flight Endpoint calls is worse).
376
+ # cancellation (cancelling mid-flight PostgreSQL calls is worse).
369
377
  if state.timeout_seconds and duration > state.timeout_seconds * 1000:
370
378
  _log.warning(
371
379
  "%s.%s took %dms — exceeded timeout_seconds=%d (advisory, not cancelled)",
@@ -1,4 +1,4 @@
1
- """Policy engine — rule-based access control for Endpoint MCP tools.
1
+ """Policy engine — rule-based access control for PostgreSQL MCP tools.
2
2
 
3
3
  Rules are loaded from ``~/.postgres-aiops/rules.yaml`` with hot-reload on file change.
4
4
  """
@@ -93,7 +93,8 @@ class PolicyEngine:
93
93
  self._load_rules()
94
94
 
95
95
  def _load_rules(self) -> None:
96
- """Load rules from YAML file. Missing file → empty rules (allow all)."""
96
+ """Load rules from YAML file. Missing file → empty rules (deny rules off;
97
+ high/critical still require an approver — see required_approval_tier)."""
97
98
  if not self._path.exists():
98
99
  self._rules = {}
99
100
  self._mtime = 0.0
@@ -113,7 +114,7 @@ class PolicyEngine:
113
114
  """Hot-reload if file changed."""
114
115
  if not self._path.exists():
115
116
  if self._rules:
116
- _log.warning("Policy rules file deleted: %s — clearing rules (allow all)", self._path)
117
+ _log.warning("Policy rules file deleted: %s — clearing rules (high/critical fall back to the default approver gate)", self._path)
117
118
  self._rules = {}
118
119
  self._mtime = 0.0
119
120
  return
@@ -156,7 +157,8 @@ class PolicyEngine:
156
157
 
157
158
  self._maybe_reload()
158
159
 
159
- # No rules file → allow everything
160
+ # No rules file → no deny rules (destructive ops are still gated by
161
+ # required_approval_tier's secure-by-default dual tier)
160
162
  if not self._rules:
161
163
  return PolicyResult(allowed=True, rule="no_rules")
162
164
 
@@ -225,11 +227,28 @@ class PolicyEngine:
225
227
  on operation glob / environment / resource tag / minimum risk and maps
226
228
  to a tier (none/confirm/dual/review). The FIRST matching, HIGHEST tier
227
229
  wins so a prod-tagged destructive op can't be down-graded by a looser
228
- rule listed earlier. No config → tier ``none`` (backward compatible).
230
+ rule listed earlier.
231
+
232
+ Secure by default: with NO rules file at all, high/critical operations
233
+ require a named approver (``dual`` tier) — a fresh install must not
234
+ allow destructive writes unattended. An operator-authored rules file
235
+ (even one without a ``risk_tiers`` block) is an explicit choice and is
236
+ honoured as-is (tier ``none``).
229
237
  """
238
+ if os.environ.get("POSTGRES_POLICY_DISABLED") == "1":
239
+ return TierDecision(tier="none", rule="policy_disabled")
230
240
  self._maybe_reload()
231
241
  tiers = self._rules.get("risk_tiers") if self._rules else None
232
242
  if not tiers:
243
+ if not self._rules and risk_level in ("high", "critical"):
244
+ return TierDecision(
245
+ tier="dual",
246
+ rule="default_high_risk",
247
+ reason=(
248
+ f"No rules file at {self._path} — high/critical operations require a named approver (POSTGRES_AUDIT_APPROVED_BY) by default. Author rules.yaml "
249
+ "(run the init wizard) to customize this."
250
+ ),
251
+ )
233
252
  return TierDecision(tier="none", rule="no_tiers")
234
253
 
235
254
  tags = _extract_tags(params)
@@ -1,6 +1,12 @@
1
- """Prompt-injection defense: strip control characters and truncate untrusted text.
1
+ """Output hygiene: strip control/format characters and truncate untrusted text.
2
2
 
3
- Consolidated from 22 duplicate ``_sanitize()`` implementations across 7 Endpoint skills.
3
+ Defense-in-depth against *encoding-level* tricks: control characters,
4
+ zero-width / bidi format characters, and payload-pushing padding. It does NOT
5
+ neutralize natural-language prompt injection — "ignore previous instructions"
6
+ passes through unchanged; semantic injection resistance must come from the
7
+ consuming agent's own prompt boundaries.
8
+
9
+ Consolidated from 22 duplicate ``_sanitize()`` implementations across the tool line.
4
10
  All skills should import from here instead of defining their own copy.
5
11
  """
6
12
 
@@ -19,14 +25,14 @@ def sanitize(text: str | None, max_len: int = 500) -> str:
19
25
  Removes:
20
26
  - C0/C1 control characters (except newline/tab)
21
27
  - Unicode Format characters (Cf): zero-width spaces, bidi overrides,
22
- zero-width joiners — used in prompt injection attacks
28
+ zero-width joiners — used to smuggle or disguise injected text
23
29
 
24
30
  Stripping happens BEFORE truncation so an attacker cannot push the real
25
31
  payload past the cut-off by padding with junk control characters.
26
32
  ``None`` sanitizes to ``""``.
27
33
 
28
34
  Args:
29
- text: Untrusted text from Endpoint SCALE API responses.
35
+ text: Untrusted text from PostgreSQL server responses.
30
36
  max_len: Maximum length after truncation. Default 500.
31
37
 
32
38
  Returns:
@@ -3,7 +3,7 @@
3
3
  Two jobs:
4
4
 
5
5
  * ``s`` — pass catalog/query text through the governance ``sanitize`` (bounded
6
- length, prompt-injection defense) before it reaches an agent.
6
+ length, control-character stripping) before it reaches an agent.
7
7
  * ``qualify`` / ``quote_ident`` — the ONLY sanctioned way to place an
8
8
  identifier (schema/table/index/column) into a statement that cannot be
9
9
  parameterised (DDL, ``VACUUM``, ``ANALYZE``, ``REINDEX``). Every part is
@@ -162,6 +162,36 @@ def create_index(
162
162
  }
163
163
 
164
164
 
165
+ # Shape gate for replaying a captured pg_get_indexdef statement. Server-generated
166
+ # (never user-composed), but validated anyway: single statement, CREATE INDEX only.
167
+ _INDEXDEF_RE = re.compile(
168
+ r"^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?"
169
+ r'("?[A-Za-z_][A-Za-z0-9_$]*"?)\s+ON\s+',
170
+ re.IGNORECASE,
171
+ )
172
+
173
+
174
+ def create_index_from_definition(conn: Any, definition: str) -> dict:
175
+ """[WRITE] Recreate an index from a captured ``pg_get_indexdef`` statement.
176
+
177
+ This is the replay path for ``drop_index``'s undo descriptor: the exact
178
+ definition captured before the drop is executed verbatim after a shape
179
+ check (single statement, must be CREATE [UNIQUE] INDEX ... ON ...).
180
+ """
181
+ stmt = (definition or "").strip().rstrip(";").strip()
182
+ if not stmt or ";" in stmt:
183
+ raise ValueError("definition must be a single CREATE INDEX statement.")
184
+ m = _INDEXDEF_RE.match(stmt)
185
+ if not m:
186
+ raise ValueError("definition must start with CREATE [UNIQUE] INDEX ... ON ...")
187
+ conn.execute(stmt) # nosec B608 — shape-validated pg_get_indexdef output
188
+ return {
189
+ "action": "create_index",
190
+ "index": m.group(1).strip('"'),
191
+ "fromDefinition": True,
192
+ }
193
+
194
+
165
195
  def drop_index(conn: Any, name: str, concurrently: bool = False) -> dict:
166
196
  """[WRITE] Drop an index. Reversible: captures pg_get_indexdef first so undo recreates it."""
167
197
  ident = qualify(name)
@@ -39,9 +39,11 @@ from cryptography.exceptions import InvalidKey
39
39
  from cryptography.fernet import Fernet, InvalidToken
40
40
  from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
41
41
 
42
+ from postgres_aiops.governance.paths import ops_home
43
+
42
44
  # ─── Tool-specific constants (change these three to vendor for another tool) ──
43
45
  APP_NAME = "postgres-aiops"
44
- CONFIG_DIR = Path.home() / ".postgres-aiops"
46
+ CONFIG_DIR = ops_home()
45
47
  MASTER_PASSWORD_ENV = "POSTGRES_AIOPS_MASTER_PASSWORD" # nosec B105 — env-var name
46
48
  # ──────────────────────────────────────────────────────────────────────────────
47
49
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "postgres-aiops"
7
- version = "0.1.1"
7
+ version = "0.2.1"
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"
@@ -22,6 +22,7 @@ dependencies = [
22
22
  [dependency-groups]
23
23
  dev = [
24
24
  "pytest>=8.0,<10.0",
25
+ "pytest-cov>=5.0,<8.0",
25
26
  "ruff>=0.5,<1.0",
26
27
  ]
27
28
 
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Postgres-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.1.1",
10
+ "version": "0.2.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.1.1",
15
+ "version": "0.2.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -105,6 +105,7 @@ Pass data straight to the analysis tools — `slow_query_rca(statements=[...])`,
105
105
 
106
106
  - Every tool is audited to `~/.postgres-aiops/audit.db` (relocatable via `POSTGRES_AIOPS_HOME`).
107
107
  - High-risk ops can require a named approver: set `POSTGRES_AUDIT_APPROVED_BY` and `POSTGRES_AUDIT_RATIONALE` (the env-var names the bundled harness reads).
108
+ - **Secure by default (v0.2.0+)**: with no `~/.postgres-aiops/rules.yaml`, high/critical operations are denied unless `POSTGRES_AUDIT_APPROVED_BY` names an approver (set `POSTGRES_AUDIT_RATIONALE` too). `postgres-aiops init` seeds a starter rules.yaml; an operator-authored rules file is honoured as-is.
108
109
  - Writes support `--dry-run` / `dry_run=True` and double confirmation at the CLI.
109
110
  - Reversible writes fetch the real before-state and record an inverse descriptor; irreversible ops (terminate/cancel, vacuum/analyze, reindex, reset stats) record prior stats only.
110
111
  - All values are bound query parameters; identifiers that cannot be parameterised are validated and quoted.
@@ -13,6 +13,15 @@ from typing import Any
13
13
  import pytest
14
14
 
15
15
 
16
+ @pytest.fixture(autouse=True)
17
+ def _default_approver(monkeypatch):
18
+ """The policy layer is secure-by-default: with no rules.yaml, high/critical
19
+ governed calls require a named approver. Tests exercising tool behavior
20
+ are not about that gate, so record a synthetic approver globally; the
21
+ governance-persistence tests remove it to test the gate itself."""
22
+ monkeypatch.setenv("POSTGRES_AUDIT_APPROVED_BY", "pytest")
23
+
24
+
16
25
  class FakePg:
17
26
  def __init__(
18
27
  self,
@@ -0,0 +1,83 @@
1
+ """CLI confirmed-write path — past dry-run, through governance, onto disk.
2
+
3
+ The CLI write commands delegate real execution to the ``@governed_tool``
4
+ functions in ``mcp_server.tools``. These tests drive a write command PAST the
5
+ dry-run branch and the double-confirm prompts and assert the call really went
6
+ through the governed path (audit row on disk) — the regression test for the
7
+ "CLI writes were unaudited" line-wide fix.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sqlite3
13
+
14
+ import pytest
15
+ from typer.testing import CliRunner
16
+
17
+ import postgres_aiops.governance.audit as audit_mod
18
+ import postgres_aiops.governance.policy as policy_mod
19
+ import postgres_aiops.governance.undo as undo_mod
20
+
21
+
22
+ @pytest.fixture
23
+ def gov_home(tmp_path, monkeypatch):
24
+ monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
25
+ audit_mod.reset_engine()
26
+ policy_mod.reset_policy_engine()
27
+ undo_mod.reset_undo_store()
28
+ yield tmp_path
29
+ audit_mod.reset_engine()
30
+ policy_mod.reset_policy_engine()
31
+ undo_mod.reset_undo_store()
32
+
33
+
34
+ def _audit_tools(db_path) -> list[str]:
35
+ conn = sqlite3.connect(db_path)
36
+ try:
37
+ return [r[0] for r in conn.execute("SELECT tool FROM audit_log ORDER BY id")]
38
+ finally:
39
+ conn.close()
40
+
41
+
42
+ @pytest.mark.unit
43
+ def test_cli_query_reset_dry_run_makes_no_call_and_no_audit(gov_home, monkeypatch, fake_pg):
44
+ from postgres_aiops.cli import app
45
+
46
+ fake = fake_pg()
47
+ import mcp_server.tools.queries as gov_queries
48
+
49
+ monkeypatch.setattr(gov_queries, "_get_connection", lambda target=None: fake)
50
+ result = CliRunner().invoke(app, ["query", "reset", "--dry-run"])
51
+ assert result.exit_code == 0
52
+ assert "DRY-RUN" in result.output
53
+ assert fake.executed == [] and fake.queried == []
54
+ assert not (gov_home / "audit.db").exists()
55
+
56
+
57
+ @pytest.mark.unit
58
+ def test_cli_query_reset_confirmed_goes_through_governance(gov_home, monkeypatch, fake_pg):
59
+ """Confirmed CLI write must execute via the governed twin: the SQL runs
60
+ AND an audit row lands in audit.db (this is what the reroute fix bought)."""
61
+ from postgres_aiops.cli import app
62
+
63
+ fake = fake_pg(responses={"pg_stat_statements_reset": [{"reset": True}]})
64
+ import mcp_server.tools.queries as gov_queries
65
+
66
+ monkeypatch.setattr(gov_queries, "_get_connection", lambda target=None: fake)
67
+ result = CliRunner().invoke(app, ["query", "reset"], input="y\ny\n")
68
+ assert result.exit_code == 0, result.output
69
+ assert _audit_tools(gov_home / "audit.db") == ["reset_query_stats"]
70
+
71
+
72
+ @pytest.mark.unit
73
+ def test_cli_query_reset_aborts_without_double_confirm(gov_home, monkeypatch, fake_pg):
74
+ from postgres_aiops.cli import app
75
+
76
+ fake = fake_pg()
77
+ import mcp_server.tools.queries as gov_queries
78
+
79
+ monkeypatch.setattr(gov_queries, "_get_connection", lambda target=None: fake)
80
+ result = CliRunner().invoke(app, ["query", "reset"], input="y\nn\n")
81
+ assert result.exit_code != 0
82
+ assert fake.executed == [] and fake.queried == []
83
+ assert not (gov_home / "audit.db").exists()