provtrail 0.1.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.
Files changed (32) hide show
  1. provtrail-0.1.0/CHANGELOG.md +27 -0
  2. provtrail-0.1.0/LICENSE +21 -0
  3. provtrail-0.1.0/MANIFEST.in +4 -0
  4. provtrail-0.1.0/PKG-INFO +311 -0
  5. provtrail-0.1.0/README.md +295 -0
  6. provtrail-0.1.0/integrations/claude-code/SKILL.md +75 -0
  7. provtrail-0.1.0/integrations/claude-code/settings.example.json +14 -0
  8. provtrail-0.1.0/integrations/claude-code/stop_hook.py +21 -0
  9. provtrail-0.1.0/integrations/mcp/provtrail_mcp.py +21 -0
  10. provtrail-0.1.0/pyproject.toml +29 -0
  11. provtrail-0.1.0/schema/provtrail-record.v1.json +75 -0
  12. provtrail-0.1.0/setup.cfg +4 -0
  13. provtrail-0.1.0/src/provtrail/__init__.py +28 -0
  14. provtrail-0.1.0/src/provtrail/__main__.py +4 -0
  15. provtrail-0.1.0/src/provtrail/cli.py +181 -0
  16. provtrail-0.1.0/src/provtrail/config.py +89 -0
  17. provtrail-0.1.0/src/provtrail/hashing.py +92 -0
  18. provtrail-0.1.0/src/provtrail/ledger.py +662 -0
  19. provtrail-0.1.0/src/provtrail/mcp_server.py +161 -0
  20. provtrail-0.1.0/src/provtrail/stop_hook.py +203 -0
  21. provtrail-0.1.0/src/provtrail.egg-info/PKG-INFO +311 -0
  22. provtrail-0.1.0/src/provtrail.egg-info/SOURCES.txt +30 -0
  23. provtrail-0.1.0/src/provtrail.egg-info/dependency_links.txt +1 -0
  24. provtrail-0.1.0/src/provtrail.egg-info/entry_points.txt +4 -0
  25. provtrail-0.1.0/src/provtrail.egg-info/requires.txt +3 -0
  26. provtrail-0.1.0/src/provtrail.egg-info/top_level.txt +1 -0
  27. provtrail-0.1.0/tests/test_cli.py +185 -0
  28. provtrail-0.1.0/tests/test_config.py +126 -0
  29. provtrail-0.1.0/tests/test_hardening.py +188 -0
  30. provtrail-0.1.0/tests/test_ledger.py +389 -0
  31. provtrail-0.1.0/tests/test_mcp.py +119 -0
  32. provtrail-0.1.0/tests/test_stop_hook.py +321 -0
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [0.1.0] - Unreleased
6
+
7
+ ### Added
8
+ - Hash-chained JSONL ledger: `Ledger.add`, `Ledger.records`,
9
+ `Ledger.verify`, and `Ledger.check` (optionally scoped by `session_id`
10
+ and bounded by `since`/`until`). `add` refuses to append to a ledger
11
+ that fails verification.
12
+ - JSON Schema (draft 2020-12) for record format v1. `provtrail verify`
13
+ is the normative check; the schema is structural.
14
+ - CLI: `provtrail add`, `provtrail verify`, `provtrail check`. `add`
15
+ takes `--session-id` from `$CLAUDE_CODE_SESSION_ID` by default.
16
+ - `provtrail.config.resolve_config`: ledger path and mode resolution
17
+ shared by the Stop hook and the MCP server.
18
+ - Claude Code Stop hook (`provtrail-stop-hook` command, module
19
+ `provtrail.stop_hook`) with `report` (default), `enforce`, and `strict`
20
+ modes, matched to the payload's `session_id`. It reports `UNKNOWN`
21
+ when it cannot scope the check to the session, and reports unexpected
22
+ errors via `systemMessage`.
23
+ - Claude Code agent skill describing when and how to capture sources.
24
+ - MCP server (`provtrail-mcp` command, module `provtrail.mcp_server`)
25
+ for mcp 1.x and 2.x. The ledger is resolved from configuration, not passed by
26
+ the caller; files are accepted via `content_path` only inside the
27
+ ledger's directory.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 provtrail contributors
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.
@@ -0,0 +1,4 @@
1
+ include CHANGELOG.md
2
+ recursive-include integrations *.py *.json *.md
3
+ recursive-include schema *.json
4
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,311 @@
1
+ Metadata-Version: 2.4
2
+ Name: provtrail
3
+ Version: 0.1.0
4
+ Summary: Append-only, hash-chained ledger for recording the sources used in LLM-assisted research.
5
+ Author: provtrail contributors
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp>=1.2; extra == "mcp"
15
+ Dynamic: license-file
16
+
17
+ # provtrail
18
+
19
+ provtrail is an append-only, hash-chained JSONL ledger for recording the
20
+ sources used in LLM-assisted research. It has no runtime dependencies
21
+ beyond the Python standard library and ships with a Claude Code Stop hook,
22
+ an agent skill, and an optional MCP server.
23
+
24
+ ## Record contract
25
+
26
+ A record is valid if and only if:
27
+
28
+ - `captured_at` is an RFC 3339 `date-time`: `T` separator, optional
29
+ fractional seconds, and `Z` or a `±HH:MM` offset (a leap second `:60`
30
+ is accepted and treated as `:59`), and
31
+ - `source_url` is a non-empty string, or `content_hash` is a
32
+ `sha256:<64 hex>` string (or both).
33
+
34
+ All other fields (`kind`, `tool`, `query`, `title`, `claim`, `snippet`,
35
+ `archived_url`, `path`, `session_id`, `extra`) are optional metadata.
36
+
37
+ Each record stores the `record_hash` of its predecessor in `prev_hash`.
38
+ `provtrail verify` walks the whole file and reports every violation with
39
+ a stable error code.
40
+
41
+ ## Scope
42
+
43
+ provtrail records *which sources were captured and when*. It does not:
44
+
45
+ - extract or track claims from a finished manuscript;
46
+ - log agent actions or tool calls in general;
47
+ - capture, render, or archive web pages. It hashes content you supply
48
+ and chains the records; preserving the page itself is left to a
49
+ dedicated capture tool, whose output can be logged as a provtrail record.
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ pip install -e . # from a clone
55
+ pip install -e ".[mcp]" # with the optional MCP server dependency
56
+ ```
57
+
58
+ The core package and the Stop hook require Python 3.9 or later. The
59
+ `[mcp]` extra requires Python 3.10 or later, because the `mcp` package
60
+ does not support 3.9.
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ # append a record
66
+ provtrail add ./ledger.jsonl --url "https://example.com/report" \
67
+ --title "Example report" --claim "X happened in 2024"
68
+
69
+ # append a record identified by content rather than URL
70
+ provtrail add ./ledger.jsonl --content-file ./report.pdf --kind file --path report.pdf
71
+
72
+ # verify the chain (add --check-files to re-hash referenced files)
73
+ provtrail verify ./ledger.jsonl
74
+
75
+ # report whether any valid record exists at or after a timestamp
76
+ provtrail check ./ledger.jsonl --since 2024-01-01T00:00:00Z
77
+
78
+ # narrow further to a time window and/or a specific session
79
+ provtrail check ./ledger.jsonl --since 2024-01-01T00:00:00Z --until 2024-01-02T00:00:00Z
80
+ provtrail check ./ledger.jsonl --session-id abc123
81
+ ```
82
+
83
+ Exit codes:
84
+
85
+ | Command | 0 | 1 | 2 |
86
+ |---|---|---|---|
87
+ | `add` | record appended | contract violation, invalid `kind`, or lock timeout | usage error |
88
+ | `verify` | no violations | violations found | usage error |
89
+ | `check` | all states without `--enforce` | — | `MISSING` under `--enforce`; usage error |
90
+
91
+ `check` returns one of three states:
92
+
93
+ - `PRESENT`: the ledger verifies and contains a matching record.
94
+ - `MISSING`: the ledger verifies and contains no matching record.
95
+ - `UNKNOWN`: the check could not be performed (ledger absent, chain
96
+ fails verification, or `--since` is unparseable).
97
+
98
+ `UNKNOWN` never produces a non-zero exit, including under `--enforce`.
99
+ A failed measurement is reported, not treated as a confirmed absence.
100
+
101
+ ## Record schema (v1)
102
+
103
+ The JSON Schema (draft 2020-12) is in `schema/provtrail-record.v1.json`.
104
+
105
+ ### Validation layers
106
+
107
+ The JSON Schema is a structural check: it constrains types, required
108
+ fields, and patterns, and annotates `captured_at` with `"format":
109
+ "date-time"`. Whether that `format` annotation is actually enforced
110
+ depends on the JSON Schema validator; draft 2020-12 treats `format` as
111
+ advisory unless the validator opts into assertion behaviour. `provtrail
112
+ verify` is the normative check: it parses `captured_at` with the same
113
+ RFC 3339 rules the ledger itself uses, recomputes every hash, and
114
+ walks the `prev_hash` chain, none of which a schema validator does. Use
115
+ the schema for editor/IDE hints and quick structural checks; use
116
+ `provtrail verify` to decide whether a ledger is actually trustworthy.
117
+
118
+ | Field | Type | Notes |
119
+ |---|---|---|
120
+ | `schema` | string | Always `"provtrail/v1"`. |
121
+ | `seq` | integer | 1-based, contiguous within a ledger. |
122
+ | `id` | string | `"ev_"` followed by the first 16 hex characters of the `record_hash` digest. |
123
+ | `captured_at` | string | RFC 3339 timestamp. Required. |
124
+ | `source_url` | string | Required unless `content_hash` is set. |
125
+ | `content_hash` | string | `sha256:<64 hex>`. Required unless `source_url` is set. |
126
+ | `kind` | string | `url`, `search`, `scrape`, `file`, or `manual`. Default `url`. |
127
+ | `tool` | string | Capturing tool or backend, e.g. `web-search:api`. |
128
+ | `query` | string | Search query, if any. |
129
+ | `title` | string | Source title. |
130
+ | `claim` | string | The statement this source supports. |
131
+ | `snippet` | string | Short supporting excerpt. |
132
+ | `archived_url` | string | URL of an archived copy. |
133
+ | `path` | string | Artifact path relative to the ledger's directory. Absolute paths and paths that leave the directory are rejected. |
134
+ | `session_id` | string | Identifier of the capturing session. |
135
+ | `extra` | object | Free-form metadata. |
136
+ | `prev_hash` | string or null | `record_hash` of the previous record; `null` for `seq` 1. |
137
+ | `record_hash` | string | `sha256:` digest of the canonical JSON of the record, excluding `record_hash` and `id`. |
138
+
139
+ Canonical JSON is `json.dumps(record, sort_keys=True, separators=(",", ":"),
140
+ ensure_ascii=False)` encoded as UTF-8.
141
+
142
+ ### Violation codes
143
+
144
+ | Code | Meaning |
145
+ |---|---|
146
+ | `INVALID_JSON` | Line is not valid UTF-8, not valid JSON, or not a JSON object. |
147
+ | `WRONG_SCHEMA` | `schema` is not `provtrail/v1`. |
148
+ | `MISSING_CAPTURED_AT` | `captured_at` is absent or empty. |
149
+ | `INVALID_CAPTURED_AT` | `captured_at` is present but not parseable. |
150
+ | `MISSING_SOURCE_LOCATOR` | Neither `source_url` nor a valid `content_hash` is present. |
151
+ | `BAD_SEQ` | `seq` does not follow the previous record. |
152
+ | `CHAIN_BROKEN` | `prev_hash` does not match the previous record's `record_hash`. |
153
+ | `HASH_MISMATCH` | `record_hash` does not match the recomputed value. |
154
+ | `ID_MISMATCH` | `id` does not match the value derived from the recomputed hash. |
155
+ | `INVALID_PATH` | `path` is absolute or leaves the ledger's directory (with `--check-files`, also after following symlinks). |
156
+ | `FILE_MISSING` | `--check-files`: referenced file is absent or unreadable. |
157
+ | `FILE_HASH_MISMATCH` | `--check-files`: file content does not match `content_hash`. |
158
+
159
+ ## Claude Code integration
160
+
161
+ ### Stop hook
162
+
163
+ A capture tool that the model has to call explicitly is easy to skip,
164
+ especially late in a long session. The Stop hook checks the ledger
165
+ state at the end of every turn regardless of what the model called, so
166
+ it is the recommended integration.
167
+
168
+ 1. Add the `Stop` entry from `integrations/claude-code/settings.example.json`
169
+ to your project's `.claude/settings.json`. It runs the
170
+ `provtrail-stop-hook` command installed with the package. If that
171
+ command is not on the `PATH` Claude Code uses, give its absolute
172
+ path, or use `python -m provtrail.stop_hook` with the interpreter of
173
+ the environment where provtrail is installed. From a source checkout
174
+ without installing, point the command at
175
+ `integrations/claude-code/stop_hook.py`.
176
+ 2. Create `.provtrail.json` in the project root:
177
+ ```json
178
+ { "ledger": "./ledger.jsonl", "mode": "report" }
179
+ ```
180
+ Alternatively set `PROVTRAIL_LEDGER` and, optionally, `PROVTRAIL_MODE`.
181
+
182
+ Without a configured ledger, the hook exits silently; projects opt in
183
+ explicitly.
184
+
185
+ #### Configuration precedence
186
+
187
+ The Stop hook and the MCP server both resolve configuration through
188
+ `provtrail.config.resolve_config`, so they always agree.
189
+
190
+ Ledger path:
191
+
192
+ 1. `PROVTRAIL_LEDGER` environment variable.
193
+ 2. the `"ledger"` field of `<project>/.provtrail.json`.
194
+
195
+ A relative path is resolved against the project directory (the hook
196
+ payload's `cwd` for the hook; the server's own working directory for
197
+ the MCP server), not the process's own working directory.
198
+
199
+ Mode, resolved **independently** of the ledger path (setting only
200
+ `PROVTRAIL_LEDGER` does not reset a mode configured in the file):
201
+
202
+ 1. `PROVTRAIL_MODE` environment variable.
203
+ 2. legacy `PROVTRAIL_ENFORCE=1` environment variable, equivalent to `enforce`.
204
+ 3. the `"mode"` field of `.provtrail.json`.
205
+ 4. legacy `"enforce": true` in `.provtrail.json`, equivalent to `enforce`.
206
+ 5. default: `report`.
207
+
208
+ An invalid mode value (from either source) raises an error rather than
209
+ silently falling back.
210
+
211
+ #### Modes
212
+
213
+ | Mode | MISSING | UNKNOWN |
214
+ |---|---|---|
215
+ | `report` (default) | `systemMessage`, never blocks | `systemMessage`, never blocks |
216
+ | `enforce` | blocks | `systemMessage`, never blocks |
217
+ | `strict` | blocks | blocks |
218
+
219
+ `PRESENT` never blocks in any mode. A hook re-invocation
220
+ (`stop_hook_active` true) never blocks in any mode either, which
221
+ prevents Stop-hook loops.
222
+
223
+ #### Session matching and the `until` bound
224
+
225
+ - If the Stop-hook payload includes `session_id`, only ledger records
226
+ whose own `session_id` field equals it count towards `PRESENT`. This
227
+ stops a record captured in an unrelated session from satisfying the
228
+ check.
229
+ - The check window closes 300 seconds after the hook runs (an `until`
230
+ bound), so a record dated more than 300 seconds in the future does
231
+ not count. The margin allows for clock differences between the
232
+ process that writes the record and the hook.
233
+ - The window still opens at the first timestamp found in the session
234
+ transcript, when there is one, so the hook asks whether a source was
235
+ captured **during the current session**, not during the latest turn.
236
+ - If `session_id` is absent from the payload AND the transcript has no
237
+ timestamp (missing, unreadable, or simply empty of them), the check
238
+ cannot be scoped to this session at all: the state is `UNKNOWN`
239
+ rather than treating an unrelated old record as `PRESENT`.
240
+ - If the ledger file does not exist but its directory does, the hook
241
+ treats the state as `MISSING` (nothing captured yet). If the
242
+ directory is also missing, the state is `UNKNOWN`, since the path is
243
+ probably misconfigured.
244
+ - Any unexpected error (a malformed `.provtrail.json`, an invalid mode
245
+ value, etc.) is reported as a `systemMessage` naming the exception,
246
+ never silently swallowed. If the mode had already resolved to
247
+ `strict` before the error, the hook blocks instead of just
248
+ reporting, unless `stop_hook_active` is true. The hook always exits 0.
249
+
250
+ ### Agent skill
251
+
252
+ `integrations/claude-code/SKILL.md` describes when and how the model
253
+ should call `provtrail add`. Install it as a project or user skill.
254
+
255
+ ### MCP server
256
+
257
+ `provtrail-mcp` (module `provtrail.mcp_server`; source-checkout entry
258
+ point `integrations/mcp/provtrail_mcp.py`) is an MCP server over stdio,
259
+ built on `MCPServer` with mcp 2.x and `FastMCP` with mcp 1.x, that
260
+ exposes `provtrail_add` and `provtrail_verify`. It requires the `[mcp]`
261
+ extra. It is a convenience for models that call tools explicitly and
262
+ does not replace the Stop hook.
263
+
264
+ Neither tool takes a ledger path; the server resolves it itself (the
265
+ same `resolve_config` used by the Stop hook, applied to the server's
266
+ own working directory). `provtrail_add` accepts a narrower set of
267
+ fields than the CLI: `source_url`, `content`, `content_path`, `tool`,
268
+ `kind`, `claim`, `title`, `query`, `snippet`, `archived_url`, and
269
+ `session_id`. A `content_path` is resolved against the ledger's
270
+ directory when relative and must resolve (after following symlinks)
271
+ inside that directory; it is rejected otherwise. The file is hashed
272
+ from its raw bytes, and its path relative to the ledger is stored in
273
+ `path`, so `provtrail verify --check-files` can re-hash it. `session_id` falls
274
+ back to the `CLAUDE_CODE_SESSION_ID` environment variable when not
275
+ supplied, but whether Claude Code actually sets that variable for an
276
+ MCP server process is not verified (it is verified for Bash tool
277
+ subprocesses), so pass `session_id` explicitly if session matching
278
+ against the Stop hook matters for your workflow.
279
+
280
+ ## Security model and limitations
281
+
282
+ - **Tamper-evident, not tamper-proof.** Editing, reordering, or removing
283
+ a record in the middle of the ledger is detected by `verify`.
284
+ Truncation is not: dropping the last N records leaves a valid, shorter
285
+ chain, and anyone able to rewrite the whole file can build a new
286
+ consistent chain. To detect either, record the latest `record_hash`
287
+ outside the ledger periodically (a commit message, a separate log, or
288
+ a timestamping service).
289
+ - **A ledger that fails verification is not extended.** `add` verifies
290
+ the ledger before appending and refuses (exit 1 from the CLI) if any
291
+ violation is found, so an edited file, or one left with a partial
292
+ last line by a crash, never gets new records chained onto it. Repair
293
+ the file or start a new ledger before capturing again.
294
+ - **Advisory locking.** `<ledger>.lock` serialises concurrent
295
+ `provtrail add` calls. It does not stop a process that ignores the
296
+ lock or writes to the file directly. A lock left behind by a crashed
297
+ process causes `add` to time out and must be removed manually.
298
+ - **No network access.** provtrail never fetches a URL and does not check
299
+ that a URL is reachable or that supplied content came from it.
300
+ - **Plain-text storage.** The ledger is unencrypted JSONL. Do not store
301
+ credentials, tokens, or personal data in any field.
302
+
303
+ ## Development
304
+
305
+ ```bash
306
+ python -m unittest discover -s tests -v
307
+ ```
308
+
309
+ ## License
310
+
311
+ MIT. See `LICENSE`.
@@ -0,0 +1,295 @@
1
+ # provtrail
2
+
3
+ provtrail is an append-only, hash-chained JSONL ledger for recording the
4
+ sources used in LLM-assisted research. It has no runtime dependencies
5
+ beyond the Python standard library and ships with a Claude Code Stop hook,
6
+ an agent skill, and an optional MCP server.
7
+
8
+ ## Record contract
9
+
10
+ A record is valid if and only if:
11
+
12
+ - `captured_at` is an RFC 3339 `date-time`: `T` separator, optional
13
+ fractional seconds, and `Z` or a `±HH:MM` offset (a leap second `:60`
14
+ is accepted and treated as `:59`), and
15
+ - `source_url` is a non-empty string, or `content_hash` is a
16
+ `sha256:<64 hex>` string (or both).
17
+
18
+ All other fields (`kind`, `tool`, `query`, `title`, `claim`, `snippet`,
19
+ `archived_url`, `path`, `session_id`, `extra`) are optional metadata.
20
+
21
+ Each record stores the `record_hash` of its predecessor in `prev_hash`.
22
+ `provtrail verify` walks the whole file and reports every violation with
23
+ a stable error code.
24
+
25
+ ## Scope
26
+
27
+ provtrail records *which sources were captured and when*. It does not:
28
+
29
+ - extract or track claims from a finished manuscript;
30
+ - log agent actions or tool calls in general;
31
+ - capture, render, or archive web pages. It hashes content you supply
32
+ and chains the records; preserving the page itself is left to a
33
+ dedicated capture tool, whose output can be logged as a provtrail record.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install -e . # from a clone
39
+ pip install -e ".[mcp]" # with the optional MCP server dependency
40
+ ```
41
+
42
+ The core package and the Stop hook require Python 3.9 or later. The
43
+ `[mcp]` extra requires Python 3.10 or later, because the `mcp` package
44
+ does not support 3.9.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ # append a record
50
+ provtrail add ./ledger.jsonl --url "https://example.com/report" \
51
+ --title "Example report" --claim "X happened in 2024"
52
+
53
+ # append a record identified by content rather than URL
54
+ provtrail add ./ledger.jsonl --content-file ./report.pdf --kind file --path report.pdf
55
+
56
+ # verify the chain (add --check-files to re-hash referenced files)
57
+ provtrail verify ./ledger.jsonl
58
+
59
+ # report whether any valid record exists at or after a timestamp
60
+ provtrail check ./ledger.jsonl --since 2024-01-01T00:00:00Z
61
+
62
+ # narrow further to a time window and/or a specific session
63
+ provtrail check ./ledger.jsonl --since 2024-01-01T00:00:00Z --until 2024-01-02T00:00:00Z
64
+ provtrail check ./ledger.jsonl --session-id abc123
65
+ ```
66
+
67
+ Exit codes:
68
+
69
+ | Command | 0 | 1 | 2 |
70
+ |---|---|---|---|
71
+ | `add` | record appended | contract violation, invalid `kind`, or lock timeout | usage error |
72
+ | `verify` | no violations | violations found | usage error |
73
+ | `check` | all states without `--enforce` | — | `MISSING` under `--enforce`; usage error |
74
+
75
+ `check` returns one of three states:
76
+
77
+ - `PRESENT`: the ledger verifies and contains a matching record.
78
+ - `MISSING`: the ledger verifies and contains no matching record.
79
+ - `UNKNOWN`: the check could not be performed (ledger absent, chain
80
+ fails verification, or `--since` is unparseable).
81
+
82
+ `UNKNOWN` never produces a non-zero exit, including under `--enforce`.
83
+ A failed measurement is reported, not treated as a confirmed absence.
84
+
85
+ ## Record schema (v1)
86
+
87
+ The JSON Schema (draft 2020-12) is in `schema/provtrail-record.v1.json`.
88
+
89
+ ### Validation layers
90
+
91
+ The JSON Schema is a structural check: it constrains types, required
92
+ fields, and patterns, and annotates `captured_at` with `"format":
93
+ "date-time"`. Whether that `format` annotation is actually enforced
94
+ depends on the JSON Schema validator; draft 2020-12 treats `format` as
95
+ advisory unless the validator opts into assertion behaviour. `provtrail
96
+ verify` is the normative check: it parses `captured_at` with the same
97
+ RFC 3339 rules the ledger itself uses, recomputes every hash, and
98
+ walks the `prev_hash` chain, none of which a schema validator does. Use
99
+ the schema for editor/IDE hints and quick structural checks; use
100
+ `provtrail verify` to decide whether a ledger is actually trustworthy.
101
+
102
+ | Field | Type | Notes |
103
+ |---|---|---|
104
+ | `schema` | string | Always `"provtrail/v1"`. |
105
+ | `seq` | integer | 1-based, contiguous within a ledger. |
106
+ | `id` | string | `"ev_"` followed by the first 16 hex characters of the `record_hash` digest. |
107
+ | `captured_at` | string | RFC 3339 timestamp. Required. |
108
+ | `source_url` | string | Required unless `content_hash` is set. |
109
+ | `content_hash` | string | `sha256:<64 hex>`. Required unless `source_url` is set. |
110
+ | `kind` | string | `url`, `search`, `scrape`, `file`, or `manual`. Default `url`. |
111
+ | `tool` | string | Capturing tool or backend, e.g. `web-search:api`. |
112
+ | `query` | string | Search query, if any. |
113
+ | `title` | string | Source title. |
114
+ | `claim` | string | The statement this source supports. |
115
+ | `snippet` | string | Short supporting excerpt. |
116
+ | `archived_url` | string | URL of an archived copy. |
117
+ | `path` | string | Artifact path relative to the ledger's directory. Absolute paths and paths that leave the directory are rejected. |
118
+ | `session_id` | string | Identifier of the capturing session. |
119
+ | `extra` | object | Free-form metadata. |
120
+ | `prev_hash` | string or null | `record_hash` of the previous record; `null` for `seq` 1. |
121
+ | `record_hash` | string | `sha256:` digest of the canonical JSON of the record, excluding `record_hash` and `id`. |
122
+
123
+ Canonical JSON is `json.dumps(record, sort_keys=True, separators=(",", ":"),
124
+ ensure_ascii=False)` encoded as UTF-8.
125
+
126
+ ### Violation codes
127
+
128
+ | Code | Meaning |
129
+ |---|---|
130
+ | `INVALID_JSON` | Line is not valid UTF-8, not valid JSON, or not a JSON object. |
131
+ | `WRONG_SCHEMA` | `schema` is not `provtrail/v1`. |
132
+ | `MISSING_CAPTURED_AT` | `captured_at` is absent or empty. |
133
+ | `INVALID_CAPTURED_AT` | `captured_at` is present but not parseable. |
134
+ | `MISSING_SOURCE_LOCATOR` | Neither `source_url` nor a valid `content_hash` is present. |
135
+ | `BAD_SEQ` | `seq` does not follow the previous record. |
136
+ | `CHAIN_BROKEN` | `prev_hash` does not match the previous record's `record_hash`. |
137
+ | `HASH_MISMATCH` | `record_hash` does not match the recomputed value. |
138
+ | `ID_MISMATCH` | `id` does not match the value derived from the recomputed hash. |
139
+ | `INVALID_PATH` | `path` is absolute or leaves the ledger's directory (with `--check-files`, also after following symlinks). |
140
+ | `FILE_MISSING` | `--check-files`: referenced file is absent or unreadable. |
141
+ | `FILE_HASH_MISMATCH` | `--check-files`: file content does not match `content_hash`. |
142
+
143
+ ## Claude Code integration
144
+
145
+ ### Stop hook
146
+
147
+ A capture tool that the model has to call explicitly is easy to skip,
148
+ especially late in a long session. The Stop hook checks the ledger
149
+ state at the end of every turn regardless of what the model called, so
150
+ it is the recommended integration.
151
+
152
+ 1. Add the `Stop` entry from `integrations/claude-code/settings.example.json`
153
+ to your project's `.claude/settings.json`. It runs the
154
+ `provtrail-stop-hook` command installed with the package. If that
155
+ command is not on the `PATH` Claude Code uses, give its absolute
156
+ path, or use `python -m provtrail.stop_hook` with the interpreter of
157
+ the environment where provtrail is installed. From a source checkout
158
+ without installing, point the command at
159
+ `integrations/claude-code/stop_hook.py`.
160
+ 2. Create `.provtrail.json` in the project root:
161
+ ```json
162
+ { "ledger": "./ledger.jsonl", "mode": "report" }
163
+ ```
164
+ Alternatively set `PROVTRAIL_LEDGER` and, optionally, `PROVTRAIL_MODE`.
165
+
166
+ Without a configured ledger, the hook exits silently; projects opt in
167
+ explicitly.
168
+
169
+ #### Configuration precedence
170
+
171
+ The Stop hook and the MCP server both resolve configuration through
172
+ `provtrail.config.resolve_config`, so they always agree.
173
+
174
+ Ledger path:
175
+
176
+ 1. `PROVTRAIL_LEDGER` environment variable.
177
+ 2. the `"ledger"` field of `<project>/.provtrail.json`.
178
+
179
+ A relative path is resolved against the project directory (the hook
180
+ payload's `cwd` for the hook; the server's own working directory for
181
+ the MCP server), not the process's own working directory.
182
+
183
+ Mode, resolved **independently** of the ledger path (setting only
184
+ `PROVTRAIL_LEDGER` does not reset a mode configured in the file):
185
+
186
+ 1. `PROVTRAIL_MODE` environment variable.
187
+ 2. legacy `PROVTRAIL_ENFORCE=1` environment variable, equivalent to `enforce`.
188
+ 3. the `"mode"` field of `.provtrail.json`.
189
+ 4. legacy `"enforce": true` in `.provtrail.json`, equivalent to `enforce`.
190
+ 5. default: `report`.
191
+
192
+ An invalid mode value (from either source) raises an error rather than
193
+ silently falling back.
194
+
195
+ #### Modes
196
+
197
+ | Mode | MISSING | UNKNOWN |
198
+ |---|---|---|
199
+ | `report` (default) | `systemMessage`, never blocks | `systemMessage`, never blocks |
200
+ | `enforce` | blocks | `systemMessage`, never blocks |
201
+ | `strict` | blocks | blocks |
202
+
203
+ `PRESENT` never blocks in any mode. A hook re-invocation
204
+ (`stop_hook_active` true) never blocks in any mode either, which
205
+ prevents Stop-hook loops.
206
+
207
+ #### Session matching and the `until` bound
208
+
209
+ - If the Stop-hook payload includes `session_id`, only ledger records
210
+ whose own `session_id` field equals it count towards `PRESENT`. This
211
+ stops a record captured in an unrelated session from satisfying the
212
+ check.
213
+ - The check window closes 300 seconds after the hook runs (an `until`
214
+ bound), so a record dated more than 300 seconds in the future does
215
+ not count. The margin allows for clock differences between the
216
+ process that writes the record and the hook.
217
+ - The window still opens at the first timestamp found in the session
218
+ transcript, when there is one, so the hook asks whether a source was
219
+ captured **during the current session**, not during the latest turn.
220
+ - If `session_id` is absent from the payload AND the transcript has no
221
+ timestamp (missing, unreadable, or simply empty of them), the check
222
+ cannot be scoped to this session at all: the state is `UNKNOWN`
223
+ rather than treating an unrelated old record as `PRESENT`.
224
+ - If the ledger file does not exist but its directory does, the hook
225
+ treats the state as `MISSING` (nothing captured yet). If the
226
+ directory is also missing, the state is `UNKNOWN`, since the path is
227
+ probably misconfigured.
228
+ - Any unexpected error (a malformed `.provtrail.json`, an invalid mode
229
+ value, etc.) is reported as a `systemMessage` naming the exception,
230
+ never silently swallowed. If the mode had already resolved to
231
+ `strict` before the error, the hook blocks instead of just
232
+ reporting, unless `stop_hook_active` is true. The hook always exits 0.
233
+
234
+ ### Agent skill
235
+
236
+ `integrations/claude-code/SKILL.md` describes when and how the model
237
+ should call `provtrail add`. Install it as a project or user skill.
238
+
239
+ ### MCP server
240
+
241
+ `provtrail-mcp` (module `provtrail.mcp_server`; source-checkout entry
242
+ point `integrations/mcp/provtrail_mcp.py`) is an MCP server over stdio,
243
+ built on `MCPServer` with mcp 2.x and `FastMCP` with mcp 1.x, that
244
+ exposes `provtrail_add` and `provtrail_verify`. It requires the `[mcp]`
245
+ extra. It is a convenience for models that call tools explicitly and
246
+ does not replace the Stop hook.
247
+
248
+ Neither tool takes a ledger path; the server resolves it itself (the
249
+ same `resolve_config` used by the Stop hook, applied to the server's
250
+ own working directory). `provtrail_add` accepts a narrower set of
251
+ fields than the CLI: `source_url`, `content`, `content_path`, `tool`,
252
+ `kind`, `claim`, `title`, `query`, `snippet`, `archived_url`, and
253
+ `session_id`. A `content_path` is resolved against the ledger's
254
+ directory when relative and must resolve (after following symlinks)
255
+ inside that directory; it is rejected otherwise. The file is hashed
256
+ from its raw bytes, and its path relative to the ledger is stored in
257
+ `path`, so `provtrail verify --check-files` can re-hash it. `session_id` falls
258
+ back to the `CLAUDE_CODE_SESSION_ID` environment variable when not
259
+ supplied, but whether Claude Code actually sets that variable for an
260
+ MCP server process is not verified (it is verified for Bash tool
261
+ subprocesses), so pass `session_id` explicitly if session matching
262
+ against the Stop hook matters for your workflow.
263
+
264
+ ## Security model and limitations
265
+
266
+ - **Tamper-evident, not tamper-proof.** Editing, reordering, or removing
267
+ a record in the middle of the ledger is detected by `verify`.
268
+ Truncation is not: dropping the last N records leaves a valid, shorter
269
+ chain, and anyone able to rewrite the whole file can build a new
270
+ consistent chain. To detect either, record the latest `record_hash`
271
+ outside the ledger periodically (a commit message, a separate log, or
272
+ a timestamping service).
273
+ - **A ledger that fails verification is not extended.** `add` verifies
274
+ the ledger before appending and refuses (exit 1 from the CLI) if any
275
+ violation is found, so an edited file, or one left with a partial
276
+ last line by a crash, never gets new records chained onto it. Repair
277
+ the file or start a new ledger before capturing again.
278
+ - **Advisory locking.** `<ledger>.lock` serialises concurrent
279
+ `provtrail add` calls. It does not stop a process that ignores the
280
+ lock or writes to the file directly. A lock left behind by a crashed
281
+ process causes `add` to time out and must be removed manually.
282
+ - **No network access.** provtrail never fetches a URL and does not check
283
+ that a URL is reachable or that supplied content came from it.
284
+ - **Plain-text storage.** The ledger is unencrypted JSONL. Do not store
285
+ credentials, tokens, or personal data in any field.
286
+
287
+ ## Development
288
+
289
+ ```bash
290
+ python -m unittest discover -s tests -v
291
+ ```
292
+
293
+ ## License
294
+
295
+ MIT. See `LICENSE`.