postgres-aiops 0.4.0__tar.gz → 0.6.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.4.0 → postgres_aiops-0.6.0}/CHANGELOG.md +20 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/PKG-INFO +30 -42
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/README.md +28 -40
- postgres_aiops-0.6.0/RELEASE_NOTES.md +33 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/SECURITY.md +6 -3
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/docs/VERIFICATION.md +10 -7
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/_shared.py +26 -4
- postgres_aiops-0.6.0/mcp_server/server.py +51 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/remediation.py +19 -1
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/undo.py +42 -11
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/_common.py +32 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/init.py +0 -38
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/query.py +7 -4
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/remediate.py +54 -30
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/undo.py +10 -4
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/config.py +14 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/connection.py +23 -1
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/__init__.py +12 -6
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/budget.py +19 -22
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/decorators.py +97 -89
- postgres_aiops-0.6.0/postgres_aiops/governance/outcome.py +104 -0
- postgres_aiops-0.6.0/postgres_aiops/governance/policy.py +67 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/sanitize.py +11 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/undo.py +52 -13
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/queries.py +43 -7
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/remediation.py +114 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/pyproject.toml +2 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/server.json +2 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/skills/postgres-aiops/SKILL.md +13 -7
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/skills/postgres-aiops/references/agent-guardrails.md +15 -7
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/skills/postgres-aiops/references/capabilities.md +1 -1
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/skills/postgres-aiops/references/cli-reference.md +8 -5
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/skills/postgres-aiops/references/setup-guide.md +3 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/conftest.py +6 -4
- postgres_aiops-0.6.0/tests/test_cli_remediate.py +220 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_cli_writes.py +36 -3
- postgres_aiops-0.6.0/tests/test_connection_lost.py +69 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_gov_decorators.py +11 -28
- postgres_aiops-0.6.0/tests/test_gov_policy.py +56 -0
- postgres_aiops-0.6.0/tests/test_governance_persistence.py +350 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_init.py +7 -18
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_optional_fields.py +4 -1
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_reads.py +33 -0
- postgres_aiops-0.6.0/tests/test_self_lockout.py +195 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_smoke.py +61 -5
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_undo_executor.py +38 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/uv.lock +1 -1
- postgres_aiops-0.4.0/RELEASE_NOTES.md +0 -48
- postgres_aiops-0.4.0/mcp_server/server.py +0 -76
- postgres_aiops-0.4.0/postgres_aiops/governance/policy.py +0 -430
- postgres_aiops-0.4.0/postgres_aiops/governance/readonly.py +0 -49
- postgres_aiops-0.4.0/tests/test_cli_remediate.py +0 -84
- postgres_aiops-0.4.0/tests/test_gov_policy.py +0 -415
- postgres_aiops-0.4.0/tests/test_governance_persistence.py +0 -170
- postgres_aiops-0.4.0/tests/test_readonly.py +0 -145
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/.gitignore +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/LICENSE +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/smithery.yaml +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_config.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_gov_audit.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_gov_patterns.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_mcp_tools.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_truncation.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_undo_replay.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.6.0}/tests/test_writes.py +0 -0
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.6.0 — 2026-07-21
|
|
4
|
+
|
|
5
|
+
### Changed (BREAKING)
|
|
6
|
+
- **Removed the authorization layer** — read-only mode, the approver gate, and rules.yaml deny are gone. The skill no longer decides read vs write; that is the agent's judgement or the connecting account's permissions. `<PREFIX>_READ_ONLY` now has no effect (a startup warning is logged); `<PREFIX>_AUDIT_APPROVED_BY`/`_RATIONALE` are optional audit annotations.
|
|
7
|
+
- The retained guarantee is **unbypassable audit over MCP and CLI alike** — no unaudited entry point. Harness = audit + runaway safety guard + undo + sanitize; `risk_level` is a descriptive audit label, not a gate.
|
|
8
|
+
|
|
9
|
+
See RELEASE_NOTES.md for tool-specific changes.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## v0.5.0 — 2026-07-20
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **`terminate_backend` / `cancel_query` refuse this tool's own backend**, and `update_setting` refuses the postmaster settings that would strand the undo at the next restart (`listen_addresses`, `port`, `max_connections`, `ssl`, `hba_file`, …).
|
|
16
|
+
- A connection lost **mid-statement** now raises a dedicated error and is audited as `unknown`, not `error`..
|
|
17
|
+
- Harness: a write whose response is lost is audited `status=unknown`, not `error` — it may have taken effect. Undo tokens gain `effectVerified` (undo.db migrated in place).
|
|
18
|
+
- Harness: a dry-run no longer records an undo token, and no longer requires a named approver. Guards now run on the preview path.
|
|
19
|
+
- Truncated strings end in an ellipsis instead of being cut silently; error messages are capped at 800 chars, not 300.
|
|
20
|
+
|
|
21
|
+
See RELEASE_NOTES.md for the full detail.
|
|
22
|
+
|
|
3
23
|
## v0.3.0 — 2026-07-17
|
|
4
24
|
|
|
5
25
|
### Added
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
-
Author-email:
|
|
5
|
+
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.11
|
|
@@ -22,8 +22,8 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
|
|
23
23
|
Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
|
|
24
24
|
**psycopg 3** and reading the system catalogs and `pg_stat_*` views — with a
|
|
25
|
-
**built-in governance harness**: unified audit log,
|
|
26
|
-
budget guard, undo-token recording, and
|
|
25
|
+
**built-in governance harness**: unified audit log, token/runaway
|
|
26
|
+
budget guard, undo-token recording, and descriptive risk-tier labels.
|
|
27
27
|
Beyond the mock test suite, the reads, a governed write, and its undo have been
|
|
28
28
|
exercised against a live PostgreSQL 16.14 instance — see [`docs/VERIFICATION.md`](docs/VERIFICATION.md).
|
|
29
29
|
|
|
@@ -70,40 +70,25 @@ The flagship analyses accept injected records for pure/offline analysis, or pull
|
|
|
70
70
|
live from a configured target. `top_queries`/`slow_query_rca` require the
|
|
71
71
|
`pg_stat_statements` extension; the read role should have `pg_monitor`.
|
|
72
72
|
|
|
73
|
-
##
|
|
73
|
+
## What this tool does, and does not, decide
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
It delivers PostgreSQL DBA operations — reads and writes — accurately and
|
|
76
|
+
efficiently, and records every one of them. It does **not** decide whether a
|
|
77
|
+
write is allowed to happen. That is the agent's judgement, or the permission of
|
|
78
|
+
the account you connect it with: connect with a PostgreSQL role that has no
|
|
79
|
+
write privileges (a read-only role, or one without INSERT/UPDATE/DELETE/DDL),
|
|
80
|
+
and the writes fail at the server — the place that actually owns the
|
|
81
|
+
permission.
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
lists **25 tools instead of 35** — the writes are not hidden, not
|
|
84
|
-
gated behind a flag, and not merely refused when called. They are absent from
|
|
85
|
-
the session. A model cannot invoke a tool it was never offered, and cannot be
|
|
86
|
-
argued into one.
|
|
87
|
-
|
|
88
|
-
That distinction is the whole point. A tool that exists but refuses still invites
|
|
89
|
-
retry loops and "I'll describe the call instead" behaviour from smaller models,
|
|
90
|
-
and it leaves a reviewer trusting a promise. An absent tool is a fact you can
|
|
91
|
-
check: connect, list the tools, and see that the writes are not there.
|
|
92
|
-
|
|
93
|
-
Enforcement is two layers deep, so the switch cannot be sidestepped by changing
|
|
94
|
-
entry point:
|
|
95
|
-
|
|
96
|
-
| Layer | What it does | Covers |
|
|
97
|
-
|---|---|---|
|
|
98
|
-
| `@governed_tool` harness | refuses every non-read operation outright | MCP, CLI, and in-process callers |
|
|
99
|
-
| MCP registration | write tools are removed from `list_tools()` | anything speaking MCP |
|
|
100
|
-
|
|
101
|
-
Read operations are unaffected, and every call is still audited to
|
|
102
|
-
`~/.postgres-aiops/audit.db`.
|
|
83
|
+
So there is no read-only switch, no policy file, no approval gate to configure.
|
|
84
|
+
The one thing the tool guarantees is that nothing is silent: **every call, over
|
|
85
|
+
MCP and over the CLI alike, lands an audit row** in `~/.postgres-aiops/audit.db`,
|
|
86
|
+
and destructive writes still capture their before-state and record an inverse
|
|
87
|
+
where one exists.
|
|
103
88
|
|
|
104
|
-
>
|
|
105
|
-
>
|
|
106
|
-
>
|
|
89
|
+
> Each tool declares a `risk_level`, carried into the audit row as a descriptive
|
|
90
|
+
> tier (none/confirm/review) — so a reviewer can see at a glance that a row was
|
|
91
|
+
> a high-risk delete. It is a label, not a gate.
|
|
107
92
|
|
|
108
93
|
Running a smaller / local model? See
|
|
109
94
|
[agent-guardrails.md](skills/postgres-aiops/references/agent-guardrails.md) — it lists
|
|
@@ -132,15 +117,18 @@ postgres-aiops-mcp
|
|
|
132
117
|
|
|
133
118
|
Every MCP tool passes through the bundled `@governed_tool` harness:
|
|
134
119
|
|
|
135
|
-
- **Audit** — every call (params, result, status, duration, risk tier,
|
|
136
|
-
rationale) is logged to `~/.postgres-aiops/audit.db`
|
|
137
|
-
`POSTGRES_AIOPS_HOME`).
|
|
138
|
-
|
|
139
|
-
- **
|
|
140
|
-
|
|
141
|
-
|
|
120
|
+
- **Audit** — every call (params, result, status, duration, risk tier, and any
|
|
121
|
+
operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
|
|
122
|
+
(relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
|
|
123
|
+
path does — there is no unaudited entry point.
|
|
124
|
+
- **Runaway guard** — a safety backstop, not an authorization gate: the same call
|
|
125
|
+
hammered in a tight loop trips a circuit breaker. Disable with
|
|
126
|
+
`POSTGRES_RUNAWAY_MAX=0`; optional hard ceilings via `POSTGRES_MAX_TOOL_CALLS` /
|
|
127
|
+
`POSTGRES_MAX_TOOL_SECONDS`.
|
|
142
128
|
- **Undo recording** — reversible writes record an inverse descriptor built from
|
|
143
129
|
the fetched before-state.
|
|
130
|
+
- **Risk tier** — a descriptive label on the audit row derived from `risk_level`;
|
|
131
|
+
it gates nothing.
|
|
144
132
|
|
|
145
133
|
## Scope
|
|
146
134
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
|
|
8
8
|
**psycopg 3** and reading the system catalogs and `pg_stat_*` views — with a
|
|
9
|
-
**built-in governance harness**: unified audit log,
|
|
10
|
-
budget guard, undo-token recording, and
|
|
9
|
+
**built-in governance harness**: unified audit log, token/runaway
|
|
10
|
+
budget guard, undo-token recording, and descriptive risk-tier labels.
|
|
11
11
|
Beyond the mock test suite, the reads, a governed write, and its undo have been
|
|
12
12
|
exercised against a live PostgreSQL 16.14 instance — see [`docs/VERIFICATION.md`](docs/VERIFICATION.md).
|
|
13
13
|
|
|
@@ -54,40 +54,25 @@ The flagship analyses accept injected records for pure/offline analysis, or pull
|
|
|
54
54
|
live from a configured target. `top_queries`/`slow_query_rca` require the
|
|
55
55
|
`pg_stat_statements` extension; the read role should have `pg_monitor`.
|
|
56
56
|
|
|
57
|
-
##
|
|
57
|
+
## What this tool does, and does not, decide
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
It delivers PostgreSQL DBA operations — reads and writes — accurately and
|
|
60
|
+
efficiently, and records every one of them. It does **not** decide whether a
|
|
61
|
+
write is allowed to happen. That is the agent's judgement, or the permission of
|
|
62
|
+
the account you connect it with: connect with a PostgreSQL role that has no
|
|
63
|
+
write privileges (a read-only role, or one without INSERT/UPDATE/DELETE/DDL),
|
|
64
|
+
and the writes fail at the server — the place that actually owns the
|
|
65
|
+
permission.
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
lists **25 tools instead of 35** — the writes are not hidden, not
|
|
68
|
-
gated behind a flag, and not merely refused when called. They are absent from
|
|
69
|
-
the session. A model cannot invoke a tool it was never offered, and cannot be
|
|
70
|
-
argued into one.
|
|
71
|
-
|
|
72
|
-
That distinction is the whole point. A tool that exists but refuses still invites
|
|
73
|
-
retry loops and "I'll describe the call instead" behaviour from smaller models,
|
|
74
|
-
and it leaves a reviewer trusting a promise. An absent tool is a fact you can
|
|
75
|
-
check: connect, list the tools, and see that the writes are not there.
|
|
76
|
-
|
|
77
|
-
Enforcement is two layers deep, so the switch cannot be sidestepped by changing
|
|
78
|
-
entry point:
|
|
79
|
-
|
|
80
|
-
| Layer | What it does | Covers |
|
|
81
|
-
|---|---|---|
|
|
82
|
-
| `@governed_tool` harness | refuses every non-read operation outright | MCP, CLI, and in-process callers |
|
|
83
|
-
| MCP registration | write tools are removed from `list_tools()` | anything speaking MCP |
|
|
84
|
-
|
|
85
|
-
Read operations are unaffected, and every call is still audited to
|
|
86
|
-
`~/.postgres-aiops/audit.db`.
|
|
67
|
+
So there is no read-only switch, no policy file, no approval gate to configure.
|
|
68
|
+
The one thing the tool guarantees is that nothing is silent: **every call, over
|
|
69
|
+
MCP and over the CLI alike, lands an audit row** in `~/.postgres-aiops/audit.db`,
|
|
70
|
+
and destructive writes still capture their before-state and record an inverse
|
|
71
|
+
where one exists.
|
|
87
72
|
|
|
88
|
-
>
|
|
89
|
-
>
|
|
90
|
-
>
|
|
73
|
+
> Each tool declares a `risk_level`, carried into the audit row as a descriptive
|
|
74
|
+
> tier (none/confirm/review) — so a reviewer can see at a glance that a row was
|
|
75
|
+
> a high-risk delete. It is a label, not a gate.
|
|
91
76
|
|
|
92
77
|
Running a smaller / local model? See
|
|
93
78
|
[agent-guardrails.md](skills/postgres-aiops/references/agent-guardrails.md) — it lists
|
|
@@ -116,15 +101,18 @@ postgres-aiops-mcp
|
|
|
116
101
|
|
|
117
102
|
Every MCP tool passes through the bundled `@governed_tool` harness:
|
|
118
103
|
|
|
119
|
-
- **Audit** — every call (params, result, status, duration, risk tier,
|
|
120
|
-
rationale) is logged to `~/.postgres-aiops/audit.db`
|
|
121
|
-
`POSTGRES_AIOPS_HOME`).
|
|
122
|
-
|
|
123
|
-
- **
|
|
124
|
-
|
|
125
|
-
|
|
104
|
+
- **Audit** — every call (params, result, status, duration, risk tier, and any
|
|
105
|
+
operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
|
|
106
|
+
(relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
|
|
107
|
+
path does — there is no unaudited entry point.
|
|
108
|
+
- **Runaway guard** — a safety backstop, not an authorization gate: the same call
|
|
109
|
+
hammered in a tight loop trips a circuit breaker. Disable with
|
|
110
|
+
`POSTGRES_RUNAWAY_MAX=0`; optional hard ceilings via `POSTGRES_MAX_TOOL_CALLS` /
|
|
111
|
+
`POSTGRES_MAX_TOOL_SECONDS`.
|
|
126
112
|
- **Undo recording** — reversible writes record an inverse descriptor built from
|
|
127
113
|
the fetched before-state.
|
|
114
|
+
- **Risk tier** — a descriptive label on the audit row derived from `risk_level`;
|
|
115
|
+
it gates nothing.
|
|
128
116
|
|
|
129
117
|
## Scope
|
|
130
118
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Release notes — postgres-aiops 0.6.0
|
|
2
|
+
|
|
3
|
+
Previous release: 0.5.0.
|
|
4
|
+
|
|
5
|
+
## BREAKING — the authorization layer is removed
|
|
6
|
+
|
|
7
|
+
This tool no longer decides whether a write is permitted. Read-only mode
|
|
8
|
+
(`<PREFIX>_READ_ONLY`), the graduated-approval / approver gate, and the
|
|
9
|
+
`rules.yaml` deny engine are **all gone**. Whether an operation runs is the
|
|
10
|
+
agent's judgement, or the permission of the account you connect it with — point
|
|
11
|
+
it at a read-only credential and the write fails at the server, the place that
|
|
12
|
+
actually owns the permission.
|
|
13
|
+
|
|
14
|
+
What the tool guarantees instead is that **nothing is silent**: every operation,
|
|
15
|
+
over MCP **and** the CLI alike, lands a row in the audit log — there is no
|
|
16
|
+
unaudited entry point. Destructive writes still capture their before-state and
|
|
17
|
+
record an undo token where a clean inverse exists.
|
|
18
|
+
|
|
19
|
+
- If you set `<PREFIX>_READ_ONLY=1`, it now has **no effect** and the MCP server
|
|
20
|
+
logs a warning at startup. Restrict writes via the connecting account instead.
|
|
21
|
+
- `<PREFIX>_AUDIT_APPROVED_BY` / `<PREFIX>_AUDIT_RATIONALE` still work, but are now
|
|
22
|
+
**optional audit annotations** — recorded on the row when set, never required.
|
|
23
|
+
- The declared `risk_level` is carried into the audit row as a descriptive tier
|
|
24
|
+
(a label, not a gate).
|
|
25
|
+
|
|
26
|
+
The governance harness is now: **audit (MCP+CLI, unbypassable) · runaway/budget
|
|
27
|
+
safety guard · undo recording · output sanitize**. `policy.py` is a small
|
|
28
|
+
risk-tier classifier; `governance/readonly.py` is deleted.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Also in this tool
|
|
32
|
+
|
|
33
|
+
- **`explain --analyze` is now genuinely read-only.** ANALYZE executes the statement, so a `SELECT ... INTO` could create a table; it now runs inside a rolled-back transaction, so no side effect can persist.
|
|
@@ -46,9 +46,12 @@ Every MCP tool runs through the bundled `@governed_tool` harness
|
|
|
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
|
|
48
48
|
an on-by-default guard that trips a tight poll/retry loop.
|
|
49
|
-
- **
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
- **Risk tiers** — a descriptive label (`none`/`confirm`/`review`) carried into
|
|
50
|
+
the audit row from each tool's declared `risk_level`; it gates nothing. The
|
|
51
|
+
harness does not decide whether a write is permitted — that is the agent's
|
|
52
|
+
judgement or the connecting account's permissions. `POSTGRES_AUDIT_APPROVED_BY`
|
|
53
|
+
/ `POSTGRES_AUDIT_RATIONALE` are optional annotations recorded on the audit
|
|
54
|
+
row, never required, never blocking.
|
|
52
55
|
- **Undo-token recording** — reversible writes fetch the **real before-state
|
|
53
56
|
first** and record a faithful inverse (`create_index`↔`drop_index`, where drop
|
|
54
57
|
captures `pg_get_indexdef`; `update_setting` restores the prior value).
|
|
@@ -28,9 +28,10 @@ suite predicts. Sections left open below are **not** claimed.
|
|
|
28
28
|
(with `drop_index` capturing `pg_get_indexdef` **before** dropping), and
|
|
29
29
|
`update_setting` capturing the prior value. Irreversible ops (terminate,
|
|
30
30
|
cancel, vacuum, analyze, reindex, reset stats) declare no undo.
|
|
31
|
-
- Governance persistence: audited rows land in the SQLite audit DB
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
- Governance persistence: audited rows land in the SQLite audit DB. The harness
|
|
32
|
+
authorizes nothing — there is no read-only, deny-rule, or approver gate to
|
|
33
|
+
test; an approver, when supplied, is recorded on the audit row as an optional
|
|
34
|
+
annotation.
|
|
34
35
|
|
|
35
36
|
## Prerequisites for a live run
|
|
36
37
|
|
|
@@ -113,10 +114,12 @@ are open — record them as gaps rather than silently passing.
|
|
|
113
114
|
long-running backend (**open gap** — not exercised live).
|
|
114
115
|
- [ ] `remediate reindex` on a real index (**open gap**).
|
|
115
116
|
|
|
116
|
-
### 6. Governance
|
|
117
|
-
- [x] ✅
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
### 6. Governance records, it does not gate
|
|
118
|
+
- [x] ✅ The harness authorizes nothing — there is no read-only, deny-rule, or
|
|
119
|
+
approver gate to test. A high-risk write ran with no approver set and
|
|
120
|
+
landed an `ok` audit row; when `POSTGRES_AUDIT_APPROVED_BY` and
|
|
121
|
+
`POSTGRES_AUDIT_RATIONALE` were set, they appeared in the audit row as
|
|
122
|
+
optional annotations.
|
|
120
123
|
- [x] ✅ Relocation: with `POSTGRES_AIOPS_HOME` set, `audit.db`, the undo store,
|
|
121
124
|
and `secrets.enc` all land under that directory.
|
|
122
125
|
- [ ] A tight poll loop trips the runaway budget guard rather than hammering the
|
|
@@ -20,14 +20,30 @@ from typing import Any, Optional
|
|
|
20
20
|
from mcp.server.fastmcp import FastMCP
|
|
21
21
|
|
|
22
22
|
from postgres_aiops.config import load_config
|
|
23
|
-
from postgres_aiops.connection import ConnectionManager, PgError
|
|
24
|
-
from postgres_aiops.governance import sanitize
|
|
23
|
+
from postgres_aiops.connection import ConnectionManager, PgConnectionLostError, PgError
|
|
24
|
+
from postgres_aiops.governance import mark_unknown, sanitize
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
|
|
28
28
|
_DOCTOR_HINT = "Run 'postgres-aiops doctor' to verify connectivity and credentials."
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# Long enough to carry the remediation sentence. These messages teach the
|
|
32
|
+
# caller what to do instead, and that clause comes last — a 300-char cap cut
|
|
33
|
+
# it off silently on every refusal long enough to need one.
|
|
34
|
+
_ERROR_MAX = 800
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Failures that leave the statement's fate genuinely undetermined. Raised
|
|
38
|
+
# only from the statement-executing path, so it means an ESTABLISHED link
|
|
39
|
+
# died mid-statement — not that the server was unreachable. The driver gives
|
|
40
|
+
# both the same class, so the connection layer discriminates by position and
|
|
41
|
+
# raises a dedicated class; this layer only has to recognise it.
|
|
42
|
+
# PostgreSQL rolls back on backend termination, so usually nothing landed —
|
|
43
|
+
# but a COMMIT whose acknowledgement was lost did land.
|
|
44
|
+
_UNDETERMINED_ERRORS = (PgConnectionLostError,)
|
|
45
|
+
|
|
46
|
+
|
|
31
47
|
def _safe_error(exc: Exception, tool: str) -> str:
|
|
32
48
|
"""Return an agent-safe error string; log full detail server-side only."""
|
|
33
49
|
logger.error("Tool %s failed", tool, exc_info=True)
|
|
@@ -41,7 +57,7 @@ def _safe_error(exc: Exception, tool: str) -> str:
|
|
|
41
57
|
PgError,
|
|
42
58
|
)
|
|
43
59
|
if isinstance(exc, _passthrough):
|
|
44
|
-
return sanitize(str(exc),
|
|
60
|
+
return sanitize(str(exc), _ERROR_MAX)
|
|
45
61
|
return f"{type(exc).__name__}: operation failed."
|
|
46
62
|
|
|
47
63
|
|
|
@@ -65,7 +81,13 @@ def tool_errors(shape: str = "dict") -> Callable:
|
|
|
65
81
|
return [{"error": msg, "hint": _DOCTOR_HINT}]
|
|
66
82
|
if shape == "str":
|
|
67
83
|
return f"Error: {msg} {_DOCTOR_HINT}"
|
|
68
|
-
|
|
84
|
+
payload = {"error": msg, "hint": _DOCTOR_HINT}
|
|
85
|
+
# Flatten the exception into a dict and its type is gone
|
|
86
|
+
# for good — so classify here, while it is still known,
|
|
87
|
+
# whether the operation may nonetheless have taken effect.
|
|
88
|
+
if isinstance(e, _UNDETERMINED_ERRORS):
|
|
89
|
+
return mark_unknown(payload)
|
|
90
|
+
return payload
|
|
69
91
|
|
|
70
92
|
return wrapper
|
|
71
93
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""MCP server wrapping postgres-aiops operations (stdio transport).
|
|
2
|
+
|
|
3
|
+
Thin adapter layer: each ``@mcp.tool()`` function (in ``mcp_server/tools/``)
|
|
4
|
+
delegates to the ``postgres_aiops`` ops package and is wrapped with the
|
|
5
|
+
postgres-aiops ``@governed_tool`` harness (audit / budget / undo / risk-tier).
|
|
6
|
+
|
|
7
|
+
Standalone, self-governed PostgreSQL DBA operations (preview).
|
|
8
|
+
For PostgreSQL servers/clusters via psycopg 3.
|
|
9
|
+
|
|
10
|
+
Source: https://github.com/AIops-tools/Postgres-AIops
|
|
11
|
+
License: MIT
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from mcp_server._shared import _safe_error, mcp, tool_errors
|
|
18
|
+
|
|
19
|
+
# Importing the tool modules registers every @mcp.tool() onto the shared
|
|
20
|
+
# `mcp` instance. Order does not matter; each module is self-contained.
|
|
21
|
+
from mcp_server.tools import ( # noqa: F401 — side effects
|
|
22
|
+
activity,
|
|
23
|
+
analysis,
|
|
24
|
+
indexes,
|
|
25
|
+
queries,
|
|
26
|
+
remediation,
|
|
27
|
+
replication,
|
|
28
|
+
server,
|
|
29
|
+
tables,
|
|
30
|
+
undo,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = ["mcp", "main", "_safe_error", "tool_errors"]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
"""Run the MCP server over stdio."""
|
|
41
|
+
logging.basicConfig(level=logging.INFO)
|
|
42
|
+
# Read-only mode was removed. Warn a deployment that still exports the old
|
|
43
|
+
# switch so it gets one audible signal instead of silently gaining writes.
|
|
44
|
+
if os.environ.get("POSTGRES_READ_ONLY"):
|
|
45
|
+
logger.warning(
|
|
46
|
+
"POSTGRES_READ_ONLY is set but no longer has any effect — "
|
|
47
|
+
"read-only mode was removed. Writes ARE enabled. Restrict them via "
|
|
48
|
+
"the connecting account's permissions instead (a read-only socket / "
|
|
49
|
+
"scope-limited token)."
|
|
50
|
+
)
|
|
51
|
+
mcp.run(transport="stdio")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""PostgreSQL maintenance MCP tools (guarded writes).
|
|
2
2
|
|
|
3
3
|
The state-changing tools. Every one is wrapped with the governance harness
|
|
4
|
-
(audit +
|
|
4
|
+
(audit + risk-tier tagging) and takes a ``dry_run`` preview. Reversible
|
|
5
5
|
writes pass an ``undo=`` callback that turns the fetched before-state into an
|
|
6
6
|
inverse descriptor the harness records; irreversible ones record none.
|
|
7
7
|
|
|
@@ -73,12 +73,18 @@ def terminate_backend(pid: int, dry_run: bool = False, target: Optional[str] = N
|
|
|
73
73
|
Captures the backend's pid + query for the audit trail; a terminate cannot be
|
|
74
74
|
undone, so no undo is offered. Pass dry_run=True to preview.
|
|
75
75
|
|
|
76
|
+
Refuses this tool's own backend pid — including under dry_run, which must
|
|
77
|
+
report a refusal rather than preview a call that will be refused.
|
|
78
|
+
|
|
76
79
|
Args:
|
|
77
80
|
pid: Backend process id (from list_activity).
|
|
78
81
|
dry_run: If True, preview without terminating.
|
|
79
82
|
target: Target name from config; omit for the default.
|
|
80
83
|
"""
|
|
81
84
|
conn = _get_connection(target)
|
|
85
|
+
# Ahead of the dry_run return: a preview whose real call would be refused
|
|
86
|
+
# must say so, or the caller reads the refusal as transient and retries.
|
|
87
|
+
ops.guard_terminate_backend(conn, pid, "terminate_backend")
|
|
82
88
|
if dry_run:
|
|
83
89
|
return {"dryRun": True, "wouldTerminate": {"pid": pid}}
|
|
84
90
|
return ops.terminate_backend(conn, pid)
|
|
@@ -93,12 +99,16 @@ def cancel_query(pid: int, dry_run: bool = False, target: Optional[str] = None)
|
|
|
93
99
|
Captures the backend's pid + query for audit; a cancel has no undo. Pass
|
|
94
100
|
dry_run=True to preview.
|
|
95
101
|
|
|
102
|
+
Refuses this tool's own backend pid — the query it would cancel is this very
|
|
103
|
+
call. Enforced under dry_run too.
|
|
104
|
+
|
|
96
105
|
Args:
|
|
97
106
|
pid: Backend process id (from list_activity).
|
|
98
107
|
dry_run: If True, preview without cancelling.
|
|
99
108
|
target: Target name from config; omit for the default.
|
|
100
109
|
"""
|
|
101
110
|
conn = _get_connection(target)
|
|
111
|
+
ops.guard_terminate_backend(conn, pid, "cancel_query")
|
|
102
112
|
if dry_run:
|
|
103
113
|
return {"dryRun": True, "wouldCancel": {"pid": pid}}
|
|
104
114
|
return ops.cancel_query(conn, pid)
|
|
@@ -283,6 +293,11 @@ def update_setting(
|
|
|
283
293
|
performed automatically. The prior value is captured so the harness records an
|
|
284
294
|
undo that sets it back. Pass dry_run=True to preview.
|
|
285
295
|
|
|
296
|
+
Refuses the connection-affecting postmaster settings (listen_addresses,
|
|
297
|
+
port, max_connections, superuser_reserved_connections, ssl, hba_file):
|
|
298
|
+
nothing breaks now, but the operator's next restart applies them and strands
|
|
299
|
+
the undo. Enforced under dry_run too.
|
|
300
|
+
|
|
286
301
|
Args:
|
|
287
302
|
name: The configuration parameter name (e.g. work_mem).
|
|
288
303
|
value: The new value (as a string).
|
|
@@ -290,6 +305,9 @@ def update_setting(
|
|
|
290
305
|
target: Target name from config; omit for the default.
|
|
291
306
|
"""
|
|
292
307
|
conn = _get_connection(target)
|
|
308
|
+
# Static denylist, so this costs nothing and cannot diverge from the real
|
|
309
|
+
# call — a preview of a refused setting must report the refusal.
|
|
310
|
+
ops.guard_update_setting(name)
|
|
293
311
|
if dry_run:
|
|
294
312
|
return {"dryRun": True, "wouldSet": {"name": name, "value": value}}
|
|
295
313
|
return ops.update_setting(conn, name, value)
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
Every governed write records a replayable inverse descriptor (tool + params) to
|
|
4
4
|
``undo.db``. This module closes the loop: ``undo_apply`` looks up a recorded
|
|
5
5
|
descriptor and dispatches it to the named governed tool, so the inverse runs on
|
|
6
|
-
the SAME governance path as any other call (audited, budget-checked, and
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
the inner tool it calls.
|
|
6
|
+
the SAME governance path as any other call (audited, budget-checked, and
|
|
7
|
+
tagged with its own risk tier). ``undo_apply`` is itself governed; audit and
|
|
8
|
+
risk-tier labeling for the inverse are handled by the inner tool it calls.
|
|
10
9
|
|
|
11
10
|
Note: recorded ``undo_params`` are the redacted safe-params captured at record
|
|
12
11
|
time, so an inverse that would need a secret value cannot be replayed here — by
|
|
@@ -36,6 +35,11 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
36
35
|
Each entry names the original tool, the inverse tool that ``undo_apply``
|
|
37
36
|
would run, and a human note. Use the ``undoId`` with ``undo_apply``.
|
|
38
37
|
|
|
38
|
+
Each entry carries ``effectVerified``. False means the original write
|
|
39
|
+
lost its response, so the change it reverses is PROBABLE, not confirmed —
|
|
40
|
+
check the live state before applying, and do not report the result as a
|
|
41
|
+
restore of a state that may never have been reached.
|
|
42
|
+
|
|
39
43
|
Args:
|
|
40
44
|
limit: Max rows to return (default 50).
|
|
41
45
|
target: Unused (undo state is host-local); accepted for CLI uniformity.
|
|
@@ -52,6 +56,7 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
52
56
|
"originalTool": r["tool"],
|
|
53
57
|
"inverseTool": r["undo_tool"],
|
|
54
58
|
"note": r.get("note", ""),
|
|
59
|
+
"effectVerified": bool(r.get("effect_verified", 1)),
|
|
55
60
|
}
|
|
56
61
|
for r in rows
|
|
57
62
|
],
|
|
@@ -67,9 +72,9 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
67
72
|
def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
|
|
68
73
|
"""[WRITE][risk=medium] Apply a recorded undo by dispatching its inverse tool.
|
|
69
74
|
|
|
70
|
-
The inverse runs through its own governed tool, so its
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
The inverse runs through its own governed tool, so its risk tier and audit
|
|
76
|
+
row are recorded there. Pass dry_run=True to preview the inverse call
|
|
77
|
+
without executing it. A token can only be applied once.
|
|
73
78
|
|
|
74
79
|
Args:
|
|
75
80
|
undo_id: The undoId from undo_list (or an ``_undo_id`` in a write result).
|
|
@@ -86,12 +91,27 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
86
91
|
)
|
|
87
92
|
|
|
88
93
|
inverse_tool = rec["undo_tool"]
|
|
94
|
+
# Unreadable parameters must stop the replay, not be replaced with {}.
|
|
95
|
+
# Defaulting dispatched the inverse tool with NO ARGUMENTS: for a tool whose
|
|
96
|
+
# parameters all have defaults that runs a real, unintended operation, and
|
|
97
|
+
# dry_run previewed it as a legitimate no-arg inverse. Refusing is the only
|
|
98
|
+
# honest answer — the recorded intent is gone, so there is nothing to apply.
|
|
99
|
+
raw = rec["undo_params"]
|
|
89
100
|
try:
|
|
90
|
-
params = json.loads(
|
|
91
|
-
except (ValueError, TypeError):
|
|
92
|
-
|
|
101
|
+
params = json.loads(raw) if raw else {}
|
|
102
|
+
except (ValueError, TypeError) as exc:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Undo '{undo_id}' has unreadable recorded parameters, so its inverse "
|
|
105
|
+
f"cannot be replayed. Inspect the row in undo.db and re-run the "
|
|
106
|
+
f"inverse tool '{inverse_tool}' by hand if the change still needs "
|
|
107
|
+
f"reversing."
|
|
108
|
+
) from exc
|
|
93
109
|
if not isinstance(params, dict):
|
|
94
|
-
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Undo '{undo_id}' recorded parameters of type "
|
|
112
|
+
f"'{type(params).__name__}' rather than an object, so its inverse "
|
|
113
|
+
f"cannot be replayed. Re-run the inverse tool '{inverse_tool}' by hand."
|
|
114
|
+
)
|
|
95
115
|
|
|
96
116
|
fn = _resolve_tool(inverse_tool)
|
|
97
117
|
if fn is None:
|
|
@@ -99,10 +119,12 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
99
119
|
f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
|
|
100
120
|
)
|
|
101
121
|
|
|
122
|
+
effect_verified = bool(rec.get("effect_verified", 1))
|
|
102
123
|
if dry_run:
|
|
103
124
|
return {
|
|
104
125
|
"dryRun": True,
|
|
105
126
|
"undoId": undo_id,
|
|
127
|
+
"effectVerified": effect_verified,
|
|
106
128
|
"wouldApply": {"tool": inverse_tool, "params": params},
|
|
107
129
|
}
|
|
108
130
|
|
|
@@ -121,6 +143,15 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
121
143
|
return {
|
|
122
144
|
"undoId": undo_id,
|
|
123
145
|
"applied": applied,
|
|
146
|
+
"effectVerified": effect_verified,
|
|
124
147
|
"inverseTool": inverse_tool,
|
|
125
148
|
"result": result,
|
|
149
|
+
"note": (
|
|
150
|
+
""
|
|
151
|
+
if effect_verified
|
|
152
|
+
else "The original write lost its response, so it was never confirmed "
|
|
153
|
+
"to have taken effect. This inverse ran regardless; report the "
|
|
154
|
+
"CURRENT server state, not a restore that may have had nothing "
|
|
155
|
+
"to restore."
|
|
156
|
+
),
|
|
126
157
|
}
|
|
@@ -27,10 +27,18 @@ DryRunOption = Annotated[
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
30
|
-
"""Exceptions translated to a one-line teaching error instead of a traceback.
|
|
30
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback.
|
|
31
|
+
|
|
32
|
+
``PolicyDenied``/``BudgetExceeded`` are raised by ``@governed_tool`` OUTSIDE
|
|
33
|
+
the tool body, so ``tool_errors`` never sees them and they never arrive as
|
|
34
|
+
an ``{"error": ...}`` dict. Their message is the teaching text (which
|
|
35
|
+
approver to set, which budget was hit) — without them here a refusal
|
|
36
|
+
reaches the CLI as a traceback instead.
|
|
37
|
+
"""
|
|
31
38
|
from postgres_aiops.connection import PgError
|
|
39
|
+
from postgres_aiops.governance import BudgetExceeded, PolicyDenied
|
|
32
40
|
|
|
33
|
-
return (PgError, KeyError, OSError, ValueError)
|
|
41
|
+
return (PgError, PolicyDenied, BudgetExceeded, KeyError, OSError, ValueError)
|
|
34
42
|
|
|
35
43
|
|
|
36
44
|
def cli_errors(fn: Callable) -> Callable:
|
|
@@ -95,6 +103,28 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
|
|
|
95
103
|
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
96
104
|
|
|
97
105
|
|
|
106
|
+
def dry_run_preview(
|
|
107
|
+
preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Render a GOVERNED dry-run result as the human-readable DRY-RUN banner.
|
|
110
|
+
|
|
111
|
+
``preview`` must come from calling the governed tool with ``dry_run=True``,
|
|
112
|
+
so every guard it carries has already run against the real target. A refusal
|
|
113
|
+
arrives as ``{"error": ...}`` (``tool_errors`` flattens the exception) — it is
|
|
114
|
+
printed like any other CLI error and exits non-zero, exactly as the real
|
|
115
|
+
write would. Printing a green banner for a call that is about to be refused
|
|
116
|
+
is the preview being wrong, not merely incomplete.
|
|
117
|
+
|
|
118
|
+
On the allowed path the banner is byte-for-byte what it always was: routing
|
|
119
|
+
through the governed call buys the guard and the audit row, not a new
|
|
120
|
+
serialization.
|
|
121
|
+
"""
|
|
122
|
+
if isinstance(preview, dict) and preview.get("error"):
|
|
123
|
+
console.print(f"[red]Error: {preview['error']}[/]")
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
dry_run_print(operation=operation, api_call=api_call, parameters=parameters)
|
|
126
|
+
|
|
127
|
+
|
|
98
128
|
def double_confirm(action: str, resource: str) -> None:
|
|
99
129
|
"""Require two confirmations for a destructive operation."""
|
|
100
130
|
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|