mcp-glp-audit-proxy 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.
- mcp_glp_audit_proxy-0.1.0/LICENSE +21 -0
- mcp_glp_audit_proxy-0.1.0/PKG-INFO +201 -0
- mcp_glp_audit_proxy-0.1.0/README.md +165 -0
- mcp_glp_audit_proxy-0.1.0/pyproject.toml +82 -0
- mcp_glp_audit_proxy-0.1.0/setup.cfg +4 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/__init__.py +10 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/__main__.py +19 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/anchor.py +149 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/approvals.py +72 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/canonical.py +44 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/chain.py +101 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/cli.py +84 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/clockutil.py +46 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/config.py +233 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/errors.py +27 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/features.py +113 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/framing.py +86 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/interfaces.py +34 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/plugins.py +19 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/policy.py +88 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/proxy.py +1693 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/records.py +88 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/reporters/__init__.py +6 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/reporters/json_export.py +88 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/reporters/text_export.py +33 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/sdkcheck.py +40 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/server.py +146 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/signers/__init__.py +5 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/signers/unsigned.py +32 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/storage/__init__.py +5 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/storage/jsonl.py +265 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/tlsutil.py +24 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/upstream.py +345 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/verification/__init__.py +15 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/verification/__main__.py +13 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/verification/oq.py +270 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy/verification/rtm.py +88 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy.egg-info/PKG-INFO +201 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy.egg-info/SOURCES.txt +61 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy.egg-info/dependency_links.txt +1 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy.egg-info/entry_points.txt +14 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy.egg-info/requires.txt +13 -0
- mcp_glp_audit_proxy-0.1.0/src/mcp_glp_audit_proxy.egg-info/top_level.txt +1 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_anchor.py +244 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_approval.py +156 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_chain.py +99 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_docs.py +82 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_export.py +97 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_fail_closed.py +106 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_faults.py +255 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_features.py +72 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_framing.py +108 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_limits.py +79 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_oq_runner.py +94 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_policy.py +102 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_properties.py +66 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_protocol.py +254 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_reconciliation_sop.py +66 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_rtm.py +48 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_sample_report.py +27 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_server_loop.py +279 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_stdio_e2e.py +395 -0
- mcp_glp_audit_proxy-0.1.0/tests/test_tamper.py +85 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Antonio Castellon / Castellon.CH
|
|
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,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-glp-audit-proxy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local MCP proxy that writes a tamper-evident audit trail. Designed to support GLP traceability.
|
|
5
|
+
Author-email: Antonio Castellon <antonio@castellon.ch>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/antonio-castellon/mcp-glp-audit-proxy
|
|
8
|
+
Project-URL: Issues, https://github.com/antonio-castellon/mcp-glp-audit-proxy/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/antonio-castellon/mcp-glp-audit-proxy/blob/main/docs/STANDARDS.md
|
|
10
|
+
Keywords: mcp,glp,audit,proxy,part-11,alcoa
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp<3,>=2.2.0
|
|
24
|
+
Requires-Dist: rfc8785==0.1.4
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
27
|
+
Requires-Dist: jsonschema>=4.21; extra == "dev"
|
|
28
|
+
Requires-Dist: hypothesis>=6.110; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
30
|
+
Requires-Dist: PyYAML>=6.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
32
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
34
|
+
Requires-Dist: defusedxml>=0.7; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
mcp-name: io.github.antonio-castellon/mcp-glp-audit-proxy
|
|
38
|
+
|
|
39
|
+
# mcp-glp-audit-proxy
|
|
40
|
+
|
|
41
|
+
Local MCP proxy that sits between an MCP host and your MCP servers and writes a tamper-evident audit trail. Community edition, v0.1.0.
|
|
42
|
+
|
|
43
|
+
It is designed to support GLP, ALCOA+, 21 CFR Part 11 and EU GMP Annex 11 traceability. Data stays on the customer's infrastructure. This package is the open core. Signed checkpoints, WORM storage, Part 11 electronic signatures, PDF/CSV reports, the IQ/OQ pack and SSO/OAuth are Pro features and are not in this repository.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
host (Claude, Cursor, Grok, self-hosted LLM)
|
|
47
|
+
| stdio JSON-RPC
|
|
48
|
+
v
|
|
49
|
+
mcp-glp-audit-proxy
|
|
50
|
+
policy (allow / deny, reason, human approval)
|
|
51
|
+
audit (append-only JSONL, RFC 8785, SHA-256 chain)
|
|
52
|
+
| stdio or HTTPS (TLS 1.3)
|
|
53
|
+
v
|
|
54
|
+
upstream MCP servers (Excel, ELN, LIMS, databases, instruments)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
Requires Python 3.11 or newer.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install mcp-glp-audit-proxy
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
From a checkout:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[dev]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `mcp` dependency is pinned to `>=2.2.0,<3`. That floor is outside GHSA-84m7-p3x7-pcfv (affected ranges are `mcp >=1.8.0,<1.30.0` and `>=2.0.0,<2.2.0`). The process refuses to start on an older SDK.
|
|
72
|
+
|
|
73
|
+
## Run
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
export GLP_PRINCIPAL_ID=operator-1
|
|
77
|
+
export GLP_PRINCIPAL_NAME="Ada Analyst"
|
|
78
|
+
export GLP_PRINCIPAL_ROLE=operator
|
|
79
|
+
mcp-glp-audit-proxy --config config.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Stdio identity comes from the environment. There is no HTTP listener in 0.1.0. A Cursor snippet is in `mcp.json`. A full config is in `config.example.json`.
|
|
83
|
+
|
|
84
|
+
Verify a chain and write a searchable JSON export (the export is skipped when the chain does not verify):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
mcp-glp-audit-proxy-verify data/audit.jsonl --json-export exports/audit.json
|
|
88
|
+
mcp-glp-audit-proxy-verify data/audit.jsonl --tool excel__write_cell --status ok
|
|
89
|
+
mcp-glp-audit-proxy-verify data/audit.jsonl --print-head
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`--print-head` prints `seq` and `entry_hash`. Copy that line into a ticket, an email or a LIMS record. The sidecar `data/audit.jsonl.head` makes a cut of whole trailing lines fail verification. Someone who can edit both the log and the sidecar can move them together; the external copy is the control. Signed checkpoints and WORM storage are Pro.
|
|
93
|
+
|
|
94
|
+
## What is recorded
|
|
95
|
+
|
|
96
|
+
Every mediated call, including denials and failures, is one JSON object on an append-only JSONL file. Each line is RFC 8785 canonical JSON. `entry_hash` is the SHA-256 of that object with `entry_hash` removed. `prev_hash` is the previous entry (64 zero digits for the first). `hash_algo` and `canonicalization` are stored on the entry. The open schema is `schema/audit-record.schema.json`. Cross-language vectors are in `schema/jcs-test-vectors.json`.
|
|
97
|
+
|
|
98
|
+
Fields include who (`principal` id, name, role), which tool, inputs, outputs, UTC timestamp plus `utc_offset`, protocol version, optional session id, record class (`gxp_data`, `access`, `technical`, `config`), reason for writes, and the previous hash.
|
|
99
|
+
|
|
100
|
+
The GxP trail is that file. The Python `logging` logger `mcp_glp_audit_proxy.technical` writes method, status and record class to stderr. It is not the record. Arguments are not written there.
|
|
101
|
+
|
|
102
|
+
## Policy
|
|
103
|
+
|
|
104
|
+
- Allow list and deny list use shell-style globs on the exposed tool name (`excel__write_cell`). Deny wins. An empty allow list denies every upstream tool.
|
|
105
|
+
- Roles are `operator`, `approver`, `qa` and `admin`. Only `operator` may call upstream tools. `qa` is read-only on the trail (`glp_verify_chain`, `glp_search_audit`, `glp_export_audit`). `approver` may record an approval and read the trail. `admin` may change policy and read the trail, and cannot call upstream tools.
|
|
106
|
+
- Auditing cannot be switched off. `glp_update_policy` with `audit_enabled: false` is denied and logged.
|
|
107
|
+
- Write tools (names matching `write_tools`) require a reason by default. The reason is `_meta["io.github.antonio-castellon/reason"]` or the argument `_glp_reason` (stripped before the upstream call).
|
|
108
|
+
- Optional human approval holds the write until a different person in the `approver` role records meaning `approved`. `reviewed` is stored and does not authorise the write. `rejected` rejects it. An approval is single-use, bound to the SHA-256 of the canonical `{tool, arguments}`, and expires (default 3600 seconds). The record label is `approval`. It is not an electronic signature.
|
|
109
|
+
- Fail closed is the default. The store is checked before a call is forwarded. If the append after a successful upstream call fails, the upstream payload is not returned to the host. Denied and failed calls are still logged. If the upstream may already have applied a write, follow `docs/RECONCILIATION_SOP.md`.
|
|
110
|
+
|
|
111
|
+
## Protocol
|
|
112
|
+
|
|
113
|
+
The proxy speaks both handshake-era MCP revisions (`2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`, with `initialize`) and spec `2026-07-28` (`server/discover`, no initialize, no session). A session id is recorded only when that revision has one. Upstream protocol `auto` tries `server/discover` and falls back to `initialize` at `2025-11-25`.
|
|
114
|
+
|
|
115
|
+
Exposed tool names are `server__tool`. Resource URIs are prefixed `server:`. Credential-bearing `_meta` keys are stripped, their names are logged, and their values are neither stored nor forwarded. Upstream stdio receives a safe environment allow-list from the MCP SDK, plus the env block in the config.
|
|
116
|
+
|
|
117
|
+
## Regulatory coverage
|
|
118
|
+
|
|
119
|
+
mcp-glp-audit-proxy is designed to support the audit-trail requirements below. Meeting them still depends on your validated use, SOPs and reviews. The clause-by-clause matrix is `docs/COVERAGE.md`. The norms and the verification log are `docs/STANDARDS.md`.
|
|
120
|
+
|
|
121
|
+
| Framework | What the proxy helps with | Edition |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| OECD GLP No. 17 (3.4) / Swiss GLPV Annex 2 | Always-on, append-only audit trail; who/what/when/why; admin-only settings; QA read-only role; 10-year Swiss retention profile | Community core; WORM retention and PDF reports in Pro |
|
|
124
|
+
| FDA 21 CFR Part 11 | Computer-generated, time-stamped, hash-chained audit trail (11.10(e)); tamper detection; complete copies; authority checks. Part 11 approval e-signatures (11.50, 11.70, 11.200) are Pro only: the Community `approval` is not an electronic signature | Community core; e-signatures (Pro only), reports, signing, SSO in Pro |
|
|
125
|
+
| EU GMP Annex 11 (2011; 2025 draft) | Audit trail of changes with reason; role, time zone; locked audit trail; searchable complete export; access log | Community core; review tooling and inactivity logout in Pro |
|
|
126
|
+
| MHRA / PIC/S PI 041-1 / WHO TRS 1033 | ALCOA+ data integrity, segregation of duties, metadata with each record, true-copy exports | Community core; signed manifests and validation pack in Pro |
|
|
127
|
+
| ISPE GAMP 5 / FDA CSA | Supplier documentation, proposed Cat. 4 configured components, machine-readable test evidence | Pro (validation pack) |
|
|
128
|
+
| EU AI Act Art. 12, 19, 26(6) | Automatic event logging, minimum 6-month retention | Community |
|
|
129
|
+
| Crypto and time (RFC 8785, SHA-256, Ed25519/ECDSA, RFC 3161, eIDAS/ZertES, TLS 1.3) | Canonical hashing and encrypted transport | Hash chain and TLS in Community; signing and timestamps in Pro |
|
|
130
|
+
| MCP authorization, OWASP LLM/Agentic | No token passthrough, tool allowlist/denylist, approval before write tools, OAuth 2.1 | Allowlist and no-passthrough in Community; OAuth/SSO in Pro |
|
|
131
|
+
| ISO/IEC 42001, NIST AI RMF | Event-log evidence for AI governance and monitoring | Community (supporting evidence only) |
|
|
132
|
+
|
|
133
|
+
Default retention is the Swiss GLP profile: 10 years after study completion, with a floor of 6 months. The `minimum` profile may use a shorter year count but cannot go under 6 months. A legal-hold flag refuses disposal. The JSONL backend has no delete. Retention-period WORM enforcement is a Pro backend.
|
|
134
|
+
|
|
135
|
+
## Community and Pro
|
|
136
|
+
|
|
137
|
+
This repository is the Community edition. Pro is available as a licensed / paid service - contact [antonio@castellon.ch](mailto:antonio@castellon.ch).
|
|
138
|
+
|
|
139
|
+
Pro adds SQL and S3 WORM backends, Ed25519 and ECDSA signing, RFC 3161 timestamps, Part 11 e-signatures, PDF and CSV auditor reports, an IQ/OQ validation pack, and SSO. It also includes the GLP Lab Data Pack for [mcp-advanced-excel](https://github.com/antonio-castellon/mcp-advanced-excel), unlocked by the same offline licence key. The free mcp-advanced-excel server reads and writes lab spreadsheets. The Data Pack adds what a GLP lab needs on top: provenance hashes that tie every result back to its source file, instrument templates for common lab exports, advanced LOD/LOQ calculations and molar unit conversions, batch processing of many files in one run, and Ed25519 signed output.
|
|
140
|
+
|
|
141
|
+
| Capability | Community (this repo) | Pro (licensed service) |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| Proxy core, both protocol eras | yes | plugs in, does not fork |
|
|
144
|
+
| Append-only JSONL hash chain | yes | |
|
|
145
|
+
| PostgreSQL / MSSQL append-only | | yes |
|
|
146
|
+
| S3 Object Lock | | yes |
|
|
147
|
+
| Verify CLI and searchable JSON export | yes | |
|
|
148
|
+
| Plain-text verify listing | yes | |
|
|
149
|
+
| PDF / CSV auditor reports | | yes |
|
|
150
|
+
| Audit-trail review tooling (flags, reviewer sign-off) | search, filter, export | yes |
|
|
151
|
+
| DE / FR / EN report templates | | later |
|
|
152
|
+
| Old/new value capture for write tools | planned | change highlighting in reports |
|
|
153
|
+
| Tool allowlist / denylist | yes | |
|
|
154
|
+
| Local roles (operator, approver, qa, admin) | yes | |
|
|
155
|
+
| Human approval before writes | yes (label: approval) | |
|
|
156
|
+
| Part 11 electronic signatures | | yes |
|
|
157
|
+
| Ed25519 / ECDSA signed checkpoints | unsigned checkpoints only | yes |
|
|
158
|
+
| RFC 3161 timestamps | | yes |
|
|
159
|
+
| IQ/OQ validation pack | | yes |
|
|
160
|
+
| SSO / OAuth | | yes |
|
|
161
|
+
| Syslog (RFC 5424) / SIEM export | | yes |
|
|
162
|
+
| Key management (KMS/HSM), field-level encryption and redaction | | yes |
|
|
163
|
+
| FIPS 140-3 mode | | on request |
|
|
164
|
+
| Merkle log with inclusion / consistency proofs | | later |
|
|
165
|
+
| OpenTelemetry spans (technical telemetry, not the audit record) | planned | |
|
|
166
|
+
| GLP Lab Data Pack for mcp-advanced-excel (provenance hashes, instrument templates, advanced LOD/LOQ and molar units, batch processing, Ed25519 signed output) | | yes |
|
|
167
|
+
| TLS 1.3 to remote upstreams, no token passthrough | yes | |
|
|
168
|
+
| Offline licence file, no phone-home | yes | reads the same file |
|
|
169
|
+
|
|
170
|
+
Plugin entry points, the `AuditStore` / `Signer` / `Reporter` contracts, and the licence file are described in `docs/PLUGINS.md`.
|
|
171
|
+
|
|
172
|
+
## Local tools
|
|
173
|
+
|
|
174
|
+
| Tool | Who | What |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `glp_verify_chain` | qa, approver, admin | Check chain integrity. Still returns the report if the chain is broken. |
|
|
177
|
+
| `glp_search_audit` | qa, approver, admin | Filter entries. The audit of the search stores the filter and the count, not a second copy of every row. |
|
|
178
|
+
| `glp_export_audit` | qa, approver, admin | JSON electronic export with a hash manifest. A filtered export is marked `complete: false`. A broken chain is not exported. |
|
|
179
|
+
| `glp_approve` | approver | Record meaning `approved`, `reviewed` or `rejected`. |
|
|
180
|
+
| `glp_update_policy` | admin | Append a config event. Policy is replayed from the log. |
|
|
181
|
+
|
|
182
|
+
## Safety
|
|
183
|
+
|
|
184
|
+
Read `SAFETY.md`. Short version: fail closed, no token passthrough, secrets are stripped from `_meta`, stdio children do not inherit the parent environment, an approval is not an electronic signature, and the JSONL file is tamper-evident, not a WORM store.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
pip install -e ".[dev]"
|
|
190
|
+
python -m pytest -q
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
CI runs the same suite on Python 3.11, 3.12 and 3.13 (`.github/workflows/ci.yml`).
|
|
194
|
+
|
|
195
|
+
## MCP registry
|
|
196
|
+
|
|
197
|
+
`server.json` names this server `io.github.antonio-castellon/mcp-glp-audit-proxy` for the official MCP Registry. The marker on the first line of this file is the registry check.
|
|
198
|
+
|
|
199
|
+
## Licence
|
|
200
|
+
|
|
201
|
+
MIT. Copyright (c) 2026 Antonio Castellon / Castellon.CH. See `LICENSE`.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
mcp-name: io.github.antonio-castellon/mcp-glp-audit-proxy
|
|
2
|
+
|
|
3
|
+
# mcp-glp-audit-proxy
|
|
4
|
+
|
|
5
|
+
Local MCP proxy that sits between an MCP host and your MCP servers and writes a tamper-evident audit trail. Community edition, v0.1.0.
|
|
6
|
+
|
|
7
|
+
It is designed to support GLP, ALCOA+, 21 CFR Part 11 and EU GMP Annex 11 traceability. Data stays on the customer's infrastructure. This package is the open core. Signed checkpoints, WORM storage, Part 11 electronic signatures, PDF/CSV reports, the IQ/OQ pack and SSO/OAuth are Pro features and are not in this repository.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
host (Claude, Cursor, Grok, self-hosted LLM)
|
|
11
|
+
| stdio JSON-RPC
|
|
12
|
+
v
|
|
13
|
+
mcp-glp-audit-proxy
|
|
14
|
+
policy (allow / deny, reason, human approval)
|
|
15
|
+
audit (append-only JSONL, RFC 8785, SHA-256 chain)
|
|
16
|
+
| stdio or HTTPS (TLS 1.3)
|
|
17
|
+
v
|
|
18
|
+
upstream MCP servers (Excel, ELN, LIMS, databases, instruments)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Requires Python 3.11 or newer.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install mcp-glp-audit-proxy
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From a checkout:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e ".[dev]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The `mcp` dependency is pinned to `>=2.2.0,<3`. That floor is outside GHSA-84m7-p3x7-pcfv (affected ranges are `mcp >=1.8.0,<1.30.0` and `>=2.0.0,<2.2.0`). The process refuses to start on an older SDK.
|
|
36
|
+
|
|
37
|
+
## Run
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export GLP_PRINCIPAL_ID=operator-1
|
|
41
|
+
export GLP_PRINCIPAL_NAME="Ada Analyst"
|
|
42
|
+
export GLP_PRINCIPAL_ROLE=operator
|
|
43
|
+
mcp-glp-audit-proxy --config config.json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Stdio identity comes from the environment. There is no HTTP listener in 0.1.0. A Cursor snippet is in `mcp.json`. A full config is in `config.example.json`.
|
|
47
|
+
|
|
48
|
+
Verify a chain and write a searchable JSON export (the export is skipped when the chain does not verify):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
mcp-glp-audit-proxy-verify data/audit.jsonl --json-export exports/audit.json
|
|
52
|
+
mcp-glp-audit-proxy-verify data/audit.jsonl --tool excel__write_cell --status ok
|
|
53
|
+
mcp-glp-audit-proxy-verify data/audit.jsonl --print-head
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`--print-head` prints `seq` and `entry_hash`. Copy that line into a ticket, an email or a LIMS record. The sidecar `data/audit.jsonl.head` makes a cut of whole trailing lines fail verification. Someone who can edit both the log and the sidecar can move them together; the external copy is the control. Signed checkpoints and WORM storage are Pro.
|
|
57
|
+
|
|
58
|
+
## What is recorded
|
|
59
|
+
|
|
60
|
+
Every mediated call, including denials and failures, is one JSON object on an append-only JSONL file. Each line is RFC 8785 canonical JSON. `entry_hash` is the SHA-256 of that object with `entry_hash` removed. `prev_hash` is the previous entry (64 zero digits for the first). `hash_algo` and `canonicalization` are stored on the entry. The open schema is `schema/audit-record.schema.json`. Cross-language vectors are in `schema/jcs-test-vectors.json`.
|
|
61
|
+
|
|
62
|
+
Fields include who (`principal` id, name, role), which tool, inputs, outputs, UTC timestamp plus `utc_offset`, protocol version, optional session id, record class (`gxp_data`, `access`, `technical`, `config`), reason for writes, and the previous hash.
|
|
63
|
+
|
|
64
|
+
The GxP trail is that file. The Python `logging` logger `mcp_glp_audit_proxy.technical` writes method, status and record class to stderr. It is not the record. Arguments are not written there.
|
|
65
|
+
|
|
66
|
+
## Policy
|
|
67
|
+
|
|
68
|
+
- Allow list and deny list use shell-style globs on the exposed tool name (`excel__write_cell`). Deny wins. An empty allow list denies every upstream tool.
|
|
69
|
+
- Roles are `operator`, `approver`, `qa` and `admin`. Only `operator` may call upstream tools. `qa` is read-only on the trail (`glp_verify_chain`, `glp_search_audit`, `glp_export_audit`). `approver` may record an approval and read the trail. `admin` may change policy and read the trail, and cannot call upstream tools.
|
|
70
|
+
- Auditing cannot be switched off. `glp_update_policy` with `audit_enabled: false` is denied and logged.
|
|
71
|
+
- Write tools (names matching `write_tools`) require a reason by default. The reason is `_meta["io.github.antonio-castellon/reason"]` or the argument `_glp_reason` (stripped before the upstream call).
|
|
72
|
+
- Optional human approval holds the write until a different person in the `approver` role records meaning `approved`. `reviewed` is stored and does not authorise the write. `rejected` rejects it. An approval is single-use, bound to the SHA-256 of the canonical `{tool, arguments}`, and expires (default 3600 seconds). The record label is `approval`. It is not an electronic signature.
|
|
73
|
+
- Fail closed is the default. The store is checked before a call is forwarded. If the append after a successful upstream call fails, the upstream payload is not returned to the host. Denied and failed calls are still logged. If the upstream may already have applied a write, follow `docs/RECONCILIATION_SOP.md`.
|
|
74
|
+
|
|
75
|
+
## Protocol
|
|
76
|
+
|
|
77
|
+
The proxy speaks both handshake-era MCP revisions (`2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`, with `initialize`) and spec `2026-07-28` (`server/discover`, no initialize, no session). A session id is recorded only when that revision has one. Upstream protocol `auto` tries `server/discover` and falls back to `initialize` at `2025-11-25`.
|
|
78
|
+
|
|
79
|
+
Exposed tool names are `server__tool`. Resource URIs are prefixed `server:`. Credential-bearing `_meta` keys are stripped, their names are logged, and their values are neither stored nor forwarded. Upstream stdio receives a safe environment allow-list from the MCP SDK, plus the env block in the config.
|
|
80
|
+
|
|
81
|
+
## Regulatory coverage
|
|
82
|
+
|
|
83
|
+
mcp-glp-audit-proxy is designed to support the audit-trail requirements below. Meeting them still depends on your validated use, SOPs and reviews. The clause-by-clause matrix is `docs/COVERAGE.md`. The norms and the verification log are `docs/STANDARDS.md`.
|
|
84
|
+
|
|
85
|
+
| Framework | What the proxy helps with | Edition |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| OECD GLP No. 17 (3.4) / Swiss GLPV Annex 2 | Always-on, append-only audit trail; who/what/when/why; admin-only settings; QA read-only role; 10-year Swiss retention profile | Community core; WORM retention and PDF reports in Pro |
|
|
88
|
+
| FDA 21 CFR Part 11 | Computer-generated, time-stamped, hash-chained audit trail (11.10(e)); tamper detection; complete copies; authority checks. Part 11 approval e-signatures (11.50, 11.70, 11.200) are Pro only: the Community `approval` is not an electronic signature | Community core; e-signatures (Pro only), reports, signing, SSO in Pro |
|
|
89
|
+
| EU GMP Annex 11 (2011; 2025 draft) | Audit trail of changes with reason; role, time zone; locked audit trail; searchable complete export; access log | Community core; review tooling and inactivity logout in Pro |
|
|
90
|
+
| MHRA / PIC/S PI 041-1 / WHO TRS 1033 | ALCOA+ data integrity, segregation of duties, metadata with each record, true-copy exports | Community core; signed manifests and validation pack in Pro |
|
|
91
|
+
| ISPE GAMP 5 / FDA CSA | Supplier documentation, proposed Cat. 4 configured components, machine-readable test evidence | Pro (validation pack) |
|
|
92
|
+
| EU AI Act Art. 12, 19, 26(6) | Automatic event logging, minimum 6-month retention | Community |
|
|
93
|
+
| Crypto and time (RFC 8785, SHA-256, Ed25519/ECDSA, RFC 3161, eIDAS/ZertES, TLS 1.3) | Canonical hashing and encrypted transport | Hash chain and TLS in Community; signing and timestamps in Pro |
|
|
94
|
+
| MCP authorization, OWASP LLM/Agentic | No token passthrough, tool allowlist/denylist, approval before write tools, OAuth 2.1 | Allowlist and no-passthrough in Community; OAuth/SSO in Pro |
|
|
95
|
+
| ISO/IEC 42001, NIST AI RMF | Event-log evidence for AI governance and monitoring | Community (supporting evidence only) |
|
|
96
|
+
|
|
97
|
+
Default retention is the Swiss GLP profile: 10 years after study completion, with a floor of 6 months. The `minimum` profile may use a shorter year count but cannot go under 6 months. A legal-hold flag refuses disposal. The JSONL backend has no delete. Retention-period WORM enforcement is a Pro backend.
|
|
98
|
+
|
|
99
|
+
## Community and Pro
|
|
100
|
+
|
|
101
|
+
This repository is the Community edition. Pro is available as a licensed / paid service - contact [antonio@castellon.ch](mailto:antonio@castellon.ch).
|
|
102
|
+
|
|
103
|
+
Pro adds SQL and S3 WORM backends, Ed25519 and ECDSA signing, RFC 3161 timestamps, Part 11 e-signatures, PDF and CSV auditor reports, an IQ/OQ validation pack, and SSO. It also includes the GLP Lab Data Pack for [mcp-advanced-excel](https://github.com/antonio-castellon/mcp-advanced-excel), unlocked by the same offline licence key. The free mcp-advanced-excel server reads and writes lab spreadsheets. The Data Pack adds what a GLP lab needs on top: provenance hashes that tie every result back to its source file, instrument templates for common lab exports, advanced LOD/LOQ calculations and molar unit conversions, batch processing of many files in one run, and Ed25519 signed output.
|
|
104
|
+
|
|
105
|
+
| Capability | Community (this repo) | Pro (licensed service) |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| Proxy core, both protocol eras | yes | plugs in, does not fork |
|
|
108
|
+
| Append-only JSONL hash chain | yes | |
|
|
109
|
+
| PostgreSQL / MSSQL append-only | | yes |
|
|
110
|
+
| S3 Object Lock | | yes |
|
|
111
|
+
| Verify CLI and searchable JSON export | yes | |
|
|
112
|
+
| Plain-text verify listing | yes | |
|
|
113
|
+
| PDF / CSV auditor reports | | yes |
|
|
114
|
+
| Audit-trail review tooling (flags, reviewer sign-off) | search, filter, export | yes |
|
|
115
|
+
| DE / FR / EN report templates | | later |
|
|
116
|
+
| Old/new value capture for write tools | planned | change highlighting in reports |
|
|
117
|
+
| Tool allowlist / denylist | yes | |
|
|
118
|
+
| Local roles (operator, approver, qa, admin) | yes | |
|
|
119
|
+
| Human approval before writes | yes (label: approval) | |
|
|
120
|
+
| Part 11 electronic signatures | | yes |
|
|
121
|
+
| Ed25519 / ECDSA signed checkpoints | unsigned checkpoints only | yes |
|
|
122
|
+
| RFC 3161 timestamps | | yes |
|
|
123
|
+
| IQ/OQ validation pack | | yes |
|
|
124
|
+
| SSO / OAuth | | yes |
|
|
125
|
+
| Syslog (RFC 5424) / SIEM export | | yes |
|
|
126
|
+
| Key management (KMS/HSM), field-level encryption and redaction | | yes |
|
|
127
|
+
| FIPS 140-3 mode | | on request |
|
|
128
|
+
| Merkle log with inclusion / consistency proofs | | later |
|
|
129
|
+
| OpenTelemetry spans (technical telemetry, not the audit record) | planned | |
|
|
130
|
+
| GLP Lab Data Pack for mcp-advanced-excel (provenance hashes, instrument templates, advanced LOD/LOQ and molar units, batch processing, Ed25519 signed output) | | yes |
|
|
131
|
+
| TLS 1.3 to remote upstreams, no token passthrough | yes | |
|
|
132
|
+
| Offline licence file, no phone-home | yes | reads the same file |
|
|
133
|
+
|
|
134
|
+
Plugin entry points, the `AuditStore` / `Signer` / `Reporter` contracts, and the licence file are described in `docs/PLUGINS.md`.
|
|
135
|
+
|
|
136
|
+
## Local tools
|
|
137
|
+
|
|
138
|
+
| Tool | Who | What |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `glp_verify_chain` | qa, approver, admin | Check chain integrity. Still returns the report if the chain is broken. |
|
|
141
|
+
| `glp_search_audit` | qa, approver, admin | Filter entries. The audit of the search stores the filter and the count, not a second copy of every row. |
|
|
142
|
+
| `glp_export_audit` | qa, approver, admin | JSON electronic export with a hash manifest. A filtered export is marked `complete: false`. A broken chain is not exported. |
|
|
143
|
+
| `glp_approve` | approver | Record meaning `approved`, `reviewed` or `rejected`. |
|
|
144
|
+
| `glp_update_policy` | admin | Append a config event. Policy is replayed from the log. |
|
|
145
|
+
|
|
146
|
+
## Safety
|
|
147
|
+
|
|
148
|
+
Read `SAFETY.md`. Short version: fail closed, no token passthrough, secrets are stripped from `_meta`, stdio children do not inherit the parent environment, an approval is not an electronic signature, and the JSONL file is tamper-evident, not a WORM store.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e ".[dev]"
|
|
154
|
+
python -m pytest -q
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
CI runs the same suite on Python 3.11, 3.12 and 3.13 (`.github/workflows/ci.yml`).
|
|
158
|
+
|
|
159
|
+
## MCP registry
|
|
160
|
+
|
|
161
|
+
`server.json` names this server `io.github.antonio-castellon/mcp-glp-audit-proxy` for the official MCP Registry. The marker on the first line of this file is the registry check.
|
|
162
|
+
|
|
163
|
+
## Licence
|
|
164
|
+
|
|
165
|
+
MIT. Copyright (c) 2026 Antonio Castellon / Castellon.CH. See `LICENSE`.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-glp-audit-proxy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local MCP proxy that writes a tamper-evident audit trail. Designed to support GLP traceability."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Antonio Castellon", email = "antonio@castellon.ch" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mcp", "glp", "audit", "proxy", "part-11", "alcoa"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
# 2.2.0 is the first 2.x release outside GHSA-84m7-p3x7-pcfv
|
|
29
|
+
# (affected: mcp >=1.8.0,<1.30.0 and >=2.0.0,<2.2.0).
|
|
30
|
+
"mcp>=2.2.0,<3",
|
|
31
|
+
# Pinned: audit hashes must be reproducible. See schema/jcs-test-vectors.json.
|
|
32
|
+
"rfc8785==0.1.4",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.3",
|
|
38
|
+
"jsonschema>=4.21",
|
|
39
|
+
"hypothesis>=6.110",
|
|
40
|
+
"pytest-cov>=5.0",
|
|
41
|
+
"PyYAML>=6.0",
|
|
42
|
+
"pip-audit>=2.7",
|
|
43
|
+
"bandit>=1.7",
|
|
44
|
+
"ruff>=0.6",
|
|
45
|
+
"defusedxml>=0.7",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
mcp-glp-audit-proxy = "mcp_glp_audit_proxy.__main__:main"
|
|
50
|
+
mcp-glp-audit-proxy-verify = "mcp_glp_audit_proxy.cli:main"
|
|
51
|
+
mcp-glp-audit-proxy-oq = "mcp_glp_audit_proxy.verification.oq:main"
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://github.com/antonio-castellon/mcp-glp-audit-proxy"
|
|
55
|
+
Issues = "https://github.com/antonio-castellon/mcp-glp-audit-proxy/issues"
|
|
56
|
+
Documentation = "https://github.com/antonio-castellon/mcp-glp-audit-proxy/blob/main/docs/STANDARDS.md"
|
|
57
|
+
|
|
58
|
+
# The licensed Pro edition registers additional names in these
|
|
59
|
+
# groups. This repo ships only the Community implementations.
|
|
60
|
+
[project.entry-points."mcp_glp_audit_proxy.storage"]
|
|
61
|
+
jsonl = "mcp_glp_audit_proxy.storage.jsonl:JsonlAuditStore"
|
|
62
|
+
|
|
63
|
+
[project.entry-points."mcp_glp_audit_proxy.signers"]
|
|
64
|
+
unsigned = "mcp_glp_audit_proxy.signers.unsigned:UnsignedSigner"
|
|
65
|
+
|
|
66
|
+
[project.entry-points."mcp_glp_audit_proxy.reporters"]
|
|
67
|
+
json = "mcp_glp_audit_proxy.reporters.json_export:JsonReporter"
|
|
68
|
+
text = "mcp_glp_audit_proxy.reporters.text_export:TextReporter"
|
|
69
|
+
|
|
70
|
+
[tool.setuptools.packages.find]
|
|
71
|
+
where = ["src"]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
pythonpath = ["src", "tests"]
|
|
76
|
+
|
|
77
|
+
[tool.coverage.run]
|
|
78
|
+
source = ["mcp_glp_audit_proxy"]
|
|
79
|
+
patch = ["subprocess"]
|
|
80
|
+
|
|
81
|
+
[tool.coverage.report]
|
|
82
|
+
show_missing = true
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Community edition of the GLP audit MCP proxy.
|
|
2
|
+
|
|
3
|
+
This package is designed to support audit-trail traceability. It does not
|
|
4
|
+
certify a customer's system. Pro features are a separately licensed
|
|
5
|
+
edition and plug in through entry points.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""python -m mcp_glp_audit_proxy - start the stdio MCP proxy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
if len(sys.argv) > 1 and sys.argv[1] == "oq-report":
|
|
10
|
+
from .verification.oq import main as report
|
|
11
|
+
|
|
12
|
+
raise SystemExit(report(sys.argv[2:]))
|
|
13
|
+
from .server import main as run
|
|
14
|
+
|
|
15
|
+
run()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Sidecar head for the JSONL log.
|
|
2
|
+
|
|
3
|
+
``<log>.head`` stores the latest ``seq`` and ``entry_hash`` after each fsynced
|
|
4
|
+
line. It is written to a temp file in the same directory, fsynced, then
|
|
5
|
+
renamed into place.
|
|
6
|
+
|
|
7
|
+
A crash between the log fsync and this rename may leave the head one record
|
|
8
|
+
behind. That lag is accepted when the extra record itself verifies. The next
|
|
9
|
+
append, and startup, advance the head. Any other disagreement (missing head,
|
|
10
|
+
shorter log, hash mismatch, lag of more than one) is a store error.
|
|
11
|
+
|
|
12
|
+
Someone who can edit both files can move them together. A copy of the head
|
|
13
|
+
kept outside this machine (ticket, email, LIMS) is the local control. Signed
|
|
14
|
+
checkpoints and WORM storage are Pro.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import errno
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .canonical import canonicalize
|
|
26
|
+
from .chain import VerifyReport, verify_file
|
|
27
|
+
from .errors import AuditStoreError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Head:
|
|
32
|
+
seq: int
|
|
33
|
+
entry_hash: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AnchorStatus:
|
|
38
|
+
ok: bool
|
|
39
|
+
errors: list[str]
|
|
40
|
+
lag: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def head_path(log: Path) -> Path:
|
|
44
|
+
return log.with_name(log.name + ".head")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_head(log: Path) -> Head | None:
|
|
48
|
+
path = head_path(log)
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
raw = path.read_text(encoding="utf-8")
|
|
53
|
+
parsed = json.loads(raw)
|
|
54
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
55
|
+
raise AuditStoreError(f"audit head unreadable: {exc}") from exc
|
|
56
|
+
if not isinstance(parsed, dict):
|
|
57
|
+
raise AuditStoreError("audit head is not an object")
|
|
58
|
+
seq = parsed.get("seq")
|
|
59
|
+
entry_hash = parsed.get("entry_hash")
|
|
60
|
+
if isinstance(seq, bool) or not isinstance(seq, int) or seq < 1:
|
|
61
|
+
raise AuditStoreError("audit head seq is invalid")
|
|
62
|
+
if not isinstance(entry_hash, str) or len(entry_hash) != 64:
|
|
63
|
+
raise AuditStoreError("audit head entry_hash is invalid")
|
|
64
|
+
return Head(seq=seq, entry_hash=entry_hash)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_head(log: Path, seq: int, entry_hash: str) -> None:
|
|
68
|
+
"""Atomic replace. Raises OSError if the head cannot be made durable."""
|
|
69
|
+
target = head_path(log)
|
|
70
|
+
temporary = target.with_name(target.name + ".tmp")
|
|
71
|
+
payload = canonicalize({"entry_hash": entry_hash, "seq": seq}) + b"\n"
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
74
|
+
try:
|
|
75
|
+
view = payload
|
|
76
|
+
while view:
|
|
77
|
+
written = os.write(fd, view)
|
|
78
|
+
if written <= 0:
|
|
79
|
+
raise OSError(errno.EIO, "short write on audit head")
|
|
80
|
+
view = view[written:]
|
|
81
|
+
os.fsync(fd)
|
|
82
|
+
except OSError:
|
|
83
|
+
try:
|
|
84
|
+
os.close(fd)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
try:
|
|
88
|
+
os.unlink(temporary)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
raise
|
|
92
|
+
os.close(fd)
|
|
93
|
+
os.replace(temporary, target)
|
|
94
|
+
directory = os.open(target.parent, os.O_RDONLY)
|
|
95
|
+
try:
|
|
96
|
+
os.fsync(directory)
|
|
97
|
+
finally:
|
|
98
|
+
os.close(directory)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def assess(report: VerifyReport, head: Head | None) -> AnchorStatus:
|
|
102
|
+
"""Compare a verified log with its sidecar. The log must already be intact."""
|
|
103
|
+
if not report.ok:
|
|
104
|
+
return AnchorStatus(False, list(report.errors), 0)
|
|
105
|
+
entries = report.entries
|
|
106
|
+
if not entries:
|
|
107
|
+
if head is None:
|
|
108
|
+
return AnchorStatus(True, [], 0)
|
|
109
|
+
return AnchorStatus(False, ["audit head present but log is empty"], 0)
|
|
110
|
+
if head is None:
|
|
111
|
+
return AnchorStatus(False, ["audit head missing for a non-empty log"], 0)
|
|
112
|
+
last = entries[-1]
|
|
113
|
+
if head.seq == last.get("seq") and head.entry_hash == last.get("entry_hash"):
|
|
114
|
+
return AnchorStatus(True, [], 0)
|
|
115
|
+
if len(entries) >= 2:
|
|
116
|
+
previous = entries[-2]
|
|
117
|
+
if head.seq == previous.get("seq") and head.entry_hash == previous.get("entry_hash"):
|
|
118
|
+
return AnchorStatus(True, [], 1)
|
|
119
|
+
return AnchorStatus(
|
|
120
|
+
False,
|
|
121
|
+
[f"audit head disagrees with log: head seq={head.seq} log seq={last.get('seq')}"],
|
|
122
|
+
0,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def verify_anchored(path: Path) -> VerifyReport:
|
|
127
|
+
"""Chain verify plus the sidecar. A one-record lag stays ok."""
|
|
128
|
+
report = verify_file(path)
|
|
129
|
+
if not report.ok:
|
|
130
|
+
return report
|
|
131
|
+
try:
|
|
132
|
+
head = read_head(path)
|
|
133
|
+
except AuditStoreError as exc:
|
|
134
|
+
return _failed(report, [str(exc)])
|
|
135
|
+
status = assess(report, head)
|
|
136
|
+
if not status.ok:
|
|
137
|
+
return _failed(report, status.errors)
|
|
138
|
+
report.anchor_lag = status.lag
|
|
139
|
+
return report
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _failed(report: VerifyReport, errors: list[str]) -> VerifyReport:
|
|
143
|
+
return VerifyReport(
|
|
144
|
+
ok=False,
|
|
145
|
+
entries=report.entries,
|
|
146
|
+
errors=errors,
|
|
147
|
+
head_hash=report.head_hash,
|
|
148
|
+
anchor_lag=0,
|
|
149
|
+
)
|