pruv 1.0.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.
pruv-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 pruv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pruv-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: pruv
3
+ Version: 1.0.0
4
+ Summary: Prove what happened. Cryptographic verification for any system.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2025 pruv
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://pruv.dev
28
+ Project-URL: Documentation, https://docs.pruv.dev
29
+ Project-URL: Repository, https://github.com/pruv-dev/pruv
30
+ Classifier: Development Status :: 5 - Production/Stable
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.10
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Topic :: Security :: Cryptography
39
+ Classifier: Topic :: Software Development :: Libraries
40
+ Requires-Python: >=3.10
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: xycore>=1.0.0
44
+ Requires-Dist: httpx>=0.25.0
45
+ Requires-Dist: click>=8.0
46
+ Requires-Dist: pydantic>=2.0
47
+ Requires-Dist: zstandard>=0.21.0
48
+ Dynamic: license-file
49
+
50
+ # pruv
51
+
52
+ Prove what happened. Cryptographic verification for any system.
53
+
54
+ ```bash
55
+ pip install pruv
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from pruv import xy_wrap
62
+
63
+ wrapped = xy_wrap(my_agent)
64
+ result = await wrapped.run("Fix the bug")
65
+ print(result.receipt.hash)
66
+ ```
67
+
68
+ ## Features
69
+
70
+ - **Scanner**: Scan any project for files, imports, env vars, frameworks, and services
71
+ - **xy_wrap()**: Universal wrapper for any agent, function, or workflow
72
+ - **Checkpoints**: Create snapshots, preview restore diffs, quick undo
73
+ - **Approval Gates**: Webhook-based human approval for high-risk operations
74
+ - **Cloud Sync**: Sync chains to api.pruv.dev
75
+ - **CLI**: `pruv scan`, `pruv verify`, `pruv export`, `pruv undo`, `pruv upload`
pruv-1.0.0/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # pruv
2
+
3
+ Prove what happened. Cryptographic verification for any system.
4
+
5
+ ```bash
6
+ pip install pruv
7
+ ```
8
+
9
+ ## Quick Start
10
+
11
+ ```python
12
+ from pruv import xy_wrap
13
+
14
+ wrapped = xy_wrap(my_agent)
15
+ result = await wrapped.run("Fix the bug")
16
+ print(result.receipt.hash)
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Scanner**: Scan any project for files, imports, env vars, frameworks, and services
22
+ - **xy_wrap()**: Universal wrapper for any agent, function, or workflow
23
+ - **Checkpoints**: Create snapshots, preview restore diffs, quick undo
24
+ - **Approval Gates**: Webhook-based human approval for high-risk operations
25
+ - **Cloud Sync**: Sync chains to api.pruv.dev
26
+ - **CLI**: `pruv scan`, `pruv verify`, `pruv export`, `pruv undo`, `pruv upload`
@@ -0,0 +1,45 @@
1
+ """pruv — Prove what happened. Full SDK with scanner, wrappers, checkpoints, and cloud sync."""
2
+
3
+ # Re-export from xycore
4
+ from xycore import XYEntry, XYChain, XYReceipt, ThinkingPhase
5
+
6
+ # Scanner
7
+ from .scanner import scan
8
+ from .graph import Graph, GraphDiff
9
+
10
+ # Wrapper
11
+ from .wrap import xy_wrap, WrappedResult
12
+
13
+ # Checkpoints
14
+ from .checkpoint import Checkpoint, CheckpointManager
15
+
16
+ # Approval
17
+ from .approval import ApprovalGate
18
+
19
+ # Cloud
20
+ from .cloud import CloudClient, CloudStorage
21
+
22
+ __version__ = "1.0.0"
23
+
24
+ __all__ = [
25
+ # xycore re-exports
26
+ "XYEntry",
27
+ "XYChain",
28
+ "XYReceipt",
29
+ "ThinkingPhase",
30
+ # Scanner
31
+ "scan",
32
+ "Graph",
33
+ "GraphDiff",
34
+ # Wrapper
35
+ "xy_wrap",
36
+ "WrappedResult",
37
+ # Checkpoints
38
+ "Checkpoint",
39
+ "CheckpointManager",
40
+ # Approval
41
+ "ApprovalGate",
42
+ # Cloud
43
+ "CloudClient",
44
+ "CloudStorage",
45
+ ]
@@ -0,0 +1,5 @@
1
+ """pruv approval gate — human approval for high-risk operations."""
2
+
3
+ from .gate import ApprovalGate, ApprovalRequest, ApprovalResponse
4
+
5
+ __all__ = ["ApprovalGate", "ApprovalRequest", "ApprovalResponse"]
@@ -0,0 +1,117 @@
1
+ """Approval gate — webhook-based human approval for high-risk operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ApprovalRequest:
12
+ """Request sent to the approval webhook."""
13
+
14
+ chain_id: str
15
+ entry_index: int
16
+ operation: str
17
+ x_state: dict[str, Any] | None = None
18
+ proposed_y_state: dict[str, Any] | None = None
19
+ timestamp: float = 0.0
20
+
21
+ def __post_init__(self):
22
+ if self.timestamp == 0.0:
23
+ self.timestamp = time.time()
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ return {
27
+ "chain_id": self.chain_id,
28
+ "entry_index": self.entry_index,
29
+ "operation": self.operation,
30
+ "x_state": self.x_state,
31
+ "proposed_y_state": self.proposed_y_state,
32
+ "timestamp": self.timestamp,
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class ApprovalResponse:
38
+ """Response from the approval webhook."""
39
+
40
+ status: str # approved, denied, timeout
41
+ approved_by: str | None = None
42
+ reason: str | None = None
43
+
44
+ @property
45
+ def is_approved(self) -> bool:
46
+ return self.status == "approved"
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ d: dict[str, Any] = {"status": self.status}
50
+ if self.approved_by:
51
+ d["approved_by"] = self.approved_by
52
+ if self.reason:
53
+ d["reason"] = self.reason
54
+ return d
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict[str, Any]) -> "ApprovalResponse":
58
+ return cls(
59
+ status=data["status"],
60
+ approved_by=data.get("approved_by"),
61
+ reason=data.get("reason"),
62
+ )
63
+
64
+
65
+ class ApprovalGate:
66
+ """Human approval gate via webhook for high-risk operations."""
67
+
68
+ def __init__(
69
+ self,
70
+ webhook_url: str,
71
+ timeout: int = 300,
72
+ operations: set[str] | None = None,
73
+ on_timeout: str = "deny",
74
+ ) -> None:
75
+ self.webhook_url = webhook_url
76
+ self.timeout = timeout
77
+ self.operations = operations or {"file.write", "deploy", "database.migrate"}
78
+ self.on_timeout = on_timeout
79
+
80
+ def requires_approval(self, operation: str) -> bool:
81
+ """Check if an operation requires approval."""
82
+ return operation in self.operations
83
+
84
+ async def request_approval(self, request: ApprovalRequest) -> ApprovalResponse:
85
+ """Send an approval request to the webhook."""
86
+ try:
87
+ import httpx
88
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
89
+ resp = await client.post(self.webhook_url, json=request.to_dict())
90
+ if resp.status_code == 200:
91
+ return ApprovalResponse.from_dict(resp.json())
92
+ return ApprovalResponse(status="denied", reason=f"HTTP {resp.status_code}")
93
+ except Exception as e:
94
+ if self.on_timeout == "approve":
95
+ return ApprovalResponse(status="approved", reason="timeout-auto-approved")
96
+ return ApprovalResponse(status="timeout", reason=str(e))
97
+
98
+ async def gate(
99
+ self,
100
+ chain_id: str,
101
+ entry_index: int,
102
+ operation: str,
103
+ x_state: dict[str, Any] | None = None,
104
+ proposed_y_state: dict[str, Any] | None = None,
105
+ ) -> ApprovalResponse:
106
+ """Full gate check — only calls webhook if operation requires approval."""
107
+ if not self.requires_approval(operation):
108
+ return ApprovalResponse(status="approved", reason="no-approval-required")
109
+
110
+ request = ApprovalRequest(
111
+ chain_id=chain_id,
112
+ entry_index=entry_index,
113
+ operation=operation,
114
+ x_state=x_state,
115
+ proposed_y_state=proposed_y_state,
116
+ )
117
+ return await self.request_approval(request)
@@ -0,0 +1,5 @@
1
+ """pruv checkpoint — create snapshots, restore, quick undo."""
2
+
3
+ from .manager import Checkpoint, CheckpointManager, RestorePreview
4
+
5
+ __all__ = ["Checkpoint", "CheckpointManager", "RestorePreview"]
@@ -0,0 +1,223 @@
1
+ """Checkpoint manager — create, restore, preview, and auto-checkpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from xycore import XYChain
13
+
14
+ from ..graph import Graph, GraphDiff
15
+ from ..scanner import scan as scan_dir
16
+
17
+ # Try zstandard for compression
18
+ _HAS_ZSTD = False
19
+ try:
20
+ import zstandard
21
+ _HAS_ZSTD = True
22
+ except ImportError:
23
+ pass
24
+
25
+
26
+ @dataclass
27
+ class Checkpoint:
28
+ """A point-in-time snapshot of chain state and optionally project files."""
29
+
30
+ id: str
31
+ name: str
32
+ chain_id: str
33
+ entry_index: int
34
+ created_at: float
35
+ chain_snapshot: dict[str, Any]
36
+ graph_snapshot: dict[str, Any] | None = None
37
+ file_snapshots: dict[str, str] | None = None
38
+ compressed: bool = False
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "id": self.id,
43
+ "name": self.name,
44
+ "chain_id": self.chain_id,
45
+ "entry_index": self.entry_index,
46
+ "created_at": self.created_at,
47
+ "chain_snapshot": self.chain_snapshot,
48
+ "graph_snapshot": self.graph_snapshot,
49
+ "compressed": self.compressed,
50
+ }
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict[str, Any]) -> "Checkpoint":
54
+ return cls(
55
+ id=data["id"],
56
+ name=data["name"],
57
+ chain_id=data["chain_id"],
58
+ entry_index=data["entry_index"],
59
+ created_at=data["created_at"],
60
+ chain_snapshot=data["chain_snapshot"],
61
+ graph_snapshot=data.get("graph_snapshot"),
62
+ compressed=data.get("compressed", False),
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class RestorePreview:
68
+ """Preview of what will change if a checkpoint is restored."""
69
+
70
+ checkpoint_id: str
71
+ checkpoint_name: str
72
+ current_entry_index: int
73
+ target_entry_index: int
74
+ entries_to_rollback: int
75
+ diff: GraphDiff | None = None
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ d: dict[str, Any] = {
79
+ "checkpoint_id": self.checkpoint_id,
80
+ "checkpoint_name": self.checkpoint_name,
81
+ "current_entry_index": self.current_entry_index,
82
+ "target_entry_index": self.target_entry_index,
83
+ "entries_to_rollback": self.entries_to_rollback,
84
+ }
85
+ if self.diff:
86
+ d["diff"] = self.diff.to_dict()
87
+ return d
88
+
89
+
90
+ class CheckpointManager:
91
+ """Manage checkpoints for an XY chain."""
92
+
93
+ def __init__(
94
+ self,
95
+ chain: XYChain,
96
+ project_dir: str | Path | None = None,
97
+ storage_dir: str | Path = ".pruv/checkpoints",
98
+ compress: bool = True,
99
+ ) -> None:
100
+ self.chain = chain
101
+ self.project_dir = Path(project_dir) if project_dir else None
102
+ self.storage_dir = Path(storage_dir)
103
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
104
+ self.compress = compress and _HAS_ZSTD
105
+ self.checkpoints: list[Checkpoint] = []
106
+
107
+ # Register auto-checkpoint callback
108
+ self.chain._checkpoint_callback = lambda name: self.create(name)
109
+
110
+ def create(self, name: str, include_files: bool = False) -> Checkpoint:
111
+ """Create a checkpoint at the current chain state."""
112
+ # Snapshot chain
113
+ chain_data = self.chain.to_dict()
114
+
115
+ # Snapshot graph
116
+ graph_data = None
117
+ file_snapshots = None
118
+ if self.project_dir and self.project_dir.exists():
119
+ graph = scan_dir(str(self.project_dir), include_contents=include_files)
120
+ graph_data = graph.to_dict()
121
+ if include_files:
122
+ file_snapshots = graph.file_contents
123
+
124
+ checkpoint = Checkpoint(
125
+ id=uuid.uuid4().hex[:12],
126
+ name=name,
127
+ chain_id=self.chain.id,
128
+ entry_index=self.chain.length - 1 if self.chain.length > 0 else -1,
129
+ created_at=time.time(),
130
+ chain_snapshot=chain_data,
131
+ graph_snapshot=graph_data,
132
+ file_snapshots=file_snapshots,
133
+ compressed=self.compress,
134
+ )
135
+
136
+ self.checkpoints.append(checkpoint)
137
+ self._save_checkpoint(checkpoint)
138
+ return checkpoint
139
+
140
+ def preview_restore(self, checkpoint_id: str) -> RestorePreview:
141
+ """Preview what will change if restoring to a checkpoint."""
142
+ checkpoint = self._find_checkpoint(checkpoint_id)
143
+ current_index = self.chain.length - 1 if self.chain.length > 0 else -1
144
+
145
+ diff = None
146
+ if self.project_dir and checkpoint.graph_snapshot:
147
+ current_graph = scan_dir(str(self.project_dir))
148
+ old_graph = Graph.from_dict(checkpoint.graph_snapshot)
149
+ diff = old_graph.diff(current_graph)
150
+
151
+ return RestorePreview(
152
+ checkpoint_id=checkpoint.id,
153
+ checkpoint_name=checkpoint.name,
154
+ current_entry_index=current_index,
155
+ target_entry_index=checkpoint.entry_index,
156
+ entries_to_rollback=max(0, current_index - checkpoint.entry_index),
157
+ diff=diff,
158
+ )
159
+
160
+ def restore(self, checkpoint_id: str) -> XYChain:
161
+ """Restore chain to a checkpoint state."""
162
+ checkpoint = self._find_checkpoint(checkpoint_id)
163
+ restored = XYChain.from_dict(checkpoint.chain_snapshot)
164
+ self.chain.entries = restored.entries
165
+ return self.chain
166
+
167
+ def quick_undo(self) -> XYChain | None:
168
+ """Restore to the most recent checkpoint."""
169
+ if not self.checkpoints:
170
+ return None
171
+ latest = self.checkpoints[-1]
172
+ return self.restore(latest.id)
173
+
174
+ def list_checkpoints(self) -> list[dict[str, Any]]:
175
+ """List all checkpoints."""
176
+ return [
177
+ {
178
+ "id": cp.id,
179
+ "name": cp.name,
180
+ "entry_index": cp.entry_index,
181
+ "created_at": cp.created_at,
182
+ }
183
+ for cp in self.checkpoints
184
+ ]
185
+
186
+ def _find_checkpoint(self, checkpoint_id: str) -> Checkpoint:
187
+ for cp in self.checkpoints:
188
+ if cp.id == checkpoint_id:
189
+ return cp
190
+ raise ValueError(f"Checkpoint not found: {checkpoint_id}")
191
+
192
+ def _save_checkpoint(self, checkpoint: Checkpoint) -> Path:
193
+ """Save checkpoint to disk."""
194
+ data = json.dumps(checkpoint.to_dict(), indent=2).encode("utf-8")
195
+ path = self.storage_dir / f"{checkpoint.id}.json"
196
+
197
+ if self.compress and _HAS_ZSTD:
198
+ cctx = zstandard.ZstdCompressor(level=3)
199
+ data = cctx.compress(data)
200
+ path = self.storage_dir / f"{checkpoint.id}.json.zst"
201
+
202
+ with open(path, "wb") as f:
203
+ f.write(data)
204
+
205
+ return path
206
+
207
+ def _load_checkpoint(self, checkpoint_id: str) -> Checkpoint:
208
+ """Load checkpoint from disk."""
209
+ # Try compressed first
210
+ zst_path = self.storage_dir / f"{checkpoint_id}.json.zst"
211
+ json_path = self.storage_dir / f"{checkpoint_id}.json"
212
+
213
+ if zst_path.exists() and _HAS_ZSTD:
214
+ with open(zst_path, "rb") as f:
215
+ dctx = zstandard.ZstdDecompressor()
216
+ data = dctx.decompress(f.read())
217
+ elif json_path.exists():
218
+ with open(json_path, "rb") as f:
219
+ data = f.read()
220
+ else:
221
+ raise FileNotFoundError(f"Checkpoint not found on disk: {checkpoint_id}")
222
+
223
+ return Checkpoint.from_dict(json.loads(data))
@@ -0,0 +1,5 @@
1
+ """pruv CLI — command-line interface."""
2
+
3
+ from .commands import cli
4
+
5
+ __all__ = ["cli"]