proofpacket 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,9 @@
1
+ """ProofPacket: privacy-safe evidence packets for AI agent runs."""
2
+
3
+ from proofpacket.hashing import VerificationResult, verify_receipt
4
+ from proofpacket.receipt import Receipt
5
+
6
+ Packet = Receipt
7
+
8
+ __all__ = ["Packet", "Receipt", "VerificationResult", "verify_receipt"]
9
+ __version__ = "0.1.0"
proofpacket/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """Command line interface for ProofPacket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from proofpacket.exporters import export_markdown
12
+ from proofpacket.hashing import verify_receipt
13
+ from proofpacket.receipt import Receipt
14
+ from proofpacket.schema import load_schema
15
+
16
+ app = typer.Typer(help="Create, validate, verify, summarize, and export ProofPacket evidence packets.")
17
+
18
+
19
+ def _load_receipt(path: Path) -> dict:
20
+ return json.loads(path.read_text(encoding="utf-8"))
21
+
22
+
23
+ @app.command()
24
+ def init(directory: Path = typer.Argument(Path("."), help="Directory to initialize.")) -> None:
25
+ """Create a minimal local ProofPacket policy and template packet."""
26
+ directory.mkdir(parents=True, exist_ok=True)
27
+ config_path = directory / "proofpacket.config.json"
28
+ template_path = directory / "proofpacket-template.json"
29
+ config_path.write_text(
30
+ json.dumps(
31
+ {
32
+ "privacy_mode": "hash_only",
33
+ "raw_capture_default": False,
34
+ "require_human_review_for_external_actions": True,
35
+ },
36
+ indent=2,
37
+ )
38
+ + "\n",
39
+ encoding="utf-8",
40
+ )
41
+ receipt = Receipt(task="example_task", actor="ai_agent")
42
+ receipt.note("Template packet initialized.")
43
+ receipt.finish(status="completed")
44
+ receipt.export_json(template_path)
45
+ typer.echo(f"Created {config_path}")
46
+ typer.echo(f"Created {template_path}")
47
+
48
+
49
+ @app.command()
50
+ def validate(receipt_path: Path) -> None:
51
+ """Validate a packet against the JSON schema."""
52
+ try:
53
+ from jsonschema import validate as jsonschema_validate
54
+ except ImportError as exc: # pragma: no cover
55
+ raise typer.Exit(f"jsonschema is required for validation: {exc}")
56
+
57
+ receipt = _load_receipt(receipt_path)
58
+ jsonschema_validate(instance=receipt, schema=load_schema())
59
+ typer.echo("valid")
60
+
61
+
62
+ @app.command()
63
+ def verify(receipt_path: Path) -> None:
64
+ """Verify hash chain and packet hash."""
65
+ result = verify_receipt(_load_receipt(receipt_path))
66
+ if result.valid:
67
+ typer.echo("valid")
68
+ return
69
+ typer.echo("invalid")
70
+ for error in result.errors:
71
+ typer.echo(f"- {error}")
72
+ raise typer.Exit(1)
73
+
74
+
75
+ @app.command()
76
+ def summarize(receipt_path: Path) -> None:
77
+ """Print a human-readable packet summary."""
78
+ receipt = _load_receipt(receipt_path)
79
+ events = receipt.get("events", [])
80
+ providers = sorted(
81
+ {
82
+ event.get("metadata", {}).get("provider")
83
+ for event in events
84
+ if event.get("event_type") == "llm_call" and event.get("metadata", {}).get("provider")
85
+ }
86
+ )
87
+ tools = sorted(
88
+ {
89
+ event.get("metadata", {}).get("tool")
90
+ for event in events
91
+ if event.get("event_type") == "tool_call" and event.get("metadata", {}).get("tool")
92
+ }
93
+ )
94
+ source_buckets = sorted({event.get("bucket") for event in events if event.get("event_type") == "source_used"})
95
+ reviews = [event for event in events if event.get("event_type") == "human_review"]
96
+ typer.echo(f"Task: {receipt.get('task')}")
97
+ typer.echo(f"Status: {receipt.get('status')}")
98
+ typer.echo(f"Started: {receipt.get('started_at')}")
99
+ typer.echo(f"Finished: {receipt.get('finished_at')}")
100
+ typer.echo(f"Events: {len(events)}")
101
+ typer.echo(f"Model providers: {', '.join(providers) if providers else 'none'}")
102
+ typer.echo(f"Tools: {', '.join(tools) if tools else 'none'}")
103
+ typer.echo(f"Source buckets: {', '.join(source_buckets) if source_buckets else 'none'}")
104
+ typer.echo(f"Human reviews: {len(reviews)}")
105
+ typer.echo(f"Risk flags: {len(receipt.get('risk_flags', []))}")
106
+
107
+
108
+ @app.command()
109
+ def export(
110
+ receipt_path: Path,
111
+ markdown: Optional[Path] = typer.Option(None, "--markdown", "-m"),
112
+ force: bool = typer.Option(False, "--force", help="Export even if verification fails."),
113
+ ) -> None:
114
+ """Export a packet to another format."""
115
+ if markdown is None:
116
+ raise typer.BadParameter("Only --markdown is supported in v0.1.")
117
+ receipt = _load_receipt(receipt_path)
118
+ result = verify_receipt(receipt)
119
+ if not result.valid and not force:
120
+ typer.echo("Packet verification failed; use --force to export anyway.")
121
+ for error in result.errors:
122
+ typer.echo(f"- {error}")
123
+ raise typer.Exit(1)
124
+ export_markdown(receipt, markdown)
125
+ typer.echo(f"Created {markdown}")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ app()
proofpacket/events.py ADDED
@@ -0,0 +1,14 @@
1
+ """Event type constants."""
2
+
3
+ EVENT_TYPES = {
4
+ "run_started",
5
+ "llm_call",
6
+ "tool_call",
7
+ "source_used",
8
+ "file_access",
9
+ "human_review",
10
+ "external_action",
11
+ "risk_flag",
12
+ "note",
13
+ "run_finished",
14
+ }
@@ -0,0 +1,94 @@
1
+ """JSON and Markdown export helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable
8
+
9
+ from proofpacket.hashing import verify_receipt
10
+
11
+
12
+ def export_json(receipt: Dict[str, Any], path: str | Path) -> None:
13
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
14
+ Path(path).write_text(json.dumps(receipt, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
15
+
16
+
17
+ def export_markdown(receipt: Dict[str, Any], path: str | Path) -> None:
18
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
19
+ Path(path).write_text(receipt_to_markdown(receipt), encoding="utf-8")
20
+
21
+
22
+ def _unique(values: Iterable[str | None]) -> list[str]:
23
+ seen: set[str] = set()
24
+ result: list[str] = []
25
+ for value in values:
26
+ if value and value not in seen:
27
+ seen.add(value)
28
+ result.append(value)
29
+ return result
30
+
31
+
32
+ def receipt_to_markdown(receipt: Dict[str, Any]) -> str:
33
+ events = receipt.get("events", [])
34
+ tools = _unique(event.get("metadata", {}).get("tool") for event in events if event.get("event_type") == "tool_call")
35
+ sources = [
36
+ f"{event.get('bucket')}: {event.get('metadata', {}).get('title') or event.get('metadata', {}).get('uri') or 'Untitled source'}"
37
+ for event in events
38
+ if event.get("event_type") == "source_used"
39
+ ]
40
+ reviews = [
41
+ f"{event.get('metadata', {}).get('decision')} by {event.get('metadata', {}).get('reviewer')}"
42
+ for event in events
43
+ if event.get("event_type") == "human_review"
44
+ ]
45
+ verification = verify_receipt(receipt)
46
+
47
+ lines = [
48
+ "# ProofPacket Evidence Packet",
49
+ "",
50
+ "## Run Summary",
51
+ "",
52
+ f"- Packet ID: {receipt.get('receipt_id')}",
53
+ f"- Run ID: {receipt.get('run_id')}",
54
+ f"- Task: {receipt.get('task')}",
55
+ f"- Status: {receipt.get('status')}",
56
+ f"- Started: {receipt.get('started_at')}",
57
+ f"- Finished: {receipt.get('finished_at')}",
58
+ f"- Privacy Mode: {receipt.get('privacy_mode')}",
59
+ f"- Attested By: {', '.join(attestor.get('name', 'unknown') for attestor in receipt.get('attested_by', [])) or 'unknown'}",
60
+ f"- Packet Hash: {receipt.get('receipt_hash')}",
61
+ "",
62
+ "## Event Summary",
63
+ "",
64
+ "| Time | Type | Bucket | Summary |",
65
+ "|---|---|---|---|",
66
+ ]
67
+ for event in events:
68
+ lines.append(
69
+ f"| {event.get('timestamp')} | {event.get('event_type')} | {event.get('bucket')} | {event.get('summary')} |"
70
+ )
71
+
72
+ lines.extend(["", "## Tools Used", ""])
73
+ lines.extend([f"- {tool}" for tool in tools] or ["- None recorded"])
74
+ lines.extend(["", "## Sources Used", ""])
75
+ lines.extend([f"- {source}" for source in sources] or ["- None recorded"])
76
+ lines.extend(["", "## Human Reviews", ""])
77
+ lines.extend([f"- {review}" for review in reviews] or ["- None recorded"])
78
+ lines.extend(["", "## Risk Flags", ""])
79
+ if receipt.get("risk_flags"):
80
+ for flag in receipt["risk_flags"]:
81
+ lines.append(f"- {flag.get('severity', '').title()}: {flag.get('message')}")
82
+ else:
83
+ lines.append("- None recorded")
84
+ lines.extend(
85
+ [
86
+ "",
87
+ "## Verification",
88
+ "",
89
+ f"- Event hash chain: {'valid' if verification.event_hash_chain_valid else 'invalid'}",
90
+ f"- Packet hash: {'valid' if verification.receipt_hash_valid else 'invalid'}",
91
+ "",
92
+ ]
93
+ )
94
+ return "\n".join(lines)
proofpacket/hashing.py ADDED
@@ -0,0 +1,133 @@
1
+ """Deterministic hashing and receipt verification helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import unicodedata
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List
12
+
13
+ from jsonschema import ValidationError, validate
14
+
15
+
16
+ ZERO_HASH = "sha256:" + ("0" * 64)
17
+
18
+
19
+ def _normalize_for_canonical_json(value: Any) -> Any:
20
+ if isinstance(value, str):
21
+ return unicodedata.normalize("NFC", value)
22
+ if isinstance(value, bool) or value is None or isinstance(value, int):
23
+ return value
24
+ if isinstance(value, float):
25
+ if not math.isfinite(value):
26
+ raise ValueError("canonical JSON does not support non-finite floats")
27
+ if value.is_integer():
28
+ return int(value)
29
+ return value
30
+ if isinstance(value, list):
31
+ return [_normalize_for_canonical_json(item) for item in value]
32
+ if isinstance(value, dict):
33
+ return {
34
+ unicodedata.normalize("NFC", str(key)): _normalize_for_canonical_json(item)
35
+ for key, item in value.items()
36
+ }
37
+ raise TypeError(f"unsupported canonical JSON value: {type(value).__name__}")
38
+
39
+
40
+ def canonical_json(obj: Dict[str, Any]) -> str:
41
+ """Return ProofPacket canonical JSON for deterministic hashing.
42
+
43
+ v0.1 uses a documented JSON subset rather than full RFC 8785 JCS. See
44
+ docs/canonicalization.md before implementing cross-language verification.
45
+ """
46
+ normalized = _normalize_for_canonical_json(obj)
47
+ return json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
48
+
49
+
50
+ def hash_text(text: str) -> str:
51
+ """Hash text with SHA-256 and return a prefixed digest."""
52
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
53
+ return f"sha256:{digest}"
54
+
55
+
56
+ def hash_json(obj: Dict[str, Any]) -> str:
57
+ """Hash a JSON-like dict using canonical JSON."""
58
+ return hash_text(canonical_json(obj))
59
+
60
+
61
+ def hash_event(event: Dict[str, Any]) -> str:
62
+ payload = copy.deepcopy(event)
63
+ payload.pop("event_hash", None)
64
+ return hash_json(payload)
65
+
66
+
67
+ def hash_receipt(receipt: Dict[str, Any]) -> str:
68
+ payload = copy.deepcopy(receipt)
69
+ payload.pop("receipt_hash", None)
70
+ return hash_json(payload)
71
+
72
+
73
+ @dataclass
74
+ class VerificationResult:
75
+ """Result of verifying a ProofPacket hash chain."""
76
+
77
+ valid: bool
78
+ event_hash_chain_valid: bool
79
+ receipt_hash_valid: bool
80
+ schema_valid: bool | None = None
81
+ errors: List[str] = field(default_factory=list)
82
+
83
+
84
+ def verify_receipt(receipt: Dict[str, Any]) -> VerificationResult:
85
+ """Verify event hashes, previous-event links, receipt hash, and schema if available."""
86
+ errors: List[str] = []
87
+ events = receipt.get("events", [])
88
+ previous_hash = ZERO_HASH
89
+ event_chain_valid = True
90
+
91
+ for index, event in enumerate(events):
92
+ expected_previous = previous_hash
93
+ actual_previous = event.get("previous_event_hash")
94
+ if actual_previous != expected_previous:
95
+ event_chain_valid = False
96
+ errors.append(
97
+ f"event {index} previous_event_hash mismatch: expected {expected_previous}, got {actual_previous}"
98
+ )
99
+
100
+ expected_event_hash = hash_event(event)
101
+ actual_event_hash = event.get("event_hash")
102
+ if actual_event_hash != expected_event_hash:
103
+ event_chain_valid = False
104
+ errors.append(
105
+ f"event {index} event_hash mismatch: expected {expected_event_hash}, got {actual_event_hash}"
106
+ )
107
+ previous_hash = actual_event_hash or expected_event_hash
108
+
109
+ expected_receipt_hash = hash_receipt(receipt)
110
+ receipt_hash_valid = receipt.get("receipt_hash") == expected_receipt_hash
111
+ if not receipt_hash_valid:
112
+ errors.append(
113
+ f"receipt_hash mismatch: expected {expected_receipt_hash}, got {receipt.get('receipt_hash')}"
114
+ )
115
+
116
+ schema_valid = None
117
+ try:
118
+ from proofpacket.schema import load_schema
119
+
120
+ validate(instance=receipt, schema=load_schema())
121
+ schema_valid = True
122
+ except ValidationError as exc:
123
+ schema_valid = False
124
+ errors.append(f"schema validation failed: {exc}")
125
+
126
+ valid = event_chain_valid and receipt_hash_valid and schema_valid is not False
127
+ return VerificationResult(
128
+ valid=valid,
129
+ event_hash_chain_valid=event_chain_valid,
130
+ receipt_hash_valid=receipt_hash_valid,
131
+ schema_valid=schema_valid,
132
+ errors=errors,
133
+ )