provtrail 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.
- provtrail/__init__.py +28 -0
- provtrail/__main__.py +4 -0
- provtrail/cli.py +181 -0
- provtrail/config.py +89 -0
- provtrail/hashing.py +92 -0
- provtrail/ledger.py +662 -0
- provtrail/mcp_server.py +161 -0
- provtrail/stop_hook.py +203 -0
- provtrail-0.1.0.dist-info/METADATA +311 -0
- provtrail-0.1.0.dist-info/RECORD +14 -0
- provtrail-0.1.0.dist-info/WHEEL +5 -0
- provtrail-0.1.0.dist-info/entry_points.txt +4 -0
- provtrail-0.1.0.dist-info/licenses/LICENSE +21 -0
- provtrail-0.1.0.dist-info/top_level.txt +1 -0
provtrail/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""provtrail: a minimal, verifiable source chain-of-custody ledger."""
|
|
2
|
+
|
|
3
|
+
from .ledger import (
|
|
4
|
+
STATE_MISSING,
|
|
5
|
+
STATE_PRESENT,
|
|
6
|
+
STATE_UNKNOWN,
|
|
7
|
+
CheckResult,
|
|
8
|
+
ContractError,
|
|
9
|
+
Ledger,
|
|
10
|
+
LedgerError,
|
|
11
|
+
LockTimeout,
|
|
12
|
+
VerifyReport,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Ledger",
|
|
19
|
+
"VerifyReport",
|
|
20
|
+
"CheckResult",
|
|
21
|
+
"ContractError",
|
|
22
|
+
"LockTimeout",
|
|
23
|
+
"LedgerError",
|
|
24
|
+
"STATE_PRESENT",
|
|
25
|
+
"STATE_MISSING",
|
|
26
|
+
"STATE_UNKNOWN",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
provtrail/__main__.py
ADDED
provtrail/cli.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Command-line interface for provtrail.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
provtrail add LEDGER (--url U | --content-file F | --content-text S) ...
|
|
5
|
+
provtrail verify LEDGER [--check-files] [--json]
|
|
6
|
+
provtrail check LEDGER [--since ISO] [--until ISO] [--session-id ID] [--enforce] [--json]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from .ledger import (
|
|
18
|
+
STATE_MISSING,
|
|
19
|
+
CheckResult,
|
|
20
|
+
ContractError,
|
|
21
|
+
Ledger,
|
|
22
|
+
LedgerError,
|
|
23
|
+
LockTimeout,
|
|
24
|
+
VerifyReport,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(prog="provtrail", description=__doc__)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
add_p = sub.add_parser("add", help="append one record to a ledger")
|
|
33
|
+
add_p.add_argument("ledger", help="path to the ledger JSONL file")
|
|
34
|
+
add_p.add_argument("--url", dest="source_url", default=None, help="source URL")
|
|
35
|
+
add_p.add_argument(
|
|
36
|
+
"--content-file", dest="content_file", default=None,
|
|
37
|
+
help="path to a file whose content is hashed for content_hash",
|
|
38
|
+
)
|
|
39
|
+
add_p.add_argument(
|
|
40
|
+
"--content-text", dest="content_text", default=None,
|
|
41
|
+
help="literal text content hashed for content_hash",
|
|
42
|
+
)
|
|
43
|
+
add_p.add_argument("--kind", default="url", help="record kind (default: url)")
|
|
44
|
+
add_p.add_argument("--tool", default=None, help="capturing tool/backend name")
|
|
45
|
+
add_p.add_argument("--query", default=None, help="search query, if applicable")
|
|
46
|
+
add_p.add_argument("--title", default=None, help="source title")
|
|
47
|
+
add_p.add_argument("--claim", default=None, help="the claim this source supports")
|
|
48
|
+
add_p.add_argument("--snippet", default=None, help="short supporting excerpt")
|
|
49
|
+
add_p.add_argument("--archived-url", default=None, help="archived copy URL")
|
|
50
|
+
add_p.add_argument(
|
|
51
|
+
"--path", default=None,
|
|
52
|
+
help="artifact path, relative to the ledger's directory",
|
|
53
|
+
)
|
|
54
|
+
add_p.add_argument(
|
|
55
|
+
"--session-id",
|
|
56
|
+
default=os.environ.get("CLAUDE_CODE_SESSION_ID"),
|
|
57
|
+
help="session identifier (default: $CLAUDE_CODE_SESSION_ID)",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
verify_p = sub.add_parser("verify", help="verify a ledger's integrity")
|
|
61
|
+
verify_p.add_argument("ledger", help="path to the ledger JSONL file")
|
|
62
|
+
verify_p.add_argument(
|
|
63
|
+
"--check-files", action="store_true",
|
|
64
|
+
help="also re-hash referenced artifact files and compare",
|
|
65
|
+
)
|
|
66
|
+
verify_p.add_argument("--json", action="store_true", help="print JSON output")
|
|
67
|
+
|
|
68
|
+
check_p = sub.add_parser("check", help="report whether a source was captured")
|
|
69
|
+
check_p.add_argument("ledger", help="path to the ledger JSONL file")
|
|
70
|
+
check_p.add_argument(
|
|
71
|
+
"--since", default=None,
|
|
72
|
+
help="only count records captured at/after this RFC3339 timestamp",
|
|
73
|
+
)
|
|
74
|
+
check_p.add_argument(
|
|
75
|
+
"--until", default=None,
|
|
76
|
+
help="only count records captured at/before this RFC3339 timestamp",
|
|
77
|
+
)
|
|
78
|
+
check_p.add_argument(
|
|
79
|
+
"--session-id", default=None,
|
|
80
|
+
help="only count records with this exact session_id",
|
|
81
|
+
)
|
|
82
|
+
check_p.add_argument(
|
|
83
|
+
"--enforce", action="store_true",
|
|
84
|
+
help="exit 2 if state is MISSING (UNKNOWN never blocks)",
|
|
85
|
+
)
|
|
86
|
+
check_p.add_argument("--json", action="store_true", help="print JSON output")
|
|
87
|
+
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cmd_add(args: argparse.Namespace) -> int:
|
|
92
|
+
content = args.content_text
|
|
93
|
+
content_path = args.content_file
|
|
94
|
+
ledger = Ledger(args.ledger)
|
|
95
|
+
try:
|
|
96
|
+
record = ledger.add(
|
|
97
|
+
source_url=args.source_url,
|
|
98
|
+
content=content,
|
|
99
|
+
content_path=content_path,
|
|
100
|
+
kind=args.kind,
|
|
101
|
+
tool=args.tool,
|
|
102
|
+
query=args.query,
|
|
103
|
+
title=args.title,
|
|
104
|
+
claim=args.claim,
|
|
105
|
+
snippet=args.snippet,
|
|
106
|
+
archived_url=args.archived_url,
|
|
107
|
+
path=args.path,
|
|
108
|
+
session_id=args.session_id,
|
|
109
|
+
)
|
|
110
|
+
except LockTimeout as e:
|
|
111
|
+
print(f"provtrail add: {e}", file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
except (ContractError, ValueError, LedgerError, OSError) as e:
|
|
114
|
+
print(f"provtrail add: rejected: {e}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
print(json.dumps(record, ensure_ascii=False, indent=2))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _print_verify_report(report: VerifyReport, as_json: bool) -> None:
|
|
121
|
+
if as_json:
|
|
122
|
+
print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2))
|
|
123
|
+
return
|
|
124
|
+
if report.ok:
|
|
125
|
+
print(f"OK: {report.record_count} record(s) verified, no violations.")
|
|
126
|
+
return
|
|
127
|
+
print(
|
|
128
|
+
f"FAILED: {report.record_count} record(s) checked, "
|
|
129
|
+
f"{len(report.violations)} violation(s):"
|
|
130
|
+
)
|
|
131
|
+
for v in report.violations:
|
|
132
|
+
print(f" seq/line {v['seq_or_line']}: {v['code']}: {v['message']}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
136
|
+
ledger = Ledger(args.ledger)
|
|
137
|
+
try:
|
|
138
|
+
report = ledger.verify(check_files=args.check_files)
|
|
139
|
+
except LedgerError as e:
|
|
140
|
+
if args.json:
|
|
141
|
+
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
|
|
142
|
+
else:
|
|
143
|
+
print(f"provtrail verify: {e}", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
_print_verify_report(report, args.json)
|
|
146
|
+
return 0 if report.ok else 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_check_result(result: CheckResult, as_json: bool) -> None:
|
|
150
|
+
if as_json:
|
|
151
|
+
print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
|
|
152
|
+
return
|
|
153
|
+
print(f"{result.state}: {result.reason}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
157
|
+
ledger = Ledger(args.ledger)
|
|
158
|
+
result = ledger.check(since=args.since, session_id=args.session_id, until=args.until)
|
|
159
|
+
_print_check_result(result, args.json)
|
|
160
|
+
if args.enforce and result.state == STATE_MISSING:
|
|
161
|
+
return 2
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
166
|
+
parser = _build_parser()
|
|
167
|
+
args = parser.parse_args(argv)
|
|
168
|
+
|
|
169
|
+
if args.command == "add":
|
|
170
|
+
return _cmd_add(args)
|
|
171
|
+
if args.command == "verify":
|
|
172
|
+
return _cmd_verify(args)
|
|
173
|
+
if args.command == "check":
|
|
174
|
+
return _cmd_check(args)
|
|
175
|
+
|
|
176
|
+
parser.print_help(sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
raise SystemExit(main())
|
provtrail/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shared configuration resolution for provtrail integrations.
|
|
2
|
+
|
|
3
|
+
Used by both the Claude Code Stop hook (``integrations/claude-code/
|
|
4
|
+
stop_hook.py``) and the MCP server (``integrations/mcp/provtrail_mcp.py``)
|
|
5
|
+
so the two agree on where the ledger lives and which mode applies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Dict, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
MODE_REPORT = "report"
|
|
15
|
+
MODE_ENFORCE = "enforce"
|
|
16
|
+
MODE_STRICT = "strict"
|
|
17
|
+
VALID_MODES = (MODE_REPORT, MODE_ENFORCE, MODE_STRICT)
|
|
18
|
+
|
|
19
|
+
CONFIG_FILENAME = ".provtrail.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_config_file(cwd: str) -> Dict[str, Any]:
|
|
23
|
+
config_path = os.path.join(cwd, CONFIG_FILENAME)
|
|
24
|
+
if not os.path.isfile(config_path):
|
|
25
|
+
return {}
|
|
26
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
27
|
+
cfg = json.load(f)
|
|
28
|
+
if not isinstance(cfg, dict):
|
|
29
|
+
raise ValueError(f"{config_path}: expected a JSON object")
|
|
30
|
+
return cfg
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_mode(value: Any) -> str:
|
|
34
|
+
if value not in VALID_MODES:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"invalid provtrail mode {value!r}; must be one of {list(VALID_MODES)}"
|
|
37
|
+
)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_mode(cfg: Dict[str, Any]) -> str:
|
|
42
|
+
env_mode = os.environ.get("PROVTRAIL_MODE")
|
|
43
|
+
if env_mode:
|
|
44
|
+
return _validate_mode(env_mode)
|
|
45
|
+
|
|
46
|
+
if os.environ.get("PROVTRAIL_ENFORCE") == "1":
|
|
47
|
+
return MODE_ENFORCE
|
|
48
|
+
|
|
49
|
+
cfg_mode = cfg.get("mode")
|
|
50
|
+
if cfg_mode:
|
|
51
|
+
return _validate_mode(cfg_mode)
|
|
52
|
+
|
|
53
|
+
if bool(cfg.get("enforce", False)):
|
|
54
|
+
return MODE_ENFORCE
|
|
55
|
+
|
|
56
|
+
return MODE_REPORT
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_config(cwd: str) -> Tuple[Optional[str], str]:
|
|
60
|
+
"""Resolve the ledger path and mode for the project at ``cwd``.
|
|
61
|
+
|
|
62
|
+
Ledger path: the ``PROVTRAIL_LEDGER`` environment variable if set,
|
|
63
|
+
else the ``"ledger"`` field of ``<cwd>/.provtrail.json``. A relative
|
|
64
|
+
path is joined to ``cwd``. Returns ``None`` if neither is configured.
|
|
65
|
+
|
|
66
|
+
Mode is resolved independently of the ledger path, so setting only
|
|
67
|
+
``PROVTRAIL_LEDGER`` does not reset a mode configured in the file.
|
|
68
|
+
Precedence, highest first:
|
|
69
|
+
1. ``PROVTRAIL_MODE`` environment variable.
|
|
70
|
+
2. legacy ``PROVTRAIL_ENFORCE=1`` environment variable -> "enforce".
|
|
71
|
+
3. the config file's ``"mode"`` field.
|
|
72
|
+
4. legacy config ``"enforce": true`` -> "enforce".
|
|
73
|
+
5. default: "report".
|
|
74
|
+
|
|
75
|
+
Raises ``ValueError`` if the config file is not valid JSON, or if an
|
|
76
|
+
explicit mode value (env or config) is not one of "report",
|
|
77
|
+
"enforce", "strict".
|
|
78
|
+
"""
|
|
79
|
+
cfg = _load_config_file(cwd)
|
|
80
|
+
|
|
81
|
+
env_ledger = os.environ.get("PROVTRAIL_LEDGER")
|
|
82
|
+
if env_ledger:
|
|
83
|
+
ledger_path: Optional[str] = os.path.join(cwd, env_ledger)
|
|
84
|
+
else:
|
|
85
|
+
cfg_ledger = cfg.get("ledger")
|
|
86
|
+
ledger_path = os.path.join(cwd, cfg_ledger) if cfg_ledger else None
|
|
87
|
+
|
|
88
|
+
mode = _resolve_mode(cfg)
|
|
89
|
+
return ledger_path, mode
|
provtrail/hashing.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Canonical JSON encoding and hashing helpers for provtrail.
|
|
2
|
+
|
|
3
|
+
The chain-of-custody guarantee rests on one rule: every record hash is
|
|
4
|
+
computed from a deterministic byte representation of the record. This
|
|
5
|
+
module is the single place that defines that representation, so ledger
|
|
6
|
+
writing and ledger verification can never silently drift apart.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
|
|
15
|
+
SHA256_PREFIX = "sha256:"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_json_bytes(obj: Mapping[str, Any]) -> bytes:
|
|
19
|
+
"""Return the canonical JSON byte encoding used for hashing.
|
|
20
|
+
|
|
21
|
+
Canonical JSON = ``json.dumps(obj, sort_keys=True,
|
|
22
|
+
separators=(",", ":"), ensure_ascii=False)`` encoded as UTF-8.
|
|
23
|
+
"""
|
|
24
|
+
return json.dumps(
|
|
25
|
+
obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
26
|
+
).encode("utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sha256_hex(data: bytes) -> str:
|
|
30
|
+
"""Return the lowercase hex sha256 digest of ``data``."""
|
|
31
|
+
return hashlib.sha256(data).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sha256_prefixed(data: bytes) -> str:
|
|
35
|
+
"""Return ``sha256:<hex>`` for ``data``."""
|
|
36
|
+
return SHA256_PREFIX + sha256_hex(data)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sha256_of_text(text: str) -> str:
|
|
40
|
+
"""Return ``sha256:<hex>`` for a text payload (UTF-8 encoded)."""
|
|
41
|
+
return sha256_prefixed(text.encode("utf-8"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sha256_of_bytes(data: bytes) -> str:
|
|
45
|
+
"""Return ``sha256:<hex>`` for a raw bytes payload."""
|
|
46
|
+
return sha256_prefixed(data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sha256_of_file(path: str, chunk_size: int = 1024 * 1024) -> str:
|
|
50
|
+
"""Stream-hash a file on disk and return ``sha256:<hex>``."""
|
|
51
|
+
h = hashlib.sha256()
|
|
52
|
+
with open(path, "rb") as f:
|
|
53
|
+
while True:
|
|
54
|
+
chunk = f.read(chunk_size)
|
|
55
|
+
if not chunk:
|
|
56
|
+
break
|
|
57
|
+
h.update(chunk)
|
|
58
|
+
return SHA256_PREFIX + h.hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_valid_content_hash(value: Any) -> bool:
|
|
62
|
+
"""Check whether ``value`` is a well-formed ``sha256:<64 hex>`` string."""
|
|
63
|
+
if not isinstance(value, str):
|
|
64
|
+
return False
|
|
65
|
+
if not value.startswith(SHA256_PREFIX):
|
|
66
|
+
return False
|
|
67
|
+
digest = value[len(SHA256_PREFIX):]
|
|
68
|
+
if len(digest) != 64:
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
int(digest, 16)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return False
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def record_hash(record: Mapping[str, Any]) -> str:
|
|
78
|
+
"""Compute the ``record_hash`` for a record dict.
|
|
79
|
+
|
|
80
|
+
The hash is computed over the canonical JSON of the record WITHOUT
|
|
81
|
+
the ``record_hash`` key itself (a record cannot include its own hash
|
|
82
|
+
inside the hashed payload).
|
|
83
|
+
|
|
84
|
+
The ``id`` key is excluded for the same reason: ``id`` is defined as
|
|
85
|
+
``"ev_" + record_hash[:16]`` (after the ``sha256:`` prefix), so it is
|
|
86
|
+
derived FROM ``record_hash`` and cannot also be an input to it without
|
|
87
|
+
circularity. ``record_hash`` is computed once, before ``id`` is
|
|
88
|
+
assigned, over a payload that contains neither key; excluding both
|
|
89
|
+
during verification recomputes the same payload.
|
|
90
|
+
"""
|
|
91
|
+
payload = {k: v for k, v in record.items() if k not in ("record_hash", "id")}
|
|
92
|
+
return sha256_prefixed(canonical_json_bytes(payload))
|