metamynd-client 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.
- metamynd_client-0.1.0/LICENSE +21 -0
- metamynd_client-0.1.0/PKG-INFO +108 -0
- metamynd_client-0.1.0/README.md +83 -0
- metamynd_client-0.1.0/pyproject.toml +35 -0
- metamynd_client-0.1.0/setup.cfg +4 -0
- metamynd_client-0.1.0/src/metamynd_client.egg-info/PKG-INFO +108 -0
- metamynd_client-0.1.0/src/metamynd_client.egg-info/SOURCES.txt +9 -0
- metamynd_client-0.1.0/src/metamynd_client.egg-info/dependency_links.txt +1 -0
- metamynd_client-0.1.0/src/metamynd_client.egg-info/requires.txt +1 -0
- metamynd_client-0.1.0/src/metamynd_client.egg-info/top_level.txt +1 -0
- metamynd_client-0.1.0/src/metamynd_client.py +588 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MetaMynd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metamynd-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MetaMynd/AgentSafe authorize-gate client for Python — the supported reference implementation of MAGP §7.3
|
|
5
|
+
Author: MetaMynd
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://metamynd.ai/developers/python
|
|
8
|
+
Project-URL: Documentation, https://metamynd.ai/developers/spec
|
|
9
|
+
Project-URL: Source, https://github.com/Metamynd/agentsafe-guard
|
|
10
|
+
Project-URL: Issues, https://github.com/Metamynd/agentsafe-guard/issues
|
|
11
|
+
Keywords: metamynd,agentsafe,governance,ai-agents,magp,authorization
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: cryptography
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# metamynd-client
|
|
27
|
+
|
|
28
|
+
The MetaMynd/AgentSafe authorize-gate client for Python — the supported reference
|
|
29
|
+
implementation. MAGP is language-agnostic by design: the gate verifies an Ed25519
|
|
30
|
+
signature over a canonical string, and nothing about that is JavaScript-specific. This
|
|
31
|
+
is the proof, and the shortest path for a Python team that doesn't want to read the
|
|
32
|
+
[Node guard](https://www.npmjs.com/package/@metamynd/agentsafe-guard) to find out what
|
|
33
|
+
the protocol actually expects.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install metamynd-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from metamynd_client import MetaMyndClient
|
|
41
|
+
|
|
42
|
+
client = MetaMyndClient.from_env() # reads METAMYND_API / AGENT_DID / AGENT_KEY
|
|
43
|
+
|
|
44
|
+
verdict = client.authorize(
|
|
45
|
+
"flight-purchase", 150,
|
|
46
|
+
merchant="skyward-air",
|
|
47
|
+
context={"tool": "book-flight", "riskLevel": "low"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if verdict.decision == "escalate":
|
|
51
|
+
# ESCALATE is a HOLD, not a denial — poll until resolved, act only on may_proceed.
|
|
52
|
+
state = client.escalation_status(verdict.escalation_id)
|
|
53
|
+
elif not verdict.permitted:
|
|
54
|
+
raise RuntimeError(f"refused: {verdict.reason_code}")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Guard a tool
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from metamynd_client import MetaMyndClient, guard_tool, GovernanceBlocked
|
|
61
|
+
|
|
62
|
+
client = MetaMyndClient.from_env()
|
|
63
|
+
|
|
64
|
+
def book_flight(amount, merchant):
|
|
65
|
+
... # your real implementation
|
|
66
|
+
|
|
67
|
+
governed_book_flight = guard_tool(
|
|
68
|
+
client, "flight-purchase", book_flight,
|
|
69
|
+
lambda amount, merchant: {"amount": amount, "merchant": merchant},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
governed_book_flight(amount=150, merchant="skyward-air")
|
|
74
|
+
except GovernanceBlocked as refused:
|
|
75
|
+
print(f"refused: {refused.verdict.reason_code}")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Works with any framework — LangGraph, the OpenAI Agents SDK, or a plain function call —
|
|
79
|
+
because the governed step is the same everywhere: ask the gate, act only on a permit. See
|
|
80
|
+
the [LangGraph guide](https://metamynd.ai/developers/langgraph) and the
|
|
81
|
+
[OpenAI Agents SDK guide](https://metamynd.ai/developers/openai-agents) for
|
|
82
|
+
framework-specific examples.
|
|
83
|
+
|
|
84
|
+
## Scope
|
|
85
|
+
|
|
86
|
+
This is the entry price, not a full SDK: it signs and submits authorize requests,
|
|
87
|
+
returns the verdict, wraps a tool (`guard_tool`), and follows up an `escalate`. Local
|
|
88
|
+
bundle evaluation, two-phase capture/void, and evidence inclusion-proof fetch all exist
|
|
89
|
+
in the protocol and in the [Node guard](https://www.npmjs.com/package/@metamynd/agentsafe-guard)
|
|
90
|
+
and are deliberately not reimplemented here — see
|
|
91
|
+
[the full comparison](https://metamynd.ai/developers/python).
|
|
92
|
+
|
|
93
|
+
## Self-test
|
|
94
|
+
|
|
95
|
+
Checks the three details that cost a Python integration a day each — key encoding,
|
|
96
|
+
number stringification, timestamp shape — without touching the network:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m metamynd_client --selftest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Links
|
|
103
|
+
|
|
104
|
+
- [Full guide](https://metamynd.ai/developers/python)
|
|
105
|
+
- [Protocol spec](https://metamynd.ai/developers/spec)
|
|
106
|
+
- [Source](https://github.com/Metamynd/agentsafe-guard)
|
|
107
|
+
|
|
108
|
+
MIT licensed.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# metamynd-client
|
|
2
|
+
|
|
3
|
+
The MetaMynd/AgentSafe authorize-gate client for Python — the supported reference
|
|
4
|
+
implementation. MAGP is language-agnostic by design: the gate verifies an Ed25519
|
|
5
|
+
signature over a canonical string, and nothing about that is JavaScript-specific. This
|
|
6
|
+
is the proof, and the shortest path for a Python team that doesn't want to read the
|
|
7
|
+
[Node guard](https://www.npmjs.com/package/@metamynd/agentsafe-guard) to find out what
|
|
8
|
+
the protocol actually expects.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install metamynd-client
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from metamynd_client import MetaMyndClient
|
|
16
|
+
|
|
17
|
+
client = MetaMyndClient.from_env() # reads METAMYND_API / AGENT_DID / AGENT_KEY
|
|
18
|
+
|
|
19
|
+
verdict = client.authorize(
|
|
20
|
+
"flight-purchase", 150,
|
|
21
|
+
merchant="skyward-air",
|
|
22
|
+
context={"tool": "book-flight", "riskLevel": "low"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if verdict.decision == "escalate":
|
|
26
|
+
# ESCALATE is a HOLD, not a denial — poll until resolved, act only on may_proceed.
|
|
27
|
+
state = client.escalation_status(verdict.escalation_id)
|
|
28
|
+
elif not verdict.permitted:
|
|
29
|
+
raise RuntimeError(f"refused: {verdict.reason_code}")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Guard a tool
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from metamynd_client import MetaMyndClient, guard_tool, GovernanceBlocked
|
|
36
|
+
|
|
37
|
+
client = MetaMyndClient.from_env()
|
|
38
|
+
|
|
39
|
+
def book_flight(amount, merchant):
|
|
40
|
+
... # your real implementation
|
|
41
|
+
|
|
42
|
+
governed_book_flight = guard_tool(
|
|
43
|
+
client, "flight-purchase", book_flight,
|
|
44
|
+
lambda amount, merchant: {"amount": amount, "merchant": merchant},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
governed_book_flight(amount=150, merchant="skyward-air")
|
|
49
|
+
except GovernanceBlocked as refused:
|
|
50
|
+
print(f"refused: {refused.verdict.reason_code}")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Works with any framework — LangGraph, the OpenAI Agents SDK, or a plain function call —
|
|
54
|
+
because the governed step is the same everywhere: ask the gate, act only on a permit. See
|
|
55
|
+
the [LangGraph guide](https://metamynd.ai/developers/langgraph) and the
|
|
56
|
+
[OpenAI Agents SDK guide](https://metamynd.ai/developers/openai-agents) for
|
|
57
|
+
framework-specific examples.
|
|
58
|
+
|
|
59
|
+
## Scope
|
|
60
|
+
|
|
61
|
+
This is the entry price, not a full SDK: it signs and submits authorize requests,
|
|
62
|
+
returns the verdict, wraps a tool (`guard_tool`), and follows up an `escalate`. Local
|
|
63
|
+
bundle evaluation, two-phase capture/void, and evidence inclusion-proof fetch all exist
|
|
64
|
+
in the protocol and in the [Node guard](https://www.npmjs.com/package/@metamynd/agentsafe-guard)
|
|
65
|
+
and are deliberately not reimplemented here — see
|
|
66
|
+
[the full comparison](https://metamynd.ai/developers/python).
|
|
67
|
+
|
|
68
|
+
## Self-test
|
|
69
|
+
|
|
70
|
+
Checks the three details that cost a Python integration a day each — key encoding,
|
|
71
|
+
number stringification, timestamp shape — without touching the network:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m metamynd_client --selftest
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Links
|
|
78
|
+
|
|
79
|
+
- [Full guide](https://metamynd.ai/developers/python)
|
|
80
|
+
- [Protocol spec](https://metamynd.ai/developers/spec)
|
|
81
|
+
- [Source](https://github.com/Metamynd/agentsafe-guard)
|
|
82
|
+
|
|
83
|
+
MIT licensed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metamynd-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MetaMynd/AgentSafe authorize-gate client for Python — the supported reference implementation of MAGP §7.3"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "MetaMynd" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["cryptography"]
|
|
15
|
+
keywords = ["metamynd", "agentsafe", "governance", "ai-agents", "magp", "authorization"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://metamynd.ai/developers/python"
|
|
29
|
+
Documentation = "https://metamynd.ai/developers/spec"
|
|
30
|
+
Source = "https://github.com/Metamynd/agentsafe-guard"
|
|
31
|
+
Issues = "https://github.com/Metamynd/agentsafe-guard/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
py-modules = ["metamynd_client"]
|
|
35
|
+
package-dir = { "" = "src" }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metamynd-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MetaMynd/AgentSafe authorize-gate client for Python — the supported reference implementation of MAGP §7.3
|
|
5
|
+
Author: MetaMynd
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://metamynd.ai/developers/python
|
|
8
|
+
Project-URL: Documentation, https://metamynd.ai/developers/spec
|
|
9
|
+
Project-URL: Source, https://github.com/Metamynd/agentsafe-guard
|
|
10
|
+
Project-URL: Issues, https://github.com/Metamynd/agentsafe-guard/issues
|
|
11
|
+
Keywords: metamynd,agentsafe,governance,ai-agents,magp,authorization
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: cryptography
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# metamynd-client
|
|
27
|
+
|
|
28
|
+
The MetaMynd/AgentSafe authorize-gate client for Python — the supported reference
|
|
29
|
+
implementation. MAGP is language-agnostic by design: the gate verifies an Ed25519
|
|
30
|
+
signature over a canonical string, and nothing about that is JavaScript-specific. This
|
|
31
|
+
is the proof, and the shortest path for a Python team that doesn't want to read the
|
|
32
|
+
[Node guard](https://www.npmjs.com/package/@metamynd/agentsafe-guard) to find out what
|
|
33
|
+
the protocol actually expects.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install metamynd-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from metamynd_client import MetaMyndClient
|
|
41
|
+
|
|
42
|
+
client = MetaMyndClient.from_env() # reads METAMYND_API / AGENT_DID / AGENT_KEY
|
|
43
|
+
|
|
44
|
+
verdict = client.authorize(
|
|
45
|
+
"flight-purchase", 150,
|
|
46
|
+
merchant="skyward-air",
|
|
47
|
+
context={"tool": "book-flight", "riskLevel": "low"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if verdict.decision == "escalate":
|
|
51
|
+
# ESCALATE is a HOLD, not a denial — poll until resolved, act only on may_proceed.
|
|
52
|
+
state = client.escalation_status(verdict.escalation_id)
|
|
53
|
+
elif not verdict.permitted:
|
|
54
|
+
raise RuntimeError(f"refused: {verdict.reason_code}")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Guard a tool
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from metamynd_client import MetaMyndClient, guard_tool, GovernanceBlocked
|
|
61
|
+
|
|
62
|
+
client = MetaMyndClient.from_env()
|
|
63
|
+
|
|
64
|
+
def book_flight(amount, merchant):
|
|
65
|
+
... # your real implementation
|
|
66
|
+
|
|
67
|
+
governed_book_flight = guard_tool(
|
|
68
|
+
client, "flight-purchase", book_flight,
|
|
69
|
+
lambda amount, merchant: {"amount": amount, "merchant": merchant},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
governed_book_flight(amount=150, merchant="skyward-air")
|
|
74
|
+
except GovernanceBlocked as refused:
|
|
75
|
+
print(f"refused: {refused.verdict.reason_code}")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Works with any framework — LangGraph, the OpenAI Agents SDK, or a plain function call —
|
|
79
|
+
because the governed step is the same everywhere: ask the gate, act only on a permit. See
|
|
80
|
+
the [LangGraph guide](https://metamynd.ai/developers/langgraph) and the
|
|
81
|
+
[OpenAI Agents SDK guide](https://metamynd.ai/developers/openai-agents) for
|
|
82
|
+
framework-specific examples.
|
|
83
|
+
|
|
84
|
+
## Scope
|
|
85
|
+
|
|
86
|
+
This is the entry price, not a full SDK: it signs and submits authorize requests,
|
|
87
|
+
returns the verdict, wraps a tool (`guard_tool`), and follows up an `escalate`. Local
|
|
88
|
+
bundle evaluation, two-phase capture/void, and evidence inclusion-proof fetch all exist
|
|
89
|
+
in the protocol and in the [Node guard](https://www.npmjs.com/package/@metamynd/agentsafe-guard)
|
|
90
|
+
and are deliberately not reimplemented here — see
|
|
91
|
+
[the full comparison](https://metamynd.ai/developers/python).
|
|
92
|
+
|
|
93
|
+
## Self-test
|
|
94
|
+
|
|
95
|
+
Checks the three details that cost a Python integration a day each — key encoding,
|
|
96
|
+
number stringification, timestamp shape — without touching the network:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m metamynd_client --selftest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Links
|
|
103
|
+
|
|
104
|
+
- [Full guide](https://metamynd.ai/developers/python)
|
|
105
|
+
- [Protocol spec](https://metamynd.ai/developers/spec)
|
|
106
|
+
- [Source](https://github.com/Metamynd/agentsafe-guard)
|
|
107
|
+
|
|
108
|
+
MIT licensed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/metamynd_client.py
|
|
5
|
+
src/metamynd_client.egg-info/PKG-INFO
|
|
6
|
+
src/metamynd_client.egg-info/SOURCES.txt
|
|
7
|
+
src/metamynd_client.egg-info/dependency_links.txt
|
|
8
|
+
src/metamynd_client.egg-info/requires.txt
|
|
9
|
+
src/metamynd_client.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metamynd_client
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MetaMynd authorize-gate client for Python — the supported reference implementation.
|
|
3
|
+
|
|
4
|
+
MAGP is language-agnostic by design: the gate verifies an Ed25519 signature over a
|
|
5
|
+
canonical string (spec §7.3), and nothing about that is JavaScript-specific. This file is
|
|
6
|
+
the proof, and the shortest path for a Python team that does not want to read the Node
|
|
7
|
+
guard to find out what the protocol actually expects.
|
|
8
|
+
|
|
9
|
+
Three details cost a Python integration a day each, because all three fail identically
|
|
10
|
+
and silently as SIGNATURE_INVALID — the request is well-formed, the key is correct, and
|
|
11
|
+
the bytes that were signed are simply not the bytes the gate reconstructs:
|
|
12
|
+
|
|
13
|
+
1. Private key encoding. The provisioning flow hands you a hex string that is DER
|
|
14
|
+
PKCS#8, not a raw 32-byte seed. `load_key` accepts either.
|
|
15
|
+
2. Number stringification. JavaScript's String(150.0) is "150"; Python's str(150.0) is
|
|
16
|
+
"150.0". Different message, different signature. `js_number_to_string` normalises.
|
|
17
|
+
3. Timestamp identity. issuedAt is signed as the literal string that goes on the wire,
|
|
18
|
+
so the two must be byte-identical. The format itself does not matter — isoformat()
|
|
19
|
+
verifies as happily as "...Z" does — but calling the clock twice, once for the
|
|
20
|
+
message and once for the body, does not. With microseconds that fails every time;
|
|
21
|
+
at second precision it fails only when a second ticks between the two calls, which
|
|
22
|
+
is an intermittent SIGNATURE_INVALID under load. `authorize` formats once.
|
|
23
|
+
|
|
24
|
+
All three are now written down in MAGP §7.3.3-§7.3.5, and the gate returns a `hint` on a
|
|
25
|
+
SIGNATURE_INVALID verdict naming them as the usual causes.
|
|
26
|
+
|
|
27
|
+
SCOPE. This is the entry price, not an SDK: it signs and submits authorize requests,
|
|
28
|
+
returns the verdict, and follows up an ESCALATE. Local bundle evaluation, tool wrapping,
|
|
29
|
+
capture/void and evidence-proof fetch all exist in the protocol and in the Node guard
|
|
30
|
+
(`integrations/agentsafe-guard`) and are deliberately not reimplemented here.
|
|
31
|
+
|
|
32
|
+
Requires: Python 3.9+ and `cryptography` (pip install cryptography).
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
|
|
36
|
+
python metamynd_client.py --selftest # offline: checks the three details
|
|
37
|
+
|
|
38
|
+
export METAMYND_API=https://metamynd.ai/api/v1
|
|
39
|
+
export AGENT_DID=did:hedera:testnet:...
|
|
40
|
+
export AGENT_KEY=302e020100... # as issued
|
|
41
|
+
python metamynd_client.py # runs the demo below
|
|
42
|
+
|
|
43
|
+
or as a library:
|
|
44
|
+
|
|
45
|
+
from metamynd_client import MetaMyndClient
|
|
46
|
+
client = MetaMyndClient.from_env()
|
|
47
|
+
verdict = client.authorize("flight-purchase", 150, merchant="skyward-air",
|
|
48
|
+
context={"tool": "book-flight", "riskLevel": "low"})
|
|
49
|
+
if verdict.decision == "escalate":
|
|
50
|
+
state = client.escalation_status(verdict.escalation_id)
|
|
51
|
+
# Poll until state.resolved; act only on state.may_proceed. Never self-approve.
|
|
52
|
+
elif not verdict.permitted:
|
|
53
|
+
raise RuntimeError(f"refused: {verdict.reason_code}")
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import functools
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import secrets
|
|
62
|
+
import sys
|
|
63
|
+
import urllib.error
|
|
64
|
+
import urllib.parse
|
|
65
|
+
import urllib.request
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
from datetime import datetime, timezone
|
|
68
|
+
from typing import Any, Mapping, Optional, Union
|
|
69
|
+
|
|
70
|
+
from cryptography.hazmat.primitives import serialization
|
|
71
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"MetaMyndClient",
|
|
75
|
+
"Verdict",
|
|
76
|
+
"EscalationStatus",
|
|
77
|
+
"load_key",
|
|
78
|
+
"js_number_to_string",
|
|
79
|
+
"utc_now_rfc3339",
|
|
80
|
+
"canonical_message",
|
|
81
|
+
"guard_tool",
|
|
82
|
+
"GovernanceBlocked",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
DEFAULT_API = "http://localhost:9926/api/v1"
|
|
86
|
+
|
|
87
|
+
# Identify the client explicitly. urllib otherwise sends "Python-urllib/3.x", which the
|
|
88
|
+
# CDN in front of the production gate blocks outright: every call comes back
|
|
89
|
+
#
|
|
90
|
+
# HTTP 403 error code: 1010
|
|
91
|
+
#
|
|
92
|
+
# and 403 is also how the gate returns an ordinary refusal, so the failure reads like a
|
|
93
|
+
# governance decision rather than a request that never arrived. Nothing in that error
|
|
94
|
+
# mentions user agents. Verified against metamynd.ai — curl, python-requests and a bare
|
|
95
|
+
# request all pass; "Python-urllib/*" is refused by name.
|
|
96
|
+
USER_AGENT = "metamynd-python-client/1.0 (+https://metamynd.ai/developers/python)"
|
|
97
|
+
|
|
98
|
+
# Only these two permit execution. Everything else — block, escalate, suspend, quarantine
|
|
99
|
+
# — must stop the action. `escalate` in particular is a HOLD, not a denial: a human owner
|
|
100
|
+
# decides, and the agent must never treat it as an allow.
|
|
101
|
+
PERMITTING_DECISIONS = frozenset({"allow", "observe"})
|
|
102
|
+
|
|
103
|
+
Number = Union[int, float]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------------------
|
|
107
|
+
# The three details
|
|
108
|
+
# --------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_key(raw: str) -> Ed25519PrivateKey:
|
|
112
|
+
"""Load an agent private key from either encoding MetaMynd hands out.
|
|
113
|
+
|
|
114
|
+
Detail 1 of 3 (MAGP §7.3.3). The managed provisioning path returns a hex string that
|
|
115
|
+
is DER PKCS#8 — Hedera's format, recognisable by the ASN.1 SEQUENCE tag `30` and the
|
|
116
|
+
Ed25519 OID that follows (`302e020100300506032b657004220420...`). A BYOK agent that
|
|
117
|
+
generated its own key may instead hold the raw 32-byte seed as 64 hex characters.
|
|
118
|
+
Feeding raw bytes to a DER loader raises; feeding DER bytes to a raw loader raises on
|
|
119
|
+
the length — so detect rather than guess.
|
|
120
|
+
"""
|
|
121
|
+
hex_str = raw.strip()
|
|
122
|
+
if hex_str.startswith(("0x", "0X")):
|
|
123
|
+
hex_str = hex_str[2:]
|
|
124
|
+
try:
|
|
125
|
+
key_bytes = bytes.fromhex(hex_str)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise ValueError("agent key must be hex (DER PKCS#8, or a raw 32-byte seed)") from exc
|
|
128
|
+
|
|
129
|
+
if len(key_bytes) == 32:
|
|
130
|
+
return Ed25519PrivateKey.from_private_bytes(key_bytes)
|
|
131
|
+
|
|
132
|
+
loaded = serialization.load_der_private_key(key_bytes, password=None)
|
|
133
|
+
if not isinstance(loaded, Ed25519PrivateKey):
|
|
134
|
+
raise ValueError(f"expected an Ed25519 key, got {type(loaded).__name__}")
|
|
135
|
+
return loaded
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def js_number_to_string(value: Number) -> str:
|
|
139
|
+
"""Render a number the way JavaScript's String() would.
|
|
140
|
+
|
|
141
|
+
Detail 2 of 3 (MAGP §7.3.4). The canonical message is a string join, so the amount has
|
|
142
|
+
to become text — and the two languages disagree on how. JS String(150.0) is "150";
|
|
143
|
+
Python str(150.0) is "150.0". One extra character, an entirely different signature,
|
|
144
|
+
and a SIGNATURE_INVALID with nothing in it to suggest the amount was the problem.
|
|
145
|
+
|
|
146
|
+
Integral floats collapse to their integer form, which is the whole of the disagreement
|
|
147
|
+
for money. Beyond ~1e21 JavaScript switches to exponential notation with its own
|
|
148
|
+
spelling; amounts that large are not a real case, and this raises rather than sign
|
|
149
|
+
bytes it cannot promise the gate will reconstruct.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(value, bool): # bool is an int subclass; a boolean amount is a bug
|
|
152
|
+
raise TypeError("amount must be a number, not a bool")
|
|
153
|
+
if isinstance(value, int):
|
|
154
|
+
return str(value)
|
|
155
|
+
|
|
156
|
+
as_float = float(value)
|
|
157
|
+
if as_float != as_float or as_float in (float("inf"), float("-inf")):
|
|
158
|
+
raise ValueError("amount must be finite")
|
|
159
|
+
if abs(as_float) >= 1e21:
|
|
160
|
+
raise ValueError("amounts >= 1e21 stringify differently in JS; send a smaller value")
|
|
161
|
+
if as_float.is_integer():
|
|
162
|
+
return str(int(as_float))
|
|
163
|
+
# repr() on a Python float is the shortest string that round-trips, which is the same
|
|
164
|
+
# rule V8 applies — so ordinary decimal amounts agree byte for byte.
|
|
165
|
+
return repr(as_float)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def utc_now_rfc3339() -> str:
|
|
169
|
+
"""Timestamp in the shape the spec's examples use (MAGP §7.3.5).
|
|
170
|
+
|
|
171
|
+
Detail 3 of 3, and the one most often misdescribed. The gate parses whatever
|
|
172
|
+
RFC 3339 string it receives, so "+00:00" and microseconds verify perfectly well —
|
|
173
|
+
what it cannot tolerate is a DIFFERENT string in the signed message than in the body.
|
|
174
|
+
Second precision is chosen here because it is what the spec's examples show and it
|
|
175
|
+
reads cleanly in an evidence record, not because the gate demands it.
|
|
176
|
+
|
|
177
|
+
The rule that actually matters lives at the call site: format once, use the result in
|
|
178
|
+
both places. Calling this function twice for one request is the bug.
|
|
179
|
+
"""
|
|
180
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def canonical_message(
|
|
184
|
+
agent_did: str,
|
|
185
|
+
action: str,
|
|
186
|
+
amount: Number,
|
|
187
|
+
currency: str,
|
|
188
|
+
merchant: Optional[str],
|
|
189
|
+
nonce: str,
|
|
190
|
+
issued_at: str,
|
|
191
|
+
) -> str:
|
|
192
|
+
"""The UTF-8 string that gets signed (MAGP §7.3.1).
|
|
193
|
+
|
|
194
|
+
Seven fields, "|"-delimited, in this order, with the empty string for an absent
|
|
195
|
+
merchant. The verifier reconstructs it from the fields it received rather than
|
|
196
|
+
trusting a client-sent message (§7.3.2), which is why every field below must be sent
|
|
197
|
+
exactly as it was signed.
|
|
198
|
+
"""
|
|
199
|
+
return "|".join(
|
|
200
|
+
[
|
|
201
|
+
agent_did,
|
|
202
|
+
action,
|
|
203
|
+
js_number_to_string(amount),
|
|
204
|
+
currency,
|
|
205
|
+
merchant or "",
|
|
206
|
+
nonce,
|
|
207
|
+
issued_at,
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------------------
|
|
213
|
+
# Verdict
|
|
214
|
+
# --------------------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class Verdict:
|
|
219
|
+
"""The gate's answer. `raw` keeps everything, including fields added after this file."""
|
|
220
|
+
|
|
221
|
+
decision: str
|
|
222
|
+
reason_code: str
|
|
223
|
+
authorization_id: Optional[str] = None
|
|
224
|
+
escalation_id: Optional[str] = None
|
|
225
|
+
hint: Optional[str] = None
|
|
226
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def permitted(self) -> bool:
|
|
230
|
+
"""True only for the dispositions that permit execution. Fail closed on anything else."""
|
|
231
|
+
return self.decision in PERMITTING_DECISIONS
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def from_response(cls, body: Mapping[str, Any]) -> "Verdict":
|
|
235
|
+
data = body.get("data") or {}
|
|
236
|
+
return cls(
|
|
237
|
+
decision=str(data.get("decision", "block")),
|
|
238
|
+
reason_code=str(data.get("reasonCode", "UNKNOWN")),
|
|
239
|
+
authorization_id=data.get("authorizationId"),
|
|
240
|
+
# Set on an escalate verdict (§9a.1) — the handle for escalation_status().
|
|
241
|
+
escalation_id=data.get("escalationId"),
|
|
242
|
+
# Diagnostic only, and present only on codes that have one — a
|
|
243
|
+
# SIGNATURE_INVALID says which of the three details is the usual cause.
|
|
244
|
+
hint=data.get("hint"),
|
|
245
|
+
raw=data,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True)
|
|
250
|
+
class EscalationStatus:
|
|
251
|
+
"""Where a held action has got to."""
|
|
252
|
+
|
|
253
|
+
status: str # pending | approved | denied | expired
|
|
254
|
+
reason_code: str
|
|
255
|
+
authorization_id: Optional[str] = None
|
|
256
|
+
expires_at: Optional[str] = None
|
|
257
|
+
approvals: int = 0
|
|
258
|
+
required: int = 1
|
|
259
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def resolved(self) -> bool:
|
|
263
|
+
"""True once a human has decided, either way — the signal to stop polling."""
|
|
264
|
+
return self.status in {"approved", "denied", "expired"}
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def may_proceed(self) -> bool:
|
|
268
|
+
"""The ONLY condition under which the held action may run."""
|
|
269
|
+
return self.status == "approved" and bool(self.authorization_id)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------------------------------
|
|
273
|
+
# Client
|
|
274
|
+
# --------------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class MetaMyndClient:
|
|
278
|
+
"""Signs and submits authorize requests to the MetaMynd gate."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, api: str, agent_did: str, agent_key: str, timeout: float = 15.0):
|
|
281
|
+
self.api = api.rstrip("/")
|
|
282
|
+
self.agent_did = agent_did
|
|
283
|
+
self.timeout = timeout
|
|
284
|
+
self._key = load_key(agent_key)
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def from_env(cls) -> "MetaMyndClient":
|
|
288
|
+
"""Build from METAMYND_API / AGENT_DID / AGENT_KEY."""
|
|
289
|
+
agent_did = os.environ.get("AGENT_DID", "")
|
|
290
|
+
agent_key = os.environ.get("AGENT_KEY", "")
|
|
291
|
+
if not agent_did or not agent_key:
|
|
292
|
+
raise RuntimeError("set AGENT_DID and AGENT_KEY (see the provisioning docs)")
|
|
293
|
+
return cls(os.environ.get("METAMYND_API", DEFAULT_API), agent_did, agent_key)
|
|
294
|
+
|
|
295
|
+
def authorize(
|
|
296
|
+
self,
|
|
297
|
+
action: str,
|
|
298
|
+
amount: Number,
|
|
299
|
+
currency: str = "USD",
|
|
300
|
+
merchant: Optional[str] = None,
|
|
301
|
+
context: Optional[Mapping[str, Any]] = None,
|
|
302
|
+
) -> Verdict:
|
|
303
|
+
"""Ask the gate whether this action may proceed (MAGP §7.1-§7.3).
|
|
304
|
+
|
|
305
|
+
`context` carries the request context the Standard/SOP rules read — `tool`,
|
|
306
|
+
`jurisdiction`, `riskLevel` and so on. Which fields your assigned rules need is
|
|
307
|
+
discoverable: each atom at GET /standards/atoms lists its `requiredContext`.
|
|
308
|
+
"""
|
|
309
|
+
nonce = secrets.token_hex(16) # §7.2 — 8-128 chars, single use
|
|
310
|
+
# Formatted ONCE and reused in both the signed message and the body (§7.3.5).
|
|
311
|
+
# Two calls to the clock is the intermittent-SIGNATURE_INVALID bug.
|
|
312
|
+
issued_at = utc_now_rfc3339()
|
|
313
|
+
message = canonical_message(self.agent_did, action, amount, currency, merchant, nonce, issued_at)
|
|
314
|
+
|
|
315
|
+
body: dict[str, Any] = {
|
|
316
|
+
"agentDid": self.agent_did,
|
|
317
|
+
"action": action,
|
|
318
|
+
# The amount is signed as text but sent as a number; the gate stringifies the
|
|
319
|
+
# received value the same way to rebuild the message.
|
|
320
|
+
"amount": amount,
|
|
321
|
+
"currency": currency,
|
|
322
|
+
"nonce": nonce,
|
|
323
|
+
"issuedAt": issued_at,
|
|
324
|
+
"signature": self._key.sign(message.encode("utf-8")).hex(),
|
|
325
|
+
}
|
|
326
|
+
if merchant:
|
|
327
|
+
body["merchant"] = merchant
|
|
328
|
+
if context:
|
|
329
|
+
body["itinerary"] = dict(context)
|
|
330
|
+
|
|
331
|
+
return Verdict.from_response(self._post("/policy/mandate/authorize", body))
|
|
332
|
+
|
|
333
|
+
def escalation_status(self, escalation_id: str) -> EscalationStatus:
|
|
334
|
+
"""Follow up a held action (MAGP §9a).
|
|
335
|
+
|
|
336
|
+
ESCALATE is not a denial — the action is PARKED for a human owner, and roughly a
|
|
337
|
+
third of what the demo below prints ends here. The endpoint is unauthenticated and
|
|
338
|
+
keyed by the escalation id, which is itself the capability: an agent has no user
|
|
339
|
+
token and needs none to ask about its own held action.
|
|
340
|
+
|
|
341
|
+
Poll until `resolved`, then act only on `may_proceed`. Never self-approve, and
|
|
342
|
+
never treat `pending` as permission to continue — a hold that has not been decided
|
|
343
|
+
is a hold.
|
|
344
|
+
"""
|
|
345
|
+
request = urllib.request.Request(
|
|
346
|
+
f"{self.api}/policy/escalations/{urllib.parse.quote(escalation_id)}/status",
|
|
347
|
+
headers={"User-Agent": USER_AGENT},
|
|
348
|
+
method="GET",
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
352
|
+
data = (json.loads(response.read()) or {}).get("data") or {}
|
|
353
|
+
except urllib.error.HTTPError as exc:
|
|
354
|
+
payload = exc.read()
|
|
355
|
+
try:
|
|
356
|
+
data = (json.loads(payload) or {}).get("data") or {}
|
|
357
|
+
except json.JSONDecodeError:
|
|
358
|
+
if exc.code == 403 and b"1010" in payload:
|
|
359
|
+
raise RuntimeError(
|
|
360
|
+
"gate returned HTTP 403 (CDN error 1010) — the request was refused "
|
|
361
|
+
"before it reached the gate, not by it. Something replaced the "
|
|
362
|
+
f"User-Agent: expected {USER_AGENT!r}."
|
|
363
|
+
) from exc
|
|
364
|
+
raise RuntimeError(f"gate returned HTTP {exc.code}: {payload[:200]!r}") from exc
|
|
365
|
+
except urllib.error.URLError as exc:
|
|
366
|
+
# Same rule as authorize: an unreachable gate is a hold, never a release.
|
|
367
|
+
raise RuntimeError(f"gate unreachable ({exc.reason}) — the hold stands") from exc
|
|
368
|
+
|
|
369
|
+
return EscalationStatus(
|
|
370
|
+
status=str(data.get("status", "unknown")),
|
|
371
|
+
reason_code=str(data.get("reasonCode", "UNKNOWN")),
|
|
372
|
+
authorization_id=data.get("authorizationId"),
|
|
373
|
+
expires_at=data.get("expiresAt"),
|
|
374
|
+
approvals=int(data.get("approvals") or 0),
|
|
375
|
+
required=int(data.get("required") or 1),
|
|
376
|
+
raw=data,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _post(self, path: str, body: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
380
|
+
request = urllib.request.Request(
|
|
381
|
+
f"{self.api}{path}",
|
|
382
|
+
data=json.dumps(body).encode("utf-8"),
|
|
383
|
+
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
|
384
|
+
method="POST",
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
388
|
+
return json.loads(response.read())
|
|
389
|
+
except urllib.error.HTTPError as exc:
|
|
390
|
+
# A refusal is a 403 carrying the verdict, so an HTTP error here is usually a
|
|
391
|
+
# normal governance outcome rather than a transport failure.
|
|
392
|
+
payload = exc.read()
|
|
393
|
+
try:
|
|
394
|
+
return json.loads(payload)
|
|
395
|
+
except json.JSONDecodeError:
|
|
396
|
+
raise RuntimeError(f"gate returned HTTP {exc.code}: {payload[:200]!r}") from exc
|
|
397
|
+
except urllib.error.URLError as exc:
|
|
398
|
+
# Rule 5 of the runtime contract: unreachable gate means BLOCK. Raising is how
|
|
399
|
+
# a caller that follows the contract fails closed.
|
|
400
|
+
raise RuntimeError(f"gate unreachable ({exc.reason}) — treat as block") from exc
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class GovernanceBlocked(RuntimeError):
|
|
404
|
+
"""Raised instead of running a tool the gate did not permit."""
|
|
405
|
+
|
|
406
|
+
def __init__(self, verdict: Verdict, action: str):
|
|
407
|
+
super().__init__(f"{action} refused: {verdict.decision}/{verdict.reason_code}")
|
|
408
|
+
self.verdict = verdict
|
|
409
|
+
self.action = action
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def guard_tool(
|
|
413
|
+
client: "MetaMyndClient",
|
|
414
|
+
action: str,
|
|
415
|
+
fn: "Any",
|
|
416
|
+
map_args: "Any" = None,
|
|
417
|
+
) -> "Any":
|
|
418
|
+
"""Wrap a callable so it runs ONLY on a permit.
|
|
419
|
+
|
|
420
|
+
The Node guard's `guardTool()` has always had this; Python did not, and its absence was
|
|
421
|
+
the real gap. A client that only returns verdicts leaves every caller to remember to
|
|
422
|
+
check one — and the failure mode of forgetting is that the tool runs anyway, which is
|
|
423
|
+
the exact thing this product exists to prevent. Wrapping inverts that: the unguarded
|
|
424
|
+
path stops being the easy one.
|
|
425
|
+
|
|
426
|
+
`map_args` turns your tool's arguments into the gate's inputs
|
|
427
|
+
(`amount`, `merchant`, `context`). Without it the call is authorized with no amount,
|
|
428
|
+
which is right for a tool that moves no money and wrong for one that does — so pass it
|
|
429
|
+
whenever there is a value at stake.
|
|
430
|
+
|
|
431
|
+
book = guard_tool(client, "flight-purchase", raw_book,
|
|
432
|
+
lambda a: {"amount": a["amount"], "merchant": a["vendor"]})
|
|
433
|
+
|
|
434
|
+
Raises GovernanceBlocked on anything that is not a permit, including an escalate: a
|
|
435
|
+
held action has not happened yet, and returning normally would tell the caller it had.
|
|
436
|
+
|
|
437
|
+
functools.wraps here is load-bearing, not tidiness. Frameworks that build a tool schema
|
|
438
|
+
from your function read `inspect.signature` and `typing.get_type_hints`; a wrapper that
|
|
439
|
+
copies only __name__ and __doc__ presents itself as `(*args, **kwargs)` with no hints at
|
|
440
|
+
all. The OpenAI Agents SDK then refuses the tool with
|
|
441
|
+
|
|
442
|
+
UserError: additionalProperties should not be set for object types.
|
|
443
|
+
This could be because you're using an older version of Pydantic
|
|
444
|
+
|
|
445
|
+
which sends you to your dependency versions, where the problem is not. `wraps` sets
|
|
446
|
+
__wrapped__ and copies __annotations__, so the schema comes out identical to the
|
|
447
|
+
unguarded function and the wrapper is invisible to the framework — which is the only
|
|
448
|
+
way "wrap the tool" can be advice we give people.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
@functools.wraps(fn)
|
|
452
|
+
def governed(*args: "Any", **kwargs: "Any") -> "Any":
|
|
453
|
+
payload = map_args(*args, **kwargs) if map_args else {}
|
|
454
|
+
verdict = client.authorize(
|
|
455
|
+
action,
|
|
456
|
+
payload.get("amount", 0),
|
|
457
|
+
merchant=payload.get("merchant", ""),
|
|
458
|
+
context=payload.get("context", {}),
|
|
459
|
+
)
|
|
460
|
+
if not verdict.permitted:
|
|
461
|
+
# Fail closed, loudly, and before the tool is touched.
|
|
462
|
+
raise GovernanceBlocked(verdict, action)
|
|
463
|
+
return fn(*args, **kwargs)
|
|
464
|
+
|
|
465
|
+
return governed
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# --------------------------------------------------------------------------------------
|
|
469
|
+
# Demo
|
|
470
|
+
# --------------------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _show(label: str, verdict: Verdict) -> None:
|
|
474
|
+
mark = {"allow": "OK ", "observe": "OBS ", "block": "DENY", "escalate": "HOLD"}.get(verdict.decision, "?? ")
|
|
475
|
+
print(f" {mark} {verdict.decision.upper():<9} {verdict.reason_code:<24} {label}")
|
|
476
|
+
if verdict.hint:
|
|
477
|
+
print(f" hint: {verdict.hint}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _demo() -> None:
|
|
481
|
+
client = MetaMyndClient.from_env()
|
|
482
|
+
print(f"\nMetaMynd gate, from Python — agent {client.agent_did[:38]}…\n")
|
|
483
|
+
|
|
484
|
+
flight = {"tool": "book-flight", "jurisdiction": "SG", "riskLevel": "low"}
|
|
485
|
+
_show("$150 flight, low risk", client.authorize("flight-purchase", 150, merchant="skyward-air", context=flight))
|
|
486
|
+
_show("$600 flight, over the SOP cap", client.authorize("flight-purchase", 600, merchant="skyward-air", context=flight))
|
|
487
|
+
held = client.authorize("flight-purchase", 150, merchant="skyward-air", context={**flight, "riskLevel": "high"})
|
|
488
|
+
_show("$150 flight, high risk", held)
|
|
489
|
+
# ESCALATE is a HOLD, not a denial. Follow it up rather than stopping here — this is
|
|
490
|
+
# the one verdict a naive integration mishandles, usually by treating it as failure.
|
|
491
|
+
if held.escalation_id:
|
|
492
|
+
state = client.escalation_status(held.escalation_id)
|
|
493
|
+
print(f" held for review: {state.status} ({state.approvals}/{state.required} approvals)"
|
|
494
|
+
f"{' — may proceed' if state.may_proceed else ''}")
|
|
495
|
+
_show(
|
|
496
|
+
"$150 flight, unapproved tool",
|
|
497
|
+
client.authorize("flight-purchase", 150, merchant="skyward-air", context={**flight, "tool": "wire-transfer"}),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Tamper check: sign one amount, send another. The gate reconstructs the message from
|
|
501
|
+
# what it received (§7.3.2), so the signature cannot cover the amount that arrived.
|
|
502
|
+
print()
|
|
503
|
+
nonce = secrets.token_hex(16)
|
|
504
|
+
issued_at = utc_now_rfc3339()
|
|
505
|
+
signed_for_50 = canonical_message(client.agent_did, "flight-purchase", 50, "USD", "skyward-air", nonce, issued_at)
|
|
506
|
+
forged = {
|
|
507
|
+
"agentDid": client.agent_did,
|
|
508
|
+
"action": "flight-purchase",
|
|
509
|
+
"amount": 5000,
|
|
510
|
+
"currency": "USD",
|
|
511
|
+
"merchant": "skyward-air",
|
|
512
|
+
"nonce": nonce,
|
|
513
|
+
"issuedAt": issued_at,
|
|
514
|
+
"signature": client._key.sign(signed_for_50.encode("utf-8")).hex(),
|
|
515
|
+
}
|
|
516
|
+
_show("signed $50, sent $5000", Verdict.from_response(client._post("/policy/mandate/authorize", forged)))
|
|
517
|
+
print()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _selftest() -> None:
|
|
521
|
+
"""Check the three details without touching the network.
|
|
522
|
+
|
|
523
|
+
A reference implementation that can only be exercised against a running gate with a
|
|
524
|
+
provisioned agent is one nobody checks before copying. These assertions cover exactly
|
|
525
|
+
the parts that are easy to get wrong and impossible to debug from the verdict.
|
|
526
|
+
"""
|
|
527
|
+
from cryptography.hazmat.primitives import serialization as _ser
|
|
528
|
+
|
|
529
|
+
# §7.3.3 — both encodings load, and to the same key.
|
|
530
|
+
der = (
|
|
531
|
+
"302e020100300506032b657004220420"
|
|
532
|
+
"1a89c8f14ad24e63f616a4e0b9270f866ec19e2f7436c68bd3e168b594f09837"
|
|
533
|
+
)
|
|
534
|
+
key = load_key(der)
|
|
535
|
+
seed = key.private_bytes(_ser.Encoding.Raw, _ser.PrivateFormat.Raw, _ser.NoEncryption()).hex()
|
|
536
|
+
assert load_key(seed).sign(b"x") == key.sign(b"x"), "raw seed must load to the same key as DER"
|
|
537
|
+
assert load_key("0x" + der).sign(b"x") == key.sign(b"x"), "0x prefix must be tolerated"
|
|
538
|
+
|
|
539
|
+
# §7.3.4 — parity with ECMAScript String().
|
|
540
|
+
for value, expected in [
|
|
541
|
+
(150, "150"),
|
|
542
|
+
(150.0, "150"), # the whole trap, in one line
|
|
543
|
+
(142.3, "142.3"),
|
|
544
|
+
(0.1, "0.1"),
|
|
545
|
+
(-5.5, "-5.5"),
|
|
546
|
+
(0, "0"),
|
|
547
|
+
(1e20, "100000000000000000000"),
|
|
548
|
+
]:
|
|
549
|
+
got = js_number_to_string(value)
|
|
550
|
+
assert got == expected, f"String({value!r}) should be {expected!r}, got {got!r}"
|
|
551
|
+
for bad in (float("nan"), float("inf"), 1e21):
|
|
552
|
+
try:
|
|
553
|
+
js_number_to_string(bad)
|
|
554
|
+
raise AssertionError(f"{bad!r} should have been rejected")
|
|
555
|
+
except ValueError:
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
# §7.3.5 — second precision, Z designator, no offset and no fractional part.
|
|
559
|
+
stamp = utc_now_rfc3339()
|
|
560
|
+
assert stamp.endswith("Z") and "+" not in stamp and "." not in stamp, stamp
|
|
561
|
+
assert len(stamp) == 20, stamp
|
|
562
|
+
|
|
563
|
+
# §7.3.1 — seven fields, "|"-delimited, empty string for an absent merchant.
|
|
564
|
+
msg = canonical_message("did:x", "act", 150.0, "USD", None, "n0nce", stamp)
|
|
565
|
+
assert msg == f"did:x|act|150|USD||n0nce|{stamp}", msg
|
|
566
|
+
|
|
567
|
+
# guard_tool must be invisible to a framework building a tool schema. This is not a
|
|
568
|
+
# style assertion: without it the OpenAI Agents SDK rejects the tool and blames Pydantic.
|
|
569
|
+
import inspect as _inspect
|
|
570
|
+
import typing as _typing
|
|
571
|
+
|
|
572
|
+
def _sample(vendor: str, amount: float, items: str) -> dict:
|
|
573
|
+
"""A tool with a real signature."""
|
|
574
|
+
return {}
|
|
575
|
+
|
|
576
|
+
_guarded = guard_tool(None, "x", _sample) # never called, so the client may be None
|
|
577
|
+
assert str(_inspect.signature(_guarded)) == str(_inspect.signature(_sample)), _inspect.signature(_guarded)
|
|
578
|
+
assert _typing.get_type_hints(_guarded) == _typing.get_type_hints(_sample)
|
|
579
|
+
assert _guarded.__name__ == "_sample" and _guarded.__doc__ == _sample.__doc__
|
|
580
|
+
|
|
581
|
+
print("selftest ok — key encodings, number stringification, timestamp shape, canonical join, tool signature")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
if __name__ == "__main__":
|
|
585
|
+
if "--selftest" in sys.argv:
|
|
586
|
+
_selftest()
|
|
587
|
+
else:
|
|
588
|
+
_demo()
|