orc-seal 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.
orc_seal-0.1.0/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: orc-seal
3
+ Version: 0.1.0
4
+ Summary: Outcome Receipts (ORC v0.1) for any MCP server: every tool result gets a receipt a stranger can verify.
5
+ Author: Viridis LLC
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/jdhart81/viridis-agent-fleet
8
+ Project-URL: Spec, https://github.com/jdhart81/viridis-agent-fleet/blob/main/docs/standards/OUTCOME_RECEIPT_v0.1.md
9
+ Project-URL: Issues, https://github.com/jdhart81/viridis-agent-fleet/issues
10
+ Keywords: mcp,agents,receipts,verification,erc-8004,x402
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # orc-seal
17
+
18
+ **Every MCP tool result gets a receipt a stranger can verify.** Put `orc-seal` in front of any MCP server, in any language, with no code changes. Each successful `tools/call` result carries an [Outcome Receipt (ORC v0.1)](https://github.com/jdhart81/viridis-agent-fleet/blob/main/docs/standards/OUTCOME_RECEIPT_v0.1.md) under `result._meta["com.viridisconservation/orc"]`.
19
+
20
+ Identity standards say who an agent is. Payment rails say what was paid. A receipt says **what was actually delivered**, and anyone can check it.
21
+
22
+ ## Use it (one line)
23
+ In your MCP client config, prefix the server command:
24
+ ```json
25
+ { "command": "orc-seal",
26
+ "args": ["--issuer", "did:web:yourdomain.com", "--", "npx", "-y", "@you/your-mcp-server"] }
27
+ ```
28
+ Install: `pip install orc-seal` (stdlib only, Python ≥ 3.10).
29
+
30
+ ## Levels
31
+ | Level | What it proves | How you get it |
32
+ |---|---|---|
33
+ | **INTACT** (L1) | The result was not altered after sealing | Default. Nothing leaves your machine. |
34
+ | **ISSUED** (L2) | A public registry recorded this exact seal, with a timestamp | Set `ORC_SEAL_API_KEY`. Free tier: 1,000 receipts/month. |
35
+
36
+ The registry only ever sees the commitment, digest, salt, profile and tool name. **Your outputs and tool arguments never leave the process.** Arguments are not in the receipt either; only their SHA-256 is.
37
+
38
+ ## Verify
39
+ - In a browser: open the receipt's `verify.page` link (runs locally in the page).
40
+ - CLI: `orc-seal verify receipt.json`
41
+ - Python: `from orc_seal import verify`
42
+
43
+ ## Python decorator (optional)
44
+ ```python
45
+ from orc_seal import Sealer, sealed
46
+ s = Sealer({"id": "did:web:yourdomain.com"})
47
+ @sealed(s)
48
+ def quote(kwh: int) -> dict: ...
49
+ quote(1000) # -> {"result": {...}, "orc": {...receipt...}}
50
+ ```
51
+
52
+ ## Guarantees (tested)
53
+ M1 result never changed · M2 every receipt verifies · M3 arguments and outputs never sent · M4 registry failures fail open · M5 deterministic float handling · M6 errors are not sealed · M7 oversized results pass through unsealed · P1 non-tool traffic is byte-identical.
54
+
55
+ ## Get a registry key
56
+ Email hartjustin6@gmail.com with your issuer id. Design partners get the free tier and help shape ORC v0.2.
57
+
58
+ Apache-2.0 · Viridis LLC
@@ -0,0 +1,43 @@
1
+ # orc-seal
2
+
3
+ **Every MCP tool result gets a receipt a stranger can verify.** Put `orc-seal` in front of any MCP server, in any language, with no code changes. Each successful `tools/call` result carries an [Outcome Receipt (ORC v0.1)](https://github.com/jdhart81/viridis-agent-fleet/blob/main/docs/standards/OUTCOME_RECEIPT_v0.1.md) under `result._meta["com.viridisconservation/orc"]`.
4
+
5
+ Identity standards say who an agent is. Payment rails say what was paid. A receipt says **what was actually delivered**, and anyone can check it.
6
+
7
+ ## Use it (one line)
8
+ In your MCP client config, prefix the server command:
9
+ ```json
10
+ { "command": "orc-seal",
11
+ "args": ["--issuer", "did:web:yourdomain.com", "--", "npx", "-y", "@you/your-mcp-server"] }
12
+ ```
13
+ Install: `pip install orc-seal` (stdlib only, Python ≥ 3.10).
14
+
15
+ ## Levels
16
+ | Level | What it proves | How you get it |
17
+ |---|---|---|
18
+ | **INTACT** (L1) | The result was not altered after sealing | Default. Nothing leaves your machine. |
19
+ | **ISSUED** (L2) | A public registry recorded this exact seal, with a timestamp | Set `ORC_SEAL_API_KEY`. Free tier: 1,000 receipts/month. |
20
+
21
+ The registry only ever sees the commitment, digest, salt, profile and tool name. **Your outputs and tool arguments never leave the process.** Arguments are not in the receipt either; only their SHA-256 is.
22
+
23
+ ## Verify
24
+ - In a browser: open the receipt's `verify.page` link (runs locally in the page).
25
+ - CLI: `orc-seal verify receipt.json`
26
+ - Python: `from orc_seal import verify`
27
+
28
+ ## Python decorator (optional)
29
+ ```python
30
+ from orc_seal import Sealer, sealed
31
+ s = Sealer({"id": "did:web:yourdomain.com"})
32
+ @sealed(s)
33
+ def quote(kwh: int) -> dict: ...
34
+ quote(1000) # -> {"result": {...}, "orc": {...receipt...}}
35
+ ```
36
+
37
+ ## Guarantees (tested)
38
+ M1 result never changed · M2 every receipt verifies · M3 arguments and outputs never sent · M4 registry failures fail open · M5 deterministic float handling · M6 errors are not sealed · M7 oversized results pass through unsealed · P1 non-tool traffic is byte-identical.
39
+
40
+ ## Get a registry key
41
+ Email hartjustin6@gmail.com with your issuer id. Design partners get the free tier and help shape ORC v0.2.
42
+
43
+ Apache-2.0 · Viridis LLC
@@ -0,0 +1,5 @@
1
+ """orc-seal: Outcome Receipts (ORC v0.1) for any MCP server. Apache-2.0."""
2
+ from .sealer import (META_KEY, RegistryClient, Sealer, normalize, sealed, # noqa: F401
3
+ verify)
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,82 @@
1
+ """CLI.
2
+
3
+ orc-seal --issuer did:web:example.com [--name "Example"] [--agent my-server]
4
+ [--registry https://mcp.viridisconservation.com] -- <server command...>
5
+ orc-seal verify receipt.json
6
+
7
+ Registration (L2 ISSUED) is enabled when ORC_SEAL_API_KEY is set.
8
+ Without a key, receipts are self-sealed (L1 INTACT) and nothing leaves the machine.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ import urllib.request
17
+
18
+ from .proxy import run
19
+ from .sealer import DEFAULT_REGISTRY, RegistryClient, Sealer, verify
20
+
21
+
22
+ def _lookup(url: str):
23
+ try:
24
+ with urllib.request.urlopen(url, timeout=5) as r:
25
+ return json.loads(r.read().decode())
26
+ except Exception:
27
+ return None
28
+
29
+
30
+ def main(argv=None) -> int:
31
+ argv = list(sys.argv[1:] if argv is None else argv)
32
+ if argv[:1] == ["verify"]:
33
+ if len(argv) != 2:
34
+ print("usage: orc-seal verify receipt.json", file=sys.stderr)
35
+ return 2
36
+ with open(argv[1]) as f:
37
+ receipt = json.load(f)
38
+ out = verify(receipt, registry_lookup=_lookup)
39
+ print(json.dumps({"level": out["level"], "label": out["label"],
40
+ "reasons": out["reasons"]}, indent=2))
41
+ return 0 if out["level"] >= 1 else 1
42
+ p = argparse.ArgumentParser(prog="orc-seal")
43
+ p.add_argument("--issuer", required=True, help="issuer id, e.g. did:web:example.com")
44
+ p.add_argument("--name", default="")
45
+ p.add_argument("--agent", default="")
46
+ p.add_argument("--registry", default=os.environ.get("ORC_SEAL_REGISTRY", DEFAULT_REGISTRY))
47
+ if "--" not in argv:
48
+ p.parse_args(argv) # --help exits successfully before requiring a server.
49
+ p.error("missing server command after --")
50
+ i = argv.index("--")
51
+ opts, cmd = argv[:i], argv[i + 1:]
52
+ a = p.parse_args(opts)
53
+ if not cmd:
54
+ p.error("missing server command after --")
55
+ key = os.environ.get("ORC_SEAL_API_KEY", "")
56
+ registry = RegistryClient(a.registry, key) if key else None
57
+ sealer = Sealer({"id": a.issuer, "name": a.name or a.issuer}, agent=a.agent or cmd[0],
58
+ registry=registry, verify_base=a.registry)
59
+ return run(cmd, sealer)
60
+
61
+
62
+ def _exit(code: int) -> None:
63
+ """P4: exit with the server's code without waiting on the stdin pump.
64
+
65
+ The client->server pump is a daemon thread blocked in stdin.readline();
66
+ normal interpreter shutdown can then die with "could not acquire lock for
67
+ <stdin>". Flush what we wrote, then leave immediately.
68
+ """
69
+ try:
70
+ sys.stdout.flush()
71
+ sys.stderr.flush()
72
+ finally:
73
+ os._exit(code if isinstance(code, int) else 1)
74
+
75
+
76
+ def cli() -> None:
77
+ """Console entry point with the same safe shutdown as python -m."""
78
+ _exit(main())
79
+
80
+
81
+ if __name__ == "__main__":
82
+ cli()
@@ -0,0 +1,189 @@
1
+ """Outcome Receipt (ORC) v0.1 — reference implementation. Stdlib only.
2
+
3
+ An ORC is a portable, verifiable record that an agent produced a specific
4
+ output. It is rail-neutral: it can bind to an x402 settlement, a Stripe
5
+ charge, an AP2 mandate, or an ERC-8004 validation response, but it never
6
+ depends on any of them. Spec: docs/standards/OUTCOME_RECEIPT_v0.1.md
7
+
8
+ Verification levels (each implies the ones before it):
9
+ L1 INTACT digest recomputes from `output`, and the commitment binds it
10
+ L2 ISSUED the issuer proves it sealed this commitment (registry record
11
+ or Ed25519 signature; checked by a caller-supplied resolver)
12
+ L3 SETTLED a payment binding is confirmed by its own rail (resolver)
13
+ L4 VALIDATED an independent validator attested the same commitment
14
+
15
+ --- INVARIANTS ---
16
+ R1 canonical() is byte-identical to the fleet engines' canonical form:
17
+ json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True).
18
+ R2 seal() never mutates its input; the same output + excludes always give
19
+ the same digest (salt only changes the commitment).
20
+ R3 verify() never raises; malformed input yields level 0 with reasons.
21
+ R4 L1 requires BOTH digest recomputation and commitment recomputation.
22
+ R5 Levels above L1 are granted only by an explicit resolver returning True;
23
+ absent resolvers leave the level capped and say why (no silent upgrade).
24
+ R6 Floats are rejected in `output` at seal time (money and quantities must be
25
+ integers or decimal strings), so any language can reproduce the digest.
26
+ R7 from_cliff_check() maps a Signed Cliff Check receipt losslessly: the ORC
27
+ digest equals the engine's audit_sha256 and the commitment is unchanged.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import secrets
34
+ from copy import deepcopy
35
+ from datetime import datetime, timezone
36
+ from typing import Any, Callable, Optional
37
+
38
+ ORC_VERSION = "0.1"
39
+ CANON_ID = "orc-canon/1"
40
+ COMMIT_SCHEME = "sha256(salt||digest)"
41
+ Resolver = Callable[[dict], bool]
42
+
43
+
44
+ def canonical(value: Any) -> str: # R1
45
+ return json.dumps(value, sort_keys=True, separators=(",", ":"),
46
+ ensure_ascii=True)
47
+
48
+
49
+ def sha256_hex(s: str) -> str:
50
+ return hashlib.sha256(s.encode()).hexdigest()
51
+
52
+
53
+ def _has_float(v: Any) -> bool:
54
+ if isinstance(v, float):
55
+ return True
56
+ if isinstance(v, dict):
57
+ return any(_has_float(x) for x in v.values())
58
+ if isinstance(v, (list, tuple)):
59
+ return any(_has_float(x) for x in v)
60
+ return False
61
+
62
+
63
+ def _is_hex64(s: Any) -> bool:
64
+ return isinstance(s, str) and len(s) == 64 and all(c in "0123456789abcdef" for c in s)
65
+
66
+
67
+ def digest_of(output: dict, excludes: list[str]) -> str:
68
+ content = {k: v for k, v in output.items() if k not in set(excludes)}
69
+ return sha256_hex(canonical(content))
70
+
71
+
72
+ def seal(output: dict, *, issuer: dict, subject: dict, profile: str = "generic",
73
+ excludes: Optional[list[str]] = None, bindings: Optional[dict] = None,
74
+ salt: Optional[str] = None, issued_at: Optional[str] = None) -> dict:
75
+ """Build an ORC receipt. Pure function except for the random salt."""
76
+ if not isinstance(output, dict):
77
+ raise ValueError("output must be a JSON object")
78
+ if _has_float(output): # R6
79
+ raise ValueError("floats are not allowed in output; use integers or decimal strings")
80
+ excludes = list(excludes or [])
81
+ out = deepcopy(output) # R2
82
+ d = digest_of(out, excludes)
83
+ salt = salt or secrets.token_hex(32)
84
+ return {
85
+ "orc": ORC_VERSION, "profile": profile,
86
+ "issuer": deepcopy(issuer), "subject": deepcopy(subject),
87
+ "output": out,
88
+ "digest": {"alg": "sha256", "canonicalization": CANON_ID,
89
+ "excludes": excludes, "value": d},
90
+ "commitment": {"scheme": COMMIT_SCHEME, "salt": salt,
91
+ "value": sha256_hex(salt + d)},
92
+ "bindings": deepcopy(bindings or {}),
93
+ "issued_at": issued_at or datetime.now(timezone.utc).isoformat(),
94
+ }
95
+
96
+
97
+ def verify(receipt: Any, *, issuer_resolver: Optional[Resolver] = None,
98
+ payment_resolver: Optional[Resolver] = None,
99
+ validation_resolver: Optional[Resolver] = None) -> dict:
100
+ """Return {level, checks, reasons}. Never raises (R3)."""
101
+ reasons: list[str] = []
102
+ checks = {"digest_recomputes": False, "commitment_binds": False,
103
+ "issuer_proven": None, "payment_settled": None, "validated": None}
104
+
105
+ def done(level: int) -> dict:
106
+ return {"level": level, "label": ["NONE", "INTACT", "ISSUED", "SETTLED",
107
+ "VALIDATED"][level],
108
+ "checks": checks, "reasons": reasons}
109
+ try:
110
+ if not isinstance(receipt, dict) or receipt.get("orc") != ORC_VERSION:
111
+ reasons.append("not an ORC v0.1 receipt"); return done(0)
112
+ dg, cm, out = receipt.get("digest", {}), receipt.get("commitment", {}), receipt.get("output")
113
+ if dg.get("canonicalization") != CANON_ID or dg.get("alg") != "sha256":
114
+ reasons.append("unsupported digest algorithm or canonicalization"); return done(0)
115
+ if cm.get("scheme") != COMMIT_SCHEME or not _is_hex64(cm.get("salt")):
116
+ reasons.append("unsupported commitment scheme or bad salt"); return done(0)
117
+ if not isinstance(out, dict):
118
+ reasons.append("output must be an object"); return done(0)
119
+ excl = dg.get("excludes", [])
120
+ if not isinstance(excl, list) or not all(isinstance(x, str) for x in excl):
121
+ reasons.append("digest.excludes must be a list of strings"); return done(0)
122
+ computed = digest_of(out, excl)
123
+ checks["digest_recomputes"] = computed == dg.get("value")
124
+ checks["commitment_binds"] = sha256_hex(cm["salt"] + str(dg.get("value"))) == cm.get("value")
125
+ if not (checks["digest_recomputes"] and checks["commitment_binds"]): # R4
126
+ if not checks["digest_recomputes"]:
127
+ reasons.append("output was altered: digest does not recompute")
128
+ if not checks["commitment_binds"]:
129
+ reasons.append("commitment does not bind the digest")
130
+ return done(0)
131
+ level = 1
132
+ for key, res, need in (("issuer_proven", issuer_resolver, "issuer"),
133
+ ("payment_settled", payment_resolver, "payment"),
134
+ ("validated", validation_resolver, "validation")): # R5
135
+ if res is None:
136
+ reasons.append(f"no {need} resolver supplied; level capped at {level}")
137
+ return done(level)
138
+ ok = bool(res(receipt))
139
+ checks[key] = ok
140
+ if not ok:
141
+ reasons.append(f"{need} check failed; level capped at {level}")
142
+ return done(level)
143
+ level += 1
144
+ return done(level)
145
+ except Exception as exc: # R3: never raise
146
+ reasons.append(f"malformed receipt: {type(exc).__name__}")
147
+ return done(0)
148
+
149
+
150
+ CLIFF_EXCLUDES = ["audit_sha256", "notary_payload"]
151
+
152
+
153
+ def from_cliff_check(cc: dict) -> dict: # R7
154
+ """Map a Signed Cliff Check receipt (v0.1/v0.2) to ORC v0.1, losslessly."""
155
+ res, sig = cc["result"], cc["signature"]
156
+ orc = {
157
+ "orc": ORC_VERSION, "profile": "viridis.cliff-check/" + cc["credit"],
158
+ "issuer": {"id": "did:web:viridisconservation.com", "name": "Viridis LLC"},
159
+ "subject": {"agent": "taxcredit-engine", "tool": "calculate_tax_credit",
160
+ "product": cc.get("product"), "product_version": cc.get("product_version"),
161
+ "client": cc.get("client"), "project": cc.get("project")},
162
+ "output": deepcopy(res),
163
+ "digest": {"alg": "sha256", "canonicalization": CANON_ID,
164
+ "excludes": list(CLIFF_EXCLUDES), "value": res["audit_sha256"]},
165
+ "commitment": {"scheme": COMMIT_SCHEME, "salt": sig["salt"],
166
+ "value": sig["commit_hash"]},
167
+ "bindings": {},
168
+ "issued_at": cc.get("generated_at"),
169
+ }
170
+ if cc.get("verify", {}).get("page"):
171
+ orc["verify"] = {"page": cc["verify"]["page"]}
172
+ return orc
173
+
174
+
175
+ def erc8004_validation_response(receipt: dict, *, request_hash: str,
176
+ response_uri: str, passed: bool) -> dict:
177
+ """Unsigned ERC-8004 Validation-Registry response fields for an ORC.
178
+
179
+ Maps: responseHash = commitment.value, responseURI = where the ORC is
180
+ published, response = 100 (pass) or 0 (fail), tag = "orc/0.1".
181
+ The caller's own signer submits it; this function never touches a chain.
182
+ """
183
+ if not _is_hex64(request_hash):
184
+ raise ValueError("request_hash must be 64 lowercase hex chars")
185
+ return {"requestHash": "0x" + request_hash,
186
+ "response": 100 if passed else 0,
187
+ "responseURI": response_uri,
188
+ "responseHash": "0x" + receipt["commitment"]["value"],
189
+ "tag": "orc/" + ORC_VERSION}
@@ -0,0 +1,94 @@
1
+ """Stdio proxy: put orc-seal in front of ANY MCP server, in any language.
2
+
3
+ orc-seal --issuer did:web:example.com -- npx -y @acme/some-mcp-server
4
+
5
+ The client talks to orc-seal exactly as it talked to the server. Every
6
+ message passes through unchanged (P1), except successful `tools/call`
7
+ responses, which gain a receipt under result._meta (sealer M1/M6).
8
+
9
+ --- INVARIANTS ---
10
+ P1 Transparency: lines that are not JSON objects, requests, notifications,
11
+ and responses to anything other than tools/call are forwarded byte-for-byte.
12
+ P2 Ordering: server->client lines are written in the order received.
13
+ P3 The proxy never originates JSON-RPC messages of its own.
14
+ P4 Exit: when the server exits, the proxy exits with the server's code.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ from typing import IO, Optional
23
+
24
+ from .sealer import Sealer
25
+
26
+
27
+ class Tracker:
28
+ """Remembers tools/call requests by id until their response arrives."""
29
+
30
+ def __init__(self):
31
+ self._lock = threading.Lock()
32
+ self._pending: dict = {}
33
+
34
+ def note_client_line(self, line: bytes) -> None:
35
+ try:
36
+ msg = json.loads(line)
37
+ except Exception:
38
+ return
39
+ if isinstance(msg, dict) and msg.get("method") == "tools/call" and "id" in msg:
40
+ with self._lock:
41
+ self._pending[json.dumps(msg["id"])] = msg
42
+
43
+ def take(self, msg_id) -> Optional[dict]:
44
+ with self._lock:
45
+ return self._pending.pop(json.dumps(msg_id), None)
46
+
47
+
48
+ def transform_server_line(line: bytes, tracker: Tracker, sealer: Sealer) -> bytes:
49
+ try:
50
+ msg = json.loads(line)
51
+ except Exception:
52
+ return line # P1
53
+ if not isinstance(msg, dict) or "id" not in msg or "method" in msg:
54
+ return line # P1
55
+ req = tracker.take(msg["id"])
56
+ if req is None:
57
+ return line # P1
58
+ out = sealer.seal_tools_call(req, msg)
59
+ if out is msg:
60
+ return line
61
+ nl = b"\r\n" if line.endswith(b"\r\n") else b"\n"
62
+ return json.dumps(out, separators=(",", ":")).encode() + nl
63
+
64
+
65
+ def pump_client(src: IO[bytes], dst: IO[bytes], tracker: Tracker) -> None:
66
+ for line in iter(src.readline, b""):
67
+ tracker.note_client_line(line)
68
+ try:
69
+ dst.write(line)
70
+ dst.flush()
71
+ except (BrokenPipeError, ValueError):
72
+ break
73
+ try:
74
+ dst.close()
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ def pump_server(src: IO[bytes], dst: IO[bytes], tracker: Tracker, sealer: Sealer) -> None:
80
+ for line in iter(src.readline, b""): # P2
81
+ dst.write(transform_server_line(line, tracker, sealer))
82
+ dst.flush()
83
+
84
+
85
+ def run(cmd: list[str], sealer: Sealer, stdin: IO[bytes] = None,
86
+ stdout: IO[bytes] = None) -> int:
87
+ stdin = stdin or sys.stdin.buffer
88
+ stdout = stdout or sys.stdout.buffer
89
+ child = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
90
+ tracker = Tracker()
91
+ t_in = threading.Thread(target=pump_client, args=(stdin, child.stdin, tracker), daemon=True)
92
+ t_in.start()
93
+ pump_server(child.stdout, stdout, tracker, sealer)
94
+ return child.wait() # P4
@@ -0,0 +1,217 @@
1
+ """orc-seal: attach an Outcome Receipt (ORC v0.1) to every MCP tool result.
2
+
3
+ Any MCP server becomes a receipt issuer without code changes. Each successful
4
+ `tools/call` result gets a receipt under
5
+ `result._meta["com.viridisconservation/orc"]`; a stranger can check it at L1
6
+ (INTACT) offline, and at L2 (ISSUED) when the seal was registered.
7
+
8
+ --- INVARIANTS ---
9
+ M1 Result preservation: sealing never changes the tool's result. It only adds
10
+ one key under `_meta` (JSON-RPC mode) or wraps it in an envelope that the
11
+ caller explicitly opted into (decorator mode).
12
+ M2 Every receipt produced here verifies at L1 with the reference verifier.
13
+ M3 Privacy: tool arguments never appear in a receipt (only `args_sha256`),
14
+ and the registry client sends only commitment, digest, salt, profile,
15
+ subject and issuer. The output and the arguments never leave the process.
16
+ M4 Fail-open: a registry, network or sealing failure never fails or blocks
17
+ the tool call beyond the registry timeout. `issuer_proof` is attached only
18
+ after a 2xx reply that echoes the same commitment and digest.
19
+ M5 Deterministic normalization: floats become shortest round-trip decimal
20
+ strings, non-finite floats become "NaN"/"Infinity"/"-Infinity", tuples
21
+ become lists, bytes become {"bytes_sha256": ...}, non-string keys become
22
+ strings. normalize(normalize(x)) == normalize(x).
23
+ M6 Only successful results are sealed: JSON-RPC errors and results with
24
+ isError=true pass through unchanged. Receipts are for delivered outcomes.
25
+ M7 Oversized results (canonical output > MAX_OUTPUT_BYTES) are not sealed;
26
+ the result passes through with `_meta[...] = {"skipped": "too_large"}`.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import functools
31
+ import hashlib
32
+ import inspect
33
+ import json
34
+ import math
35
+ import urllib.error
36
+ import urllib.request
37
+ from copy import deepcopy
38
+ from typing import Any, Callable, Optional
39
+
40
+ from . import orc
41
+
42
+ META_KEY = "com.viridisconservation/orc"
43
+ MAX_OUTPUT_BYTES = 262_144
44
+ DEFAULT_REGISTRY = "https://mcp.viridisconservation.com"
45
+
46
+
47
+ def normalize(value: Any) -> Any: # M5
48
+ if isinstance(value, bool) or value is None or isinstance(value, (int, str)):
49
+ return value
50
+ if isinstance(value, float):
51
+ if math.isnan(value):
52
+ return "NaN"
53
+ if math.isinf(value):
54
+ return "Infinity" if value > 0 else "-Infinity"
55
+ return repr(value)
56
+ if isinstance(value, dict):
57
+ return {str(k): normalize(v) for k, v in value.items()}
58
+ if isinstance(value, (list, tuple)):
59
+ return [normalize(v) for v in value]
60
+ if isinstance(value, (bytes, bytearray)):
61
+ return {"bytes_sha256": hashlib.sha256(bytes(value)).hexdigest()}
62
+ return str(value)
63
+
64
+
65
+ def args_digest(args: Any) -> str:
66
+ return orc.sha256_hex(orc.canonical(normalize(args if args is not None else {})))
67
+
68
+
69
+ class RegistryClient:
70
+ """POST {base}/orc/v0/register. Sends no output and no arguments (M3)."""
71
+
72
+ def __init__(self, base_url: str = DEFAULT_REGISTRY, api_key: str = "",
73
+ timeout: float = 3.0,
74
+ opener: Optional[Callable[..., Any]] = None):
75
+ self.base = base_url.rstrip("/")
76
+ self.api_key = api_key
77
+ self.timeout = timeout
78
+ self._open = opener or urllib.request.urlopen
79
+ self.last_error: Optional[str] = None
80
+
81
+ @staticmethod
82
+ def payload(receipt: dict) -> dict:
83
+ return {"commitment": receipt["commitment"]["value"],
84
+ "salt": receipt["commitment"]["salt"],
85
+ "digest": receipt["digest"]["value"],
86
+ "profile": receipt["profile"],
87
+ "issuer": receipt["issuer"],
88
+ "subject": {"agent": str(receipt["subject"].get("agent", "")),
89
+ "tool": str(receipt["subject"].get("tool", ""))},
90
+ "issued_at": receipt["issued_at"]}
91
+
92
+ def register(self, receipt: dict) -> Optional[dict]:
93
+ """Return issuer_proof on success, else None (never raises; M4)."""
94
+ self.last_error = None
95
+ try:
96
+ body = json.dumps(self.payload(receipt)).encode()
97
+ req = urllib.request.Request(
98
+ self.base + "/orc/v0/register", data=body, method="POST",
99
+ headers={"content-type": "application/json",
100
+ "authorization": "Bearer " + self.api_key,
101
+ "user-agent": "orc-seal/0.1"})
102
+ with self._open(req, timeout=self.timeout) as resp:
103
+ status = getattr(resp, "status", 200)
104
+ data = json.loads(resp.read().decode() or "{}")
105
+ if not (200 <= status < 300):
106
+ self.last_error = f"http {status}"
107
+ return None
108
+ if (data.get("commitment") != receipt["commitment"]["value"]
109
+ or data.get("digest") != receipt["digest"]["value"]):
110
+ self.last_error = "registry echo mismatch"
111
+ return None
112
+ proof = data.get("issuer_proof") or {}
113
+ if proof.get("method") != "registry" or not str(proof.get("url", "")).startswith("http"):
114
+ self.last_error = "registry returned no proof url"
115
+ return None
116
+ return {"method": "registry", "url": proof["url"]}
117
+ except urllib.error.HTTPError as exc:
118
+ self.last_error = f"http {exc.code}"
119
+ except Exception as exc: # network, JSON, anything: fail open
120
+ self.last_error = type(exc).__name__
121
+ return None
122
+
123
+
124
+ class Sealer:
125
+ def __init__(self, issuer: dict, *, agent: str = "",
126
+ registry: Optional[RegistryClient] = None,
127
+ profile: str = "generic", verify_base: Optional[str] = None):
128
+ if not isinstance(issuer, dict) or not issuer.get("id"):
129
+ raise ValueError("issuer must be a dict with an 'id' (e.g. did:web:example.com)")
130
+ self.issuer = {"id": str(issuer["id"]), "name": str(issuer.get("name", issuer["id"]))}
131
+ self.agent = agent
132
+ self.registry = registry
133
+ self.profile = profile
134
+ self.verify_base = (verify_base or (registry.base if registry else DEFAULT_REGISTRY)).rstrip("/")
135
+
136
+ def seal(self, tool: str, args: Any, result: Any,
137
+ bindings: Optional[dict] = None) -> Optional[dict]:
138
+ """Return a receipt, or None if the result is too large (M7)."""
139
+ output = {"tool": str(tool), "args_sha256": args_digest(args),
140
+ "result": normalize(result)}
141
+ if len(orc.canonical(output)) > MAX_OUTPUT_BYTES:
142
+ return None
143
+ receipt = orc.seal(output, issuer=self.issuer,
144
+ subject={"agent": self.agent, "tool": str(tool)},
145
+ profile=self.profile, bindings=bindings)
146
+ c = receipt["commitment"]["value"]
147
+ receipt["verify"] = {"page": f"{self.verify_base}/verify?c={c}"}
148
+ if self.registry is not None:
149
+ proof = self.registry.register(receipt)
150
+ if proof:
151
+ receipt["issuer_proof"] = proof
152
+ return receipt
153
+
154
+ # -- JSON-RPC (MCP wire) mode -------------------------------------------
155
+ def seal_tools_call(self, request: dict, response: dict) -> dict:
156
+ """Return `response` with a receipt in result._meta (M1, M6, M7)."""
157
+ try:
158
+ if request.get("method") != "tools/call" or "result" not in response:
159
+ return response
160
+ result = response["result"]
161
+ if not isinstance(result, dict) or result.get("isError"):
162
+ return response
163
+ params = request.get("params") or {}
164
+ sealed_part = {k: v for k, v in result.items() if k != "_meta"}
165
+ receipt = self.seal(params.get("name", ""), params.get("arguments"), sealed_part)
166
+ out = dict(response)
167
+ new_result = dict(result)
168
+ meta = dict(result.get("_meta") or {})
169
+ meta[META_KEY] = receipt if receipt is not None else {"skipped": "too_large"}
170
+ new_result["_meta"] = meta
171
+ out["result"] = new_result
172
+ return out
173
+ except Exception:
174
+ return response # M4
175
+
176
+
177
+ def sealed(sealer: Sealer, tool: Optional[str] = None, key: str = "orc"):
178
+ """Decorator for plain Python tools: returns {"result": r, key: receipt}.
179
+
180
+ Opt-in envelope (M1). Works for sync and async functions.
181
+ """
182
+ def wrap(fn):
183
+ name = tool or fn.__name__
184
+
185
+ def envelope(result, args):
186
+ try:
187
+ receipt = sealer.seal(name, args, result)
188
+ except Exception:
189
+ receipt = None
190
+ return {"result": result, key: receipt}
191
+
192
+ if inspect.iscoroutinefunction(fn):
193
+ @functools.wraps(fn)
194
+ async def aw(*a, **kw):
195
+ r = await fn(*a, **kw)
196
+ return envelope(r, {"args": list(a), "kwargs": kw})
197
+ return aw
198
+
199
+ @functools.wraps(fn)
200
+ def w(*a, **kw):
201
+ r = fn(*a, **kw)
202
+ return envelope(r, {"args": list(a), "kwargs": kw})
203
+ return w
204
+ return wrap
205
+
206
+
207
+ def verify(receipt: dict, *, registry_lookup: Optional[Callable[[str], Optional[dict]]] = None) -> dict:
208
+ """L1 always; L2 if `registry_lookup(url) -> record` confirms the seal."""
209
+ def issuer_ok(r: dict) -> bool:
210
+ proof = r.get("issuer_proof") or {}
211
+ if proof.get("method") != "registry" or registry_lookup is None:
212
+ return False
213
+ rec = registry_lookup(proof.get("url", "")) or {}
214
+ return (rec.get("commitment") == r["commitment"]["value"]
215
+ and rec.get("digest") == r["digest"]["value"])
216
+ has_proof = bool(receipt.get("issuer_proof")) and registry_lookup is not None
217
+ return orc.verify(deepcopy(receipt), issuer_resolver=issuer_ok if has_proof else None)
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: orc-seal
3
+ Version: 0.1.0
4
+ Summary: Outcome Receipts (ORC v0.1) for any MCP server: every tool result gets a receipt a stranger can verify.
5
+ Author: Viridis LLC
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/jdhart81/viridis-agent-fleet
8
+ Project-URL: Spec, https://github.com/jdhart81/viridis-agent-fleet/blob/main/docs/standards/OUTCOME_RECEIPT_v0.1.md
9
+ Project-URL: Issues, https://github.com/jdhart81/viridis-agent-fleet/issues
10
+ Keywords: mcp,agents,receipts,verification,erc-8004,x402
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # orc-seal
17
+
18
+ **Every MCP tool result gets a receipt a stranger can verify.** Put `orc-seal` in front of any MCP server, in any language, with no code changes. Each successful `tools/call` result carries an [Outcome Receipt (ORC v0.1)](https://github.com/jdhart81/viridis-agent-fleet/blob/main/docs/standards/OUTCOME_RECEIPT_v0.1.md) under `result._meta["com.viridisconservation/orc"]`.
19
+
20
+ Identity standards say who an agent is. Payment rails say what was paid. A receipt says **what was actually delivered**, and anyone can check it.
21
+
22
+ ## Use it (one line)
23
+ In your MCP client config, prefix the server command:
24
+ ```json
25
+ { "command": "orc-seal",
26
+ "args": ["--issuer", "did:web:yourdomain.com", "--", "npx", "-y", "@you/your-mcp-server"] }
27
+ ```
28
+ Install: `pip install orc-seal` (stdlib only, Python ≥ 3.10).
29
+
30
+ ## Levels
31
+ | Level | What it proves | How you get it |
32
+ |---|---|---|
33
+ | **INTACT** (L1) | The result was not altered after sealing | Default. Nothing leaves your machine. |
34
+ | **ISSUED** (L2) | A public registry recorded this exact seal, with a timestamp | Set `ORC_SEAL_API_KEY`. Free tier: 1,000 receipts/month. |
35
+
36
+ The registry only ever sees the commitment, digest, salt, profile and tool name. **Your outputs and tool arguments never leave the process.** Arguments are not in the receipt either; only their SHA-256 is.
37
+
38
+ ## Verify
39
+ - In a browser: open the receipt's `verify.page` link (runs locally in the page).
40
+ - CLI: `orc-seal verify receipt.json`
41
+ - Python: `from orc_seal import verify`
42
+
43
+ ## Python decorator (optional)
44
+ ```python
45
+ from orc_seal import Sealer, sealed
46
+ s = Sealer({"id": "did:web:yourdomain.com"})
47
+ @sealed(s)
48
+ def quote(kwh: int) -> dict: ...
49
+ quote(1000) # -> {"result": {...}, "orc": {...receipt...}}
50
+ ```
51
+
52
+ ## Guarantees (tested)
53
+ M1 result never changed · M2 every receipt verifies · M3 arguments and outputs never sent · M4 registry failures fail open · M5 deterministic float handling · M6 errors are not sealed · M7 oversized results pass through unsealed · P1 non-tool traffic is byte-identical.
54
+
55
+ ## Get a registry key
56
+ Email hartjustin6@gmail.com with your issuer id. Design partners get the free tier and help shape ORC v0.2.
57
+
58
+ Apache-2.0 · Viridis LLC
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ orc_seal/__init__.py
5
+ orc_seal/__main__.py
6
+ orc_seal/orc.py
7
+ orc_seal/proxy.py
8
+ orc_seal/sealer.py
9
+ orc_seal.egg-info/PKG-INFO
10
+ orc_seal.egg-info/SOURCES.txt
11
+ orc_seal.egg-info/dependency_links.txt
12
+ orc_seal.egg-info/entry_points.txt
13
+ orc_seal.egg-info/top_level.txt
14
+ tests/test_orc_seal.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orc-seal = orc_seal.__main__:cli
@@ -0,0 +1 @@
1
+ orc_seal
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "orc-seal"
7
+ version = "0.1.0"
8
+ description = "Outcome Receipts (ORC v0.1) for any MCP server: every tool result gets a receipt a stranger can verify."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Viridis LLC" }]
14
+ keywords = ["mcp", "agents", "receipts", "verification", "erc-8004", "x402"]
15
+ dependencies = []
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/jdhart81/viridis-agent-fleet"
19
+ Spec = "https://github.com/jdhart81/viridis-agent-fleet/blob/main/docs/standards/OUTCOME_RECEIPT_v0.1.md"
20
+
21
+ Issues = "https://github.com/jdhart81/viridis-agent-fleet/issues"
22
+
23
+ [project.scripts]
24
+ orc-seal = "orc_seal.__main__:cli"
25
+
26
+ [tool.setuptools]
27
+ packages = ["orc_seal"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,286 @@
1
+ """M1-M7 (sealer) and P1-P4 (stdio proxy) for orc-seal."""
2
+ import asyncio
3
+ import io
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ HERE = Path(__file__).resolve().parent
12
+ PKG = HERE.parent
13
+ ROOT = PKG.parents[1]
14
+ sys.path.insert(0, str(PKG))
15
+ from orc_seal import META_KEY, RegistryClient, Sealer, normalize, sealed, verify # noqa: E402
16
+ from orc_seal import orc as vendored # noqa: E402
17
+ from orc_seal.proxy import Tracker, transform_server_line # noqa: E402
18
+
19
+ ISSUER = {"id": "did:web:example.com", "name": "Example"}
20
+ REQ = {"jsonrpc": "2.0", "id": 7, "method": "tools/call",
21
+ "params": {"name": "double", "arguments": {"x": 21, "secret": "hunter2"}}}
22
+ RESP = {"jsonrpc": "2.0", "id": 7,
23
+ "result": {"content": [{"type": "text", "text": "42"}],
24
+ "structuredContent": {"value": 42, "ratio": 0.1},
25
+ "_meta": {"other": 1}}}
26
+
27
+
28
+ class FakeResp:
29
+ def __init__(self, status, body):
30
+ self.status, self.body = status, body
31
+ def read(self): return self.body
32
+ def __enter__(self): return self
33
+ def __exit__(self, *a): return False
34
+
35
+
36
+ def opener_echo(captured):
37
+ def op(req, timeout):
38
+ body = json.loads(req.data)
39
+ captured.append({"body": body, "headers": dict(req.header_items()), "timeout": timeout})
40
+ return FakeResp(200, json.dumps({
41
+ "commitment": body["commitment"], "digest": body["digest"],
42
+ "issuer_proof": {"method": "registry",
43
+ "url": "https://reg.test/orc/v0/commitments/" + body["commitment"]}}).encode())
44
+ return op
45
+
46
+
47
+ def test_vendored_orc_is_byte_identical_to_reference():
48
+ assert (PKG / "orc_seal" / "orc.py").read_bytes() == (ROOT / "fleet_utils" / "orc.py").read_bytes()
49
+
50
+
51
+ def test_m1_m6_result_preserved_and_only_meta_added():
52
+ s = Sealer(ISSUER, agent="demo")
53
+ out = s.seal_tools_call(REQ, json.loads(json.dumps(RESP)))
54
+ assert {k: v for k, v in out["result"].items() if k != "_meta"} == \
55
+ {k: v for k, v in RESP["result"].items() if k != "_meta"}
56
+ assert out["result"]["_meta"]["other"] == 1
57
+ assert set(out["result"]["_meta"]) == {"other", META_KEY}
58
+ assert RESP["result"]["_meta"] == {"other": 1} # input not mutated
59
+
60
+
61
+ def test_m2_receipt_verifies_intact_and_tamper_fails():
62
+ s = Sealer(ISSUER, agent="demo")
63
+ r = s.seal_tools_call(REQ, RESP)["result"]["_meta"][META_KEY]
64
+ assert verify(r)["label"] == "INTACT"
65
+ bad = json.loads(json.dumps(r)); bad["output"]["result"]["structuredContent"]["value"] = 43
66
+ assert verify(bad)["level"] == 0
67
+ assert r["verify"]["page"].endswith("/verify?c=" + r["commitment"]["value"])
68
+
69
+
70
+ def test_m3_args_never_in_receipt_nor_registry_payload():
71
+ cap = []
72
+ s = Sealer(ISSUER, registry=RegistryClient("https://reg.test", "k", opener=opener_echo(cap)))
73
+ r = s.seal_tools_call(REQ, RESP)["result"]["_meta"][META_KEY]
74
+ assert "hunter2" not in json.dumps(r)
75
+ sent = json.dumps(cap[0]["body"])
76
+ assert "hunter2" not in sent and "42" not in sent.replace(r["commitment"]["value"], "").replace(r["digest"]["value"], "").replace(r["commitment"]["salt"], "")
77
+ assert set(cap[0]["body"]) == {"commitment", "salt", "digest", "profile", "issuer", "subject", "issued_at"}
78
+ assert cap[0]["headers"]["Authorization"] == "Bearer k"
79
+
80
+
81
+ def test_m4_registry_success_adds_proof_and_l2():
82
+ cap = []
83
+ s = Sealer(ISSUER, registry=RegistryClient("https://reg.test", "k", opener=opener_echo(cap)))
84
+ r = s.seal("double", {"x": 1}, {"v": 2})
85
+ assert r["issuer_proof"]["url"].endswith(r["commitment"]["value"])
86
+ rec = {"commitment": r["commitment"]["value"], "digest": r["digest"]["value"]}
87
+ assert verify(r, registry_lookup=lambda u: rec)["label"] == "ISSUED"
88
+ assert verify(r, registry_lookup=lambda u: {**rec, "digest": "0" * 64})["label"] == "INTACT"
89
+
90
+
91
+ @pytest.mark.parametrize("opener", [
92
+ lambda req, timeout: (_ for _ in ()).throw(OSError("down")),
93
+ lambda req, timeout: FakeResp(500, b"{}"),
94
+ lambda req, timeout: FakeResp(200, b"not json"),
95
+ lambda req, timeout: FakeResp(200, json.dumps({"commitment": "0" * 64, "digest": "0" * 64,
96
+ "issuer_proof": {"method": "registry", "url": "https://x"}}).encode()),
97
+ ])
98
+ def test_m4_registry_failures_fail_open_to_intact(opener):
99
+ reg = RegistryClient("https://reg.test", "k", opener=opener)
100
+ s = Sealer(ISSUER, registry=reg)
101
+ out = s.seal_tools_call(REQ, RESP)
102
+ r = out["result"]["_meta"][META_KEY]
103
+ assert "issuer_proof" not in r and verify(r)["label"] == "INTACT"
104
+ assert reg.last_error
105
+
106
+
107
+ def test_m5_normalization_deterministic_and_idempotent():
108
+ x = {1: (0.1, float("nan"), float("inf"), -float("inf"), b"ab"), "k": [True, None, 3]}
109
+ n = normalize(x)
110
+ assert n == {"1": ["0.1", "NaN", "Infinity", "-Infinity",
111
+ {"bytes_sha256": "fb8e20fc2e4c3f248c60c39bd652f3c1347298bb977b8b4d5903b85055620603"}],
112
+ "k": [True, None, 3]}
113
+ assert normalize(n) == n
114
+ s = Sealer(ISSUER)
115
+ a, b = s.seal("t", {"x": 0.1}, {"y": 1.5}), s.seal("t", {"x": 0.1}, {"y": 1.5})
116
+ assert a["digest"]["value"] == b["digest"]["value"]
117
+
118
+
119
+ def test_m6_errors_and_iserror_pass_through():
120
+ s = Sealer(ISSUER)
121
+ err = {"jsonrpc": "2.0", "id": 7, "error": {"code": -1, "message": "x"}}
122
+ assert s.seal_tools_call(REQ, err) is err
123
+ ie = {"jsonrpc": "2.0", "id": 7, "result": {"content": [], "isError": True}}
124
+ assert s.seal_tools_call(REQ, ie) is ie
125
+ other = {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
126
+ assert s.seal_tools_call({"method": "tools/list", "id": 1}, other) is other
127
+
128
+
129
+ def test_m7_oversized_not_sealed():
130
+ s = Sealer(ISSUER)
131
+ big = {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "a" * 300_000}]}}
132
+ out = s.seal_tools_call(REQ, big)
133
+ assert out["result"]["_meta"][META_KEY] == {"skipped": "too_large"}
134
+ assert out["result"]["content"] == big["result"]["content"]
135
+
136
+
137
+ def test_decorator_sync_and_async():
138
+ s = Sealer(ISSUER)
139
+
140
+ @sealed(s)
141
+ def add(a, b): return a + b
142
+
143
+ @sealed(s, tool="mul")
144
+ async def mul(a, b): return a * b
145
+
146
+ r1 = add(2, 3)
147
+ assert r1["result"] == 5 and verify(r1["orc"])["level"] == 1
148
+ r2 = asyncio.run(mul(2, 3))
149
+ assert r2["result"] == 6 and r2["orc"]["subject"]["tool"] == "mul"
150
+
151
+
152
+ def test_issuer_required():
153
+ with pytest.raises(ValueError):
154
+ Sealer({"name": "no id"})
155
+
156
+
157
+ # ---- proxy -----------------------------------------------------------------
158
+ def test_p1_non_tool_lines_byte_identical():
159
+ s, t = Sealer(ISSUER), Tracker()
160
+ for line in [b"garbage\n", b'{"jsonrpc":"2.0","method":"notifications/x"}\n',
161
+ b'{"jsonrpc":"2.0","id":9,"result":{"a":1.50}}\n', b"[1,2]\n"]:
162
+ assert transform_server_line(line, t, s) == line
163
+
164
+
165
+ def test_p_end_to_end_subprocess_proxy():
166
+ cmd = [sys.executable, "-m", "orc_seal", "--issuer", "did:web:example.com", "--",
167
+ sys.executable, str(HERE / "fake_server.py")]
168
+ msgs = [{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
169
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
170
+ {"jsonrpc": "2.0", "id": "a", "method": "tools/call", "params": {"name": "double", "arguments": {"x": 4}}},
171
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "fail"}},
172
+ {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "iserror"}}]
173
+ stdin = "".join(json.dumps(m) + "\n" for m in msgs).encode()
174
+ p = subprocess.run(cmd, input=stdin, capture_output=True, cwd=PKG, timeout=30,
175
+ env={"PATH": "/usr/bin:/bin", "PYTHONPATH": str(PKG)})
176
+ assert p.returncode == 0, p.stderr
177
+ lines = [json.loads(x) for x in p.stdout.decode().splitlines()]
178
+ assert [m["id"] for m in lines] == [1, "a", 3, 4] # P2
179
+ assert lines[0]["result"] == {"echo": "initialize"}
180
+ r = lines[1]["result"]
181
+ assert r["content"][0]["text"] == "8" and r["structuredContent"]["ratio"] == 0.1
182
+ assert verify(r["_meta"][META_KEY])["label"] == "INTACT"
183
+ assert "error" in lines[2] and "_meta" not in lines[3]["result"]
184
+
185
+
186
+ def test_p_real_mcp_sdk_server_through_proxy(tmp_path):
187
+ # The fleet (and production gateway) pins mcp 1.x. mcp 2.x renamed FastMCP
188
+ # to MCPServer; skip rather than fail if a 2.x SDK is installed here.
189
+ pytest.importorskip("mcp.server.fastmcp", reason="needs the mcp 1.x SDK (FastMCP)")
190
+ server = tmp_path / "srv.py"
191
+ server.write_text(
192
+ "from mcp.server.fastmcp import FastMCP\n"
193
+ "m = FastMCP('demo')\n"
194
+ "@m.tool()\n"
195
+ "def carbon(kwh: float) -> dict:\n"
196
+ " return {'kg_co2e': round(kwh * 0.386, 3)}\n"
197
+ "m.run()\n")
198
+ from mcp import ClientSession, StdioServerParameters
199
+ from mcp.client.stdio import stdio_client
200
+
201
+ async def go():
202
+ params = StdioServerParameters(
203
+ command=sys.executable,
204
+ args=["-m", "orc_seal", "--issuer", "did:web:example.com", "--agent", "demo",
205
+ "--", sys.executable, str(server)],
206
+ env={"PYTHONPATH": str(PKG), "PATH": "/usr/bin:/bin"}, cwd=str(PKG))
207
+ async with stdio_client(params) as (r, w):
208
+ async with ClientSession(r, w) as s:
209
+ await s.initialize()
210
+ tools = await s.list_tools()
211
+ assert [t.name for t in tools.tools] == ["carbon"]
212
+ res = await s.call_tool("carbon", {"kwh": 1000})
213
+ return res
214
+ res = asyncio.run(go())
215
+ receipt = res.meta[META_KEY]
216
+ assert verify(receipt)["label"] == "INTACT"
217
+ sealed_text = json.dumps(receipt["output"]["result"])
218
+ assert "386.0" in sealed_text # the tool's answer is what got sealed
219
+ assert [c.text for c in res.content] == [c["text"] for c in receipt["output"]["result"]["content"]]
220
+ assert receipt["subject"] == {"agent": "demo", "tool": "carbon"}
221
+
222
+
223
+ def test_browser_verifier_agrees(tmp_path):
224
+ """The public verify page's JS core accepts orc-seal receipts and rejects tampering."""
225
+ import shutil
226
+ node = shutil.which("node")
227
+ if not node:
228
+ pytest.skip("node not installed")
229
+ r = Sealer(ISSUER, agent="demo").seal(
230
+ "carbon", {"kwh": 1000},
231
+ {"content": [{"type": "text", "text": "Café ☀ 386.0"}], "structuredContent": {"kg": 0.386}})
232
+ good = tmp_path / "good.json"; good.write_text(json.dumps(r))
233
+ t = json.loads(json.dumps(r)); t["output"]["result"]["structuredContent"]["kg"] = "0.387"
234
+ bad = tmp_path / "bad.json"; bad.write_text(json.dumps(t))
235
+ js = (
236
+ "const fs=require('fs');"
237
+ f"const h=fs.readFileSync({json.dumps(str(ROOT / 'products/signed-cliff-check/verify.html'))},'utf8');"
238
+ "const s=[...h.matchAll(/<script(?:\\s[^>]*)?>([\\s\\S]*?)<\\/script>/g)].map(x=>x[1]).find(x=>x.includes('verifyReceipt'));"
239
+ "const m={exports:{}};new Function('module','require',s)(m,require);"
240
+ f"Promise.all([{json.dumps(str(good))},{json.dumps(str(bad))}].map(p=>m.exports.verifyReceipt(fs.readFileSync(p,'utf8'))))"
241
+ ".then(v=>console.log(JSON.stringify(v.map(x=>x.verified))))")
242
+ out = subprocess.run([node, "-e", js], capture_output=True, text=True, timeout=30)
243
+ assert out.stdout.strip() == "[true,false]", out.stderr
244
+
245
+
246
+ def test_p4_server_dies_early_proxy_exits_with_its_code_cleanly(tmp_path):
247
+ """P4 regression: server exits while the client's stdin is still open."""
248
+ dead = tmp_path / "dead.py"
249
+ dead.write_text("import sys; sys.stderr.write('boom\\n'); sys.exit(3)\n")
250
+ p = subprocess.Popen([sys.executable, "-m", "orc_seal", "--issuer", "did:web:example.com", "--",
251
+ sys.executable, str(dead)],
252
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
253
+ cwd=PKG, env={"PATH": "/usr/bin:/bin", "PYTHONPATH": str(PKG)})
254
+ try:
255
+ out, err = p.communicate(timeout=15) # stdin is left open until communicate closes it
256
+ except subprocess.TimeoutExpired:
257
+ p.kill(); raise
258
+ assert p.returncode == 3
259
+ assert b"Fatal Python error" not in err
260
+
261
+
262
+ def test_p4_server_dies_early_with_client_still_writing(tmp_path):
263
+ """Same, but the client keeps its stdin pipe open (not closed by communicate)."""
264
+ import time
265
+ dead = tmp_path / "dead.py"
266
+ dead.write_text("import sys, time; time.sleep(0.2); sys.exit(5)\n")
267
+ p = subprocess.Popen([sys.executable, "-m", "orc_seal", "--issuer", "did:web:example.com", "--",
268
+ sys.executable, str(dead)],
269
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
270
+ cwd=PKG, env={"PATH": "/usr/bin:/bin", "PYTHONPATH": str(PKG)})
271
+ p.stdin.write(b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n'); p.stdin.flush()
272
+ deadline = time.time() + 15
273
+ while p.poll() is None and time.time() < deadline:
274
+ time.sleep(0.05)
275
+ assert p.returncode == 5, "proxy must exit when the server exits, even with stdin open"
276
+ err = p.stderr.read()
277
+ assert b"Fatal Python error" not in err
278
+ p.stdin.close()
279
+
280
+
281
+ def test_cli_help_without_server_command(capsys):
282
+ from orc_seal.__main__ import main
283
+ with pytest.raises(SystemExit) as exc:
284
+ main(["--help"])
285
+ assert exc.value.code == 0
286
+ assert "usage: orc-seal" in capsys.readouterr().out