diff --git a/docs/guides/setup-preview.md b/docs/guides/setup-preview.md new file mode 100644 index 0000000..27fafa7 --- /dev/null +++ b/docs/guides/setup-preview.md @@ -0,0 +1,11 @@ +# Setup preview contract + +`verdict setup --dry-run --json` produces a mutation-free `setup_plan`. +Every proposed action includes its reason, security impact, postcondition, and +exact undo description. The preview does not read configuration contents, +probe optional tools, contact the network, or access credentials. + +An existing configuration is preserved as a read-only action. A missing +configuration is presented as a consent-required creation action. Applying +either action remains a separate transactional boundary; the preview itself +does not authorize or perform mutations. diff --git a/tests/test_setup_plan.py b/tests/test_setup_plan.py new file mode 100644 index 0000000..68c133e --- /dev/null +++ b/tests/test_setup_plan.py @@ -0,0 +1,39 @@ +"""Tests for auditable, mutation-free setup plans.""" + +from __future__ import annotations + +from pathlib import Path + +from verdict.setup_plan import build_setup_plan + + +def test_setup_actions_explain_reason_security_postcondition_and_undo( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + report = build_setup_plan().to_dict() + action = report["actions"][0] + + for field in ("reason", "security_impact", "postcondition", "undo"): + assert isinstance(action[field], str) + assert action[field] + assert report["mutation_free"] is True + assert not (tmp_path / "config").exists() + + +def test_existing_config_preview_explains_noop_without_reading_contents( + tmp_path: Path, monkeypatch +) -> None: + config_dir = tmp_path / "config" / "verdict" + config_dir.mkdir(parents=True) + config_path = config_dir / "verdict.yaml" + config_path.write_text("private: do-not-read\n", encoding="utf-8") + before = config_path.read_bytes() + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + action = build_setup_plan().to_dict()["actions"][0] + + assert action["action_id"] == "preserve-config" + assert "byte-for-byte" in action["postcondition"] + assert config_path.read_bytes() == before diff --git a/verdict/setup_plan.py b/verdict/setup_plan.py index 3872040..aa27d32 100644 --- a/verdict/setup_plan.py +++ b/verdict/setup_plan.py @@ -25,6 +25,10 @@ class SetupAction: kind: str target: str description: str + reason: str + security_impact: str + postcondition: str + undo: str reversible: bool requires_consent: bool @@ -36,6 +40,10 @@ def to_dict(self) -> dict[str, object]: "kind": self.kind, "target": self.target, "description": self.description, + "reason": self.reason, + "security_impact": self.security_impact, + "postcondition": self.postcondition, + "undo": self.undo, "reversible": self.reversible, "requires_consent": self.requires_consent, } @@ -124,6 +132,10 @@ def build_setup_plan() -> SetupPlan: kind="inspect", target="config", description="Preserve the existing Verdict configuration unchanged.", + reason="An existing configuration must not be overwritten implicitly.", + security_impact="No change; existing configuration and credential references remain untouched.", + postcondition="The existing configuration remains byte-for-byte unchanged.", + undo="No action required; this is a read-only inspection.", reversible=True, requires_consent=False, ), @@ -135,6 +147,10 @@ def build_setup_plan() -> SetupPlan: kind="write_config", target="config", description="Create a minimal Verdict configuration after explicit consent.", + reason="A missing configuration is required before an operator can apply setup.", + security_impact="Creates only local configuration; credentials remain environment/keyring references.", + postcondition="A validated minimal configuration exists at the planned target.", + undo="Remove the newly created configuration after confirming it was not modified.", reversible=True, requires_consent=True, ),