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.
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.claude-plugin/plugin.json +1 -1
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.mcp.json +1 -1
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/PKG-INFO +5 -2
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/README-CN.md +2 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/README.md +3 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/RELEASE_NOTES.md +16 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/openclaw.plugin.json +1 -1
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/package.json +1 -1
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/pyproject.toml +2 -2
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/server.json +2 -2
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/SKILL.md +2 -2
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/setup-guide.md +8 -5
- vmware_aiops-2.3.2/tests/eval/regression/test_host_paths_protected.py +239 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_guest_tools_gate.py +3 -1
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/uv.lock +5 -5
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/__init__.py +1 -1
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/guest_gate.py +5 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/guest_ops.py +65 -4
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/ova_deploy.py +3 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/planner.py +26 -5
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_deploy.py +3 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.clawhubignore +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.env.example +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.github/dependabot.yml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/.gitignore +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/Dockerfile +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/LICENSE +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/SECURITY.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/config.example.yaml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docker-compose.yml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/continue.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/cursor.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/goose.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/localcowork.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/mcp-agent.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/integrations/vscode-copilot.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/docs/plans/2026-03-09-plan-apply-design.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/deploy.yaml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/README.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/claude-code.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/continue.yaml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/cursor.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/goose.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/localcowork.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/mcp-agent.yaml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/mcp-configs/vscode-copilot.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/examples/ollama-local-setup.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/evals/evals.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/agent-guardrails.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/capabilities.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/cli-reference.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/skills/vmware-aiops/references/investigation-protocol.md +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/smithery.yaml +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/conftest.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_family.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_scores.json +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_scoring.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/_skill.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/conftest.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_entity_reachability.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_error_actionability.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/_diag_fakes.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/_pc_fakes.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_aiops_specific.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_alarm_health_scale_n1.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_capability_grader.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_cli_destructive_guards.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_cluster_health_summary_delegation.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_code_review_fixes.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_config_path_resolution_is_shared.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_daemon_calls_are_audited.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_daemon_host_log_cycles.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_deploy_correctness.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_documented_gates_match_the_registry.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_enum_params_match_implementation.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_event_sets_match_real_class_names.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_events_are_read_newest_first.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_guest_download_writes_the_local_disk.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_guest_username_is_explicit.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_host_log_scan_reads_real_logs.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_instructions_name_targets.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_inventory_scale_issue31.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_list_envelope_conformance.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_list_plans_is_non_destructive.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_login_without_vsphere_role.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_mcp_frame_marks_failures.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_ova_network_mapping.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_release_blockers.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_result_envelope.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_session_probe_eviction.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_set_ttl_safety.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_snapshot_async.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_text_io_declares_encoding.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_tool_annotations_match_behaviour.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_unreachable_host_vmks.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_v1_5_25_blockers.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_access_info.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_cluster_mgmt_sanitize.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_cluster_ttl_plan_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_concurrent_local_state.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_config.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_drs_rules.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_gate_review_fixes.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_host_network_mgmt.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_init_and_onboarding.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_network_mgmt.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_no_destructive_ops.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_plan_gate_narrow_fixes.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_plan_rollback_final_fixes.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_reset_alarm_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_results_name_their_target.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_safe_error_passthrough.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_session_pool.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_stop_signal_runs_atexit.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_vm_delete_blast_radius.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/tests/test_vm_tools_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/_fsutil.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/access_requirements.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/_common.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/_root.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/access.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/alarm.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/cluster.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/deploy.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/doctor.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/hub.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/investigate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/mcp_config.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/plan.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/scan.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/summary.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/cli/vm.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/config.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/connection.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/doctor.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/init_wizard.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/__main__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/_shared.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/server.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/access.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/alarm.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/cluster.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/datastore.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/deploy.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/guest.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/network.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/plan.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/summary.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/ttl.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/mcp_server/tools/vm.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/audit.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/logger.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/notify/webhook.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/_paging.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/alarm_mgmt.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/cluster_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/cluster_mgmt.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/datastore_browser.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/health.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/host_network_mgmt.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/inventory.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/iscsi_config.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/network_mgmt.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/plan_executor.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/plan_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/ttl.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_delete_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_gate.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/ops/vm_lifecycle.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/policy_environment.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/__init__.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/alarm_scanner.py +0 -0
- {vmware_aiops-2.3.1 → vmware_aiops-2.3.2}/vmware_aiops/scanner/log_scanner.py +0 -0
- {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.
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vmware-aiops
|
|
3
|
-
Version: 2.3.
|
|
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.
|
|
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.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vmware-aiops"
|
|
7
|
-
version = "2.3.
|
|
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.
|
|
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.
|
|
10
|
+
"version": "2.3.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "vmware-aiops",
|
|
15
|
-
"version": "2.3.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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/
|
|
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
|
]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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):
|