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.
- proofpacket/__init__.py +9 -0
- proofpacket/cli.py +129 -0
- proofpacket/events.py +14 -0
- proofpacket/exporters.py +94 -0
- proofpacket/hashing.py +133 -0
- proofpacket/receipt.py +410 -0
- proofpacket/redaction.py +47 -0
- proofpacket/risk.py +47 -0
- proofpacket/schema/__init__.py +12 -0
- proofpacket/schema/proofpacket.schema.json +138 -0
- proofpacket/wrappers/__init__.py +3 -0
- proofpacket/wrappers/anthropic.py +111 -0
- proofpacket/wrappers/openai.py +120 -0
- proofpacket-0.1.0.dist-info/METADATA +265 -0
- proofpacket-0.1.0.dist-info/RECORD +19 -0
- proofpacket-0.1.0.dist-info/WHEEL +5 -0
- proofpacket-0.1.0.dist-info/entry_points.txt +2 -0
- proofpacket-0.1.0.dist-info/licenses/LICENSE +21 -0
- proofpacket-0.1.0.dist-info/top_level.txt +1 -0
proofpacket/__init__.py
ADDED
|
@@ -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
proofpacket/exporters.py
ADDED
|
@@ -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
|
+
)
|