zerynex-python 0.2.0__py3-none-any.whl
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.
- zerynex_py/__init__.py +21 -0
- zerynex_py/agent_workflows.py +125 -0
- zerynex_py/client.py +406 -0
- zerynex_py/fixtures/clean-v6.bin +0 -0
- zerynex_py/fixtures/refund-isolated.json +21 -0
- zerynex_py/fixtures/refund-regression-expanded.json +29 -0
- zerynex_py/fixtures/refund-risky.json +21 -0
- zerynex_py/fixtures/rule-catalog.json +95 -0
- zerynex_py/fixtures/transparent-v6.bin +0 -0
- zerynex_py/fixtures/zallet-pczt-lifecycle-contract.json +87 -0
- zerynex_py/fixtures/zallet-pczt-lifecycle-synthetic-openrpc.json +127 -0
- zerynex_py/mcp_doctor.py +152 -0
- zerynex_py/mcp_server.py +391 -0
- zerynex_py/node.py +189 -0
- zerynex_py/preflight.py +42 -0
- zerynex_py/project.py +313 -0
- zerynex_py/refund_privacy.py +252 -0
- zerynex_py/refund_workflows.py +118 -0
- zerynex_py/rpc_contract.py +172 -0
- zerynex_py/rules.py +55 -0
- zerynex_py/z3.py +107 -0
- zerynex_py/zallet_fixture.py +158 -0
- zerynex_python-0.2.0.dist-info/METADATA +287 -0
- zerynex_python-0.2.0.dist-info/RECORD +28 -0
- zerynex_python-0.2.0.dist-info/WHEEL +5 -0
- zerynex_python-0.2.0.dist-info/entry_points.txt +10 -0
- zerynex_python-0.2.0.dist-info/licenses/LICENSE +21 -0
- zerynex_python-0.2.0.dist-info/top_level.txt +1 -0
zerynex_py/mcp_server.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Local-only, read-only MCP adapter for *approved synthetic fixtures*.
|
|
2
|
+
|
|
3
|
+
No raw PCZT bytes, passwords, mnemonic phrases or viewing keys are returned to
|
|
4
|
+
the agent. No transaction creation, signing, network API, or generic host file
|
|
5
|
+
reader is exposed.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from .client import Policy, ZerynexClient, ZerynexError
|
|
14
|
+
|
|
15
|
+
SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,79}\.bin$", re.ASCII)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def allowed_fixture(root: Path, fixture_name: str) -> Path:
|
|
19
|
+
"""Permit a single simple .bin filename under an explicitly selected root."""
|
|
20
|
+
if not isinstance(fixture_name, str) or not SAFE_NAME.fullmatch(fixture_name):
|
|
21
|
+
raise ZerynexError("Only an approved synthetic fixture filename is allowed.")
|
|
22
|
+
if not root.is_dir():
|
|
23
|
+
raise ZerynexError("The configured synthetic fixture directory is unavailable.")
|
|
24
|
+
base = root.expanduser().resolve(strict=True)
|
|
25
|
+
candidate = base / fixture_name
|
|
26
|
+
if candidate.is_symlink():
|
|
27
|
+
raise ZerynexError("Symlink fixtures are not allowed.")
|
|
28
|
+
try:
|
|
29
|
+
checked = candidate.resolve(strict=True)
|
|
30
|
+
except OSError:
|
|
31
|
+
raise ZerynexError("The selected fixture is unavailable.") from None
|
|
32
|
+
if not checked.is_relative_to(base) or not checked.is_file():
|
|
33
|
+
raise ZerynexError("The selected fixture is outside the allowed directory.")
|
|
34
|
+
return checked
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def scan_fixture(
|
|
38
|
+
client: ZerynexClient,
|
|
39
|
+
root: Path,
|
|
40
|
+
fixture_name: str,
|
|
41
|
+
profile: str = "external_signer",
|
|
42
|
+
deny_transparent: bool = True,
|
|
43
|
+
deny_sapling: bool = False,
|
|
44
|
+
) -> dict[str, object]:
|
|
45
|
+
"""Pure handler used by MCP and unit tests; no LLM or network dependency."""
|
|
46
|
+
if profile not in ("external_signer", "restricted_handoff"):
|
|
47
|
+
raise ZerynexError("Unsupported privacy profile.")
|
|
48
|
+
if type(deny_transparent) is not bool or type(deny_sapling) is not bool:
|
|
49
|
+
raise ZerynexError("Privacy policy flags must be booleans.")
|
|
50
|
+
safe = allowed_fixture(root, fixture_name)
|
|
51
|
+
policy = Policy(
|
|
52
|
+
profile=profile,
|
|
53
|
+
deny_transparent=deny_transparent,
|
|
54
|
+
deny_sapling=deny_sapling,
|
|
55
|
+
)
|
|
56
|
+
report = client.analyze_file(safe, policy)
|
|
57
|
+
return report.agent_summary()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compare_synthetic_fixtures(
|
|
61
|
+
client: ZerynexClient,
|
|
62
|
+
root: Path,
|
|
63
|
+
baseline_name: str,
|
|
64
|
+
candidate_name: str,
|
|
65
|
+
profile: str = "external_signer",
|
|
66
|
+
deny_transparent: bool = True,
|
|
67
|
+
deny_sapling: bool = False,
|
|
68
|
+
) -> dict[str, object]:
|
|
69
|
+
"""Compare only two explicitly approved synthetic PCZT fixture basenames."""
|
|
70
|
+
if profile not in ("external_signer", "restricted_handoff"):
|
|
71
|
+
raise ZerynexError("Unsupported privacy profile.")
|
|
72
|
+
if type(deny_transparent) is not bool or type(deny_sapling) is not bool:
|
|
73
|
+
raise ZerynexError("Privacy policy flags must be booleans.")
|
|
74
|
+
baseline = allowed_fixture(root, baseline_name)
|
|
75
|
+
candidate = allowed_fixture(root, candidate_name)
|
|
76
|
+
report = client.compare_files(
|
|
77
|
+
baseline,
|
|
78
|
+
candidate,
|
|
79
|
+
Policy(
|
|
80
|
+
profile=profile,
|
|
81
|
+
deny_transparent=deny_transparent,
|
|
82
|
+
deny_sapling=deny_sapling,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
return report.agent_summary()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Fixed explanations are intentionally free from fixture bytes or agent-supplied
|
|
90
|
+
# instructions. The authoritative result still comes from the Rust analyzer.
|
|
91
|
+
RULE_GUIDANCE: dict[str, tuple[str, str]] = {
|
|
92
|
+
"PCZT-DUMMY-KEY": (
|
|
93
|
+
"An accessible intermediate dummy spending key was found.",
|
|
94
|
+
"Review the signer/prover role boundary. Do not disclose the dummy spend key to an untrusted recipient.",
|
|
95
|
+
),
|
|
96
|
+
"PCZT-GLOBAL-METADATA": (
|
|
97
|
+
"A global proprietary metadata field is available at this handoff.",
|
|
98
|
+
"Review the metadata's necessity and redact the field in a test workflow before external handoff.",
|
|
99
|
+
),
|
|
100
|
+
"PCZT-UNREVIEWED-METADATA": (
|
|
101
|
+
"A proprietary spend or output metadata field is available.",
|
|
102
|
+
"Review role-specific metadata and use the upstream PCZT redactor where appropriate.",
|
|
103
|
+
),
|
|
104
|
+
"PCZT-RESTRICTED-DISCLOSURE": (
|
|
105
|
+
"A field blocked by the restricted handoff policy is present.",
|
|
106
|
+
"Check whether the receiving role really needs this field. Restricted profiles may not suit every signer.",
|
|
107
|
+
),
|
|
108
|
+
"PCZT-SAPLING-POLICY": (
|
|
109
|
+
"Sapling components exist despite an application-level deny policy.",
|
|
110
|
+
"Review the app-specific Sapling restriction; Sapling is not inherently forbidden by Zcash consensus.",
|
|
111
|
+
),
|
|
112
|
+
"PCZT-TRANSPARENT-INPUTS": (
|
|
113
|
+
"The PCZT includes transparent inputs under a shielded-only policy.",
|
|
114
|
+
"Review transparent input selection and policy configuration before moving to signing.",
|
|
115
|
+
),
|
|
116
|
+
"PCZT-TRANSPARENT-OUTPUTS": (
|
|
117
|
+
"The PCZT includes transparent outputs under a shielded-only policy.",
|
|
118
|
+
"Review output construction and destination pool selection; recheck with a synthetic fixture.",
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def discover_synthetic_fixtures(root: Path) -> dict[str, object]:
|
|
124
|
+
"""List a bounded subset of approved, regular .bin files without reading bytes."""
|
|
125
|
+
try:
|
|
126
|
+
base = root.expanduser().resolve(strict=True)
|
|
127
|
+
if not base.is_dir():
|
|
128
|
+
raise OSError("invalid")
|
|
129
|
+
names: list[str] = []
|
|
130
|
+
inspected = 0
|
|
131
|
+
# Avoid unbounded work on an unexpectedly large, untrusted directory.
|
|
132
|
+
for path in base.iterdir():
|
|
133
|
+
inspected += 1
|
|
134
|
+
if inspected > 256:
|
|
135
|
+
break
|
|
136
|
+
if not SAFE_NAME.fullmatch(path.name) or path.is_symlink():
|
|
137
|
+
continue
|
|
138
|
+
if not path.is_file() or not 0 < path.stat().st_size <= 8 * 1024 * 1024:
|
|
139
|
+
continue
|
|
140
|
+
names.append(path.name)
|
|
141
|
+
names.sort()
|
|
142
|
+
return {
|
|
143
|
+
"fixtures": names[:32],
|
|
144
|
+
"truncated": len(names) > 32 or inspected > 256,
|
|
145
|
+
"note": "Names from the explicitly approved synthetic-fixture root only; no bytes returned.",
|
|
146
|
+
}
|
|
147
|
+
except (OSError, ValueError):
|
|
148
|
+
raise ZerynexError("Approved synthetic-fixture directory unavailable.") from None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def explain_policy_rule(rule_id: str) -> dict[str, str]:
|
|
152
|
+
"""Explain only enumerated rules. Never echo arbitrary agent prompt contents."""
|
|
153
|
+
if not isinstance(rule_id, str) or rule_id not in RULE_GUIDANCE:
|
|
154
|
+
raise ZerynexError("Unknown supported privacy rule.")
|
|
155
|
+
from .rules import bundled_rule_catalog
|
|
156
|
+
catalog = bundled_rule_catalog()
|
|
157
|
+
definition = next((item for item in catalog['rules'] if item['id'] == rule_id), None)
|
|
158
|
+
if definition is None:
|
|
159
|
+
raise ZerynexError("Unknown supported privacy rule.")
|
|
160
|
+
meaning, next_step = RULE_GUIDANCE[rule_id]
|
|
161
|
+
return {
|
|
162
|
+
"rule": rule_id,
|
|
163
|
+
"severity": definition["severity"],
|
|
164
|
+
"category": definition["category"],
|
|
165
|
+
"rule_set_version": catalog["rule_set_version"],
|
|
166
|
+
"meaning": meaning,
|
|
167
|
+
"suggested_review": next_step,
|
|
168
|
+
"limitation": "Policy guidance, not a complete security review or cryptographic verification.",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def evaluate_synthetic_smoke_suite(client: ZerynexClient, root: Path) -> dict[str, object]:
|
|
173
|
+
"""Prove that scan + compare tool paths work with an approved bundled suite.
|
|
174
|
+
|
|
175
|
+
Expected failures are *successful test detections*. Never return file
|
|
176
|
+
contents, external absolute paths or CLI error details.
|
|
177
|
+
"""
|
|
178
|
+
tests: list[dict[str, object]] = []
|
|
179
|
+
|
|
180
|
+
for name, expected_rules in (
|
|
181
|
+
("clean-v6.bin", ()),
|
|
182
|
+
("transparent-v6.bin", ("PCZT-TRANSPARENT-OUTPUTS",)),
|
|
183
|
+
("flagged-v6.bin", ("PCZT-GLOBAL-METADATA",)),
|
|
184
|
+
):
|
|
185
|
+
try:
|
|
186
|
+
result = scan_fixture(client, root, name)
|
|
187
|
+
found = sorted({finding["rule"] for finding in result["findings"]})
|
|
188
|
+
matched = found == sorted(expected_rules) and result["passed"] == (not expected_rules)
|
|
189
|
+
except (ZerynexError, OSError):
|
|
190
|
+
found = []
|
|
191
|
+
matched = False
|
|
192
|
+
tests.append({
|
|
193
|
+
"scenario": name.removesuffix(".bin"),
|
|
194
|
+
"matched_expectation": matched,
|
|
195
|
+
"detected_rules": found,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
changes = compare_synthetic_fixtures(
|
|
200
|
+
client, root, "clean-v6.bin", "flagged-v6.bin"
|
|
201
|
+
)
|
|
202
|
+
added = sorted({finding["rule"] for finding in changes["added_findings"]})
|
|
203
|
+
matched = (
|
|
204
|
+
added == ["PCZT-GLOBAL-METADATA"] and
|
|
205
|
+
changes["no_new_findings"] is False and
|
|
206
|
+
changes["passed"] is False
|
|
207
|
+
)
|
|
208
|
+
except (ZerynexError, OSError):
|
|
209
|
+
added = []
|
|
210
|
+
matched = False
|
|
211
|
+
tests.append({
|
|
212
|
+
"scenario": "clean-to-flagged",
|
|
213
|
+
"matched_expectation": matched,
|
|
214
|
+
"detected_rules": added,
|
|
215
|
+
})
|
|
216
|
+
matched_count = sum(case["matched_expectation"] is True for case in tests)
|
|
217
|
+
return {
|
|
218
|
+
"passed": matched_count == len(tests),
|
|
219
|
+
"matched": matched_count,
|
|
220
|
+
"total": len(tests),
|
|
221
|
+
"tests": tests,
|
|
222
|
+
"limitation": "Only approved synthetic PCZT fixtures. Checks policy invariants, not proofs or consensus.",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def create_server(client: ZerynexClient, root: Path):
|
|
227
|
+
"""Bind MCP tools only after the operator explicitly supplies a fixture root."""
|
|
228
|
+
try:
|
|
229
|
+
from mcp.server import MCPServer
|
|
230
|
+
except ImportError:
|
|
231
|
+
raise ZerynexError("Install the optional MCP dependency: pip install 'zerynex-python[mcp]'") from None
|
|
232
|
+
|
|
233
|
+
server = MCPServer("Zerynex Local Privacy Checks")
|
|
234
|
+
|
|
235
|
+
@server.tool()
|
|
236
|
+
def analyze_synthetic_pczt(
|
|
237
|
+
fixture_name: str,
|
|
238
|
+
profile: str = "external_signer",
|
|
239
|
+
deny_transparent: bool = True,
|
|
240
|
+
deny_sapling: bool = False,
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""Analyze an approved synthetic PCZT filename. Return only limited, sanitized Rust policy findings. No wallet access."""
|
|
243
|
+
try:
|
|
244
|
+
return scan_fixture(client, root, fixture_name, profile, deny_transparent, deny_sapling)
|
|
245
|
+
except ZerynexError:
|
|
246
|
+
return {"error": "Fixture was rejected or the local analysis failed. Review root and policy settings."}
|
|
247
|
+
|
|
248
|
+
@server.tool()
|
|
249
|
+
def compare_synthetic_pczt_stages(
|
|
250
|
+
baseline_name: str,
|
|
251
|
+
candidate_name: str,
|
|
252
|
+
profile: str = "external_signer",
|
|
253
|
+
deny_transparent: bool = True,
|
|
254
|
+
deny_sapling: bool = False,
|
|
255
|
+
) -> dict:
|
|
256
|
+
"""Compare two approved synthetic PCZT fixtures locally. Returns sanitized changes, never raw data or cryptographic guarantees."""
|
|
257
|
+
try:
|
|
258
|
+
return compare_synthetic_fixtures(
|
|
259
|
+
client,
|
|
260
|
+
root,
|
|
261
|
+
baseline_name,
|
|
262
|
+
candidate_name,
|
|
263
|
+
profile,
|
|
264
|
+
deny_transparent,
|
|
265
|
+
deny_sapling,
|
|
266
|
+
)
|
|
267
|
+
except ZerynexError:
|
|
268
|
+
return {"error": "Fixture was rejected or the local stage comparison failed."}
|
|
269
|
+
|
|
270
|
+
@server.tool()
|
|
271
|
+
def list_synthetic_fixtures() -> dict:
|
|
272
|
+
"""Discover permitted synthetic .bin fixture basenames; never read or return their contents."""
|
|
273
|
+
try:
|
|
274
|
+
return discover_synthetic_fixtures(root)
|
|
275
|
+
except ZerynexError:
|
|
276
|
+
return {"error": "Cannot inspect the approved fixture directory."}
|
|
277
|
+
|
|
278
|
+
@server.tool()
|
|
279
|
+
def explain_zerynex_rule(rule_id: str) -> dict:
|
|
280
|
+
"""Get reviewed plain-language interpretation and suggested developer follow-up for a known Zerynex rule."""
|
|
281
|
+
try:
|
|
282
|
+
return explain_policy_rule(rule_id)
|
|
283
|
+
except ZerynexError:
|
|
284
|
+
return {"error": "Unknown supported Zerynex policy rule."}
|
|
285
|
+
|
|
286
|
+
@server.tool()
|
|
287
|
+
def run_synthetic_smoke_suite() -> dict:
|
|
288
|
+
"""Run four local Rust-backed fixture checks and report which expected findings matched. Test failures stay failures."""
|
|
289
|
+
return evaluate_synthetic_smoke_suite(client, root)
|
|
290
|
+
|
|
291
|
+
@server.tool()
|
|
292
|
+
def zerynex_core_capabilities() -> dict:
|
|
293
|
+
"""Discover validated local Rust CLI operations and limits; no transaction file access."""
|
|
294
|
+
from .agent_workflows import core_capabilities
|
|
295
|
+
try:
|
|
296
|
+
return core_capabilities(client)
|
|
297
|
+
except ZerynexError:
|
|
298
|
+
return {"error": "Local Rust CLI capabilities are unavailable."}
|
|
299
|
+
|
|
300
|
+
@server.tool()
|
|
301
|
+
def check_synthetic_zallet_rpc_contract(scenario: str = "baseline") -> dict:
|
|
302
|
+
"""Check a bundled synthetic Zallet PCZT method schema. Scenarios: baseline, missing_method, new_required_parameter. Never connects to an RPC server."""
|
|
303
|
+
from .agent_workflows import synthetic_zallet_rpc_contract
|
|
304
|
+
try:
|
|
305
|
+
return synthetic_zallet_rpc_contract(scenario)
|
|
306
|
+
except ZerynexError:
|
|
307
|
+
return {"error": "Synthetic RPC regression scenario was rejected."}
|
|
308
|
+
|
|
309
|
+
@server.tool()
|
|
310
|
+
def audit_synthetic_refund_privacy(scenario: str = "isolated") -> dict:
|
|
311
|
+
"""Audit reviewed metadata-only swap refund scenarios: isolated, risky, regression-expanded. No external paths, addresses or viewing keys."""
|
|
312
|
+
from .refund_workflows import synthetic_refund_audit
|
|
313
|
+
try:
|
|
314
|
+
return synthetic_refund_audit(scenario)
|
|
315
|
+
except ZerynexError:
|
|
316
|
+
return {"error": "Synthetic refund privacy scenario was rejected or unavailable."}
|
|
317
|
+
|
|
318
|
+
@server.tool()
|
|
319
|
+
def compare_synthetic_refund_privacy(
|
|
320
|
+
baseline: str = "risky",
|
|
321
|
+
candidate: str = "regression-expanded",
|
|
322
|
+
) -> dict:
|
|
323
|
+
"""Compare two bundled refund configurations and return only sanitized per-flow risk counts and rule IDs. No secret data."""
|
|
324
|
+
from .refund_workflows import synthetic_refund_regression
|
|
325
|
+
try:
|
|
326
|
+
return synthetic_refund_regression(baseline, candidate)
|
|
327
|
+
except ZerynexError:
|
|
328
|
+
return {"error": "Synthetic refund privacy regression was rejected or unavailable."}
|
|
329
|
+
|
|
330
|
+
@server.tool()
|
|
331
|
+
def run_zerynex_developer_preflight() -> dict:
|
|
332
|
+
"""Verify local Rust/PCZT checks, bundled RPC contract and six offline synthetic refund-privacy scenarios. No wallet access."""
|
|
333
|
+
from .agent_workflows import developer_preflight
|
|
334
|
+
try:
|
|
335
|
+
return developer_preflight(client, root)
|
|
336
|
+
except ZerynexError:
|
|
337
|
+
return {"error": "Local developer preflight failed or is misconfigured."}
|
|
338
|
+
|
|
339
|
+
@server.tool()
|
|
340
|
+
def zerynex_rule_catalog() -> dict:
|
|
341
|
+
"""Discover the reviewed versioned Rust rule registry. Static metadata only; no wallet, keys or PCZT bytes."""
|
|
342
|
+
try:
|
|
343
|
+
from .rules import bundled_rule_catalog
|
|
344
|
+
source = client.rule_catalog()
|
|
345
|
+
reviewed = bundled_rule_catalog()
|
|
346
|
+
if source != reviewed:
|
|
347
|
+
return {"error": "Local Rust rule catalog differs from packaged SDK; update the trusted versions."}
|
|
348
|
+
return source
|
|
349
|
+
except ZerynexError:
|
|
350
|
+
return {"error": "Local Rust rule catalog is unavailable or unsupported."}
|
|
351
|
+
|
|
352
|
+
@server.tool()
|
|
353
|
+
def zerynex_policy_schema() -> dict:
|
|
354
|
+
"""Describe supported Zcash disclosure checks without accessing private files."""
|
|
355
|
+
return {
|
|
356
|
+
"profiles": ["external_signer", "restricted_handoff"],
|
|
357
|
+
"flags": {
|
|
358
|
+
"deny_transparent": "Prohibit transparent inputs and outputs.",
|
|
359
|
+
"deny_sapling": "Prohibit Sapling spends and outputs.",
|
|
360
|
+
},
|
|
361
|
+
"default_agent_policy": {
|
|
362
|
+
"profile": "external_signer",
|
|
363
|
+
"deny_transparent": True,
|
|
364
|
+
"deny_sapling": False,
|
|
365
|
+
},
|
|
366
|
+
"restrictions": "Synthetic fixtures only. Read-only. Supports comparing two stages. Does not access arbitrary paths or sign transactions.",
|
|
367
|
+
"limitations": "A pass is not a proof of privacy or Zcash consensus validity.",
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return server
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main() -> None:
|
|
374
|
+
root_text = os.environ.get("ZERYNEX_AGENT_ALLOWED_ROOT")
|
|
375
|
+
if not root_text:
|
|
376
|
+
# Keep stderr output generic; never expose environment variable values.
|
|
377
|
+
raise SystemExit("Set ZERYNEX_AGENT_ALLOWED_ROOT to a dedicated synthetic-fixture directory.")
|
|
378
|
+
try:
|
|
379
|
+
root = Path(root_text).expanduser().resolve(strict=True)
|
|
380
|
+
if not root.is_dir():
|
|
381
|
+
raise ZerynexError("Fixture root is not a directory.")
|
|
382
|
+
client = ZerynexClient()
|
|
383
|
+
server = create_server(client, root)
|
|
384
|
+
except (ZerynexError, OSError):
|
|
385
|
+
raise SystemExit("Cannot start safe local Zerynex agent server.") from None
|
|
386
|
+
# Official MCP SDK uses local stdio by default; no network listener.
|
|
387
|
+
server.run()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
main()
|
zerynex_py/node.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Read-only Zcash node readiness for application developers.
|
|
2
|
+
|
|
3
|
+
Only getblockchaininfo on an explicit numeric loopback endpoint; never remote
|
|
4
|
+
hosts, payment RPCs, wallet methods, transaction content or agent access.
|
|
5
|
+
Cookie authentication is sourced from an operator-owned local file, never argv.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import base64
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from http.client import HTTPConnection, HTTPException
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import stat
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .client import ZerynexError
|
|
21
|
+
from .z3 import _safe_loopback_host
|
|
22
|
+
|
|
23
|
+
MAX_RESPONSE = 64 * 1024
|
|
24
|
+
MAX_COOKIE = 4096
|
|
25
|
+
REQUEST_ID = 'zerynex-readiness-v1'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class NodeReadiness:
|
|
30
|
+
schema_version: int
|
|
31
|
+
network: str | None
|
|
32
|
+
expected_network: str
|
|
33
|
+
height: int | None
|
|
34
|
+
headers: int | None
|
|
35
|
+
header_lag: int | None
|
|
36
|
+
initial_sync_complete: bool | None
|
|
37
|
+
verification_progress: float | None
|
|
38
|
+
ready: bool
|
|
39
|
+
checks: tuple[dict[str, str], ...]
|
|
40
|
+
limitation: str = ('Local JSON-RPC chain-state observation only; not wallet readiness, '
|
|
41
|
+
'spend authority, Zallet compatibility, consensus audit, or privacy proof.')
|
|
42
|
+
|
|
43
|
+
def as_dict(self) -> dict[str, Any]:
|
|
44
|
+
data = asdict(self)
|
|
45
|
+
data['checks'] = list(self.checks)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _int(value: Any) -> int | None:
|
|
50
|
+
return value if type(value) is int and 0 <= value <= 2**32 - 1 else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def evaluate_blockchain_info(
|
|
54
|
+
result: object, *, expected_network: str = 'test', max_header_lag: int = 2,
|
|
55
|
+
min_verification_progress: float = 0.999,
|
|
56
|
+
) -> NodeReadiness:
|
|
57
|
+
"""Evaluate untrusted RPC output using an explicit, conservative policy.
|
|
58
|
+
|
|
59
|
+
No original response fields are returned; especially no hashes, addresses,
|
|
60
|
+
value pools, error messages, or opaque server-provided strings.
|
|
61
|
+
"""
|
|
62
|
+
if expected_network not in ('main', 'test', 'regtest'):
|
|
63
|
+
raise ZerynexError('Expected Zcash network must be main, test, or regtest.')
|
|
64
|
+
if type(max_header_lag) is not int or not 0 <= max_header_lag <= 1000:
|
|
65
|
+
raise ZerynexError('Invalid maximum header lag.')
|
|
66
|
+
if (type(min_verification_progress) not in (int, float) or
|
|
67
|
+
not math.isfinite(min_verification_progress) or
|
|
68
|
+
not 0 < min_verification_progress <= 1):
|
|
69
|
+
raise ZerynexError('Invalid verification progress threshold.')
|
|
70
|
+
if not isinstance(result, dict):
|
|
71
|
+
raise ZerynexError('Node returned an invalid blockchain information object.')
|
|
72
|
+
|
|
73
|
+
network = result.get('chain')
|
|
74
|
+
network = network if type(network) is str and network in ('main', 'test', 'regtest') else None
|
|
75
|
+
height = _int(result.get('blocks'))
|
|
76
|
+
headers = _int(result.get('headers'))
|
|
77
|
+
header_lag = max(0, headers - height) if height is not None and headers is not None else None
|
|
78
|
+
# zcashd names this boolean initial_block_download_complete; some node
|
|
79
|
+
# variants expose initialblockdownload instead. Unknown fails closed.
|
|
80
|
+
ibd_complete = result.get('initial_block_download_complete')
|
|
81
|
+
if type(ibd_complete) is not bool:
|
|
82
|
+
ibd = result.get('initialblockdownload')
|
|
83
|
+
ibd_complete = not ibd if type(ibd) is bool else None
|
|
84
|
+
progress = result.get('verificationprogress')
|
|
85
|
+
progress = float(progress) if type(progress) in (int, float) and math.isfinite(progress) and 0 <= progress <= 1 else None
|
|
86
|
+
checks = (
|
|
87
|
+
{'code': 'NETWORK_MATCH', 'status': 'pass' if network == expected_network else 'fail' if network else 'unknown'},
|
|
88
|
+
{'code': 'CHAIN_HEIGHT', 'status': 'pass' if height is not None and headers is not None and headers >= height else 'fail' if height is not None and headers is not None else 'unknown'},
|
|
89
|
+
{'code': 'HEADER_LAG', 'status': 'pass' if header_lag is not None and header_lag <= max_header_lag else 'fail' if header_lag is not None else 'unknown'},
|
|
90
|
+
{'code': 'INITIAL_SYNC', 'status': 'pass' if ibd_complete is True else 'fail' if ibd_complete is False else 'unknown'},
|
|
91
|
+
{'code': 'VERIFICATION_PROGRESS', 'status': 'pass' if progress is not None and progress >= min_verification_progress else 'fail' if progress is not None else 'unknown'},
|
|
92
|
+
)
|
|
93
|
+
return NodeReadiness(
|
|
94
|
+
schema_version=1, network=network, expected_network=expected_network,
|
|
95
|
+
height=height, headers=headers, header_lag=header_lag,
|
|
96
|
+
initial_sync_complete=ibd_complete, verification_progress=progress,
|
|
97
|
+
ready=all(check['status'] == 'pass' for check in checks), checks=checks,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cookie_authorization(cookie_file: Path | None) -> str | None:
|
|
102
|
+
if cookie_file is None:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
path = Path(cookie_file)
|
|
106
|
+
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
|
|
107
|
+
if path.is_symlink():
|
|
108
|
+
raise OSError
|
|
109
|
+
fd = os.open(path, flags)
|
|
110
|
+
with os.fdopen(fd, 'rb') as stream:
|
|
111
|
+
info = os.fstat(stream.fileno())
|
|
112
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_size > MAX_COOKIE or
|
|
113
|
+
info.st_size == 0 or (info.st_mode & 0o077) != 0):
|
|
114
|
+
raise OSError
|
|
115
|
+
raw = stream.read(MAX_COOKIE + 1)
|
|
116
|
+
if len(raw) > MAX_COOKIE or b':' not in raw or any(b < 33 or b > 126 for b in raw):
|
|
117
|
+
raise OSError
|
|
118
|
+
return 'Basic ' + base64.b64encode(raw).decode('ascii')
|
|
119
|
+
except (OSError, TypeError, ValueError):
|
|
120
|
+
raise ZerynexError('RPC cookie is invalid or not owner-private (0600 or 0400).') from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def inspect_node_local(
|
|
124
|
+
*, host: str = '127.0.0.1', port: int = 8232, timeout: float = 1.5,
|
|
125
|
+
expected_network: str = 'test', max_header_lag: int = 2,
|
|
126
|
+
cookie_file: Path | None = None,
|
|
127
|
+
) -> NodeReadiness:
|
|
128
|
+
"""Make precisely one loopback-only, read-only getblockchaininfo RPC call."""
|
|
129
|
+
safe_host = _safe_loopback_host(host)
|
|
130
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
131
|
+
raise ZerynexError('Invalid local RPC port.')
|
|
132
|
+
if type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 3:
|
|
133
|
+
raise ZerynexError('RPC timeout must be between zero and three seconds.')
|
|
134
|
+
# Validate policy before network use.
|
|
135
|
+
evaluate_blockchain_info({}, expected_network=expected_network, max_header_lag=max_header_lag)
|
|
136
|
+
auth = _cookie_authorization(cookie_file)
|
|
137
|
+
headers = {'Content-Type':'application/json', 'Accept':'application/json', 'Connection':'close'}
|
|
138
|
+
if auth:
|
|
139
|
+
headers['Authorization'] = auth
|
|
140
|
+
request = json.dumps({'jsonrpc': '1.0', 'id': REQUEST_ID,
|
|
141
|
+
'method': 'getblockchaininfo', 'params': []}, separators=(',', ':'))
|
|
142
|
+
connection = HTTPConnection(safe_host, port, timeout=float(timeout))
|
|
143
|
+
try:
|
|
144
|
+
connection.request('POST', '/', body=request, headers=headers)
|
|
145
|
+
response = connection.getresponse()
|
|
146
|
+
if response.status != 200:
|
|
147
|
+
raise ZerynexError('Local Zcash RPC is unavailable or rejected the request.')
|
|
148
|
+
if (response.getheader('Content-Length') and
|
|
149
|
+
int(response.getheader('Content-Length')) > MAX_RESPONSE):
|
|
150
|
+
raise ZerynexError('Local Zcash RPC response exceeds the size limit.')
|
|
151
|
+
body = response.read(MAX_RESPONSE + 1)
|
|
152
|
+
if len(body) > MAX_RESPONSE:
|
|
153
|
+
raise ZerynexError('Local Zcash RPC response exceeds the size limit.')
|
|
154
|
+
parsed = json.loads(body)
|
|
155
|
+
if (not isinstance(parsed, dict) or parsed.get('id') != REQUEST_ID or
|
|
156
|
+
parsed.get('error') is not None or 'result' not in parsed):
|
|
157
|
+
raise ZerynexError('Local Zcash RPC returned an invalid envelope.')
|
|
158
|
+
return evaluate_blockchain_info(parsed['result'], expected_network=expected_network,
|
|
159
|
+
max_header_lag=max_header_lag)
|
|
160
|
+
except ZerynexError:
|
|
161
|
+
raise
|
|
162
|
+
except (OSError, TimeoutError, HTTPException, ValueError, TypeError, UnicodeError, OverflowError):
|
|
163
|
+
raise ZerynexError('Local Zcash RPC request failed or returned invalid data.') from None
|
|
164
|
+
finally:
|
|
165
|
+
connection.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> None:
|
|
169
|
+
parser = argparse.ArgumentParser(description='Zerynex read-only local Zcash node compatibility probe')
|
|
170
|
+
parser.add_argument('--host', default='127.0.0.1', help='Numeric loopback or localhost only')
|
|
171
|
+
parser.add_argument('--port', type=int, default=8232)
|
|
172
|
+
parser.add_argument('--timeout', type=float, default=1.5)
|
|
173
|
+
parser.add_argument('--expect-network', default='test', choices=['main','test','regtest'])
|
|
174
|
+
parser.add_argument('--max-header-lag', type=int, default=2)
|
|
175
|
+
parser.add_argument('--cookie-file', type=Path, help='Local owner-private RPC cookie; never supply credentials on argv')
|
|
176
|
+
args = parser.parse_args()
|
|
177
|
+
try:
|
|
178
|
+
report = inspect_node_local(host=args.host,port=args.port,timeout=args.timeout,
|
|
179
|
+
expected_network=args.expect_network,
|
|
180
|
+
max_header_lag=args.max_header_lag,cookie_file=args.cookie_file)
|
|
181
|
+
except ZerynexError:
|
|
182
|
+
parser.exit(1, 'Zerynex: local node probe failed or configuration rejected.\n')
|
|
183
|
+
print(json.dumps(report.as_dict(),indent=2))
|
|
184
|
+
if not report.ready:
|
|
185
|
+
parser.exit(2)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == '__main__':
|
|
189
|
+
main()
|
zerynex_py/preflight.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Installable, offline preflight spanning Rust, synthetic PCZT and RPC checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
from .agent_workflows import developer_preflight
|
|
10
|
+
from .client import ZerynexClient, ZerynexError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Run the local Zerynex developer integration preflight.")
|
|
15
|
+
parser.add_argument("--cli", default=os.environ.get("ZERYNEX_CLI", ""), help="Trusted local Rust CLI executable")
|
|
16
|
+
parser.add_argument("--fixtures", default=os.environ.get("ZERYNEX_AGENT_ALLOWED_ROOT", ""), help="Explicit synthetic PCZT fixture directory")
|
|
17
|
+
parser.add_argument("--json", action="store_true", help="Output sanitized machine-readable results")
|
|
18
|
+
options = parser.parse_args()
|
|
19
|
+
if not options.cli or not options.fixtures:
|
|
20
|
+
parser.error("Provide --cli and --fixtures or their ZERYNEX environment values.")
|
|
21
|
+
try:
|
|
22
|
+
root = Path(options.fixtures).expanduser().resolve(strict=True)
|
|
23
|
+
if not root.is_dir():
|
|
24
|
+
raise ZerynexError("Fixture root unavailable.")
|
|
25
|
+
client = ZerynexClient(cli=options.cli)
|
|
26
|
+
report = developer_preflight(client, root)
|
|
27
|
+
except (ZerynexError, OSError, ValueError):
|
|
28
|
+
print("Zerynex preflight could not evaluate the local setup.", file=sys.stderr)
|
|
29
|
+
raise SystemExit(1) from None
|
|
30
|
+
if options.json:
|
|
31
|
+
print(json.dumps(report, indent=2))
|
|
32
|
+
else:
|
|
33
|
+
print("Zerynex developer preflight:", "PASS" if report["passed"] else "FAIL")
|
|
34
|
+
print("Synthetic PCZT suite:", report["checks"]["synthetic_pczt_suite"]["matched"], "/", report["checks"]["synthetic_pczt_suite"]["total"])
|
|
35
|
+
print("Synthetic RPC contract:", "PASS" if report["checks"]["synthetic_rpc_contract"]["passed"] else "FAIL")
|
|
36
|
+
print("Synthetic refund privacy:", report["checks"]["synthetic_refund_privacy"]["matched"], "/",
|
|
37
|
+
report["checks"]["synthetic_refund_privacy"]["total"])
|
|
38
|
+
raise SystemExit(0 if report["passed"] else 2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|