866332bfb5
* fix(relay): authorize send_message targets and surface egress declines
P5 of the relay egress-authorization workstream. The relay path
authenticated the SENDER but never authorized the DESTINATION, and the
gateway compounded it from both ends.
(a) send_message could silently name an arbitrary relay target. Its
`target` parameter is free-form ('platform:chat_id'), so a model could
name ANY chat id and the gateway would emit an outbound frame for it.
gateway/relay/egress.py adds an attestation floor: a relay-routed
destination must have a provenance this gateway can show -- the
operator's home channel, the channel directory, or its own gateway
session origins. Anything else is refused HERE, with a visible tool
error naming the target, before a frame is written. Non-relay platforms
and platforms served by a live native adapter in this process are
untouched (same precedence resolve_delivery_transport applies).
(b) Connector declines were swallowed into apparent successes. The
connector's egress floor answers an unauthorized destination with a
DEFINITE failure whose text is deliberately uniform (F-005). Several
relay lanes degrade a *transport drop* by design and were degrading an
*authorization refusal* the same way:
- _send_media returned None, sending the caller into
BasePlatformAdapter's text fallback -- a DIFFERENT op re-addressed at
the very chat the connector had just refused.
- _send_prompt returned None, so exec-approval / slash-confirm /
clarify reported "relay prompt op unavailable" (a wrong reason) and
ran their numbered-text fallbacks into the refused chat.
- task_card_stop discarded the error entirely.
- typing / delete / react / thread ops degraded silently at debug.
is_egress_decline() classifies THAT a decline happened (never why --
the uniform text is not parsed for reasons) and requires a definite,
non-ambiguous failure, so a lost-ack retry is still a transport
outcome. Lanes with an error-carrying contract now report the decline
verbatim; cosmetic bool/None lanes still degrade but log it at WARNING.
Advisory progress drops that legitimately degrade are unchanged: the
task_card send lane, the draft ambiguous/except branches, and every
transport-exception path keep their existing fail-open behaviour.
Tests: 21 mutations of the production source, all KILLED.
* fix(relay): authorize the RESOLVED target; declines must not fall back
Review round 1 (independently confirmed by a second reviewer) found three
blockers. Two are fixed here; the third (B-2, Telegram @username) is a policy
decision left open deliberately.
B-1 — THE FIX CAUSED THE OUTAGE IT PREVENTED (tools/send_message_tool.py)
The P5(a) guard ran ABOVE Slack user->DM resolution, so it authorized the
internal pseudo-id `_parse_target_ref` emits (`user_name:ben`, `user:U...`).
Provenances only ever hold RESOLVED conversation ids, so a fully attested DM
was compared as a handle against a set of `D...` ids and refused:
base slack:@ben SENT head(before) slack:@ben REFUSED
Every Slack DM by handle was broken. Moved the guard below resolution; it now
authorizes the destination that is actually sent to, and the refusal names the
resolved id. Position is load-bearing, so it is commented as such and pinned:
reverting the move turns exactly the four new cases red.
B-3 — A DECLINE IS NOT A LANE FAILURE (gateway/run.py)
`_approval_send_outcome` had only sent/failed/ambiguous, so a connector
decline collapsed into `failed` — which is the cue to run the plain-text
fallback into the chat the connector had just refused. The adapter fix in the
previous commit improved the error STRING while user-visible behaviour stayed
identical to base; the commit message overstated it. Fixed properly:
- new `declined` verdict, recognised via the shared `is_egress_decline`
contract (not string sniffing at the call site)
- exec-approval returns without the text fallback
- slash-confirm suppresses the text reply AND clears the registration, so a
card that never rendered cannot capture the user's next message
`send_clarify` was already correct (returns early inside the adapter).
MUTATIONS (production source; both directions)
classifier never returns 'declined' -> KILLED (4 cases)
ALL failures classified as 'declined' -> KILLED (2 cases)
guard moved back above Slack resolution -> KILLED (4 cases)
decline CODE changed (review M05) -> KILLED
marker match made case-sensitive (M10) -> KILLED
M05 was a tautology: the test asserted the imported constant against itself,
so changing the constant could not fail it. The wire contract is now pinned as
a literal, because the connector stamps that exact string and a one-sided
change is a silent cross-repo break.
REGRESSION CHECK: the 12 failures + 1 collection error in this test selection
are PRE-EXISTING cross-test contamination — the identical set fails at
7cf86188ac. Verified by diffing the failing sets: no new failures, 363 -> 374
passed.
NOT FIXED (deliberate): B-2, Telegram `@username`. The Bot API resolves handles
at send time, so there is no id to compare and no canonicalization exists yet.
That is a policy decision, not a code move.
* fix(relay): fail CLOSED on guard faults; classify the structured decline
Third independent review. Two more blockers, both reproduced before fixing.
1. THE GUARD ITSELF FAILED OPEN (tools/send_message_tool.py:158)
`_authorize_relay_target` wrapped BOTH the import and the call in one
`except Exception: return None` — and None means AUTHORIZED at every call site.
So any runtime bug inside the guard silently switched the entire P5(a) boundary
off. Reproduced: with the guard raising, an unattested target sent.
The docstring already stated the correct intent ("must not fail closed on its
own IMPORT error") and the code did something broader. The two failures are not
the same: a missing gateway package means there is no relay egress to
authorize; a fault inside the guard means authorization did not happen. The
import is tolerated, the call is not — a guard that cannot answer refuses.
2. THE STRUCTURED DECLINE WAS THROWN AWAY (gateway/run.py)
The adapter preserves the connector's dict in `SendResult.raw_response`. My
previous commit rebuilt a dict from the error STRING, which loses two
contracts:
* a decline carrying `code: egress_declined` and NO text renders as
"relay egress declined" — no marker colon — so it classified as `failed`,
which is exactly the cue to run the fallback into the refused chat;
* `ambiguous: True` (lost ack) was flattened into a DEFINITE failure,
re-sending a card that may already be on the user's screen. That is the
duplicate-card bug the ambiguous verdict exists to prevent, reintroduced
by the fix meant to harden the same path.
Both call sites now classify `raw_response` when present, ambiguity first, and
fall back to the wire sentence only for connectors that send no structured
response.
I had fixed the text-marker path and tested only the text-marker path. Worth
naming: the review's probe was a shape my tests never produced.
MUTATIONS (production source)
guard fault returns None (fail open again) -> KILLED
classifier ignores raw_response -> KILLED (3 cases)
ambiguous treated as a definite failure -> KILLED (2 cases)
40 focused tests pass. Regression check vs be321faf27: identical 13-item
failing set (pre-existing cross-test contamination), no new failures.
STILL OPEN: B-2 / finding 3, Telegram `@username`. The reviewer is right that
this is a REGRESSION of an existing contract (#53573 added Bot API username
support), not merely an unspecified input, since relay provenance stores the
numeric chat id. Fixing it means resolving the handle before authorization, or
explicitly revoking the contract. That is a policy decision, not a code move,
and it is Ben's call.
* test(relay): pin M21 and M25, the survivors whose comments called them load-bearing
Round-2 review reported six unpinned survivors from round 1. Two guard real
behaviour and are now covered; the other four are cosmetic-lane warnings and
fail-open branches I am leaving documented rather than pretending to close.
M25 — thread-qualified session ids. `_session_ids` adds BOTH "chat:thread" and
the bare chat, because the connector authorizes the CHAT. Without the split a
gateway whose session origin is `-100999:77` cannot send to `-100999`, the chat
it is demonstrably already talking in. KILLED.
M21 — the generic `relay` plane must union every fronted platform, since a
relay session is filed under its LOGICAL platform. KILLED.
MY FIRST M21 TEST WAS THE DEFECT IT WAS TESTING FOR. I patched `_relay_fronted`
— the very function the mutation empties — so emptying it changed nothing the
test could see, and the mutation SURVIVED against a green test. Rewritten to
drive the real `relay_fronted_platforms()` through its env source
(`GATEWAY_RELAY_PLATFORMS`), which is how production learns it.
That is the same "the test verifies my stand-in" failure I have spent this
workstream removing from the connector harnesses, reproduced here in three
lines of Python. The tell was identical: a mutation that survives a test
written specifically to kill it.
334 tests pass.
NOT PINNED, deliberately: M03 (success-guard on a malformed dict), M24
(empty-target allowance — the one fail-open branch, reachable only when the
bare-platform path already resolved a home channel), M35/M36 (decline WARNINGs
on cosmetic lanes). All four are observability or defence-in-depth rather than
authorization, and the review agrees they are non-blocking.
* fix(relay): defer Telegram @username authorization to the connector (B-2)
Closes the last blocker. Two reviewers independently called this a REGRESSION
of the public-channel username support added in #53573, not an unspecified
input, and they were right: provenance stores RESOLVED numeric chat ids, so
comparing `@channel` against them could only ever refuse.
WHY THE GATEWAY CANNOT ANSWER IT. The guard fires only when there is no live
native adapter — i.e. relay-fronted deployments — and on exactly those the
CONNECTOR holds the bot token, not this process. There is no local way to turn
a handle into the numeric id. Refusing here is not "fail closed", it is "fail
always".
WHY DEFERRING IS SAFE. The destination is still authorized one layer out: the
connector's Telegram egress floor (gg#238, merged 743a7c2) classifies and
refuses unauthorized destinations after ITS resolution — the layer that closed
the reported vulnerability in the first place. Handles go from two guards to
one, the authoritative one, not to zero.
The carve-out is deliberately narrow and its EDGES are pinned, because the
failure mode of an exemption is silent widening:
telegram `@handle` -> deferred (the regression case)
telegram numeric id -> still guarded
matrix `@user:server` -> still guarded (telegram-only)
bare name, no `@` -> still guarded
attested handle -> normal path, attestation still consulted
MUTATIONS
carve-out widened to all platforms -> KILLED
carve-out widened to every target -> KILLED
carve-out removed (regression back) -> KILLED
carve-out checked BEFORE attestation -> KILLED
THE ORDERING MUTANT SURVIVED MY FIRST TEST. Both orderings return None, so
asserting the verdict could not tell them apart — the test asserted the claim
instead of the mechanism. Rewritten to observe that attestation is actually
consulted. Same defect class as the M21 test earlier in this branch: a
mutation surviving a test written specifically to kill it means the test is
measuring the wrong thing.
341 tests pass.
FOLLOW-UP (option 2, Ben's call, deliberately NOT done here): resolve the
handle before authorizing so BOTH layers apply. That needs a resolution
round-trip through the connector — new wire surface — so it belongs in its own
phase rather than bolted onto this one. Recorded in the code comment at the
carve-out, not just here.
* fix(relay): close two fail-open boundaries; test the code-only decline for real
Both blockers from review, each REPRODUCED before fixing.
1. STRUCTURED DECLINE HAD NO GUARD. Deleting `raw_response=result` from both
`_send_prompt` return branches left all 34 tests green — a surviving,
non-equivalent security mutant. The `code` field is the documented
PREFERRED signal precisely because a connector may send no prose, and a
caller rebuilding `{"success": False, "error": ...}` cannot see it.
Cause: every existing case declines with marker TEXT. The evidence for the
code-only path was a hand-built SimpleNamespace in a different file — a
stand-in for the adapter, so it verified my fixture instead of production.
Fixed with a CodeOnlyDecliningConnector driving the real
`send_exec_approval` -> `_send_prompt`, feeding the REAL SendResult to the
REAL `_approval_send_outcome`, plus the same shape on the media lane.
drop raw_response SURVIVED (34 passed) -> KILLED
2. TWO FAIL-OPEN BOUNDARIES, both "absence" and "fault" sharing a return.
`_relay_fronted` swallowed EVERY exception and returned an empty set, which
`relay_routed_platform` reads as "not relay-routed" — skipping the guard.
Probe, with a positive control in the same run:
positive_control_denied = True
discovery_fault_denied = False <- unattested target AUTHORIZED
`_authorize_relay_target` caught every exception during IMPORT as "no
gateway package". A module that exists and fails to initialize is a fault,
not an absence, and returning None there means authorized.
Now: ImportError alone is absence; anything else raises RelayRouteUnknown
and `authorize_relay_target` converts it to a REFUSAL STRING (not a raised
exception — every caller treats the return value as the verdict, so raising
would trade a fail-open for a crash).
Kept the converse under test so "fail closed" does not silently become
"refuse everything in CLI/cron", which is the outage the broad except
existed to prevent.
discovery fault -> empty set KILLED
RelayRouteUnknown -> authorized KILLED
import fault -> authorized KILLED
397 passed (was 392, +5 new cases), zero failures.
* fix(relay): close all seven review-round-3 blockers
Every finding reproduced before fixing; every fix mutation-checked after.
CONTENT LEAKS (the decline was laundered into a different op, same chat)
#1 A declined DRAFT SEAL replayed as a plain send. On stream-is-the-message
platforms the turn-final becomes draft(final=True); `_seal_open_draft`
dropped the structured body, so `_absorb_into_open_draft` read a REFUSAL as
a lane failure and fell through. Probe, Slack descriptor:
before: draft(partial) -> draft(final,SECRET) -> send(SECRET)
after: draft(partial) -> draft(final,SECRET)
My first probe of this used a discord descriptor and showed no seal at all —
the leak is real, my probe was wrong (streams only arm for Slack).
#6 Task-card PROGRESS had the same defect one lane over: a bare failed
SendResult reads as "card lane unavailable", and TurnRunner then sends the
task text to the same chat. Both card methods now carry raw_response and
the caller suppresses the fallback on a decline.
AUTHORIZATION BYPASSES
#2 `except ImportError` was NOT the fix I claimed last round. ImportError also
covers a broken dependency inside an INSTALLED gateway; review probed
`ImportError.name = "gateway.relay.dependency"` and got an authorized
verdict. Now only a name identifying the gateway relay module itself is
absence. An ImportError with NO name stays absence — refusing on a fault we
cannot attribute would trade an unidentifiable bug for a real CLI/cron
outage, and an existing test caught exactly that when I first got it wrong.
#3 `relay_routed_platform` lowercases the requested platform; `_relay_fronted`
returned configured names verbatim. A platform configured as "Discord"
missed the membership test, looked native, and skipped the guard:
'discord' => refused 'Discord' => ALLOWED 'DISCORD' => ALLOWED
An attestation bypass on a string comparison.
UNDELIVERABLE PROMPTS THAT HUNG
#4 `_clarify_send_disposition` handled `failed` and `ambiguous` but not
`declined`, so a REFUSED clarify card fell through to wait_for_response and
blocked until clarify_timeout — indefinitely when configured non-positive.
A decline is more definitive than a failure, not less.
#5 The exec-approval decline branch returned quietly, which suppressed the text
fallback (right) but left the CENTRAL approval entry pending (wrong) — the
dangerous command stayed blocked until the approval timeout. My comment
claimed the registration was torn down; only RelayAdapter's private map was.
It now raises `_ExecApprovalDeclined`, which propagates to
`_await_gateway_decision`'s existing notify-failure path (drops the entry,
unblocks the tool). A dedicated type, re-raised past the local
`except Exception` that would otherwise have restored the leak.
#7 THE GAP THAT LET ALL OF THIS SHIP. Both caller-level suppressions were
unfalsifiable: deleting either branch left 36/38 tests green. The suites
drove `_approval_send_outcome` and `RelayAdapter` but never the real
TurnRunner / busy-session callers, so nothing observed whether a text send
FOLLOWED a decline — which is the whole property.
tests/gateway/test_decline_fallback_suppression.py drives both real callers
and records every send. Each decline case is paired with an ordinary-FAILURE
control, because without one a caller that never falls back would also pass.
MUTATIONS (all on production source, anchors count-checked, restored after)
#1 seal decline -> plain send KILLED
#1b seal drops raw_response KILLED
#2 nested ImportError -> authorized KILLED
#3 fronted set not normalized KILLED
#4 clarify declined branch removed KILLED
#5 approval decline returns not raises KILLED
#6 task_card drops raw_response KILLED
#7 slash-confirm suppression removed KILLED
#7's two were the reviewer's SURVIVORS (36/38 passing); both now die.
425 passed, zero failures.
* fix(relay): close the three round-4 blockers
Round 4 confirmed six of seven round-3 fixes and found three more. Each
reproduced before fixing, each mutation-checked after.
1. A NAMELESS ImportError still authorized. Last round I admitted it as
"absence" to protect the CLI/cron path. That reasoning was WRONG and the
interpreter says so:
import gateway.relay.nope -> ModuleNotFoundError, name="gateway.relay.nope"
import totally_absent_pkg -> ModuleNotFoundError, name="totally_absent_pkg"
Genuine absence is ALWAYS ModuleNotFoundError with `.name` set, so the
CLI/cron path never produces a bare ImportError and nothing legitimate was
being protected. A plain or nameless ImportError comes from an import hook
or a module that failed while initializing — an unattributable FAULT.
Now: absence is ModuleNotFoundError naming gateway / gateway.relay /
gateway.relay.egress; everything else refuses. Two existing tests raised a
bare ImportError to simulate absence and were corrected to the real shape.
2. SESSION ATTESTATION INVENTED IDS. `_session_ids` split every id on the first
colon to recover "chat" from "chat:thread". Matrix ids contain a colon
natively, so `!room:server.org` attested a bare `!room` — the guard
vouching for a destination on its own fabrication. The split now applies
only to platforms whose ids genuinely carry a `:thread` suffix (allow-list;
unknown platforms are treated as un-splittable, which can only refuse more).
Kept a Slack control: dropping the split entirely would refuse legitimate
thread replies, which is the outage the split exists to prevent.
3. THE TASK-CARD FIX WAS UNFALSIFIABLE — my own round-3 mistake, and the same
one round 3 caught me making. I added the production branch AND a test, but
the test stopped at RelayAdapter: it proved `raw_response` is carried and
never called `TurnRunner._task_card_publish`, which owns the property.
Deleting the real branch left 30 tests green. Now driven through the real
caller, with an ordinary-failure control.
The lesson generalises: proving the DATA reaches the boundary is not proving
the CALLER acts on it. Every one of these decline fixes has two halves and
the second half is where the security lives.
Also closed the round-4 non-blocking finding: `gateway/relay/egress.py` has its
OWN import boundary, and the existing test intercepted the earlier import in
tools/send_message_tool.py, so it was never exercised. Mutating that classifier
to treat every ImportError as absence now dies.
MUTATIONS (production source, anchors count-checked, restored after)
R4-1 nameless ImportError -> authorized KILLED
R4-2 session split unconditional KILLED
R4-3 task-card caller branch removed KILLED (was SURVIVED)
egress classifier: any ImportError = absence KILLED
Also probed and found NOT a leak: a refused OPENING draft frame disarms the
stream and the turn-final goes out via `send`. That send is itself guarded and
the connector refuses it too, so no content is delivered — unlike the seal case
(round 3, #1) where the seal was the only check on that path.
452 passed, zero failures.
* fix(relay): recover the thread parent from thread_id, not a colon split
Round 4 blocker 2 was closed with an allow-list of platforms whose ids have no
native colon. Reviewing my own fix while round 5 ran, the allow-list is the
wrong mechanism: it NARROWS a guess instead of removing it, and it still gets
Matrix wrong the moment a Matrix session is thread-qualified
(`!room:server.org:$thr` -> split yields `!room`).
The structured field was there all along. `_session_entry_id` composes the id
as f"{chat_id}:{thread_id}" and the entry still carries `thread_id`
separately, so the parent is knowable EXACTLY: strip the known suffix, or add
nothing. No platform list, no guessing, correct for ids that contain colons.
Mutations:
back to splitting on the first colon KILLED
thread parent never recovered (over-refuse) KILLED
Both directions matter: the first invents attestations, the second refuses
legitimate thread replies.
One existing test (M25) asserted the right PROPERTY with a fixture that omitted
`thread_id` — a shape real entries never have. Fixture corrected, assertions
untouched.
453 passed.
* fix(relay): close the four round-5 blockers
Each reproduced before fixing, each mutation-checked after.
R5-1 A DISABLED NATIVE ADAPTER BYPASSED AUTHORIZATION. `_has_live_native_adapter`
treated any entry in the adapter map as native; `resolve_delivery_transport`
ignores a native adapter whose config is disabled and routes over Relay.
Two independent routing classifiers, disagreeing:
guard says native: True delivery routes relay: True
So the guard skipped authorization for a send that went over the relay.
The guard now applies the router's enabled-state rule; probed both
configurations and they agree.
R5-2 THREAD IDS WERE NEVER AUTHORIZED. The parser splits chat_id and thread_id;
only chat_id reached the guard. On Discord the thread IS the destination —
`POST /channels/{thread_id}/messages` — so an attested parent channel
authorized an arbitrary caller-supplied thread. `authorize_relay_target`
now takes thread_id and requires its own attestation (bare id or the
`chat:thread` form a session origin produces); both call sites forward it.
R5-3 A DECLINED **INITIAL** DRAFT WAS RETRIED AS A PLAIN SEND. Round 3 fixed the
declined SEAL; the declined OPEN was a different path. `send_draft`
returned a bare failure, so the stream consumer read "draft transport
unusable", disabled drafts and fell through to `_first_send`. Measured
through the real adapter and real StreamTransportMixin:
before: ops ['draft', 'send'] after: ops ['draft']
send_draft now carries raw_response; a decline is terminal for the run and
the guard sits in `_first_send`, where every fallback path converges.
R5-4 MY ROUND-4 TASK-CARD FIX SUPPRESSED EXACTLY ONE UPDATE. It set
`native_failed`, which the entry gate already uses for an ordinary broken
lane, so the next progress event skipped the decline branch and went
straight to the text fallback:
after first publish: [] after second: ['send']
Terminal declines are now a separate `egress_declined` state checked at the
entry gate. A refusal does not expire after one tick.
MUTATIONS
R5-1 disabled native counts as native KILLED
R5-2 thread_id not authorized KILLED
R5-2b tool does not forward thread_id KILLED (was SURVIVED)
R5-3 initial-draft decline not terminal KILLED
R5-3b _first_send guard removed KILLED
R5-4 declined state not persistent KILLED
R5-2b is the same gap that produced findings 3 and 4 of the last two rounds, a
third time: every test called `authorize_relay_target` directly, so dropping the
argument from the TOOL WRAPPER changed nothing. Testing the callee never proves
the caller uses it — now pinned explicitly.
Each fix ships with an ordinary-failure control, because every one of these
makes the guard refuse MORE, and over-refusal is now the larger risk.
474 passed, zero failures.
* refactor(relay): declare the terminal-decline state where it lives
Both terminal-decline flags were set dynamically. They worked (neither class is
frozen or slotted) but an undeclared attribute hides the state from anyone
reading the class, and this one is security-relevant.
_TaskCardState.egress_declined — declared dataclass field
StreamConsumer._egress_declined — initialised in __init__
Lifetime verified while checking whether a refusal can leak ACROSS turns and
mute a healthy destination: it cannot. _TaskCardState is constructed per
progress-drain (run_turn_runner.py:420) and the consumer's flags per run
(stream_consumer.py:163), so both are fresh each turn.
Also verified the guard's blast radius after adding thread authorization: the
ONLY callers of authorize_relay_target are the two model-facing send_message
call sites. Gateway-internal sends — notably the handoff path, which creates a
thread and immediately posts to it with no session provenance yet — go through
transport.adapter directly and are unaffected. That was the most plausible
over-refusal, and it does not reach this guard.
461 passed.
* fix(relay): close the four round-6 blockers — the edit lane
R6-1 MY OWN R5-1 FIX REINTRODUCED THE BYPASS IT CLOSED. I wrote
`except Exception: return True` around the config lookup, so a config read
fault declared the platform native while the ROUTER, reading the real
config, sends over the relay:
guard_has_live_native True guard_verdict None router relay
Routing we cannot determine is UNKNOWN. It now raises RelayRouteUnknown,
which the outer handler must re-raise rather than flatten to False, and
`authorize_relay_target` turns into a refusal. This is the second time a
convenience `except` in this function created a bypass; there is now no
permissive return left in it.
R6-2/3/4 THE NINTH LANE: `edit`. ONE dropped field, THREE leaks.
`RelayAdapter.edit_message` discarded the connector response, and three
independent callers read a bare edit failure as "editing is unavailable"
and re-send the content as a NEW message to the same chat:
stream edit fallback ['edit', 'edit', 'send'] the unseen tail
queued reconciliation ['edit', 'send'] the WHOLE response
task-card fallback ['edit', 'send'] the task text again
Fixed at the source (edit_message carries raw_response) plus each caller:
`_on_edit_failure` — the single funnel for stream edit failures — makes a
decline terminal for the run, `_send_fallback_final` refuses to deliver a
continuation after one, the queued reconciler returns instead of sending,
and the task-card fallback sets the same terminal state R5-4 introduced.
R5-4 fixed the native task-card op and I did not check its sibling
fallback path. The pattern across rounds 3-6 is consistent: the fix goes
where the decline is OBSERVED, and the leak lives wherever someone else
later decides to retry.
MUTATIONS
R6-1 config fault -> assume native KILLED
R6-1b RelayRouteUnknown swallowed as False KILLED
R6-2 edit drops raw_response KILLED
R6-2b edit-failure decline not terminal KILLED
R6-3 queued reconcile falls back on decline KILLED
R6-4 task-card fallback edit decline KILLED
Each with an ordinary-failure control: a genuinely un-editable message must
still be delivered, and a broken card lane must still reach the user.
481 passed, zero failures.
* fix(relay): add a terminal-decline latch at the adapter choke point
THE STRUCTURAL FIX, not a twelfth local check.
Rounds 3-6 of review found ONE defect in eleven lanes: the connector refuses an
op, and some caller downstream reads that as 'this lane is unavailable' and
retries the same content through a DIFFERENT op against the SAME chat. Media,
prompt, draft-open, draft-seal, native task card, task-card fallback edit,
slash-confirm, exec-approval, clarify, stream edit, queued reconciliation.
Each was closed by adding a check at one more call site. That approach cannot
converge: gateway/ has ~60 outbound call sites, every one of them a place a
future change can reintroduce this, and four consecutive review rounds each
found another. The reviewer's own count of lanes is the argument against the
per-site design.
Every relay frame from every one of those callers passes through
_transport.send_outbound. One latch there covers them all: once the connector
refuses a chat, this adapter stops emitting CONTENT frames for that chat.
Proven to subsume the local checks: with the stream-edit per-site check
DISABLED, the leak probe still reports blocked=true — the frame never reaches
the wire. The local checks stay as defence in depth and for their better error
messages, but they are no longer the only thing standing between a decline and
a re-addressed send.
Scope is deliberately narrow, and each limit is mutation-pinned:
per CHAT - a refusal must not mute other conversations
CONTENT ops - typing/delete carry nothing; latching them would leave a
stuck typing indicator for no security gain
self-healing - cleared when the connector accepts that chat again, so a
transient policy change does not need a restart
Mutations:
latch never set KILLED
latch never consulted KILLED
latch is global, not per-chat KILLED
latch never clears KILLED
485 passed.
* fix(relay): one route source; the latch already covered round 7's lanes
Round 7 reviewed 573e41e294 — one commit BEFORE the terminal-decline latch —
and independently reached the same conclusion I had: 'The per-call-site
approach is structurally wrong. Use one turn-scoped choke point.' That is the
latch in 6dbc004594.
Its four 'still broken' lanes (tool-progress edit, progress-overflow edit,
long-running heartbeat edit, stale streamed-final reconciliation) all share the
shape edit_message->declined->adapter.send(same chat, same content), and NONE
has a local check. Probed all four against the latch:
tool_progress ops ['edit'] blocked
progress_overflow ops ['edit'] blocked
heartbeat ops ['edit'] blocked
stale_final ops ['edit'] blocked
That is the argument for the choke point, measured: lanes nobody patched are
safe anyway. Pinned by a parametrized test named for those four lanes.
R7-1 IS A REAL BYPASS THE LATCH DOES NOT COVER, and it is fixed here. The guard
rebuilt routing from GATEWAY_RELAY_PLATFORMS while resolve_delivery_transport
asks the CONNECTED adapter (fronts_platform, from the handshake identity set).
Different snapshots: with env discovery stale or momentarily empty, the guard
said 'native' and the router sent over the relay, skipping authorization.
before: guard_relay_routed False / delivery relay
after: guard_relay_routed True / delivery relay / unattested target refused
The guard now asks the live adapter first and falls back to config only when
there is no runner (CLI/cron) — pinned in both directions.
R7-5 (non-blocking, and a fair hit): my stream-fallback test asserted
_egress_declined and never drove _send_fallback_final, so removing that early
return SURVIVED. The test now calls the real fallback and asserts the wire is
untouched; the mutation dies.
Mutations:
R7-1 guard ignores the live adapter KILLED (was SURVIVED)
R7-5 fallback early return removed KILLED (was SURVIVED)
latch not consulted KILLED
491 passed.
* fix(relay): close three holes found by attacking my own latch
Round 8's brief told the reviewer to attack the latch. I did the same in
parallel and found three real holes in it before the review returned.
1. send_for_platform BYPASSED THE LATCH ENTIRELY. It builds and posts its frame
directly rather than through _outbound — and it is the delivery resolver's
OWN entry point, so it is the single most important caller.
before: ops ['edit', 'send'] after: ops ['edit']
gateway/AGENTS.md states the rule I had just broken: 'Seal-interception
exists at BOTH egress doors (send() and send_for_platform()); a new egress
door needs the same two checks.' The latch is a third such check and I had
wired it to one door.
2. A COSMETIC SUCCESS CLEARED THE LATCH. Clearing on ANY success meant a
typing indicator — routinely allowed for a chat whose content is refused —
re-opened the door for the very next send:
ops ['edit', 'typing', 'send']
Only a CONTENT op the connector accepted may clear it now.
3. A THREAD INSIDE A REFUSED CHAT WAS NOT COVERED. A thread lives inside its
parent, so the same content reached the same conversation one level down:
ops ['edit', 'send']
The latch key now strips the thread suffix.
Also normalised int/str chat ids (callers pass both; a type mismatch would
silently unlatch).
MUTATIONS
send_for_platform not latched KILLED
cosmetic success clears the latch KILLED
thread suffix not stripped KILLED
draft-seal retry not latched SURVIVED — EQUIVALENT, proven:
is unreachable while latched (a declined edit before the seal
produces ZERO seal frames, measured). Kept as defence in depth because it
posts directly, and documented at the site rather than covered by a
test that could not fail.
One self-inflicted bug on the way: a blanket replace put 1Password CLI brings 1Password to your terminal.
Turn on the 1Password app integration and sign in to get started. Run
'op signin --help' to learn more.
For more help, read our documentation:
https://www.1password.dev/cli
1Password CLI is built using open-source software. View our credits and
licenses:
https://downloads.1password.com/op/credits/stable/credits.html
Usage: op [command] [flags]
Management Commands:
account Manage your locally configured 1Password accounts
connect Manage Connect server instances and tokens in your 1Password account
document Perform CRUD operations on Document items in your vaults
events-api Manage Events API integrations in your 1Password account
group Manage the groups in your 1Password account
item Perform CRUD operations on the 1Password items in your vaults
plugin Manage the shell plugins you use to authenticate third-party CLIs
service-account Manage service accounts
user Manage users within this 1Password account
vault Manage permissions and perform CRUD operations on your 1Password vaults
Commands:
completion Generate shell completion information
inject Inject secrets into a config file
read Read a secret reference
run Pass secrets as environment variables to a process
signin Sign in to a 1Password account
signout Sign out of a 1Password account
update Check for and download updates.
whoami Get information about a signed-in account
Global Flags:
--account account Select the account to execute the command by account shorthand, sign-in address, account ID, or user ID. For a list
of available accounts, run 'op account list'. Can be set as the OP_ACCOUNT environment variable.
--cache Store and use cached information. Caching is enabled by default on UNIX-like systems. Caching is not available on
Windows. Options: true, false. Can also be set with the OP_CACHE environment variable. (default true)
--config directory Use this configuration directory.
--debug Enable debug mode. Can also be enabled by setting the OP_DEBUG environment variable to true.
--encoding type Use this character encoding type. Default: UTF-8. Supported: SHIFT_JIS, gbk.
--format string Use this output format. Can be 'human-readable' or 'json'. Can be set as the OP_FORMAT environment variable.
(default "human-readable")
-h, --help Get help for op.
--iso-timestamps Format timestamps according to ISO 8601 / RFC 3339. Can be set as the OP_ISO_TIMESTAMPS environment variable.
--no-color Print output without color.
--session token Authenticate with this session token. 1Password CLI outputs session tokens for successful 'op signin' commands when
1Password app integration is not enabled.
-v, --version version for op
Run 'op [command] --help' for more information on the command. into
send_for_platform, which has no such variable. Two existing unfurl tests caught
it — NameError at adapter.py:1407.
504 passed.
* fix(relay): Telegram handle exemption + a turn boundary for the latch
Round 8 blockers. Two of its four were already closed by 93750e351a (it
reviewed the commit before it); these two are real and both are mine.
B1 — THE TELEGRAM @HANDLE EXEMPTION COVERED A NATIVE SEND.
_is_unresolved_handle exempts telegram @handles from attestation because
"the connector resolves and authorizes it". That justification is FALSE
whenever the gateway holds its own token: _send_to_platform calls
_send_telegram(pconfig.token, ...) directly and no connector is involved.
So an unattested @handle went out under the gateway's own credential
while the numeric control was correctly refused.
The exemption now requires that no native credential exists. A probe
fault WITHDRAWS the exemption (falls back to the ordinary attestation
check) rather than granting it.
Shipped with the converse control: relay-only config still exempts
@handles, and numeric targets stay guarded in both modes.
B4 — THE LATCH HAD NO BOUNDARY, SO IT WAS AN OUTAGE MECHANISM.
My own regression, and worse than reported. Removing "clear on cosmetic
success" (correctly) removed the ONLY way the latch could ever clear: a
content op can never reach the connector to succeed, because the latch
blocks it locally first. A refusal at 09:00 muted that chat forever.
A new inbound message for a chat is the generation marker — the natural
teardown point. Suppression still holds for the whole turn.
same_turn_blocked: true next_turn_delivered: true
MUTATIONS (all killed)
handle exemption ignores native credential
native-credential fault GRANTS the exemption
no turn boundary (latch never clears)
teardown clears ALL chats not just this one
teardown ignores the chat
The last two SURVIVED first: I tested _clear_declined_for_turn directly
and never proved _on_inbound calls it — the caller-level gap that has now
produced four blockers on this branch. Added a test driving the real
inbound entry point.
One self-inflicted bug, caught by my own fault test: the probe imported
load_config, which does not exist (it is load_gateway_config), so it
always threw and returned the fault default. The test that pinned fault
behaviour is what exposed it.
510 passed.
* fix(relay): correct latch identity and boundary; one config snapshot
Round 9, four blockers, all reproduced.
B1+B4 — THE TEARDOWN WAS AT THE WRONG PLACE, twice over.
It sat on the adapter's raw _on_inbound, which runs BEFORE profile
routing, the ignored-channel guard, plugin hooks and user authorization.
An unauthorized or dropped event could therefore clear a refusal
belonging to an active turn, and stale content then went out as a
different op. The same placement missed Discord interaction passthrough,
which builds its own MessageEvent and calls handle_message directly, so
slash commands and modal submits stayed muted after an earlier decline.
Both are one mistake: I picked a lane instead of a boundary. Teardown now
runs immediately after _hm_admit_event, the single admission gate every
entry path shares.
dropped event -> latch survives, stale send blocked
admitted event -> latch clears
B2 — THE LATCH KEY SPLIT ON ':', WHICH IS A MISTAKE I ALREADY FIXED ONCE.
_latch_key did str(chat_id).split(":", 1)[0], so !room:tenant-a and
!room:tenant-b both keyed !room: a decline in one Matrix room muted
another, and inbound from one cleared the other's refusal. egress.py
::_session_ids stopped doing exactly this in round 4 and I reintroduced
it three rounds later.
Parent identity is never recoverable from identifier TEXT. Thread
coverage is now structural: _thread_parent looks the relationship up in
the recorded auto-thread map.
B3 — AUTHORIZATION AND DISPATCH USED DIFFERENT CONFIG SNAPSHOTS.
_handle_send retains one pconfig; the guard independently reloaded
config. Across a transition the authorization snapshot could see a
connector-only setup (exemption granted) while dispatch still held the
native token and sent the unattested @handle itself. The guard now takes
native_token from the SAME snapshot dispatch will use. A caller that
omits it does not silently look like "no token".
NB-1/2/3 also closed: real-object snapshot tests, an exception shield
that faces a real exception, and send_follow_up no longer discards the
connector's verdict (that discard is exactly how the edit lane laundered
declines).
MUTATIONS (all killed)
latch key splits on colon again
thread parent lookup disabled
dispatch token ignored by guard
tool drops the snapshot token
admission teardown removed
teardown moved BEFORE admission
exception shield removed
follow_up drops raw_response
"admission teardown removed" SURVIVED first: I had tested the helper, not
_handle_message. Added a test driving production _handle_message with
admission stubbed both ways. Fifth caller-level gap on this branch.
One self-inflicted bug caught before commit: I passed pconfig.token in
_handle_react, which has no pconfig — a NameError on every reaction.
516 passed.
* docs(relay): pin the latch's thread coverage limit as a deliberate trade
_thread_parent only sees connector auto-threads, and that map is capped at
256 entries, so a user-created or evicted thread does not inherit its
parent's latch. Documented at the site and asserted by a test, because the
alternative - deriving parents from identifier text - is exactly what muted
unrelated Matrix rooms in round 9.
The primary control is unaffected: authorize_relay_target takes thread_id as
part of the destination and attests it on every send (6 thread tests).
* refactor(relay): one SendResult decline classifier for all 8 gateway lanes
The extraction found a DEFECT, not just repetition.
Eight gateway lanes each hand-rolled the unwrapping of a decline from a
SendResult, and they did not agree. Six checked only raw_response. Two
also checked the error text. A connector that answers with the uniform
decline SENTENCE and no structured code - the documented contract for
older connectors, per _approval_send_outcome - was therefore classified
as an ordinary failure by those six lanes, so each treated a refusal as
"editing unavailable" and retried through another op.
Measured:
text-only decline six-site check False two-site check True
structured decline six-site check True two-site check True
No content leaked, because the adapter latch classifies the transport
dict directly and catches both shapes (verified: text-only decline still
latches C1 and keeps SECRET off the wire). The cost was wrong verdicts
and futile retries, not disclosure.
declined_send(result) in gateway/relay/egress.py now owns this. It checks
raw_response when structured, else the error text, and preserves the
ambiguous exclusion - an ambiguous result is a transport outcome, so it
must never read as a refusal.
run.py keeps its own shape deliberately: that lane has three verdicts
(ambiguous / declined / failed), so it checks ambiguous first and then
delegates the boolean.
MUTATIONS (all killed)
helper drops the text-only branch
helper drops the structured branch
ambiguous no longer excluded
draft lane decline check removed
edit-failure lane decline check removed
prompt verdict lane check removed
slash-confirm lane check removed
draft lane goes terminal on ANY failure (over-refusal direction)
"draft lane decline check removed" SURVIVED first: _send_draft_frame had
no test driving an unsuccessful send_draft at all. Added one, with an
ordinary-failure control so the fix cannot silently become "one flaky
frame mutes the chat". A non-unique anchor also masked the edit-failure
lane on the first pass - the trap my own skill warns about.
This closes the duplication that caused four of nine rounds of blockers:
a new lane now calls one classifier instead of copying three lines.
519 passed.
* fix(relay): latch identity, new-turn boundary, seal arming, ambiguity
Round 10, four blockers, each reproduced before fixing. Two are my own
regressions from the previous two rounds.
B1 - ADMISSION IS NOT A NEW-TURN BOUNDARY.
Round 9 moved teardown to just after _hm_admit_event. That is only an
ADMISSION gate: an authorized message can be steered into a running
session, answer a pending prompt, run a busy slash command, or be refused
by the pause/drain gates - all without starting a turn. Each of those
cleared the ACTIVE turn's refusal, and a later fallback from that turn
reached the wire (probe: latch emptied, wire ops ['edit', 'send']).
Teardown now runs after _claim_active_session_slot, the first point the
runner OWNS a new turn. The new test drives production _handle_message
through all four non-turn lanes plus the real new-turn path.
B2 - LATCH IDENTITY OMITTED THE LOGICAL PLATFORM.
One relay adapter fronts several platforms, so native ids collide. A
Discord refusal for chat 42 was cleared by clear_egress_latch("telegram",
"42") - the method took a platform and ignored it - and the Discord
fallback then reached the connector. Keyed by normalized platform plus
exact chat id; thread-parent expansion keeps the platform component.
B3 - THE DIRECT DRAFT-SEAL PATH DID NOT ARM THE LATCH.
_seal_open_draft posts through _attempt directly rather than _outbound,
so a definite decline logged and returned but never latched. The
immediate plain-send fallback was suppressed by the caller's own check;
later same-turn sends were not (wire ['draft', 'draft', 'send'], the
third frame carrying refused content).
B4 - MY OWN REFACTOR MADE AMBIGUOUS RESULTS TERMINAL.
send_draft's ambiguous projection discarded raw_response, so
declined_send fell through to the error-text branch - and an ambiguous
result whose text carries the decline marker ("... egress declined: ack
lost") read as a DEFINITE refusal and terminated the run. Ambiguous means
the frame may well have been delivered: a transport outcome, never an
authorization one.
Fixed on both layers: the projection carries the body (and the seal's
ambiguous return is now explicit too), and declined_send's text-only
branch - which cannot see the ambiguous flag - treats ack-lost text as
transport ambiguity. Audited every SendResult projection in adapter.py
for the same shape.
MUTATIONS (all killed)
latch key drops the platform
clear_egress_latch ignores platform
draft seal does not arm the latch
ambiguous projection drops raw body
declined_send infers decline from ack-lost text
teardown back at admission
523 passed.
* refactor(relay): split the terminal-decline latch out of the guard PR
The latch moves to feat/p5-egress-decline-latch (pushed at 3cf45736d7,
which retains the full history) for redesign. This PR keeps the
authorization guard and the per-site decline checks.
WHY. Across eleven review rounds the two halves behaved very differently.
The guard is a PURE FUNCTION of the destination - its blockers were all
"you asked the wrong question" (case sensitivity, nested ImportError,
missing thread_id, config snapshot skew), each a one-line correction that
then stayed fixed. Rounds 7-10 found nothing new in it.
The latch is MUTABLE STATE WITH A LIFETIME living on RelayAdapter - an
object registered once per process that holds the WebSocket and has no
concept of a turn. Nine of its blockers reduce to three questions the
adapter cannot answer: when does it end, who arms it, what is it keyed
on. Every answer so far has been a proxy (a successful op, an inbound
message, an admitted event, a claimed session slot) and every proxy was
wrong in a lane found later.
The per-site checks hold identical information on `st` - a PER-TURN
object - and have produced zero blockers, because the state dies with the
turn and nobody has to decide when it ends.
The no-relaunder property does NOT depend on the latch. Measured on the
real consumer path with the latch absent: a declined draft frame sets
_egress_declined and puts nothing on the wire.
Removal verified structurally rather than by eye: an AST diff of every
symbol between HEAD and this tree reports only latch symbols gone,
nothing added. That check caught two over-deletions my strip made -
_on_inbound (consumed by a "next def" boundary) and _SEEN_INBOUND_MAX
(a class constant inside the removed span). Both restored; 19 failures
went to 0.
ALSO: RESTORED A TEST I WRONGLY REPORTED AS PASSING.
test_tool_guard_forwards_thread_id never made it into the repo - `git log
-S` finds it in no commit - though round 5 recorded its mutant as killed.
Dropping thread_id from the guard call therefore survived the entire
tests/tools suite (146 passed). Written properly this time, driving the
real _handle_send far enough to reach the guard. It now KILLS that
mutant.
MUTATIONS on this tree
guard fault authorizes instead of refusing KILLED
thread_id dropped from the guard call KILLED (was SURVIVED)
handle exemption ignores native credential KILLED
draft lane decline check removed KILLED
prompt verdict lane check removed KILLED
slash-confirm lane check removed KILLED
503 passed.
* test(relay): close the phantom-coverage gaps the guard audit found
The thread_id test that was reported as killing a round-5 mutant turned
out never to have been committed. That is a reason to distrust the other
claimed kills, so I re-ran every guard mutation against the COMMITTED
tree instead of trusting the earlier reports.
Result: 9 of 11 killed, and the two "SKIPPED" ones had non-unique
anchors hiding SIX separate sites. Mutating those individually found
three real survivors.
CASE NORMALISATION (round 3, finding 3) WAS HALF-COVERED.
test_relay_fronted_matching_is_case_insensitive varies the CONFIGURED
name but always requests lowercase "discord", so it pins _relay_fronted's
normalisation and nothing else. The REQUESTED name's `.lower()` was
covered by nothing at all. Probe with it removed:
relay_routed("Discord") -> False
authorize("Discord", unattested) -> AUTHORIZED
which is exactly the bypass round 3 reported, alive again and untested.
Two further sites were untested in the OVER-REFUSAL direction: the
attested store is keyed lowercase, so a mixed-case request missed its own
attested set and refused legitimate traffic. attested_relay_targets' own
normalisation was invisible to every existing test because they all
monkeypatch that function away; it is now asserted against the real
function with only its leaf sources stubbed.
Three tests added. All six case sites now die when mutated.
I also re-did the three fail-closed RelayRouteUnknown mutations properly.
The first pass swapped whole lines and produced IndentationErrors, so
"KILLED" there proved nothing but a syntax error. Neutralising each raise
at correct indentation: all three genuinely KILLED.
FINAL AUDIT ON THIS TREE — 17 mutations, zero survivors
guard: thread_id dropped at the call site
guard: react path unguarded
guard: handle exemption ignores native credential
guard: 3x fail-closed raise neutralised
guard: 6x case-normalisation site
classifier: ambiguous treated as a decline
classifier: text-only decline branch removed
lane: draft / stream-edit / prompt / slash-confirm checks removed
511 passed.
* test(relay): make the stream-edit test fail for the right reason
Review of 45835a282d raised one blocking issue and three non-blocking
ones. All four are addressed; none was a production defect.
BLOCKING — the stream-edit test failed on the double, not on a leak.
test_declined_stream_edit_does_not_send_the_unseen_tail implemented only
the GUARDED path in its consumer double. Removing either guard therefore
raised AttributeError inside the fake before any send could be observed:
guard 1 removed -> AttributeError: no attribute '_is_flood_error'
guard 2 removed -> AttributeError: no attribute '_clean_for_display'
Red, but for the wrong reason — the test could not have caught the leak
it is named for. My own docstring claimed it drove the fallback and
checked the wire; it did neither.
The double now implements everything the UNGUARDED path reaches
(_is_flood_error, _flood_strikes, _current_edit_interval, _last_edit_time,
_notify_new_message, _try_strip_cursor, _clean_for_display,
_fallback_prefix, _metadata_for_send). Both mutations now fail on real
assertions:
guard 1 removed -> assert consumer._egress_declined is True
guard 2 removed -> AssertionError: the unseen tail reached the wire:
['send']
NON-BLOCKING 1 — a docstring claimed more than the test exercises.
test_requested_platform_name_is_also_normalised described a mixed-case
send_message(target="Discord:999") bypass. That entry point cannot reach
it: _resolve_tool_target lowercases the platform at
tools/send_message_tool.py:47 before the guard runs. The test still pins
a real contract — the helpers must not assume a lowercased argument, for
the gateway lanes and any future non-normalising caller — so the claim is
narrowed to that rather than the test removed.
NON-BLOCKING 2 — the module docstring said "every lane drives the REAL
RelayAdapter". The stream tests drive mixin doubles by design, because
the behaviour under test belongs to the adapter's CALLER. Docstring now
distinguishes the two kinds.
NON-BLOCKING 3 — latch-deletion residue in gateway/relay/adapter.py:418:
return None
return latched if surface_declines else None
The second line was unreachable and referenced a name deleted with the
latch. Removed, along with the 20-line comment block describing the latch
as "the structural fix" — that mechanism now lives on
feat/p5-egress-decline-latch, not here.
The reviewer independently confirmed the large deletion: an AST census
between 3cf45736d7 and f57a2298fa reports only latch symbols removed and
nothing added.
511 passed.
* docs(relay): correct three claims that outran the code
Review of 41ce3cc765 found no new production defect but three overstated
claims, one of them in my own commit message.
1. THE LATCH COMMENTARY WAS STILL THERE. My previous commit message said
it removed "the 20-line comment block describing the latch as the
structural fix". It removed only the unreachable statement. Twenty
lines at adapter.py:361-380 still described a per-chat latch, a choke
point and its scope rules - none of which exist on this branch. In a
refusal-sensitive module that reads as coverage this branch does not
have. Now removed for real.
This is the same defect class as the tests: a claim that outran what
the code does. I made it while fixing that class.
2. THE STREAM-TEST DOCSTRING OVERSTATED BOTH MUTANTS. It said the
mutation "now fails on the assertion that a send reached the wire" -
true of one guard, not both. Verified separately:
remove the _on_edit_failure check -> dies on _egress_declined,
never reaches the fallback
remove the fallback early return -> dies on the wire: ['send']
Both are valid behavioural failures, which is what the blocker asked
for; they are different observables and the docstring now says so.
3. Duplicate `from types import SimpleNamespace` from an earlier scripted
insert; imports reordered.
112 tests pass in the four focused files.
* fix(relay): close two authorization defects found in review
Both were reproduced before fixing and both mutants are pinned.
1. A LIVE relay adapter whose fronts_platform() raised degraded into the
config fallback. `_live_relay_fronted` returned None for every failure,
and None means "no live adapter, use the config snapshot" — so a faulting
adapter plus an empty/stale snapshot made the guard conclude "not
relay-routed" and authorize an unattested destination, while
resolve_delivery_transport asks that same adapter and still routes over
the relay. Measured: relay_routed=False, verdict None for chat 999.
Absence and fault now have separate return values: None only when there
is no runner or no relay adapter; a live adapter that cannot answer
raises RelayRouteUnknown. This is the third instance of this bug class in
this file, and the first two were also mine.
2. An attested chat whose id equalled the requested THREAD id vouched for
that thread. The `thread in attested` arm proved nothing about parentage.
Measured: attested {"-100A", "7"} authorized (-100A, thread 7).
Only the bound `parent:thread` form is accepted now. Nothing legitimate
needed the bare arm — _session_entry_id records a threaded origin as
f"{chat_id}:{thread_id}", and a thread addressed as its own channel
arrives as chat_id and passes the parent check.
The existing test blessed the bare form via parametrize, so it PINNED the
defect. Corrected, plus negative controls for the sibling-chat and
other-parent cases and a positive control proving genuine absence still
takes the config path (otherwise fix 1 would break native-only deploys).
Merged origin/main (was 22 behind). 428 passed via scripts/run_tests.sh;
full 10-row mutation ledger re-killed on the merged tree, none dying on an
exception rather than an assertion.
* fix(relay): only a missing adapter is absence; everything else is a fault
Reviewer BLOCKER, reproduced before fixing. Two more paths where a PRESENT
relay adapter still degraded into the config snapshot:
1. `fronts_platform` may be a property or descriptor, so the ATTRIBUTE
LOOKUP can raise — and the lookup sat inside the absence handler. Probed
with a raising property plus an empty snapshot: live=None, routed=False,
verdict=None, i.e. an unattested target authorized. The previous test made
an already-retrieved METHOD raise, so it could not reach this.
2. A present adapter with no usable `fronts_platform` returned None for the
same reason. An adapter that cannot say what it fronts is broken, not
absent, so it now raises too.
Also found by my own spot-check while the review ran: the nested imports of
`gateway.config` / `gateway.run` inside the live probe shared the broad
handler, so a broken installation degraded to the snapshot as well. Probed
with a healthy-adapter positive control in the same run — healthy refused
the unattested target, faulted authorized it. `_relay_fronted` one function
below already drew this exact distinction for its own import.
The boundary is now: `relay is None` is the ONLY absence. Everything about a
present adapter — attribute access, callability, the call itself, and the
imports needed to reach it — is a fault and raises RelayRouteUnknown.
This is the fourth variant of absence-vs-fault in this file and all four
were mine. The lesson is in the code as a comment rather than in a commit
message nobody re-reads.
Four controls keep genuine absence benign: no runner, no relay adapter in
the runner, a real ModuleNotFoundError naming the gateway package, and the
configured-attested-target-still-sends case.
434 passed via scripts/run_tests.sh; 9-row mutation ledger re-killed
including both new guards, none dying on an exception.
* fix(relay): invert the live probe to fail closed by default
Reviewer BLOCKER round 2, reproduced: reading the adapter registry can also
raise. A runner whose `adapters.get()` raised gave relay_present=True,
live=None, routed=False, verdict=None — unattested discord:999 authorized.
That was the FIFTH boundary in one function with the same defect: the call,
the attribute lookup, a non-callable attribute, the nested imports, and now
the registry lookup. Each round I patched the reported boundary and the
defect moved one statement up. The cause was the shape, not the statements:
the function asked "did something go wrong?" and answered None, and None
MEANS "no live adapter, use the config snapshot" — so every statement was a
new chance to fail open, and every new statement would have been too.
Inverted rather than patched a sixth time. Each `return None` now sits
behind an explicit narrow check that cannot itself be the fault (no runner,
no adapters, no relay key, gateway package genuinely absent), and one outer
handler turns anything else into RelayRouteUnknown. A statement added inside
this function is now fail-CLOSED by default.
Verified all six fault shapes raise (call, attribute, missing method,
registry .get, .adapters property, runner ref) and all five absence shapes
stay benign, plus a liveness control where the config snapshot disagrees
with a healthy adapter and the adapter still wins.
Four new tests, including the two absence controls that keep native-only and
CLI deployments working. 438 passed via scripts/run_tests.sh. Mutation
ledger: 8 killed. One survivor recorded as a proven equivalent mutant —
widening `if not registry` to `or {}` is behaviourally identical because
`{}.get()` returns None, i.e. the same absence; it is a readability guard.
1424 lines
54 KiB
Python
1424 lines
54 KiB
Python
"""P5(a): `send_message` cannot silently name an arbitrary relay target.
|
|
|
|
The `target` tool parameter is free-form (`'platform:chat_id'`), so before
|
|
this guard a model could name ANY chat id and the gateway would emit an
|
|
outbound relay frame for it — authenticating the sender while never
|
|
authorizing the destination. These tests drive the REAL `send_message_tool`
|
|
entrypoint through the REAL production wiring (`gateway.relay.egress`,
|
|
`gateway.channel_directory`, `gateway.relay.relay_fronted_platforms`) against
|
|
a temp HERMES_HOME; nothing under test is constructed by the test itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform
|
|
from tools.send_message_tool import send_message_tool
|
|
|
|
ATTESTED_CHAT = "111111111111111111"
|
|
ARBITRARY_CHAT = "999999999999999999"
|
|
HOME_CHAT = "222222222222222222"
|
|
|
|
|
|
@pytest.fixture
|
|
def relay_env(tmp_path, monkeypatch):
|
|
"""A gateway whose ONLY reachable Discord destinations are attested.
|
|
|
|
Mirrors the production shape: `GATEWAY_RELAY_PLATFORMS` is the deploy
|
|
stamp `gateway.relay.relay_fronted_platforms()` reads, the channel
|
|
directory json is the file `channel_directory.load_directory()` reads, and
|
|
no live native adapter exists in this process (so the relay owns egress
|
|
for `discord`, exactly as `gateway/delivery.resolve_delivery_transport`
|
|
decides it).
|
|
"""
|
|
import gateway.channel_directory as cd
|
|
|
|
monkeypatch.setenv("GATEWAY_RELAY_URL", "wss://connector.example/relay")
|
|
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord")
|
|
monkeypatch.setenv("GATEWAY_RELAY_BOT_IDS", json.dumps({"discord": {"botId": "b1"}}))
|
|
|
|
directory = tmp_path / "channel_directory.json"
|
|
directory.write_text(
|
|
json.dumps(
|
|
{
|
|
"updated_at": None,
|
|
"platforms": {
|
|
"discord": [
|
|
{"id": ATTESTED_CHAT, "name": "bot-home", "type": "channel"}
|
|
]
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cd, "DIRECTORY_PATH", directory)
|
|
monkeypatch.setattr(cd, "CHANNEL_ALIASES_PATH", tmp_path / "channel_aliases.json")
|
|
# No gateway-session origins for discord in this temp home.
|
|
monkeypatch.setattr(cd, "_build_from_sessions", lambda _platform: [])
|
|
return directory
|
|
|
|
|
|
def _send(target: str, sent):
|
|
"""Invoke the real tool, recording any egress it attempts."""
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import asyncio
|
|
|
|
discord_cfg = SimpleNamespace(enabled=True, token="t", extra={})
|
|
config = SimpleNamespace(
|
|
platforms={Platform.DISCORD: discord_cfg},
|
|
get_home_channel=lambda _p: SimpleNamespace(chat_id=HOME_CHAT),
|
|
)
|
|
|
|
async def _record(platform, pconfig, chat_id, message, **kwargs):
|
|
sent.append(chat_id)
|
|
return {"success": True, "message_id": "m1"}
|
|
|
|
with patch("gateway.config.load_gateway_config", return_value=config), patch(
|
|
"tools.interrupt.is_interrupted", return_value=False
|
|
), patch("model_tools._run_async", side_effect=lambda c: asyncio.run(c)), patch(
|
|
"tools.send_message_tool._send_to_platform", side_effect=_record
|
|
), patch(
|
|
"gateway.mirror.mirror_to_session", return_value=False
|
|
):
|
|
return json.loads(
|
|
send_message_tool(
|
|
{"action": "send", "target": target, "message": "hello"}
|
|
)
|
|
)
|
|
|
|
|
|
def test_arbitrary_relay_chat_id_is_refused_and_never_egresses(relay_env):
|
|
"""The whole observable: refused, naming THAT target, and ZERO egress."""
|
|
sent: list[str] = []
|
|
result = _send(f"discord:{ARBITRARY_CHAT}", sent)
|
|
|
|
assert result == {
|
|
"error": (
|
|
f"Refusing to send to unattested relay target 'discord:{ARBITRARY_CHAT}': "
|
|
"this gateway has no record of that destination. Use "
|
|
"send_message(action='list') to see the targets it can reach."
|
|
)
|
|
}
|
|
assert sent == []
|
|
|
|
|
|
def test_attested_directory_chat_id_still_sends(relay_env):
|
|
"""The guard must not destroy the feature: an attested chat goes through."""
|
|
sent: list[str] = []
|
|
result = _send(f"discord:{ATTESTED_CHAT}", sent)
|
|
|
|
assert result == {"success": True, "message_id": "m1"}
|
|
assert sent == [ATTESTED_CHAT]
|
|
|
|
|
|
def test_home_channel_is_attested(relay_env):
|
|
"""The operator-configured home channel is a provenance, not a guess."""
|
|
sent: list[str] = []
|
|
result = _send("discord", sent)
|
|
|
|
assert result["success"] is True
|
|
assert sent == [HOME_CHAT]
|
|
|
|
|
|
def test_session_origin_chat_is_attested(relay_env, monkeypatch):
|
|
"""A chat this gateway actually holds a session in is reachable."""
|
|
import gateway.channel_directory as cd
|
|
|
|
monkeypatch.setattr(
|
|
cd,
|
|
"_build_from_sessions",
|
|
lambda platform: (
|
|
[{"id": ARBITRARY_CHAT, "name": "seen", "type": "channel"}]
|
|
if platform == "discord"
|
|
else []
|
|
),
|
|
)
|
|
sent: list[str] = []
|
|
result = _send(f"discord:{ARBITRARY_CHAT}", sent)
|
|
|
|
assert result["success"] is True
|
|
assert sent == [ARBITRARY_CHAT]
|
|
|
|
|
|
def test_platform_not_fronted_by_relay_is_untouched(relay_env, monkeypatch):
|
|
"""Non-relay platforms keep their own adapters' authorization, unchanged."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "telegram")
|
|
monkeypatch.setenv(
|
|
"GATEWAY_RELAY_BOT_IDS", json.dumps({"telegram": {"botId": "b1"}})
|
|
)
|
|
sent: list[str] = []
|
|
result = _send(f"discord:{ARBITRARY_CHAT}", sent)
|
|
|
|
assert result["success"] is True
|
|
assert sent == [ARBITRARY_CHAT]
|
|
|
|
|
|
def test_live_native_adapter_takes_precedence_over_the_relay_guard(
|
|
relay_env, monkeypatch
|
|
):
|
|
"""A platform served by a live NATIVE adapter here is not a relay egress.
|
|
|
|
Same precedence `gateway/delivery.resolve_delivery_transport` applies: a
|
|
concrete native adapter always wins over the relay, so this guard must not
|
|
fire for it.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.run
|
|
|
|
runner = SimpleNamespace(adapters={Platform.DISCORD: object()})
|
|
monkeypatch.setattr(gateway.run, "_gateway_runner_ref", lambda: runner)
|
|
sent: list[str] = []
|
|
result = _send(f"discord:{ARBITRARY_CHAT}", sent)
|
|
|
|
assert result["success"] is True
|
|
assert sent == [ARBITRARY_CHAT]
|
|
|
|
|
|
def test_react_refuses_an_arbitrary_relay_target(relay_env):
|
|
"""Reactions are outbound acts too — same floor, same refusal."""
|
|
result = json.loads(
|
|
send_message_tool(
|
|
{
|
|
"action": "react",
|
|
"target": f"discord:{ARBITRARY_CHAT}",
|
|
"emoji": "👍",
|
|
}
|
|
)
|
|
)
|
|
assert result == {
|
|
"error": (
|
|
f"Refusing to send to unattested relay target 'discord:{ARBITRARY_CHAT}': "
|
|
"this gateway has no record of that destination. Use "
|
|
"send_message(action='list') to see the targets it can reach."
|
|
)
|
|
}
|
|
|
|
# ── B-1: the guard must authorize the RESOLVED destination ──────────────────
|
|
#
|
|
# Slack `@handle` / `U...` targets are internal PSEUDO-ids
|
|
# (`user_name:ben`, `user:U...`) until `_resolve_slack_user_target` opens the
|
|
# DM and returns the real `D...` conversation. Provenances only ever hold
|
|
# resolved ids, so authorizing the pseudo-id compares a handle against a set
|
|
# of channel ids and refuses every Slack DM — an OUTAGE caused by a security
|
|
# fix. Review round 1 found this; reproduced before fixing.
|
|
#
|
|
# These tests are the falsifiable floor for the guard's POSITION: they pass
|
|
# only while authorization happens AFTER resolution.
|
|
|
|
SLACK_DM = "D01234567AB"
|
|
SLACK_USER = "U01234567AB"
|
|
|
|
|
|
@pytest.fixture
|
|
def slack_relay_env(tmp_path, monkeypatch):
|
|
"""A relay-fronted Slack gateway whose attested destination is a DM id."""
|
|
import gateway.channel_directory as cd
|
|
|
|
monkeypatch.setenv("GATEWAY_RELAY_URL", "wss://connector.example/relay")
|
|
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "slack")
|
|
monkeypatch.setenv("GATEWAY_RELAY_BOT_IDS", json.dumps({"slack": {"botId": "b1"}}))
|
|
|
|
directory = tmp_path / "channel_directory.json"
|
|
directory.write_text(
|
|
json.dumps(
|
|
{
|
|
"updated_at": None,
|
|
# The DM conversation id — what resolution produces, and the
|
|
# only form any provenance ever stores.
|
|
"platforms": {"slack": [{"id": SLACK_DM, "name": "ben", "type": "im"}]},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cd, "DIRECTORY_PATH", directory)
|
|
monkeypatch.setattr(cd, "CHANNEL_ALIASES_PATH", tmp_path / "channel_aliases.json")
|
|
monkeypatch.setattr(cd, "_build_from_sessions", lambda _platform: [])
|
|
return directory
|
|
|
|
|
|
def _send_slack(target: str, sent, *, resolves_to: str | None = SLACK_DM):
|
|
"""Invoke the real tool with the REAL Slack resolution step in the path.
|
|
|
|
Only `conversations.open` is faked (a network call). The ordering of the
|
|
guard against the resolver is production's.
|
|
"""
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
slack_cfg = SimpleNamespace(enabled=True, token="xoxb-t", extra={})
|
|
config = SimpleNamespace(
|
|
platforms={Platform.SLACK: slack_cfg},
|
|
get_home_channel=lambda _p: SimpleNamespace(chat_id=SLACK_DM),
|
|
)
|
|
|
|
async def _record(platform, pconfig, chat_id, message, **kwargs):
|
|
sent.append(chat_id)
|
|
return {"success": True, "message_id": "m1"}
|
|
|
|
async def _resolve(_token, target_ref):
|
|
# Stands in for the Slack API call only; returns what production's
|
|
# resolver returns — the opened DM channel id.
|
|
return (resolves_to, None)
|
|
|
|
with patch("gateway.config.load_gateway_config", return_value=config), patch(
|
|
"tools.interrupt.is_interrupted", return_value=False
|
|
), patch("model_tools._run_async", side_effect=lambda c: asyncio.run(c)), patch(
|
|
"tools.send_message_tool._send_to_platform", side_effect=_record
|
|
), patch(
|
|
"tools.send_message_tool._resolve_slack_user_target", side_effect=_resolve
|
|
), patch(
|
|
"gateway.mirror.mirror_to_session", return_value=False
|
|
):
|
|
return json.loads(
|
|
send_message_tool({"action": "send", "target": target, "message": "hello"})
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[f"slack:@ben", f"slack:{SLACK_USER}", f"slack:<@{SLACK_USER}>"],
|
|
)
|
|
def test_slack_user_targets_resolve_then_authorize(slack_relay_env, target):
|
|
"""An attested DM must SEND regardless of which alias names it.
|
|
|
|
Fails if the guard runs before resolution: the pseudo-id
|
|
(`user_name:ben` / `user:U...`) is not in any provenance, so the send is
|
|
refused and `sent` stays empty.
|
|
"""
|
|
sent: list[str] = []
|
|
result = _send_slack(target, sent)
|
|
|
|
assert result == {"success": True, "message_id": "m1"}
|
|
# The whole observable: it egressed, and to the RESOLVED destination.
|
|
assert sent == [SLACK_DM]
|
|
|
|
|
|
def test_slack_user_target_resolving_to_unattested_dm_is_refused(slack_relay_env):
|
|
"""Moving the guard must not disable it.
|
|
|
|
A handle that resolves to a DM this gateway cannot attest is still
|
|
refused — and the refusal names the RESOLVED id, which is the destination
|
|
that was actually authorized.
|
|
"""
|
|
sent: list[str] = []
|
|
unattested = "D99999999XX"
|
|
result = _send_slack("slack:@stranger", sent, resolves_to=unattested)
|
|
|
|
assert result == {
|
|
"error": (
|
|
f"Refusing to send to unattested relay target 'slack:{unattested}': "
|
|
"this gateway has no record of that destination. Use "
|
|
"send_message(action='list') to see the targets it can reach."
|
|
)
|
|
}
|
|
assert sent == []
|
|
|
|
|
|
# ── the guard must FAIL CLOSED on its own fault ─────────────────────────────
|
|
#
|
|
# Round-2 review: `_authorize_relay_target` wrapped BOTH the import and the
|
|
# call in one `except Exception: return None`, and None means AUTHORIZED. So
|
|
# any runtime bug inside the guard silently switched the whole P5(a) boundary
|
|
# off — the most expensive possible failure mode for an authorization check.
|
|
|
|
|
|
def test_guard_fault_refuses_rather_than_authorizing(relay_env, monkeypatch):
|
|
"""A guard that cannot answer must refuse, and must not egress."""
|
|
import gateway.relay.egress as eg
|
|
from tools import send_message_tool as smt
|
|
|
|
def _boom(*_a, **_k):
|
|
raise RuntimeError("bug inside the guard")
|
|
|
|
monkeypatch.setattr(eg, "authorize_relay_target", _boom)
|
|
|
|
denial = smt._authorize_relay_target("discord", ATTESTED_CHAT)
|
|
assert denial is not None, "a faulting guard authorized the send"
|
|
assert "authorization check failed" in denial
|
|
|
|
# And end to end: nothing may egress.
|
|
sent: list[str] = []
|
|
result = _send(f"discord:{ATTESTED_CHAT}", sent)
|
|
assert "error" in result
|
|
assert sent == []
|
|
|
|
|
|
def test_missing_gateway_package_still_allows(relay_env, monkeypatch):
|
|
"""The tolerated case survives: no gateway ⇒ no relay egress to authorize.
|
|
|
|
This is the distinction the original code collapsed. Keeping it tested
|
|
stops a future "make it fail closed" change from breaking the CLI-only
|
|
install.
|
|
"""
|
|
import builtins
|
|
|
|
from tools import send_message_tool as smt
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def _no_gateway(name, *a, **k):
|
|
if name.startswith("gateway.relay.egress"):
|
|
# The shape real absence takes: ModuleNotFoundError WITH a name.
|
|
# A bare ImportError is not something a missing module produces,
|
|
# and treating it as absence was a fail-open (round 4, blocker 1).
|
|
raise ModuleNotFoundError("no gateway package", name="gateway")
|
|
return real_import(name, *a, **k)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _no_gateway)
|
|
assert smt._authorize_relay_target("discord", ARBITRARY_CHAT) is None
|
|
|
|
|
|
# ── fail-open boundaries: ABSENCE is not FAULT ─────────────────────────────
|
|
|
|
|
|
def test_route_discovery_fault_refuses_rather_than_authorizing(relay_env, monkeypatch):
|
|
"""A fault while determining relay routing must DENY, not fall through.
|
|
|
|
`_relay_fronted` used to swallow every exception and return an empty set,
|
|
which `relay_routed_platform` reads as "not relay-routed" — skipping the
|
|
guard entirely. Review injected a discovery fault and watched an
|
|
unattested target get authorized.
|
|
"""
|
|
import gateway.relay as gr
|
|
from tools.send_message_tool import _authorize_relay_target
|
|
|
|
def boom():
|
|
raise RuntimeError("config unreadable while listing fronted platforms")
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", boom)
|
|
denial = _authorize_relay_target("discord", "999")
|
|
assert denial is not None
|
|
assert "could not be" in denial
|
|
|
|
|
|
def test_missing_relay_module_still_authorizes(relay_env, monkeypatch):
|
|
"""The converse: genuine ABSENCE must keep working (no gateway ⇒ no relay).
|
|
|
|
Without this, "fail closed on faults" would silently become "refuse
|
|
everything in CLI/cron contexts", which is the outage the original broad
|
|
except was there to avoid.
|
|
"""
|
|
import gateway.relay as gr
|
|
from tools.send_message_tool import _authorize_relay_target
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
assert _authorize_relay_target("discord", "999") is None
|
|
|
|
|
|
def test_relay_module_import_fault_refuses(monkeypatch):
|
|
"""A module that EXISTS but fails to import is a fault, not an absence."""
|
|
import builtins
|
|
|
|
import tools.send_message_tool as smt
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name == "gateway.relay.egress":
|
|
raise RuntimeError("broken dependency inside an installed gateway")
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
denial = smt._authorize_relay_target("discord", "999")
|
|
assert denial is not None
|
|
assert "could not be" in denial
|
|
|
|
|
|
@pytest.mark.parametrize("configured", ["discord", "Discord", "DISCORD", " discord "])
|
|
def test_relay_fronted_matching_is_case_insensitive(relay_env, monkeypatch, configured):
|
|
"""Review round 3, finding 3 — an attestation bypass on a string compare.
|
|
|
|
`relay_routed_platform` lowercases the REQUESTED name but `_relay_fronted`
|
|
returned configured names verbatim, so a platform configured as "Discord"
|
|
missed the membership test and looked native — skipping the guard entirely.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {configured})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: set())
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
@pytest.mark.parametrize("requested", ["Discord", "DISCORD", " discord "])
|
|
def test_requested_platform_name_is_also_normalised(relay_env, monkeypatch, requested):
|
|
"""The OTHER half of round 3, finding 3 — and it was never covered.
|
|
|
|
The test above varies the CONFIGURED name while always requesting
|
|
lowercase "discord", so it only pins `_relay_fronted`'s normalisation.
|
|
Removing `.lower()` from the REQUESTED name in `relay_routed_platform`
|
|
(and in `authorize_relay_target`) therefore survived the whole suite.
|
|
|
|
SCOPE, precisely: `send_message` itself cannot reach this, because
|
|
`_resolve_tool_target` lowercases the platform at
|
|
tools/send_message_tool.py:47 before the guard is called. This pins the
|
|
HELPERS' own contract for every other caller — the gateway lanes, and any
|
|
future entry point that does not pre-normalise. Both functions are public
|
|
within the package and must not assume a lowercased argument.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: set())
|
|
|
|
assert eg.relay_routed_platform(requested) is True
|
|
assert eg.authorize_relay_target(requested, "999") is not None
|
|
|
|
|
|
@pytest.mark.parametrize("requested", ["Discord", "DISCORD"])
|
|
def test_mixed_case_request_still_reaches_attested_targets(relay_env, monkeypatch, requested):
|
|
"""Control: normalising the requested name must not refuse real traffic.
|
|
|
|
The attestation store is keyed by the LOWERCASE platform. If the lookup in
|
|
`attested_relay_targets` / `authorize_relay_target` stops normalising, a
|
|
mixed-case request misses its own attested set and is refused — an OUTAGE
|
|
for legitimate traffic rather than a bypass. Both directions matter, so the
|
|
lookup name is asserted, not just the routing decision.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
seen = []
|
|
|
|
def _attested(platform):
|
|
seen.append(platform)
|
|
return {"999"} if platform == "discord" else set()
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", _attested)
|
|
|
|
assert eg.authorize_relay_target(requested, "999") is None
|
|
# The store was queried with the NORMALISED name.
|
|
assert seen and all(p == "discord" for p in seen), seen
|
|
|
|
|
|
@pytest.mark.parametrize("requested", ["Discord", "DISCORD", " discord "])
|
|
def test_attested_lookup_normalises_before_querying_the_sources(relay_env, monkeypatch, requested):
|
|
"""`attested_relay_targets` does its OWN normalisation, and every other
|
|
test monkeypatches this function away — so that `.lower()` was covered by
|
|
nothing. Dropping it made a mixed-case platform find an EMPTY attested set
|
|
(its sources are keyed lowercase), refusing legitimate traffic.
|
|
|
|
Asserted against the real function with only its leaf sources stubbed.
|
|
"""
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(eg, "_home_channel_id", lambda n: None)
|
|
monkeypatch.setattr(eg, "_directory_ids", lambda n: {"999"} if n == "discord" else set())
|
|
monkeypatch.setattr(eg, "_session_ids", lambda n: set())
|
|
|
|
assert eg.attested_relay_targets(requested) == {"999"}
|
|
|
|
|
|
def test_attested_target_still_allowed_when_config_case_differs(relay_env, monkeypatch):
|
|
"""Control: normalizing must not start refusing legitimate traffic."""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"Discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"999"})
|
|
assert eg.authorize_relay_target("discord", "999") is None
|
|
|
|
|
|
def test_nested_dependency_importerror_refuses(monkeypatch):
|
|
"""Review round 3, finding 2 — `except ImportError` was still fail-open.
|
|
|
|
An ImportError naming a NESTED module means an installed gateway failed to
|
|
load (broken dependency). That is a fault, not "there is no relay here",
|
|
and returning None means authorized.
|
|
"""
|
|
import builtins
|
|
|
|
import tools.send_message_tool as smt
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name == "gateway.relay.egress":
|
|
raise ModuleNotFoundError(
|
|
"No module named 'gateway.relay.dependency'",
|
|
name="gateway.relay.dependency",
|
|
)
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
denial = smt._authorize_relay_target("discord", "999")
|
|
assert denial is not None
|
|
|
|
|
|
def test_absent_gateway_module_importerror_still_authorizes(monkeypatch):
|
|
"""Control: genuine absence (CLI/cron) must keep working."""
|
|
import builtins
|
|
|
|
import tools.send_message_tool as smt
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name == "gateway.relay.egress":
|
|
raise ModuleNotFoundError("No module named 'gateway'", name="gateway")
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
assert smt._authorize_relay_target("discord", "999") is None
|
|
|
|
|
|
def test_nameless_importerror_refuses(monkeypatch):
|
|
"""Round 4, blocker 1 — a bare ImportError is a FAULT, not absence.
|
|
|
|
Genuine absence raises ModuleNotFoundError with `.name` set (verified
|
|
against the interpreter). A plain ImportError therefore comes from an
|
|
import hook or a module that failed while initializing, and authorizing on
|
|
it means any such fault silently disables the boundary.
|
|
"""
|
|
import builtins
|
|
|
|
import tools.send_message_tool as smt
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name == "gateway.relay.egress":
|
|
raise ImportError("something went wrong during init")
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
assert smt._authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_session_attestation_does_not_invent_a_matrix_room_prefix(monkeypatch):
|
|
"""Round 4, blocker 2 — the attestation set must not FABRICATE ids.
|
|
|
|
`_session_ids` split every id on the first colon to recover "chat" from
|
|
"chat:thread". Matrix room ids contain a colon natively
|
|
(`!room:server.org`), so the split attested a bare `!room` that no session
|
|
ever used — the guard vouching for a destination on its own invention.
|
|
"""
|
|
import gateway.channel_directory as cd
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"matrix"})
|
|
# A Matrix room with NO thread: the colon is part of the address itself.
|
|
monkeypatch.setattr(
|
|
cd,
|
|
"_build_from_sessions",
|
|
lambda p: [{"id": "!owned:server.org", "thread_id": None}],
|
|
)
|
|
|
|
# The real session id is still attested...
|
|
assert eg.authorize_relay_target("matrix", "!owned:server.org") is None
|
|
# ...but the invented prefix is not.
|
|
assert eg.authorize_relay_target("matrix", "!owned") is not None
|
|
|
|
|
|
def test_session_attestation_still_recovers_a_slack_thread_parent(monkeypatch):
|
|
"""Control: thread-parent recovery must keep working.
|
|
|
|
Slack session ids are genuinely `chat:thread` and the connector authorizes
|
|
the CHAT, so failing to recover the parent would refuse legitimate replies.
|
|
"""
|
|
import gateway.channel_directory as cd
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"slack"})
|
|
# A genuinely thread-qualified entry carries thread_id separately, which is
|
|
# how the parent is recovered — no string guessing.
|
|
monkeypatch.setattr(
|
|
cd,
|
|
"_build_from_sessions",
|
|
lambda p: [{"id": "C123:1700000000.1", "thread_id": "1700000000.1"}],
|
|
)
|
|
|
|
assert eg.authorize_relay_target("slack", "C123") is None
|
|
|
|
|
|
def test_egress_module_own_import_boundary_fails_closed(monkeypatch):
|
|
"""Round 4, non-blocking finding: `gateway/relay/egress.py` has its OWN
|
|
import boundary (`_relay_fronted` -> `from gateway.relay import ...`), and
|
|
the existing nested-ImportError test intercepts the EARLIER import in
|
|
tools/send_message_tool.py, so this one was never exercised.
|
|
"""
|
|
import builtins
|
|
|
|
import gateway.relay.egress as eg
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name == "gateway.relay" and "relay_fronted_platforms" in (kw.get("fromlist") or a[2] if len(a) > 2 else []):
|
|
raise ModuleNotFoundError("broken dep", name="gateway.relay.broken_dep")
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._relay_fronted()
|
|
|
|
|
|
def test_matrix_thread_parent_is_recovered_without_splitting_the_room_id(monkeypatch):
|
|
"""The case the allow-list could never have handled correctly.
|
|
|
|
A Matrix room id contains a colon AND the session can be thread-qualified:
|
|
`!room:server.org:$thread`. Splitting on the FIRST colon yields `!room`
|
|
(invented); using the structured `thread_id` yields the real room.
|
|
"""
|
|
import gateway.channel_directory as cd
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"matrix"})
|
|
monkeypatch.setattr(
|
|
cd,
|
|
"_build_from_sessions",
|
|
lambda p: [{"id": "!room:server.org:$thr", "thread_id": "$thr"}],
|
|
)
|
|
|
|
assert eg.authorize_relay_target("matrix", "!room:server.org") is None
|
|
assert eg.authorize_relay_target("matrix", "!room") is not None
|
|
|
|
|
|
# ── round 5 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_disabled_native_adapter_is_not_treated_as_native(monkeypatch):
|
|
"""R5-1: the guard and the delivery router must not disagree about routing.
|
|
|
|
`resolve_delivery_transport` ignores a native adapter whose config is
|
|
DISABLED and routes over Relay. `_has_live_native_adapter` treated mere
|
|
presence in the adapter map as native, so the guard skipped authorization
|
|
for a send that actually went over the relay.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay.egress as eg
|
|
from gateway.config import Platform
|
|
|
|
monkeypatch.setattr(
|
|
eg,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.DISCORD: object()}),
|
|
raising=False,
|
|
)
|
|
import gateway.run as gr_run
|
|
|
|
monkeypatch.setattr(gr_run, "_gateway_runner_ref", eg._gateway_runner_ref, raising=False)
|
|
monkeypatch.setattr(
|
|
eg,
|
|
"load_gateway_config",
|
|
lambda: SimpleNamespace(
|
|
platforms={Platform.DISCORD: SimpleNamespace(enabled=False)}
|
|
),
|
|
raising=False,
|
|
)
|
|
import gateway.config as gc
|
|
|
|
monkeypatch.setattr(
|
|
gc,
|
|
"load_gateway_config",
|
|
lambda: SimpleNamespace(
|
|
platforms={Platform.DISCORD: SimpleNamespace(enabled=False)}
|
|
),
|
|
)
|
|
assert eg._has_live_native_adapter("discord") is False
|
|
|
|
|
|
def test_enabled_native_adapter_is_still_native(monkeypatch):
|
|
"""Control: an ENABLED native adapter must keep bypassing the relay guard."""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.config as gc
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
ref = lambda: SimpleNamespace(adapters={Platform.DISCORD: object()}) # noqa: E731
|
|
monkeypatch.setattr(gr_run, "_gateway_runner_ref", ref, raising=False)
|
|
monkeypatch.setattr(
|
|
gc,
|
|
"load_gateway_config",
|
|
lambda: SimpleNamespace(
|
|
platforms={Platform.DISCORD: SimpleNamespace(enabled=True)}
|
|
),
|
|
)
|
|
assert eg._has_live_native_adapter("discord") is True
|
|
|
|
|
|
def test_arbitrary_thread_under_an_attested_parent_is_refused(relay_env, monkeypatch):
|
|
"""R5-2: on Discord the THREAD is the REST destination.
|
|
|
|
`POST /channels/{thread_id}/messages` — so an attested parent channel must
|
|
not vouch for a caller-supplied thread the gateway has never seen.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"111"})
|
|
assert eg.authorize_relay_target("discord", "111", "999") is not None
|
|
|
|
|
|
def test_attested_thread_is_allowed(relay_env, monkeypatch):
|
|
"""Control: a thread the gateway HAS a provenance for must still send.
|
|
|
|
Only the BOUND `chat:thread` form counts. This test used to also accept a
|
|
bare `{"111", "999"}` and so pinned a real defect: an unrelated attested
|
|
chat whose id equalled the requested thread id authorized that thread. The
|
|
bound form is what `_session_entry_id` actually records, so nothing
|
|
legitimate is lost.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"111", "111:999"})
|
|
assert eg.authorize_relay_target("discord", "111", "999") is None
|
|
|
|
|
|
def test_unrelated_attested_chat_does_not_vouch_for_a_thread(relay_env, monkeypatch):
|
|
"""An attested chat id equal to the requested THREAD id proves nothing.
|
|
|
|
Reproduced against the pre-fix code: attested `{"-100A", "7"}` plus a
|
|
request for thread `7` under parent `-100A` returned None (authorized),
|
|
though no session ever existed in that thread. `7` is a sibling CHAT, not
|
|
a thread of `-100A`.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"-100A", "7"})
|
|
denial = eg.authorize_relay_target("discord", "-100A", "7")
|
|
assert denial is not None and "no record of" in denial
|
|
|
|
|
|
def test_a_thread_attested_under_another_parent_is_refused(relay_env, monkeypatch):
|
|
"""`B:55` must not authorize thread 55 under parent A."""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(
|
|
eg, "attested_relay_targets", lambda p: {"-100A", "-100B", "-100B:55"}
|
|
)
|
|
assert eg.authorize_relay_target("discord", "-100A", "55") is not None
|
|
# Control: the same thread under its OWN parent still sends.
|
|
assert eg.authorize_relay_target("discord", "-100B", "55") is None
|
|
|
|
|
|
def test_tool_guard_forwards_the_thread_id(monkeypatch):
|
|
"""The wrapper must PASS thread_id, not just accept it.
|
|
|
|
Mutating `_authorize_relay_target` to drop the argument survived every
|
|
other test here — they all call `authorize_relay_target` directly, so
|
|
nothing observed what the tool wrapper forwards. Same gap as the caller
|
|
findings: testing the callee never proves the caller uses it.
|
|
"""
|
|
import tools.send_message_tool as smt
|
|
|
|
seen = {}
|
|
|
|
def fake_authorize(platform_name, chat_id, thread_id=None):
|
|
seen["args"] = (platform_name, chat_id, thread_id)
|
|
return None
|
|
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(eg, "authorize_relay_target", fake_authorize)
|
|
smt._authorize_relay_target("discord", "111", "999")
|
|
assert seen["args"] == ("discord", "111", "999")
|
|
|
|
|
|
def test_config_read_fault_refuses_rather_than_assuming_native(monkeypatch):
|
|
"""R6-1: my own R5-1 fix reintroduced the bypass it was closing.
|
|
|
|
`_has_live_native_adapter` caught a `load_gateway_config()` failure and
|
|
returned True, so a config fault declared the platform native while the
|
|
ROUTER — reading the real config — would send over the relay. Routing we
|
|
cannot determine is UNKNOWN, and unknown must refuse.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.config as gc
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.DISCORD: object()}),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: set())
|
|
|
|
def boom():
|
|
raise RuntimeError("config unreadable")
|
|
|
|
monkeypatch.setattr(gc, "load_gateway_config", boom)
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_healthy_config_with_enabled_native_still_bypasses_the_guard(monkeypatch):
|
|
"""Control: the guard must not start refusing native platforms."""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.config as gc
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.DISCORD: object()}),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: set())
|
|
monkeypatch.setattr(
|
|
gc,
|
|
"load_gateway_config",
|
|
lambda: SimpleNamespace(
|
|
platforms={Platform.DISCORD: SimpleNamespace(enabled=True)}
|
|
),
|
|
)
|
|
assert eg.authorize_relay_target("discord", "999") is None
|
|
|
|
|
|
def test_guard_uses_the_live_adapter_not_stale_env_discovery(monkeypatch):
|
|
"""R7-1: the guard and the delivery router read DIFFERENT snapshots.
|
|
|
|
`resolve_delivery_transport` asks the CONNECTED relay adapter
|
|
(`fronts_platform`, from the handshake identity set). The guard rebuilt
|
|
routing from `GATEWAY_RELAY_PLATFORMS`. When that env is stale or
|
|
momentarily empty the guard said "native", the router sent over the relay,
|
|
and the guard was skipped for a relay send.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
class _LiveRelay:
|
|
def fronts_platform(self, p):
|
|
return getattr(p, "value", p) == "discord"
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.RELAY: _LiveRelay()}),
|
|
raising=False,
|
|
)
|
|
# Env discovery is empty/stale — the disagreement condition.
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: set())
|
|
|
|
assert eg.relay_routed_platform("discord") is True
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_guard_falls_back_to_config_when_no_live_adapter(monkeypatch):
|
|
"""Control: with no live runner (CLI/cron) the config path must still work."""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
|
|
monkeypatch.setattr(gr_run, "_gateway_runner_ref", lambda: None, raising=False)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: set())
|
|
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_a_live_adapter_that_cannot_answer_is_a_fault_not_an_absence(monkeypatch):
|
|
"""A LIVE relay adapter whose `fronts_platform()` raises must fail closed.
|
|
|
|
This was a real bypass. `_live_relay_fronted` caught every exception and
|
|
returned None, which means "no live adapter — use the config snapshot". So
|
|
a live adapter that could not report its routing PLUS an empty/stale config
|
|
snapshot made the guard conclude "not relay-routed" and authorize an
|
|
unattested destination, while `resolve_delivery_transport` asks that same
|
|
adapter and still routes over the relay. Measured before the fix:
|
|
`relay_routed=False`, verdict `None` for chat `999`.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
class Exploding:
|
|
def fronts_platform(self, platform):
|
|
raise RuntimeError("descriptor lookup failed")
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.RELAY: Exploding()}),
|
|
raising=False,
|
|
)
|
|
# An EMPTY config snapshot: the fallback the fault used to reach.
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._live_relay_fronted()
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg.relay_routed_platform("discord")
|
|
|
|
# And the tool-facing guard must refuse rather than authorize.
|
|
from tools.send_message_tool import _authorize_relay_target
|
|
|
|
denial = _authorize_relay_target("discord", "999")
|
|
assert denial is not None
|
|
assert "could not" in denial or "unavailable" in denial or "refus" in denial.lower()
|
|
|
|
|
|
def test_a_missing_relay_adapter_is_still_an_absence(monkeypatch):
|
|
"""Positive control for the split above: genuine ABSENCE keeps the config path.
|
|
|
|
A runner with no relay adapter at all must NOT raise — otherwise the fix
|
|
above would have turned every native-only deployment into a hard failure.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
|
|
monkeypatch.setattr(
|
|
gr_run, "_gateway_runner_ref", lambda: SimpleNamespace(adapters={}), raising=False
|
|
)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
assert eg._live_relay_fronted() is None
|
|
assert eg.relay_routed_platform("discord") is True
|
|
assert eg.authorize_relay_target("discord", "123") is None
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_a_raising_fronts_platform_attribute_is_a_fault_not_an_absence(monkeypatch):
|
|
"""Reading the attribute can raise, and that is still a FAULT.
|
|
|
|
Reviewer finding, reproduced before fixing. `fronts_platform` may be a
|
|
property or descriptor, so the LOOKUP itself can raise — and the lookup
|
|
used to sit inside the absence handler, which returned None and degraded
|
|
to the config snapshot:
|
|
|
|
live=None, routed=False, verdict=None ← unattested target authorized
|
|
|
|
The earlier test only made an already-retrieved METHOD raise, so it could
|
|
not catch this. Only `relay is None` is absence now; everything about a
|
|
present adapter, attribute access included, is a fault.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
class RaisingAttr:
|
|
@property
|
|
def fronts_platform(self):
|
|
raise RuntimeError("attribute access failed")
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.RELAY: RaisingAttr()}),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._live_relay_fronted()
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg.relay_routed_platform("discord")
|
|
|
|
|
|
def test_an_adapter_without_fronts_platform_is_a_fault(monkeypatch):
|
|
"""A present adapter missing the method entirely cannot answer either.
|
|
|
|
It used to return None (→ config fallback). A relay adapter that cannot
|
|
say what it fronts is a broken adapter, not an absent one.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.RELAY: object()}),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._live_relay_fronted()
|
|
|
|
|
|
def test_an_adapter_registry_that_cannot_be_read_is_a_fault(monkeypatch):
|
|
"""Reviewer finding, round 2: `.get()` on the registry can raise too.
|
|
|
|
This was the FIFTH boundary in this one function where "something went
|
|
wrong" became `None`, i.e. "no live adapter, use the config snapshot".
|
|
Probed before the fix: relay_present=True, live=None, routed=False,
|
|
verdict=None — unattested `discord:999` authorized.
|
|
|
|
The function is now inverted: each `return None` sits behind an explicit
|
|
narrow check, and anything else raises. That is why this test and the one
|
|
below are grouped with the other fault cases rather than patching a sixth
|
|
boundary.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
class HostileRegistry(dict):
|
|
def get(self, key, default=None):
|
|
raise RuntimeError("registry read failed")
|
|
|
|
class Runner:
|
|
def __init__(self):
|
|
self.adapters = HostileRegistry({Platform.RELAY: object()})
|
|
|
|
monkeypatch.setattr(gr_run, "_gateway_runner_ref", Runner, raising=False)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._live_relay_fronted()
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg.relay_routed_platform("discord")
|
|
|
|
|
|
def test_an_unreadable_adapters_attribute_is_a_fault(monkeypatch):
|
|
"""`.adapters` may itself be a property that raises."""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
|
|
class Hostile:
|
|
@property
|
|
def adapters(self):
|
|
raise RuntimeError("adapters unavailable")
|
|
|
|
monkeypatch.setattr(gr_run, "_gateway_runner_ref", Hostile, raising=False)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._live_relay_fronted()
|
|
|
|
|
|
def test_a_runner_with_no_relay_key_is_still_an_absence(monkeypatch):
|
|
"""Control: a runner holding only NATIVE adapters is an absence, not a fault.
|
|
|
|
Without this control the inversion above could have turned every
|
|
native-only gateway into a hard failure.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.TELEGRAM: object()}),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"discord"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
assert eg._live_relay_fronted() is None
|
|
assert eg.authorize_relay_target("discord", "123") is None
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_a_healthy_live_adapter_is_unaffected_by_the_fault_handling(monkeypatch):
|
|
"""Liveness control: the ordinary path must still answer from the adapter.
|
|
|
|
Every other test here drives a failure. This one proves the fault handling
|
|
did not swallow the success case: a healthy adapter's own answer wins, the
|
|
attested target sends, and the unattested one is refused.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
import gateway.run as gr_run
|
|
from gateway.config import Platform
|
|
|
|
class Healthy:
|
|
def fronts_platform(self, platform):
|
|
return str(getattr(platform, "value", "")).lower() == "discord"
|
|
|
|
monkeypatch.setattr(
|
|
gr_run,
|
|
"_gateway_runner_ref",
|
|
lambda: SimpleNamespace(adapters={Platform.RELAY: Healthy()}),
|
|
raising=False,
|
|
)
|
|
# The config snapshot DISAGREES on purpose: the live adapter must win.
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
assert eg._live_relay_fronted() == {"discord"}
|
|
assert eg.relay_routed_platform("discord") is True
|
|
assert eg.authorize_relay_target("discord", "123") is None
|
|
assert eg.authorize_relay_target("discord", "999") is not None
|
|
|
|
|
|
def test_a_broken_gateway_import_inside_the_live_probe_is_a_fault(monkeypatch):
|
|
"""A nested ImportError in the live probe must not degrade to the config path.
|
|
|
|
Found by spot-check, not by the reviewer. `_live_relay_fronted` imports
|
|
`gateway.config` and `gateway.run` inside its own try; before this, ANY
|
|
ImportError there returned None, which means "no live adapter — use the
|
|
config snapshot". So a broken installation plus an empty snapshot
|
|
authorized an unattested destination. Probed with a healthy-adapter
|
|
positive control in the same run:
|
|
|
|
healthy_routed=True, healthy_refuses_unattested=True
|
|
fault_routed=False, fault_verdict=None ← authorized
|
|
|
|
`_relay_fronted` below already made exactly this distinction for its own
|
|
import; this is the same rule one function up.
|
|
"""
|
|
import builtins
|
|
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: set())
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def broken(name, *args, **kwargs):
|
|
if name == "gateway.config":
|
|
raise ImportError("cannot import name Platform from gateway.config")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", broken)
|
|
|
|
with pytest.raises(eg.RelayRouteUnknown):
|
|
eg._live_relay_fronted()
|
|
|
|
|
|
def test_a_genuinely_absent_gateway_package_is_still_an_absence(monkeypatch):
|
|
"""Control for the test above: real absence must stay benign.
|
|
|
|
`ModuleNotFoundError` naming the gateway package itself means there is no
|
|
relay egress to authorize. If this raised, a CLI-only install would fail
|
|
every send instead of using its native credential.
|
|
"""
|
|
import builtins
|
|
|
|
import gateway.relay.egress as eg
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def absent(name, *args, **kwargs):
|
|
if name == "gateway.run":
|
|
raise ModuleNotFoundError("No module named 'gateway'", name="gateway")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", absent)
|
|
|
|
assert eg._live_relay_fronted() is None
|
|
|
|
|
|
# ── the Telegram @handle exemption must not cover a NATIVE send ────────────
|
|
|
|
|
|
def test_handle_exemption_withdrawn_when_a_native_token_exists(monkeypatch):
|
|
"""The exemption's justification is "the connector authorizes this".
|
|
|
|
That is false when the gateway holds its own Telegram token:
|
|
`_send_to_platform` calls `_send_telegram(pconfig.token, ...)` directly and
|
|
no connector is involved. Measured before the fix: the numeric control was
|
|
refused while `@unattested_public` was DELIVERED with the gateway's own
|
|
credential.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"telegram"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
monkeypatch.setattr(eg, "_has_native_credential", lambda p: True)
|
|
|
|
assert eg.authorize_relay_target("telegram", "@unattested_public") is not None
|
|
# Controls: the ordinary guard is unchanged in both directions.
|
|
assert eg.authorize_relay_target("telegram", "999") is not None
|
|
assert eg.authorize_relay_target("telegram", "123") is None
|
|
|
|
|
|
def test_handle_exemption_survives_when_only_the_connector_can_send(monkeypatch):
|
|
"""The converse control — without it the fix is just "refuse everything".
|
|
|
|
Relay-only is the configuration the exemption exists for: the connector
|
|
holds the token and resolves the handle, so it authorizes the destination.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"telegram"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
monkeypatch.setattr(eg, "_has_native_credential", lambda p: False)
|
|
|
|
assert eg.authorize_relay_target("telegram", "@public_channel") is None
|
|
# A resolved destination stays fully guarded even in this mode.
|
|
assert eg.authorize_relay_target("telegram", "999") is not None
|
|
|
|
|
|
def test_native_credential_probe_fault_withdraws_the_exemption(monkeypatch):
|
|
"""A fault must not GRANT an exemption — the safe direction is to withdraw
|
|
it and fall back to the ordinary attestation check."""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"telegram"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
|
|
import gateway.config as gcfg
|
|
|
|
def _boom(*a, **kw):
|
|
raise RuntimeError("config unreadable")
|
|
|
|
monkeypatch.setattr(gcfg, "load_gateway_config", _boom)
|
|
assert eg._has_native_credential("telegram") is True
|
|
assert eg.authorize_relay_target("telegram", "@anything") is not None
|
|
|
|
|
|
# ── one config snapshot for authorization AND dispatch ─────────────────────
|
|
|
|
|
|
def test_dispatch_snapshot_token_withdraws_the_exemption(monkeypatch):
|
|
"""Authorization must use the SAME token dispatch will send with.
|
|
|
|
Reviewer probe: the guard reloaded config independently, so a transition
|
|
where the authorization snapshot had no token but the retained dispatch
|
|
snapshot did produced a native send to an unattested handle.
|
|
"""
|
|
import gateway.relay as gr
|
|
import gateway.relay.egress as eg
|
|
|
|
monkeypatch.setattr(gr, "relay_fronted_platforms", lambda: {"telegram"})
|
|
monkeypatch.setattr(eg, "attested_relay_targets", lambda p: {"123"})
|
|
# The independent reload is STALE and says connector-only.
|
|
monkeypatch.setattr(eg, "_has_native_credential", lambda p: False)
|
|
|
|
# Dispatch will use this token, so the exemption must not be granted.
|
|
assert eg.authorize_relay_target(
|
|
"telegram", "@unattested", native_token="123456:dispatch-token"
|
|
) is not None
|
|
# Converse: a genuinely tokenless dispatch still exempts the handle.
|
|
assert eg.authorize_relay_target("telegram", "@public", native_token=None) is None
|
|
# Controls unchanged in both modes.
|
|
assert eg.authorize_relay_target("telegram", "999", native_token=None) is not None
|
|
assert eg.authorize_relay_target("telegram", "123", native_token="t") is None
|
|
|
|
|
|
def test_tool_guard_forwards_the_dispatch_token(monkeypatch):
|
|
"""CALLER-LEVEL. The test above drives the callee directly, so it passes
|
|
even if the tool never forwards its snapshot — the gap that has produced
|
|
four blockers on this branch. This asserts the wiring itself.
|
|
"""
|
|
import tools.send_message_tool as smt
|
|
|
|
seen = {}
|
|
|
|
def _spy(platform_name, chat_id, thread_id=None, *, native_token=smt._TOKEN_UNSET):
|
|
seen["native_token"] = native_token
|
|
return None
|
|
|
|
monkeypatch.setattr("gateway.relay.egress.authorize_relay_target", _spy)
|
|
smt._authorize_relay_target("telegram", "@x", None, native_token="123:tok")
|
|
assert seen["native_token"] == "123:tok"
|
|
|
|
|
|
def test_a_caller_that_omits_the_snapshot_does_not_get_the_exemption(monkeypatch):
|
|
"""A forgotten argument must not silently look like "no native token"."""
|
|
import tools.send_message_tool as smt
|
|
|
|
seen = {}
|
|
|
|
def _spy(platform_name, chat_id, thread_id=None, **kwargs):
|
|
seen["kwargs"] = kwargs
|
|
return None
|
|
|
|
monkeypatch.setattr("gateway.relay.egress.authorize_relay_target", _spy)
|
|
smt._authorize_relay_target("telegram", "@x")
|
|
# No native_token forwarded at all -> the guard runs its own probe.
|
|
assert "native_token" not in seen["kwargs"]
|
|
|
|
|
|
def test_tool_guard_forwards_thread_id(monkeypatch):
|
|
"""CALLER-LEVEL: the tool must pass thread_id INTO the guard.
|
|
|
|
thread_id is part of the DESTINATION — on Discord the thread is the literal
|
|
REST target, so an attested parent must not vouch for an arbitrary thread.
|
|
Every other test in this file calls `authorize_relay_target` directly, so
|
|
they all pass even when the tool drops the argument on the floor: the
|
|
mutation `_authorize_relay_target(platform_name, chat_id, None, ...)`
|
|
survived the ENTIRE tests/tools suite (146 passed).
|
|
"""
|
|
import tools.send_message_tool as smt
|
|
|
|
seen = {}
|
|
|
|
def _spy(platform_name, chat_id, thread_id=None, **kwargs):
|
|
seen["thread_id"] = thread_id
|
|
# Refuse, so the send stops here and the test asserts only the wiring.
|
|
return "refused-for-test"
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
|
|
monkeypatch.setattr(smt, "_authorize_relay_target", _spy)
|
|
monkeypatch.setattr(
|
|
smt, "_resolve_tool_target", lambda target: ("discord", "C1", "T99", None)
|
|
)
|
|
# Get past config resolution so execution actually reaches the guard.
|
|
monkeypatch.setattr(
|
|
smt,
|
|
"_resolve_platform_config",
|
|
lambda name, config: (
|
|
Platform.DISCORD,
|
|
PlatformConfig(enabled=True, token="t", extra={}),
|
|
None,
|
|
None,
|
|
),
|
|
)
|
|
|
|
smt._handle_send({"target": "discord:C1:T99", "message": "hi"})
|
|
|
|
assert seen.get("thread_id") == "T99", "the tool dropped thread_id before the guard"
|