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.
- provtrail-0.1.0/CHANGELOG.md +27 -0
- provtrail-0.1.0/LICENSE +21 -0
- provtrail-0.1.0/MANIFEST.in +4 -0
- provtrail-0.1.0/PKG-INFO +311 -0
- provtrail-0.1.0/README.md +295 -0
- provtrail-0.1.0/integrations/claude-code/SKILL.md +75 -0
- provtrail-0.1.0/integrations/claude-code/settings.example.json +14 -0
- provtrail-0.1.0/integrations/claude-code/stop_hook.py +21 -0
- provtrail-0.1.0/integrations/mcp/provtrail_mcp.py +21 -0
- provtrail-0.1.0/pyproject.toml +29 -0
- provtrail-0.1.0/schema/provtrail-record.v1.json +75 -0
- provtrail-0.1.0/setup.cfg +4 -0
- provtrail-0.1.0/src/provtrail/__init__.py +28 -0
- provtrail-0.1.0/src/provtrail/__main__.py +4 -0
- provtrail-0.1.0/src/provtrail/cli.py +181 -0
- provtrail-0.1.0/src/provtrail/config.py +89 -0
- provtrail-0.1.0/src/provtrail/hashing.py +92 -0
- provtrail-0.1.0/src/provtrail/ledger.py +662 -0
- provtrail-0.1.0/src/provtrail/mcp_server.py +161 -0
- provtrail-0.1.0/src/provtrail/stop_hook.py +203 -0
- provtrail-0.1.0/src/provtrail.egg-info/PKG-INFO +311 -0
- provtrail-0.1.0/src/provtrail.egg-info/SOURCES.txt +30 -0
- provtrail-0.1.0/src/provtrail.egg-info/dependency_links.txt +1 -0
- provtrail-0.1.0/src/provtrail.egg-info/entry_points.txt +4 -0
- provtrail-0.1.0/src/provtrail.egg-info/requires.txt +3 -0
- provtrail-0.1.0/src/provtrail.egg-info/top_level.txt +1 -0
- provtrail-0.1.0/tests/test_cli.py +185 -0
- provtrail-0.1.0/tests/test_config.py +126 -0
- provtrail-0.1.0/tests/test_hardening.py +188 -0
- provtrail-0.1.0/tests/test_ledger.py +389 -0
- provtrail-0.1.0/tests/test_mcp.py +119 -0
- 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.
|
provtrail-0.1.0/LICENSE
ADDED
|
@@ -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.
|
provtrail-0.1.0/PKG-INFO
ADDED
|
@@ -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`.
|