@moonglade/provenance 2.6.0 → 2.6.1

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.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Moonglade AI
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moonglade AI
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.
package/README.md CHANGED
@@ -1,7 +1,19 @@
1
- # @moonglade/provenance 2.6.0
1
+ # @moonglade/provenance 2.6.1
2
2
 
3
3
  Ed25519 signatures, canonical file hashes, RFC 3161 timestamp checks and Merkle inclusion proofs for Node 22 and 24.
4
4
 
5
+ ## Installation
6
+
7
+ Install directly from npm:
8
+ ```sh
9
+ npm install @moonglade/provenance
10
+ ```
11
+
12
+ Or install globally for the CLI and MCP tools:
13
+ ```sh
14
+ npm install -g @moonglade/provenance
15
+ ```
16
+
5
17
  ## Build from source
6
18
 
7
19
  Run these commands from the repository root:
@@ -11,7 +23,11 @@ npm ci
11
23
  npm test
12
24
  ```
13
25
 
14
- The npm package can be installed after a release is authorized. This candidate has not been published.
26
+ Run the built-in self-test with `provenance test` (no file argument; add `--json` for a machine-readable report). It exits non-zero on any failure. Programmatic access: `require("@moonglade/provenance/self-test")` exposes `runSelfTests` and `printSelfTestReport`. The WASM core is available as `require("@moonglade/provenance/wasm")`. `provenance install-hooks [--pre-commit] [--mcp-verify-read] [--mcp-auto-seal] [--force]` installs only what you select (it asks per option in a terminal, and does nothing without flags otherwise). An existing pre-commit hook that this tool did not install is never overwritten: the command refuses, and with `--force` first copies it to `<hook>.bak-<UTC timestamp>`. A hook counts as ours only if its whole content equals a hook this tool generated; an edited copy of it, or any other hook that merely mentions the tool, is refused (and backed up under `--force`). An existing `.provenance-hooks.json` that is not in the tool's own shape is backed up before it is rewritten, and MCP choices recorded earlier are kept. The generated hook needs bash. It reads NUL-delimited staged names, so names with spaces or non-ASCII characters are verified, and it blocks the commit if the staged list cannot be read. `--mcp-verify-read` and `--mcp-auto-seal` only record your choice in `.provenance-hooks.json` and print an MCP configuration snippet; nothing in this package reads that file yet. The hooks directory is resolved by `git rev-parse --git-path hooks`, so linked worktrees and `core.hooksPath` work. A hook installed from one linked worktree applies to every worktree of that repository, because they share one hooks directory. If `core.hooksPath` points at a shared or global directory, the hook is written there and affects every repository that uses it; the command prints the path it wrote. Ending input (Ctrl-D) at a prompt cancels with a non-zero exit and changes nothing.
27
+
28
+ ## WASM core provenance
29
+
30
+ `wasm/` is built from the Rust crate `mgc-wasm` (with `mgc-core`) in the private Moonglade monorepo, commit `84ff58c679c64c723f91b101012e7ba884e93020`, with rustc 1.98.1, wasm-bindgen 0.2.129 and `wasm-bindgen --target nodejs`; no wasm-opt pass. Rebuilt from a clean export of that commit, all four files in `wasm/` are byte-identical to that rebuild: `mgc_wasm_bg.wasm` (sha256 `c7f0f294a3de9913c9e71e2a35926d60eb79fa6c45856aa08cf7f2f552f3d1d0`), `mgc_wasm.js` (`63d38046e9e00418043a6291937b03b817df75b333c9b00ee3f2899b5a3c93e2`), `mgc_wasm.d.ts` and `mgc_wasm_bg.wasm.d.ts`. No file is hand-edited. The private source is not published here; hashes and the build commands are in `PUBLIC_SOURCE_IMPORT.json` under `wasm_provenance`.
15
31
 
16
32
  ## Sign and verify
17
33
 
@@ -72,6 +88,6 @@ Gate assertions check the supplied signature and policy; they do not establish h
72
88
 
73
89
  `dist/index.d.ts` declares the public exports: the engine, vault, verification policy, canonicalization and bounded reads; Merkle helpers; timestamp parsing, verification and acquisition; badge helpers; artifact and envelope utilities; read-only MCP tools and self-test.
74
90
 
75
- The built-in verifier returns `INDETERMINATE` for OpenPGP and SSHSIG attestations. The legacy library OpenPGP signing helper is not covered by the default test suite; its keyring selection is caller-controlled. Default hooks, platform keystores, the hook CLI and sibling packages are excluded.
91
+ The built-in verifier returns `INDETERMINATE` for OpenPGP and SSHSIG attestations. The legacy library OpenPGP signing helper is not covered by the default test suite; its keyring selection is caller-controlled. Platform keystores and sibling packages are excluded. The optional hook installer (`provenance install-hooks`) installs only a git pre-commit verification gate and writes a manifest of the options you chose. Usage is described in the build section above.
76
92
 
77
93
  See [SPEC.md](SPEC.md), [TIMESTAMP.md](TIMESTAMP.md), [NOTICE.md](NOTICE.md) and [trust/README.md](trust/README.md). GPG checks the OpenPGP release identity separately. Artifact verification uses raw Ed25519 SPKI PEM keys. Candidate documentation is intentionally unsealed. The root release key is not an agent key.
package/SPEC.md CHANGED
@@ -1,176 +1,176 @@
1
- # Attestation Bundle v3 (Extended): Generic Claims, Dual-Hash Binding & Multi-Party Countersigning
2
-
3
- **Status:** Implemented in `@moonglade/provenance` v2.6.0+ / v3-Extended Engine
4
- **Author:** Marc Ottley (Moonglade AI)
5
- **Supersedes:** Bundle v2 Specification (`SPEC.md`)
6
-
7
- ---
8
-
9
- ## 1. Motivation & Design Intent
10
-
11
- Attestation Bundle v2 solved field binding and domain separation for local-first document attestation:
12
- ```text
13
- moonglade-provenance/v2\n
14
- scope:<scope>\n
15
- sha256:<sha256 of canonical bytes>\n
16
- signer:<identity>\n
17
- ```
18
-
19
- While v2 securely bound the document's content hash to its repository scope and signer, real-world systems require attaching **rich, structured domain metadata** that must also be tamper-evident:
20
- - **Photography & Generative Media:** EXIF camera telemetry, focal length, color space, creator royalty splits, and Creative Commons / commercial licensing terms.
21
- - **Enterprise PDF & Legal Documents:** Contract identifiers, governing jurisdictions, classification levels, and counterparty entities.
22
- - **Forensic Science & Chain of Custody:** Evidence acquisition timestamps, laboratory operator IDs, case numbers, and forensic instrument serial numbers.
23
- - **Agentic AI Governance:** Model version, generation prompt hash, hallucination audit logs, and parent artifact DAG pointers.
24
-
25
- Attestation Bundle v3 introduces **Generic Signed Claims** with **Dual-Hash Cryptographic Preimage Binding** and **Multi-Party Countersigning**, preserving 100% backward compatibility with v2 bundles.
26
-
27
- ---
28
-
29
- ## 2. Generic Claims Architecture
30
-
31
- ### 2.1 Arbitrary Datatype Support
32
- Claims are not restricted to predefined strings. Any valid JSON data structure can be used as a claim key or value:
33
- - **Primitive types:** Strings, numbers (safe integers only; carry a decimal as a string or a scaled integer), booleans, null.
34
- - **Structured types:** Arrays, nested objects, associative lookup tables.
35
-
36
- ### 2.2 Deterministic Canonicalization (RFC 8785 JCS)
37
- To ensure that claims serialize identically across different programming languages and runtimes (Node.js, Python, Rust, Go, browser extensions):
38
- 1. **Object Key Sorting:** All object keys are sorted lexicographically by UTF-16 code units.
39
- 2. **Whitespace Stripping:** No whitespace is permitted between keys, colons, commas, or values.
40
- 3. **Number Formatting:** Numbers MUST be IEEE 754 integers within -(2^53-1) to 2^53-1, judged by value (draft -03 section 3.3): `1.0` and `1e2` are accepted and written `1` and `100`, `-0` is written `0`. Non-integral numbers (such as `1.5`), values beyond the safe range, `NaN` and `Infinity` are rejected by the TypeScript, Python and Rust engines alike.
41
- 4. **String Escaping:** Characters below `0x20` are escaped using standard escape sequences (`\\n`, `\\r`, `\\t`) or `\\u00xx`.
42
- 5. **Undefined Exclusion:** Undefined object properties are omitted from serialization.
43
-
44
- Formally:
45
- $$\\text{ClaimsHash} = \\text{SHA-256}(\\text{JCS}(\\text{claims}))$$
46
-
47
- ---
48
-
49
- ## 3. The 5-Line Signing Preimage Framing
50
-
51
- ### 3.1 Framing Specification
52
- The digital signature covers an unambiguous, newline-delimited framing payload that binds both the artifact bytes and the claims:
53
-
54
- ```text
55
- moonglade-provenance/v3\n
56
- scope:<scope-uri>\n
57
- target-sha256:<64-hex of canonical artifact bytes>\n
58
- signer:<signer-identity>\n
59
- claims-sha256:<64-hex of JCS canonical claims map>\n
60
- ```
61
-
62
- ### 3.2 ABNF Definition
63
-
64
- ```abnf
65
- V3-Payload = "moonglade-provenance/v3" LF
66
- "scope:" Scope-URI LF
67
- "target-sha256:" 64HEXDIG LF
68
- "signer:" Identity-String LF
69
- "claims-sha256:" 64HEXDIG LF
70
-
71
- Scope-URI = 1*(ALPHA / DIGIT / "-" / "_" / "." / "/" / ":")
72
- Identity-String = 1*(VCHAR)
73
- 64HEXDIG = 64HEXDIG
74
- LF = %x0A
75
- ```
76
-
77
- ### 3.3 Security Invariants
78
- 1. **Domain Separation:** The header `"moonglade-provenance/v3\n"` guarantees that v3 signatures cannot be replayed or downgraded to v2 or v1 formats.
79
- 2. **Dual-Hash Binding:** The signature cryptographically binds the canonical artifact bytes (`target-sha256`) and the claims payload (`claims-sha256`) into a single atomic assertion. Tampering with either the artifact OR the claims invalidates the signature.
80
- 3. **Control Character Rejection:** Control characters (`0x00` through `0x1F` and `0x7F`) in any field abort signing immediately, preventing delimiter injection attacks.
81
-
82
- ---
83
-
84
- ## 4. Sidecar Schema (v3)
85
-
86
- ```json
87
- {
88
- "standard": "Moonglade Provenance Attestation Bundle v3",
89
- "artifact": "bathsheba_twilight_raw.jpg",
90
- "claims": {
91
- "photographer": "Marc Ottley",
92
- "focal_length_mm": 85,
93
- "camera_make": "Sony",
94
- "camera_model": "ILCE-7RM5",
95
- "iso": 100,
96
- "aperture": "1.8",
97
- "shutter_speed": "1/250",
98
- "license": "CC-BY-NC-ND-4.0",
99
- "royalty_split": {
100
- "photographer_pct": 70,
101
- "gallery_fund_pct": 30
102
- },
103
- "tags": ["bathsheba", "east-coast", "golden-hour", "raw"]
104
- },
105
- "claims_sha256": "3c89b27e8d1a49f5...",
106
- "attestations": [
107
- {
108
- "method": "moonglade-ed25519-v3",
109
- "signer": "marc.ottley@moongladeai.net",
110
- "scope": "moonglade:vault:gallery/bathsheba_twilight_raw.jpg",
111
- "target_sha256": "9f83a2...",
112
- "claims_sha256": "3c89b27e8d1a49f5...",
113
- "created": "2026-09-14T14:35:00Z",
114
- "git_commit": "c4d2e1f",
115
- "signature": "base64-ed25519-sig..."
116
- },
117
- {
118
- "method": "moonglade-ed25519-v3",
119
- "signer": "gallery.curator@moongladeai.net",
120
- "scope": "moonglade:vault:gallery/bathsheba_twilight_raw.jpg",
121
- "target_sha256": "9f83a2...",
122
- "claims_sha256": "3c89b27e8d1a49f5...",
123
- "created": "2026-09-14T14:40:00Z",
124
- "git_commit": "c4d2e1f",
125
- "signature": "base64-ed25519-countersig..."
126
- },
127
- {
128
- "method": "rfc3161",
129
- "tier": "L1_CRYPTO_PRIMARY",
130
- "tsa": "https://freetsa.org/tsr",
131
- "tsa_time": "2026-09-14T14:35:03Z",
132
- "token_b64": "MIIB..."
133
- }
134
- ],
135
- "sha256_at_last_write": "9f83a2...",
136
- "updated": "2026-09-14T14:40:00Z",
137
- "status": "canonical"
138
- }
139
- ```
140
-
141
- ---
142
-
143
- ## 5. Multi-Party Countersigning Protocol
144
-
145
- In distributed workflows (e.g. Creator to Publisher, Laboratory Analyst to Quality Inspector):
146
- 1. **Primary Seal:** The creator seals the artifact with their private key, attaching the canonical claims.
147
- 2. **Review & Countersign:**
148
- - The countersigner reads `<artifact>.provenance.json`.
149
- - The engine validates that `target_sha256` matches the artifact bytes and `claims_sha256` matches the JCS-canonicalized claims.
150
- - The countersigner executes `countersignDocument()`:
151
- $$\\text{Sig}_{\\text{counter}} = \\text{Sign}_{K_{\\text{counter}}}(\\text{V3-Payload})$$
152
- - Appends the countersignature to `attestations`.
153
- 3. **Independent Keyring Verification:**
154
- - Recipients pass a keyring mapping signer identities to public keys:
155
- ```typescript
156
- const keyring = {
157
- "marc.ottley@moongladeai.net": marcPublicKeyPem,
158
- "gallery.curator@moongladeai.net": galleryPublicKeyPem
159
- };
160
- const result = await ProvenanceEngine.verifyDocument(path, keyring);
161
- ```
162
- - Every signature in the bundle must verify against its corresponding key. If any key is missing or forged, the verification fails closed.
163
-
164
- ---
165
-
166
- ## 6. Threat Model & Verification Guarantees
167
-
168
- | Attack Vector | Detection Mechanism | Result |
169
- | :--- | :--- | :--- |
170
- | **Artifact Byte Mutation** | Target hash mismatch | `Hash mismatch: Document has been mutated` |
171
- | **Claims Value Tampering** | Recomputed JCS hash mismatch | `Claims hash mismatch: claims payload has been mutated` |
172
- | **Claims Substitution** | Preimage `claims-sha256` mismatch | `Signature validation failed` |
173
- | **Scope Smuggling** | Strict POSIX relative URI binding | `Attestation records a different hash / scope mismatch` |
174
- | **Signature Downgrade** | Version header framing (`moonglade-provenance/v3`) | Legacy v1/v2 parsers fail closed on version string |
175
- | **Forged Countersignature** | Keyring verification fails on invalid signature | `Signature validation failed for signer` |
1
+ # Attestation Bundle v3 (Extended): Generic Claims, Dual-Hash Binding & Multi-Party Countersigning
2
+
3
+ **Status:** Implemented in `@moonglade/provenance` v2.6.0+ / v3-Extended Engine
4
+ **Author:** Marc Ottley (Moonglade AI)
5
+ **Supersedes:** Bundle v2 Specification (`SPEC.md`)
6
+
7
+ ---
8
+
9
+ ## 1. Motivation & Design Intent
10
+
11
+ Attestation Bundle v2 solved field binding and domain separation for local-first document attestation:
12
+ ```text
13
+ moonglade-provenance/v2\n
14
+ scope:<scope>\n
15
+ sha256:<sha256 of canonical bytes>\n
16
+ signer:<identity>\n
17
+ ```
18
+
19
+ While v2 securely bound the document's content hash to its repository scope and signer, real-world systems require attaching **rich, structured domain metadata** that must also be tamper-evident:
20
+ - **Photography & Generative Media:** EXIF camera telemetry, focal length, color space, creator royalty splits, and Creative Commons / commercial licensing terms.
21
+ - **Enterprise PDF & Legal Documents:** Contract identifiers, governing jurisdictions, classification levels, and counterparty entities.
22
+ - **Forensic Science & Chain of Custody:** Evidence acquisition timestamps, laboratory operator IDs, case numbers, and forensic instrument serial numbers.
23
+ - **Agentic AI Governance:** Model version, generation prompt hash, hallucination audit logs, and parent artifact DAG pointers.
24
+
25
+ Attestation Bundle v3 introduces **Generic Signed Claims** with **Dual-Hash Cryptographic Preimage Binding** and **Multi-Party Countersigning**, preserving 100% backward compatibility with v2 bundles.
26
+
27
+ ---
28
+
29
+ ## 2. Generic Claims Architecture
30
+
31
+ ### 2.1 Arbitrary Datatype Support
32
+ Claims are not restricted to predefined strings. Any valid JSON data structure can be used as a claim key or value:
33
+ - **Primitive types:** Strings, numbers (safe integers only; carry a decimal as a string or a scaled integer), booleans, null.
34
+ - **Structured types:** Arrays, nested objects, associative lookup tables.
35
+
36
+ ### 2.2 Deterministic Canonicalization (RFC 8785 JCS)
37
+ To ensure that claims serialize identically across different programming languages and runtimes (Node.js, Python, Rust, Go, browser extensions):
38
+ 1. **Object Key Sorting:** All object keys are sorted lexicographically by UTF-16 code units.
39
+ 2. **Whitespace Stripping:** No whitespace is permitted between keys, colons, commas, or values.
40
+ 3. **Number Formatting:** Numbers MUST be IEEE 754 integers within -(2^53-1) to 2^53-1, judged by value (draft -03 section 3.3): `1.0` and `1e2` are accepted and written `1` and `100`, `-0` is written `0`. Non-integral numbers (such as `1.5`), values beyond the safe range, `NaN` and `Infinity` are rejected by the TypeScript, Python and Rust engines alike.
41
+ 4. **String Escaping:** Characters below `0x20` are escaped using standard escape sequences (`\\n`, `\\r`, `\\t`) or `\\u00xx`.
42
+ 5. **Undefined Exclusion:** Undefined object properties are omitted from serialization.
43
+
44
+ Formally:
45
+ $$\\text{ClaimsHash} = \\text{SHA-256}(\\text{JCS}(\\text{claims}))$$
46
+
47
+ ---
48
+
49
+ ## 3. The 5-Line Signing Preimage Framing
50
+
51
+ ### 3.1 Framing Specification
52
+ The digital signature covers an unambiguous, newline-delimited framing payload that binds both the artifact bytes and the claims:
53
+
54
+ ```text
55
+ moonglade-provenance/v3\n
56
+ scope:<scope-uri>\n
57
+ target-sha256:<64-hex of canonical artifact bytes>\n
58
+ signer:<signer-identity>\n
59
+ claims-sha256:<64-hex of JCS canonical claims map>\n
60
+ ```
61
+
62
+ ### 3.2 ABNF Definition
63
+
64
+ ```abnf
65
+ V3-Payload = "moonglade-provenance/v3" LF
66
+ "scope:" Scope-URI LF
67
+ "target-sha256:" 64HEXDIG LF
68
+ "signer:" Identity-String LF
69
+ "claims-sha256:" 64HEXDIG LF
70
+
71
+ Scope-URI = 1*(ALPHA / DIGIT / "-" / "_" / "." / "/" / ":")
72
+ Identity-String = 1*(VCHAR)
73
+ 64HEXDIG = 64HEXDIG
74
+ LF = %x0A
75
+ ```
76
+
77
+ ### 3.3 Security Invariants
78
+ 1. **Domain Separation:** The header `"moonglade-provenance/v3\n"` guarantees that v3 signatures cannot be replayed or downgraded to v2 or v1 formats.
79
+ 2. **Dual-Hash Binding:** The signature cryptographically binds the canonical artifact bytes (`target-sha256`) and the claims payload (`claims-sha256`) into a single atomic assertion. Tampering with either the artifact OR the claims invalidates the signature.
80
+ 3. **Control Character Rejection:** Control characters (`0x00` through `0x1F` and `0x7F`) in any field abort signing immediately, preventing delimiter injection attacks.
81
+
82
+ ---
83
+
84
+ ## 4. Sidecar Schema (v3)
85
+
86
+ ```json
87
+ {
88
+ "standard": "Moonglade Provenance Attestation Bundle v3",
89
+ "artifact": "bathsheba_twilight_raw.jpg",
90
+ "claims": {
91
+ "photographer": "Marc Ottley",
92
+ "focal_length_mm": 85,
93
+ "camera_make": "Sony",
94
+ "camera_model": "ILCE-7RM5",
95
+ "iso": 100,
96
+ "aperture": "1.8",
97
+ "shutter_speed": "1/250",
98
+ "license": "CC-BY-NC-ND-4.0",
99
+ "royalty_split": {
100
+ "photographer_pct": 70,
101
+ "gallery_fund_pct": 30
102
+ },
103
+ "tags": ["bathsheba", "east-coast", "golden-hour", "raw"]
104
+ },
105
+ "claims_sha256": "3c89b27e8d1a49f5...",
106
+ "attestations": [
107
+ {
108
+ "method": "moonglade-ed25519-v3",
109
+ "signer": "marc.ottley@moongladeai.net",
110
+ "scope": "moonglade:vault:gallery/bathsheba_twilight_raw.jpg",
111
+ "target_sha256": "9f83a2...",
112
+ "claims_sha256": "3c89b27e8d1a49f5...",
113
+ "created": "2026-09-14T14:35:00Z",
114
+ "git_commit": "c4d2e1f",
115
+ "signature": "base64-ed25519-sig..."
116
+ },
117
+ {
118
+ "method": "moonglade-ed25519-v3",
119
+ "signer": "gallery.curator@moongladeai.net",
120
+ "scope": "moonglade:vault:gallery/bathsheba_twilight_raw.jpg",
121
+ "target_sha256": "9f83a2...",
122
+ "claims_sha256": "3c89b27e8d1a49f5...",
123
+ "created": "2026-09-14T14:40:00Z",
124
+ "git_commit": "c4d2e1f",
125
+ "signature": "base64-ed25519-countersig..."
126
+ },
127
+ {
128
+ "method": "rfc3161",
129
+ "tier": "L1_CRYPTO_PRIMARY",
130
+ "tsa": "https://freetsa.org/tsr",
131
+ "tsa_time": "2026-09-14T14:35:03Z",
132
+ "token_b64": "MIIB..."
133
+ }
134
+ ],
135
+ "sha256_at_last_write": "9f83a2...",
136
+ "updated": "2026-09-14T14:40:00Z",
137
+ "status": "canonical"
138
+ }
139
+ ```
140
+
141
+ ---
142
+
143
+ ## 5. Multi-Party Countersigning Protocol
144
+
145
+ In distributed workflows (e.g. Creator to Publisher, Laboratory Analyst to Quality Inspector):
146
+ 1. **Primary Seal:** The creator seals the artifact with their private key, attaching the canonical claims.
147
+ 2. **Review & Countersign:**
148
+ - The countersigner reads `<artifact>.provenance.json`.
149
+ - The engine validates that `target_sha256` matches the artifact bytes and `claims_sha256` matches the JCS-canonicalized claims.
150
+ - The countersigner executes `countersignDocument()`:
151
+ $$\\text{Sig}_{\\text{counter}} = \\text{Sign}_{K_{\\text{counter}}}(\\text{V3-Payload})$$
152
+ - Appends the countersignature to `attestations`.
153
+ 3. **Independent Keyring Verification:**
154
+ - Recipients pass a keyring mapping signer identities to public keys:
155
+ ```typescript
156
+ const keyring = {
157
+ "marc.ottley@moongladeai.net": marcPublicKeyPem,
158
+ "gallery.curator@moongladeai.net": galleryPublicKeyPem
159
+ };
160
+ const result = await ProvenanceEngine.verifyDocument(path, keyring);
161
+ ```
162
+ - Every signature in the bundle must verify against its corresponding key. If any key is missing or forged, the verification fails closed.
163
+
164
+ ---
165
+
166
+ ## 6. Threat Model & Verification Guarantees
167
+
168
+ | Attack Vector | Detection Mechanism | Result |
169
+ | :--- | :--- | :--- |
170
+ | **Artifact Byte Mutation** | Target hash mismatch | `Hash mismatch: Document has been mutated` |
171
+ | **Claims Value Tampering** | Recomputed JCS hash mismatch | `Claims hash mismatch: claims payload has been mutated` |
172
+ | **Claims Substitution** | Preimage `claims-sha256` mismatch | `Signature validation failed` |
173
+ | **Scope Smuggling** | Strict POSIX relative URI binding | `Attestation records a different hash / scope mismatch` |
174
+ | **Signature Downgrade** | Version header framing (`moonglade-provenance/v3`) | Legacy v1/v2 parsers fail closed on version string |
175
+ | **Forged Countersignature** | Keyring verification fails on invalid signature | `Signature validation failed for signer` |
176
176
  | **Timestamp Forgery** | RFC 3161 X.509 cert chain and ASN.1 message imprint | `Timestamp token verification failed` |\n
@@ -5,16 +5,21 @@ const index_1 = require("./index");
5
5
  const artifactHook_1 = require("./artifactHook");
6
6
  const self_test_1 = require("./self-test");
7
7
  const badge_1 = require("./badge");
8
+ const hookInstaller_1 = require("./hookInstaller");
8
9
  async function main() {
9
10
  const [, , action, file, dest] = process.argv;
10
11
  if (action === '--help' || action === '-h') {
11
- console.log('provenance <verify|badge|observe|seal-provisional|move|test> <file> [dest]. Keys: MOONGLADE_PUBLIC_KEY_PEM / MOONGLADE_PRIVATE_KEY_PEM. Verification root: MOONGLADE_ROOT or current directory.');
12
+ console.log('provenance <verify|badge|observe|seal-provisional|move|test|install-hooks> <file> [dest]. Keys: MOONGLADE_PUBLIC_KEY_PEM / MOONGLADE_PRIVATE_KEY_PEM. Verification root: MOONGLADE_ROOT or current directory.');
12
13
  return;
13
14
  }
14
15
  if (action === '--version') {
15
16
  console.log(require('../package.json').version);
16
17
  return;
17
18
  }
19
+ if (action === 'install-hooks' || action === 'hooks') {
20
+ process.exitCode = await (0, hookInstaller_1.runInstallHooksCli)(process.argv.slice(3));
21
+ return;
22
+ }
18
23
  if (action === 'verify') {
19
24
  if (!file || !process.env.MOONGLADE_PUBLIC_KEY_PEM)
20
25
  throw new Error('verify requires a file and MOONGLADE_PUBLIC_KEY_PEM');
@@ -78,7 +83,7 @@ async function main() {
78
83
  return;
79
84
  }
80
85
  if (!action || !file || !['observe', 'seal-provisional', 'move'].includes(action)) {
81
- console.error('usage: provenance <observe|seal-provisional|move|test|badge> <file> [dest]');
86
+ console.error('usage: provenance <observe|seal-provisional|move|test|badge|install-hooks> <file> [dest]');
82
87
  process.exitCode = 2;
83
88
  return;
84
89
  }
@@ -0,0 +1,75 @@
1
+ export interface HookSelection {
2
+ preCommit: boolean;
3
+ mcpVerifyRead: boolean;
4
+ mcpAutoSeal: boolean;
5
+ }
6
+ export interface InstallOptions extends HookSelection {
7
+ cwd?: string;
8
+ /** Replace a hook that was not installed by this tool, after backing it up. */
9
+ force?: boolean;
10
+ }
11
+ export interface HookResult {
12
+ path: string;
13
+ action: 'created' | 'unchanged' | 'updated' | 'replaced';
14
+ backup?: string;
15
+ }
16
+ export interface InstallResult {
17
+ /** What the manifest now records (selected options merged with earlier recorded state). */
18
+ selection: HookSelection;
19
+ hooksDir: string | null;
20
+ hook: HookResult | null;
21
+ manifestPath: string;
22
+ manifestBackup?: string;
23
+ }
24
+ /** Marker line (line 2 of the generated hook). A hook is ours only if its whole content equals a known generated version. */
25
+ export declare const HOOK_MARKER = "# moonglade-provenance-hook v1";
26
+ export declare class HookConflictError extends Error {
27
+ readonly hookPath: string;
28
+ constructor(hookPath: string);
29
+ }
30
+ export declare function findGitRoot(startDir?: string): Promise<string | null>;
31
+ /**
32
+ * Hooks directory as git itself resolves it (`git rev-parse --git-path hooks`), so linked worktrees,
33
+ * submodules and core.hooksPath work. Falls back to a `.git` directory only when git is not installed.
34
+ * Returns null when cwd is not inside a repository.
35
+ */
36
+ export declare function resolveHooksDir(cwd?: string): Promise<string | null>;
37
+ /**
38
+ * The generated pre-commit hook. Written for bash (not POSIX sh): it reads NUL-delimited names (`read -d ''`)
39
+ * so file names with spaces, non-ASCII characters (git would quote them otherwise) or newlines are all checked.
40
+ * Fail closed: if the staged list cannot be read, the commit is blocked.
41
+ */
42
+ export declare const PRE_COMMIT_SCRIPT = "#!/usr/bin/env bash\n# moonglade-provenance-hook v1\n# Moonglade Provenance - Git Pre-Commit Verification Gate (requires bash)\n# Blocks the commit when a staged artifact that has a provenance sidecar no longer matches it.\n\necho \"Moonglade Provenance: Verifying staged artifacts...\"\n\nTMP_LIST=$(mktemp) || exit 1\ntrap 'rm -f \"$TMP_LIST\"' EXIT\nif ! git diff --cached --name-only -z --diff-filter=ACMR > \"$TMP_LIST\"; then\n echo \"Moonglade Provenance: could not list staged files; commit aborted.\"\n exit 1\nfi\n\nFAILED=0\nwhile IFS= read -r -d '' FILE; do\n case \"$FILE\" in\n *.provenance.json|*.provenance-registry.json) continue ;;\n *.md|*.csv|*.json|*.sql|*.yml|*.yaml) ;;\n *) continue ;;\n esac\n [ -f \"$FILE\" ] || continue\n [ -f \"$FILE.provenance.json\" ] || continue\n TARGET=\"$FILE\"\n case \"$TARGET\" in -*) TARGET=\"./$TARGET\" ;; esac\n if ! npx --no-install @moonglade/provenance badge \"$TARGET\" --short < /dev/null > /dev/null 2>&1; then\n echo \"[PROVENANCE DRIFT] $FILE has mutated or failed verification!\"\n FAILED=1\n fi\ndone < \"$TMP_LIST\"\n\nif [ \"$FAILED\" -ne 0 ]; then\n echo \"\"\n echo \"Commit aborted by Moonglade Provenance Gate.\"\n echo \"Please re-seal mutated artifacts before committing:\"\n echo \" npx @moonglade/provenance seal-provisional <file>\"\n echo \"\"\n exit 1\nfi\n\necho \"All staged artifacts verified.\"\nexit 0\n";
43
+ /**
44
+ * Install one hook script. An existing hook is replaced only if its content equals a known generated version
45
+ * (`known`, default: the shipped hook). Anything else, including our own hook with an edit or a foreign hook
46
+ * that merely mentions the marker, is refused unless `force`, and a forced replace is backed up first.
47
+ */
48
+ export declare function installGitHook(hooksDir: string, hookName: string, scriptContent: string, opts?: {
49
+ force?: boolean;
50
+ known?: string[];
51
+ }): Promise<HookResult>;
52
+ /**
53
+ * Write the manifest. An existing manifest that is not exactly in the shape this tool writes is backed up first.
54
+ */
55
+ export declare function writeProvenanceConfig(dir: string, selection: HookSelection): Promise<{
56
+ path: string;
57
+ backup?: string;
58
+ }>;
59
+ /**
60
+ * Install exactly the selected gates. Nothing is enabled that was not selected. The manifest records the
61
+ * selected options merged with earlier recorded MCP choices (never switched off silently) and with reality:
62
+ * preCommit is true when an unmodified generated pre-commit hook is installed.
63
+ */
64
+ export declare function installHooks(opts: InstallOptions): Promise<InstallResult>;
65
+ export declare const HOOK_FLAGS: readonly ["--pre-commit", "--mcp-verify-read", "--mcp-auto-seal", "--force"];
66
+ export declare const HOOK_USAGE: string;
67
+ /** CLI entry. Returns the process exit code. */
68
+ export declare function runInstallHooksCli(args: string[], cwd?: string, io?: PromptIo): Promise<number>;
69
+ interface PromptIo {
70
+ input?: NodeJS.ReadableStream;
71
+ output?: NodeJS.WritableStream;
72
+ /** Force interactive (true) or non-interactive (false) mode; default is whether stdin and stdout are terminals. */
73
+ interactive?: boolean;
74
+ }
75
+ export {};
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.HOOK_USAGE = exports.HOOK_FLAGS = exports.PRE_COMMIT_SCRIPT = exports.HookConflictError = exports.HOOK_MARKER = void 0;
37
+ exports.findGitRoot = findGitRoot;
38
+ exports.resolveHooksDir = resolveHooksDir;
39
+ exports.installGitHook = installGitHook;
40
+ exports.writeProvenanceConfig = writeProvenanceConfig;
41
+ exports.installHooks = installHooks;
42
+ exports.runInstallHooksCli = runInstallHooksCli;
43
+ const fs = __importStar(require("fs/promises"));
44
+ const path = __importStar(require("path"));
45
+ const readline = __importStar(require("readline"));
46
+ const child_process_1 = require("child_process");
47
+ /** Marker line (line 2 of the generated hook). A hook is ours only if its whole content equals a known generated version. */
48
+ exports.HOOK_MARKER = '# moonglade-provenance-hook v1';
49
+ class HookConflictError extends Error {
50
+ hookPath;
51
+ constructor(hookPath) {
52
+ super(`${hookPath} already exists and is not an unmodified hook generated by Moonglade Provenance (it may be foreign or edited). It was left unchanged. Re-run with --force to back it up and replace it.`);
53
+ this.hookPath = hookPath;
54
+ this.name = 'HookConflictError';
55
+ }
56
+ }
57
+ exports.HookConflictError = HookConflictError;
58
+ function git(cwd, args) {
59
+ return new Promise(resolve => {
60
+ (0, child_process_1.execFile)('git', args, { cwd, windowsHide: true }, (err, stdout) => {
61
+ if (err)
62
+ resolve({ ok: false, missing: err.code === 'ENOENT', out: '' });
63
+ else
64
+ resolve({ ok: true, missing: false, out: String(stdout).trim() });
65
+ });
66
+ });
67
+ }
68
+ async function findGitRoot(startDir = process.cwd()) {
69
+ let curr = path.resolve(startDir);
70
+ while (true) {
71
+ try {
72
+ const stat = await fs.stat(path.join(curr, '.git'));
73
+ if (stat.isDirectory())
74
+ return curr;
75
+ }
76
+ catch {
77
+ // not in this dir
78
+ }
79
+ const parent = path.dirname(curr);
80
+ if (parent === curr)
81
+ return null;
82
+ curr = parent;
83
+ }
84
+ }
85
+ /**
86
+ * Hooks directory as git itself resolves it (`git rev-parse --git-path hooks`), so linked worktrees,
87
+ * submodules and core.hooksPath work. Falls back to a `.git` directory only when git is not installed.
88
+ * Returns null when cwd is not inside a repository.
89
+ */
90
+ async function resolveHooksDir(cwd = process.cwd()) {
91
+ const r = await git(cwd, ['rev-parse', '--git-path', 'hooks']);
92
+ if (r.ok)
93
+ return path.resolve(cwd, r.out);
94
+ if (r.missing) {
95
+ const root = await findGitRoot(cwd);
96
+ return root ? path.join(root, '.git', 'hooks') : null;
97
+ }
98
+ return null;
99
+ }
100
+ async function resolveTopLevel(cwd) {
101
+ const r = await git(cwd, ['rev-parse', '--show-toplevel']);
102
+ return r.ok ? path.resolve(cwd, r.out) : path.resolve(cwd);
103
+ }
104
+ /**
105
+ * The generated pre-commit hook. Written for bash (not POSIX sh): it reads NUL-delimited names (`read -d ''`)
106
+ * so file names with spaces, non-ASCII characters (git would quote them otherwise) or newlines are all checked.
107
+ * Fail closed: if the staged list cannot be read, the commit is blocked.
108
+ */
109
+ exports.PRE_COMMIT_SCRIPT = `#!/usr/bin/env bash
110
+ ${exports.HOOK_MARKER}
111
+ # Moonglade Provenance - Git Pre-Commit Verification Gate (requires bash)
112
+ # Blocks the commit when a staged artifact that has a provenance sidecar no longer matches it.
113
+
114
+ echo "Moonglade Provenance: Verifying staged artifacts..."
115
+
116
+ TMP_LIST=$(mktemp) || exit 1
117
+ trap 'rm -f "$TMP_LIST"' EXIT
118
+ if ! git diff --cached --name-only -z --diff-filter=ACMR > "$TMP_LIST"; then
119
+ echo "Moonglade Provenance: could not list staged files; commit aborted."
120
+ exit 1
121
+ fi
122
+
123
+ FAILED=0
124
+ while IFS= read -r -d '' FILE; do
125
+ case "$FILE" in
126
+ *.provenance.json|*.provenance-registry.json) continue ;;
127
+ *.md|*.csv|*.json|*.sql|*.yml|*.yaml) ;;
128
+ *) continue ;;
129
+ esac
130
+ [ -f "$FILE" ] || continue
131
+ [ -f "$FILE.provenance.json" ] || continue
132
+ TARGET="$FILE"
133
+ case "$TARGET" in -*) TARGET="./$TARGET" ;; esac
134
+ if ! npx --no-install @moonglade/provenance badge "$TARGET" --short < /dev/null > /dev/null 2>&1; then
135
+ echo "[PROVENANCE DRIFT] $FILE has mutated or failed verification!"
136
+ FAILED=1
137
+ fi
138
+ done < "$TMP_LIST"
139
+
140
+ if [ "$FAILED" -ne 0 ]; then
141
+ echo ""
142
+ echo "Commit aborted by Moonglade Provenance Gate."
143
+ echo "Please re-seal mutated artifacts before committing:"
144
+ echo " npx @moonglade/provenance seal-provisional <file>"
145
+ echo ""
146
+ exit 1
147
+ fi
148
+
149
+ echo "All staged artifacts verified."
150
+ exit 0
151
+ `;
152
+ /** Generated hook contents this tool has shipped. A hook is ours only if its content equals one of these exactly. */
153
+ const KNOWN_GENERATED_HOOKS = [exports.PRE_COMMIT_SCRIPT];
154
+ function backupStamp() {
155
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, '');
156
+ }
157
+ /** Copy a file to `<file>.bak-<UTC timestamp>` (exclusive create, counter suffix on collision). */
158
+ async function backupFile(file) {
159
+ const stamp = backupStamp();
160
+ for (let i = 0;; i++) {
161
+ const candidate = `${file}.bak-${stamp}${i ? '-' + i : ''}`;
162
+ try {
163
+ await fs.copyFile(file, candidate, 1 /* COPYFILE_EXCL */);
164
+ return candidate;
165
+ }
166
+ catch (e) {
167
+ if (e.code !== 'EEXIST')
168
+ throw e;
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Install one hook script. An existing hook is replaced only if its content equals a known generated version
174
+ * (`known`, default: the shipped hook). Anything else, including our own hook with an edit or a foreign hook
175
+ * that merely mentions the marker, is refused unless `force`, and a forced replace is backed up first.
176
+ */
177
+ async function installGitHook(hooksDir, hookName, scriptContent, opts = {}) {
178
+ await fs.mkdir(hooksDir, { recursive: true });
179
+ const hookPath = path.join(hooksDir, hookName);
180
+ const known = opts.known ?? KNOWN_GENERATED_HOOKS;
181
+ let existing = null;
182
+ try {
183
+ existing = await fs.readFile(hookPath, 'utf8');
184
+ }
185
+ catch (e) {
186
+ if (e.code !== 'ENOENT')
187
+ throw e;
188
+ }
189
+ let action = 'created';
190
+ let backup;
191
+ if (existing !== null) {
192
+ if (existing === scriptContent) {
193
+ action = 'unchanged';
194
+ }
195
+ else if (known.includes(existing)) {
196
+ action = 'updated';
197
+ }
198
+ else {
199
+ if (!opts.force)
200
+ throw new HookConflictError(hookPath);
201
+ action = 'replaced';
202
+ backup = await backupFile(hookPath);
203
+ }
204
+ }
205
+ if (action !== 'unchanged') {
206
+ await fs.writeFile(hookPath, scriptContent, { mode: 0o755 });
207
+ }
208
+ await fs.chmod(hookPath, 0o755);
209
+ return backup ? { path: hookPath, action, backup } : { path: hookPath, action };
210
+ }
211
+ function manifestPayload(selection) {
212
+ const payload = {
213
+ standard: MANIFEST_STANDARD,
214
+ hooks: selection
215
+ };
216
+ if (selection.mcpVerifyRead || selection.mcpAutoSeal) {
217
+ payload.mcpServer = { command: 'npx', args: ['-y', '@moonglade/provenance', 'provenance-gate'] };
218
+ }
219
+ return payload;
220
+ }
221
+ /** Previously recorded choices from an existing manifest, or null when absent or unreadable. */
222
+ async function readRecordedHooks(file) {
223
+ let raw;
224
+ try {
225
+ raw = await fs.readFile(file, 'utf8');
226
+ }
227
+ catch (e) {
228
+ if (e.code === 'ENOENT')
229
+ return { parsed: null, raw: null };
230
+ throw e;
231
+ }
232
+ try {
233
+ const parsed = JSON.parse(raw);
234
+ return { parsed: parsed && typeof parsed === 'object' ? parsed : null, raw };
235
+ }
236
+ catch {
237
+ return { parsed: null, raw };
238
+ }
239
+ }
240
+ const MANIFEST_STANDARD = 'Moonglade Provenance Hook Manifest v1';
241
+ /** True only for a manifest of exactly the shape this tool writes. Anything else (user keys, other standard, bad JSON) is backed up before a rewrite. */
242
+ function isOurManifest(parsed) {
243
+ if (!parsed || parsed.standard !== MANIFEST_STANDARD)
244
+ return false;
245
+ const allowed = new Set(['standard', 'installedAt', 'hooks', 'mcpServer']);
246
+ if (!Object.keys(parsed).every(k => allowed.has(k)))
247
+ return false;
248
+ const hooks = parsed.hooks;
249
+ if (!hooks || typeof hooks !== 'object')
250
+ return false;
251
+ const hk = new Set(['preCommit', 'mcpVerifyRead', 'mcpAutoSeal']);
252
+ return Object.keys(hooks).every(k => hk.has(k) && typeof hooks[k] === 'boolean');
253
+ }
254
+ /**
255
+ * Write the manifest. An existing manifest that is not exactly in the shape this tool writes is backed up first.
256
+ */
257
+ async function writeProvenanceConfig(dir, selection) {
258
+ const configPath = path.join(dir, '.provenance-hooks.json');
259
+ const payload = manifestPayload(selection);
260
+ const { parsed, raw } = await readRecordedHooks(configPath);
261
+ let backup;
262
+ if (raw !== null && !isOurManifest(parsed))
263
+ backup = await backupFile(configPath);
264
+ const out = { standard: payload.standard, installedAt: new Date().toISOString(), ...payload };
265
+ await fs.writeFile(configPath, JSON.stringify(out, null, 2) + '\n', 'utf8');
266
+ return backup ? { path: configPath, backup } : { path: configPath };
267
+ }
268
+ /**
269
+ * Install exactly the selected gates. Nothing is enabled that was not selected. The manifest records the
270
+ * selected options merged with earlier recorded MCP choices (never switched off silently) and with reality:
271
+ * preCommit is true when an unmodified generated pre-commit hook is installed.
272
+ */
273
+ async function installHooks(opts) {
274
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
275
+ const selected = {
276
+ preCommit: opts.preCommit === true,
277
+ mcpVerifyRead: opts.mcpVerifyRead === true,
278
+ mcpAutoSeal: opts.mcpAutoSeal === true
279
+ };
280
+ let hooksDir = null;
281
+ let hook = null;
282
+ if (selected.preCommit) {
283
+ hooksDir = await resolveHooksDir(cwd);
284
+ if (!hooksDir)
285
+ throw new Error(`pre-commit hook requested but ${cwd} is not inside a git repository`);
286
+ hook = await installGitHook(hooksDir, 'pre-commit', exports.PRE_COMMIT_SCRIPT, { force: opts.force });
287
+ }
288
+ else {
289
+ hooksDir = await resolveHooksDir(cwd);
290
+ }
291
+ const top = await resolveTopLevel(cwd);
292
+ const recorded = (await readRecordedHooks(path.join(top, '.provenance-hooks.json'))).parsed?.hooks;
293
+ let installed = false;
294
+ if (hooksDir) {
295
+ try {
296
+ installed = KNOWN_GENERATED_HOOKS.includes(await fs.readFile(path.join(hooksDir, 'pre-commit'), 'utf8'));
297
+ }
298
+ catch { /* no hook */ }
299
+ }
300
+ const selection = {
301
+ preCommit: selected.preCommit || installed,
302
+ mcpVerifyRead: selected.mcpVerifyRead || recorded?.mcpVerifyRead === true,
303
+ mcpAutoSeal: selected.mcpAutoSeal || recorded?.mcpAutoSeal === true
304
+ };
305
+ const manifest = await writeProvenanceConfig(top, selection);
306
+ return { selection, hooksDir, hook, manifestPath: manifest.path, manifestBackup: manifest.backup };
307
+ }
308
+ exports.HOOK_FLAGS = ['--pre-commit', '--mcp-verify-read', '--mcp-auto-seal', '--force'];
309
+ exports.HOOK_USAGE = 'usage: provenance install-hooks [--pre-commit] [--mcp-verify-read] [--mcp-auto-seal] [--force]\n' +
310
+ ' --pre-commit install the git pre-commit verification gate\n' +
311
+ ' --mcp-verify-read record the fail-closed verify-on-read MCP option\n' +
312
+ ' --mcp-auto-seal record the provisional auto-seal-on-agent-write MCP option\n' +
313
+ ' --force replace an existing hook that is not ours, after backing it up to <hook>.bak-<UTC timestamp>\n' +
314
+ 'With no choice flag, the command asks about each option when run in a terminal and does nothing otherwise.';
315
+ /** CLI entry. Returns the process exit code. */
316
+ async function runInstallHooksCli(args, cwd = process.cwd(), io = {}) {
317
+ const unknown = args.filter(a => !exports.HOOK_FLAGS.includes(a));
318
+ if (unknown.length) {
319
+ console.error(`unknown argument: ${unknown.join(' ')}\n${exports.HOOK_USAGE}`);
320
+ return 2;
321
+ }
322
+ const has = (f) => args.includes(f);
323
+ let selection = { preCommit: has('--pre-commit'), mcpVerifyRead: has('--mcp-verify-read'), mcpAutoSeal: has('--mcp-auto-seal') };
324
+ if (!selection.preCommit && !selection.mcpVerifyRead && !selection.mcpAutoSeal) {
325
+ const interactive = io.interactive ?? (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY));
326
+ if (!interactive) {
327
+ console.error(`no option selected and no terminal to ask.\n${exports.HOOK_USAGE}`);
328
+ return 2;
329
+ }
330
+ try {
331
+ selection = await askInteractively(io);
332
+ }
333
+ catch (e) {
334
+ if (e instanceof PromptCancelled) {
335
+ console.error(`install-hooks ${e.message}.`);
336
+ return 1;
337
+ }
338
+ throw e;
339
+ }
340
+ }
341
+ try {
342
+ const r = await installHooks({ cwd, ...selection, force: has('--force') });
343
+ if (r.hook) {
344
+ console.log(`${r.hook.action}: ${r.hook.path}`);
345
+ if (r.hook.backup)
346
+ console.log(`backup of previous hook: ${r.hook.backup}`);
347
+ }
348
+ console.log(`manifest: ${r.manifestPath}`);
349
+ if (r.manifestBackup)
350
+ console.log(`backup of previous manifest: ${r.manifestBackup}`);
351
+ console.log(`enabled: ${JSON.stringify(r.selection)}`);
352
+ if (selection.mcpVerifyRead || selection.mcpAutoSeal) {
353
+ console.log('MCP configuration snippet:');
354
+ console.log(JSON.stringify({ mcpServers: { 'provenance-gate': { command: 'npx', args: ['-y', '@moonglade/provenance', 'provenance-gate'] } } }, null, 2));
355
+ }
356
+ return 0;
357
+ }
358
+ catch (e) {
359
+ console.error(e.message);
360
+ return 1;
361
+ }
362
+ }
363
+ class PromptCancelled extends Error {
364
+ constructor() {
365
+ super('cancelled, nothing changed');
366
+ this.name = 'PromptCancelled';
367
+ }
368
+ }
369
+ async function askInteractively(io = {}) {
370
+ const rl = readline.createInterface({ input: io.input ?? process.stdin, output: io.output ?? process.stdout });
371
+ // Queue lines from the start so piped input delivered in one chunk is not lost; end of input cancels.
372
+ const lines = [];
373
+ let waiter = null;
374
+ let closed = false;
375
+ rl.on('line', l => { if (waiter) {
376
+ const w = waiter;
377
+ waiter = null;
378
+ w.resolve(l);
379
+ }
380
+ else
381
+ lines.push(l); });
382
+ rl.on('close', () => { closed = true; if (waiter) {
383
+ const w = waiter;
384
+ waiter = null;
385
+ w.reject(new PromptCancelled());
386
+ } });
387
+ const nextLine = () => new Promise((resolve, reject) => {
388
+ if (lines.length)
389
+ return resolve(lines.shift());
390
+ if (closed)
391
+ return reject(new PromptCancelled());
392
+ waiter = { resolve, reject };
393
+ });
394
+ const ask = async (q, def) => {
395
+ rl.setPrompt(`${q} (${def ? 'Y/n' : 'y/N'}): `);
396
+ rl.prompt();
397
+ const t = (await nextLine()).trim().toLowerCase();
398
+ return t === '' ? def : t === 'y' || t === 'yes';
399
+ };
400
+ try {
401
+ return {
402
+ preCommit: await ask('Install the git pre-commit verification gate?', true),
403
+ mcpVerifyRead: await ask('Record the fail-closed verify-on-read MCP option?', false),
404
+ mcpAutoSeal: await ask('Record the provisional auto-seal-on-agent-write MCP option?', false)
405
+ };
406
+ }
407
+ finally {
408
+ rl.close();
409
+ }
410
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonglade/provenance",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "Local-first cryptographic proof for markdown files and agentic AI memory substrates.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -67,6 +67,11 @@
67
67
  "require": "./dist/index.js",
68
68
  "default": "./dist/index.js"
69
69
  },
70
+ "./self-test": {
71
+ "types": "./dist/self-test.d.ts",
72
+ "require": "./dist/self-test.js",
73
+ "default": "./dist/self-test.js"
74
+ },
70
75
  "./wasm": {
71
76
  "types": "./wasm/mgc_wasm.d.ts",
72
77
  "require": "./wasm/mgc_wasm.js",
@@ -1,10 +1,10 @@
1
- -----BEGIN PGP PUBLIC KEY BLOCK-----
2
-
3
- mDMEapbUuBYJKwYBBAHaRw8BAQdAULJ9to5djqb63TWkuBsylWfKgrujIbevcM5L
4
- acOhv1C0O01hcmMgT3R0bGV5IChGb3VuZGVyLCBNb29uZ2xhZGUgQUkpIDxtb29u
5
- Z2xhZGVhaUBnbWFpbC5jb20+iJkEExYKAEEWIQRef6n2xbCmW8I6dXBP8sLeacDE
6
- owUCapbUuAIbIwUJBaOagAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBP
7
- 8sLeacDEowfNAQC1o+XcHAHwhpc1MMSZClFb/ylagyPo7Y7pdJkgPRWHzAEA3Owi
8
- zID5JKGznM+xVhNA7LaiHIYMEKx3KgLJbsUeRAc=
9
- =gFm3
10
- -----END PGP PUBLIC KEY BLOCK-----
1
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+ mDMEapbUuBYJKwYBBAHaRw8BAQdAULJ9to5djqb63TWkuBsylWfKgrujIbevcM5L
4
+ acOhv1C0O01hcmMgT3R0bGV5IChGb3VuZGVyLCBNb29uZ2xhZGUgQUkpIDxtb29u
5
+ Z2xhZGVhaUBnbWFpbC5jb20+iJkEExYKAEEWIQRef6n2xbCmW8I6dXBP8sLeacDE
6
+ owUCapbUuAIbIwUJBaOagAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBP
7
+ 8sLeacDEowfNAQC1o+XcHAHwhpc1MMSZClFb/ylagyPo7Y7pdJkgPRWHzAEA3Owi
8
+ zID5JKGznM+xVhNA7LaiHIYMEKx3KgLJbsUeRAc=
9
+ =gFm3
10
+ -----END PGP PUBLIC KEY BLOCK-----
package/wasm/mgc_wasm.js CHANGED
@@ -380,8 +380,7 @@ if (!('encodeInto' in cachedTextEncoder)) {
380
380
 
381
381
  let WASM_VECTOR_LEN = 0;
382
382
 
383
- const path = require('path');
384
- const wasmPath = path.join(__dirname, 'mgc_wasm_bg.wasm');
383
+ const wasmPath = `${__dirname}/mgc_wasm_bg.wasm`;
385
384
  const wasmBytes = require('fs').readFileSync(wasmPath);
386
385
  const wasmModule = new WebAssembly.Module(wasmBytes);
387
386
  let wasmInstance = new WebAssembly.Instance(wasmModule, __wbg_get_imports());