vmware-policy 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: vmware-policy
3
+ Version: 1.4.0
4
+ Summary: Unified audit logging, policy enforcement, and sanitization for VMware MCP skills
5
+ Author: Wei Zhou / 周崴
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: rich<15.0,>=13.0
9
+ Requires-Dist: typer<1.0,>=0.12
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-cov<8.0,>=5.0; extra == 'dev'
12
+ Requires-Dist: pytest<10.0,>=8.0; extra == 'dev'
13
+ Requires-Dist: ruff<1.0,>=0.5; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # VMware Policy
17
+
18
+ Unified audit logging, policy enforcement, and sanitization for the VMware MCP skill family.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install vmware-policy
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from vmware_policy import vmware_tool
30
+
31
+ @vmware_tool(risk_level="high", sensitive_params=["password"])
32
+ def delete_segment(name: str, env: str = "") -> dict:
33
+ ...
34
+ ```
35
+
36
+ ## CLI
37
+
38
+ ```bash
39
+ vmware-audit log --last 20
40
+ vmware-audit log --status denied --since 2026-03-28
41
+ vmware-audit stats --days 7
42
+ ```
@@ -0,0 +1,27 @@
1
+ # VMware Policy
2
+
3
+ Unified audit logging, policy enforcement, and sanitization for the VMware MCP skill family.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install vmware-policy
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from vmware_policy import vmware_tool
15
+
16
+ @vmware_tool(risk_level="high", sensitive_params=["password"])
17
+ def delete_segment(name: str, env: str = "") -> dict:
18
+ ...
19
+ ```
20
+
21
+ ## CLI
22
+
23
+ ```bash
24
+ vmware-audit log --last 20
25
+ vmware-audit log --status denied --since 2026-03-28
26
+ vmware-audit stats --days 7
27
+ ```
@@ -0,0 +1,54 @@
1
+ # VMware Policy — Version Strategy
2
+
3
+ ## Independent Versioning
4
+
5
+ vmware-policy uses its own version number, independent of the VMware skill family (currently v1.3.5).
6
+
7
+ **Rationale:** Policy is infrastructure-layer software with a different iteration cadence than business skills. Binding versions would force all 7 skills to release whenever policy gets a patch.
8
+
9
+ ## Semantic Versioning (semver)
10
+
11
+ | Version bump | When | Example |
12
+ |---|---|---|
13
+ | Patch (1.0.x) | Bug fixes, logging improvements, rule additions | 1.0.0 -> 1.0.1 |
14
+ | Minor (1.x.0) | New features (backward compatible): new decorator params, new CLI commands | 1.0.0 -> 1.1.0 |
15
+ | Major (x.0.0) | Breaking changes to decorator signature or audit.db schema | 1.0.0 -> 2.0.0 |
16
+
17
+ ## Stability Guarantees (v1.x)
18
+
19
+ The following are **public API** and will NOT change in v1.x:
20
+
21
+ 1. `@vmware_tool` decorator signature (all params remain optional, keyword-only)
22
+ 2. `_is_vmware_tool` attribute on decorated functions
23
+ 3. `_risk_level`, `_idempotent`, `_timeout_seconds`, `_sensitive_params` metadata attributes
24
+ 4. `sanitize(text, max_len)` function signature
25
+ 5. `audit.db` table schema (columns may be added, never removed or renamed)
26
+ 6. `PolicyDenied` exception class
27
+ 7. `vmware-audit` CLI command names (log, export, stats)
28
+
29
+ ## Dependency Declaration
30
+
31
+ All skills should declare:
32
+
33
+ ```toml
34
+ "vmware-policy>=1.0.0,<2.0"
35
+ ```
36
+
37
+ This allows any 1.x patch/minor update without requiring skill changes.
38
+
39
+ ## Release Checklist
40
+
41
+ 1. Update `vmware_policy/__init__.py` version
42
+ 2. Update `pyproject.toml` version
43
+ 3. Run `pytest tests/ -v` (all must pass)
44
+ 4. Build: `python -m build`
45
+ 5. Tag: `git tag v1.x.x`
46
+ 6. Publish to PyPI: `uv publish`
47
+
48
+ ## vmware-harness Versioning
49
+
50
+ vmware-harness also has its own version (starting at v0.1.0, pre-stable).
51
+
52
+ - v0.x: API may change without major version bump
53
+ - v1.0: First stable release (after production validation)
54
+ - Same semver rules as vmware-policy after v1.0
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vmware-policy"
7
+ version = "1.4.0"
8
+ description = "Unified audit logging, policy enforcement, and sanitization for VMware MCP skills"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Wei Zhou / 周崴" }]
13
+ dependencies = [
14
+ "typer>=0.12,<1.0",
15
+ "rich>=13.0,<15.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0,<10.0",
21
+ "pytest-cov>=5.0,<8.0",
22
+ "ruff>=0.5,<1.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ vmware-audit = "vmware_policy.cli:app"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["vmware_policy"]
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "N", "W", "UP"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ markers = [
41
+ "unit: Unit tests",
42
+ ]
File without changes
@@ -0,0 +1,107 @@
1
+ """Tests for vmware_policy.audit."""
2
+
3
+ import sqlite3
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from vmware_policy.audit import AuditEngine, detect_agent
10
+
11
+
12
+ @pytest.mark.unit
13
+ class TestAuditEngine:
14
+ def setup_method(self):
15
+ self._tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
16
+ self._tmp.close()
17
+ self.db_path = Path(self._tmp.name)
18
+ self.engine = AuditEngine(self.db_path)
19
+
20
+ def teardown_method(self):
21
+ self.db_path.unlink(missing_ok=True)
22
+
23
+ def test_creates_db_and_table(self):
24
+ conn = sqlite3.connect(str(self.db_path))
25
+ tables = conn.execute(
26
+ "SELECT name FROM sqlite_master WHERE type='table'"
27
+ ).fetchall()
28
+ conn.close()
29
+ assert ("audit_log",) in tables
30
+
31
+ def test_wal_mode_enabled(self):
32
+ conn = sqlite3.connect(str(self.db_path))
33
+ mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
34
+ conn.close()
35
+ assert mode == "wal"
36
+
37
+ def test_log_writes_record(self):
38
+ self.engine.log(skill="aiops", tool="vm_power_on", status="ok")
39
+ rows = self.engine.query(limit=10)
40
+ assert len(rows) == 1
41
+ assert rows[0]["skill"] == "aiops"
42
+ assert rows[0]["tool"] == "vm_power_on"
43
+ assert rows[0]["status"] == "ok"
44
+
45
+ def test_log_denied_writes_record(self):
46
+ self.engine.log(skill="nsx", tool="delete_segment", status="denied")
47
+ rows = self.engine.query(status="denied")
48
+ assert len(rows) == 1
49
+ assert rows[0]["status"] == "denied"
50
+
51
+ def test_query_filters(self):
52
+ self.engine.log(skill="aiops", tool="vm_power_on", status="ok")
53
+ self.engine.log(skill="nsx", tool="delete_segment", status="denied")
54
+ self.engine.log(skill="aiops", tool="vm_power_off", status="error")
55
+
56
+ assert len(self.engine.query(skill="aiops")) == 2
57
+ assert len(self.engine.query(status="denied")) == 1
58
+ assert len(self.engine.query(tool="vm_power_on")) == 1
59
+
60
+ def test_stats(self):
61
+ self.engine.log(skill="aiops", tool="a", status="ok")
62
+ self.engine.log(skill="aiops", tool="b", status="ok")
63
+ self.engine.log(skill="nsx", tool="c", status="denied")
64
+ data = self.engine.stats(days=1)
65
+ assert data["total"] == 3
66
+ assert data["by_skill"]["aiops"] == 2
67
+ assert data["by_status"]["denied"] == 1
68
+
69
+ def test_log_never_raises(self):
70
+ """Audit logging should swallow errors, not crash the tool."""
71
+ # Use an invalid path that can't be written
72
+ bad_engine = AuditEngine("/nonexistent/path/audit.db")
73
+ bad_engine.log(skill="test", tool="test") # should not raise
74
+
75
+ def test_sensitive_params_stored(self):
76
+ self.engine.log(
77
+ skill="aiops",
78
+ tool="guest_exec",
79
+ params={"command": "ls", "password": "***"},
80
+ status="ok",
81
+ )
82
+ rows = self.engine.query(limit=1)
83
+ assert "***" in rows[0]["params"]
84
+
85
+
86
+ @pytest.mark.unit
87
+ class TestDetectAgent:
88
+ def test_default_unknown(self, monkeypatch):
89
+ monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False)
90
+ monkeypatch.delenv("CLAUDE_CODE", raising=False)
91
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
92
+ monkeypatch.delenv("OLLAMA_HOST", raising=False)
93
+ monkeypatch.delenv("DEERFLOW_SESSION", raising=False)
94
+ monkeypatch.delenv("CODEX_SESSION", raising=False)
95
+ assert detect_agent() == "unknown"
96
+
97
+ def test_detect_claude(self, monkeypatch):
98
+ monkeypatch.setenv("CLAUDE_CODE", "1")
99
+ assert detect_agent() == "claude"
100
+
101
+ def test_detect_local(self, monkeypatch):
102
+ monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False)
103
+ monkeypatch.delenv("CLAUDE_CODE", raising=False)
104
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
105
+ monkeypatch.delenv("CODEX_SESSION", raising=False)
106
+ monkeypatch.setenv("OLLAMA_HOST", "http://localhost:11434")
107
+ assert detect_agent() == "local"
@@ -0,0 +1,147 @@
1
+ """Tests for vmware_policy.decorators — the @vmware_tool decorator."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from vmware_policy.audit import AuditEngine, _engine
9
+ from vmware_policy.decorators import PolicyDenied, vmware_tool
10
+ import vmware_policy.audit as audit_mod
11
+ import vmware_policy.policy as policy_mod
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _fresh_singletons(tmp_path):
16
+ """Reset audit and policy singletons for each test."""
17
+ db_path = tmp_path / "test_audit.db"
18
+ engine = AuditEngine(db_path)
19
+ audit_mod._engine = engine
20
+ policy_mod._engine = None
21
+ yield engine
22
+ audit_mod._engine = None
23
+ policy_mod._engine = None
24
+
25
+
26
+ @pytest.mark.unit
27
+ class TestVmwareTool:
28
+ def test_bare_decorator(self, _fresh_singletons):
29
+ """@vmware_tool without arguments."""
30
+ @vmware_tool
31
+ def list_segments(target: str = "") -> list:
32
+ return ["seg1", "seg2"]
33
+
34
+ result = list_segments()
35
+ assert result == ["seg1", "seg2"]
36
+
37
+ def test_decorator_with_args(self, _fresh_singletons):
38
+ """@vmware_tool(risk_level='high')."""
39
+ @vmware_tool(risk_level="high", sensitive_params=["password"])
40
+ def delete_vm(name: str, password: str = "") -> str:
41
+ return f"deleted {name}"
42
+
43
+ result = delete_vm(name="test-vm", password="secret123")
44
+ assert result == "deleted test-vm"
45
+
46
+ def test_metadata_attached(self):
47
+ @vmware_tool(risk_level="critical", idempotent=True, timeout_seconds=60)
48
+ def my_tool() -> None:
49
+ pass
50
+
51
+ assert my_tool._is_vmware_tool is True
52
+ assert my_tool._risk_level == "critical"
53
+ assert my_tool._idempotent is True
54
+ assert my_tool._timeout_seconds == 60
55
+
56
+ def test_audit_log_written(self, _fresh_singletons):
57
+ engine = _fresh_singletons
58
+
59
+ @vmware_tool
60
+ def my_op(target: str = "") -> str:
61
+ return "done"
62
+
63
+ my_op(target="vcenter1")
64
+ rows = engine.query(limit=10)
65
+ assert len(rows) == 1
66
+ assert rows[0]["tool"] == "my_op"
67
+ assert rows[0]["status"] == "ok"
68
+
69
+ def test_error_logged(self, _fresh_singletons):
70
+ engine = _fresh_singletons
71
+
72
+ @vmware_tool
73
+ def failing_op() -> None:
74
+ raise RuntimeError("boom")
75
+
76
+ with pytest.raises(RuntimeError, match="boom"):
77
+ failing_op()
78
+
79
+ rows = engine.query(limit=10)
80
+ assert len(rows) == 1
81
+ assert rows[0]["status"] == "error"
82
+
83
+ def test_sensitive_params_redacted(self, _fresh_singletons):
84
+ engine = _fresh_singletons
85
+
86
+ @vmware_tool(sensitive_params=["password", "token"])
87
+ def login(user: str, password: str, token: str = "") -> str:
88
+ return "ok"
89
+
90
+ login(user="admin", password="secret", token="abc123")
91
+ rows = engine.query(limit=1)
92
+ params = rows[0]["params"]
93
+ assert "secret" not in params
94
+ assert "abc123" not in params
95
+ assert "***" in params
96
+ assert "admin" in params
97
+
98
+ def test_preserves_function_name(self):
99
+ @vmware_tool
100
+ def original_name() -> None:
101
+ """Original docstring."""
102
+ pass
103
+
104
+ assert original_name.__name__ == "original_name"
105
+ assert original_name.__doc__ == "Original docstring."
106
+
107
+ def test_duration_recorded(self, _fresh_singletons):
108
+ import time
109
+
110
+ engine = _fresh_singletons
111
+
112
+ @vmware_tool
113
+ def slow_op() -> str:
114
+ time.sleep(0.05)
115
+ return "done"
116
+
117
+ slow_op()
118
+ rows = engine.query(limit=1)
119
+ assert rows[0]["duration_ms"] >= 40
120
+
121
+
122
+ @pytest.mark.unit
123
+ class TestPolicyDenied:
124
+ def test_denied_logged_with_status(self, _fresh_singletons, tmp_path):
125
+ engine = _fresh_singletons
126
+
127
+ # Create a policy with deny rule
128
+ rules_path = tmp_path / "rules.yaml"
129
+ rules_path.write_text(
130
+ "deny:\n"
131
+ " - name: no-delete\n"
132
+ ' operations: ["delete_*"]\n'
133
+ " reason: Deletion not allowed\n"
134
+ )
135
+ policy_mod._engine = None
136
+ import vmware_policy.policy as pm
137
+ pm._engine = pm.PolicyEngine(rules_path)
138
+
139
+ @vmware_tool(risk_level="high")
140
+ def delete_segment(name: str) -> str:
141
+ return "deleted"
142
+
143
+ with pytest.raises(PolicyDenied, match="Deletion not allowed"):
144
+ delete_segment(name="seg1")
145
+
146
+ rows = engine.query(limit=1)
147
+ assert rows[0]["status"] == "denied"
@@ -0,0 +1,119 @@
1
+ """Tests for vmware_policy.policy — the policy engine."""
2
+
3
+ import pytest
4
+
5
+ from vmware_policy.policy import PolicyEngine
6
+
7
+
8
+ @pytest.mark.unit
9
+ class TestPolicyEngine:
10
+ def test_no_rules_allows_all(self, tmp_path):
11
+ rules_path = tmp_path / "nonexistent.yaml"
12
+ engine = PolicyEngine(rules_path)
13
+ result = engine.check_allowed("delete_vm")
14
+ assert result.allowed is True
15
+ assert result.rule == "no_rules"
16
+
17
+ def test_deny_rule_blocks(self, tmp_path):
18
+ rules = tmp_path / "rules.yaml"
19
+ rules.write_text(
20
+ "deny:\n"
21
+ " - name: no-delete\n"
22
+ ' operations: ["delete_*"]\n'
23
+ " reason: No deletions allowed\n"
24
+ )
25
+ engine = PolicyEngine(rules)
26
+ result = engine.check_allowed("delete_segment")
27
+ assert result.allowed is False
28
+ assert result.rule == "no-delete"
29
+ assert "No deletions" in result.reason
30
+
31
+ def test_deny_rule_env_filter(self, tmp_path):
32
+ rules = tmp_path / "rules.yaml"
33
+ rules.write_text(
34
+ "deny:\n"
35
+ " - name: no-delete-prod\n"
36
+ ' operations: ["delete_*"]\n'
37
+ ' environments: ["production"]\n'
38
+ " reason: No deletions in prod\n"
39
+ )
40
+ engine = PolicyEngine(rules)
41
+
42
+ prod = engine.check_allowed("delete_vm", env="production")
43
+ assert prod.allowed is False
44
+
45
+ dev = engine.check_allowed("delete_vm", env="development")
46
+ assert dev.allowed is True
47
+
48
+ def test_deny_rule_risk_filter(self, tmp_path):
49
+ rules = tmp_path / "rules.yaml"
50
+ rules.write_text(
51
+ "deny:\n"
52
+ " - name: no-critical\n"
53
+ " min_risk_level: critical\n"
54
+ " reason: Critical ops blocked\n"
55
+ )
56
+ engine = PolicyEngine(rules)
57
+
58
+ crit = engine.check_allowed("any_op", risk_level="critical")
59
+ assert crit.allowed is False
60
+
61
+ low = engine.check_allowed("any_op", risk_level="low")
62
+ assert low.allowed is True
63
+
64
+ def test_wildcard_pattern(self, tmp_path):
65
+ rules = tmp_path / "rules.yaml"
66
+ rules.write_text(
67
+ "deny:\n"
68
+ " - name: block-all\n"
69
+ ' operations: ["*"]\n'
70
+ " reason: Everything blocked\n"
71
+ )
72
+ engine = PolicyEngine(rules)
73
+ assert engine.check_allowed("anything").allowed is False
74
+
75
+ def test_exact_match(self, tmp_path):
76
+ rules = tmp_path / "rules.yaml"
77
+ rules.write_text(
78
+ "deny:\n"
79
+ " - name: specific\n"
80
+ ' operations: ["vm_power_off"]\n'
81
+ " reason: Blocked\n"
82
+ )
83
+ engine = PolicyEngine(rules)
84
+ assert engine.check_allowed("vm_power_off").allowed is False
85
+ assert engine.check_allowed("vm_power_on").allowed is True
86
+
87
+ def test_hot_reload(self, tmp_path):
88
+ rules = tmp_path / "rules.yaml"
89
+ rules.write_text("")
90
+ engine = PolicyEngine(rules)
91
+ assert engine.check_allowed("delete_vm").allowed is True
92
+
93
+ # Update rules file
94
+ rules.write_text(
95
+ "deny:\n"
96
+ " - name: new-rule\n"
97
+ ' operations: ["delete_*"]\n'
98
+ " reason: Now blocked\n"
99
+ )
100
+ # Force mtime change detection
101
+ import os
102
+ os.utime(rules, (rules.stat().st_mtime + 1, rules.stat().st_mtime + 1))
103
+
104
+ assert engine.check_allowed("delete_vm").allowed is False
105
+
106
+ def test_bypass_mode(self, tmp_path, monkeypatch):
107
+ rules = tmp_path / "rules.yaml"
108
+ rules.write_text(
109
+ "deny:\n"
110
+ " - name: block-all\n"
111
+ ' operations: ["*"]\n'
112
+ " reason: Blocked\n"
113
+ )
114
+ engine = PolicyEngine(rules)
115
+
116
+ monkeypatch.setenv("VMWARE_POLICY_DISABLED", "1")
117
+ result = engine.check_allowed("delete_vm")
118
+ assert result.allowed is True
119
+ assert result.rule == "policy_disabled"
@@ -0,0 +1,33 @@
1
+ """Tests for vmware_policy.sanitize."""
2
+
3
+ import pytest
4
+
5
+ from vmware_policy.sanitize import sanitize
6
+
7
+
8
+ @pytest.mark.unit
9
+ class TestSanitize:
10
+ def test_strips_control_chars(self):
11
+ assert sanitize("hello\x00world") == "helloworld"
12
+ assert sanitize("a\x0b\x0cb") == "ab"
13
+ assert sanitize("\x7f\x80\x9f") == ""
14
+
15
+ def test_preserves_newline_and_tab(self):
16
+ assert sanitize("line1\nline2") == "line1\nline2"
17
+ assert sanitize("col1\tcol2") == "col1\tcol2"
18
+
19
+ def test_truncates_to_max_len(self):
20
+ long_text = "a" * 1000
21
+ assert len(sanitize(long_text)) == 500
22
+ assert len(sanitize(long_text, max_len=100)) == 100
23
+
24
+ def test_handles_non_string_input(self):
25
+ assert sanitize(12345) == "12345"
26
+ assert sanitize(None) == "None"
27
+
28
+ def test_empty_string(self):
29
+ assert sanitize("") == ""
30
+
31
+ def test_unicode_preserved(self):
32
+ assert sanitize("中文测试") == "中文测试"
33
+ assert sanitize("émojis 🎉") == "émojis 🎉"
@@ -0,0 +1,8 @@
1
+ """VMware Policy — unified audit, policy enforcement, and sanitization for VMware MCP skills."""
2
+
3
+ __version__ = "1.4.0"
4
+
5
+ from vmware_policy.decorators import vmware_tool
6
+ from vmware_policy.sanitize import sanitize
7
+
8
+ __all__ = ["vmware_tool", "sanitize"]