@securecoms/agent-bridge 0.21.0 → 0.24.0
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.
- package/README.md +91 -3
- package/connectors/hermes/adapter.py +63 -7
- package/connectors/hermes/tests/test_adapter_wiring.py +23 -0
- package/connectors/hermes/tests/test_logging_privacy.py +61 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +561 -0
- package/connectors/openclaw/openclaw-entry.js +270 -47
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +26 -0
- package/dist/bin.d.ts +3 -7
- package/dist/bin.js +7626 -954
- package/dist/bridge-agent.d.ts +846 -30
- package/dist/coordination.d.ts +112 -5
- package/dist/decrypt-error-log-limiter.d.ts +6 -30
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +24 -5
- package/dist/index.js +7608 -927
- package/dist/reply-intent.d.ts +68 -0
- package/dist/service-log.d.ts +7 -0
- package/dist/summarize.d.ts +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/voice/fast-client.d.ts +12 -2
- package/dist/ws-server.d.ts +37 -3
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -213,6 +213,15 @@ HTTP 503
|
|
|
213
213
|
|
|
214
214
|
Retry with exponential back-off. The channel becomes available once the owner's client adds the agent device to the MLS group.
|
|
215
215
|
|
|
216
|
+
**`POST /v1/send` — 409 group_superseded**
|
|
217
|
+
|
|
218
|
+
The channel's owner reset its secure session and this daemon still holds the previous MLS group. Nothing was sent or stored. The daemon asks the owner's client to re-admit it and never wipes its local state on this answer. Retry later with back-off; a reply sent with `in_reply_to` or `idempotency_key` is republished under the same message id once the daemon holds the new group. Until then the daemon holds that channel's inbound messages: they are not handed to the runtime and do not use up their delivery attempts, for at most 24 hours from the first refusal. Adoption and the re-admission request are skipped on FileSafe vault channels and on any channel the enclave channel listing does not show.
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
HTTP 409
|
|
222
|
+
{"error": "group_superseded", "message": "channel moved to a new secure group; not sent; retry after the agent rejoins"}
|
|
223
|
+
```
|
|
224
|
+
|
|
216
225
|
**`POST /v1/status` — state values**
|
|
217
226
|
|
|
218
227
|
`state` must be one of `idle`, `working`, `stalled`, or `error`. `detail` is an optional free-form string.
|
|
@@ -241,7 +250,7 @@ All frames are JSON text.
|
|
|
241
250
|
"sender_membership":"active"|"unknown", "tool_grants":[] } }
|
|
242
251
|
{ "type":"sent", "message_id":"..." } // acks your send
|
|
243
252
|
{ "type":"ok" } // acks your status report
|
|
244
|
-
{ "type":"error", "error":"channel_pending"|"invalid_request"
|
|
253
|
+
{ "type":"error", "error":"channel_pending"|"invalid_request"|"group_superseded"|..., "message":"...", "detail":"..." }
|
|
245
254
|
```
|
|
246
255
|
|
|
247
256
|
The daemon derives `authorization` only from the authenticated channel and
|
|
@@ -265,12 +274,91 @@ closed without exposing OpenClaw memory.
|
|
|
265
274
|
**Agent → daemon**
|
|
266
275
|
|
|
267
276
|
```
|
|
268
|
-
{ "type":"send", "channel_id":"...", "text":"...", "idempotency_key":"..." } //
|
|
277
|
+
{ "type":"send", "channel_id":"...", "text":"...", "in_reply_to":"...", "idempotency_key":"..." } // both optional
|
|
269
278
|
{ "type":"status", "channel_id":"...", "state":"idle"|"working"|"stalled"|"error", "detail":"..." }
|
|
270
279
|
{ "type":"ack", "message_id":"..." } // confirms you consumed a delivered message
|
|
271
280
|
```
|
|
272
281
|
|
|
273
|
-
**Acknowledge what you receive.** Inbound delivery is **at-least-once**.
|
|
282
|
+
**Acknowledge what you receive.** Inbound delivery is **at-least-once**. Before the remote server queue is acknowledged, the daemon seals each runtime-bound `message` into `bridge-state.db`. It remains durable until you send a matching `{type:"ack", message_id}`. If your socket, daemon, or host disappears first, the message is restored and redelivered to the next consumer in FIFO order. The cost is that you may occasionally see a message twice; dedup by `message_id`. Ack as soon as you've durably handled a message, not before.
|
|
283
|
+
|
|
284
|
+
For the first-party runtime, a reply carries `in_reply_to`. The daemon persists a
|
|
285
|
+
content-free reply receipt after publish, and a replacement runtime checks that
|
|
286
|
+
receipt before running the turn again. This closes the publish-success/ACK-loss
|
|
287
|
+
window where the result is known. It does not pretend an indeterminate external
|
|
288
|
+
tool call is safe to repeat; those calls use the runtime's separate reconciliation
|
|
289
|
+
ledger.
|
|
290
|
+
|
|
291
|
+
**Replies are published at most once (SEC-06 #18).** A `send` that carries
|
|
292
|
+
`in_reply_to` (or, on `/v1/ws`, an `idempotency_key`) is an idempotent reply:
|
|
293
|
+
|
|
294
|
+
- Its `message_id` is a deterministic, keyed UUID of (agent identity, channel,
|
|
295
|
+
the inbound it answers, which part). The key is derived from the identity
|
|
296
|
+
seed, so other channel members cannot predict the id and publish under it
|
|
297
|
+
first. The voice fast path's reply id is keyed the same way. The
|
|
298
|
+
`idempotency_key` is never used as the `message_id` itself.
|
|
299
|
+
- Before the publish, the daemon writes a durable reply intent to
|
|
300
|
+
`bridge-state.db`: the reply body and the signed envelope about to be sent,
|
|
301
|
+
both sealed like the rest of the store and dropped once the publish is
|
|
302
|
+
confirmed.
|
|
303
|
+
- Every retry resends those exact envelope bytes, so a backend that already
|
|
304
|
+
accepted them answers with the original ack (even if the epoch has moved
|
|
305
|
+
since). A `message_in_flight` 409 (the original is still being processed)
|
|
306
|
+
is retried later with the same bytes, never re-encrypted: 3 in-process
|
|
307
|
+
retries with backoff, then the redelivery backoff and the delivery cap. The
|
|
308
|
+
reply is re-encrypted (same id, fresh nonce) only when the backend says the
|
|
309
|
+
earlier attempt was not accepted: a `replay_nonce` 409 on the exact bytes,
|
|
310
|
+
or `mls.stale_epoch`, which it answers only for an id it has not seen.
|
|
311
|
+
- `message_id_conflict` means the id slot is held by OTHER bytes, never that
|
|
312
|
+
our reply was delivered: the cached envelope always holds the last bytes
|
|
313
|
+
posted under the id, and a resend of those bytes gets the original ack. So
|
|
314
|
+
the slot belongs to another message (for example an old reply whose intent
|
|
315
|
+
was pruned): an unkeyed part moves to the next part number, and a keyed one
|
|
316
|
+
fails with `invalid_request`.
|
|
317
|
+
- If the inbound is redelivered while its reply has an intent (a 502 after the
|
|
318
|
+
backend committed, a crash before the receipt, a nack), the daemon
|
|
319
|
+
republishes that reply itself and acknowledges the inbound. The runtime is
|
|
320
|
+
not handed the message again, so the model does not run twice.
|
|
321
|
+
- A `rate_limited` publish (the daemon's room floor or a backend 429) is
|
|
322
|
+
retried in-process with backoff, up to 3 times, before `rate_limited` is
|
|
323
|
+
returned. The intent stays pending, so the redelivery republishes it.
|
|
324
|
+
- Parts are numbered from `bridge-state.db`, not from memory, so a restart
|
|
325
|
+
between two parts never reuses a published part's id. Intents are kept for
|
|
326
|
+
7 days (at most 5000).
|
|
327
|
+
- **Send an `idempotency_key` per reply block.** Without one, a send whose text
|
|
328
|
+
equals the MOST RECENT part of the same `in_reply_to` is treated as a retry
|
|
329
|
+
of that part and returns its `message_id` without publishing. Two identical
|
|
330
|
+
consecutive blocks therefore become one message. With a key, each key is its
|
|
331
|
+
own part; resending the same key and text is a retry, and the same key with
|
|
332
|
+
different text is refused with `invalid_request`. The OpenClaw and Hermes
|
|
333
|
+
connectors send `<in_reply_to>:<n>` per block. `idempotency_key` is honoured
|
|
334
|
+
on both `/v1/ws` and `POST /v1/send`.
|
|
335
|
+
- **Trade-off: a crash in the middle of a multi-part reply.** If the runtime
|
|
336
|
+
published part 1 of a reply and then the runtime, daemon or host died before
|
|
337
|
+
it produced part 2, the redelivered message is answered from the intents:
|
|
338
|
+
part 1 is republished (or confirmed) and the message is acknowledged. Part 2
|
|
339
|
+
is never produced, because producing it means running the model again, which
|
|
340
|
+
would write a new, different reply around a part 1 that was already sent.
|
|
341
|
+
The daemon chooses "no duplicate" over "complete".
|
|
342
|
+
- An intent only answers a message the runtime was actually handed (one with a
|
|
343
|
+
delivery attempt). A reply that names a message still waiting in the buffer
|
|
344
|
+
does not stop that message reaching the runtime.
|
|
345
|
+
- **Replies to a message more than 7 days old.** Intents are pruned after 7
|
|
346
|
+
days (or past 5000 rows), and a pruned intent's id is still held by the
|
|
347
|
+
reply the backend stored. Replying to that same inbound again then collides
|
|
348
|
+
with the old reply:
|
|
349
|
+
- a connector key reused after the prune (for example `X:0` again) is
|
|
350
|
+
refused with `400 invalid_request`, and that block is lost;
|
|
351
|
+
- an unkeyed reply moves past the old reply's parts one number at a time, at
|
|
352
|
+
most 8 per send. If the old reply had more than 8 parts, sends fail until
|
|
353
|
+
enough collided parts have been skipped (each failed send skips up to 8).
|
|
354
|
+
- **Hermes: a block retried after a different block went out can be
|
|
355
|
+
published twice.** The Hermes connector reuses a block's key only while it
|
|
356
|
+
is the most recent unconfirmed block. If block A fails ambiguously (the
|
|
357
|
+
daemon may in fact have published it), then block B is sent, and then
|
|
358
|
+
Hermes retries A, the retry carries a new key and A appears twice. The
|
|
359
|
+
OpenClaw connector uses the same key rule; it does not retry a failed block
|
|
360
|
+
itself, so the same case arises there only if OpenClaw's own reply
|
|
361
|
+
dispatcher retries one.
|
|
274
362
|
|
|
275
363
|
**One consumer at a time.** The daemon serves a single agent per process. Opening a second `/v1/ws` connection closes the first (newest wins, code `1000`); the replaced socket's un-acked messages are re-buffered for the new one. Don't run two consumers against one daemon expecting to share the stream.
|
|
276
364
|
|
|
@@ -81,6 +81,17 @@ _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
|
|
|
81
81
|
_CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _error_class(error: object) -> str:
|
|
85
|
+
"""Return a bounded class name without reading exception prose."""
|
|
86
|
+
try:
|
|
87
|
+
name = type(error).__name__
|
|
88
|
+
except Exception:
|
|
89
|
+
return "Error"
|
|
90
|
+
if not name or len(name) > 64 or not name.replace("_", "a").isalnum():
|
|
91
|
+
return "Error"
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
|
|
84
95
|
def _read_file_text(path: str) -> str:
|
|
85
96
|
with open(path, "r") as f:
|
|
86
97
|
return f.read()
|
|
@@ -112,6 +123,8 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
112
123
|
self.ws_url: Optional[str] = None
|
|
113
124
|
self._token: Optional[str] = None
|
|
114
125
|
|
|
126
|
+
# SEC-06 #18: reply_to -> {"seq": n, "pending": text|None}; see _block_key.
|
|
127
|
+
self._block_seq: Dict[str, Dict[str, Any]] = {}
|
|
115
128
|
self._session = None # aiohttp.ClientSession - the CURRENT connected link
|
|
116
129
|
self._ws = None
|
|
117
130
|
self._recv_task: Optional[asyncio.Task] = None
|
|
@@ -184,7 +197,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
184
197
|
self._recv_task = asyncio.create_task(self._supervise())
|
|
185
198
|
self._tick_task = asyncio.create_task(self._tick_loop())
|
|
186
199
|
self._mark_connected()
|
|
187
|
-
logger.info("SecureComs: connected to daemon
|
|
200
|
+
logger.info("SecureComs: connected to daemon")
|
|
188
201
|
return True
|
|
189
202
|
|
|
190
203
|
async def _open_ws(self) -> bool:
|
|
@@ -230,7 +243,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
230
243
|
changed = self._hold_reason != reason
|
|
231
244
|
self._hold_reason = reason
|
|
232
245
|
if changed:
|
|
233
|
-
logger.warning("SecureComs: waiting for SecureComs daemon
|
|
246
|
+
logger.warning("SecureComs: waiting for SecureComs daemon")
|
|
234
247
|
self._set_fatal_error(
|
|
235
248
|
"waiting_for_daemon",
|
|
236
249
|
f"waiting for SecureComs daemon: {reason}",
|
|
@@ -253,7 +266,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
253
266
|
except asyncio.CancelledError:
|
|
254
267
|
raise
|
|
255
268
|
except Exception as e:
|
|
256
|
-
logger.warning(
|
|
269
|
+
logger.warning(
|
|
270
|
+
"SecureComs: receive failed (error_class=%s)",
|
|
271
|
+
_error_class(e),
|
|
272
|
+
)
|
|
257
273
|
if self._stopped:
|
|
258
274
|
break
|
|
259
275
|
self._mark_disconnected()
|
|
@@ -311,7 +327,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
311
327
|
"is_dm": bool(c.get("is_dm")),
|
|
312
328
|
}
|
|
313
329
|
except Exception as e:
|
|
314
|
-
logger.debug(
|
|
330
|
+
logger.debug(
|
|
331
|
+
"SecureComs: channel refresh failed (error_class=%s)",
|
|
332
|
+
_error_class(e),
|
|
333
|
+
)
|
|
315
334
|
|
|
316
335
|
# ── Status emit ───────────────────────────────────────────────────────
|
|
317
336
|
|
|
@@ -321,7 +340,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
321
340
|
try:
|
|
322
341
|
await self._ws.send_json(frame)
|
|
323
342
|
except Exception as e:
|
|
324
|
-
logger.debug(
|
|
343
|
+
logger.debug(
|
|
344
|
+
"SecureComs: status frame send failed (error_class=%s)",
|
|
345
|
+
_error_class(e),
|
|
346
|
+
)
|
|
325
347
|
|
|
326
348
|
async def _tick_loop(self) -> None:
|
|
327
349
|
"""Drives ChannelStatusManager.tick() so a channel goes idle after
|
|
@@ -334,7 +356,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
334
356
|
except asyncio.CancelledError:
|
|
335
357
|
raise
|
|
336
358
|
except Exception as e:
|
|
337
|
-
logger.debug(
|
|
359
|
+
logger.debug(
|
|
360
|
+
"SecureComs: status tick failed (error_class=%s)",
|
|
361
|
+
_error_class(e),
|
|
362
|
+
)
|
|
338
363
|
|
|
339
364
|
# ── Inbound ───────────────────────────────────────────────────────────
|
|
340
365
|
|
|
@@ -360,7 +385,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
360
385
|
{"type": "ack", "message_id": frame.get("message_id")}
|
|
361
386
|
)
|
|
362
387
|
except Exception as e:
|
|
363
|
-
logger.warning(
|
|
388
|
+
logger.warning(
|
|
389
|
+
"SecureComs: ack failed (error_class=%s)",
|
|
390
|
+
_error_class(e),
|
|
391
|
+
)
|
|
364
392
|
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
365
393
|
return # WS closed → supervisor reconnects
|
|
366
394
|
|
|
@@ -393,6 +421,29 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
393
421
|
|
|
394
422
|
# ── Outbound ──────────────────────────────────────────────────────────
|
|
395
423
|
|
|
424
|
+
_BLOCK_SEQ_MAX = 1000
|
|
425
|
+
|
|
426
|
+
def _block_key(self, reply_to: str, content: str) -> Dict[str, Any]:
|
|
427
|
+
"""SEC-06 #18: a stable idempotency_key per reply block.
|
|
428
|
+
|
|
429
|
+
The daemon matches a keyless retry only against the most recent part,
|
|
430
|
+
so two identical blocks would collapse into one message. The key is
|
|
431
|
+
``<reply_to>:<n>``: n advances after a confirmed send, and also when a
|
|
432
|
+
new block arrives after an unconfirmed one (that block is abandoned). A
|
|
433
|
+
retry of the unconfirmed block (same text) reuses its key, so the daemon
|
|
434
|
+
never publishes it twice.
|
|
435
|
+
"""
|
|
436
|
+
st = self._block_seq.get(reply_to)
|
|
437
|
+
if st is None:
|
|
438
|
+
if len(self._block_seq) >= self._BLOCK_SEQ_MAX:
|
|
439
|
+
self._block_seq.pop(next(iter(self._block_seq)))
|
|
440
|
+
st = {"seq": 0, "pending": None}
|
|
441
|
+
self._block_seq[reply_to] = st
|
|
442
|
+
if st["pending"] is not None and st["pending"] != content:
|
|
443
|
+
st["seq"] += 1
|
|
444
|
+
st["pending"] = content
|
|
445
|
+
return st
|
|
446
|
+
|
|
396
447
|
async def send(
|
|
397
448
|
self,
|
|
398
449
|
chat_id: str,
|
|
@@ -402,6 +453,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
402
453
|
) -> SendResult:
|
|
403
454
|
if not self._session or self._session.closed:
|
|
404
455
|
return SendResult(success=False, error="not connected")
|
|
456
|
+
block = self._block_key(reply_to, content) if reply_to else None
|
|
405
457
|
try:
|
|
406
458
|
async with self._session.post(
|
|
407
459
|
f"{self.bridge_url}/v1/send",
|
|
@@ -410,9 +462,13 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
410
462
|
"channel_id": chat_id,
|
|
411
463
|
"text": content,
|
|
412
464
|
**({"in_reply_to": reply_to} if reply_to else {}),
|
|
465
|
+
**({"idempotency_key": f"{reply_to}:{block['seq']}"} if block else {}),
|
|
413
466
|
},
|
|
414
467
|
) as r:
|
|
415
468
|
if r.status == 200:
|
|
469
|
+
if block:
|
|
470
|
+
block["seq"] += 1
|
|
471
|
+
block["pending"] = None
|
|
416
472
|
j = await r.json()
|
|
417
473
|
await self._status.on_reply_posted(chat_id)
|
|
418
474
|
return SendResult(success=True, message_id=str(j.get("message_id")))
|
|
@@ -352,8 +352,31 @@ class TestSendWiring:
|
|
|
352
352
|
"channel_id": "chan-1",
|
|
353
353
|
"text": "hello",
|
|
354
354
|
"in_reply_to": "inbound-7",
|
|
355
|
+
"idempotency_key": "inbound-7:0",
|
|
355
356
|
}
|
|
356
357
|
|
|
358
|
+
@pytest.mark.asyncio
|
|
359
|
+
async def test_send_gives_each_reply_block_a_stable_idempotency_key(self, monkeypatch):
|
|
360
|
+
# SEC-06 #18 (PR #716 review MEDIUM-2): the daemon matches a keyless
|
|
361
|
+
# retry only against the most recent part, so two identical blocks
|
|
362
|
+
# collapse into one message. A per-block key keeps them apart, and the
|
|
363
|
+
# SAME key on a retry of an unconfirmed block keeps it from publishing twice.
|
|
364
|
+
a, _ = make_adapter(monkeypatch)
|
|
365
|
+
a.bridge_url = "http://127.0.0.1:9001"
|
|
366
|
+
a._token = "tok"
|
|
367
|
+
a._session = FakeSession(post_response=(200, {"message_id": "m"}))
|
|
368
|
+
await a.send("chan-1", "ok", reply_to="in-1")
|
|
369
|
+
await a.send("chan-1", "ok", reply_to="in-1") # a second, identical block
|
|
370
|
+
a._session._post_response = (500, "boom")
|
|
371
|
+
await a.send("chan-1", "third", reply_to="in-1") # fails, unconfirmed
|
|
372
|
+
await a.send("chan-1", "third", reply_to="in-1") # retry of the same block
|
|
373
|
+
a._session._post_response = (200, {"message_id": "m"})
|
|
374
|
+
await a.send("chan-1", "fourth", reply_to="in-1") # the previous block is abandoned
|
|
375
|
+
await a.send("chan-1", "other", reply_to="in-2")
|
|
376
|
+
await a.send("chan-1", "no correlation")
|
|
377
|
+
keys = [c[2].get("idempotency_key") for c in a._session.post_calls]
|
|
378
|
+
assert keys == ["in-1:0", "in-1:1", "in-1:2", "in-1:2", "in-1:3", "in-2:0", None]
|
|
379
|
+
|
|
357
380
|
@pytest.mark.asyncio
|
|
358
381
|
async def test_send_failure_does_not_fire_on_reply_posted(self, monkeypatch):
|
|
359
382
|
a, _ = make_adapter(monkeypatch)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from adapter import _error_class
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _logging_violations(source: str) -> list[str]:
|
|
8
|
+
violations: list[str] = []
|
|
9
|
+
tree = ast.parse(source)
|
|
10
|
+
for node in ast.walk(tree):
|
|
11
|
+
if not (
|
|
12
|
+
isinstance(node, ast.Call)
|
|
13
|
+
and isinstance(node.func, ast.Attribute)
|
|
14
|
+
and isinstance(node.func.value, ast.Name)
|
|
15
|
+
and node.func.value.id == "logger"
|
|
16
|
+
):
|
|
17
|
+
continue
|
|
18
|
+
if node.func.attr == "exception":
|
|
19
|
+
violations.append("logger.exception")
|
|
20
|
+
if not node.args or not (
|
|
21
|
+
isinstance(node.args[0], ast.Constant)
|
|
22
|
+
and isinstance(node.args[0].value, str)
|
|
23
|
+
):
|
|
24
|
+
violations.append("dynamic format")
|
|
25
|
+
continue
|
|
26
|
+
rendered = node.args[0].value.lower()
|
|
27
|
+
if any(label in rendered for label in ("identity=", "channel=", "reason=", "url=")):
|
|
28
|
+
violations.append("sensitive field")
|
|
29
|
+
for arg in node.args[1:]:
|
|
30
|
+
if not (
|
|
31
|
+
isinstance(arg, ast.Call)
|
|
32
|
+
and isinstance(arg.func, ast.Name)
|
|
33
|
+
and arg.func.id == "_error_class"
|
|
34
|
+
) and not (isinstance(arg, ast.Name) and arg.id == "backoff"):
|
|
35
|
+
violations.append("unbounded argument")
|
|
36
|
+
return violations
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_adapter_logging_source_is_content_free() -> None:
|
|
40
|
+
source = (Path(__file__).parents[1] / "adapter.py").read_text()
|
|
41
|
+
assert _logging_violations(source) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_adapter_logging_guard_detects_dynamic_values() -> None:
|
|
45
|
+
hostile = '''
|
|
46
|
+
logger.info(dynamic_format, identity)
|
|
47
|
+
logger.warning("channel=%s", channel_id)
|
|
48
|
+
logger.error("failed: %s", error)
|
|
49
|
+
'''
|
|
50
|
+
violations = _logging_violations(hostile)
|
|
51
|
+
assert "dynamic format" in violations
|
|
52
|
+
assert "sensitive field" in violations
|
|
53
|
+
assert "unbounded argument" in violations
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_error_class_does_not_render_exception_prose() -> None:
|
|
57
|
+
class HostileError(RuntimeError):
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
raise AssertionError("exception prose was rendered")
|
|
60
|
+
|
|
61
|
+
assert _error_class(HostileError()) == "HostileError"
|