proxy-aiops 0.3.1__tar.gz → 0.4.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.
Files changed (103) hide show
  1. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/CHANGELOG.md +10 -0
  2. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/PKG-INFO +2 -2
  3. proxy_aiops-0.4.0/RELEASE_NOTES.md +49 -0
  4. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/_shared.py +33 -3
  5. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/undo.py +17 -0
  6. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/writes.py +25 -0
  7. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/_common.py +22 -0
  8. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/configcmd.py +13 -5
  9. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/__init__.py +10 -0
  10. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/decorators.py +65 -9
  11. proxy_aiops-0.4.0/proxy_aiops/governance/outcome.py +104 -0
  12. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/sanitize.py +11 -2
  13. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/undo.py +52 -13
  14. proxy_aiops-0.4.0/proxy_aiops/ops/writes.py +333 -0
  15. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/pyproject.toml +2 -2
  16. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/server.json +2 -2
  17. proxy_aiops-0.4.0/tests/test_governance_persistence.py +379 -0
  18. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_optional_fields.py +4 -1
  19. proxy_aiops-0.4.0/tests/test_self_lockout.py +389 -0
  20. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_writes.py +11 -2
  21. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/uv.lock +1 -1
  22. proxy_aiops-0.3.1/RELEASE_NOTES.md +0 -26
  23. proxy_aiops-0.3.1/proxy_aiops/ops/writes.py +0 -174
  24. proxy_aiops-0.3.1/tests/test_governance_persistence.py +0 -187
  25. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/.github/workflows/mcp-publish.yml +0 -0
  26. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/.github/workflows/publish.yml +0 -0
  27. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/.gitignore +0 -0
  28. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/LICENSE +0 -0
  29. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/README.md +0 -0
  30. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/SECURITY.md +0 -0
  31. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/docs/VERIFICATION.md +0 -0
  32. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/__init__.py +0 -0
  33. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/server.py +0 -0
  34. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/__init__.py +0 -0
  35. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/analysis.py +0 -0
  36. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/certs.py +0 -0
  37. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/configread.py +0 -0
  38. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/routes.py +0 -0
  39. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/services.py +0 -0
  40. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/status.py +0 -0
  41. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/mcp_server/tools/traffic.py +0 -0
  42. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/__init__.py +0 -0
  43. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/__init__.py +0 -0
  44. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/_root.py +0 -0
  45. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/analyze.py +0 -0
  46. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/certs.py +0 -0
  47. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/doctor.py +0 -0
  48. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/init.py +0 -0
  49. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/overview.py +0 -0
  50. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/routes.py +0 -0
  51. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/secret.py +0 -0
  52. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/server.py +0 -0
  53. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/services.py +0 -0
  54. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/cli/undo.py +0 -0
  55. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/config.py +0 -0
  56. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/connection.py +0 -0
  57. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/doctor.py +0 -0
  58. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/audit.py +0 -0
  59. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/budget.py +0 -0
  60. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/paths.py +0 -0
  61. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/patterns.py +0 -0
  62. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/policy.py +0 -0
  63. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/governance/readonly.py +0 -0
  64. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/__init__.py +0 -0
  65. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/_util.py +0 -0
  66. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/analysis.py +0 -0
  67. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/certs.py +0 -0
  68. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/configread.py +0 -0
  69. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/overview.py +0 -0
  70. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/routes.py +0 -0
  71. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/services.py +0 -0
  72. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/status.py +0 -0
  73. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/ops/traffic.py +0 -0
  74. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/platform.py +0 -0
  75. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/proxy_aiops/secretstore.py +0 -0
  76. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/skills/proxy-aiops/SKILL.md +0 -0
  77. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/agent-guardrails.md +0 -0
  78. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/capabilities.md +0 -0
  79. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/cli-reference.md +0 -0
  80. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/setup-guide.md +0 -0
  81. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/smithery.yaml +0 -0
  82. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/conftest.py +0 -0
  83. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_analysis.py +0 -0
  84. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_cli_reads.py +0 -0
  85. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_cli_secret.py +0 -0
  86. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_cli_writes.py +0 -0
  87. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_connection.py +0 -0
  88. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_doctor.py +0 -0
  89. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_dpapi_version.py +0 -0
  90. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_gov_audit.py +0 -0
  91. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_gov_decorators.py +0 -0
  92. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_gov_patterns.py +0 -0
  93. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_gov_policy.py +0 -0
  94. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_init.py +0 -0
  95. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_int_fidelity.py +0 -0
  96. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_mcp_read_tools.py +0 -0
  97. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_ops_extra.py +0 -0
  98. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_platform.py +0 -0
  99. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_readonly.py +0 -0
  100. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_reads.py +0 -0
  101. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_secretstore.py +0 -0
  102. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_smoke.py +0 -0
  103. {proxy_aiops-0.3.1 → proxy_aiops-0.4.0}/tests/test_undo_executor.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.0 — 2026-07-20
4
+
5
+ ### Fixed
6
+ - **Caddy config writes refuse the `admin` subtree.** The Caddy admin API is this tool's own transport, and `admin` is an ordinary top-level key in the config tree — so `set_config_value("admin/disabled", true)` tore down the listener mid-request and left the undo with nowhere to go.
7
+ - 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).
8
+ - Harness: a dry-run no longer records an undo token, and no longer requires a named approver. Guards now run on the preview path.
9
+ - Truncated strings end in an ellipsis instead of being cut silently; error messages are capped at 800 chars, not 300.
10
+
11
+ See RELEASE_NOTES.md for the full detail.
12
+
3
13
  All notable changes to proxy-aiops are documented here. This project adheres
4
14
  to [Semantic Versioning](https://semver.org/).
5
15
 
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy-aiops
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Governed AI-ops for Traefik + Caddy + HAProxy reverse proxies / load balancers: routes, services, upstream health, TLS cert expiry sweep, 5xx error-rate RCA, route conflict analysis, and governed writes (caddy config, haproxy server state/weight) with a built-in governance harness (audit, budget, undo, risk tiers)
5
- Author-email: wei <zhouwei008@gmail.com>
5
+ Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.11
@@ -0,0 +1,49 @@
1
+ # Release notes — proxy-aiops 0.4.0
2
+
3
+ Previous release: 0.3.1.
4
+
5
+ ## In this tool
6
+
7
+ - **Caddy config writes refuse the `admin` subtree.** The Caddy admin API is this tool's own transport, and `admin` is an ordinary top-level key in the config tree — so `set_config_value("admin/disabled", true)` tore down the listener mid-request and left the undo with nowhere to go. `load_config` likewise refuses a config that disables admin or moves its listener. The tool's own troubleshooting guide already listed this as a terminal state; it just did not stop itself causing it.
8
+
9
+ ## Every tool in the line: previews and undetermined outcomes
10
+
11
+ This release fixes three harness defects that were silently degrading the audit
12
+ trail and the undo store.
13
+
14
+ **A write that loses its response is no longer recorded as a failure.** The
15
+ harness assumed a sanitized error meant nothing had happened. That assumption is
16
+ false in exactly the case that matters most: when a write severs its own
17
+ connection, the request has already landed, the response cannot come back, and
18
+ the operation was recorded as `status=error` with **no undo token created at
19
+ all**. Transport-level failures are now audited as `status=unknown`, the result
20
+ says plainly that the operation may have taken effect and should be verified
21
+ before retrying, and a write that stashed its before-state has its inverse
22
+ recorded anyway — flagged `effectVerified: false`, which `undo_list` and
23
+ `undo_apply` both surface. Existing `undo.db` files are migrated in place; their
24
+ rows read as verified, which is accurate, since the old code only ever recorded
25
+ on the confirmed path.
26
+
27
+ **A dry-run no longer writes an undo token.** Previews were recording inverses
28
+ built from a before-state they never had: the undo callback's permissive default
29
+ filled the gap with a guess, producing a real, applicable token for an operation
30
+ that never happened.
31
+
32
+ **A dry-run no longer demands a named approver.** Requiring an approval in order
33
+ to ask whether something needs approval inverts what a preview is for. The tier
34
+ is still computed and still audited, so the preview can tell you an approver
35
+ will be needed; it just no longer refuses to answer. The write itself is gated
36
+ exactly as before.
37
+
38
+ The invariant, now stated: **a dry_run may read; it must never write.** Guards
39
+ run on the preview path, which means a preview can and does report that an
40
+ operation would be refused.
41
+
42
+ ## Also line-wide
43
+
44
+ - **Truncated text now ends in an ellipsis** instead of being cut silently. This
45
+ line already treats a silent cut as a defect for lists; it was doing exactly
46
+ that to strings.
47
+ - **Error messages are capped at 800 characters, not 300.** These messages end
48
+ with what to do instead, so the cap was removing the most useful sentence of
49
+ every long refusal.
@@ -17,11 +17,12 @@ from collections.abc import Callable
17
17
  from pathlib import Path
18
18
  from typing import Any, Optional
19
19
 
20
+ import httpx
20
21
  from mcp.server.fastmcp import FastMCP
21
22
 
22
23
  from proxy_aiops.config import load_config
23
24
  from proxy_aiops.connection import ConnectionManager, ProxyApiError
24
- from proxy_aiops.governance import sanitize
25
+ from proxy_aiops.governance import mark_unknown, sanitize
25
26
  from proxy_aiops.platform import UnsupportedOperation
26
27
 
27
28
  logger = logging.getLogger(__name__)
@@ -29,6 +30,29 @@ logger = logging.getLogger(__name__)
29
30
  _DOCTOR_HINT = "Run 'proxy-aiops doctor' to verify connectivity and credentials."
30
31
 
31
32
 
33
+ # Failures that leave the request's fate genuinely undetermined: the bytes
34
+ # went out and either the response or the rest of the connection was lost. A
35
+ # write that hits one of these MAY have taken effect on the server.
36
+ #
37
+ # Deliberately narrow. Connect errors and pool timeouts mean the request never
38
+ # left this process, and an API error carrying a status means the server
39
+ # answered — all are ordinary failures where nothing, or a known something,
40
+ # happened. Marking them 'unknown' would cry wolf on every unreachable host.
41
+ _UNDETERMINED_ERRORS = (
42
+ httpx.ReadTimeout,
43
+ httpx.WriteTimeout,
44
+ httpx.ReadError,
45
+ httpx.WriteError,
46
+ httpx.RemoteProtocolError,
47
+ )
48
+
49
+
50
+ # Long enough to carry the remediation sentence. These messages teach the
51
+ # caller what to do instead, and that clause comes last — a 300-char cap cut
52
+ # it off silently on every refusal long enough to need one.
53
+ _ERROR_MAX = 800
54
+
55
+
32
56
  def _safe_error(exc: Exception, tool: str) -> str:
33
57
  """Return an agent-safe error string; log full detail server-side only."""
34
58
  logger.error("Tool %s failed", tool, exc_info=True)
@@ -43,7 +67,7 @@ def _safe_error(exc: Exception, tool: str) -> str:
43
67
  UnsupportedOperation,
44
68
  )
45
69
  if isinstance(exc, _passthrough):
46
- return sanitize(str(exc), 400)
70
+ return sanitize(str(exc), _ERROR_MAX)
47
71
  return f"{type(exc).__name__}: operation failed."
48
72
 
49
73
 
@@ -67,7 +91,13 @@ def tool_errors(shape: str = "dict") -> Callable:
67
91
  return [{"error": msg, "hint": _DOCTOR_HINT}]
68
92
  if shape == "str":
69
93
  return f"Error: {msg} {_DOCTOR_HINT}"
70
- return {"error": msg, "hint": _DOCTOR_HINT}
94
+ payload = {"error": msg, "hint": _DOCTOR_HINT}
95
+ # Flatten the exception into a dict and its type is gone
96
+ # for good — so classify here, while it is still known,
97
+ # whether the operation may nonetheless have taken effect.
98
+ if isinstance(e, _UNDETERMINED_ERRORS):
99
+ return mark_unknown(payload)
100
+ return payload
71
101
 
72
102
  return wrapper
73
103
 
@@ -41,6 +41,11 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
41
41
  length coincidence: when it is true there are MORE tokens than shown, so
42
42
  re-run with a higher ``limit`` rather than reporting the list as complete.
43
43
 
44
+ Each entry carries ``effectVerified``. False means the original write lost
45
+ its response, so the change it reverses is PROBABLE, not confirmed — check
46
+ the live state before applying, and do not report the result as a restore
47
+ of a state that may never have been reached.
48
+
44
49
  Args:
45
50
  limit: Max rows to return (default 50, capped at 500).
46
51
  target: Unused (undo state is host-local); accepted for CLI uniformity.
@@ -61,6 +66,7 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
61
66
  "originalTool": r["tool"],
62
67
  "inverseTool": r["undo_tool"],
63
68
  "note": r.get("note", ""),
69
+ "effectVerified": bool(r.get("effect_verified", 1)),
64
70
  }
65
71
  for r in rows
66
72
  ],
@@ -105,10 +111,12 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
105
111
  f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
106
112
  )
107
113
 
114
+ effect_verified = bool(rec.get("effect_verified", 1))
108
115
  if dry_run:
109
116
  return {
110
117
  "dryRun": True,
111
118
  "undoId": undo_id,
119
+ "effectVerified": effect_verified,
112
120
  "wouldApply": {"tool": inverse_tool, "params": params},
113
121
  }
114
122
 
@@ -127,6 +135,15 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
127
135
  return {
128
136
  "undoId": undo_id,
129
137
  "applied": applied,
138
+ "effectVerified": effect_verified,
130
139
  "inverseTool": inverse_tool,
131
140
  "result": result,
141
+ "note": (
142
+ ""
143
+ if effect_verified
144
+ else "The original write lost its response, so it was never confirmed "
145
+ "to have taken effect. This inverse ran regardless; report the "
146
+ "CURRENT server state, not a restore that may have had nothing "
147
+ "to restore."
148
+ ),
132
149
  }
@@ -131,6 +131,12 @@ def set_config_value(
131
131
  Caddy applies the change immediately. On traefik/haproxy this raises the
132
132
  support matrix's teaching error. Pass dry_run=True to preview.
133
133
 
134
+ Refuses the 'admin' subtree: that configures the admin API this tool speaks
135
+ to, so disabling or moving it would end the connection the undo needs.
136
+ Change the admin block in caddy's own config file and reload locally. The
137
+ refusal applies under dry_run too — a preview whose real call would be
138
+ refused must report that, not a green 'wouldSet'.
139
+
134
140
  Args:
135
141
  path: Slash-separated config path (from search_config / list_routes,
136
142
  e.g. apps/http/servers/srv0/routes/0/handle/0/upstreams).
@@ -139,6 +145,9 @@ def set_config_value(
139
145
  target: Proxy target name from config; omit for the default.
140
146
  """
141
147
  conn = _get_connection(target)
148
+ # Ahead of the dry_run return: a preview whose real call would be refused
149
+ # must say so, or the caller reads the refusal as transient and retries.
150
+ ops.guard_set_config_value(conn, path, value)
142
151
  if dry_run:
143
152
  return {"dryRun": True, "wouldSet": {"path": path, "value": value}}
144
153
  return ops.set_config_value(conn, path, value)
@@ -158,12 +167,20 @@ def delete_config_path(
158
167
  Requires an approver (PROXY_AUDIT_APPROVED_BY) under the
159
168
  graduated-autonomy policy. Pass dry_run=True to preview.
160
169
 
170
+ Refuses the 'admin' subtree and the config root: both remove the admin API
171
+ this tool speaks to, leaving the undo with no way to reach the server. The
172
+ refusal applies under dry_run too, which must report it rather than preview
173
+ a call that will be refused.
174
+
161
175
  Args:
162
176
  path: Slash-separated config path to delete.
163
177
  dry_run: If True, preview without deleting.
164
178
  target: Proxy target name from config; omit for the default.
165
179
  """
166
180
  conn = _get_connection(target)
181
+ # Ahead of the dry_run return: a preview whose real call would be refused
182
+ # must say so, or the caller reads the refusal as transient and retries.
183
+ ops.guard_delete_config_path(path)
167
184
  if dry_run:
168
185
  return {"dryRun": True, "wouldDelete": {"path": path}}
169
186
  return ops.delete_config_path(conn, path)
@@ -183,12 +200,20 @@ def load_config(
183
200
  Requires an approver (PROXY_AUDIT_APPROVED_BY) under the
184
201
  graduated-autonomy policy. Pass dry_run=True to preview.
185
202
 
203
+ Refuses a config that disables the admin API or moves admin.listen off the
204
+ configured base_url — the undo re-POSTs the snapshot over that same API.
205
+ Send the admin block unchanged, or omit it. The refusal applies under
206
+ dry_run too, which must report it rather than preview a refused call.
207
+
186
208
  Args:
187
209
  config: The full config tree to load (a JSON object).
188
210
  dry_run: If True, preview without loading.
189
211
  target: Proxy target name from config; omit for the default.
190
212
  """
191
213
  conn = _get_connection(target)
214
+ # Ahead of the dry_run return: costs the same config_root GET the real call
215
+ # makes, and in exchange the preview can never contradict it.
216
+ ops.guard_load_config(conn, config)
192
217
  if dry_run:
193
218
  return {"dryRun": True, "wouldLoad": {"topLevelKeys": sorted(config or {})}}
194
219
  return ops.load_config(conn, config)
@@ -68,6 +68,28 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
68
68
  console.print("[magenta] Run without --dry-run to execute.[/]\n")
69
69
 
70
70
 
71
+ def dry_run_preview(
72
+ preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
73
+ ) -> None:
74
+ """Render a GOVERNED dry-run result as the human-readable DRY-RUN banner.
75
+
76
+ ``preview`` must come from calling the governed tool with ``dry_run=True``,
77
+ so every guard it carries has already run against the real target. A refusal
78
+ arrives as ``{"error": ...}`` (``tool_errors`` flattens the exception) — it is
79
+ printed like any other CLI error and exits non-zero, exactly as the real
80
+ write would. Printing a green banner for a call that is about to be refused
81
+ is the preview being wrong, not merely incomplete.
82
+
83
+ On the allowed path the banner is byte-for-byte what it always was: routing
84
+ through the governed call buys the guard and the audit row, not a new
85
+ serialization.
86
+ """
87
+ if isinstance(preview, dict) and preview.get("error"):
88
+ console.print(f"[red]Error: {preview['error']}[/]")
89
+ raise typer.Exit(1)
90
+ dry_run_print(operation=operation, api_call=api_call, parameters=parameters)
91
+
92
+
71
93
  def double_confirm(action: str, resource: str) -> None:
72
94
  """Require two confirmations for a destructive operation."""
73
95
  console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
@@ -18,7 +18,7 @@ from proxy_aiops.cli._common import (
18
18
  cli_errors,
19
19
  console,
20
20
  double_confirm,
21
- dry_run_print,
21
+ dry_run_preview,
22
22
  get_connection,
23
23
  )
24
24
 
@@ -88,8 +88,12 @@ def config_set(
88
88
  except json.JSONDecodeError as exc:
89
89
  raise ValueError(f"value must be valid JSON: {exc}") from exc
90
90
  if dry_run:
91
- dry_run_print(operation="set_config_value", api_call=f"PATCH /config/{path}",
92
- parameters={"path": path, "value": value})
91
+ # Through the governed call: set_config_value refuses the admin subtree,
92
+ # so a preview must report that rather than a green banner.
93
+ dry_run_preview(
94
+ gov.set_config_value(path=path, value=parsed, dry_run=True, target=target),
95
+ operation="set_config_value", api_call=f"PATCH /config/{path}",
96
+ parameters={"path": path, "value": value})
93
97
  return
94
98
  double_confirm("set config value at", path)
95
99
  console.print_json(json.dumps(gov.set_config_value(path=path, value=parsed, target=target)))
@@ -106,8 +110,12 @@ def config_delete(
106
110
  from mcp_server.tools import writes as gov
107
111
 
108
112
  if dry_run:
109
- dry_run_print(operation="delete_config_path", api_call=f"DELETE /config/{path}",
110
- parameters={"path": path})
113
+ # Through the governed call: delete_config_path refuses the admin subtree
114
+ # and the config root, so a preview must report that.
115
+ dry_run_preview(
116
+ gov.delete_config_path(path=path, dry_run=True, target=target),
117
+ operation="delete_config_path", api_call=f"DELETE /config/{path}",
118
+ parameters={"path": path})
111
119
  return
112
120
  double_confirm("delete config path", path)
113
121
  console.print_json(json.dumps(gov.delete_config_path(path=path, target=target)))
@@ -16,6 +16,12 @@ State lives under ``ops_home()`` (default ``~/.proxy-aiops``).
16
16
  from proxy_aiops.governance.audit import AuditEngine, get_engine
17
17
  from proxy_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
18
18
  from proxy_aiops.governance.decorators import PolicyDenied, governed_tool
19
+ from proxy_aiops.governance.outcome import (
20
+ UNKNOWN_FIELD,
21
+ capture_prior_state,
22
+ is_unknown,
23
+ mark_unknown,
24
+ )
19
25
  from proxy_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
20
26
  from proxy_aiops.governance.policy import TierDecision, get_policy_engine
21
27
  from proxy_aiops.governance.readonly import READ_ONLY_ENV, is_read_only
@@ -28,6 +34,10 @@ __all__ = [
28
34
  "opt_str",
29
35
  "is_read_only",
30
36
  "READ_ONLY_ENV",
37
+ "capture_prior_state",
38
+ "mark_unknown",
39
+ "is_unknown",
40
+ "UNKNOWN_FIELD",
31
41
  "PolicyDenied",
32
42
  "get_engine",
33
43
  "AuditEngine",
@@ -34,6 +34,11 @@ from typing import Any
34
34
 
35
35
  from proxy_aiops.governance.audit import detect_agent, get_engine
36
36
  from proxy_aiops.governance.budget import BudgetExceeded, get_budget
37
+ from proxy_aiops.governance.outcome import (
38
+ clear_prior_state,
39
+ is_unknown,
40
+ take_prior_state,
41
+ )
37
42
  from proxy_aiops.governance.patterns import PatternMatch, get_pattern_engine
38
43
  from proxy_aiops.governance.policy import PolicyResult, get_policy_engine
39
44
  from proxy_aiops.governance.readonly import is_read_only, read_only_denial
@@ -155,7 +160,7 @@ class _CallState:
155
160
  "skill", "tool_name", "agent", "start", "status", "result",
156
161
  "policy_result", "pattern_match", "audit", "policy",
157
162
  "safe_params", "env", "risk_level", "timeout_seconds",
158
- "rationale", "approved_by", "risk_tier", "undo",
163
+ "rationale", "approved_by", "risk_tier", "undo", "dry_run",
159
164
  )
160
165
 
161
166
  def __init__(
@@ -169,6 +174,10 @@ class _CallState:
169
174
  timeout_seconds: int,
170
175
  undo: Any = None,
171
176
  ) -> None:
177
+ # A previous call on this thread/task may have captured before-state
178
+ # that its own wrapper never consumed (it raised past _annotate_result).
179
+ # Start clean so one call's prior state can never be recorded as another's.
180
+ clear_prior_state()
172
181
  self.undo = undo
173
182
  self.skill = _infer_skill(func)
174
183
  self.tool_name = func.__name__
@@ -187,6 +196,10 @@ class _CallState:
187
196
  # log and participate in env scoping (previously only kwargs did).
188
197
  params = _bind_params(signature, args, kwargs)
189
198
  self.safe_params = _redact(params, sensitive)
199
+ # Previews are governed like any other call — audited, policy-checked,
200
+ # budget-counted — but they change nothing, so two things must not apply
201
+ # to them: the named-approver gate, and undo recording.
202
+ self.dry_run = bool(params.get("dry_run", False))
190
203
  env = params.get("target", params.get("env", ""))
191
204
  self.env = str(env) if env else ""
192
205
 
@@ -269,7 +282,15 @@ def _pre_check(state: _CallState) -> None:
269
282
  params=state.safe_params,
270
283
  )
271
284
  state.risk_tier = tier.tier
272
- if tier.requires_approver and not state.approved_by:
285
+ # A preview is not the operation. Requiring a named human approver before a
286
+ # caller may ask "would this even be permitted?" inverts what a preview is
287
+ # for — you would have to obtain the approval in order to learn whether the
288
+ # thing needs one, or would be refused outright. The tier is still computed
289
+ # and still audited, so the preview can TELL the caller an approver will be
290
+ # needed; it just does not demand one to answer. Deny rules, budget, the
291
+ # read-only switch and every self-lockout guard still apply, and the write
292
+ # itself is gated normally.
293
+ if tier.requires_approver and not state.approved_by and not state.dry_run:
273
294
  reason = (
274
295
  f"Operation '{state.tool_name}' on '{state.env or 'target'}' requires "
275
296
  f"'{tier.tier}' approval (rule: {tier.rule}) but no approver is recorded. "
@@ -322,17 +343,51 @@ def _annotate_result(state: _CallState, result: Any) -> Any:
322
343
 
323
344
 
324
345
  def _record_undo(state: _CallState, result: Any) -> None:
325
- """Compute and persist the inverse descriptor for a successful write.
346
+ """Compute and persist the inverse descriptor for a write.
347
+
348
+ Two paths:
326
349
 
327
- Best-effort: a broken undo callable or store must never fail the call.
328
- Attaches ``_undo_id`` to dict results so the agent / pilot can reference it.
350
+ * **Confirmed** the tool returned normally, so ``result`` carries the
351
+ before-state its undo callable needs. Recorded ``effect_verified=True``.
352
+ * **Undetermined** — the response was lost (see
353
+ :mod:`proxy_aiops.governance.outcome`). The change may be live, so an
354
+ inverse still matters, but ``result`` is only an error payload. An
355
+ inverse is recorded ONLY when the write explicitly stashed its
356
+ before-state via ``capture_prior_state``; synthesizing one from an
357
+ empty result would produce a *wrong* inverse, which is worse than none.
358
+
359
+ Best-effort throughout: a broken undo callable or store must never fail
360
+ the call. Attaches ``_undo_id`` to dict results for later reference.
329
361
  """
330
362
  if state.undo is None:
331
363
  return
332
- if isinstance(result, dict) and result.get("error"):
333
- return # sanitized failure no change happened, so no inverse to record
364
+ if state.dry_run:
365
+ # A preview changed nothing, so there is nothing to invert — and the
366
+ # descriptor it would produce is actively dangerous. An undo callable
367
+ # reads the before-state out of the result, and a preview has none, so
368
+ # permissive defaults (``(result.get("priorState") or {}).get("running",
369
+ # True)``) fill the gap with a guess. That yielded a REAL, applicable
370
+ # start_container token for a stop that never happened.
371
+ return
372
+
373
+ verified = True
374
+ undo_input = result
375
+ if is_unknown(result):
376
+ prior = take_prior_state()
377
+ if prior is None:
378
+ _log.warning(
379
+ "%s.%s outcome undetermined and no prior state captured — "
380
+ "no inverse recorded; the change may be live and unrecorded",
381
+ state.skill, state.tool_name,
382
+ )
383
+ return
384
+ verified = False
385
+ undo_input = {"priorState": prior, "outcomeUnknown": True}
386
+ elif isinstance(result, dict) and result.get("error"):
387
+ return # definite failure — no change happened, so no inverse
388
+
334
389
  try:
335
- descriptor = state.undo(state.safe_params, result)
390
+ descriptor = state.undo(state.safe_params, undo_input)
336
391
  except Exception: # noqa: BLE001 — undo computation must not fail the call
337
392
  _log.warning("undo callable for %s.%s raised", state.skill, state.tool_name,
338
393
  exc_info=True)
@@ -347,6 +402,7 @@ def _record_undo(state: _CallState, result: Any) -> None:
347
402
  tool=state.tool_name,
348
403
  undo_descriptor=descriptor,
349
404
  orig_params=state.safe_params,
405
+ effect_verified=verified,
350
406
  )
351
407
  if undo_id and isinstance(result, dict):
352
408
  result.setdefault("_undo_id", undo_id)
@@ -374,7 +430,7 @@ def _finalize(state: _CallState) -> None:
374
430
  # dicts BEFORE this harness sees them) must not be audited as success —
375
431
  # compliance exception reports are built from this status.
376
432
  if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
377
- state.status = "error"
433
+ state.status = "unknown" if is_unknown(state.result) else "error"
378
434
 
379
435
  duration = int((time.time() - state.start) * 1000)
380
436
 
@@ -0,0 +1,104 @@
1
+ """Tell "the write did not happen" apart from "we cannot tell whether it happened".
2
+
3
+ A governed write that loses its response is not a failed write. The request may
4
+ already have landed; the only thing that certainly failed is our knowledge of
5
+ the outcome. The harness used to collapse both into ``status='error'`` with no
6
+ undo token — and it did so precisely where the operation was most dangerous,
7
+ because the two coincide:
8
+
9
+ a write whose target is the very thing serving this tool's API
10
+ → the change takes effect
11
+ → the response never comes back, because what would have sent it is gone
12
+ → sanitized to {"error": ...} → audited as a FAILURE → no inverse recorded
13
+
14
+ The audit trail then reports that the destructive call failed while the change
15
+ is live and unrecorded. For a line whose product is "governed and reversible",
16
+ a row that confidently states the wrong outcome is worse than a missing one.
17
+
18
+ Two primitives fix that:
19
+
20
+ ``mark_unknown`` / ``is_unknown``
21
+ The MCP layer's ``tool_errors`` classifies the exception it caught. Only
22
+ errors that leave the request's fate genuinely undetermined (a read
23
+ timeout, a half-closed socket, a protocol error mid-response) are marked.
24
+ A connect error or a pool timeout means the request never left, and an HTTP
25
+ status error means the server answered — both are ordinary failures and are
26
+ NOT marked. The distinction is per-tool knowledge, so each tool's
27
+ ``mcp_server/_shared.py`` owns the exception tuple; this module only carries
28
+ the marker.
29
+
30
+ ``capture_prior_state`` / ``take_prior_state``
31
+ An undo callable receives ``(params, result)``. When the write raises, the
32
+ before-state that was read moments earlier dies with the exception and no
33
+ inverse can be computed. A write that wants its undo to survive a lost
34
+ response stashes the before-state *before* issuing the mutating request::
35
+
36
+ before = conn.get(...)
37
+ capture_prior_state({"running": before["running"]})
38
+ conn.post(...) # may never return
39
+
40
+ The harness then records the inverse even on the unknown path, flagged
41
+ ``effect_verified=False`` so nobody mistakes it for a confirmed change.
42
+ Opt-in by design: fabricating a descriptor from an empty result would
43
+ produce a *wrong* inverse, which is worse than none.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from contextvars import ContextVar
49
+ from typing import Any
50
+
51
+ # Public result field. Explicit rather than inferred: a caller must never have
52
+ # to guess from the error text whether the operation may have taken effect.
53
+ UNKNOWN_FIELD = "outcomeUnknown"
54
+
55
+ _UNKNOWN_NOTE = (
56
+ "The request was sent but no usable response came back, so this operation "
57
+ "MAY have taken effect on the server. Verify the current state before "
58
+ "retrying — a blind retry could apply it twice."
59
+ )
60
+
61
+ # Set by a write immediately before it issues its mutating request; consumed by
62
+ # the harness. A ContextVar (not a module global) so concurrent calls and async
63
+ # tools never read each other's state.
64
+ _prior_state: ContextVar[dict[str, Any] | None] = ContextVar(
65
+ "governed_prior_state", default=None
66
+ )
67
+
68
+
69
+ def mark_unknown(payload: dict[str, Any]) -> dict[str, Any]:
70
+ """Return a NEW error payload flagged as having an undetermined outcome."""
71
+ marked = dict(payload)
72
+ marked[UNKNOWN_FIELD] = True
73
+ marked["note"] = _UNKNOWN_NOTE
74
+ return marked
75
+
76
+
77
+ def is_unknown(result: Any) -> bool:
78
+ """True when ``result`` is an error payload whose effect is undetermined."""
79
+ return isinstance(result, dict) and result.get(UNKNOWN_FIELD) is True
80
+
81
+
82
+ def capture_prior_state(state: dict[str, Any]) -> None:
83
+ """Stash before-state so the inverse survives a lost response.
84
+
85
+ Call this AFTER reading the before-state and BEFORE issuing the mutating
86
+ request. Overwrites any previously captured state for this call.
87
+ """
88
+ _prior_state.set(dict(state))
89
+
90
+
91
+ def take_prior_state() -> dict[str, Any] | None:
92
+ """Consume the captured before-state (harness-internal).
93
+
94
+ Consuming rather than peeking keeps one call's state from leaking into the
95
+ next on a reused thread or task.
96
+ """
97
+ state = _prior_state.get()
98
+ _prior_state.set(None)
99
+ return state
100
+
101
+
102
+ def clear_prior_state() -> None:
103
+ """Drop any captured state. Called at the start of every governed call."""
104
+ _prior_state.set(None)
@@ -31,18 +31,27 @@ def sanitize(text: str | None, max_len: int = 500) -> str:
31
31
  payload past the cut-off by padding with junk control characters.
32
32
  ``None`` sanitizes to ``""``.
33
33
 
34
+ A truncated result ends in an ellipsis (U+2026) so the cut is visible. This
35
+ line already treats a silent cut as a defect for lists — a capped page that
36
+ looks complete gets reported as the whole set — and a capped string is the
37
+ same failure at a smaller scale: a shortened error message reads as the
38
+ whole explanation, and its remediation sentence, which comes last, is gone
39
+ without a trace.
40
+
34
41
  Args:
35
42
  text: Untrusted text from proxy API responses.
36
43
  max_len: Maximum length after truncation. Default 500.
37
44
 
38
45
  Returns:
39
- Cleaned, truncated string safe for LLM consumption.
46
+ Cleaned string, ellipsis-terminated when it had to be cut.
40
47
  """
41
48
  if text is None:
42
49
  return ""
43
50
  stripped = _CONTROL_CHAR_RE.sub("", str(text))
44
51
  cleaned = "".join(c for c in stripped if unicodedata.category(c) != "Cf")
45
- return cleaned[:max_len]
52
+ if len(cleaned) <= max_len:
53
+ return cleaned
54
+ return cleaned[: max(0, max_len - 1)] + "\u2026"
46
55
 
47
56
 
48
57
  def opt_str(value: object | None, max_len: int = 500) -> str | None: