probity-verify 0.1.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.
- probity_verify/__init__.py +5 -0
- probity_verify/a2a_client.py +110 -0
- probity_verify/a2a_server.py +283 -0
- probity_verify/adapters/__init__.py +1 -0
- probity_verify/adapters/authority_anchor.py +148 -0
- probity_verify/adapters/brokered_file_write.py +165 -0
- probity_verify/adapters/event_absence.py +239 -0
- probity_verify/adapters/operand_lineage.py +253 -0
- probity_verify/adapters/source_coverage.py +180 -0
- probity_verify/adapters/spend_reservation.py +334 -0
- probity_verify/broker_records.py +387 -0
- probity_verify/bundle.py +288 -0
- probity_verify/cli.py +47 -0
- probity_verify/common.py +174 -0
- probity_verify/core.py +148 -0
- probity_verify/index.py +344 -0
- probity_verify/json_input.py +64 -0
- probity_verify-0.1.0.dist-info/METADATA +76 -0
- probity_verify-0.1.0.dist-info/RECORD +23 -0
- probity_verify-0.1.0.dist-info/WHEEL +5 -0
- probity_verify-0.1.0.dist-info/entry_points.txt +6 -0
- probity_verify-0.1.0.dist-info/licenses/LICENSE +201 -0
- probity_verify-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Official-SDK client for the optional Verify A2A service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
|
|
14
|
+
from a2a.types import (
|
|
15
|
+
GetTaskRequest, Message, Part, Role, SendMessageConfiguration,
|
|
16
|
+
SendMessageRequest, TaskState,
|
|
17
|
+
)
|
|
18
|
+
from a2a.utils.errors import A2AError
|
|
19
|
+
from google.protobuf.json_format import MessageToDict
|
|
20
|
+
|
|
21
|
+
from .a2a_server import (
|
|
22
|
+
CASE_MIME, DECISION_MIME, LAB_QUERY_MIME, LAB_RECORD_MIME, MAX_CASE_BYTES,
|
|
23
|
+
digest, parse_raw, read_bounded,
|
|
24
|
+
)
|
|
25
|
+
from .core import CaseError, canonical_json
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def request(base_url: str, raw: bytes, mime: str, pin: str) -> tuple[bytes, str]:
|
|
29
|
+
if not raw or len(raw) > MAX_CASE_BYTES:
|
|
30
|
+
raise CaseError("request bytes exceed the input limit")
|
|
31
|
+
expected_mime = DECISION_MIME if mime == CASE_MIME else LAB_RECORD_MIME
|
|
32
|
+
pin_name = "policy_sha256" if mime == CASE_MIME else "register_sha256"
|
|
33
|
+
origin = urlsplit(base_url)
|
|
34
|
+
if (origin.scheme not in {"http", "https"} or not origin.netloc or origin.path not in {"", "/"}
|
|
35
|
+
or origin.username or origin.password or origin.query or origin.fragment):
|
|
36
|
+
raise CaseError("base URL must be an HTTP origin")
|
|
37
|
+
use_environment = origin.hostname not in {"127.0.0.1", "::1", "localhost"}
|
|
38
|
+
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=use_environment) as http:
|
|
39
|
+
card = await A2ACardResolver(http, base_url).get_agent_card()
|
|
40
|
+
interfaces = [i for i in card.supported_interfaces
|
|
41
|
+
if i.protocol_binding == "JSONRPC" and i.protocol_version == "1.0"]
|
|
42
|
+
if (len(interfaces) != 1 or urlsplit(interfaces[0].url).scheme != origin.scheme
|
|
43
|
+
or urlsplit(interfaces[0].url).netloc != origin.netloc
|
|
44
|
+
or urlsplit(interfaces[0].url).path != "/a2a/jsonrpc"):
|
|
45
|
+
raise CaseError("agent card must name its own A2A 1.0 JSON-RPC route")
|
|
46
|
+
client = ClientFactory(ClientConfig(streaming=False, httpx_client=http,
|
|
47
|
+
supported_protocol_bindings=["JSONRPC"])).create(card)
|
|
48
|
+
message = Message(message_id=str(uuid4()), role=Role.ROLE_USER,
|
|
49
|
+
parts=[Part(raw=raw, media_type=mime, metadata={
|
|
50
|
+
"length": len(raw), "sha256": digest(raw), pin_name: pin})])
|
|
51
|
+
task = None
|
|
52
|
+
try:
|
|
53
|
+
async for response in client.send_message(SendMessageRequest(
|
|
54
|
+
message=message, configuration=SendMessageConfiguration(return_immediately=False))):
|
|
55
|
+
if response.HasField("task"):
|
|
56
|
+
task = response.task
|
|
57
|
+
if task is None or task.status.state != TaskState.TASK_STATE_COMPLETED:
|
|
58
|
+
raise CaseError("A2A request did not complete")
|
|
59
|
+
retained = await client.get_task(GetTaskRequest(id=task.id))
|
|
60
|
+
if retained != task:
|
|
61
|
+
raise CaseError("retained task differs from the completed response")
|
|
62
|
+
parts = [part for artifact in task.artifacts for part in artifact.parts
|
|
63
|
+
if part.media_type == expected_mime]
|
|
64
|
+
if len(parts) != 1 or parts[0].WhichOneof("content") != "raw":
|
|
65
|
+
raise CaseError("expected one raw result artifact")
|
|
66
|
+
result = parts[0].raw
|
|
67
|
+
metadata = MessageToDict(parts[0].metadata)
|
|
68
|
+
if (metadata.get("sha256") != digest(result) or metadata.get("length") != len(result)
|
|
69
|
+
or metadata.get(pin_name) != pin):
|
|
70
|
+
raise CaseError("result binding differs")
|
|
71
|
+
if mime == CASE_MIME and metadata.get("case_sha256") != digest(raw):
|
|
72
|
+
raise CaseError("result case binding differs")
|
|
73
|
+
parse_raw(result)
|
|
74
|
+
return result, task.id
|
|
75
|
+
finally:
|
|
76
|
+
await client.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
parser = argparse.ArgumentParser(prog="probity-a2a-client")
|
|
81
|
+
parser.add_argument("--url", default="http://127.0.0.1:41241")
|
|
82
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
83
|
+
verify = commands.add_parser("verify")
|
|
84
|
+
verify.add_argument("case", type=Path)
|
|
85
|
+
verify.add_argument("--policy", type=Path, required=True)
|
|
86
|
+
lab = commands.add_parser("lab-record")
|
|
87
|
+
lab.add_argument("record_id")
|
|
88
|
+
lab.add_argument("--register-sha256", required=True)
|
|
89
|
+
args = parser.parse_args(argv)
|
|
90
|
+
try:
|
|
91
|
+
if args.command == "verify":
|
|
92
|
+
raw = read_bounded(args.case, MAX_CASE_BYTES)
|
|
93
|
+
policy = parse_raw(read_bounded(args.policy, 4 * 1024 * 1024))
|
|
94
|
+
pin = digest(canonical_json(policy))
|
|
95
|
+
mime = CASE_MIME
|
|
96
|
+
else:
|
|
97
|
+
raw = canonical_json({"record_id": args.record_id})
|
|
98
|
+
pin = args.register_sha256
|
|
99
|
+
mime = LAB_QUERY_MIME
|
|
100
|
+
result, _ = asyncio.run(request(args.url, raw, mime, pin))
|
|
101
|
+
except (CaseError, ValueError, OSError, RecursionError, httpx.InvalidURL,
|
|
102
|
+
httpx.HTTPError, A2AError) as exc:
|
|
103
|
+
print(f"probity-a2a-client: {exc}", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
sys.stdout.buffer.write(result)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Optional A2A 1.0 transport for operator-configured Verify and Lab reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import copy
|
|
8
|
+
import hashlib
|
|
9
|
+
import ipaddress
|
|
10
|
+
import sys
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
16
|
+
from a2a.server.events import EventQueue
|
|
17
|
+
from a2a.server.request_handlers import DefaultRequestHandler
|
|
18
|
+
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
|
19
|
+
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
|
|
20
|
+
from a2a.types import (
|
|
21
|
+
AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Part,
|
|
22
|
+
Task, TaskState, TaskStatus,
|
|
23
|
+
)
|
|
24
|
+
from a2a.utils.errors import InvalidParamsError, UnsupportedOperationError
|
|
25
|
+
from google.protobuf.json_format import MessageToDict
|
|
26
|
+
from starlette.applications import Starlette
|
|
27
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
28
|
+
from starlette.routing import Route
|
|
29
|
+
|
|
30
|
+
from .common import _read_input
|
|
31
|
+
from .json_input import parse_json
|
|
32
|
+
from .core import CaseError, adjudicate, canonical_json, render_packet
|
|
33
|
+
|
|
34
|
+
CASE_MIME = "application/vnd.probity.case+json"
|
|
35
|
+
DECISION_MIME = "application/vnd.probity.decision+json"
|
|
36
|
+
LAB_QUERY_MIME = "application/vnd.probity.lab-query+json"
|
|
37
|
+
LAB_RECORD_MIME = "application/vnd.probity.lab-record+json"
|
|
38
|
+
MAX_CASE_BYTES = 256 * 1024
|
|
39
|
+
MAX_REQUEST_BYTES = 384 * 1024
|
|
40
|
+
MAX_RETAINED_TASKS = 128
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def digest(raw: bytes) -> str:
|
|
44
|
+
return hashlib.sha256(raw).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_raw(raw: bytes) -> object:
|
|
48
|
+
"""Use bounded duplicate-key and non-JSON-number admission rules."""
|
|
49
|
+
return parse_json(raw, max_bytes=16 * 1024 * 1024, max_depth=32, max_nodes=32768)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_bounded(path: Path, limit: int) -> bytes:
|
|
53
|
+
return _read_input(path, limit, "configured input")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BoundedTaskStore(InMemoryTaskStore):
|
|
57
|
+
"""Retain the latest completed tasks through the SDK's public store API."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, limit: int = MAX_RETAINED_TASKS) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
if limit < 1:
|
|
62
|
+
raise ValueError("task retention limit must be positive")
|
|
63
|
+
self.limit = limit
|
|
64
|
+
self.completed = OrderedDict()
|
|
65
|
+
self.retention_lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
async def save(self, task, context) -> None:
|
|
68
|
+
async with self.retention_lock:
|
|
69
|
+
await super().save(task, context)
|
|
70
|
+
if task.status.state == TaskState.TASK_STATE_COMPLETED:
|
|
71
|
+
self.completed[task.id] = context
|
|
72
|
+
self.completed.move_to_end(task.id)
|
|
73
|
+
while len(self.completed) > self.limit:
|
|
74
|
+
expired, owner = self.completed.popitem(last=False)
|
|
75
|
+
await super().delete(expired, owner)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class VerifyExecutor(AgentExecutor):
|
|
79
|
+
def __init__(self, policy: bytes, artifact_root: Path,
|
|
80
|
+
lab_register: bytes | None = None,
|
|
81
|
+
lab_register_sha256: str | None = None) -> None:
|
|
82
|
+
self.policy = parse_raw(policy)
|
|
83
|
+
if not isinstance(self.policy, dict):
|
|
84
|
+
raise CaseError("consumer policy must be a JSON object")
|
|
85
|
+
self.policy_sha256 = digest(canonical_json(self.policy))
|
|
86
|
+
self.policy_source_sha256 = digest(policy)
|
|
87
|
+
self.artifact_root = artifact_root.absolute()
|
|
88
|
+
if not self.artifact_root.is_dir():
|
|
89
|
+
raise CaseError("artifact root must be a directory")
|
|
90
|
+
self.records: dict[str, dict] = {}
|
|
91
|
+
self.lab_register_sha256 = None
|
|
92
|
+
if lab_register is not None:
|
|
93
|
+
if digest(lab_register) != lab_register_sha256:
|
|
94
|
+
raise CaseError("Lab register digest differs from the operator pin")
|
|
95
|
+
register = parse_raw(lab_register)
|
|
96
|
+
if not isinstance(register, dict) or not isinstance(register.get("records"), list):
|
|
97
|
+
raise CaseError("Lab register must contain records")
|
|
98
|
+
for record in register["records"]:
|
|
99
|
+
if (not isinstance(record, dict) or not isinstance(record.get("id"), str)
|
|
100
|
+
or not record["id"] or record["id"] in self.records):
|
|
101
|
+
raise CaseError("Lab record IDs must be distinct non-empty strings")
|
|
102
|
+
self.records[record["id"]] = record
|
|
103
|
+
self.lab_register_sha256 = lab_register_sha256
|
|
104
|
+
elif lab_register_sha256 is not None:
|
|
105
|
+
raise CaseError("Lab register pin needs a configured register")
|
|
106
|
+
self.capacity = asyncio.Semaphore(4)
|
|
107
|
+
|
|
108
|
+
def assess(self, part: Part) -> tuple[bytes, str, dict, str | None]:
|
|
109
|
+
if part.WhichOneof("content") != "raw" or not part.raw or len(part.raw) > MAX_CASE_BYTES:
|
|
110
|
+
raise CaseError("supply one bounded raw JSON part")
|
|
111
|
+
metadata = MessageToDict(part.metadata)
|
|
112
|
+
raw_hash = digest(part.raw)
|
|
113
|
+
expected = {"sha256", "length", "policy_sha256" if part.media_type == CASE_MIME
|
|
114
|
+
else "register_sha256"}
|
|
115
|
+
if (set(metadata) != expected or metadata.get("sha256") != raw_hash
|
|
116
|
+
or isinstance(metadata.get("length"), bool)
|
|
117
|
+
or metadata.get("length") != len(part.raw)):
|
|
118
|
+
raise CaseError("raw part binding is missing or differs")
|
|
119
|
+
value = parse_raw(part.raw)
|
|
120
|
+
if part.media_type == CASE_MIME:
|
|
121
|
+
if metadata["policy_sha256"] != self.policy_sha256:
|
|
122
|
+
raise CaseError("consumer policy digest differs")
|
|
123
|
+
result = adjudicate(value, copy.deepcopy(self.policy), self.artifact_root)
|
|
124
|
+
bindings = {"case_sha256": raw_hash, "policy_sha256": self.policy_sha256,
|
|
125
|
+
"policy_source_sha256": self.policy_source_sha256}
|
|
126
|
+
return canonical_json(result), DECISION_MIME, bindings, render_packet(result)
|
|
127
|
+
if part.media_type == LAB_QUERY_MIME:
|
|
128
|
+
if (not self.lab_register_sha256
|
|
129
|
+
or metadata["register_sha256"] != self.lab_register_sha256):
|
|
130
|
+
raise CaseError("Lab register digest differs")
|
|
131
|
+
if (not isinstance(value, dict) or set(value) != {"record_id"}
|
|
132
|
+
or not isinstance(value["record_id"], str)
|
|
133
|
+
or value["record_id"] not in self.records):
|
|
134
|
+
raise CaseError("unknown Lab record ID")
|
|
135
|
+
result = {"schema_version": "probity-lab-record/v1",
|
|
136
|
+
"register_sha256": self.lab_register_sha256,
|
|
137
|
+
"record": self.records[value["record_id"]]}
|
|
138
|
+
return canonical_json(result), LAB_RECORD_MIME, {
|
|
139
|
+
"register_sha256": self.lab_register_sha256}, None
|
|
140
|
+
raise CaseError("unsupported input media type")
|
|
141
|
+
|
|
142
|
+
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
143
|
+
message = context.message
|
|
144
|
+
if (not message or len(message.parts) != 1
|
|
145
|
+
or not context.task_id or not context.context_id):
|
|
146
|
+
raise InvalidParamsError(message="Supply a new request with one raw JSON part")
|
|
147
|
+
try:
|
|
148
|
+
async with self.capacity:
|
|
149
|
+
raw, mime, bindings, packet = await asyncio.to_thread(self.assess, message.parts[0])
|
|
150
|
+
except (CaseError, ValueError, OSError, RecursionError) as exc:
|
|
151
|
+
raise InvalidParamsError(message=str(exc)) from exc
|
|
152
|
+
await event_queue.enqueue_event(Task(
|
|
153
|
+
id=context.task_id, context_id=context.context_id,
|
|
154
|
+
status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), history=[message]))
|
|
155
|
+
updater = TaskUpdater(event_queue, context.task_id, context.context_id)
|
|
156
|
+
parts = [Part(raw=raw, media_type=mime,
|
|
157
|
+
metadata={**bindings, "sha256": digest(raw), "length": len(raw)})]
|
|
158
|
+
if packet is not None:
|
|
159
|
+
parts.append(Part(text=packet, media_type="text/plain"))
|
|
160
|
+
await updater.add_artifact(parts, name="probity-result", last_chunk=True)
|
|
161
|
+
await updater.complete()
|
|
162
|
+
|
|
163
|
+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
164
|
+
raise UnsupportedOperationError(message="Assessments complete in one request")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class BoundedJSON:
|
|
168
|
+
"""Admit bounded raw HTTP JSON before the SDK's protocol parsing."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, app: Starlette) -> None:
|
|
171
|
+
self.app = app
|
|
172
|
+
|
|
173
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
174
|
+
if scope["type"] != "http" or scope["method"] != "POST":
|
|
175
|
+
await self.app(scope, receive, send)
|
|
176
|
+
return
|
|
177
|
+
body = bytearray()
|
|
178
|
+
while True:
|
|
179
|
+
event = await receive()
|
|
180
|
+
if event["type"] == "http.disconnect":
|
|
181
|
+
return
|
|
182
|
+
body.extend(event.get("body", b""))
|
|
183
|
+
if len(body) > MAX_REQUEST_BYTES:
|
|
184
|
+
response = JSONResponse({"error": "request exceeds the byte limit"}, status_code=413)
|
|
185
|
+
await response(scope, receive, send)
|
|
186
|
+
return
|
|
187
|
+
if not event.get("more_body", False):
|
|
188
|
+
break
|
|
189
|
+
try:
|
|
190
|
+
value = parse_raw(bytes(body))
|
|
191
|
+
except (CaseError, ValueError, RecursionError) as exc:
|
|
192
|
+
response = JSONResponse({"jsonrpc": "2.0", "id": None,
|
|
193
|
+
"error": {"code": -32600, "message": str(exc)}})
|
|
194
|
+
await response(scope, receive, send)
|
|
195
|
+
return
|
|
196
|
+
if isinstance(value, dict) and isinstance(value.get("params"), dict):
|
|
197
|
+
message = value["params"].get("message")
|
|
198
|
+
if isinstance(message, dict) and (message.get("taskId") or message.get("task_id")):
|
|
199
|
+
response = JSONResponse({"jsonrpc": "2.0", "id": value.get("id"),
|
|
200
|
+
"error": {"code": -32602,
|
|
201
|
+
"message": "Supply a new assessment request"}})
|
|
202
|
+
await response(scope, receive, send)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
async def replay():
|
|
206
|
+
return {"type": "http.request", "body": bytes(body), "more_body": False}
|
|
207
|
+
|
|
208
|
+
await self.app(scope, replay, send)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def create_app(policy: bytes, artifact_root: Path, base_url: str,
|
|
212
|
+
lab_register: bytes | None = None,
|
|
213
|
+
lab_register_sha256: str | None = None):
|
|
214
|
+
url = urlsplit(base_url)
|
|
215
|
+
if url.scheme not in {"http", "https"} or not url.netloc or url.path not in {"", "/"}:
|
|
216
|
+
raise CaseError("base URL must be an HTTP origin")
|
|
217
|
+
if url.username or url.password or url.query or url.fragment:
|
|
218
|
+
raise CaseError("base URL must be an HTTP origin")
|
|
219
|
+
base_url = base_url.rstrip("/")
|
|
220
|
+
executor = VerifyExecutor(policy, artifact_root, lab_register, lab_register_sha256)
|
|
221
|
+
skills = [AgentSkill(id="verify_case", name="Verify a pinned case",
|
|
222
|
+
description="Assess raw case bytes against operator-held policy and artifacts.",
|
|
223
|
+
tags=["evidence", "verification"], input_modes=[CASE_MIME],
|
|
224
|
+
output_modes=[DECISION_MIME, "text/plain"])]
|
|
225
|
+
if executor.lab_register_sha256:
|
|
226
|
+
skills.append(AgentSkill(id="read_lab_record", name="Read a pinned Lab record",
|
|
227
|
+
description="Return one retained publisher record from the configured register.",
|
|
228
|
+
tags=["evidence", "research"], input_modes=[LAB_QUERY_MIME],
|
|
229
|
+
output_modes=[LAB_RECORD_MIME]))
|
|
230
|
+
card = AgentCard(name="Probity Verify", version="0.1.0",
|
|
231
|
+
description="Consumer-policy assessments and pinned Lab record reads.",
|
|
232
|
+
documentation_url="https://github.com/probityai/probity-verify/blob/main/docs/a2a.md",
|
|
233
|
+
capabilities=AgentCapabilities(streaming=False, push_notifications=False),
|
|
234
|
+
default_input_modes=[skill.input_modes[0] for skill in skills],
|
|
235
|
+
default_output_modes=list(dict.fromkeys(mode for skill in skills
|
|
236
|
+
for mode in skill.output_modes)), skills=skills,
|
|
237
|
+
supported_interfaces=[AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0",
|
|
238
|
+
url=base_url + "/a2a/jsonrpc")])
|
|
239
|
+
handler = DefaultRequestHandler(executor, BoundedTaskStore(), card)
|
|
240
|
+
discovery = ("# Probity Verify\n\nA2A 1.0 JSON-RPC.\n"
|
|
241
|
+
f"Agent card: {base_url}/.well-known/agent-card.json\n"
|
|
242
|
+
f"POST: {base_url}/a2a/jsonrpc\n"
|
|
243
|
+
f"Consumer policy SHA-256: {executor.policy_sha256}\n"
|
|
244
|
+
f"Policy source SHA-256: {executor.policy_source_sha256}\n"
|
|
245
|
+
"Send raw JSON case bytes with length, SHA-256 and consumer policy digest.\n"
|
|
246
|
+
"Results are byte-bound Verify decision packets. The latest 128 tasks last for this process.\n")
|
|
247
|
+
if executor.lab_register_sha256:
|
|
248
|
+
discovery += f"Lab register SHA-256: {executor.lab_register_sha256}\n"
|
|
249
|
+
|
|
250
|
+
async def llms(request):
|
|
251
|
+
return PlainTextResponse(discovery)
|
|
252
|
+
|
|
253
|
+
routes = create_agent_card_routes(card) + create_jsonrpc_routes(handler, "/a2a/jsonrpc")
|
|
254
|
+
routes.append(Route("/llms.txt", llms))
|
|
255
|
+
return BoundedJSON(Starlette(routes=routes))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main(argv: list[str] | None = None) -> int:
|
|
259
|
+
parser = argparse.ArgumentParser(prog="probity-a2a")
|
|
260
|
+
parser.add_argument("--policy", type=Path, required=True)
|
|
261
|
+
parser.add_argument("--artifact-root", type=Path, required=True)
|
|
262
|
+
parser.add_argument("--lab-register", type=Path)
|
|
263
|
+
parser.add_argument("--lab-register-sha256")
|
|
264
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
265
|
+
parser.add_argument("--port", type=int, default=41241)
|
|
266
|
+
args = parser.parse_args(argv)
|
|
267
|
+
try:
|
|
268
|
+
if not ipaddress.ip_address(args.host).is_loopback or not 1 <= args.port <= 65535:
|
|
269
|
+
raise CaseError("bind to a loopback address and a valid port")
|
|
270
|
+
origin = f"http://[{args.host}]:{args.port}" if ":" in args.host else f"http://{args.host}:{args.port}"
|
|
271
|
+
app = create_app(read_bounded(args.policy, 4 * 1024 * 1024), args.artifact_root, origin,
|
|
272
|
+
read_bounded(args.lab_register, 16 * 1024 * 1024) if args.lab_register else None,
|
|
273
|
+
args.lab_register_sha256)
|
|
274
|
+
except (CaseError, ValueError, OSError, RecursionError) as exc:
|
|
275
|
+
print(f"probity-a2a: {exc}", file=sys.stderr)
|
|
276
|
+
return 2
|
|
277
|
+
import uvicorn
|
|
278
|
+
uvicorn.run(app, host=args.host, port=args.port, access_log=False, limit_concurrency=16)
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Versioned claim adapters. Each returns the common decision envelope."""
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Match an identity claim against a consumer-pinned authority capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from ..common import CaseError, _artifact, _digest, _instant, _object, _string
|
|
11
|
+
|
|
12
|
+
CLAIM_TYPE = "authority_anchor/v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _https_url(value: Any, label: str) -> str:
|
|
16
|
+
url = _string(value, label)
|
|
17
|
+
try:
|
|
18
|
+
parsed = urlsplit(url)
|
|
19
|
+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or
|
|
20
|
+
parsed.password or parsed.fragment):
|
|
21
|
+
raise ValueError()
|
|
22
|
+
parsed.port
|
|
23
|
+
except ValueError as exc:
|
|
24
|
+
raise CaseError(f"{label}: expected HTTPS URL without credentials or fragment") from exc
|
|
25
|
+
return url
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _capture(data: bytes) -> dict:
|
|
29
|
+
"""Read a bound JSON capture without accepting ambiguous object keys."""
|
|
30
|
+
def unique(pairs: list[tuple[str, Any]]) -> dict:
|
|
31
|
+
value: dict = {}
|
|
32
|
+
for key, item in pairs:
|
|
33
|
+
if key in value:
|
|
34
|
+
raise ValueError("duplicate JSON key")
|
|
35
|
+
value[key] = item
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
value = json.loads(data.decode("utf-8"), object_pairs_hook=unique,
|
|
40
|
+
parse_constant=lambda _: (_ for _ in ()).throw(ValueError()))
|
|
41
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
42
|
+
raise CaseError("capture is not unambiguous UTF-8 JSON") from exc
|
|
43
|
+
return _object(value, "capture", {"schema_version", "request_url", "captured_at",
|
|
44
|
+
"status", "body"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pointer(body: Any, pointer: str) -> str | None:
|
|
48
|
+
"""Resolve an RFC 6901 pointer. A missing or non-string field is unavailable."""
|
|
49
|
+
current = body
|
|
50
|
+
for part in pointer.split("/")[1:]:
|
|
51
|
+
key = part.replace("~1", "/").replace("~0", "~")
|
|
52
|
+
if isinstance(current, dict):
|
|
53
|
+
if key not in current:
|
|
54
|
+
return None
|
|
55
|
+
current = current[key]
|
|
56
|
+
elif (isinstance(current, list) and key.isascii() and key.isdecimal() and
|
|
57
|
+
len(key) <= 19 and (key == "0" or not key.startswith("0")) and
|
|
58
|
+
int(key) < len(current)):
|
|
59
|
+
current = current[int(key)]
|
|
60
|
+
else:
|
|
61
|
+
return None
|
|
62
|
+
return current if isinstance(current, str) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def adjudicate(case: Any, policy: Any, artifact_root: Path) -> dict:
|
|
66
|
+
"""Decide an exact JSON field in a bound capture, within a policy time window.
|
|
67
|
+
|
|
68
|
+
The consumer must establish the capture's provenance outside this program.
|
|
69
|
+
The program neither contacts the authority nor authenticates its transport.
|
|
70
|
+
"""
|
|
71
|
+
case = _object(case, "case", {"schema_version", "case_id", "artifacts"})
|
|
72
|
+
policy = _object(policy, "policy", {"schema_version", "witnesses", "assessments"})
|
|
73
|
+
if case["schema_version"] != "probity-case/v1" or policy["schema_version"] != "probity-policy/v1":
|
|
74
|
+
raise CaseError("unsupported schema_version")
|
|
75
|
+
case_id = _string(case["case_id"], "case_id")
|
|
76
|
+
artifacts = case["artifacts"]
|
|
77
|
+
witnesses = policy["witnesses"]
|
|
78
|
+
assessments = policy["assessments"]
|
|
79
|
+
if not isinstance(artifacts, dict) or not isinstance(witnesses, dict) or not isinstance(assessments, dict):
|
|
80
|
+
raise CaseError("artifacts, witnesses, and assessments: expected objects")
|
|
81
|
+
if case_id not in assessments:
|
|
82
|
+
raise CaseError("case_id has no consumer assessment")
|
|
83
|
+
assessment = _object(assessments[case_id], "assessment", {
|
|
84
|
+
"claim_type", "capture_witness", "request_url", "json_pointer",
|
|
85
|
+
"expected_value", "capture_window"
|
|
86
|
+
})
|
|
87
|
+
if assessment["claim_type"] != CLAIM_TYPE:
|
|
88
|
+
raise CaseError("unsupported claim_type")
|
|
89
|
+
witness_id = _string(assessment["capture_witness"], "capture_witness")
|
|
90
|
+
request_url = _https_url(assessment["request_url"], "request_url")
|
|
91
|
+
expected = _string(assessment["expected_value"], "expected_value")
|
|
92
|
+
pointer = _string(assessment["json_pointer"], "json_pointer")
|
|
93
|
+
if not pointer.startswith("/") or any(
|
|
94
|
+
pointer[i + 1:i + 2] not in {"0", "1"}
|
|
95
|
+
for i, char in enumerate(pointer) if char == "~"
|
|
96
|
+
):
|
|
97
|
+
raise CaseError("json_pointer: expected nonempty RFC 6901 pointer")
|
|
98
|
+
window = _object(assessment["capture_window"], "capture_window", {"start", "end"})
|
|
99
|
+
start = _instant(window["start"], "capture_window.start")
|
|
100
|
+
end = _instant(window["end"], "capture_window.end")
|
|
101
|
+
if end < start:
|
|
102
|
+
raise CaseError("capture_window.end precedes start")
|
|
103
|
+
if witness_id not in witnesses:
|
|
104
|
+
raise CaseError("capture_witness has no consumer pin")
|
|
105
|
+
witness = _object(witnesses[witness_id], "witness", {"artifact", "sha256", "authority"})
|
|
106
|
+
artifact_id = _string(witness["artifact"], "witness.artifact")
|
|
107
|
+
pinned = _digest(witness["sha256"], "witness.sha256")
|
|
108
|
+
authority = _string(witness["authority"], "witness.authority")
|
|
109
|
+
if artifact_id not in artifacts:
|
|
110
|
+
raise CaseError("capture_witness names no case artifact")
|
|
111
|
+
data, binding = _artifact(artifact_root, artifacts[artifact_id], f"artifacts.{artifact_id}")
|
|
112
|
+
scope = {
|
|
113
|
+
"claim_type": CLAIM_TYPE, "request_url": request_url,
|
|
114
|
+
"capture_window": window, "json_pointer": pointer, "expected_value": expected,
|
|
115
|
+
"witness": {"id": witness_id, "authority": authority,
|
|
116
|
+
"consumer_pinned_sha256": pinned, "artifact": binding},
|
|
117
|
+
"limit": "The consumer pinned these capture bytes. This program does not authenticate the authority, transport, or capture time, or infer that a class-level detection stamp proves this particular observation.",
|
|
118
|
+
}
|
|
119
|
+
result = {"schema_version": "probity-decision/v1", "case_id": case_id,
|
|
120
|
+
"claim_type": CLAIM_TYPE, "scope": scope, "checks": []}
|
|
121
|
+
if data is None or binding["sha256"] != pinned:
|
|
122
|
+
return {**result, "decision": "not_established", "reason": "witness_unavailable_or_unbound"}
|
|
123
|
+
try:
|
|
124
|
+
capture = _capture(data)
|
|
125
|
+
if capture["schema_version"] != "probity-http-capture/v1":
|
|
126
|
+
raise CaseError("unsupported capture schema_version")
|
|
127
|
+
observed_at = _instant(capture["captured_at"], "capture.captured_at")
|
|
128
|
+
if type(capture["status"]) is not int or not 100 <= capture["status"] <= 599:
|
|
129
|
+
raise CaseError("capture.status: expected HTTP status")
|
|
130
|
+
_https_url(capture["request_url"], "capture.request_url")
|
|
131
|
+
except CaseError:
|
|
132
|
+
return {**result, "decision": "not_established", "reason": "capture_unreadable"}
|
|
133
|
+
scope["witness"]["captured_at"] = capture["captured_at"]
|
|
134
|
+
scope["witness"]["status"] = capture["status"]
|
|
135
|
+
if capture["request_url"] != request_url:
|
|
136
|
+
return {**result, "decision": "not_established", "reason": "capture_target_mismatch"}
|
|
137
|
+
if not start <= observed_at <= end:
|
|
138
|
+
return {**result, "decision": "not_established", "reason": "capture_outside_window"}
|
|
139
|
+
if capture["status"] != 200:
|
|
140
|
+
return {**result, "decision": "not_established", "reason": "authority_response_unavailable"}
|
|
141
|
+
observed = _pointer(capture["body"], pointer)
|
|
142
|
+
result["checks"] = [{"id": "anchor", "status": "unavailable" if observed is None else
|
|
143
|
+
"met" if observed == expected else "failed", "observed": observed}]
|
|
144
|
+
if observed is None:
|
|
145
|
+
return {**result, "decision": "not_established", "reason": "field_unavailable"}
|
|
146
|
+
if observed != expected:
|
|
147
|
+
return {**result, "decision": "contradicted", "reason": "anchor_mismatch"}
|
|
148
|
+
return {**result, "decision": "supported", "reason": "anchor_matches"}
|