postgres-aiops 0.11.0__tar.gz → 0.13.0__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.
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.claude-plugin/plugin.json +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.mcp.json +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/CHANGELOG.md +21 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/PKG-INFO +4 -2
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/README.md +3 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/SECURITY.md +19 -2
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/docs/VERIFICATION.md +4 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/openclaw.plugin.json +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/package.json +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_root.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/query.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/remediate.py +8 -8
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/undo.py +3 -3
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/audit.py +178 -34
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/decorators.py +78 -14
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/_shared.py +2 -2
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/server.py +4 -4
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/activity.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/analysis.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/indexes.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/queries.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/remediation.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/replication.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/server.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/tables.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/undo.py +4 -4
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/pyproject.toml +4 -4
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/server.json +2 -2
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/SKILL.md +3 -1
- postgres_aiops-0.13.0/tests/test_audit_guardrails.py +209 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_audited.py +3 -2
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_governed_exit.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_remediate.py +3 -3
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_writes.py +4 -4
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_connection_lost.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_audit.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_decorators.py +4 -6
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_patterns.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_governance_persistence.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_mcp_tools.py +18 -18
- postgres_aiops-0.13.0/tests/test_package_layout.py +62 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_smoke.py +15 -15
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_tool_annotations.py +17 -6
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_truncation.py +2 -2
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_undo_executor.py +5 -5
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_undo_replay.py +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/uv.lock +1 -1
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.gitignore +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/Dockerfile +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/LICENSE +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/glama.json +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_common.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/outcome.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/_annotations.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/remediation.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/smithery.yaml +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_config.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_policy.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_init.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_optional_fields.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_self_lockout.py +0 -0
- {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/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.
|
|
5
|
+
"version": "0.13.0",
|
|
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",
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.13.0 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Security and observability (BREAKING)
|
|
6
|
+
- **BREAKING:** Writes now require a durable audit intent before contacting the upstream platform. If the audit database cannot accept the intent, the write fails closed; a failed final audit update leaves an unresolved `pending` intent.
|
|
7
|
+
- Real writes now commit a durable `pending` audit intent before the upstream call. If audit storage is unavailable, the write does not start; a failed final update leaves a visible pending intent and blocks further writes in that process.
|
|
8
|
+
- Audit rows retain outcome metadata instead of full business responses. `AIOPS_WORKFLOW_ID` links calls across a workflow; optional `AIOPS_TELEMETRY=1` emits metadata-only JSON events and `AuditEngine.health()` reports audit failures and pending intents.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## v0.12.0 — 2026-10-15
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- The MCP server moved from a top-level `mcp_server` package to
|
|
15
|
+
`postgres_aiops.mcp_server`. Every AIops tool used to ship its own top-level
|
|
16
|
+
`mcp_server`, so installing two of them into one environment merged the two
|
|
17
|
+
into one package: `postgres-aiops-mcp` could start another tool's server, and
|
|
18
|
+
`postgres-aiops undo` wrote audit and undo rows into the other tool's home
|
|
19
|
+
directory. Installs through `uvx`, the Claude Code plugin or the OpenClaw
|
|
20
|
+
bundle were isolated and unaffected. The `postgres-aiops-mcp` command is
|
|
21
|
+
unchanged; only code that imported `mcp_server` directly must now import
|
|
22
|
+
`postgres_aiops.mcp_server`.
|
|
23
|
+
|
|
3
24
|
## v0.11.0 — 2026-10-15
|
|
4
25
|
|
|
5
26
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
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
|
|
@@ -18,6 +18,8 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
|
|
19
19
|
# Postgres AIops
|
|
20
20
|
|
|
21
|
+
> **Audit availability:** Real writes require a committed `pending` audit row and stop before contacting the platform if storage is unavailable. Reads and previews may continue with a warning; new audit rows store outcome summaries rather than full responses.
|
|
22
|
+
|
|
21
23
|
> **Disclaimer**: Community-maintained open-source project. **Not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and the elephant logo are trademarks of the PostgreSQL Community Association; all product/trademark names belong to their respective owners. MIT licensed.
|
|
22
24
|
|
|
23
25
|
Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
|
|
@@ -152,7 +154,7 @@ postgres-aiops-mcp
|
|
|
152
154
|
|
|
153
155
|
Every MCP tool passes through the bundled `@governed_tool` harness:
|
|
154
156
|
|
|
155
|
-
- **Audit** — every call (params,
|
|
157
|
+
- **Audit** — every call (params, outcome summary, status, duration, risk tier, and any
|
|
156
158
|
operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
|
|
157
159
|
(relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
|
|
158
160
|
path does — there is no unaudited entry point.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# Postgres AIops
|
|
4
4
|
|
|
5
|
+
> **Audit availability:** Real writes require a committed `pending` audit row and stop before contacting the platform if storage is unavailable. Reads and previews may continue with a warning; new audit rows store outcome summaries rather than full responses.
|
|
6
|
+
|
|
5
7
|
> **Disclaimer**: Community-maintained open-source project. **Not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and the elephant logo are trademarks of the PostgreSQL Community Association; all product/trademark names belong to their respective owners. MIT licensed.
|
|
6
8
|
|
|
7
9
|
Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
|
|
@@ -136,7 +138,7 @@ postgres-aiops-mcp
|
|
|
136
138
|
|
|
137
139
|
Every MCP tool passes through the bundled `@governed_tool` harness:
|
|
138
140
|
|
|
139
|
-
- **Audit** — every call (params,
|
|
141
|
+
- **Audit** — every call (params, outcome summary, status, duration, risk tier, and any
|
|
140
142
|
operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
|
|
141
143
|
(relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
|
|
142
144
|
path does — there is no unaudited entry point.
|
|
@@ -41,7 +41,7 @@ reports.
|
|
|
41
41
|
### Governed Operations
|
|
42
42
|
Every MCP tool runs through the bundled `@governed_tool` harness
|
|
43
43
|
(`postgres_aiops.governance`):
|
|
44
|
-
- **Audit** —
|
|
44
|
+
- **Audit** — calls logged to a local SQLite DB when storage is available under `~/.postgres-aiops/`
|
|
45
45
|
(relocatable via `POSTGRES_AIOPS_HOME`), agent-attributed, secret-redacted.
|
|
46
46
|
- **Token/runaway budget** — hard ceilings (`POSTGRES_MAX_TOOL_CALLS` /
|
|
47
47
|
`POSTGRES_MAX_TOOL_SECONDS` — the env-var names the bundled harness reads) plus
|
|
@@ -82,7 +82,7 @@ connection. No post-install scripts or background services.
|
|
|
82
82
|
## Static Analysis
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
uvx bandit -r postgres_aiops/
|
|
85
|
+
uvx bandit -r postgres_aiops/
|
|
86
86
|
uv run ruff check .
|
|
87
87
|
```
|
|
88
88
|
|
|
@@ -90,3 +90,20 @@ uv run ruff check .
|
|
|
90
90
|
|
|
91
91
|
The latest released version receives security fixes. This is a preview (0.x);
|
|
92
92
|
pin a version in production.
|
|
93
|
+
|
|
94
|
+
## Audit availability and telemetry
|
|
95
|
+
|
|
96
|
+
Real writes first commit a `pending` audit intent. If audit storage is unavailable,
|
|
97
|
+
the upstream write does not start. If finalization fails after an upstream call,
|
|
98
|
+
the intent stays visible and further writes in that process are blocked. Check
|
|
99
|
+
that the process is no longer executing and verify server state before calling
|
|
100
|
+
`AuditEngine.reconcile_pending(audit_id)` to mark the intent `unknown`.
|
|
101
|
+
|
|
102
|
+
Reads and dry-run previews continue when audit storage fails, with a warning
|
|
103
|
+
rather than a durable row. New audit rows store outcome summaries instead of
|
|
104
|
+
full business responses; older rows and rotated archives are not rewritten
|
|
105
|
+
automatically. Parameter redaction still follows each tool's
|
|
106
|
+
`sensitive_params`. `AIOPS_WORKFLOW_ID` supplies a correlation ID, and optional
|
|
107
|
+
`AIOPS_TELEMETRY=1` emits metadata-only JSON logs. `AuditEngine.health()`
|
|
108
|
+
reports availability, write blocking, failure count, and pending intent count.
|
|
109
|
+
On POSIX the rotation lock spans processes; on Windows it is process-local.
|
|
@@ -174,3 +174,7 @@ based analysis, replication reads, session termination, `REINDEX`, and
|
|
|
174
174
|
`pg_basebackup`) and re-run `repl status` / `repl slots` / `repl wal`.
|
|
175
175
|
- The analyses also accept **injected records**, so exported rows from a cluster
|
|
176
176
|
you cannot write to still exercise section 3 without any write access.
|
|
177
|
+
|
|
178
|
+
## Governance hardening — local synthetic verification (2026-10-30)
|
|
179
|
+
|
|
180
|
+
The shared governance tests exercise a durable `pending` row before a write, refusal when the audit DB cannot persist that row, a visible pending row after finalization failure, explicit reconciliation to `unknown`, async/preview parity, result minimization, and metadata-only telemetry with `AIOPS_WORKFLOW_ID`. The tests use a temporary SQLite DB and synthetic tools; they do not claim a new live upstream write was performed. Existing audit rows and rotated archives from earlier releases are not automatically scrubbed. A pending intent from another process is never auto-reconciled. On POSIX the rotation/intent lock spans processes; on Windows it currently protects only threads in this process.
|
|
@@ -70,7 +70,7 @@ def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> N
|
|
|
70
70
|
``@governed_tool``-wrapped MCP function, so each is audited on the same
|
|
71
71
|
governance path as MCP calls.
|
|
72
72
|
"""
|
|
73
|
-
from mcp_server.tools import queries as gov
|
|
73
|
+
from postgres_aiops.mcp_server.tools import queries as gov
|
|
74
74
|
|
|
75
75
|
if dry_run:
|
|
76
76
|
dry_run_preview(
|
|
@@ -32,7 +32,7 @@ def remediate_terminate(
|
|
|
32
32
|
dry_run: DryRunOption = False,
|
|
33
33
|
) -> None:
|
|
34
34
|
"""Terminate a backend (no undo; dry-run + confirm)."""
|
|
35
|
-
from mcp_server.tools import remediation as gov
|
|
35
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
36
36
|
|
|
37
37
|
if dry_run:
|
|
38
38
|
# Through the governed call: terminate_backend refuses this tool's own
|
|
@@ -54,7 +54,7 @@ def remediate_cancel(
|
|
|
54
54
|
dry_run: DryRunOption = False,
|
|
55
55
|
) -> None:
|
|
56
56
|
"""Cancel a backend's running query (no undo; dry-run + confirm)."""
|
|
57
|
-
from mcp_server.tools import remediation as gov
|
|
57
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
58
58
|
|
|
59
59
|
if dry_run:
|
|
60
60
|
dry_run_preview(
|
|
@@ -76,7 +76,7 @@ def remediate_vacuum(
|
|
|
76
76
|
dry_run: DryRunOption = False,
|
|
77
77
|
) -> None:
|
|
78
78
|
"""VACUUM a table (dry-run + confirm)."""
|
|
79
|
-
from mcp_server.tools import remediation as gov
|
|
79
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
80
80
|
|
|
81
81
|
if dry_run:
|
|
82
82
|
dry_run_preview(
|
|
@@ -97,7 +97,7 @@ def remediate_analyze(
|
|
|
97
97
|
dry_run: DryRunOption = False,
|
|
98
98
|
) -> None:
|
|
99
99
|
"""ANALYZE a table (dry-run + confirm)."""
|
|
100
|
-
from mcp_server.tools import remediation as gov
|
|
100
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
101
101
|
|
|
102
102
|
if dry_run:
|
|
103
103
|
dry_run_preview(
|
|
@@ -120,7 +120,7 @@ def remediate_create_index(
|
|
|
120
120
|
dry_run: DryRunOption = False,
|
|
121
121
|
) -> None:
|
|
122
122
|
"""Create an index (reversible; dry-run + confirm)."""
|
|
123
|
-
from mcp_server.tools import remediation as gov
|
|
123
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
124
124
|
|
|
125
125
|
if dry_run:
|
|
126
126
|
# Through the governed call: create_index validates the table/columns vs
|
|
@@ -146,7 +146,7 @@ def remediate_drop_index(
|
|
|
146
146
|
dry_run: DryRunOption = False,
|
|
147
147
|
) -> None:
|
|
148
148
|
"""Drop an index (reversible; captures the definition first; dry-run + confirm)."""
|
|
149
|
-
from mcp_server.tools import remediation as gov
|
|
149
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
150
150
|
|
|
151
151
|
if dry_run:
|
|
152
152
|
dry_run_preview(
|
|
@@ -168,7 +168,7 @@ def remediate_reindex(
|
|
|
168
168
|
dry_run: DryRunOption = False,
|
|
169
169
|
) -> None:
|
|
170
170
|
"""REINDEX an index/table/schema (dry-run + confirm)."""
|
|
171
|
-
from mcp_server.tools import remediation as gov
|
|
171
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
172
172
|
|
|
173
173
|
if dry_run:
|
|
174
174
|
dry_run_preview(
|
|
@@ -195,7 +195,7 @@ def remediate_set(
|
|
|
195
195
|
dry_run: DryRunOption = False,
|
|
196
196
|
) -> None:
|
|
197
197
|
"""ALTER SYSTEM SET a parameter, or --reset it (reversible; dry-run + confirm)."""
|
|
198
|
-
from mcp_server.tools import remediation as gov
|
|
198
|
+
from postgres_aiops.mcp_server.tools import remediation as gov
|
|
199
199
|
|
|
200
200
|
action = f"ALTER SYSTEM RESET {name}" if reset else f"ALTER SYSTEM SET {name} ="
|
|
201
201
|
if dry_run:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""``postgres-aiops undo`` — list recorded undo tokens and apply them.
|
|
2
2
|
|
|
3
3
|
Real execution is delegated to the ``@governed_tool``-wrapped functions in
|
|
4
|
-
``mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
|
|
4
|
+
``postgres_aiops.mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
|
|
5
5
|
path as any other write (the inverse tool it dispatches is itself re-gated).
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -38,7 +38,7 @@ def undo_list_cmd(
|
|
|
38
38
|
target: TargetOption = None,
|
|
39
39
|
) -> None:
|
|
40
40
|
"""List recorded, not-yet-applied undo tokens."""
|
|
41
|
-
from mcp_server.tools import undo as gov
|
|
41
|
+
from postgres_aiops.mcp_server.tools import undo as gov
|
|
42
42
|
|
|
43
43
|
console.print_json(json.dumps(gov.undo_list(limit=limit, target=target)))
|
|
44
44
|
|
|
@@ -49,7 +49,7 @@ def undo_apply_cmd(
|
|
|
49
49
|
undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
|
|
50
50
|
) -> None:
|
|
51
51
|
"""Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
|
|
52
|
-
from mcp_server.tools import undo as gov
|
|
52
|
+
from postgres_aiops.mcp_server.tools import undo as gov
|
|
53
53
|
|
|
54
54
|
if dry_run:
|
|
55
55
|
# undo_apply refuses an unknown or already-applied token, and refuses one
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Unified audit logging engine — all
|
|
1
|
+
"""Unified audit logging engine — all Monitoring 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.
|
|
@@ -12,12 +12,18 @@ import logging
|
|
|
12
12
|
import os
|
|
13
13
|
import sqlite3
|
|
14
14
|
import threading
|
|
15
|
+
from contextlib import contextmanager
|
|
15
16
|
from datetime import UTC, datetime, timedelta
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
19
20
|
from postgres_aiops.governance.paths import ops_path
|
|
20
21
|
|
|
22
|
+
try:
|
|
23
|
+
import fcntl
|
|
24
|
+
except ImportError: # Windows has no fcntl; the in-process lock still applies.
|
|
25
|
+
fcntl = None
|
|
26
|
+
|
|
21
27
|
_log = logging.getLogger("postgres-aiops.audit")
|
|
22
28
|
|
|
23
29
|
# Back-compat override hook. When None (default) the path resolves via
|
|
@@ -29,6 +35,26 @@ _DEFAULT_DB: Path | None = None
|
|
|
29
35
|
_MAX_DB_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
30
36
|
_MAX_ARCHIVES = 5
|
|
31
37
|
_BUSY_TIMEOUT_MS = 5000
|
|
38
|
+
_rotation_thread_lock = threading.RLock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def _rotation_guard(path: Path):
|
|
43
|
+
"""Serialize rotation and intent insertion across local processes."""
|
|
44
|
+
with _rotation_thread_lock:
|
|
45
|
+
fd = os.open(path.with_name("audit.lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
46
|
+
try:
|
|
47
|
+
if fcntl is not None:
|
|
48
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
if fcntl is not None:
|
|
52
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
53
|
+
os.close(fd)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuditUnavailable(RuntimeError):
|
|
57
|
+
"""A write was stopped because its durable audit intent could not be recorded."""
|
|
32
58
|
|
|
33
59
|
_CREATE_TABLE = """\
|
|
34
60
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
@@ -78,6 +104,8 @@ class AuditEngine:
|
|
|
78
104
|
else:
|
|
79
105
|
self._path = _DEFAULT_DB or ops_path("audit.db")
|
|
80
106
|
self._ok = False
|
|
107
|
+
self._write_blocked = False
|
|
108
|
+
self._write_failures = 0
|
|
81
109
|
try:
|
|
82
110
|
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
83
111
|
self._init_db()
|
|
@@ -90,6 +118,7 @@ class AuditEngine:
|
|
|
90
118
|
conn = self._connect()
|
|
91
119
|
conn.execute(_CREATE_TABLE)
|
|
92
120
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
121
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
93
122
|
self._migrate(conn)
|
|
94
123
|
conn.commit()
|
|
95
124
|
conn.close()
|
|
@@ -126,6 +155,7 @@ class AuditEngine:
|
|
|
126
155
|
def _connect(self) -> sqlite3.Connection:
|
|
127
156
|
conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
128
157
|
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
158
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
129
159
|
return conn
|
|
130
160
|
|
|
131
161
|
def log(
|
|
@@ -144,43 +174,128 @@ class AuditEngine:
|
|
|
144
174
|
rationale: str = "",
|
|
145
175
|
approved_by: str = "",
|
|
146
176
|
risk_tier: str = "",
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
177
|
+
strict: bool = False,
|
|
178
|
+
) -> int | None:
|
|
179
|
+
"""Append a row. Strict mode refuses writes if the intent cannot persist.
|
|
150
180
|
|
|
151
181
|
rationale / approved_by / risk_tier carry the accountability trail:
|
|
152
182
|
*why* a change was made, *who* signed off, and the *approval tier* the
|
|
153
183
|
policy engine assigned (none/confirm/dual/review).
|
|
154
184
|
"""
|
|
155
185
|
if not self._ok:
|
|
156
|
-
|
|
186
|
+
if strict:
|
|
187
|
+
self._write_blocked = True
|
|
188
|
+
self._write_failures += 1
|
|
189
|
+
raise AuditUnavailable("Audit storage is unavailable; write was not started")
|
|
190
|
+
self._write_failures += 1
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
with _rotation_guard(self._path):
|
|
194
|
+
self._maybe_rotate()
|
|
195
|
+
conn = self._connect()
|
|
196
|
+
try:
|
|
197
|
+
cursor = conn.execute(
|
|
198
|
+
_INSERT,
|
|
199
|
+
(
|
|
200
|
+
datetime.now(tz=UTC).isoformat(),
|
|
201
|
+
skill,
|
|
202
|
+
tool,
|
|
203
|
+
_safe_json(params),
|
|
204
|
+
_safe_json(_summarize_result(result)),
|
|
205
|
+
status,
|
|
206
|
+
duration_ms,
|
|
207
|
+
agent,
|
|
208
|
+
workflow_id,
|
|
209
|
+
user or _current_user(),
|
|
210
|
+
risk_level,
|
|
211
|
+
rationale,
|
|
212
|
+
approved_by,
|
|
213
|
+
risk_tier,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
conn.commit()
|
|
217
|
+
audit_id = cursor.lastrowid
|
|
218
|
+
finally:
|
|
219
|
+
conn.close()
|
|
220
|
+
_log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
|
|
221
|
+
return audit_id
|
|
222
|
+
except Exception:
|
|
223
|
+
self._write_failures += 1
|
|
224
|
+
_log.warning("Failed to write audit log", exc_info=True)
|
|
225
|
+
if strict:
|
|
226
|
+
self._write_blocked = True
|
|
227
|
+
raise AuditUnavailable("Audit storage is unavailable; write was not started") from None
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def begin_write(self, **metadata: Any) -> int:
|
|
231
|
+
"""Commit a durable intent before the caller sends an upstream write."""
|
|
232
|
+
if self._write_blocked:
|
|
233
|
+
raise AuditUnavailable("Audit storage failed; further writes are blocked")
|
|
234
|
+
audit_id = self.log(status="pending", strict=True, **metadata)
|
|
235
|
+
assert audit_id is not None
|
|
236
|
+
return audit_id
|
|
237
|
+
|
|
238
|
+
def finish_write(self, audit_id: int, *, result: Any, status: str, duration_ms: int) -> None:
|
|
239
|
+
"""Resolve a write intent; on failure leave a recoverable pending row."""
|
|
157
240
|
try:
|
|
158
|
-
self._maybe_rotate()
|
|
159
241
|
conn = self._connect()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
242
|
+
try:
|
|
243
|
+
cursor = conn.execute(
|
|
244
|
+
"UPDATE audit_log SET result = ?, status = ?, duration_ms = ? "
|
|
245
|
+
"WHERE id = ? AND status = 'pending'",
|
|
246
|
+
(_safe_json(_summarize_result(result)), status, duration_ms, audit_id),
|
|
247
|
+
)
|
|
248
|
+
if cursor.rowcount != 1:
|
|
249
|
+
raise RuntimeError(f"audit intent {audit_id} is missing")
|
|
250
|
+
conn.commit()
|
|
251
|
+
finally:
|
|
252
|
+
conn.close()
|
|
253
|
+
except Exception:
|
|
254
|
+
self._write_blocked = True
|
|
255
|
+
self._write_failures += 1
|
|
256
|
+
_log.warning("Failed to finalize audit intent %s", audit_id, exc_info=True)
|
|
257
|
+
raise AuditUnavailable(
|
|
258
|
+
f"Audit intent {audit_id} remains pending; operation may have completed"
|
|
259
|
+
) from None
|
|
260
|
+
|
|
261
|
+
def reconcile_pending(self, audit_id: int) -> bool:
|
|
262
|
+
"""Mark an orphaned intent unknown after an operator checks the process.
|
|
263
|
+
|
|
264
|
+
Never run this automatically: another process may still be executing
|
|
265
|
+
the write. The row remains visible until explicitly reconciled.
|
|
266
|
+
"""
|
|
267
|
+
conn = self._connect()
|
|
268
|
+
try:
|
|
269
|
+
cursor = conn.execute(
|
|
270
|
+
"UPDATE audit_log SET status = 'unknown', result = ? "
|
|
271
|
+
"WHERE id = ? AND status = 'pending'",
|
|
272
|
+
(_safe_json({"auditInterrupted": True}), audit_id),
|
|
178
273
|
)
|
|
179
274
|
conn.commit()
|
|
275
|
+
return cursor.rowcount == 1
|
|
276
|
+
finally:
|
|
180
277
|
conn.close()
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
278
|
+
|
|
279
|
+
def health(self) -> dict[str, Any]:
|
|
280
|
+
"""Safe local health counters for operators and telemetry exporters."""
|
|
281
|
+
pending: int | None = None
|
|
282
|
+
if self._ok:
|
|
283
|
+
try:
|
|
284
|
+
conn = self._connect()
|
|
285
|
+
try:
|
|
286
|
+
pending = conn.execute(
|
|
287
|
+
"SELECT COUNT(*) FROM audit_log WHERE status = 'pending'"
|
|
288
|
+
).fetchone()[0]
|
|
289
|
+
finally:
|
|
290
|
+
conn.close()
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
return {
|
|
294
|
+
"available": self._ok and pending is not None and not self._write_blocked,
|
|
295
|
+
"writeBlocked": self._write_blocked,
|
|
296
|
+
"auditWriteFailures": self._write_failures,
|
|
297
|
+
"pendingWrites": pending,
|
|
298
|
+
}
|
|
184
299
|
|
|
185
300
|
# ── Rotation ──────────────────────────────────────────────────────
|
|
186
301
|
|
|
@@ -191,20 +306,27 @@ class AuditEngine:
|
|
|
191
306
|
return
|
|
192
307
|
if self._path.stat().st_size < _MAX_DB_SIZE_BYTES:
|
|
193
308
|
return
|
|
309
|
+
pending_conn = self._connect()
|
|
310
|
+
try:
|
|
311
|
+
if pending_conn.execute(
|
|
312
|
+
"SELECT 1 FROM audit_log WHERE status = 'pending' LIMIT 1"
|
|
313
|
+
).fetchone():
|
|
314
|
+
return
|
|
315
|
+
finally:
|
|
316
|
+
pending_conn.close()
|
|
194
317
|
archive_name = self._path.with_suffix(
|
|
195
318
|
f".{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}.db"
|
|
196
319
|
)
|
|
197
320
|
# Checkpoint the WAL into the main file before renaming, otherwise
|
|
198
321
|
# un-checkpointed rows in audit.db-wal are silently lost (the
|
|
199
322
|
# sidecars belong to the OLD path and never follow the archive).
|
|
323
|
+
conn = self._connect()
|
|
200
324
|
try:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
except Exception:
|
|
207
|
-
_log.warning("WAL checkpoint before rotation failed", exc_info=True)
|
|
325
|
+
checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
326
|
+
if checkpoint is None or checkpoint[0]:
|
|
327
|
+
raise RuntimeError(f"WAL checkpoint incomplete: {checkpoint}")
|
|
328
|
+
finally:
|
|
329
|
+
conn.close()
|
|
208
330
|
self._path.rename(archive_name)
|
|
209
331
|
# Drop now-stale sidecars (empty after a successful checkpoint).
|
|
210
332
|
for suffix in ("-wal", "-shm"):
|
|
@@ -327,6 +449,28 @@ def _safe_json(obj: Any) -> str:
|
|
|
327
449
|
return json.dumps({"_raw": str(obj)})
|
|
328
450
|
|
|
329
451
|
|
|
452
|
+
def _summarize_result(result: Any) -> dict[str, Any]:
|
|
453
|
+
"""Persist only outcome metadata; never copy a tool's business payload."""
|
|
454
|
+
if isinstance(result, dict):
|
|
455
|
+
summary: dict[str, Any] = {"kind": "object"}
|
|
456
|
+
if result.get("error"):
|
|
457
|
+
summary["error"] = True
|
|
458
|
+
if result.get("outcomeUnknown") is True:
|
|
459
|
+
summary["outcomeUnknown"] = True
|
|
460
|
+
if result.get("_undo_id"):
|
|
461
|
+
summary["undoRecorded"] = True
|
|
462
|
+
if result.get("_pattern_id"):
|
|
463
|
+
summary["patternMatched"] = True
|
|
464
|
+
for key in ("truncated", "returned", "limit"):
|
|
465
|
+
value = result.get(key)
|
|
466
|
+
if type(value) in (bool, int):
|
|
467
|
+
summary[key] = value
|
|
468
|
+
return summary
|
|
469
|
+
if isinstance(result, (list, tuple)):
|
|
470
|
+
return {"kind": "list", "count": len(result)}
|
|
471
|
+
return {"kind": type(result).__name__}
|
|
472
|
+
|
|
473
|
+
|
|
330
474
|
def detect_agent() -> str:
|
|
331
475
|
"""Infer the calling AI agent from environment variables."""
|
|
332
476
|
if os.environ.get("CLAUDE_SESSION_ID") or os.environ.get("CLAUDE_CODE"):
|