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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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))