agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
agentconnect/policy.py
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
"""The safety policy: one decision for every message, made before any adapter
|
|
2
|
+
sees it (SPEC §10).
|
|
3
|
+
|
|
4
|
+
Encryption keeps the relay out. It does nothing about a member that is
|
|
5
|
+
malicious or confused, so every decrypted message goes through
|
|
6
|
+
:meth:`SafetyPolicy.inbound` and every message the agent sends through
|
|
7
|
+
:meth:`SafetyPolicy.outbound`. Each returns a :class:`Decision`:
|
|
8
|
+
|
|
9
|
+
* ``deliver``: hand it to the adapter, triggering the agent;
|
|
10
|
+
* ``deliver_as_context``: hand it over with ``addressed_to_me: false``
|
|
11
|
+
(not for this member, or past the loop guard, SPEC §9.3 and §10.3);
|
|
12
|
+
* ``hold_for_approval``: keep it until the human approves it with the human
|
|
13
|
+
token (``require_approval``), or drop it when the hold expires;
|
|
14
|
+
* ``drop``: never deliver (or send) it, with a reason.
|
|
15
|
+
|
|
16
|
+
The daemon calls these in exactly one place each (``Engine._make_delivery``
|
|
17
|
+
when a message is received, ``Engine.queue_message`` when one is sent) and
|
|
18
|
+
records the decision with the message. Adapters only ever see what was
|
|
19
|
+
recorded as delivered, and adapters can only send through
|
|
20
|
+
``Engine.adapter_send``, so neither direction can bypass the policy.
|
|
21
|
+
|
|
22
|
+
Settings come from ``[policy]`` in ``config.toml`` (:data:`KNOBS`), with
|
|
23
|
+
per-group overrides and, for the limits, per-sender overrides, which the
|
|
24
|
+
human sets through the local API (``POST /v0/groups/{gid}/safety``) or
|
|
25
|
+
``agentconnect safety set``. The §10.4 verification gate (``require_verified``)
|
|
26
|
+
is part of the group's MLS policy instead, and holds deliveries until the
|
|
27
|
+
human has verified every member (:func:`verification_holds`).
|
|
28
|
+
|
|
29
|
+
Every decision is written to the audit log: who, when, kind, size, decision
|
|
30
|
+
and reason, never the body. With ``transcript`` on, the group's delivered
|
|
31
|
+
and sent messages are also kept in full. Both live in the encrypted daemon
|
|
32
|
+
database with bounded retention.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import enum
|
|
38
|
+
import math
|
|
39
|
+
from collections import OrderedDict, deque
|
|
40
|
+
from collections.abc import Callable, Mapping
|
|
41
|
+
from dataclasses import dataclass, field, fields, replace
|
|
42
|
+
from typing import TYPE_CHECKING, Any
|
|
43
|
+
|
|
44
|
+
from agentconnect.clock import Clock, unix_seconds
|
|
45
|
+
from agentconnect.limits import (
|
|
46
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
47
|
+
GROUP_PER_MINUTE,
|
|
48
|
+
GROUP_TASK_PER_MINUTE,
|
|
49
|
+
IDENTITY_RATE_MULTIPLE,
|
|
50
|
+
MAX_MESSAGE_BYTES,
|
|
51
|
+
MAX_TASK_ID,
|
|
52
|
+
SENDER_PER_MINUTE,
|
|
53
|
+
TASK_PER_MINUTE,
|
|
54
|
+
TASK_UPDATE_KINDS,
|
|
55
|
+
Limits,
|
|
56
|
+
RateLimiter,
|
|
57
|
+
content_problem,
|
|
58
|
+
)
|
|
59
|
+
from agentconnect.schemas import NON_CONTENT_KINDS
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from agentconnect.store import Store
|
|
63
|
+
|
|
64
|
+
# SPEC 7.3: controls only a human-originated message may carry.
|
|
65
|
+
HUMAN_ONLY_KINDS = frozenset({"control.resume"})
|
|
66
|
+
# What require_approval holds: content from (or for) an agent. Controls and
|
|
67
|
+
# Agent Cards carry no content (agentconnect.schemas) and pass, so membership,
|
|
68
|
+
# the loop guard and capabilities keep working.
|
|
69
|
+
HOLDABLE_KINDS = frozenset(
|
|
70
|
+
{"a2a.message", "a2a.task_status", "a2a.task_artifact", "a2a.task_cancel"}
|
|
71
|
+
)
|
|
72
|
+
# Drop reasons that count against the sender (an unknown kind doesn't: SPEC
|
|
73
|
+
# 7.2 makes ignoring it the forward-compatible behaviour).
|
|
74
|
+
VIOLATIONS = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"too_large",
|
|
77
|
+
"malformed",
|
|
78
|
+
"part_kind",
|
|
79
|
+
"file_too_large",
|
|
80
|
+
"rate_limited",
|
|
81
|
+
"not_human",
|
|
82
|
+
"not_owner",
|
|
83
|
+
"hold_overflow",
|
|
84
|
+
"bad_signature",
|
|
85
|
+
# A message that failed in the receiver's own handling (SPEC 7.2).
|
|
86
|
+
"processing_failed",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
APPROVAL_MODES = ("off", "inbound", "outbound", "both")
|
|
90
|
+
DAY_SECS = 86_400
|
|
91
|
+
PURGE_EVERY_SECS = 60
|
|
92
|
+
MAX_AUDIT_ROWS = 100_000
|
|
93
|
+
DEFAULT_TRANSCRIPT_MAX_ROWS = 10_000
|
|
94
|
+
DEFAULT_TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024
|
|
95
|
+
DEFAULT_TRANSCRIPT_SENDER_ROWS = 1000
|
|
96
|
+
DEFAULT_TRANSCRIPT_GROUP_BYTES = 16 * 1024 * 1024
|
|
97
|
+
DEFAULT_AUDIT_GROUP_ROWS = 20_000
|
|
98
|
+
DEFAULT_AUDIT_SENDER_ROWS = 2000
|
|
99
|
+
DEFAULT_MAX_HELD_PER_GROUP = 100
|
|
100
|
+
DEFAULT_MAX_HELD_PER_SENDER = 20
|
|
101
|
+
DEFAULT_MAX_HELD_BYTES = 8 * 1024 * 1024
|
|
102
|
+
DEFAULT_MAX_HELD_SENDER_BYTES = 1024 * 1024
|
|
103
|
+
DEFAULT_TASK_WINDOW_SECS = 600
|
|
104
|
+
MAX_TASK_WINDOW_SECS = 3600
|
|
105
|
+
TASK_DECISION_DAYS = 7 # decided tasks are forgotten after a week untouched
|
|
106
|
+
MAX_TRACKED = 10_000 # (group, sender) violation counters kept at most
|
|
107
|
+
DEFAULT_VIOLATION_THRESHOLD = 10
|
|
108
|
+
DEFAULT_VIOLATION_WINDOW_SECS = 600
|
|
109
|
+
DEFAULT_AUDIT_RETENTION_DAYS = 30
|
|
110
|
+
MAX_PENDING_KNOCKS = 32 # SPEC 6.6.3 step 7, per door
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Verdict(enum.StrEnum):
|
|
114
|
+
DELIVER = "deliver"
|
|
115
|
+
DELIVER_AS_CONTEXT = "deliver_as_context"
|
|
116
|
+
HOLD_FOR_APPROVAL = "hold_for_approval"
|
|
117
|
+
DROP = "drop"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Decision:
|
|
122
|
+
"""What happens to one message, and why (``reason`` is empty for a plain
|
|
123
|
+
``deliver``)."""
|
|
124
|
+
|
|
125
|
+
verdict: Verdict
|
|
126
|
+
reason: str = ""
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def delivered(self) -> bool:
|
|
130
|
+
"""Whether the adapter gets it now (triggering or as context)."""
|
|
131
|
+
return self.verdict in (Verdict.DELIVER, Verdict.DELIVER_AS_CONTEXT)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
DELIVER = Decision(Verdict.DELIVER)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def as_context(reason: str) -> Decision:
|
|
138
|
+
return Decision(Verdict.DELIVER_AS_CONTEXT, reason)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def hold(reason: str) -> Decision:
|
|
142
|
+
return Decision(Verdict.HOLD_FOR_APPROVAL, reason)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def drop(reason: str) -> Decision:
|
|
146
|
+
return Decision(Verdict.DROP, reason)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PolicyError(ValueError):
|
|
150
|
+
"""An invalid setting."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Knob:
|
|
155
|
+
"""One setting: its ``[policy]`` key, type, default and bounds."""
|
|
156
|
+
|
|
157
|
+
name: str
|
|
158
|
+
kind: type
|
|
159
|
+
default: Any
|
|
160
|
+
doc: str
|
|
161
|
+
lo: int = 0
|
|
162
|
+
hi: int = 0
|
|
163
|
+
choices: tuple[str, ...] = ()
|
|
164
|
+
per_sender: bool = False # may be overridden for one sender
|
|
165
|
+
|
|
166
|
+
def check(self, value: Any) -> Any:
|
|
167
|
+
"""``value`` if it is valid for this knob, else :class:`PolicyError`."""
|
|
168
|
+
if not isinstance(value, self.kind) or (self.kind is int and isinstance(value, bool)):
|
|
169
|
+
msg = f"{self.name} must be {self.kind.__name__}"
|
|
170
|
+
raise PolicyError(msg)
|
|
171
|
+
if self.kind is int and not self.lo <= value <= self.hi:
|
|
172
|
+
msg = f"{self.name} must be {self.lo}..{self.hi}"
|
|
173
|
+
raise PolicyError(msg)
|
|
174
|
+
if self.choices and value not in self.choices:
|
|
175
|
+
msg = f"{self.name} must be one of {', '.join(self.choices)}"
|
|
176
|
+
raise PolicyError(msg)
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
KNOBS: tuple[Knob, ...] = (
|
|
181
|
+
Knob(
|
|
182
|
+
"max_message_bytes",
|
|
183
|
+
int,
|
|
184
|
+
MAX_MESSAGE_BYTES,
|
|
185
|
+
"Largest inner message accepted, in bytes (1024 to 262144, the SPEC 10.2 default; "
|
|
186
|
+
"never more).",
|
|
187
|
+
1024,
|
|
188
|
+
MAX_MESSAGE_BYTES,
|
|
189
|
+
per_sender=True,
|
|
190
|
+
),
|
|
191
|
+
Knob(
|
|
192
|
+
"sender_per_minute",
|
|
193
|
+
int,
|
|
194
|
+
SENDER_PER_MINUTE,
|
|
195
|
+
"Messages a minute accepted from one sender instance (1 to 30, the SPEC 10.2 "
|
|
196
|
+
"default; never more).",
|
|
197
|
+
1,
|
|
198
|
+
SENDER_PER_MINUTE,
|
|
199
|
+
per_sender=True,
|
|
200
|
+
),
|
|
201
|
+
Knob(
|
|
202
|
+
"group_per_minute",
|
|
203
|
+
int,
|
|
204
|
+
GROUP_PER_MINUTE,
|
|
205
|
+
"Messages a minute accepted from the whole group (1 to 120, the SPEC 10.2 "
|
|
206
|
+
"default; never more).",
|
|
207
|
+
1,
|
|
208
|
+
GROUP_PER_MINUTE,
|
|
209
|
+
),
|
|
210
|
+
Knob(
|
|
211
|
+
"identity_rate_multiple",
|
|
212
|
+
int,
|
|
213
|
+
IDENTITY_RATE_MULTIPLE,
|
|
214
|
+
"All instances of one identity together may send this many times "
|
|
215
|
+
"`sender_per_minute` (1 to 3, the SPEC 10.2 default), so replicas can't multiply "
|
|
216
|
+
"an identity's budget.",
|
|
217
|
+
1,
|
|
218
|
+
IDENTITY_RATE_MULTIPLE,
|
|
219
|
+
),
|
|
220
|
+
Knob(
|
|
221
|
+
"task_per_minute",
|
|
222
|
+
int,
|
|
223
|
+
TASK_PER_MINUTE,
|
|
224
|
+
"Updates a minute accepted for one task of one sender instance (1 to 300, the "
|
|
225
|
+
"SPEC 10.2 default; never more). All of one identity's task updates together "
|
|
226
|
+
"may be `identity_rate_multiple` times this.",
|
|
227
|
+
1,
|
|
228
|
+
TASK_PER_MINUTE,
|
|
229
|
+
per_sender=True,
|
|
230
|
+
),
|
|
231
|
+
Knob(
|
|
232
|
+
"group_task_per_minute",
|
|
233
|
+
int,
|
|
234
|
+
GROUP_TASK_PER_MINUTE,
|
|
235
|
+
"Task updates a minute accepted from the whole group (1 to 1200, the SPEC 10.2 "
|
|
236
|
+
"default; never more).",
|
|
237
|
+
1,
|
|
238
|
+
GROUP_TASK_PER_MINUTE,
|
|
239
|
+
),
|
|
240
|
+
Knob(
|
|
241
|
+
"allow_files",
|
|
242
|
+
bool,
|
|
243
|
+
False,
|
|
244
|
+
"Accept A2A `file` parts. Off by default: only `text` and `data` parts pass.",
|
|
245
|
+
per_sender=True,
|
|
246
|
+
),
|
|
247
|
+
Knob(
|
|
248
|
+
"max_file_bytes",
|
|
249
|
+
int,
|
|
250
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
251
|
+
"Largest `file` part accepted when `allow_files` is on, in bytes of its JSON "
|
|
252
|
+
"(1 to 262144).",
|
|
253
|
+
1,
|
|
254
|
+
MAX_MESSAGE_BYTES,
|
|
255
|
+
per_sender=True,
|
|
256
|
+
),
|
|
257
|
+
Knob(
|
|
258
|
+
"frame_text",
|
|
259
|
+
bool,
|
|
260
|
+
True,
|
|
261
|
+
"Ask text-oriented consumers to show the SPEC 10.1 notice in front of the text "
|
|
262
|
+
"(`frame_text` in webhook and SSE payloads; the SDK's `msg.text`).",
|
|
263
|
+
),
|
|
264
|
+
Knob(
|
|
265
|
+
"require_approval",
|
|
266
|
+
str,
|
|
267
|
+
"off",
|
|
268
|
+
"Hold agent messages until the human approves them: `off`, `inbound`, `outbound` "
|
|
269
|
+
"or `both`.",
|
|
270
|
+
choices=APPROVAL_MODES,
|
|
271
|
+
),
|
|
272
|
+
Knob(
|
|
273
|
+
"approval_ttl_secs",
|
|
274
|
+
int,
|
|
275
|
+
3600,
|
|
276
|
+
"How long a held message waits for approval before it is dropped (60 to 604800).",
|
|
277
|
+
60,
|
|
278
|
+
604_800,
|
|
279
|
+
),
|
|
280
|
+
Knob(
|
|
281
|
+
"max_held_per_group",
|
|
282
|
+
int,
|
|
283
|
+
DEFAULT_MAX_HELD_PER_GROUP,
|
|
284
|
+
"Messages that may wait for approval in one group at a time (1 to 100). Past it, "
|
|
285
|
+
"another is dropped, not held, and counts as a violation.",
|
|
286
|
+
1,
|
|
287
|
+
DEFAULT_MAX_HELD_PER_GROUP,
|
|
288
|
+
),
|
|
289
|
+
Knob(
|
|
290
|
+
"max_held_per_sender",
|
|
291
|
+
int,
|
|
292
|
+
DEFAULT_MAX_HELD_PER_SENDER,
|
|
293
|
+
"Messages from one sender instance that may wait for approval at a time (1 to 20).",
|
|
294
|
+
1,
|
|
295
|
+
DEFAULT_MAX_HELD_PER_SENDER,
|
|
296
|
+
),
|
|
297
|
+
Knob(
|
|
298
|
+
"max_held_bytes",
|
|
299
|
+
int,
|
|
300
|
+
DEFAULT_MAX_HELD_BYTES,
|
|
301
|
+
"Bytes of messages that may wait for approval in one group at a time "
|
|
302
|
+
"(1024 to 8388608, 8 MiB).",
|
|
303
|
+
1024,
|
|
304
|
+
DEFAULT_MAX_HELD_BYTES,
|
|
305
|
+
),
|
|
306
|
+
Knob(
|
|
307
|
+
"max_held_bytes_per_sender",
|
|
308
|
+
int,
|
|
309
|
+
DEFAULT_MAX_HELD_SENDER_BYTES,
|
|
310
|
+
"Bytes one sender identity may have waiting for approval in a group (1024 to "
|
|
311
|
+
"1048576, 1 MiB), and never more than its share of `max_held_bytes` among the "
|
|
312
|
+
"senders waiting.",
|
|
313
|
+
1024,
|
|
314
|
+
DEFAULT_MAX_HELD_SENDER_BYTES,
|
|
315
|
+
),
|
|
316
|
+
Knob(
|
|
317
|
+
"task_approval_window_secs",
|
|
318
|
+
int,
|
|
319
|
+
DEFAULT_TASK_WINDOW_SECS,
|
|
320
|
+
"How long approving a peer task's first update lets its later inbound updates "
|
|
321
|
+
"through (1 to 3600); the task's end closes it sooner.",
|
|
322
|
+
1,
|
|
323
|
+
MAX_TASK_WINDOW_SECS,
|
|
324
|
+
),
|
|
325
|
+
Knob(
|
|
326
|
+
"violation_threshold",
|
|
327
|
+
int,
|
|
328
|
+
DEFAULT_VIOLATION_THRESHOLD,
|
|
329
|
+
"Policy violations by one sender within `violation_window_secs` that raise a "
|
|
330
|
+
"`repeated_violations` event (1 to 1000).",
|
|
331
|
+
1,
|
|
332
|
+
1000,
|
|
333
|
+
),
|
|
334
|
+
Knob(
|
|
335
|
+
"violation_window_secs",
|
|
336
|
+
int,
|
|
337
|
+
DEFAULT_VIOLATION_WINDOW_SECS,
|
|
338
|
+
"The window violations are counted over (60 to 86400).",
|
|
339
|
+
60,
|
|
340
|
+
86_400,
|
|
341
|
+
),
|
|
342
|
+
Knob(
|
|
343
|
+
"transcript",
|
|
344
|
+
bool,
|
|
345
|
+
False,
|
|
346
|
+
"Keep a full transcript of the messages delivered and sent, in the encrypted "
|
|
347
|
+
"daemon database.",
|
|
348
|
+
),
|
|
349
|
+
Knob(
|
|
350
|
+
"transcript_retention_days",
|
|
351
|
+
int,
|
|
352
|
+
7,
|
|
353
|
+
"Days a transcript entry is kept (1 to 365).",
|
|
354
|
+
1,
|
|
355
|
+
365,
|
|
356
|
+
),
|
|
357
|
+
Knob(
|
|
358
|
+
"transcript_max_rows",
|
|
359
|
+
int,
|
|
360
|
+
DEFAULT_TRANSCRIPT_MAX_ROWS,
|
|
361
|
+
"Transcript entries a group keeps at most (1 to 10000); the oldest go first.",
|
|
362
|
+
1,
|
|
363
|
+
DEFAULT_TRANSCRIPT_MAX_ROWS,
|
|
364
|
+
),
|
|
365
|
+
Knob(
|
|
366
|
+
"transcript_max_rows_per_sender",
|
|
367
|
+
int,
|
|
368
|
+
DEFAULT_TRANSCRIPT_SENDER_ROWS,
|
|
369
|
+
"Transcript entries one sender keeps in a group at most (1 to 1000), so a flood "
|
|
370
|
+
"pushes out only its own.",
|
|
371
|
+
1,
|
|
372
|
+
DEFAULT_TRANSCRIPT_SENDER_ROWS,
|
|
373
|
+
),
|
|
374
|
+
Knob(
|
|
375
|
+
"transcript_max_group_bytes",
|
|
376
|
+
int,
|
|
377
|
+
DEFAULT_TRANSCRIPT_GROUP_BYTES,
|
|
378
|
+
"Bytes a group's transcript keeps at most (65536 to 16777216, 16 MiB).",
|
|
379
|
+
65536,
|
|
380
|
+
DEFAULT_TRANSCRIPT_GROUP_BYTES,
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
KNOB = {k.name: k for k in KNOBS}
|
|
384
|
+
LIMIT_FIELDS = tuple(f.name for f in fields(Limits))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass(frozen=True)
|
|
388
|
+
class Settings:
|
|
389
|
+
"""The effective settings of one group. ``senders`` holds per-sender
|
|
390
|
+
overrides of the limits (identity to ``{knob: value}``)."""
|
|
391
|
+
|
|
392
|
+
max_message_bytes: int = MAX_MESSAGE_BYTES
|
|
393
|
+
sender_per_minute: int = SENDER_PER_MINUTE
|
|
394
|
+
group_per_minute: int = GROUP_PER_MINUTE
|
|
395
|
+
identity_rate_multiple: int = IDENTITY_RATE_MULTIPLE
|
|
396
|
+
task_per_minute: int = TASK_PER_MINUTE
|
|
397
|
+
group_task_per_minute: int = GROUP_TASK_PER_MINUTE
|
|
398
|
+
allow_files: bool = False
|
|
399
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
|
|
400
|
+
frame_text: bool = True
|
|
401
|
+
require_approval: str = "off"
|
|
402
|
+
approval_ttl_secs: int = 3600
|
|
403
|
+
max_held_per_group: int = DEFAULT_MAX_HELD_PER_GROUP
|
|
404
|
+
max_held_per_sender: int = DEFAULT_MAX_HELD_PER_SENDER
|
|
405
|
+
max_held_bytes: int = DEFAULT_MAX_HELD_BYTES
|
|
406
|
+
max_held_bytes_per_sender: int = DEFAULT_MAX_HELD_SENDER_BYTES
|
|
407
|
+
task_approval_window_secs: int = DEFAULT_TASK_WINDOW_SECS
|
|
408
|
+
violation_threshold: int = DEFAULT_VIOLATION_THRESHOLD
|
|
409
|
+
violation_window_secs: int = DEFAULT_VIOLATION_WINDOW_SECS
|
|
410
|
+
transcript: bool = False
|
|
411
|
+
transcript_retention_days: int = 7
|
|
412
|
+
transcript_max_rows: int = DEFAULT_TRANSCRIPT_MAX_ROWS
|
|
413
|
+
transcript_max_rows_per_sender: int = DEFAULT_TRANSCRIPT_SENDER_ROWS
|
|
414
|
+
transcript_max_group_bytes: int = DEFAULT_TRANSCRIPT_GROUP_BYTES
|
|
415
|
+
senders: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def of(values: Mapping[str, Any]) -> Settings:
|
|
419
|
+
"""Settings from ``{knob: value}`` (unknown names are an error)."""
|
|
420
|
+
return Settings(**check_changes(values, sender=False, clear=False))
|
|
421
|
+
|
|
422
|
+
def limits(self, sender: str | None = None) -> Limits:
|
|
423
|
+
"""The §10.2 limits for ``sender`` (its overrides applied)."""
|
|
424
|
+
values: dict[str, Any] = {name: getattr(self, name) for name in LIMIT_FIELDS}
|
|
425
|
+
if sender is not None:
|
|
426
|
+
values.update(self.senders.get(sender, {}))
|
|
427
|
+
return Limits(**values)
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def approve_inbound(self) -> bool:
|
|
431
|
+
return self.require_approval in ("inbound", "both")
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def approve_outbound(self) -> bool:
|
|
435
|
+
return self.require_approval in ("outbound", "both")
|
|
436
|
+
|
|
437
|
+
def as_json(self) -> dict[str, Any]:
|
|
438
|
+
out: dict[str, Any] = {k.name: getattr(self, k.name) for k in KNOBS}
|
|
439
|
+
out["senders"] = {s: dict(v) for s, v in self.senders.items()}
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
def merged(self, overrides: Mapping[str, Any]) -> Settings:
|
|
443
|
+
"""These settings with a group's stored overrides applied."""
|
|
444
|
+
values = {k: v for k, v in overrides.items() if k in KNOB}
|
|
445
|
+
senders = overrides.get("senders")
|
|
446
|
+
return replace(
|
|
447
|
+
self,
|
|
448
|
+
**values,
|
|
449
|
+
senders={str(s): dict(v) for s, v in senders.items()}
|
|
450
|
+
if isinstance(senders, dict)
|
|
451
|
+
else {},
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def check_changes(raw: Mapping[str, Any], *, sender: bool, clear: bool = True) -> dict[str, Any]:
|
|
456
|
+
"""Validate ``{knob: value}``; ``None`` (with ``clear``) removes an
|
|
457
|
+
override. With ``sender``, only the per-sender knobs are allowed."""
|
|
458
|
+
out: dict[str, Any] = {}
|
|
459
|
+
for name, value in raw.items():
|
|
460
|
+
knob = KNOB.get(name)
|
|
461
|
+
if knob is None:
|
|
462
|
+
msg = f"unknown setting {name!r}"
|
|
463
|
+
raise PolicyError(msg)
|
|
464
|
+
if sender and not knob.per_sender:
|
|
465
|
+
msg = f"{name} can't be set for one sender"
|
|
466
|
+
raise PolicyError(msg)
|
|
467
|
+
out[name] = None if value is None and clear else knob.check(value)
|
|
468
|
+
return out
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def apply_changes(
|
|
472
|
+
overrides: Mapping[str, Any], changes: Mapping[str, Any], sender: str | None
|
|
473
|
+
) -> dict[str, Any]:
|
|
474
|
+
"""A group's stored overrides after ``changes`` (validated by
|
|
475
|
+
:func:`check_changes`) for the group or for ``sender``."""
|
|
476
|
+
out = {k: v for k, v in overrides.items() if k != "senders"}
|
|
477
|
+
senders = {str(s): dict(v) for s, v in dict(overrides.get("senders") or {}).items()}
|
|
478
|
+
target = out if sender is None else senders.setdefault(sender, {})
|
|
479
|
+
for name, value in changes.items():
|
|
480
|
+
if value is None:
|
|
481
|
+
target.pop(name, None)
|
|
482
|
+
else:
|
|
483
|
+
target[name] = value
|
|
484
|
+
senders = {s: v for s, v in senders.items() if v}
|
|
485
|
+
if senders:
|
|
486
|
+
out["senders"] = senders
|
|
487
|
+
return out
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def task_of(inner: dict[str, Any]) -> str | None:
|
|
491
|
+
"""The ``taskId`` of a task status or artifact update, else None."""
|
|
492
|
+
body = inner.get("body")
|
|
493
|
+
task = body.get("taskId") if isinstance(body, dict) else None
|
|
494
|
+
if (
|
|
495
|
+
inner.get("kind") in TASK_UPDATE_KINDS
|
|
496
|
+
and isinstance(task, str)
|
|
497
|
+
and 0 < len(task) <= MAX_TASK_ID
|
|
498
|
+
):
|
|
499
|
+
return task
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
TERMINAL_STATES = frozenset(
|
|
504
|
+
{
|
|
505
|
+
"TASK_STATE_COMPLETED",
|
|
506
|
+
"TASK_STATE_FAILED",
|
|
507
|
+
"TASK_STATE_CANCELED",
|
|
508
|
+
"TASK_STATE_REJECTED",
|
|
509
|
+
"completed",
|
|
510
|
+
"failed",
|
|
511
|
+
"canceled",
|
|
512
|
+
"rejected",
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
INTERRUPTED_STATES = frozenset(
|
|
518
|
+
{"TASK_STATE_INPUT_REQUIRED", "TASK_STATE_AUTH_REQUIRED", "input-required", "auth-required"}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def task_state(inner: dict[str, Any]) -> str | None:
|
|
523
|
+
"""The state a task status update reports, in its A2A v1.0 name
|
|
524
|
+
(``TASK_STATE_COMPLETED``; an A2A 0.x name such as ``completed`` is
|
|
525
|
+
mapped to it), else ``None``."""
|
|
526
|
+
body = inner.get("body")
|
|
527
|
+
status = body.get("status") if isinstance(body, dict) else None
|
|
528
|
+
state = status.get("state") if isinstance(status, dict) else None
|
|
529
|
+
if inner.get("kind") != "a2a.task_status" or not isinstance(state, str) or not state:
|
|
530
|
+
return None
|
|
531
|
+
if state.startswith("TASK_STATE_"):
|
|
532
|
+
return state
|
|
533
|
+
return "TASK_STATE_" + str(state).upper().replace("-", "_")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def acts_on(state: str | None) -> bool:
|
|
537
|
+
"""Whether a requester acts on, or learns the outcome from, a task
|
|
538
|
+
entering ``state`` (SPEC 9.3): input or auth required, or an end."""
|
|
539
|
+
return state is not None and (state in INTERRUPTED_STATES or state in TERMINAL_STATES)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def task_ended(inner: dict[str, Any]) -> bool:
|
|
543
|
+
"""Whether a task status update reports a terminal state."""
|
|
544
|
+
body = inner.get("body")
|
|
545
|
+
status = body.get("status") if isinstance(body, dict) else None
|
|
546
|
+
state = status.get("state") if isinstance(status, dict) else None
|
|
547
|
+
return bool(inner.get("kind") == "a2a.task_status" and state in TERMINAL_STATES)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def verification_holds(require_verified: bool, trust_state: Mapping[str, Any]) -> bool:
|
|
551
|
+
"""SPEC 10.4: with ``require_verified``, deliveries wait until
|
|
552
|
+
``trust_state.self`` is ``verified``."""
|
|
553
|
+
return require_verified and str(trust_state.get("self")) != "verified"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@dataclass(frozen=True)
|
|
557
|
+
class Inbound:
|
|
558
|
+
"""One received inner message, as the policy sees it."""
|
|
559
|
+
|
|
560
|
+
group_id: str
|
|
561
|
+
sender: str # the authenticated identity
|
|
562
|
+
instance: str # its instance (SPEC 3.3): rates count per instance
|
|
563
|
+
inner: dict[str, Any]
|
|
564
|
+
size: int # bytes of the inner message's JSON
|
|
565
|
+
owner: str | None # the group's owner (SPEC 7.3: only it may close)
|
|
566
|
+
hop: int
|
|
567
|
+
max_turns: int
|
|
568
|
+
addressed: bool # the floor rules trigger this instance (SPEC 9.3)
|
|
569
|
+
replayed: bool = False # a journal replay: counted when first received
|
|
570
|
+
drain_exempt: Callable[[], bool] = lambda: False # SPEC 8.2/10.2
|
|
571
|
+
card_changed: bool = False # an Agent Card that differs from the one kept
|
|
572
|
+
card_problem: str = "" # an unusable card: malformed (unparsable), bad_signature
|
|
573
|
+
# A task update answering a message this instance sent (SPEC 9.3): only
|
|
574
|
+
# such an update triggers the agent and has the task budget.
|
|
575
|
+
requested: bool = False
|
|
576
|
+
# A requested task update that doesn't move its task to a state the
|
|
577
|
+
# requester acts on (SPEC 9.3): progress, delivered as context.
|
|
578
|
+
progress: bool = False
|
|
579
|
+
# A requested task update entering a state the requester acts on, for the
|
|
580
|
+
# first time for its request (SPEC 9.3): at most three per request, so it
|
|
581
|
+
# is never refused for the task's budget, which its progress used.
|
|
582
|
+
trigger: bool = False
|
|
583
|
+
admitted: bool = False # SafetyPolicy.admit passed it already
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def kind(self) -> str:
|
|
587
|
+
return str(self.inner.get("kind"))
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def origin(self) -> str:
|
|
591
|
+
return "human" if self.inner.get("origin") == "human" else "agent"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@dataclass(frozen=True)
|
|
595
|
+
class Outbound:
|
|
596
|
+
"""One inner message this instance is about to queue."""
|
|
597
|
+
|
|
598
|
+
group_id: str
|
|
599
|
+
sender: str
|
|
600
|
+
instance: str
|
|
601
|
+
inner: dict[str, Any]
|
|
602
|
+
size: int
|
|
603
|
+
internal: bool # the daemon's own (Agent Cards, loop-guard pauses)
|
|
604
|
+
# A floor grant the daemon checked the role for (SPEC 9.3): a control
|
|
605
|
+
# the agent may send, counted against its rate like its messages.
|
|
606
|
+
grant: bool = False
|
|
607
|
+
# This instance's task entering a state its requester acts on, for the
|
|
608
|
+
# first time for the request it answers (SPEC 9.3): never refused for
|
|
609
|
+
# the task's budget, so the requester always learns the outcome.
|
|
610
|
+
trigger: bool = False
|
|
611
|
+
|
|
612
|
+
@property
|
|
613
|
+
def kind(self) -> str:
|
|
614
|
+
return str(self.inner.get("kind"))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@dataclass(frozen=True)
|
|
618
|
+
class KnockInbound:
|
|
619
|
+
"""A knock that passed the owner's checks 1-5 and the replay ledger
|
|
620
|
+
(SPEC 6.6.3), as the policy sees it: who, on which door, and how many
|
|
621
|
+
knocks already wait for the human. Never its name or note."""
|
|
622
|
+
|
|
623
|
+
identity: str # the knocker, authenticated by the knock's KeyPackage
|
|
624
|
+
door_id: str
|
|
625
|
+
pending_on_door: int # knocks on this door waiting for the human
|
|
626
|
+
pending_for_identity: int # this identity's waiting knocks on the listing
|
|
627
|
+
max_pending_per_door: int # knock.max_pending_per_door (at most 32)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
#: Every verdict :meth:`SafetyPolicy.knock` can give. There is no delivering
|
|
631
|
+
#: verdict: a knock is held for the human or dropped (SPEC 6.6.4).
|
|
632
|
+
KNOCK_VERDICTS = frozenset({Verdict.HOLD_FOR_APPROVAL, Verdict.DROP})
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class SafetyPolicy:
|
|
636
|
+
"""The policy for every group of one daemon."""
|
|
637
|
+
|
|
638
|
+
def __init__( # noqa: PLR0913
|
|
639
|
+
self,
|
|
640
|
+
defaults: Settings,
|
|
641
|
+
store: Store,
|
|
642
|
+
clock: Clock,
|
|
643
|
+
*,
|
|
644
|
+
audit_retention_days: int = DEFAULT_AUDIT_RETENTION_DAYS,
|
|
645
|
+
transcript_max_bytes: int = DEFAULT_TRANSCRIPT_MAX_BYTES,
|
|
646
|
+
audit_group_rows: int = DEFAULT_AUDIT_GROUP_ROWS,
|
|
647
|
+
audit_sender_rows: int = DEFAULT_AUDIT_SENDER_ROWS,
|
|
648
|
+
) -> None:
|
|
649
|
+
self.defaults = defaults
|
|
650
|
+
self.store = store
|
|
651
|
+
self.clock = clock
|
|
652
|
+
self.audit_retention_days = audit_retention_days
|
|
653
|
+
self.transcript_max_bytes = transcript_max_bytes
|
|
654
|
+
self.audit_group_rows = audit_group_rows
|
|
655
|
+
self.audit_sender_rows = audit_sender_rows
|
|
656
|
+
self.limiter = RateLimiter(clock)
|
|
657
|
+
# Bounded: pruned by purge(), dropped with their group, and at most
|
|
658
|
+
# MAX_TRACKED senders (least recently seen go first).
|
|
659
|
+
self._violations: OrderedDict[tuple[str, str], deque[int]] = OrderedDict()
|
|
660
|
+
self._alerted: dict[tuple[str, str], float] = {}
|
|
661
|
+
self._purged_at = -math.inf
|
|
662
|
+
|
|
663
|
+
def _now(self) -> int:
|
|
664
|
+
return unix_seconds(self.clock)
|
|
665
|
+
|
|
666
|
+
# -- settings -----------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
def settings(self, group_id: str) -> Settings:
|
|
669
|
+
return self.defaults.merged(self.store.safety_overrides(group_id))
|
|
670
|
+
|
|
671
|
+
def overrides(self, group_id: str) -> dict[str, Any]:
|
|
672
|
+
return self.store.safety_overrides(group_id)
|
|
673
|
+
|
|
674
|
+
def update(
|
|
675
|
+
self, group_id: str, changes: Mapping[str, Any], sender: str | None = None
|
|
676
|
+
) -> Settings:
|
|
677
|
+
"""Change a group's overrides (``None`` clears one); for ``sender``
|
|
678
|
+
only the limits. Raises :class:`PolicyError`."""
|
|
679
|
+
checked = check_changes(changes, sender=sender is not None)
|
|
680
|
+
merged = apply_changes(self.overrides(group_id), checked, sender)
|
|
681
|
+
# Each value was checked on its own (check_changes); merging doesn't
|
|
682
|
+
# check again.
|
|
683
|
+
settings = self.defaults.merged(merged)
|
|
684
|
+
self.store.set_safety_overrides(group_id, merged)
|
|
685
|
+
return settings
|
|
686
|
+
|
|
687
|
+
# -- decisions ----------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
def admit(self, m: Inbound) -> Decision | None:
|
|
690
|
+
"""SPEC 10.2 before anything else reads a received message: its size,
|
|
691
|
+
shape and parts, then the sender's rates, which count it (except
|
|
692
|
+
replays and the reconnect drain). A drop, or ``None``. It comes first
|
|
693
|
+
so that a message that fails later in its handling was counted
|
|
694
|
+
against its sender all the same (SPEC 7.2)."""
|
|
695
|
+
limits = self.settings(m.group_id).limits(m.sender)
|
|
696
|
+
problem = content_problem(m.inner, m.size, limits)
|
|
697
|
+
if problem is not None:
|
|
698
|
+
return drop(problem)
|
|
699
|
+
unrequested = m.kind in TASK_UPDATE_KINDS and not m.requested
|
|
700
|
+
if (
|
|
701
|
+
not m.replayed
|
|
702
|
+
# A trigger that will reach the agent is never lost to the
|
|
703
|
+
# budget its own progress used (SPEC 10.2).
|
|
704
|
+
and not (m.trigger and m.addressed and m.hop <= m.max_turns)
|
|
705
|
+
and not m.drain_exempt()
|
|
706
|
+
and not self.limiter.allow_inner(
|
|
707
|
+
m.group_id,
|
|
708
|
+
m.instance,
|
|
709
|
+
m.kind,
|
|
710
|
+
m.inner.get("body"),
|
|
711
|
+
limits,
|
|
712
|
+
identity=m.sender,
|
|
713
|
+
requested=not unrequested,
|
|
714
|
+
)
|
|
715
|
+
):
|
|
716
|
+
# Another member's task may be honest at any rate its daemon
|
|
717
|
+
# allows: dropping its progress here is no violation. It never
|
|
718
|
+
# triggers, so a flood costs only a bounded audit share.
|
|
719
|
+
return drop("context_rate_limited" if unrequested else "rate_limited")
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
def inbound(self, m: Inbound) -> Decision: # noqa: PLR0911, PLR0912
|
|
723
|
+
"""The decision for a received message: :meth:`admit` first, unless
|
|
724
|
+
it passed already (``m.admitted``), then the rest of the policy."""
|
|
725
|
+
refused = None if m.admitted else self.admit(m)
|
|
726
|
+
if refused is not None:
|
|
727
|
+
return refused
|
|
728
|
+
settings = self.settings(m.group_id)
|
|
729
|
+
unrequested = m.kind in TASK_UPDATE_KINDS and not m.requested
|
|
730
|
+
if m.card_problem:
|
|
731
|
+
return drop(m.card_problem) # SPEC 10.5: not used, and held against it
|
|
732
|
+
if m.kind in HUMAN_ONLY_KINDS and m.origin != "human":
|
|
733
|
+
return drop("not_human") # SPEC 7.3: an agent can't resume
|
|
734
|
+
if m.kind == "control.close" and m.sender != m.owner:
|
|
735
|
+
return drop("not_owner") # SPEC 7.3: only the owner may close
|
|
736
|
+
if m.kind == "a2a.agent_card" and m.card_changed and settings.approve_inbound:
|
|
737
|
+
# A changed card's text reaches the agent: held like content.
|
|
738
|
+
decision = self._hold(
|
|
739
|
+
m.group_id,
|
|
740
|
+
sender=m.sender,
|
|
741
|
+
instance=m.instance,
|
|
742
|
+
inner=m.inner,
|
|
743
|
+
size=m.size,
|
|
744
|
+
settings=settings,
|
|
745
|
+
)
|
|
746
|
+
if decision is not None and decision.verdict is not Verdict.HOLD_FOR_APPROVAL:
|
|
747
|
+
return decision
|
|
748
|
+
if decision is not None:
|
|
749
|
+
return Decision(Verdict.HOLD_FOR_APPROVAL, "card_changed")
|
|
750
|
+
if m.kind == "a2a.agent_card" and m.card_changed:
|
|
751
|
+
# Not held (approve_inbound off): its text still reaches the
|
|
752
|
+
# agent, as context only. The audit log keeps it with content.
|
|
753
|
+
return as_context("card_changed")
|
|
754
|
+
if m.kind in NON_CONTENT_KINDS:
|
|
755
|
+
# Controls and cards carry no content (agentconnect.schemas) and
|
|
756
|
+
# never trigger the agent; they are context only.
|
|
757
|
+
return as_context("not_content")
|
|
758
|
+
if settings.approve_inbound and m.kind in HOLDABLE_KINDS:
|
|
759
|
+
decision = self._hold(
|
|
760
|
+
m.group_id,
|
|
761
|
+
sender=m.sender,
|
|
762
|
+
instance=m.instance,
|
|
763
|
+
inner=m.inner,
|
|
764
|
+
size=m.size,
|
|
765
|
+
settings=settings,
|
|
766
|
+
trigger=m.trigger,
|
|
767
|
+
)
|
|
768
|
+
if (
|
|
769
|
+
(unrequested or m.trigger)
|
|
770
|
+
and decision is not None
|
|
771
|
+
and decision.reason == "hold_overflow"
|
|
772
|
+
):
|
|
773
|
+
# Another member's task, or a task's end, held for the human:
|
|
774
|
+
# a full queue is not that sender's violation.
|
|
775
|
+
decision = drop("hold_queue_full")
|
|
776
|
+
if decision is not None:
|
|
777
|
+
return decision
|
|
778
|
+
if m.hop > m.max_turns:
|
|
779
|
+
return as_context("loop_guard") # SPEC 10.3: don't trigger the agent
|
|
780
|
+
if unrequested:
|
|
781
|
+
return as_context("not_requested") # SPEC 9.3: no task we asked for
|
|
782
|
+
if m.progress:
|
|
783
|
+
return as_context("task_progress") # SPEC 9.3: nothing to act on yet
|
|
784
|
+
if not m.addressed:
|
|
785
|
+
return as_context("not_addressed") # SPEC 9.3
|
|
786
|
+
return DELIVER
|
|
787
|
+
|
|
788
|
+
def outbound(self, m: Outbound) -> Decision:
|
|
789
|
+
"""The decision for a message the agent (or the human) sends. Counts
|
|
790
|
+
it against this sender's rate unless it is the daemon's own."""
|
|
791
|
+
settings = self.settings(m.group_id)
|
|
792
|
+
limits = settings.limits(m.sender)
|
|
793
|
+
problem = content_problem(m.inner, m.size, limits)
|
|
794
|
+
if problem is not None:
|
|
795
|
+
return drop(problem)
|
|
796
|
+
human = m.inner.get("origin") == "human"
|
|
797
|
+
if m.kind in NON_CONTENT_KINDS and not m.internal and not human and not m.grant:
|
|
798
|
+
# Cards and controls come from the daemon or the human, never
|
|
799
|
+
# from the agent: it can't use them to carry content.
|
|
800
|
+
return drop("not_for_agents")
|
|
801
|
+
# The human's own controls (resume above all, to clear the loop
|
|
802
|
+
# guard) are never rate limited; nothing else shares this window.
|
|
803
|
+
exempt = m.internal or m.trigger or (human and m.kind.startswith("control."))
|
|
804
|
+
if not exempt and not self.limiter.allow_inner(
|
|
805
|
+
m.group_id,
|
|
806
|
+
m.instance,
|
|
807
|
+
m.kind,
|
|
808
|
+
m.inner.get("body"),
|
|
809
|
+
limits,
|
|
810
|
+
identity=m.sender,
|
|
811
|
+
direction="out",
|
|
812
|
+
):
|
|
813
|
+
return drop("rate_limited")
|
|
814
|
+
if not m.internal and not human and settings.approve_outbound and m.kind in HOLDABLE_KINDS:
|
|
815
|
+
decision = self._hold(
|
|
816
|
+
m.group_id,
|
|
817
|
+
sender=m.sender,
|
|
818
|
+
instance=m.instance,
|
|
819
|
+
inner=m.inner,
|
|
820
|
+
size=m.size,
|
|
821
|
+
settings=settings,
|
|
822
|
+
direction="out",
|
|
823
|
+
)
|
|
824
|
+
if decision is not None:
|
|
825
|
+
return decision
|
|
826
|
+
return DELIVER
|
|
827
|
+
|
|
828
|
+
@staticmethod
|
|
829
|
+
def knock(m: KnockInbound) -> Decision:
|
|
830
|
+
"""The decision for a verified knock (SPEC 6.6.3 steps 6-8): hold it
|
|
831
|
+
for the human, or drop it. Nothing here can approve one: there is no
|
|
832
|
+
automatic approval of knocks (SPEC 6.6.4), and ``auto_approve`` is
|
|
833
|
+
about joins only. The limits are the SPEC's or lower."""
|
|
834
|
+
if m.pending_for_identity > 0:
|
|
835
|
+
return drop("knock_identity_pending")
|
|
836
|
+
if m.pending_on_door >= min(m.max_pending_per_door, MAX_PENDING_KNOCKS):
|
|
837
|
+
return drop("knock_door_full")
|
|
838
|
+
return hold("knock")
|
|
839
|
+
|
|
840
|
+
def audit_knock(
|
|
841
|
+
self,
|
|
842
|
+
*,
|
|
843
|
+
sender: str,
|
|
844
|
+
msg_id: str,
|
|
845
|
+
size: int,
|
|
846
|
+
decision: str,
|
|
847
|
+
reason: str = "",
|
|
848
|
+
) -> None:
|
|
849
|
+
"""One metadata-only audit row for a knock (no group yet): who (the
|
|
850
|
+
knocker's identity once its knock verified, else ``mailbox:<id>``),
|
|
851
|
+
the envelope's ``msg_id``, the sealed size and the decision. Never
|
|
852
|
+
the knock's name, note or door."""
|
|
853
|
+
self.audit(
|
|
854
|
+
"",
|
|
855
|
+
"in",
|
|
856
|
+
sender=sender,
|
|
857
|
+
msg_id=msg_id,
|
|
858
|
+
kind="knock",
|
|
859
|
+
size=size,
|
|
860
|
+
decision=decision,
|
|
861
|
+
reason=reason,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
def _hold( # noqa: PLR0913
|
|
865
|
+
self,
|
|
866
|
+
group_id: str,
|
|
867
|
+
*,
|
|
868
|
+
sender: str,
|
|
869
|
+
instance: str,
|
|
870
|
+
inner: dict[str, Any],
|
|
871
|
+
size: int,
|
|
872
|
+
settings: Settings,
|
|
873
|
+
direction: str = "in",
|
|
874
|
+
trigger: bool = False,
|
|
875
|
+
) -> Decision | None:
|
|
876
|
+
"""What ``require_approval`` does with one message: hold it, drop it,
|
|
877
|
+
or nothing (``None``: a peer's task the human approved).
|
|
878
|
+
|
|
879
|
+
**Tasks, inbound only.** A peer's task is decided on its first update.
|
|
880
|
+
While that waits, later updates are dropped (``task_pending``), except
|
|
881
|
+
a ``trigger`` (the task asking for input or ending, SPEC 9.3): it is
|
|
882
|
+
held behind the first and follows the human's decision on the task.
|
|
883
|
+
Approving it lets the task's inbound updates through until
|
|
884
|
+
``task_approval_window_secs`` have passed or the task ends; rejecting it (or
|
|
885
|
+
letting it expire) drops them (``task_rejected``) for as long. After
|
|
886
|
+
that window the next update is a new first update. Nothing covers the
|
|
887
|
+
agent's own sends: each is held.
|
|
888
|
+
|
|
889
|
+
**Caps, per direction.** At most ``max_held_per_sender`` per sender
|
|
890
|
+
instance and per identity (all its instances), and per identity at
|
|
891
|
+
most its byte share, ``max_held_bytes_per_sender`` or
|
|
892
|
+
``max_held_bytes`` divided by the senders waiting, whichever is
|
|
893
|
+
smaller: past either the message is dropped as ``hold_overflow``, a
|
|
894
|
+
violation. A sender holding nothing is always within its share, so
|
|
895
|
+
its first message is never a violation, however small the share has
|
|
896
|
+
become. When the group's
|
|
897
|
+
queue (``max_held_per_group``, ``max_held_bytes``) is full, the
|
|
898
|
+
message is dropped too, but only a sender holding something, and at
|
|
899
|
+
least its fair share (the group cap divided by the senders waiting,
|
|
900
|
+
at most the per-sender cap), is charged (``hold_overflow``); anyone
|
|
901
|
+
else gets ``hold_queue_full``, which isn't a violation."""
|
|
902
|
+
task = task_of(inner) if direction == "in" else None
|
|
903
|
+
now = self._now()
|
|
904
|
+
queued = False
|
|
905
|
+
if task is not None:
|
|
906
|
+
decided = self.store.task_decision(group_id, sender, task)
|
|
907
|
+
state, at = decided if decided is not None else ("", -math.inf)
|
|
908
|
+
if now - at < settings.task_approval_window_secs:
|
|
909
|
+
if state == "approved":
|
|
910
|
+
if task_ended(inner):
|
|
911
|
+
self.store.forget_task(group_id, sender, task)
|
|
912
|
+
return None
|
|
913
|
+
if state == "pending" and not trigger:
|
|
914
|
+
return drop("task_pending")
|
|
915
|
+
queued = state == "pending"
|
|
916
|
+
if state == "rejected":
|
|
917
|
+
return drop("task_rejected")
|
|
918
|
+
total, mine, ours, held_bytes, senders, our_bytes = self.store.held_load(
|
|
919
|
+
group_id, direction, instance, sender
|
|
920
|
+
)
|
|
921
|
+
cap = settings.max_held_per_sender
|
|
922
|
+
waiting = max(senders + (0 if ours else 1), 1)
|
|
923
|
+
byte_share = min(settings.max_held_bytes_per_sender, settings.max_held_bytes // waiting)
|
|
924
|
+
# A sender holding nothing is within its share, whatever the share
|
|
925
|
+
# has shrunk to: the group caps below still bound the queue.
|
|
926
|
+
if mine >= cap or ours >= cap or (ours and our_bytes + size > byte_share):
|
|
927
|
+
return drop("hold_overflow")
|
|
928
|
+
if total >= settings.max_held_per_group or held_bytes + size > settings.max_held_bytes:
|
|
929
|
+
share = min(settings.max_held_per_group // waiting, cap)
|
|
930
|
+
return drop("hold_overflow" if ours and ours >= share else "hold_queue_full")
|
|
931
|
+
if task is not None and not queued:
|
|
932
|
+
self.store.set_task_state(group_id, sender, task, "pending", now)
|
|
933
|
+
return hold("require_approval")
|
|
934
|
+
|
|
935
|
+
def decide_task(self, group_id: str, sender: str, task: str, *, approved: bool) -> None:
|
|
936
|
+
"""Record the human's decision on a task's first update."""
|
|
937
|
+
state = "approved" if approved else "rejected"
|
|
938
|
+
self.store.set_task_state(group_id, sender, task, state, self._now())
|
|
939
|
+
|
|
940
|
+
# -- violations -----------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
def violation(self, group_id: str, sender: str, reason: str) -> dict[str, Any] | None:
|
|
943
|
+
"""Count a drop against ``sender``. Returns the data of a
|
|
944
|
+
``repeated_violations`` event when the count reaches the threshold
|
|
945
|
+
(at most once per window and sender), else ``None``."""
|
|
946
|
+
if reason not in VIOLATIONS:
|
|
947
|
+
return None
|
|
948
|
+
settings = self.settings(group_id)
|
|
949
|
+
now = self._now()
|
|
950
|
+
window = settings.violation_window_secs
|
|
951
|
+
key = (group_id, sender)
|
|
952
|
+
times = self._violations.setdefault(key, deque())
|
|
953
|
+
self._violations.move_to_end(key)
|
|
954
|
+
while len(self._violations) > MAX_TRACKED:
|
|
955
|
+
old, _times = self._violations.popitem(last=False)
|
|
956
|
+
self._alerted.pop(old, None)
|
|
957
|
+
while times and times[0] <= now - window:
|
|
958
|
+
times.popleft()
|
|
959
|
+
times.append(now)
|
|
960
|
+
if len(times) < settings.violation_threshold:
|
|
961
|
+
return None
|
|
962
|
+
if now - self._alerted.get(key, -math.inf) < window:
|
|
963
|
+
return None
|
|
964
|
+
self._alerted[key] = now
|
|
965
|
+
return {
|
|
966
|
+
"sender": sender,
|
|
967
|
+
"count": len(times),
|
|
968
|
+
"threshold": settings.violation_threshold,
|
|
969
|
+
"window_secs": window,
|
|
970
|
+
"last_reason": reason,
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
def tracked_senders(self) -> int:
|
|
974
|
+
"""How many (group, sender) violation counters are kept."""
|
|
975
|
+
return len(self._violations)
|
|
976
|
+
|
|
977
|
+
def forget_group(self, group_id: str) -> None:
|
|
978
|
+
"""Drop a group's rate windows and violation counters (it ended)."""
|
|
979
|
+
self.limiter.forget_group(group_id)
|
|
980
|
+
for key in [k for k in self._violations if k[0] == group_id]:
|
|
981
|
+
del self._violations[key]
|
|
982
|
+
self._alerted.pop(key, None)
|
|
983
|
+
|
|
984
|
+
def _prune_violations(self, now: int) -> None:
|
|
985
|
+
windows: dict[str, int] = {}
|
|
986
|
+
for key, times in list(self._violations.items()):
|
|
987
|
+
if key[0] not in windows:
|
|
988
|
+
windows[key[0]] = self.settings(key[0]).violation_window_secs
|
|
989
|
+
window = windows[key[0]]
|
|
990
|
+
while times and times[0] <= now - window:
|
|
991
|
+
times.popleft()
|
|
992
|
+
if not times and now - self._alerted.get(key, -math.inf) >= window:
|
|
993
|
+
del self._violations[key]
|
|
994
|
+
self._alerted.pop(key, None)
|
|
995
|
+
|
|
996
|
+
def violations(self, group_id: str, sender: str) -> int:
|
|
997
|
+
"""Violations by ``sender`` in the current window."""
|
|
998
|
+
times = self._violations.get((group_id, sender), deque())
|
|
999
|
+
window = self.settings(group_id).violation_window_secs
|
|
1000
|
+
return sum(1 for t in times if t > self._now() - window)
|
|
1001
|
+
|
|
1002
|
+
# -- audit log and transcript ------------------------------------------------
|
|
1003
|
+
|
|
1004
|
+
def audit( # noqa: PLR0913
|
|
1005
|
+
self,
|
|
1006
|
+
group_id: str,
|
|
1007
|
+
direction: str,
|
|
1008
|
+
*,
|
|
1009
|
+
sender: str,
|
|
1010
|
+
msg_id: str,
|
|
1011
|
+
kind: str,
|
|
1012
|
+
size: int,
|
|
1013
|
+
decision: str,
|
|
1014
|
+
reason: str = "",
|
|
1015
|
+
) -> None:
|
|
1016
|
+
"""One metadata-only audit row: never the body."""
|
|
1017
|
+
self.store.audit_add(
|
|
1018
|
+
group_id,
|
|
1019
|
+
at=self._now(),
|
|
1020
|
+
direction=direction,
|
|
1021
|
+
sender=sender,
|
|
1022
|
+
msg_id=msg_id,
|
|
1023
|
+
kind=kind,
|
|
1024
|
+
size=size,
|
|
1025
|
+
decision=decision,
|
|
1026
|
+
reason=reason,
|
|
1027
|
+
keep=MAX_AUDIT_ROWS,
|
|
1028
|
+
keep_group=self.audit_group_rows,
|
|
1029
|
+
keep_sender=self.audit_sender_rows,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
def transcribe(
|
|
1033
|
+
self, group_id: str, direction: str, sender: str, msg_id: str, inner: dict[str, Any]
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Keep the full message, if the group has ``transcript`` on."""
|
|
1036
|
+
settings = self.settings(group_id)
|
|
1037
|
+
if settings.transcript:
|
|
1038
|
+
self.store.transcript_add(
|
|
1039
|
+
group_id,
|
|
1040
|
+
at=self._now(),
|
|
1041
|
+
direction=direction,
|
|
1042
|
+
sender=sender,
|
|
1043
|
+
msg_id=msg_id,
|
|
1044
|
+
kind=str(inner.get("kind")),
|
|
1045
|
+
inner=inner,
|
|
1046
|
+
max_rows=settings.transcript_max_rows,
|
|
1047
|
+
max_sender_rows=settings.transcript_max_rows_per_sender,
|
|
1048
|
+
max_group_bytes=settings.transcript_max_group_bytes,
|
|
1049
|
+
max_bytes=self.transcript_max_bytes,
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
def purge(self) -> None:
|
|
1053
|
+
"""Bounded retention: audit rows older than ``audit_retention_days``
|
|
1054
|
+
(and beyond the newest :data:`MAX_AUDIT_ROWS`), transcript entries
|
|
1055
|
+
older than their group's ``transcript_retention_days``. Runs at most
|
|
1056
|
+
once a minute of daemon time."""
|
|
1057
|
+
now = self._now()
|
|
1058
|
+
if now - self._purged_at < PURGE_EVERY_SECS:
|
|
1059
|
+
return
|
|
1060
|
+
self._purged_at = now
|
|
1061
|
+
self._prune_violations(now)
|
|
1062
|
+
self.store.task_states_purge(now - TASK_DECISION_DAYS * DAY_SECS)
|
|
1063
|
+
self.store.audit_purge(now - self.audit_retention_days * DAY_SECS, MAX_AUDIT_ROWS)
|
|
1064
|
+
for group_id in self.store.transcript_groups():
|
|
1065
|
+
days = self.settings(group_id).transcript_retention_days
|
|
1066
|
+
self.store.transcript_purge(group_id, now - days * DAY_SECS)
|