pacioli 0.30.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.
Files changed (69) hide show
  1. pacioli/__init__.py +10 -0
  2. pacioli/a2a.py +571 -0
  3. pacioli/amend.py +162 -0
  4. pacioli/anchor.py +244 -0
  5. pacioli/cascade.py +371 -0
  6. pacioli/cli.py +1721 -0
  7. pacioli/clock.py +127 -0
  8. pacioli/close.py +219 -0
  9. pacioli/consent.py +123 -0
  10. pacioli/doctor.py +894 -0
  11. pacioli/erpnext.py +1194 -0
  12. pacioli/plan.py +218 -0
  13. pacioli/prove.py +182 -0
  14. pacioli/reconcile.py +382 -0
  15. pacioli/reconciliation.py +427 -0
  16. pacioli/registry.py +218 -0
  17. pacioli/response.py +340 -0
  18. pacioli/runtime.py +139 -0
  19. pacioli/server.py +296 -0
  20. pacioli/spine.py +325 -0
  21. pacioli/store.py +1635 -0
  22. pacioli/tests/__init__.py +0 -0
  23. pacioli/tests/_torn_write_crash_worker.py +62 -0
  24. pacioli/tests/test_a2a.py +553 -0
  25. pacioli/tests/test_a2a_signing.py +327 -0
  26. pacioli/tests/test_amend.py +190 -0
  27. pacioli/tests/test_anchor.py +1024 -0
  28. pacioli/tests/test_attest_cli.py +616 -0
  29. pacioli/tests/test_cascade.py +616 -0
  30. pacioli/tests/test_clock.py +116 -0
  31. pacioli/tests/test_close.py +677 -0
  32. pacioli/tests/test_close_advance.py +734 -0
  33. pacioli/tests/test_close_clock.py +313 -0
  34. pacioli/tests/test_close_envelope.py +692 -0
  35. pacioli/tests/test_consent.py +115 -0
  36. pacioli/tests/test_doctor.py +1273 -0
  37. pacioli/tests/test_erpnext.py +1739 -0
  38. pacioli/tests/test_erpnext_reconciliation_reads.py +495 -0
  39. pacioli/tests/test_plan.py +250 -0
  40. pacioli/tests/test_prove.py +167 -0
  41. pacioli/tests/test_reconcile.py +729 -0
  42. pacioli/tests/test_reconciliation.py +531 -0
  43. pacioli/tests/test_registry.py +204 -0
  44. pacioli/tests/test_response.py +358 -0
  45. pacioli/tests/test_runtime.py +154 -0
  46. pacioli/tests/test_seal_cli.py +569 -0
  47. pacioli/tests/test_seal_gate.py +501 -0
  48. pacioli/tests/test_server.py +112 -0
  49. pacioli/tests/test_server_http.py +284 -0
  50. pacioli/tests/test_spine.py +400 -0
  51. pacioli/tests/test_store.py +514 -0
  52. pacioli/tests/test_store_close_record.py +1132 -0
  53. pacioli/tests/test_store_seal.py +829 -0
  54. pacioli/tests/test_store_torn_write.py +248 -0
  55. pacioli/tests/test_tool_surface.py +189 -0
  56. pacioli/tests/test_tools.py +4046 -0
  57. pacioli/tests/test_version.py +23 -0
  58. pacioli/tests/test_via.py +80 -0
  59. pacioli/tests/test_webguard.py +412 -0
  60. pacioli/tests/test_workflow.py +439 -0
  61. pacioli/tools.py +2445 -0
  62. pacioli/webguard.py +245 -0
  63. pacioli/workflow.py +337 -0
  64. pacioli-0.30.0.dist-info/METADATA +1006 -0
  65. pacioli-0.30.0.dist-info/RECORD +69 -0
  66. pacioli-0.30.0.dist-info/WHEEL +5 -0
  67. pacioli-0.30.0.dist-info/entry_points.txt +2 -0
  68. pacioli-0.30.0.dist-info/licenses/LICENSE +202 -0
  69. pacioli-0.30.0.dist-info/top_level.txt +1 -0
pacioli/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # Copyright (c) 2026, John Broadway and contributors
2
+ # License: Apache-2.0
3
+ """Pacioli Broker — the governed MCP broker for ERPNext.
4
+
5
+ The security-critical pillar logic lives in the pure cores (``prove``, ``consent``, ``plan``,
6
+ ``spine``) with no frappe/network import, so it is unit-testable without a running bench. The frappe-
7
+ and MCP-facing glue (``erpnext``, ``server``, ``registry``) is thin and proven live. See ``SPEC.md``.
8
+ """
9
+
10
+ __version__ = "0.30.0"
pacioli/a2a.py ADDED
@@ -0,0 +1,571 @@
1
+ # Copyright (c) 2026, John Broadway and contributors
2
+ # License: Apache-2.0
3
+ """Pacioli Broker — the A2A door (glue): the first non-MCP door.
4
+
5
+ **A DOOR ADMITS; IT NEVER DECIDES** (the doors ruling). Stdio and streamable-HTTP are both MCP;
6
+ this door speaks A2A v1.0 (Agent2Agent — agent→agent delegation, JSON-RPC over HTTP, Agent Card
7
+ discovery at ``/.well-known/agent-card.json``) — the first protocol the spine has never seen. It
8
+ carries the FULL governed tool surface, curates nothing, and routes every call through
9
+ :func:`pacioli.server.dispatch_raw` — the same process-wide lock and the same
10
+ ``PacioliBroker.dispatch`` the MCP doors use. No second dispatch path, no second refusal logic.
11
+
12
+ Wire convention (the house shape — Proximo's A2A door speaks the same one; shape, not coupling):
13
+ the inbound Message must carry a DataPart whose data is ``{"tool": "<name>", "params": {...}}``.
14
+ ``"skill"`` is accepted as an alias for ``"tool"``; absent/null ``params`` is an empty dict; any
15
+ other shape produces a clean failed task, never a traceback. An UNKNOWN tool goes through
16
+ dispatch like any other call and comes back as the structured ``stage: request`` deny PROVE
17
+ already records — hostile enumeration is a ledger entry, not an invisible transport error.
18
+
19
+ Deny-biased posture, identical to the HTTP door (the same tested primitives from
20
+ :mod:`pacioli.server`, not re-grown copies): OFF by default (``pacioli serve --a2a`` is a
21
+ deliberate act); binds loopback by default; a non-loopback bind REFUSES TO START without a
22
+ bearer token held by reference (``env:VAR``/``file:/path``); the JSON-RPC route answers 401
23
+ before the SDK sees the request. The Agent Card stays readable WITHOUT auth — an A2A client must
24
+ be able to discover how to authenticate before it can authenticate — and when a token is
25
+ configured the card DECLARES the bearer scheme so clients self-configure from discovery.
26
+
27
+ HONEST NOTES, said plainly rather than half-fixed:
28
+
29
+ * **A standing listener.** An A2A door listens by the protocol's nature. Acceptable only because
30
+ doors are opt-in, loopback-default, and deny-biased at startup (F6, the doors ruling).
31
+ * **Card signing is opt-in (0.29.0).** The card is served UNSIGNED by default; set
32
+ ``PACIOLI_A2A_SIGNING_KEY_FILE`` to an EC P-256 key (mint one with ``pacioli a2a-keygen``) and
33
+ the card is ES256-signed, with its public key served at ``/.well-known/jwks.json``. The key
34
+ lives in the OPERATOR's tier (same as the seal key + consent marker) — 0600, refuse-if-exposed,
35
+ never auto-minted. Honest ceiling: a signing key only proves authorship if the agent cannot
36
+ REWRITE it (a compromised broker re-signs a forged card), so it must live outside the agent's
37
+ own write reach; and a peer must pin the public key OUT-OF-BAND (a card's own ``jku`` is not a
38
+ trust root — see :func:`verifier_for_jwk`).
39
+ * **The in-door perimeter covers Host/CORS/size; TLS stays the proxy's job.** Since 0.28.0
40
+ this door mounts the shared :func:`pacioli.webguard.guard_asgi` (Host-header/DNS-rebind
41
+ allowlist + cross-origin guard + a body-size cap) outside the bearer gate, across both
42
+ network doors — so a bad Host is 400, a browser cross-origin POST is 403/415, and an
43
+ oversized body (including a chunked one that drops ``Content-Length``) is 413, all in-process
44
+ rather than delegated. TLS is the one honest remaining reverse-proxy job — front with a proxy
45
+ for TLS before any non-local exposure. Non-browser callers (curl, a local process) send none
46
+ of the cross-origin triggers and pass the perimeter transparently.
47
+
48
+ The ``a2a-sdk`` is an OPTIONAL dependency (``pip install 'pacioli[a2a]'``), imported lazily —
49
+ the pure cores and every CLI path never require it.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import sys
54
+ from pathlib import Path
55
+
56
+ from pacioli.runtime import RuntimeError_, assemble
57
+ from pacioli.server import (
58
+ TransportConfigError,
59
+ _bearer_ok,
60
+ _bind_requires_auth,
61
+ _resolve_transport_token,
62
+ dispatch_raw,
63
+ )
64
+ from pacioli.tools import TOOLS
65
+
66
+ DEFAULT_PORT = 8792 # the HTTP door is 8791 — adjacency reads as family
67
+
68
+
69
+ def _a2a_via(auth_ref):
70
+ """The A2A door's ledger stamp (F3): the principal is the token's REFERENCE label (e.g.
71
+ ``env:A2A_T``) — never the token itself — or ``loopback`` when no token is configured."""
72
+ return {"transport": "a2a",
73
+ "principal": auth_ref if auth_ref is not None else "loopback"}
74
+
75
+
76
+ SIGNING_KEY_ENV = "PACIOLI_A2A_SIGNING_KEY_FILE" # noqa: S105 — env var NAME, not a secret
77
+ _JWKS_PATH = "/.well-known/jwks.json"
78
+
79
+
80
+ # --- card signing (docs/plans/2026-07-17-a2a-card-signing.md) ---------------------------------
81
+ # ES256/JWS agent-card signing — Pacioli's OWN (composition-not-coupling; mechanism mirrors
82
+ # Proximo's SIGNET). The key lives in the operator's tier (same as the seal key + consent
83
+ # marker): a PEM EC P-256 held BY REFERENCE at PACIOLI_A2A_SIGNING_KEY_FILE, 0600, refuse-if-
84
+ # exposed — the honest ceiling is that a signing key only proves authorship if the agent cannot
85
+ # REWRITE it (a compromised broker re-signs a forged card), so it must live where the agent's
86
+ # own surface cannot author it. Opt-in: unset → unsigned card; set → sign or FAIL LOUD. All
87
+ # crypto imports are lazy (the pure/CLI paths never need them).
88
+
89
+
90
+ class SigningKey:
91
+ """The operator's A2A signing key: private PEM (to sign) + public key + a stable ``kid``
92
+ (RFC 7638 JWK thumbprint). Built by :func:`load_signing_key`."""
93
+
94
+ __slots__ = ("private_pem", "public_key", "kid")
95
+
96
+ def __init__(self, private_pem, public_key, kid):
97
+ self.private_pem = private_pem
98
+ self.public_key = public_key
99
+ self.kid = kid
100
+
101
+
102
+ def _b64url(b):
103
+ import base64
104
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
105
+
106
+
107
+ def _p256_xy(pub):
108
+ nums = pub.public_numbers()
109
+ return _b64url(nums.x.to_bytes(32, "big")), _b64url(nums.y.to_bytes(32, "big"))
110
+
111
+
112
+ def _thumbprint(pub):
113
+ """RFC 7638 JWK thumbprint over the canonical required members — a stable, derived ``kid``."""
114
+ import hashlib
115
+ import json as _json
116
+ x, y = _p256_xy(pub)
117
+ members = {"crv": "P-256", "kty": "EC", "x": x, "y": y}
118
+ canon = _json.dumps(members, separators=(",", ":"), sort_keys=True).encode("utf-8")
119
+ return _b64url(hashlib.sha256(canon).digest())
120
+
121
+
122
+ def load_signing_key(path):
123
+ """Load the operator's EC **P-256** signing key (PEM) from ``path``, refusing anything that
124
+ would make signing meaningless: a missing file, a key that is group/world-readable (the
125
+ seal-key discipline — a leaked key voids the assertion), a non-EC or non-P-256 key. Returns a
126
+ :class:`SigningKey` with a derived thumbprint ``kid``. Never auto-mints (use
127
+ ``pacioli a2a-keygen``) and never serves unsigned when a key was configured (the caller
128
+ fails loud on this raising)."""
129
+ import stat as _stat
130
+
131
+ from cryptography.hazmat.primitives import serialization
132
+ from cryptography.hazmat.primitives.asymmetric import ec
133
+
134
+ from pacioli.runtime import RuntimeError_ as _RE
135
+
136
+ p = Path(path)
137
+ if not p.exists():
138
+ raise _RE(f"A2A signing key {path} does not exist — mint one with `pacioli a2a-keygen` "
139
+ f"or unset {SIGNING_KEY_ENV} to serve an unsigned card")
140
+ if not p.is_file():
141
+ # a directory / FIFO / device at the path passes the mode check but blows up (or hangs)
142
+ # on read — refuse cleanly (security redteam 2026-07-17) rather than a raw OSError.
143
+ raise _RE(f"A2A signing key {path} is not a regular file")
144
+ mode = _stat.S_IMODE(p.stat().st_mode)
145
+ if mode & 0o077:
146
+ raise _RE(f"A2A signing key {path} has permissions {oct(mode)}; it must be 0600 "
147
+ f"(owner-only) — a leaked signing key voids every card seal. run: chmod 600 "
148
+ f"{path}")
149
+ pem = p.read_bytes()
150
+ try:
151
+ priv = serialization.load_pem_private_key(pem, password=None)
152
+ except Exception as exc: # noqa: BLE001 — any parse failure is a bad-key config error
153
+ raise ValueError(f"A2A signing key {path} is not a readable PEM private key: "
154
+ f"{type(exc).__name__}") from exc
155
+ if not isinstance(priv, ec.EllipticCurvePrivateKey) or \
156
+ not isinstance(priv.curve, ec.SECP256R1):
157
+ raise ValueError(
158
+ "A2A signing key must be an EC P-256 (prime256v1) private key for ES256 "
159
+ f"(got {type(priv).__name__})")
160
+ pub = priv.public_key()
161
+ return SigningKey(private_pem=pem, public_key=pub, kid=_thumbprint(pub))
162
+
163
+
164
+ def sign_card(card, key, *, jku=None):
165
+ """Press an ES256/JOSE seal onto ``card`` (mutates in place, returns it). ``alg`` is PINNED
166
+ to ES256 — never the a2a-sdk's HS256 default — so the seal is asymmetric and cannot be forged
167
+ from the public key (the JWT algorithm-confusion class, closed at the source)."""
168
+ from a2a.utils.signing import create_agent_card_signer
169
+
170
+ header = {"alg": "ES256", "typ": "JOSE", "kid": key.kid, "jku": jku}
171
+ signer = create_agent_card_signer(signing_key=key.private_pem, protected_header=header)
172
+ return signer(card)
173
+
174
+
175
+ def public_jwk(key):
176
+ """The operator's PUBLIC key as a JWK (RFC 7517) — public point only, NEVER the private
177
+ scalar ``d``. This is what a peer needs to verify a card seal."""
178
+ x, y = _p256_xy(key.public_key)
179
+ return {"kty": "EC", "crv": "P-256", "x": x, "y": y,
180
+ "kid": key.kid, "use": "sig", "alg": "ES256"}
181
+
182
+
183
+ def jwks(key):
184
+ """A JWK Set wrapping the operator's public key — served at ``/.well-known/jwks.json``."""
185
+ return {"keys": [public_jwk(key)]}
186
+
187
+
188
+ def verifier_for_jwk(jwk):
189
+ """An **ES256-only** verifier pinned to ONE trusted public JWK (obtained OUT-OF-BAND, not
190
+ fetched from a card's ``jku``) — the CLIENT-side safe pattern. Binds to that key and IGNORES
191
+ whatever ``kid``/``jku`` a card presents, so a MITM cannot substitute their key by pointing
192
+ ``jku`` at an attacker JWKS. The ``algorithms=['ES256']`` allowlist refuses an HS256 downgrade
193
+ outright. Returns a callable that raises on a card that does not verify (or carries no seal).
194
+ Included here (rather than only in a client) so the broker's own tests prove the seal it
195
+ presses is verifiable and downgrade-proof."""
196
+ from a2a.utils.signing import create_signature_verifier
197
+ from jwt import PyJWK
198
+
199
+ pinned = PyJWK.from_dict(jwk)
200
+
201
+ def key_provider(kid, jku):
202
+ return pinned
203
+
204
+ return create_signature_verifier(key_provider=key_provider, algorithms=["ES256"])
205
+
206
+
207
+ def _parse_tool_call(message, get_data_parts):
208
+ """Extract ``(tool_name, params)`` from an inbound A2A Message per the wire convention.
209
+
210
+ Returns ``(None, None)`` when no DataPart carries a ``tool``/``skill`` key — the caller
211
+ fails the task with the expected-shape message. The FIRST matching part wins; ``params``
212
+ that is anything but a dict is treated as empty (a non-dict params is a malformed call the
213
+ dispatch layer's own schema validation will refuse loudly, with the tool named)."""
214
+ if message is None:
215
+ return None, None
216
+ for payload in get_data_parts(message.parts):
217
+ if isinstance(payload, dict) and ("tool" in payload or "skill" in payload):
218
+ tool_name = payload.get("tool", payload.get("skill"))
219
+ raw = payload.get("params")
220
+ return tool_name, (raw if isinstance(raw, dict) else {})
221
+ return None, None
222
+
223
+
224
+ def make_executor(broker):
225
+ """Build the A2A executor bound to ``broker``. Imports the SDK here so importing
226
+ :mod:`pacioli.a2a` itself never requires it (the class statement needs the SDK's
227
+ ``AgentExecutor`` base at definition time)."""
228
+ import uuid
229
+
230
+ import anyio
231
+ from a2a.helpers.proto_helpers import (
232
+ get_data_parts,
233
+ new_data_part,
234
+ new_task,
235
+ new_text_part,
236
+ )
237
+ from a2a.server.agent_execution import AgentExecutor
238
+ from a2a.server.tasks import TaskUpdater
239
+ from a2a.types.a2a_pb2 import Message, Role, TaskState
240
+ from a2a.utils.errors import UnsupportedOperationError
241
+
242
+ class PacioliAgentExecutor(AgentExecutor):
243
+ """Stateless A2A executor — parse, route through the ONE locked dispatch, reply."""
244
+
245
+ async def _fail(self, event_queue, context, message):
246
+ # SDK 1.1 lifecycle: a bare TaskStatusUpdateEvent with no prior Task event is
247
+ # refused (InvalidAgentResponseError in active_task), and a SUBMITTED-Task-then-
248
+ # failed-status pair races the non-streaming response snapshot (probed live:
249
+ # the client read SUBMITTED). ONE terminal Task event — state FAILED, the
250
+ # explanation riding as an agent message in its history — is the shape the SDK
251
+ # returns faithfully in both streaming and polling modes.
252
+ agent_msg = Message(
253
+ message_id=str(uuid.uuid4()), role=Role.ROLE_AGENT,
254
+ parts=[new_text_part(message)],
255
+ task_id=context.task_id or "", context_id=context.context_id or "")
256
+ await event_queue.enqueue_event(new_task(
257
+ context.task_id or "", context.context_id or "",
258
+ TaskState.TASK_STATE_FAILED, history=[agent_msg]))
259
+
260
+ async def execute(self, context, event_queue):
261
+ updater = TaskUpdater(event_queue, context.task_id or "", context.context_id or "")
262
+ tool_name, params = _parse_tool_call(context.message, get_data_parts)
263
+
264
+ if tool_name is None:
265
+ await self._fail(
266
+ event_queue, context,
267
+ 'Expected a DataPart with shape {"tool": "<name>", "params": {...}}.'
268
+ " No such part found in the inbound message.")
269
+ return
270
+
271
+ try:
272
+ # the same worker-thread offload + process-wide lock as every other door (F5):
273
+ # the loop stays live, governed acts run strictly one-at-a-time.
274
+ result = await anyio.to_thread.run_sync(dispatch_raw, broker, tool_name, params)
275
+ except Exception as exc: # noqa: BLE001 — last-resort sanitize; never leak a traceback
276
+ # dispatch() itself answers structured denies as ok:False DICTS, so anything
277
+ # raising here is glue-level. Name the exception TYPE only — never its message
278
+ # (which can carry paths/params) and never a traceback.
279
+ await self._fail(event_queue, context,
280
+ f"tool '{tool_name}' failed: {type(exc).__name__}")
281
+ return
282
+
283
+ await updater.add_artifact(parts=[new_data_part(result)], name="result")
284
+ await updater.complete()
285
+
286
+ async def cancel(self, context, event_queue):
287
+ raise UnsupportedOperationError()
288
+
289
+ return PacioliAgentExecutor()
290
+
291
+
292
+ def build_agent_card(rpc_url, *, secured=False, signing_key=None, jwks_url=None):
293
+ """The machine-readable capability advertisement: one ``AgentSkill`` per governed tool —
294
+ the FULL surface an MCP client sees, 1-to-1, tags carrying the tool-family prefix so a peer
295
+ can filter without a naming convention. ``secured=True`` declares the bearer scheme the
296
+ server enforces, so clients self-configure from discovery instead of learning auth from a
297
+ 401. When ``signing_key`` is given the card is ES256-signed (:func:`sign_card`) with
298
+ ``jku`` → ``jwks_url`` (the served JWKS); unsigned otherwise (opt-in — the key-custody
299
+ ruling in ``docs/plans/2026-07-17-a2a-card-signing.md``)."""
300
+ from a2a.types import (
301
+ AgentCapabilities,
302
+ AgentCard,
303
+ AgentInterface,
304
+ AgentSkill,
305
+ SecurityRequirement,
306
+ )
307
+ from a2a.utils.constants import PROTOCOL_VERSION_CURRENT, TransportProtocol
308
+
309
+ from pacioli import __version__
310
+
311
+ skills = [
312
+ AgentSkill(
313
+ id=t["name"],
314
+ name=t["name"],
315
+ description=t["description"],
316
+ tags=[t["name"].split("_", 1)[0]],
317
+ )
318
+ for t in TOOLS
319
+ ]
320
+
321
+ interface = AgentInterface(
322
+ url=rpc_url,
323
+ protocol_binding=TransportProtocol.JSONRPC,
324
+ protocol_version=PROTOCOL_VERSION_CURRENT,
325
+ )
326
+
327
+ card = AgentCard(
328
+ name="Pacioli",
329
+ description=(
330
+ "Governed ERPNext bookkeeping agent — every write is planned, human-consented "
331
+ "(out-of-band marker), and receipted (PLAN→CONSENT→PROVE); deny-by-default beyond "
332
+ "the granted surface. A door admits; it never decides."
333
+ ),
334
+ version=__version__,
335
+ capabilities=AgentCapabilities(streaming=False, push_notifications=False),
336
+ supported_interfaces=[interface],
337
+ default_input_modes=["application/json", "text/plain"],
338
+ default_output_modes=["application/json", "text/plain"],
339
+ skills=skills,
340
+ )
341
+
342
+ if secured:
343
+ card.security_schemes["bearerAuth"].http_auth_security_scheme.scheme = "bearer"
344
+ req = SecurityRequirement()
345
+ _ = req.schemes["bearerAuth"] # auto-creates an empty scope list
346
+ card.security_requirements.append(req)
347
+
348
+ if signing_key is not None:
349
+ sign_card(card, signing_key, jku=jwks_url)
350
+
351
+ return card
352
+
353
+
354
+ def _bearer_middleware_asgi(app, token, rpc_path):
355
+ """Wrap ``app`` so the JSON-RPC route requires ``Authorization: Bearer <token>`` — answered
356
+ as a JSON-RPC error envelope (-32001) BEFORE the SDK sees the request. Discovery routes
357
+ (the card) stay readable pre-auth. No-op wrapper when ``token`` is None (loopback-only)."""
358
+ if token is None:
359
+ return app
360
+
361
+ import json
362
+
363
+ rpc = "/" + rpc_path.strip("/") if rpc_path.strip("/") else "/"
364
+ body = json.dumps({"jsonrpc": "2.0", "id": None,
365
+ "error": {"code": -32001, "message": "unauthorized"}}).encode()
366
+
367
+ async def guarded(scope, receive, send):
368
+ if scope["type"] == "http" and scope.get("path", "/").rstrip("/") == rpc.rstrip("/"):
369
+ headers = {k.decode().lower(): v.decode()
370
+ for k, v in scope.get("headers", [])}
371
+ if not _bearer_ok(headers.get("authorization"), token):
372
+ await send({"type": "http.response.start", "status": 401,
373
+ "headers": [(b"content-type", b"application/json")]})
374
+ await send({"type": "http.response.body", "body": body})
375
+ return
376
+ await app(scope, receive, send)
377
+
378
+ return guarded
379
+
380
+
381
+ def build_app(broker, *, rpc_url, token=None, allowed_hosts=None, signing_key=None):
382
+ """Assemble the A2A ASGI app: SDK card + JSON-RPC routes over the executor, the shared
383
+ in-door perimeter (Host allowlist + cross-origin + body cap, :func:`pacioli.webguard.guard_asgi`)
384
+ outside a bearer gate on the RPC path only. Extracted from :func:`serve_a2a` so the
385
+ 401/card/refusal paths are testable without binding a socket (the same reason the HTTP door
386
+ has ``_asgi_app``).
387
+
388
+ When ``signing_key`` is given the agent card is ES256-signed and a ``GET
389
+ /.well-known/jwks.json`` route serves the public key — BOTH readable pre-auth (discovery must
390
+ precede authentication), like the card, but still under the perimeter's Host check.
391
+
392
+ **Defense-in-depth (security redteam 2026-07-16):** re-checks the advertised host itself —
393
+ a public ``rpc_url`` with no ``token`` REFUSES here, not only in :func:`serve_a2a`. This is
394
+ public API an embedder or a ``uvicorn --factory`` path can reach directly, bypassing
395
+ ``serve_a2a``'s own bind check; the refusal must live where the app is actually built, the
396
+ same guard Proximo's ``build_app`` carries. ``serve_a2a`` already refused a public bind
397
+ before reaching here, so on the shipped path this never fires — it is the belt for the
398
+ direct-call path.
399
+
400
+ ``allowed_hosts`` is the perimeter's Host allowlist (``None`` → the advertised host +
401
+ loopback forms); the cross-origin/body-cap checks apply to POSTs on the RPC path only, so the
402
+ agent-card GET stays readable pre-auth while the Host check still covers it."""
403
+ from urllib.parse import urlparse
404
+
405
+ from a2a.server.request_handlers import DefaultRequestHandler
406
+ from a2a.server.routes.agent_card_routes import create_agent_card_routes
407
+ from a2a.server.routes.jsonrpc_routes import create_jsonrpc_routes
408
+ from a2a.server.tasks import InMemoryTaskStore
409
+ from starlette.applications import Starlette
410
+ from starlette.responses import JSONResponse
411
+ from starlette.routing import Route
412
+
413
+ from pacioli.webguard import default_allowed_hosts, guard_asgi
414
+
415
+ host = urlparse(rpc_url).hostname
416
+ if _bind_requires_auth(host) and token is None:
417
+ raise TransportConfigError(
418
+ f"advertised host {host!r} is not loopback — refusing to "
419
+ "build an A2A app with no bearer token; a governed door that admits everyone is the "
420
+ "one thing this must never construct")
421
+
422
+ jwks_url = None
423
+ if signing_key is not None:
424
+ parts = urlparse(rpc_url)
425
+ jwks_url = f"{parts.scheme}://{parts.netloc}{_JWKS_PATH}"
426
+ card = build_agent_card(rpc_url, secured=token is not None,
427
+ signing_key=signing_key, jwks_url=jwks_url)
428
+ handler = DefaultRequestHandler(
429
+ agent_executor=make_executor(broker),
430
+ task_store=InMemoryTaskStore(),
431
+ agent_card=card,
432
+ )
433
+ rpc_path = urlparse(rpc_url).path or "/"
434
+ routes = (
435
+ create_jsonrpc_routes(request_handler=handler, rpc_url=rpc_path)
436
+ + create_agent_card_routes(agent_card=card)
437
+ )
438
+ if signing_key is not None:
439
+ # Publish the operator's PUBLIC key so a peer can verify the card seal (the card's jku
440
+ # target). OUTSIDE the bearer gate — like the card, discovery must be readable pre-auth —
441
+ # but still under the perimeter's Host check. Public point only (never the private key).
442
+ _jwks_body = jwks(signing_key)
443
+
444
+ async def _serve_jwks(_request):
445
+ return JSONResponse(_jwks_body)
446
+
447
+ routes = [*routes, Route(_JWKS_PATH, _serve_jwks, methods=["GET"])]
448
+ gated = _bearer_middleware_asgi(Starlette(routes=routes), token, rpc_path)
449
+ # protect EVERY path, not just the RPC path (security redteam 2026-07-16, Minor): the
450
+ # cross-origin + size checks apply to POSTs only, and the agent card is a GET, so guarding
451
+ # all paths never touches discovery — but a bare ``path == rpc_path`` compare would skip the
452
+ # checks for an un-normalized request target (``/./``, absolute-form) that a future/ fronting
453
+ # router might still route to the RPC handler. Path-independent, exactly like the HTTP door,
454
+ # removes that coincidental-safety gap. (The bearer gate keeps its own RPC-path scoping so the
455
+ # card stays readable pre-auth; only the perimeter widens.)
456
+ return guard_asgi(
457
+ gated,
458
+ allowed_hosts=allowed_hosts or default_allowed_hosts(host),
459
+ protect=lambda p: True)
460
+
461
+
462
+ def serve_a2a(env=None, *, bind="127.0.0.1", port=DEFAULT_PORT, auth=None, allowed_hosts=None):
463
+ """Run the A2A door. Blocks until stopped.
464
+
465
+ Deny-biased start-up order, IDENTICAL to the HTTP door's — every refusal happens BEFORE
466
+ anything binds: (1) a non-loopback ``bind`` with no ``auth`` refuses (exit 2); (2) ``auth``
467
+ must be a reference that resolves to a non-empty token; (3) only then are the SDK imports
468
+ attempted and the broker assembled with ``via.transport: "a2a"``. ``allowed_hosts`` (the
469
+ in-door Host allowlist) is threaded to :func:`build_app`; ``None`` → the bind host +
470
+ loopback forms."""
471
+ import os
472
+ if _bind_requires_auth(bind) and auth is None:
473
+ print(f"error: bind {bind!r} is not loopback — refusing to start the A2A transport "
474
+ "without a bearer token (--auth env:VAR or file:/path). Exposing an "
475
+ "ungoverned door is the one thing a governance product must never do",
476
+ file=sys.stderr)
477
+ return 2
478
+ token = None
479
+ if auth is not None:
480
+ try:
481
+ token = _resolve_transport_token(auth, env if env is not None else dict(os.environ))
482
+ except TransportConfigError as exc:
483
+ print(f"error: {exc}", file=sys.stderr)
484
+ return 2
485
+
486
+ try:
487
+ import uvicorn # noqa: F401 — availability probe before assembling anything
488
+ import a2a # noqa: F401
489
+ except ImportError:
490
+ print("error: the A2A transport needs the 'a2a-sdk' package and 'uvicorn'. "
491
+ "Install with: pip install 'pacioli[a2a]'", file=sys.stderr)
492
+ return 2
493
+
494
+ # Card signing is opt-in: PACIOLI_A2A_SIGNING_KEY_FILE set → load-or-FAIL-LOUD (never serve
495
+ # unsigned when signing was intended); unset → unsigned card. Loaded BEFORE assembling the
496
+ # broker so a bad key path refuses to start rather than serving unsigned.
497
+ signing_key = None
498
+ key_path = (env if env is not None else os.environ).get(SIGNING_KEY_ENV)
499
+ if key_path is not None:
500
+ # PRESENT-but-empty (a broken env interpolation resolving an unset upstream var to "")
501
+ # must FAIL LOUD, not silently serve unsigned — the var being set means signing was
502
+ # intended (security redteam 2026-07-17, Major; same posture as the empty-token refusal
503
+ # in _resolve_transport_token). Absent (None) is the genuine opt-out.
504
+ stripped = key_path.strip()
505
+ if not stripped:
506
+ print(f"error: {SIGNING_KEY_ENV} is set but empty — refusing to serve an unsigned "
507
+ "card when signing was configured; point it at a key (`pacioli a2a-keygen`) "
508
+ f"or UNSET {SIGNING_KEY_ENV} to serve unsigned deliberately", file=sys.stderr)
509
+ return 2
510
+ try:
511
+ signing_key = load_signing_key(stripped)
512
+ except (RuntimeError_, ValueError, OSError) as exc:
513
+ print(f"error: {exc}", file=sys.stderr)
514
+ return 2
515
+
516
+ try:
517
+ broker = assemble(env, via=_a2a_via(auth))
518
+ except RuntimeError_ as exc:
519
+ print(f"error: {exc}", file=sys.stderr)
520
+ return 2
521
+
522
+ import uvicorn
523
+
524
+ app = build_app(broker, rpc_url=f"http://{bind}:{port}/", token=token,
525
+ allowed_hosts=allowed_hosts, signing_key=signing_key)
526
+ card_note = (f"SIGNED (ES256, kid {signing_key.kid[:12]}…); public key at {_JWKS_PATH}"
527
+ if signing_key else "UNSIGNED — set PACIOLI_A2A_SIGNING_KEY_FILE to sign")
528
+ print(f"pacioli A2A transport on {bind}:{port} "
529
+ f"({'bearer token required' if token else 'loopback, no token'}); "
530
+ f"agent card at /.well-known/agent-card.json ({card_note}); "
531
+ "a standing listener by A2A's nature — TLS is the perimeter's job",
532
+ file=sys.stderr)
533
+ uvicorn.run(app, host=bind, port=port, log_level="warning")
534
+ return 0
535
+
536
+
537
+ def keygen(out_path):
538
+ """Mint a fresh EC **P-256** signing key at ``out_path`` (0600, refuse-to-overwrite) for
539
+ ``PACIOLI_A2A_SIGNING_KEY_FILE`` — a deliberate operator act (the key is never auto-minted on
540
+ serve). Returns the loaded :class:`SigningKey` so the caller can print its ``kid``."""
541
+ import os
542
+
543
+ from cryptography.hazmat.primitives import serialization
544
+ from cryptography.hazmat.primitives.asymmetric import ec
545
+
546
+ p = Path(out_path)
547
+ if p.exists():
548
+ raise RuntimeError_(f"refusing to overwrite an existing key at {out_path} — a signing "
549
+ "key is an identity; move or remove it deliberately first")
550
+ # mode=0o700 so an auto-created parent is never group/world-WRITABLE (a writable dir lets a
551
+ # local user unlink-replace the 0600 key); mkdir's mode only narrows under umask, never
552
+ # widens, so 0o700 is a ceiling regardless of umask (security redteam 2026-07-17). An
553
+ # already-existing parent keeps the operator's own perms (exist_ok).
554
+ p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
555
+ pem = ec.generate_private_key(ec.SECP256R1()).private_bytes(
556
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
557
+ serialization.NoEncryption())
558
+ # 0600 from the first byte — never a window where the fresh private key is world-readable.
559
+ try:
560
+ fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
561
+ except OSError as exc:
562
+ # O_EXCL refuses any existing last component — including a DANGLING symlink that
563
+ # `p.exists()` reported False for. Wrap it so the CLI shows a clean error, not a raw
564
+ # traceback (security redteam 2026-07-17). The safety property (no overwrite, no
565
+ # world-readable window) held regardless; this is UX only.
566
+ raise RuntimeError_(f"refusing to create the key at {out_path}: {type(exc).__name__} "
567
+ "(a symlink or special file already occupies the path — remove it "
568
+ "deliberately first)") from exc
569
+ with os.fdopen(fd, "wb") as f:
570
+ f.write(pem)
571
+ return load_signing_key(str(p))