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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()