vmware-debug 1.12.1__tar.gz → 1.12.2__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 (95) hide show
  1. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/.claude-plugin/plugin.json +1 -1
  2. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/.mcp.json +1 -1
  3. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/PKG-INFO +2 -2
  4. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/RELEASE_NOTES.md +32 -0
  5. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/openclaw.plugin.json +1 -1
  6. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/package.json +1 -1
  7. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/pyproject.toml +2 -2
  8. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/server.json +2 -2
  9. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/SKILL.md +27 -5
  10. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/references/agent-guardrails.md +1 -1
  11. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/references/setup-guide.md +29 -6
  12. vmware_debug-1.12.2/tests/test_mcp_audit.py +274 -0
  13. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/uv.lock +5 -5
  14. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/__init__.py +1 -1
  15. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/cli.py +3 -3
  16. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/mcp_server/server.py +45 -19
  17. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/.clawhubignore +0 -0
  18. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/.gitignore +0 -0
  19. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/README-CN.md +0 -0
  20. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/README.md +0 -0
  21. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/SECURITY.md +0 -0
  22. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/docs/plans/2026-08-28-investigation-layer-design.md +0 -0
  23. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/references/capabilities.md +0 -0
  24. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/references/cli-reference.md +0 -0
  25. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/references/event-envelope.md +0 -0
  26. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/skills/vmware-debug/references/routing.md +0 -0
  27. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/__init__.py +0 -0
  28. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/conftest.py +0 -0
  29. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/__init__.py +0 -0
  30. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/__init__.py +0 -0
  31. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/_family.py +0 -0
  32. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/_scores.json +0 -0
  33. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/_scoring.py +0 -0
  34. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/_skill.py +0 -0
  35. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/conftest.py +0 -0
  36. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/test_entity_reachability.py +0 -0
  37. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/test_error_actionability.py +0 -0
  38. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
  39. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  40. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/__init__.py +0 -0
  41. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/_vsphere_event_vocabulary.py +0 -0
  42. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_audit_isolation.py +0 -0
  43. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_capability_grader.py +0 -0
  44. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_case_id_non_ascii.py +0 -0
  45. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_case_plan_max_steps.py +0 -0
  46. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_classification_against_vsphere_vocabulary.py +0 -0
  47. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_debug_regressions.py +0 -0
  48. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_environment_resolver_isolation.py +0 -0
  49. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_every_tool_parameter_is_documented.py +0 -0
  50. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_evidence_payload_contract.py +0 -0
  51. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_host_lifecycle_classification.py +0 -0
  52. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_investigation_metrics.py +0 -0
  53. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_real_alert_titles_classify.py +0 -0
  54. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_result_envelope.py +0 -0
  55. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_skill_name_aliases.py +0 -0
  56. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_spike_resolution.py +0 -0
  57. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/eval/regression/test_tool_annotations.py +0 -0
  58. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_conclusion.py +0 -0
  59. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_evidence.py +0 -0
  60. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_grading.py +0 -0
  61. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_hypotheses.py +0 -0
  62. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_ids.py +0 -0
  63. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_ledger_concurrency.py +0 -0
  64. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_plan.py +0 -0
  65. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_readiness.py +0 -0
  66. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_store.py +0 -0
  67. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_timeline_close.py +0 -0
  68. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_case_tools.py +0 -0
  69. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_knowledge.py +0 -0
  70. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_safe_error_passthrough.py +0 -0
  71. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/tests/test_timeline.py +0 -0
  72. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/envelope.py +0 -0
  73. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/mcp/__init__.py +0 -0
  74. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/mcp/tools.py +0 -0
  75. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/mcp_server/__init__.py +0 -0
  76. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/__init__.py +0 -0
  77. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/__init__.py +0 -0
  78. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/api.py +0 -0
  79. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/conclusion.py +0 -0
  80. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/evidence.py +0 -0
  81. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/grading.py +0 -0
  82. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/hypotheses.py +0 -0
  83. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/ids.py +0 -0
  84. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/knowledge.py +0 -0
  85. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/model.py +0 -0
  86. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/payloads.py +0 -0
  87. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/plan.py +0 -0
  88. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/readiness.py +0 -0
  89. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/sources.py +0 -0
  90. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/store.py +0 -0
  91. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/cases/timeline.py +0 -0
  92. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/skill_names.py +0 -0
  93. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/ops/timeline.py +0 -0
  94. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/rules/evidence_sources.yaml +0 -0
  95. {vmware_debug-1.12.1 → vmware_debug-1.12.2}/vmware_debug/rules/grading_rules.yaml +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "vmware-debug",
4
4
  "displayName": "VMware Debug",
5
- "version": "1.12.1",
5
+ "version": "1.12.2",
6
6
  "description": "VMware incident timeline, root-cause routing, local case ledger; no vSphere access. 14 MCP tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "vmware-debug==1.12.1",
8
+ "vmware-debug==1.12.2",
9
9
  "vmware-debug-mcp"
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vmware-debug
3
- Version: 1.12.1
3
+ Version: 1.12.2
4
4
  Summary: VMware diagnostic brain — offline incident triage, log/event correlation, and root-cause routing across the VMware skill family
5
5
  Project-URL: Homepage, https://github.com/vmware-skills/VMware-Debug
6
6
  Project-URL: Repository, https://github.com/vmware-skills/VMware-Debug
@@ -18,7 +18,7 @@ Requires-Dist: mcp[cli]<2.0,>=1.10
18
18
  Requires-Dist: pyyaml<7.0,>=6.0
19
19
  Requires-Dist: rich<15.0,>=13.0
20
20
  Requires-Dist: typer<1.0,>=0.12
21
- Requires-Dist: vmware-policy<2.0,>=1.15.0
21
+ Requires-Dist: vmware-policy<2.0,>=1.16.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  <!-- mcp-name: io.github.vmware-skills/vmware-debug -->
@@ -1,3 +1,35 @@
1
+ ## v1.12.2 — every tool call is audited, failures included
2
+
3
+ Until now no vmware-debug MCP tool wrote to `~/.vmware/audit.db`. The server said that was on
4
+ purpose — nothing here acts on a VMware target — and the case ledger stood in as the record. It
5
+ records the investigation, not the calls: in a live session case evidence was submitted with
6
+ invented fetch times, and nothing could show when the calls had really been made or which failed.
7
+
8
+ * All 14 MCP tools now go through `@vmware_tool`. Each call writes one row under the tool's own
9
+ name; a call that raises or returns `{"error": …}` is recorded as `error`.
10
+ * `payload` (`case_submit_evidence`) and `events` (`incident_timeline`) are kept out of the row.
11
+ The row still has the case id, source skill and tool, query, fetch time and outcome; the data
12
+ itself is in the case folder, or with the caller.
13
+ * The CLI twins audit too: `categories` as `list_symptom_categories`, `triage` as
14
+ `incident_timeline` — including a `triage` that fails on bad input. `version` and `mcp` still
15
+ write no row.
16
+ * **Event text is not copied into the row.** `incident_timeline` and `case_timeline` return event
17
+ text (`hypotheses[].sample_text`, `classification.unmatched_samples`, `rejected`), and a probe
18
+ with `password=…` and `https://user:pass@host` in an event stored both in the row's result. Both
19
+ tools now declare `sensitive_result`: the row keeps the call, arguments and status (a returned
20
+ `{"error": …}` is still `error`) and records the result as `[redacted: return value declared
21
+ sensitive]`. The other tools return ids, counts, grades and text you passed as an argument, and
22
+ are stored after vmware-policy's credential scrubbing.
23
+ * Because the tools now pass through `guard()`, vmware-policy's rules apply to them: a `deny` rule
24
+ naming a debug tool, or with no `operations`, refuses it; a malformed `~/.vmware/rules.yaml`
25
+ refuses every tool (the `categories`/`triage` commands exit 1 with a traceback ending in the
26
+ reason); the runaway guard refuses the 26th identical call within 120 s. It compares a digest of the
27
+ arguments the tool received, not the audit row's redacted copy, so `incident_timeline` calls with
28
+ different events are not identical — this needs vmware-policy 1.16.0, which this release requires.
29
+ Environment-scoped rules still never match (debug has no targets), and maintenance windows never
30
+ gate a debug tool (all are `low` risk). `references/setup-guide.md` said the opposite — that no
31
+ policy rule applies — and is corrected.
32
+
1
33
  ## v1.12.1 — every CLI command declares what it reaches
2
34
 
3
35
  Across the family, CLI reads never wrote `~/.vmware/audit.db`; they now do (`@audited`, under the
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "vmware-debug==1.12.1",
14
+ "vmware-debug==1.12.2",
15
15
  "vmware-debug-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/vmware-debug",
3
- "version": "1.12.1",
3
+ "version": "1.12.2",
4
4
  "description": "VMware incident timeline, root-cause routing, local case ledger; no vSphere access. 14 MCP tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmware-debug"
7
- version = "1.12.1"
7
+ version = "1.12.2"
8
8
  description = "VMware diagnostic brain — offline incident triage, log/event correlation, and root-cause routing across the VMware skill family"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "typer>=0.12,<1.0",
22
22
  "rich>=13.0,<15.0",
23
23
  "mcp[cli]>=1.10,<2.0",
24
- "vmware-policy>=1.15.0,<2.0",
24
+ "vmware-policy>=1.16.0,<2.0",
25
25
  # Imported directly by ops/cases/grading.py for the rules file. It has
26
26
  # always arrived transitively via vmware-policy; a package should
27
27
  # declare what it imports rather than rely on a dependency's dependency.
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/vmware-skills/VMware-Debug",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.12.1",
10
+ "version": "1.12.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-debug",
15
- "version": "1.12.1",
15
+ "version": "1.12.2",
16
16
  "runtimeHint": "uvx",
17
17
  "packageArguments": [
18
18
  {
@@ -51,7 +51,7 @@ write tools touch only the local case ledger.
51
51
  ## Quick Install
52
52
 
53
53
  ```bash
54
- uv tool install vmware-debug==1.12.1
54
+ uv tool install vmware-debug==1.12.2
55
55
  vmware-debug categories # see what it can diagnose
56
56
  ```
57
57
 
@@ -275,10 +275,32 @@ No network, nothing executed. The seven [WRITE] tools write only to the local ca
275
275
  ledger under `$OPS_HOME`; nothing here touches a remote VMware estate. Evidence, gaps,
276
276
  hypotheses and grade history are only ever added to — `timeline.md` is regenerated from
277
277
  the evidence and `case.json` holds the current grade and state. Remediation is always routed to
278
- aiops/pilot, where the double-confirm / approval / audit gates live (audit DB
279
- `~/.vmware/audit.db`). debug has no config and no connection, so it registers no
280
- environment resolver, and its tools do not pass through the policy engine — no policy
281
- rule applies to them. See `references/setup-guide.md`.
278
+ aiops/pilot, where the double-confirm / approval gates live.
279
+
280
+ **What the audit row holds.** Every debug tool call — a failed one included — writes one
281
+ row to `~/.vmware/audit.db` through `@vmware_tool`, as do the `categories` and `triage`
282
+ CLI commands under their MCP tools' names. The row records the tool, time, OS user,
283
+ detected agent, arguments, status (`ok`, `error`, `denied`, `budget_exceeded`,
284
+ `interrupted`) and a result. Two arguments are stored as `***`: evidence `payload` and
285
+ `incident_timeline` `events`. Two results are not stored at all — `incident_timeline` and
286
+ `case_timeline` quote event text (`sample_text`, `unmatched_samples`, `rejected`), which
287
+ can carry anything the source logged, so their row says
288
+ `[redacted: return value declared sensitive]`. Every other result (ids, counts, grades,
289
+ your own summaries and statements) is stored after credential scrubbing. The data itself
290
+ is in the case folder, or with you.
291
+
292
+ **What debug inherits from vmware-policy**, since its tools pass through `guard()`:
293
+ - **Runaway guard** — the 26th call to one tool with identical arguments within 120 s is
294
+ refused (`budget_exceeded`; `VMWARE_RUNAWAY_MAX`, `VMWARE_RUNAWAY_WINDOW_SEC`). It
295
+ compares a digest of the arguments the tool received (vmware-policy ≥1.16.0), so
296
+ calls with different `events` are not identical.
297
+ - **A malformed `~/.vmware/rules.yaml` fails closed** — every tool is refused with a reason
298
+ naming the file; `categories`/`triage` exit 1 with a traceback ending in that message.
299
+ - **A `deny` rule without `operations`** matches every tool, debug's included.
300
+ - **Environment-scoped rules never match** — debug registers no resolver (no targets).
301
+ - **Maintenance windows** gate only `high`/`critical` risk; every debug tool is `low`.
302
+
303
+ See `references/setup-guide.md`.
282
304
 
283
305
  **Case data is sensitive and is kept until you delete it.** Each case lives in
284
306
  `$OPS_HOME/cases/<case-id>/` (default `~/.vmware/cases/`), created owner-only (`0700`).
@@ -46,7 +46,7 @@ These are structural, so it cannot.
46
46
  | "Do not fabricate a timeline — build it from the events I gave you" | **`incident_timeline` correlates only its input.** It is source-agnostic and has no way to fetch anything, so the timeline cannot contain an event the agent did not supply. |
47
47
  | "Tell me when the symptom is outside what you can recognise" | **`list_symptom_categories`** states the catalogue, and unmatched symptoms come back as `uncategorized` rather than being forced into the nearest signature. |
48
48
  | "Use explicit limits for queries that may return large amounts of data" | **The list envelope.** `list_symptom_categories` returns `{items, returned, limit, total, truncated, hint}` with `truncated` always `false` — which is the point: it states that the catalogue is complete instead of leaving you to infer it. |
49
- | "Log everything you looked at" | **The case ledger, for investigations.** Evidence submitted to a case is recorded with its source skill, tool, query and fetch time. Debug's own tool calls are not written to `~/.vmware/audit.db`: none of them acts on a VMware target. |
49
+ | "Log everything you looked at" | **`~/.vmware/audit.db` and the case ledger.** Every debug tool call, a failed one included, writes one audit row (tool, time, user, agent, arguments, status and result — evidence `payload` and `events` are stored as `***`, and the results of `incident_timeline` and `case_timeline`, which quote event text, are not stored at all). Evidence submitted to a case is also recorded in the ledger with its source skill, tool, query and fetch time — so pass the real fetch time, never a placeholder. |
50
50
 
51
51
  ---
52
52
 
@@ -6,7 +6,7 @@ is a pure, offline correlation engine. There is no `config.yaml` and no `.env`.
6
6
  ## Install
7
7
 
8
8
  ```bash
9
- uv tool install vmware-debug==1.12.1
9
+ uv tool install vmware-debug==1.12.2
10
10
  vmware-debug categories # verify it runs
11
11
  ```
12
12
 
@@ -15,7 +15,7 @@ vmware-debug categories # verify it runs
15
15
  ```json
16
16
  {
17
17
  "command": "uvx",
18
- "args": ["--from", "vmware-debug==1.12.1", "vmware-debug-mcp"]
18
+ "args": ["--from", "vmware-debug==1.12.2", "vmware-debug-mcp"]
19
19
  }
20
20
  ```
21
21
 
@@ -53,8 +53,31 @@ routes fixes to (vmware-aiops, vmware-pilot).
53
53
  `staging` / `lab`) per target in their own `config.yaml` as an optional label
54
54
  an environment-scoped `deny` rule can match on. debug has no config and no
55
55
  connection to declare one about, so it registers no environment resolver —
56
- it has no basis to answer for any target. Its tools do not pass through the
57
- policy engine either, so no policy rule, environment-scoped or not, applies
58
- to them.
59
- 7. **Static analysis** — `uvx bandit -r vmware_debug/` (release bar:
56
+ it has no basis to answer for any target — and an environment-scoped rule
57
+ never matches its tools. Every other policy rule does apply: each MCP tool
58
+ and the `categories` / `triage` CLI commands pass through vmware-policy's
59
+ `guard()`. Verified behaviour:
60
+ - A `deny` rule naming a debug tool, or with no `operations` key at all,
61
+ refuses it (status `denied`, with the rule's reason).
62
+ - A malformed `~/.vmware/rules.yaml` fails closed: every tool is refused with
63
+ a reason naming the file and the way out (fix it, or
64
+ `VMWARE_POLICY_DISABLED=1`). The `categories` and `triage` commands exit 1
65
+ with a Python traceback whose last lines are that message; `version` and
66
+ `mcp` are unaffected.
67
+ - Runaway guard: within one MCP server process, the 26th call to the same
68
+ tool with identical arguments inside 120 s is refused (`budget_exceeded`).
69
+ Tune with `VMWARE_RUNAWAY_MAX` / `VMWARE_RUNAWAY_WINDOW_SEC`. The guard
70
+ compares a SHA-256 digest of the arguments the tool actually received
71
+ (vmware-policy ≥1.16.0), not the redacted copy in the audit row — so calls
72
+ with different `events` or `payload` are never counted as identical.
73
+ - A `maintenance_window` gates only `high`/`critical` risk operations; every
74
+ debug tool is `low`, so a window never blocks one.
75
+ 7. **Audit row contents** — one row per call in `~/.vmware/audit.db`: tool,
76
+ time, OS user, detected agent, arguments, status and result. `payload` and
77
+ `events` are stored as `***`. The results of `incident_timeline` and
78
+ `case_timeline` are not stored (`[redacted: return value declared
79
+ sensitive]`) because they quote event text; other results are stored after
80
+ credential scrubbing. The status is still truthful — a call that returns
81
+ `{"error": …}` is `error`.
82
+ 8. **Static analysis** — `uvx bandit -r vmware_debug/` (release bar:
60
83
  0 Medium+).
@@ -0,0 +1,274 @@
1
+ """Every call to this skill's MCP tools — and every failed call — writes one audit row.
2
+
3
+ Until 2026-09-15 no vmware-debug tool wrote to ``~/.vmware/audit.db``. The server
4
+ said that was deliberate ("nothing in this skill acts on a VMware target"), and
5
+ the case ledger was offered as the record. It is a record of the investigation,
6
+ not of the calls: a live session submitted evidence with invented timestamps,
7
+ and nothing could show when the calls had really been made or which had failed.
8
+ HLD §8.1 / I-10 (amended 2026-09-15b) now require a row for every MCP call,
9
+ local tools included, and for the CLI twins of those tools under the same name.
10
+
11
+ Rows are read back from the sandbox database the engine is bound to, so these
12
+ tests would fail if the call reached the audit engine but not the file.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import sqlite3
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+ from typer.testing import CliRunner
24
+ from vmware_policy import get_engine
25
+ from vmware_policy.audit import reset_engine
26
+
27
+ from vmware_debug.cli import app
28
+ from vmware_debug.mcp_server.server import build_server
29
+
30
+ EXPECTED_TOOLS = {
31
+ "incident_timeline",
32
+ "list_symptom_categories",
33
+ "case_open",
34
+ "case_list",
35
+ "case_get",
36
+ "case_submit_evidence",
37
+ "case_record_gap",
38
+ "case_grade",
39
+ "case_readiness",
40
+ "case_plan",
41
+ "case_hypotheses",
42
+ "case_timeline",
43
+ "case_close",
44
+ "case_knowledge",
45
+ }
46
+
47
+ AT = "2026-09-15T05:48:16Z"
48
+
49
+
50
+ @pytest.fixture(autouse=True)
51
+ def isolated_audit(tmp_path, monkeypatch):
52
+ """A fresh OPS_HOME per test, and an engine bound to it."""
53
+ monkeypatch.setenv("OPS_HOME", str(tmp_path / "vmware"))
54
+ reset_engine()
55
+ yield
56
+ reset_engine()
57
+
58
+
59
+ @pytest.fixture
60
+ def server():
61
+ return build_server()
62
+
63
+
64
+ def rows(tool: str | None = None) -> list[dict]:
65
+ db = Path(get_engine()._path)
66
+ if not db.exists():
67
+ return []
68
+ with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
69
+ con.row_factory = sqlite3.Row
70
+ query = "SELECT skill, tool, status, params, result FROM audit_log"
71
+ args: tuple = ()
72
+ if tool is not None:
73
+ query += " WHERE tool = ?"
74
+ args = (tool,)
75
+ return [dict(r) for r in con.execute(query + " ORDER BY id", args)]
76
+
77
+
78
+ def call(server, name: str, arguments: dict):
79
+ return asyncio.run(server.call_tool(name, arguments))
80
+
81
+
82
+ def test_every_registered_tool_wears_vmware_tool(server) -> None:
83
+ tools = server._tool_manager._tools
84
+ assert set(tools) >= EXPECTED_TOOLS, f"tools missing from the registry: {EXPECTED_TOOLS - set(tools)}"
85
+ unwrapped = sorted(name for name, tool in tools.items() if not getattr(tool.fn, "_is_vmware_tool", False))
86
+ assert not unwrapped, f"MCP tools that write no audit row: {unwrapped}"
87
+
88
+
89
+ def test_a_successful_call_writes_one_ok_row_under_the_tool_name(server) -> None:
90
+ call(server, "case_open", {"summary": "vsan latency on cluster-01", "determined_by": "alarm 42"})
91
+
92
+ found = rows("case_open")
93
+ assert len(found) == 1, f"expected one case_open row, got {rows()}"
94
+ assert found[0]["skill"] == "debug"
95
+ assert found[0]["status"] == "ok"
96
+
97
+
98
+ def test_a_failed_call_writes_an_error_row(server) -> None:
99
+ call(server, "case_get", {"case_id": "20990101-000000-no-such-case"})
100
+
101
+ found = rows("case_get")
102
+ assert len(found) == 1, f"a failed call left no row: {rows()}"
103
+ assert found[0]["status"] == "error"
104
+
105
+
106
+ def test_a_malformed_incident_timeline_call_is_recorded_as_an_error(server) -> None:
107
+ call(server, "incident_timeline", {"events": [{"not": "an event"}]})
108
+
109
+ found = rows("incident_timeline")
110
+ assert len(found) == 1
111
+ assert found[0]["status"] == "error"
112
+
113
+
114
+ def test_evidence_payload_is_not_copied_into_the_audit_row(server) -> None:
115
+ opened = call(server, "case_open", {"summary": "vcenter memory exhaustion", "determined_by": "alarm"})
116
+ case_id = _structured(opened)["case_id"]
117
+ marker = "PAYLOAD-MARKER-3f9c"
118
+
119
+ call(
120
+ server,
121
+ "case_submit_evidence",
122
+ {
123
+ "case_id": case_id,
124
+ "source_skill": "vmware-monitor",
125
+ "source_tool": "get_events",
126
+ "summary": "one event",
127
+ "fetched_at": AT,
128
+ "query": {"target": "home-vcenter", "hours": 1},
129
+ "payload": [{"time": AT, "message": marker}],
130
+ },
131
+ )
132
+
133
+ found = rows("case_submit_evidence")
134
+ assert len(found) == 1
135
+ assert found[0]["status"] == "ok"
136
+ assert marker not in found[0]["params"], "the raw payload was duplicated into audit.db"
137
+ params = json.loads(found[0]["params"])
138
+ assert params["case_id"] == case_id
139
+ assert params["source_tool"] == "get_events"
140
+ assert params["fetched_at"] == AT
141
+
142
+
143
+ def test_cli_categories_is_audited_under_its_mcp_twin_name() -> None:
144
+ result = CliRunner().invoke(app, ["categories"])
145
+
146
+ assert result.exit_code == 0, result.output
147
+ found = rows("list_symptom_categories")
148
+ assert len(found) == 1 and found[0]["status"] == "ok", rows()
149
+
150
+
151
+ def test_cli_triage_is_audited_under_its_mcp_twin_name(tmp_path) -> None:
152
+ events = tmp_path / "events.json"
153
+ events.write_text(
154
+ json.dumps([{"ts": AT, "source": "vcenter", "severity": "warning", "entity": "vcsa", "text": "memory"}]),
155
+ encoding="utf-8",
156
+ )
157
+
158
+ result = CliRunner().invoke(app, ["triage", "--events", str(events)])
159
+
160
+ assert result.exit_code == 0, result.output
161
+ found = rows("incident_timeline")
162
+ assert len(found) == 1 and found[0]["status"] == "ok", rows()
163
+
164
+
165
+ def test_a_failed_cli_triage_is_recorded_as_an_error(tmp_path) -> None:
166
+ bad = tmp_path / "events.json"
167
+ bad.write_text("{not json", encoding="utf-8")
168
+
169
+ result = CliRunner().invoke(app, ["triage", "--events", str(bad)])
170
+
171
+ assert result.exit_code != 0
172
+ found = rows("incident_timeline")
173
+ assert len(found) == 1, f"a failed triage left no row: {rows()}"
174
+ assert found[0]["status"] == "error"
175
+
176
+
177
+ def test_version_writes_no_row() -> None:
178
+ result = CliRunner().invoke(app, ["version"])
179
+
180
+ assert result.exit_code == 0
181
+ assert rows() == []
182
+
183
+
184
+ def _structured(result) -> dict:
185
+ """FastMCP returns (content, structured) or a content list depending on version."""
186
+ if isinstance(result, tuple):
187
+ content, structured = result
188
+ if isinstance(structured, dict):
189
+ return structured.get("result", structured)
190
+ result = content
191
+ return json.loads("".join(getattr(c, "text", "") for c in result))
192
+
193
+
194
+ # ── Result bodies that carry event text are not copied (review D3) ──────────
195
+
196
+ REDACTED_RESULT = json.dumps("[redacted: return value declared sensitive]")
197
+ EVENT_MARKER = "EVENT-TEXT-MARKER-7d21"
198
+
199
+ #: Tools whose result quotes event text the caller fetched from another system —
200
+ #: vCenter event messages, log lines — which can carry anything, credentials
201
+ #: included. incident_timeline returns it as hypotheses[].sample_text and
202
+ #: classification.unmatched_samples; case_timeline returns the same structure
203
+ #: rebuilt from the ledger, plus `rejected` entries that quote the offending row.
204
+ #: Every other tool returns ids, counts, grades and text the caller wrote as an
205
+ #: argument (a summary, a hypothesis statement, a gap's how_to_close), which the
206
+ #: row already holds in `params`.
207
+ SENSITIVE_RESULT_TOOLS = {"incident_timeline", "case_timeline"}
208
+
209
+
210
+ def _events(text: str) -> list[dict]:
211
+ return [{"ts": AT, "source": "vcenter", "severity": "error", "entity": "vc01", "text": text}]
212
+
213
+
214
+ def test_only_tools_that_return_event_text_declare_a_sensitive_result(server) -> None:
215
+ declared = {
216
+ name for name, tool in server._tool_manager._tools.items() if getattr(tool.fn, "_sensitive_result", False)
217
+ }
218
+ assert declared == SENSITIVE_RESULT_TOOLS
219
+
220
+
221
+ def test_incident_timeline_result_body_is_not_copied(server) -> None:
222
+ returned = _structured(call(server, "incident_timeline", {"events": _events(f"{EVENT_MARKER} login failed")}))
223
+
224
+ assert any(EVENT_MARKER in h["sample_text"] for h in returned["hypotheses"]), "the caller must get the real result"
225
+ found = rows("incident_timeline")
226
+ assert len(found) == 1
227
+ assert found[0]["status"] == "ok"
228
+ assert EVENT_MARKER not in found[0]["result"], "event text was copied into audit.db"
229
+ assert found[0]["result"] == REDACTED_RESULT
230
+ assert json.loads(found[0]["params"])["top_n"] == 5
231
+
232
+
233
+ def test_incident_timeline_returned_error_is_still_recorded_as_error(server) -> None:
234
+ call(server, "incident_timeline", {"events": [{"text": EVENT_MARKER}]})
235
+
236
+ found = rows("incident_timeline")
237
+ assert len(found) == 1
238
+ assert found[0]["status"] == "error", "declaring the result sensitive hid the failure"
239
+ assert EVENT_MARKER not in found[0]["result"]
240
+
241
+
242
+ def test_case_timeline_result_body_is_not_copied(server) -> None:
243
+ case_id = _structured(call(server, "case_open", {"summary": "auth failures on vc01", "determined_by": "alarm"}))[
244
+ "case_id"
245
+ ]
246
+ call(
247
+ server,
248
+ "case_submit_evidence",
249
+ {
250
+ "case_id": case_id,
251
+ "source_skill": "vmware-monitor",
252
+ "source_tool": "get_events",
253
+ "summary": "one event",
254
+ "fetched_at": AT,
255
+ "payload": _events(f"{EVENT_MARKER} login failed"),
256
+ },
257
+ )
258
+
259
+ returned = _structured(call(server, "case_timeline", {"case_id": case_id}))
260
+
261
+ assert returned["event_count"] == 1
262
+ found = rows("case_timeline")
263
+ assert len(found) == 1
264
+ assert found[0]["status"] == "ok"
265
+ assert EVENT_MARKER not in found[0]["result"], "event text was copied into audit.db"
266
+ assert json.loads(found[0]["params"])["case_id"] == case_id
267
+
268
+
269
+ def test_case_timeline_returned_error_is_still_recorded_as_error(server) -> None:
270
+ call(server, "case_timeline", {"case_id": "20990101-000000-no-such-case"})
271
+
272
+ found = rows("case_timeline")
273
+ assert len(found) == 1
274
+ assert found[0]["status"] == "error"
@@ -1293,7 +1293,7 @@ wheels = [
1293
1293
 
1294
1294
  [[package]]
1295
1295
  name = "vmware-debug"
1296
- version = "1.12.1"
1296
+ version = "1.12.2"
1297
1297
  source = { editable = "." }
1298
1298
  dependencies = [
1299
1299
  { name = "mcp", extra = ["cli"] },
@@ -1316,7 +1316,7 @@ requires-dist = [
1316
1316
  { name = "pyyaml", specifier = ">=6.0,<7.0" },
1317
1317
  { name = "rich", specifier = ">=13.0,<15.0" },
1318
1318
  { name = "typer", specifier = ">=0.12,<1.0" },
1319
- { name = "vmware-policy", specifier = ">=1.15.0,<2.0" },
1319
+ { name = "vmware-policy", specifier = ">=1.16.0,<2.0" },
1320
1320
  ]
1321
1321
 
1322
1322
  [package.metadata.requires-dev]
@@ -1328,14 +1328,14 @@ dev = [
1328
1328
 
1329
1329
  [[package]]
1330
1330
  name = "vmware-policy"
1331
- version = "1.15.0"
1331
+ version = "1.16.0"
1332
1332
  source = { registry = "https://pypi.org/simple" }
1333
1333
  dependencies = [
1334
1334
  { name = "pyyaml" },
1335
1335
  { name = "rich" },
1336
1336
  { name = "typer" },
1337
1337
  ]
1338
- sdist = { url = "https://files.pythonhosted.org/packages/ac/0c/c7b6c9e2e82b5c8d40b0721bfc9a6428bdd9fd41d8c1c6ec492b94f183f8/vmware_policy-1.15.0.tar.gz", hash = "sha256:48e1850c434dd3f34401b1e3785c073d72b451af904cc2f8c7d3bde772309e8a", size = 181525, upload-time = "2026-09-15T05:43:38.307Z" }
1338
+ sdist = { url = "https://files.pythonhosted.org/packages/6c/b2/489ce2ab84e7d365c2a32ca37598766a78f998865a9c267d588e638217d7/vmware_policy-1.16.0.tar.gz", hash = "sha256:cbb9f387f5fa6aba749f5c9abfdf8678ad9deed1f8272bbf39a0000f6e54a9d5", size = 194662, upload-time = "2026-09-15T08:49:31.863Z" }
1339
1339
  wheels = [
1340
- { url = "https://files.pythonhosted.org/packages/37/26/088f02325e51109cfe8e433d861c28f75f22ce26bb375944d365c46f85c4/vmware_policy-1.15.0-py3-none-any.whl", hash = "sha256:2f5a7b0e57fc084ea9083e67fb6e3c27059e6bd9c75b3eabfed58d6ee4366db4", size = 69524, upload-time = "2026-09-15T05:43:36.606Z" },
1340
+ { url = "https://files.pythonhosted.org/packages/29/50/fa251d9560db49d606f53ea84e05db69db3237d68fdcf3bf68d2ce9dcf97/vmware_policy-1.16.0-py3-none-any.whl", hash = "sha256:0a35621fece1aeaea70157f0d360c7ac0ecd582bd2aaba38c69f32e4eeab415f", size = 73037, upload-time = "2026-09-15T08:49:30.252Z" },
1341
1341
  ]
@@ -6,4 +6,4 @@ remediation to vmware-aiops / vmware-pilot. Never writes to any VMware system
6
6
  and never executes fixes; its only writes go to the local case ledger.
7
7
  """
8
8
 
9
- __version__ = "1.12.1"
9
+ __version__ = "1.12.2"
@@ -20,7 +20,7 @@ from rich.table import Table
20
20
 
21
21
  from vmware_debug import __version__
22
22
  from vmware_debug.mcp.tools import incident_timeline, list_symptom_categories
23
- from vmware_policy import cli_local
23
+ from vmware_policy import audited, cli_local
24
24
 
25
25
 
26
26
  def _harden_console_encoding() -> None:
@@ -61,7 +61,7 @@ def version() -> None:
61
61
 
62
62
 
63
63
  @app.command()
64
- @cli_local("prints the built-in symptom catalogue; contacts nothing")
64
+ @audited(tool="list_symptom_categories")
65
65
  def categories() -> None:
66
66
  """List the symptom categories debug recognises and what to check for each."""
67
67
  table = Table(title="vmware-debug symptom categories")
@@ -74,7 +74,7 @@ def categories() -> None:
74
74
 
75
75
 
76
76
  @app.command()
77
- @cli_local("correlates events read from a file or stdin; contacts nothing")
77
+ @audited(tool="incident_timeline")
78
78
  def triage(
79
79
  events_file: Optional[Path] = typer.Option(
80
80
  None, "--events", "-e", help="JSON file of event envelopes; reads stdin if omitted."
@@ -4,10 +4,22 @@ Tool logic lives in vmware_debug.mcp.tools (the case tools' bodies in
4
4
  ops/cases/api.py). This module wires them into a FastMCP server and provides
5
5
  the stdio entry point.
6
6
 
7
- No tool here goes through vmware_policy's audit decorator, and that is
8
- deliberate: nothing in this skill acts on a VMware target, so there is no
9
- operation for the family audit DB (~/.vmware/audit.db) to record. The case
10
- tools' writes land in the investigation ledger, which is its own record.
7
+ Every tool here goes through vmware_policy's ``@vmware_tool``, so each call —
8
+ a failed one included — writes one row to ~/.vmware/audit.db (HLD §8.1, I-10,
9
+ amended 2026-09-15b). Until then none did: this module said no audit was needed
10
+ because nothing here acts on a VMware target, and the case ledger was offered
11
+ as the record. The ledger records the investigation, not the calls — a live
12
+ session submitted evidence with invented fetch times and nothing could show
13
+ when the calls had really been made or which had failed. Large data the caller
14
+ hands over (``payload``, ``events``) is kept out of the row via
15
+ ``sensitive_params``; it lives in the ledger or with the caller. The two tools
16
+ whose *result* quotes that event text — ``incident_timeline`` and
17
+ ``case_timeline`` (``hypotheses[].sample_text``, ``unmatched_samples``,
18
+ ``rejected``) — declare ``sensitive_result``, so their row keeps the call, the
19
+ arguments and the status but not the result body: event text fetched from
20
+ vCenter or a log can carry anything, credentials included (review D3,
21
+ 2026-09-15). The other tools return ids, counts, grades and text the caller
22
+ wrote as an argument, which the row already holds.
11
23
 
12
24
  Note: signatures here use typing.Optional, never PEP 604 ``X | None`` — FastMCP
13
25
  reflects these at registration and ``X | None`` crashes on Python 3.10 + older
@@ -19,7 +31,7 @@ import sys
19
31
  from typing import Optional, Union
20
32
 
21
33
  from mcp.server.fastmcp import FastMCP
22
- from vmware_policy import describe_tool_parameters, sanitize
34
+ from vmware_policy import describe_tool_parameters, sanitize, vmware_tool
23
35
 
24
36
  from vmware_debug.mcp import tools as t
25
37
  from vmware_debug import __version__
@@ -201,7 +213,8 @@ def build_server() -> FastMCP:
201
213
  # bin_seconds and z_threshold are exactly the two knobs a wrong guess makes
202
214
  # silently useless.
203
215
  @server.tool(name="incident_timeline", annotations=_READ)
204
- def _incident_timeline_impl(
216
+ @vmware_tool(risk_level="low", sensitive_params=["events"], sensitive_result=True)
217
+ def incident_timeline(
205
218
  events: list[dict],
206
219
  bin_seconds: Optional[float] = None,
207
220
  z_threshold: float = 2.0,
@@ -262,7 +275,8 @@ def build_server() -> FastMCP:
262
275
  }
263
276
 
264
277
  @server.tool(name="list_symptom_categories", annotations=_READ)
265
- def _list_symptom_categories_impl() -> dict:
278
+ @vmware_tool(risk_level="low")
279
+ def list_symptom_categories() -> dict:
266
280
  """[READ] List the symptom categories vmware-debug recognises, each with
267
281
  example keywords and a suggested next check (which skill/tool to run).
268
282
  Takes no parameters. Use this when you don't yet know what to look
@@ -287,7 +301,8 @@ def build_server() -> FastMCP:
287
301
  return {"error": _safe_error(exc, tool), "hint": _CASE_ERROR_HINT}
288
302
 
289
303
  @server.tool(name="case_open", annotations=_WRITE_LOCAL)
290
- def _case_open_impl(
304
+ @vmware_tool(risk_level="low")
305
+ def case_open(
291
306
  summary: str,
292
307
  determined_by: str,
293
308
  objects: Optional[list[str]] = None,
@@ -339,7 +354,8 @@ def build_server() -> FastMCP:
339
354
  return _case_error(exc, "case_open")
340
355
 
341
356
  @server.tool(name="case_list", annotations=_READ)
342
- def _case_list_impl(limit: int = 50) -> dict:
357
+ @vmware_tool(risk_level="low")
358
+ def case_list(limit: int = 50) -> dict:
343
359
  """[READ] List investigation cases, newest first.
344
360
 
345
361
  WHEN: to find the id of a case you or someone else opened earlier.
@@ -359,7 +375,8 @@ def build_server() -> FastMCP:
359
375
  return _case_error(exc, "case_list")
360
376
 
361
377
  @server.tool(name="case_get", annotations=_READ)
362
- def _case_get_impl(case_id: str) -> dict:
378
+ @vmware_tool(risk_level="low")
379
+ def case_get(case_id: str) -> dict:
363
380
  """[READ] One case: its scope, its ledger sizes, and its grade history.
364
381
 
365
382
  WHEN: to pick up an investigation, or to see why a case sits at the
@@ -392,7 +409,8 @@ def build_server() -> FastMCP:
392
409
  # what a model fills with invention. Trim the prose here and the cost stays
393
410
  # while the explanation goes.
394
411
  @server.tool(name="case_submit_evidence", annotations=_WRITE_LOCAL)
395
- def _case_submit_evidence_impl(
412
+ @vmware_tool(risk_level="low", sensitive_params=["payload"])
413
+ def case_submit_evidence(
396
414
  case_id: str,
397
415
  source_skill: str,
398
416
  source_tool: str,
@@ -485,7 +503,8 @@ def build_server() -> FastMCP:
485
503
  return _case_error(exc, "case_submit_evidence")
486
504
 
487
505
  @server.tool(name="case_record_gap", annotations=_WRITE_LOCAL)
488
- def _case_record_gap_impl(
506
+ @vmware_tool(risk_level="low")
507
+ def case_record_gap(
489
508
  case_id: str,
490
509
  what: str,
491
510
  why: str,
@@ -535,7 +554,8 @@ def build_server() -> FastMCP:
535
554
  return _case_error(exc, "case_record_gap")
536
555
 
537
556
  @server.tool(name="case_grade", annotations=_WRITE_LOCAL)
538
- def _case_grade_impl(case_id: str) -> dict:
557
+ @vmware_tool(risk_level="low")
558
+ def case_grade(case_id: str) -> dict:
539
559
  """[WRITE] Compute and record the conclusion grade — steps 07/08.
540
560
 
541
561
  WHEN: when you think the investigation has reached a conclusion, or to
@@ -574,7 +594,8 @@ def build_server() -> FastMCP:
574
594
  return _case_error(exc, "case_grade")
575
595
 
576
596
  @server.tool(name="case_readiness", annotations=_READ)
577
- def _case_readiness_impl(available_skills: Optional[list[str]] = None) -> dict:
597
+ @vmware_tool(risk_level="low")
598
+ def case_readiness(available_skills: Optional[list[str]] = None) -> dict:
578
599
  """[READ] What strength of conclusion can this environment reach?
579
600
 
580
601
  WHEN: before starting an investigation, or when a case will not go
@@ -614,7 +635,8 @@ def build_server() -> FastMCP:
614
635
  return _case_error(exc, "case_readiness")
615
636
 
616
637
  @server.tool(name="case_plan", annotations=_READ)
617
- def _case_plan_impl(
638
+ @vmware_tool(risk_level="low")
639
+ def case_plan(
618
640
  case_id: str,
619
641
  category: Optional[str] = None,
620
642
  available_skills: Optional[list[str]] = None,
@@ -675,7 +697,8 @@ def build_server() -> FastMCP:
675
697
  return _case_error(exc, "case_plan")
676
698
 
677
699
  @server.tool(name="case_hypotheses", annotations=_WRITE_LOCAL)
678
- def _case_hypotheses_impl(case_id: str, statement: Optional[str] = None) -> dict:
700
+ @vmware_tool(risk_level="low")
701
+ def case_hypotheses(case_id: str, statement: Optional[str] = None) -> dict:
679
702
  """[WRITE] Register a candidate explanation, or read the ledger — step 06.
680
703
 
681
704
  WHEN: as soon as you have a theory worth testing, and again to see where
@@ -712,7 +735,8 @@ def build_server() -> FastMCP:
712
735
  return _case_error(exc, "case_hypotheses")
713
736
 
714
737
  @server.tool(name="case_timeline", annotations=_WRITE_LOCAL)
715
- def _case_timeline_impl(
738
+ @vmware_tool(risk_level="low", sensitive_result=True)
739
+ def case_timeline(
716
740
  case_id: str,
717
741
  bin_seconds: Optional[float] = None,
718
742
  z_threshold: float = 2.0,
@@ -762,7 +786,8 @@ def build_server() -> FastMCP:
762
786
  return _case_error(exc, "case_timeline")
763
787
 
764
788
  @server.tool(name="case_close", annotations=_WRITE_LOCAL)
765
- def _case_close_impl(case_id: str) -> dict:
789
+ @vmware_tool(risk_level="low")
790
+ def case_close(case_id: str) -> dict:
766
791
  """[WRITE] Record the final grade and archive the case — step 08.
767
792
 
768
793
  WHEN: when the investigation is finished, or is being handed over.
@@ -788,7 +813,8 @@ def build_server() -> FastMCP:
788
813
  return _case_error(exc, "case_close")
789
814
 
790
815
  @server.tool(name="case_knowledge", annotations=_READ)
791
- def _case_knowledge_impl(case_id: Optional[str] = None) -> dict:
816
+ @vmware_tool(risk_level="low")
817
+ def case_knowledge(case_id: Optional[str] = None) -> dict:
792
818
  """[READ] What the knowledge layer accepts, and what is mounted.
793
819
 
794
820
  WHEN: when someone asks what can be added to make conclusions stronger,
File without changes
File without changes
File without changes