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.
- pacioli/__init__.py +10 -0
- pacioli/a2a.py +571 -0
- pacioli/amend.py +162 -0
- pacioli/anchor.py +244 -0
- pacioli/cascade.py +371 -0
- pacioli/cli.py +1721 -0
- pacioli/clock.py +127 -0
- pacioli/close.py +219 -0
- pacioli/consent.py +123 -0
- pacioli/doctor.py +894 -0
- pacioli/erpnext.py +1194 -0
- pacioli/plan.py +218 -0
- pacioli/prove.py +182 -0
- pacioli/reconcile.py +382 -0
- pacioli/reconciliation.py +427 -0
- pacioli/registry.py +218 -0
- pacioli/response.py +340 -0
- pacioli/runtime.py +139 -0
- pacioli/server.py +296 -0
- pacioli/spine.py +325 -0
- pacioli/store.py +1635 -0
- pacioli/tests/__init__.py +0 -0
- pacioli/tests/_torn_write_crash_worker.py +62 -0
- pacioli/tests/test_a2a.py +553 -0
- pacioli/tests/test_a2a_signing.py +327 -0
- pacioli/tests/test_amend.py +190 -0
- pacioli/tests/test_anchor.py +1024 -0
- pacioli/tests/test_attest_cli.py +616 -0
- pacioli/tests/test_cascade.py +616 -0
- pacioli/tests/test_clock.py +116 -0
- pacioli/tests/test_close.py +677 -0
- pacioli/tests/test_close_advance.py +734 -0
- pacioli/tests/test_close_clock.py +313 -0
- pacioli/tests/test_close_envelope.py +692 -0
- pacioli/tests/test_consent.py +115 -0
- pacioli/tests/test_doctor.py +1273 -0
- pacioli/tests/test_erpnext.py +1739 -0
- pacioli/tests/test_erpnext_reconciliation_reads.py +495 -0
- pacioli/tests/test_plan.py +250 -0
- pacioli/tests/test_prove.py +167 -0
- pacioli/tests/test_reconcile.py +729 -0
- pacioli/tests/test_reconciliation.py +531 -0
- pacioli/tests/test_registry.py +204 -0
- pacioli/tests/test_response.py +358 -0
- pacioli/tests/test_runtime.py +154 -0
- pacioli/tests/test_seal_cli.py +569 -0
- pacioli/tests/test_seal_gate.py +501 -0
- pacioli/tests/test_server.py +112 -0
- pacioli/tests/test_server_http.py +284 -0
- pacioli/tests/test_spine.py +400 -0
- pacioli/tests/test_store.py +514 -0
- pacioli/tests/test_store_close_record.py +1132 -0
- pacioli/tests/test_store_seal.py +829 -0
- pacioli/tests/test_store_torn_write.py +248 -0
- pacioli/tests/test_tool_surface.py +189 -0
- pacioli/tests/test_tools.py +4046 -0
- pacioli/tests/test_version.py +23 -0
- pacioli/tests/test_via.py +80 -0
- pacioli/tests/test_webguard.py +412 -0
- pacioli/tests/test_workflow.py +439 -0
- pacioli/tools.py +2445 -0
- pacioli/webguard.py +245 -0
- pacioli/workflow.py +337 -0
- pacioli-0.30.0.dist-info/METADATA +1006 -0
- pacioli-0.30.0.dist-info/RECORD +69 -0
- pacioli-0.30.0.dist-info/WHEEL +5 -0
- pacioli-0.30.0.dist-info/entry_points.txt +2 -0
- pacioli-0.30.0.dist-info/licenses/LICENSE +202 -0
- 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))
|