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.
@@ -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()
@@ -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()