vmware-aiops 2.3.1__tar.gz → 2.3.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 (196) hide show
  1. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.claude-plugin/plugin.json +1 -1
  2. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.mcp.json +1 -1
  3. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/PKG-INFO +5 -2
  4. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/README-CN.md +2 -0
  5. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/README.md +3 -0
  6. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/RELEASE_NOTES.md +16 -0
  7. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/openclaw.plugin.json +1 -1
  8. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/package.json +1 -1
  9. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/pyproject.toml +2 -2
  10. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/server.json +2 -2
  11. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/SKILL.md +2 -2
  12. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/setup-guide.md +8 -5
  13. vmware_aiops-2.3.2/tests/eval/regression/test_host_paths_protected.py +239 -0
  14. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_guest_tools_gate.py +3 -1
  15. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/uv.lock +5 -5
  16. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/__init__.py +1 -1
  17. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/guest_gate.py +5 -0
  18. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/guest_ops.py +65 -4
  19. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/ova_deploy.py +3 -0
  20. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/planner.py +26 -5
  21. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_deploy.py +3 -0
  22. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.clawhubignore +0 -0
  23. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.env.example +0 -0
  24. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  25. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  26. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  27. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/dependabot.yml +0 -0
  29. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.gitignore +0 -0
  30. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/Dockerfile +0 -0
  31. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/LICENSE +0 -0
  32. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/SECURITY.md +0 -0
  33. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/config.example.yaml +0 -0
  34. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docker-compose.yml +0 -0
  35. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/continue.md +0 -0
  36. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/cursor.md +0 -0
  37. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/goose.md +0 -0
  38. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/localcowork.md +0 -0
  39. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/mcp-agent.md +0 -0
  40. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/vscode-copilot.md +0 -0
  41. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/plans/2026-03-09-plan-apply-design.md +0 -0
  42. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/deploy.yaml +0 -0
  43. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/README.md +0 -0
  44. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/claude-code.json +0 -0
  45. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/continue.yaml +0 -0
  46. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/cursor.json +0 -0
  47. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/goose.json +0 -0
  48. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/localcowork.json +0 -0
  49. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/mcp-agent.yaml +0 -0
  50. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/vscode-copilot.json +0 -0
  51. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/ollama-local-setup.md +0 -0
  52. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/evals/evals.json +0 -0
  53. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/agent-guardrails.md +0 -0
  54. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/capabilities.md +0 -0
  55. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/cli-reference.md +0 -0
  56. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/investigation-protocol.md +0 -0
  57. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/smithery.yaml +0 -0
  58. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/__init__.py +0 -0
  59. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/conftest.py +0 -0
  60. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/__init__.py +0 -0
  61. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/__init__.py +0 -0
  62. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_family.py +0 -0
  63. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_scores.json +0 -0
  64. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_scoring.py +0 -0
  65. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_skill.py +0 -0
  66. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/conftest.py +0 -0
  67. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_entity_reachability.py +0 -0
  68. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_error_actionability.py +0 -0
  69. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
  70. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  71. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/__init__.py +0 -0
  72. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/_diag_fakes.py +0 -0
  73. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/_pc_fakes.py +0 -0
  74. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_aiops_specific.py +0 -0
  75. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_alarm_health_scale_n1.py +0 -0
  76. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
  77. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_capability_grader.py +0 -0
  78. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_cli_destructive_guards.py +0 -0
  79. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
  80. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_cluster_health_summary_delegation.py +0 -0
  81. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_code_review_fixes.py +0 -0
  82. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_config_path_resolution_is_shared.py +0 -0
  83. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_daemon_calls_are_audited.py +0 -0
  84. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_daemon_host_log_cycles.py +0 -0
  85. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_deploy_correctness.py +0 -0
  86. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
  87. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_documented_gates_match_the_registry.py +0 -0
  88. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_enum_params_match_implementation.py +0 -0
  89. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
  90. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_event_sets_match_real_class_names.py +0 -0
  91. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_events_are_read_newest_first.py +0 -0
  92. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_guest_download_writes_the_local_disk.py +0 -0
  93. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_guest_username_is_explicit.py +0 -0
  94. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_host_log_scan_reads_real_logs.py +0 -0
  95. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_instructions_name_targets.py +0 -0
  96. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_inventory_scale_issue31.py +0 -0
  97. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_list_envelope_conformance.py +0 -0
  98. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_list_plans_is_non_destructive.py +0 -0
  99. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_login_without_vsphere_role.py +0 -0
  100. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_mcp_frame_marks_failures.py +0 -0
  101. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_ova_network_mapping.py +0 -0
  102. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_release_blockers.py +0 -0
  103. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_result_envelope.py +0 -0
  104. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
  105. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_session_probe_eviction.py +0 -0
  106. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_set_ttl_safety.py +0 -0
  107. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_snapshot_async.py +0 -0
  108. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_text_io_declares_encoding.py +0 -0
  109. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_tool_annotations_match_behaviour.py +0 -0
  110. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_unreachable_host_vmks.py +0 -0
  111. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_v1_5_25_blockers.py +0 -0
  112. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
  113. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_access_info.py +0 -0
  114. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_cluster_mgmt_sanitize.py +0 -0
  115. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_cluster_ttl_plan_gate.py +0 -0
  116. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_concurrent_local_state.py +0 -0
  117. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_config.py +0 -0
  118. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_drs_rules.py +0 -0
  119. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_gate_review_fixes.py +0 -0
  120. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_host_network_mgmt.py +0 -0
  121. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_init_and_onboarding.py +0 -0
  122. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_network_mgmt.py +0 -0
  123. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_no_destructive_ops.py +0 -0
  124. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_plan_gate_narrow_fixes.py +0 -0
  125. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_plan_rollback_final_fixes.py +0 -0
  126. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_reset_alarm_gate.py +0 -0
  127. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_results_name_their_target.py +0 -0
  128. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_safe_error_passthrough.py +0 -0
  129. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_session_pool.py +0 -0
  130. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_stop_signal_runs_atexit.py +0 -0
  131. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_vm_delete_blast_radius.py +0 -0
  132. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_vm_tools_gate.py +0 -0
  133. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/_fsutil.py +0 -0
  134. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/access_requirements.py +0 -0
  135. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/__init__.py +0 -0
  136. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/_common.py +0 -0
  137. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/_root.py +0 -0
  138. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/access.py +0 -0
  139. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/alarm.py +0 -0
  140. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/cluster.py +0 -0
  141. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/deploy.py +0 -0
  142. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/doctor.py +0 -0
  143. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/hub.py +0 -0
  144. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/investigate.py +0 -0
  145. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/mcp_config.py +0 -0
  146. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/plan.py +0 -0
  147. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/scan.py +0 -0
  148. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/summary.py +0 -0
  149. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/vm.py +0 -0
  150. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/config.py +0 -0
  151. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/connection.py +0 -0
  152. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/doctor.py +0 -0
  153. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/init_wizard.py +0 -0
  154. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/__init__.py +0 -0
  155. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/__main__.py +0 -0
  156. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/_shared.py +0 -0
  157. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/server.py +0 -0
  158. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/__init__.py +0 -0
  159. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/access.py +0 -0
  160. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/alarm.py +0 -0
  161. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/cluster.py +0 -0
  162. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/datastore.py +0 -0
  163. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/deploy.py +0 -0
  164. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/guest.py +0 -0
  165. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/network.py +0 -0
  166. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/plan.py +0 -0
  167. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/summary.py +0 -0
  168. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/ttl.py +0 -0
  169. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/vm.py +0 -0
  170. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/__init__.py +0 -0
  171. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/audit.py +0 -0
  172. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/logger.py +0 -0
  173. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/webhook.py +0 -0
  174. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/__init__.py +0 -0
  175. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/_paging.py +0 -0
  176. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/alarm_mgmt.py +0 -0
  177. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/cluster_gate.py +0 -0
  178. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/cluster_mgmt.py +0 -0
  179. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/datastore_browser.py +0 -0
  180. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/gate.py +0 -0
  181. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/health.py +0 -0
  182. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/host_network_mgmt.py +0 -0
  183. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/inventory.py +0 -0
  184. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/iscsi_config.py +0 -0
  185. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/network_mgmt.py +0 -0
  186. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/plan_executor.py +0 -0
  187. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/plan_gate.py +0 -0
  188. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/ttl.py +0 -0
  189. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_delete_gate.py +0 -0
  190. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_gate.py +0 -0
  191. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_lifecycle.py +0 -0
  192. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/policy_environment.py +0 -0
  193. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/__init__.py +0 -0
  194. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/alarm_scanner.py +0 -0
  195. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/log_scanner.py +0 -0
  196. {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/scheduler.py +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "vmware-aiops",
4
4
  "displayName": "VMware AIops",
5
- "version": "2.3.1",
5
+ "version": "2.3.2",
6
6
  "description": "AI-powered VMware vCenter/ESXi VM lifecycle and deployment with 61 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-aiops==2.3.1",
8
+ "vmware-aiops==2.3.2",
9
9
  "vmware-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vmware-aiops
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: VMware vCenter/ESXi AI-powered VM lifecycle and deployment tool
5
5
  Project-URL: Homepage, https://github.com/vmware-skills/VMware-AIops
6
6
  Project-URL: Repository, https://github.com/vmware-skills/VMware-AIops
@@ -19,7 +19,7 @@ Requires-Dist: pyvmomi<10.0,>=8.0.3.0
19
19
  Requires-Dist: rich<16.0,>=13.0
20
20
  Requires-Dist: typer<1.0,>=0.12
21
21
  Requires-Dist: vmware-monitor<3.0,>=2.3.1
22
- Requires-Dist: vmware-policy<2.0,>=1.23.0
22
+ Requires-Dist: vmware-policy<2.0,>=1.23.1
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  <!-- mcp-name: io.github.vmware-skills/vmware-aiops -->
@@ -1197,6 +1197,9 @@ claude mcp add --transport http vmware-aiops https://mcp.example.com:8443/mcp \
1197
1197
  (vmware-policy ≥ 1.23.0).
1198
1198
  - Send `traceparent` or `X-Request-ID` with each request to have it stored on the audit row;
1199
1199
  `vmware-audit log --correlation-id ID` then finds the calls behind one platform request.
1200
+ - Tools that read or write files on this server (here: guest file upload/download, OVA and deployment-spec files) never touch its configuration, tokens, audit log or
1201
+ TLS key, and over the network they are off unless `VMWARE_MCP_FILE_ROOT` names a directory for them
1202
+ (vmware-policy ≥ 1.23.1).
1200
1203
  - The audit log records each caller as `token:<name>`. Calls run side by side, with up to `VMWARE_MCP_POOL_SIZE`
1201
1204
  (default 2) sessions per target; each session is a login on the target, and a call waits at most 300 s for one.
1202
1205
  - **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
@@ -837,6 +837,8 @@ claude mcp add --transport http vmware-aiops https://mcp.example.com:8443/mcp \
837
837
  `{"status": "ok"}`(需 vmware-policy ≥ 1.23.0)。
838
838
  - 每个请求带上 `traceparent` 或 `X-Request-ID`,它会记进审计记录;用 `vmware-audit log --correlation-id ID`
839
839
  就能查到平台上某一次请求对应的调用。
840
+ - 会读写本机文件的工具(这里是:虚拟机文件上传/下载、OVA 和部署 spec 文件)永远碰不到本机的配置、token、审计日志和 TLS 私钥;通过网络访问时,
841
+ 除非用 `VMWARE_MCP_FILE_ROOT` 指定一个目录,否则这些工具不能访问本机文件(需 vmware-policy ≥ 1.23.1)。
840
842
  - 审计日志把每个调用者记为 `token:<名字>`。调用并行执行,每个 target 最多 `VMWARE_MCP_POOL_SIZE`(默认 2)
841
843
  个会话;每个会话都是在该 target 上的一次登录,一个调用最多等待 300 秒拿到会话。
842
844
  - **能连到这个端点的人,都以该 server 配置的账号操作。** token 只用来识别调用者,不在平台上授予任何权限。
@@ -1173,6 +1173,9 @@ claude mcp add --transport http vmware-aiops https://mcp.example.com:8443/mcp \
1173
1173
  (vmware-policy ≥ 1.23.0).
1174
1174
  - Send `traceparent` or `X-Request-ID` with each request to have it stored on the audit row;
1175
1175
  `vmware-audit log --correlation-id ID` then finds the calls behind one platform request.
1176
+ - Tools that read or write files on this server (here: guest file upload/download, OVA and deployment-spec files) never touch its configuration, tokens, audit log or
1177
+ TLS key, and over the network they are off unless `VMWARE_MCP_FILE_ROOT` names a directory for them
1178
+ (vmware-policy ≥ 1.23.1).
1176
1179
  - The audit log records each caller as `token:<name>`. Calls run side by side, with up to `VMWARE_MCP_POOL_SIZE`
1177
1180
  (default 2) sessions per target; each session is a login on the target, and a call waits at most 300 s for one.
1178
1181
  - **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
@@ -1,3 +1,19 @@
1
+ ## v2.3.2 — tools may not touch this server's own control files
2
+
3
+ * **Security fix (HLD §18 step 0).** guest file download/upload, OVA and deployment-spec paths could name this server's own files, so over HTTPS a token holder could
4
+ overwrite `$OPS_HOME/mcp-tokens` or `rules.yaml`, or read a skill's `.env`. A path that resolves into `$OPS_HOME`,
5
+ a `~/.vmware-*` directory, a `VMWARE_*_CONFIG` directory, or the server's token file or TLS key/certificate is now
6
+ refused before anything is read or written. Over a network transport these tools read and write host files only
7
+ inside `VMWARE_MCP_FILE_ROOT` (unset: refused). Needs vmware-policy 1.23.1.
8
+ * `download_vm_guest_file` writes without following a link swapped in during the transfer (`O_NOFOLLOW`, a
9
+ re-check after the transfer) and refuses a hard-linked destination; the `provision_vm_guest` preview no longer
10
+ reports the size of a file the run would refuse. Uploads read the source the same way, and a FIFO no longer
11
+ blocks either direction.
12
+ * **`plan_id` can no longer name a path.** `apply_vm_plan` / `rollback_vm_plan` / `list`/`delete` built the plan file
13
+ name from the caller's `plan_id`, and saving used the id stored in the file, so a crafted plan could be written to
14
+ any `*.json` on the host. Plan ids are now `plan-` plus letters, digits and hyphens, and a plan file whose stored id
15
+ differs from its name is refused.
16
+
1
17
  ## v2.3.1 — an account with no vSphere role is named, not passed (AIops#47)
2
18
 
3
19
  Needs vmware-policy 1.23.0, and the vmware-monitor release with the same fix for the `attention` part.
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "vmware-aiops==2.3.1",
14
+ "vmware-aiops==2.3.2",
15
15
  "vmware-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/vmware-aiops",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "description": "AI-powered VMware vCenter/ESXi VM lifecycle and deployment with 61 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-aiops"
7
- version = "2.3.1"
7
+ version = "2.3.2"
8
8
  description = "VMware vCenter/ESXi AI-powered VM lifecycle and deployment tool"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,7 +19,7 @@ dependencies = [
19
19
  "httpx>=0.27,<1.0",
20
20
  "python-dotenv>=1.0,<2.0",
21
21
  "mcp[cli]>=1.28,<2.0",
22
- "vmware-policy>=1.23.0,<2.0",
22
+ "vmware-policy>=1.23.1,<2.0",
23
23
  # Read-only cluster-health triage (summary + HTML snapshot) is owned by
24
24
  # vmware-monitor; AIops delegates to it so a conversation with the AIops
25
25
  # entry point can produce the same report without duplicating the code.
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/vmware-skills/VMware-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "2.3.1",
10
+ "version": "2.3.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-aiops",
15
- "version": "2.3.1",
15
+ "version": "2.3.2",
16
16
  "runtimeHint": "uvx",
17
17
  "packageArguments": [
18
18
  {
@@ -63,7 +63,7 @@ Read before connecting an agent. Per-tool inventory: `references/capabilities.md
63
63
  ## Quick Install
64
64
 
65
65
  ```bash
66
- uv tool install vmware-aiops==2.3.1
66
+ uv tool install vmware-aiops==2.3.2
67
67
  vmware-aiops doctor
68
68
  vmware-aiops hub status # see which family members are installed
69
69
  ```
@@ -303,7 +303,7 @@ The account has no vSphere role. Assign one (Read-Only or higher) on the vCenter
303
303
  ## Setup
304
304
 
305
305
  ```bash
306
- uv tool install vmware-aiops==2.3.1
306
+ uv tool install vmware-aiops==2.3.2
307
307
  mkdir -p ~/.vmware-aiops
308
308
  vmware-aiops init # generates config.yaml and .env templates
309
309
  chmod 600 ~/.vmware-aiops/.env
@@ -6,13 +6,13 @@ All install methods fetch from the same source: [github.com/vmware-skills/VMware
6
6
 
7
7
  ```bash
8
8
  # Via PyPI (recommended for version pinning)
9
- uv tool install vmware-aiops==2.3.1
9
+ uv tool install vmware-aiops==2.3.2
10
10
 
11
11
  # Via Skills.sh (fetches from GitHub)
12
- npx skills add vmware-skills/VMware-AIops#v2.3.1
12
+ npx skills add vmware-skills/VMware-AIops#v2.3.2
13
13
 
14
14
  # Via ClawHub (fetches from ClawHub registry snapshot of GitHub)
15
- clawhub install @zw008/vmware-aiops --version 2.3.1
15
+ clawhub install @zw008/vmware-aiops --version 2.3.2
16
16
  ```
17
17
 
18
18
  ### Claude Code
@@ -35,7 +35,7 @@ claude mcp add vmware-aiops -- vmware-aiops mcp
35
35
 
36
36
  ```bash
37
37
  # 1. Install from PyPI (source: github.com/vmware-skills/VMware-AIops)
38
- uv tool install vmware-aiops==2.3.1
38
+ uv tool install vmware-aiops==2.3.2
39
39
 
40
40
  # 2. Verify installation source
41
41
  vmware-aiops --version # confirms installed version
@@ -73,7 +73,7 @@ The `vmware-aiops` package installs a Python CLI binary and its dependencies (py
73
73
  ## Development Install
74
74
 
75
75
  ```bash
76
- git clone --branch v2.3.1 https://github.com/vmware-skills/VMware-AIops.git
76
+ git clone --branch v2.3.2 https://github.com/vmware-skills/VMware-AIops.git
77
77
  cd VMware-AIops
78
78
  uv venv && source .venv/bin/activate
79
79
  # --no-sources: pyproject's [tool.uv.sources] points vmware-monitor at a sibling
@@ -233,6 +233,9 @@ vmware-aiops mcp --transport https --host 0.0.0.0 --port 8443 \
233
233
  - Correlation: each audit row stores the caller's `traceparent` trace-id, else its `X-Request-ID` /
234
234
  `X-Correlation-ID`. Have the agent platform send one, and `vmware-audit log --correlation-id ID` lists the calls
235
235
  behind one platform request.
236
+ - Tools that read or write files on this server (here: guest file upload/download, OVA and deployment-spec files) never touch its configuration, tokens, audit log or
237
+ TLS key, and over the network they are off unless `VMWARE_MCP_FILE_ROOT` names a directory for them
238
+ (vmware-policy ≥ 1.23.1).
236
239
  - `vmware-audit token list` / `token revoke NAME` manage callers; a revoked token is refused from its next request.
237
240
  Replace a leaked token under a new name.
238
241
  - The server will not start without at least one token, and checks before it binds that unauthenticated requests
@@ -0,0 +1,239 @@
1
+ """Regression: tools could read or write this server's own control files (HLD §18 step 0).
2
+
3
+ Over HTTPS any token holder could call ``download_vm_guest_file(local_path=
4
+ "~/.vmware/mcp-tokens", overwrite=True)`` and replace the token file, or upload
5
+ a skill's ``.env`` into a VM. Every place that opens a caller-supplied host path
6
+ now refuses one that resolves into ``$OPS_HOME`` or a ``~/.vmware-*`` directory,
7
+ before anything is read, written or transferred.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from unittest.mock import MagicMock, patch
15
+
16
+ import pytest
17
+ from vmware_policy.hostpaths import HostPathRefusedError
18
+
19
+ from vmware_aiops.ops import guest_ops, ova_deploy, vm_deploy
20
+
21
+
22
+ @pytest.fixture
23
+ def ops_home() -> Path:
24
+ home = Path(os.environ["OPS_HOME"]) # the sandbox conftest set
25
+ home.mkdir(parents=True, exist_ok=True)
26
+ tokens = home / "mcp-tokens"
27
+ tokens.write_text("alice:abc\n", encoding="utf-8")
28
+ return home
29
+
30
+
31
+ def test_download_cannot_overwrite_the_token_file(ops_home):
32
+ si = MagicMock()
33
+ with pytest.raises(HostPathRefusedError):
34
+ guest_ops.guest_download(
35
+ si, "vm1", "/etc/hosts", str(ops_home / "mcp-tokens"), "u", "p", overwrite=True
36
+ )
37
+ assert (ops_home / "mcp-tokens").read_text(encoding="utf-8") == "alice:abc\n"
38
+ si.RetrieveContent.assert_not_called()
39
+
40
+
41
+ def test_upload_cannot_send_a_control_file_into_a_vm(ops_home):
42
+ si = MagicMock()
43
+ with patch.object(guest_ops, "_require_vm_with_tools", return_value=MagicMock()), \
44
+ patch("urllib.request.urlopen") as urlopen:
45
+ with pytest.raises(HostPathRefusedError):
46
+ guest_ops.guest_upload(
47
+ si, "vm1", str(ops_home / "mcp-tokens"), "/tmp/x", "u", "p"
48
+ )
49
+ urlopen.assert_not_called()
50
+ fm = si.RetrieveContent.return_value.guestOperationsManager.fileManager
51
+ fm.InitiateFileTransferToGuest.assert_not_called()
52
+
53
+
54
+ def test_a_deploy_spec_inside_ops_home_is_not_read(ops_home):
55
+ with pytest.raises(HostPathRefusedError):
56
+ vm_deploy.load_deploy_spec(str(ops_home / "rules.yaml"))
57
+
58
+
59
+ def test_an_ova_inside_ops_home_is_not_read(ops_home):
60
+ with pytest.raises(HostPathRefusedError):
61
+ ova_deploy._read_ovf_from_ova(str(ops_home / "audit.db"))
62
+
63
+
64
+ def test_the_mcp_tool_refuses_before_running(ops_home):
65
+ from vmware_aiops.mcp_server.tools import guest as guest_tools
66
+
67
+ with patch.object(guest_tools, "_get_connection") as connect:
68
+ with pytest.raises(HostPathRefusedError):
69
+ guest_tools.download_vm_guest_file(
70
+ vm_name="vm1",
71
+ guest_path="/etc/hosts",
72
+ local_path=str(ops_home / "mcp-tokens"),
73
+ username="u",
74
+ password="p",
75
+ overwrite=True,
76
+ )
77
+ connect.assert_not_called()
78
+
79
+
80
+ def test_ordinary_paths_still_work(tmp_path):
81
+ spec = tmp_path / "spec.yaml"
82
+ spec.write_text("vms: []\n", encoding="utf-8")
83
+ assert vm_deploy.load_deploy_spec(str(spec)) is not None
84
+
85
+
86
+ # ── review 2026-10-16: hard links, symlink swapped in after the check, preview ──
87
+
88
+
89
+ def _download_with_fake_transfer(local: str, overwrite: bool, before_write=None):
90
+ """Run guest_download with the guest transfer faked; call before_write after the check."""
91
+ si = MagicMock()
92
+ vm = MagicMock()
93
+ resp = MagicMock()
94
+ resp.__enter__.return_value.read.return_value = b"payload"
95
+
96
+ def urlopen(*a, **k):
97
+ if before_write:
98
+ before_write()
99
+ return resp
100
+
101
+ with patch.object(guest_ops, "_require_vm_with_tools", return_value=vm), \
102
+ patch("urllib.request.urlopen", side_effect=urlopen), \
103
+ patch.object(guest_ops, "get_verify_ssl", return_value=True):
104
+ return guest_ops.guest_download(
105
+ si, "vm1", "/etc/motd", local, "u", "p", overwrite=overwrite
106
+ )
107
+
108
+
109
+ def test_a_hard_link_to_a_protected_file_is_not_written_through(ops_home, tmp_path):
110
+ outside = tmp_path / "innocent.txt"
111
+ os.link(ops_home / "mcp-tokens", outside)
112
+ with pytest.raises(ValueError, match="hard link"):
113
+ _download_with_fake_transfer(str(outside), overwrite=True)
114
+ assert (ops_home / "mcp-tokens").read_text(encoding="utf-8") == "alice:abc\n"
115
+
116
+
117
+ def test_a_symlink_swapped_in_during_the_transfer_is_not_followed(ops_home, tmp_path):
118
+ dest = tmp_path / "out.txt"
119
+
120
+ def swap():
121
+ dest.symlink_to(ops_home / "mcp-tokens")
122
+
123
+ with pytest.raises((HostPathRefusedError, OSError)):
124
+ _download_with_fake_transfer(str(dest), overwrite=True, before_write=swap)
125
+ assert (ops_home / "mcp-tokens").read_text(encoding="utf-8") == "alice:abc\n"
126
+
127
+
128
+ def test_an_ordinary_download_is_written(tmp_path):
129
+ dest = tmp_path / "out.txt"
130
+ _download_with_fake_transfer(str(dest), overwrite=False)
131
+ assert dest.read_bytes() == b"payload"
132
+
133
+
134
+ def test_the_provision_preview_does_not_reveal_a_protected_file(ops_home):
135
+ from vmware_aiops.ops import guest_gate
136
+
137
+ info, blocker = guest_gate._local_file(str(ops_home / "mcp-tokens"))
138
+ assert info["local_size_bytes"] is None
139
+ assert blocker and "configuration, tokens and audit log" in blocker
140
+
141
+
142
+ def test_a_parent_dir_swapped_for_a_link_during_the_transfer_is_caught(ops_home, tmp_path):
143
+ # O_NOFOLLOW guards only the last component; the re-check after the transfer
144
+ # catches a parent directory replaced by a link into OPS_HOME.
145
+ sub = tmp_path / "sub"
146
+ sub.mkdir()
147
+
148
+ def swap():
149
+ sub.rmdir()
150
+ sub.symlink_to(ops_home, target_is_directory=True)
151
+
152
+ with pytest.raises(HostPathRefusedError):
153
+ _download_with_fake_transfer(str(sub / "rules.yaml"), overwrite=False, before_write=swap)
154
+ assert not (ops_home / "rules.yaml").exists()
155
+
156
+
157
+ # ── second review 2026-10-16: plan_id was an unchecked host path ──────────────
158
+
159
+
160
+ @pytest.mark.parametrize(
161
+ "bad",
162
+ ["/tmp/evil", "../../.cursor/mcp", "plan-x/../../evil", "plan-..", "", "plan-a.b", "x" * 300],
163
+ )
164
+ def test_a_plan_id_cannot_name_a_path(bad, tmp_path, monkeypatch):
165
+ from vmware_aiops.ops import planner
166
+
167
+ monkeypatch.setattr(planner, "_PLANS_DIR", tmp_path / "plans")
168
+ with pytest.raises(ValueError, match="plan id"):
169
+ planner.load_plan(bad)
170
+ with pytest.raises(ValueError, match="plan id"):
171
+ planner.delete_plan(bad)
172
+ with pytest.raises(ValueError, match="plan id"):
173
+ planner.save_plan({"plan_id": bad, "steps": []})
174
+
175
+
176
+ def test_a_plan_file_naming_another_plan_id_is_refused(tmp_path, monkeypatch):
177
+ import json
178
+
179
+ from vmware_aiops.ops import planner
180
+
181
+ plans = tmp_path / "plans"
182
+ plans.mkdir()
183
+ monkeypatch.setattr(planner, "_PLANS_DIR", plans)
184
+ (plans / "plan-20261016-000000-abcd.json").write_text(
185
+ json.dumps({"plan_id": "../../.cursor/mcp", "steps": []}), encoding="utf-8"
186
+ )
187
+ with pytest.raises(ValueError, match="plan id"):
188
+ planner.load_plan("plan-20261016-000000-abcd")
189
+
190
+
191
+ def test_a_generated_plan_id_round_trips(tmp_path, monkeypatch):
192
+ from vmware_aiops.ops import planner
193
+
194
+ monkeypatch.setattr(planner, "_PLANS_DIR", tmp_path / "plans")
195
+ (tmp_path / "plans").mkdir()
196
+ pid = planner._generate_plan_id()
197
+ planner.save_plan({"plan_id": pid, "steps": []})
198
+ assert planner.load_plan(pid)["plan_id"] == pid
199
+ planner.delete_plan(pid)
200
+ assert planner.load_plan(pid) is None
201
+
202
+
203
+ def test_download_onto_a_fifo_does_not_hang(tmp_path):
204
+ fifo = tmp_path / "pipe"
205
+ os.mkfifo(fifo)
206
+ with pytest.raises((ValueError, OSError)):
207
+ _download_with_fake_transfer(str(fifo), overwrite=True)
208
+
209
+
210
+ def test_upload_reads_a_regular_file_without_following_a_swapped_link(ops_home, tmp_path):
211
+ src = tmp_path / "src.txt"
212
+ src.write_text("data", encoding="utf-8")
213
+ assert guest_ops._read_upload_source(src) == b"data"
214
+ link = tmp_path / "link.txt"
215
+ link.symlink_to(ops_home / "mcp-tokens")
216
+ with pytest.raises(OSError):
217
+ guest_ops._read_upload_source(link)
218
+
219
+
220
+ def test_upload_source_swapped_for_a_fifo_is_refused_not_hung(tmp_path):
221
+ fifo = tmp_path / "pipe"
222
+ os.mkfifo(fifo)
223
+ with pytest.raises(ValueError, match="regular file"):
224
+ guest_ops._read_upload_source(fifo)
225
+
226
+
227
+ def test_a_plan_id_with_a_trailing_newline_is_refused(tmp_path, monkeypatch):
228
+ from vmware_aiops.ops import planner
229
+
230
+ monkeypatch.setattr(planner, "_PLANS_DIR", tmp_path / "plans")
231
+ with pytest.raises(ValueError, match="plan id"):
232
+ planner.load_plan("plan-abc\n")
233
+
234
+
235
+ def test_an_upload_source_that_is_a_hard_link_is_refused(ops_home, tmp_path):
236
+ link = tmp_path / "innocent.txt"
237
+ os.link(ops_home / "mcp-tokens", link)
238
+ with pytest.raises(ValueError, match="hard link"):
239
+ guest_ops._read_upload_source(link)
@@ -37,7 +37,9 @@ GATED = ("run_vm_guest_command", "run_vm_guest_command_with_output", "upload_vm_
37
37
 
38
38
  @pytest.fixture(autouse=True)
39
39
  def harness(tmp_path, monkeypatch):
40
- monkeypatch.setenv("OPS_HOME", str(tmp_path))
40
+ # OPS_HOME in its own subdirectory: the files these tests upload live in
41
+ # tmp_path, and a file inside OPS_HOME is refused (HLD §18 step 0).
42
+ monkeypatch.setenv("OPS_HOME", str(tmp_path / "ops"))
41
43
  reset_policy_engine()
42
44
  reset_budget()
43
45
  reset_undo_store()
@@ -1195,7 +1195,7 @@ wheels = [
1195
1195
 
1196
1196
  [[package]]
1197
1197
  name = "vmware-aiops"
1198
- version = "2.3.1"
1198
+ version = "2.3.2"
1199
1199
  source = { editable = "." }
1200
1200
  dependencies = [
1201
1201
  { name = "apscheduler" },
@@ -1228,7 +1228,7 @@ requires-dist = [
1228
1228
  { name = "rich", specifier = ">=13.0,<16.0" },
1229
1229
  { name = "typer", specifier = ">=0.12,<1.0" },
1230
1230
  { name = "vmware-monitor", editable = "../VMware-Monitor" },
1231
- { name = "vmware-policy", specifier = ">=1.23.0,<2.0" },
1231
+ { name = "vmware-policy", specifier = ">=1.23.1,<2.0" },
1232
1232
  ]
1233
1233
 
1234
1234
  [package.metadata.requires-dev]
@@ -1276,14 +1276,14 @@ dev = [
1276
1276
 
1277
1277
  [[package]]
1278
1278
  name = "vmware-policy"
1279
- version = "1.23.0"
1279
+ version = "1.23.1"
1280
1280
  source = { registry = "https://pypi.org/simple" }
1281
1281
  dependencies = [
1282
1282
  { name = "pyyaml" },
1283
1283
  { name = "rich" },
1284
1284
  { name = "typer" },
1285
1285
  ]
1286
- sdist = { url = "https://files.pythonhosted.org/packages/1b/ef/2c56cc93e0744d7e68b75f7a288f580215865b4685a07b9d58e49773819b/vmware_policy-1.23.0.tar.gz", hash = "sha256:70a682fa44e36fadee7ca09c7c5411fff51c9e25ad9d638f9877737361e2ae31", size = 325401, upload-time = "2026-10-16T00:10:32.297Z" }
1286
+ sdist = { url = "https://files.pythonhosted.org/packages/ec/2c/03d2327478838ed3d407c7fc30ebbd6a2cf3b3d2c58285ba6653697224b0/vmware_policy-1.23.1.tar.gz", hash = "sha256:6fe55a99fc71f9591bedd3c5dcc00042559953f02a7bf7da9d2b9b4638877c08", size = 332522, upload-time = "2026-10-16T02:55:48.081Z" }
1287
1287
  wheels = [
1288
- { url = "https://files.pythonhosted.org/packages/e3/ca/bdfc9e50cedf6684ed7d34348177f7688323617ce26d492f55b3d80cd45c/vmware_policy-1.23.0-py3-none-any.whl", hash = "sha256:2f35a97f7d0997dd49ab5a0d8d0fd5f4eae11ef66ca8ed26bc9e4b395cb0d45c", size = 110836, upload-time = "2026-10-16T00:10:30.55Z" },
1288
+ { url = "https://files.pythonhosted.org/packages/b7/22/94fc30b3e273fd0e9739fb11ac0bfa8232352cefcaf95ee25229bb277733/vmware_policy-1.23.1-py3-none-any.whl", hash = "sha256:4afe95894e8a05556dfeb9e2c6a37186cf8c4bf3017131dedfb8c23478514cec", size = 114484, upload-time = "2026-10-16T02:55:46.591Z" },
1289
1289
  ]
@@ -1,3 +1,3 @@
1
1
  """VMware AIops - AI-powered vCenter/ESXi monitoring and operations."""
2
2
 
3
- __version__ = "2.3.1"
3
+ __version__ = "2.3.2"
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any
25
25
 
26
26
  from pyVmomi import vim, vmodl
27
27
  from vmware_policy import sanitize
28
+ from vmware_policy.hostpaths import HostPathRefusedError, check_host_path
28
29
 
29
30
  from vmware_aiops.ops import vm_lifecycle
30
31
  from vmware_aiops.ops.guest_ops import _FAMILY_WINDOWS, _detect_shell
@@ -148,6 +149,10 @@ def _local_file(path: str) -> tuple[dict[str, Any], str | None]:
148
149
  """
149
150
  local = Path(path).expanduser()
150
151
  info: dict[str, Any] = {"local_path": _text(path, MAX_PATH_CHARS), "local_size_bytes": None}
152
+ try:
153
+ check_host_path(path) # the preview must not reveal what the run refuses
154
+ except HostPathRefusedError as exc:
155
+ return info, _text(str(exc), 300)
151
156
  if not local.is_file():
152
157
  return info, (
153
158
  f"Local upload source is not an existing regular file: {_text(local, MAX_PATH_CHARS)}. "
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import logging
10
10
  import os
11
11
  import shlex
12
+ import stat
12
13
  import tempfile
13
14
  import time
14
15
  import uuid
@@ -18,6 +19,7 @@ from typing import TYPE_CHECKING
18
19
  from pyVmomi import vim
19
20
 
20
21
  from vmware_policy import sanitize
22
+ from vmware_policy.hostpaths import check_host_path
21
23
 
22
24
  from vmware_aiops.connection import get_verify_ssl
23
25
  from vmware_aiops.ops.inventory import find_vm_by_name
@@ -412,6 +414,8 @@ def guest_upload(
412
414
 
413
415
  auth = _guest_auth(username, password)
414
416
 
417
+ # Never this server's own config, tokens or audit log (HLD §18 step 0).
418
+ check_host_path(local_path)
415
419
  # Read local file — must be an existing regular file (not a dir/device/fifo).
416
420
  local = Path(local_path).expanduser()
417
421
  if not local.is_file():
@@ -426,8 +430,7 @@ def guest_upload(
426
430
  f"(chmod +r) to the user running vmware-aiops, or pass a readable "
427
431
  f"path to --local."
428
432
  )
429
- with open(local, "rb") as f:
430
- file_data = f.read()
433
+ file_data = _read_upload_source(local)
431
434
 
432
435
  file_size = len(file_data)
433
436
 
@@ -462,6 +465,61 @@ def guest_upload(
462
465
  return f"Uploaded {file_size} bytes to {guest_path} on VM '{vm_name}'"
463
466
 
464
467
 
468
+ def _read_upload_source(local: Path) -> bytes:
469
+ """Read the upload source without following a link swapped in after the checks.
470
+
471
+ ``O_NOFOLLOW`` refuses a final symlink and ``O_NONBLOCK`` keeps a FIFO from
472
+ blocking the worker; anything but a regular file is refused after opening.
473
+ """
474
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
475
+ fd = os.open(local, flags)
476
+ try:
477
+ st = os.fstat(fd)
478
+ if not stat.S_ISREG(st.st_mode):
479
+ raise ValueError(
480
+ f"Local upload source is not a regular file: {local}. Pass a regular file."
481
+ )
482
+ if st.st_nlink > 1: # another path shares it, possibly a protected one
483
+ raise ValueError(
484
+ f"Local upload source is a hard link: {local}. Copy the file and upload the copy."
485
+ )
486
+ with os.fdopen(fd, "rb") as f:
487
+ fd = -1
488
+ return f.read()
489
+ finally:
490
+ if fd != -1:
491
+ os.close(fd)
492
+
493
+
494
+ def _write_download(local: Path, data: bytes, *, overwrite: bool) -> None:
495
+ """Write a downloaded file without following a link swapped in after the checks.
496
+
497
+ ``O_NOFOLLOW`` refuses a final symlink; ``O_EXCL`` keeps the no-overwrite
498
+ promise even if a file appeared meanwhile; and a destination that is not a
499
+ single regular file (a hard link shares its inode with another path, which
500
+ may be a protected one) is refused before anything is truncated.
501
+ """
502
+ # O_NONBLOCK: an existing FIFO must not block the worker in open().
503
+ flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
504
+ if not overwrite:
505
+ flags |= os.O_EXCL
506
+ fd = os.open(local, flags, 0o666)
507
+ try:
508
+ st = os.fstat(fd)
509
+ if not stat.S_ISREG(st.st_mode) or st.st_nlink > 1:
510
+ raise ValueError(
511
+ "Choose a new local path: the destination is a hard link or not a regular "
512
+ f"file, so writing it would change another file too. Path: {local}"
513
+ )
514
+ os.ftruncate(fd, 0)
515
+ with os.fdopen(fd, "wb") as f:
516
+ fd = -1
517
+ f.write(data)
518
+ finally:
519
+ if fd != -1:
520
+ os.close(fd)
521
+
522
+
465
523
  def _check_download_destination(local_path: str, overwrite: bool) -> None:
466
524
  """Refuse a download destination that is not free to write.
467
525
 
@@ -487,6 +545,8 @@ def _check_download_destination(local_path: str, overwrite: bool) -> None:
487
545
  is caller-supplied and unbounded, so a message that closed with its remedy
488
546
  would lose exactly the part the agent needs.
489
547
  """
548
+ # Never this server's own config, tokens or audit log (HLD §18 step 0).
549
+ check_host_path(local_path)
490
550
  local = Path(local_path).expanduser()
491
551
  if local.is_symlink():
492
552
  raise ValueError(
@@ -566,8 +626,9 @@ def guest_download(
566
626
 
567
627
  local = Path(local_path).expanduser()
568
628
  local.parent.mkdir(parents=True, exist_ok=True)
569
- with open(local, "wb") as f:
570
- f.write(file_data)
629
+ # The transfer took time: check again, then write without following a link.
630
+ check_host_path(local_path)
631
+ _write_download(local, file_data, overwrite=overwrite)
571
632
 
572
633
  logger.info(
573
634
  "Downloaded %d bytes from '%s:%s' to '%s'",
@@ -17,6 +17,7 @@ from urllib.request import Request, urlopen
17
17
 
18
18
  from pyVmomi import vim
19
19
  from vmware_policy import sanitize
20
+ from vmware_policy.hostpaths import check_host_path
20
21
 
21
22
  from vmware_aiops.connection import get_verify_ssl
22
23
  from vmware_aiops.ops.inventory import (
@@ -101,6 +102,7 @@ def _read_ovf_from_ova(ova_path: str) -> tuple[str, dict[str, int]]:
101
102
  Returns:
102
103
  Tuple of (ovf_xml_string, {vmdk_filename: file_size_bytes})
103
104
  """
105
+ check_host_path(ova_path) # never this server's own files (HLD §18 step 0)
104
106
  disks: dict[str, int] = {}
105
107
  ovf_content = ""
106
108
 
@@ -158,6 +160,7 @@ def _upload_disk(
158
160
  if not upload_url.lower().startswith("https://"):
159
161
  raise ValueError(f"Refusing non-HTTPS upload URL: {upload_url}")
160
162
 
163
+ check_host_path(ova_path) # opened again here; check again (HLD §18 step 0)
161
164
  with tarfile.open(ova_path, "r", encoding="utf-8") as tar:
162
165
  member = tar.getmember(disk_name)
163
166
  if not _safe_tar_member(member):