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 +202 -0
- orc_seal-0.1.0/PKG-INFO +58 -0
- orc_seal-0.1.0/README.md +43 -0
- orc_seal-0.1.0/orc_seal/__init__.py +5 -0
- orc_seal-0.1.0/orc_seal/__main__.py +82 -0
- orc_seal-0.1.0/orc_seal/orc.py +189 -0
- orc_seal-0.1.0/orc_seal/proxy.py +94 -0
- orc_seal-0.1.0/orc_seal/sealer.py +217 -0
- orc_seal-0.1.0/orc_seal.egg-info/PKG-INFO +58 -0
- orc_seal-0.1.0/orc_seal.egg-info/SOURCES.txt +14 -0
- orc_seal-0.1.0/orc_seal.egg-info/dependency_links.txt +1 -0
- orc_seal-0.1.0/orc_seal.egg-info/entry_points.txt +2 -0
- orc_seal-0.1.0/orc_seal.egg-info/top_level.txt +1 -0
- orc_seal-0.1.0/pyproject.toml +27 -0
- orc_seal-0.1.0/setup.cfg +4 -0
- orc_seal-0.1.0/tests/test_orc_seal.py +286 -0
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.
|
orc_seal-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
orc_seal-0.1.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|
orc_seal-0.1.0/setup.cfg
ADDED
|
@@ -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
|