From ca7b44f8de41aa3d1d8420d178b5c9dbc6c2e393 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:46:09 +0800 Subject: [PATCH 1/2] fix(lark): verify source authority once per reply operation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../app-conversation-and-async-inbox-v0.md | 11 ++ ...p-conversation-and-async-inbox-v0.zh-CN.md | 8 + loopx/extensions/lark/inbox_reply.py | 113 +++++++------ .../extensions/lark/private_conversations.py | 37 +++- tests/test_lark_private_conversations.py | 25 ++- tests/test_lark_source_authority_reply.py | 158 ++++++++++++++++++ 6 files changed, 284 insertions(+), 68 deletions(-) create mode 100644 tests/test_lark_source_authority_reply.py diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index ccdfd9b6ca..2a003d18c9 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -69,6 +69,17 @@ Future callback repeatedly reschedules unfinished work. File/Core and native protocol fixtures qualify this scheduling path separately from installed service, provider latency, complete stop acknowledgement and first-visible Web results. +Native Lark sends and receipt recovery use one operation-bound source verifier +for the current App, exact profile/chat/message and Core grant, as existing +reply edits already do. The observed Bot name must match the Inbox metadata; +a rename still holds the first send while metadata refreshes. The verifier +joins source reads and rechecks the exact binding before returning. A lost +readback retains the same attempt and re-verifies without resending. No identity +or grant is cached across operations, and legacy membership-only callbacks +retain separate sender authentication. File/Core fixtures qualify this transport +transaction, including late revocation; they do not qualify installed provider +latency, the complete stop SLA or first-visible Web results. + The packaged settings journey, source revocation/recovery, duplicate events, native queue/stop and two-App isolation have synthetic-provider regression and browser coverage. A native Codex source canary separately qualifies distinct diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index b2429ee4e3..ee3787af38 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -49,6 +49,14 @@ canonical journal,并重新核验来源与 Core。扫描前清除提示,避 反复重排尚未完成的工作。File/Core 与原生协议 fixture 单独验证这一调度路径, 不替代安装态服务、provider 延迟、完整停止回执及 Web 首次可见结果的验收。 +原生 Lark 新发送和回执恢复在单次操作内,由同一来源校验核验当前 App、确切 +profile/chat/message 及 Core grant,与既有回复编辑路径一致。观测到的 Bot 名称 +必须匹配 Inbox 元数据;改名时仍先停止发送,刷新元数据后再继续。校验等待全部 +来源读取完成,并在返回前重读确切绑定。丢失读回保留同一 attempt,重新校验但不 +重复发送。操作之间不缓存身份或 grant;旧 membership-only callback 继续独立验证 +发送身份。File/Core fixture 验证这一传输事务及读取期间撤权,不认证安装态 provider +延迟、完整停止 SLA 或 Web 首次可见结果。 + 源码 canary 已验证打包页面的桌面/窄屏、撤权拒绝与恢复、慢会话期间另一 App 完成、 后续消息持久排队、exact stop,以及重启后原会话恢复和已确认回复不重复发送。 这些是合成 provider/协议验收;真实原生 Codex 另行验证独立线程与上下文隔离。 diff --git a/loopx/extensions/lark/inbox_reply.py b/loopx/extensions/lark/inbox_reply.py index 36c82ff460..05b3ddd333 100644 --- a/loopx/extensions/lark/inbox_reply.py +++ b/loopx/extensions/lark/inbox_reply.py @@ -33,6 +33,10 @@ ) CommandRunner = Callable[[Sequence[str]], Mapping[str, Any]] +# Internal transport callback: verify the current Core App/grant and exact +# profile/chat/source, returning that fresh App's display name only on success. +# This is not a caller-supplied boolean or a reusable identity observation. +SourceAuthorityVerifier = Callable[[str, str, str], str | None] BOT_IDENTITY_VERIFY_ATTEMPTS = 3 @@ -227,6 +231,27 @@ def _bot_identity_verified( return False +def _verified_sender_and_source( + *, runner: CommandRunner, base: Sequence[str], profile: str, chat_id: str, + message_id: str, expected_name: str, + source_membership_verifier: Callable[[], bool] | None, + source_authority_verifier: SourceAuthorityVerifier | None, +) -> tuple[bool, bool]: + if source_authority_verifier is not None: + if source_membership_verifier is not None or not message_id: + raise ValueError("one source-bound verification owner is required") + name = source_authority_verifier(profile, chat_id, message_id) + verified = isinstance(name, str) and bool(name) + return verified and name == expected_name, verified + identity = _bot_identity_verified(runner=runner, base=base, expected_name=expected_name) + if not identity: + return False, False + membership = ({"returncode": 0 if source_membership_verifier() else 1} + if source_membership_verifier is not None else _call(runner, + [*base, "im", "chats", "get", "--chat-id", chat_id, "--as", "bot", "--format", "json"])) + return True, membership.get("returncode") == 0 + + def _result( *, status: str, @@ -285,6 +310,7 @@ def _deliver_lark_inbox_outbound( provider_preflight: bool = False, runner: CommandRunner = _default_runner, source_membership_verifier: Callable[[], bool] | None = None, + source_authority_verifier: SourceAuthorityVerifier | None = None, before_send: Callable[[str], Mapping[str, Any]] | None = None, delivery_attempt_recorder: Callable[[Mapping[str, str | None]], None] | None = None, short_message_limit: int | None = DEFAULT_LARK_TEXT_LIMIT, @@ -394,10 +420,11 @@ def _deliver_lark_inbox_outbound( ) base = ["lark-cli", "--profile", profile] - identity_verified = _bot_identity_verified( - runner=runner, - base=base, - expected_name=str(reply_config["bot_display_name"]), + identity_verified, membership_verified = _verified_sender_and_source( + runner=runner, base=base, profile=profile, chat_id=chat_id, + message_id=source_message_id, expected_name=str(reply_config["bot_display_name"]), + source_membership_verifier=source_membership_verifier, + source_authority_verifier=source_authority_verifier, ) if not identity_verified: return _result( @@ -409,25 +436,7 @@ def _deliver_lark_inbox_outbound( format_preflight_passed=True, ) - if source_membership_verifier is not None: - membership = {"returncode": 0 if source_membership_verifier() else 1} - else: - membership = _call( - runner, - base - + [ - "im", - "chats", - "get", - "--chat-id", - chat_id, - "--as", - "bot", - "--format", - "json", - ], - ) - if membership.get("returncode") != 0: + if not membership_verified: return _result( status="gate_required", ok=False, @@ -759,6 +768,7 @@ def reply_lark_event_inbox( provider_preflight: bool = False, runner: CommandRunner = _default_runner, source_membership_verifier: Callable[[], bool] | None = None, + source_authority_verifier: SourceAuthorityVerifier | None = None, before_send: Callable[[str], Mapping[str, Any]] | None = None, delivery_attempt_recorder: Callable[[Mapping[str, str | None]], None] | None = None, short_message_limit: int | None = DEFAULT_LARK_TEXT_LIMIT, @@ -774,6 +784,12 @@ def reply_lark_event_inbox( Intermediate admission/progress replies pass ``finalize_reactions=False``; their verified delivery does not settle the source's processing lifecycle. + + Native Core callers may supply ``source_authority_verifier`` instead of + the legacy membership callback. It must freshly verify the exact supplied + profile/chat/message and Core grants, returning the observed Bot name. + The sender consumes that result once; retries invoke the callback again. + Ordinary/legacy callers retain independent identity and membership checks. """ result = _deliver_lark_inbox_outbound( @@ -786,6 +802,7 @@ def reply_lark_event_inbox( provider_preflight=provider_preflight, runner=runner, source_membership_verifier=source_membership_verifier, + source_authority_verifier=source_authority_verifier, before_send=before_send, delivery_attempt_recorder=delivery_attempt_recorder, short_message_limit=short_message_limit, @@ -808,6 +825,7 @@ def verify_lark_inbox_reply( attempt: Mapping[str, Any], runner: CommandRunner = _default_runner, source_membership_verifier: Callable[[], bool] | None = None, + source_authority_verifier: SourceAuthorityVerifier | None = None, finalize_reactions: bool = True, attachment_keys: tuple[str, ...] = (), attachment_names: tuple[str, ...] = (), @@ -864,36 +882,13 @@ def verify_lark_inbox_reply( "blocker": "provider_delivery_intent_conflict", } base = ["lark-cli", "--profile", profile] - if not _bot_identity_verified( - runner=runner, - base=base, + identity, membership = _verified_sender_and_source( + runner=runner, base=base, profile=profile, chat_id=chat_id, message_id=message_id, expected_name=str(reply_config.get("bot_display_name") or ""), - ): - return { - "ok": False, - "verification_performed": False, - "reply_verified": False, - "blocker": "provider_verification_unavailable", - } - if source_membership_verifier is not None: - membership = {"returncode": 0 if source_membership_verifier() else 1} - else: - membership = _call( - runner, - [ - *base, - "im", - "chats", - "get", - "--chat-id", - chat_id, - "--as", - "bot", - "--format", - "json", - ], - ) - if membership.get("returncode") != 0: + source_membership_verifier=source_membership_verifier, + source_authority_verifier=source_authority_verifier, + ) + if not identity or not membership: return { "ok": False, "verification_performed": False, @@ -987,7 +982,8 @@ def update_lark_inbox_reply( *, project: str | Path, config_path: str | Path, message_id: str, text: str, previous_text: str, attempt: Mapping[str, Any], runner: CommandRunner = _default_runner, - source_membership_verifier: Callable[[], bool], + source_membership_verifier: Callable[[], bool] | None = None, + source_authority_verifier: SourceAuthorityVerifier | None = None, before_send: Callable[[str], Any], delivery_attempt_recorder: Callable[[Any], None], finalize_reactions: bool = False, @@ -1026,7 +1022,16 @@ def update_lark_inbox_reply( body = {"msg_type": "post", "content": content} if len(json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) > LARK_POST_REQUEST_MAX_BYTES: return {"ok": False, "reply_verified": False, "blocker": "provider_update_too_large"} - if not source_membership_verifier(): + if source_authority_verifier is not None: + identity, membership = _verified_sender_and_source( + runner=runner, base=base, profile=profile, chat_id=chat_id, message_id=message_id, + expected_name=str(reply.get("bot_display_name") or ""), + source_membership_verifier=source_membership_verifier, + source_authority_verifier=source_authority_verifier) + source_verified = identity and membership + else: + source_verified = source_membership_verifier is not None and source_membership_verifier() + if not source_verified: return {"ok": False, "reply_verified": False, "blocker": "provider_verification_unavailable"} ref = str(attempt["message_ref"]) read_args = [*base, "im", "+messages-mget", "--message-ids", ref, "--as", "bot", "--no-reactions", "--format", "json"] diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index a5dead1846..76d9b0a691 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -141,7 +141,8 @@ def _completed_stop_root(self, record: Mapping[str, Any], binding: Mapping[str, return None def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] | None = None, - render_content: bool = True) -> Mapping[str, Any] | None: + render_content: bool = True, + sender_identity: dict[str, str] | None = None) -> Mapping[str, Any] | None: """Read the exact source under this App, then recheck the Core audience. A p2p source read proves the destination without requiring group-member @@ -174,7 +175,8 @@ def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] | if current != read_binding or current != selected["binding"]: return None # Revocation/replacement while reads ran is not a delivery grant. else: - selected = self.bindings.resolve(binding_id=record["binding_id"], **record["source"]) + selected = self.bindings.resolve(binding_id=record["binding_id"], + include_observation=sender_identity is not None, **record["source"]) native_path = self.core.root / f"{record['request_ref']}.json" native = _read_json(native_path) if native_path.exists() else {} if native.get("agent_target"): @@ -302,13 +304,35 @@ def sources() -> tuple[Mapping[str, Any] | None, Mapping[str, Any] | None]: message["_command_input"] = self._group_text_command_input(message, observation) if not message["_command_input"]: return None # A display rendering cannot supply missing canonical text. + # Outbound source reads must join before consuming this + # operation's proof. Admission retains Core's fenced rejection + # and its existing response when a binding changes mid-read. + if not render_content: + current = next((row for row in self.bindings.read()["bindings"] + if row.get("binding_id") == record["binding_id"]), None) + if current != selected["binding"]: + return None + if sender_identity is not None: + if observation is None: + return None + sender_identity["bot_display_name"] = str(observation.get("bot_display_name") or "") return message except (KeyError, ValueError, OSError): pass return None - def _source_verified(self, record: dict[str, Any]) -> bool: - return self._source_message(record, render_content=False) is not None + def _source_verified(self, record: dict[str, Any], *, sender_identity: dict[str, str] | None = None) -> bool: + return self._source_message(record, render_content=False, sender_identity=sender_identity) is not None + + def _source_authority(self, record: dict[str, Any], profile: str, chat_id: str, message_id: str) -> str | None: + """Verify one exact outbound operation; retain no identity or grant.""" + if (profile != record["profile"] or chat_id != record["event"]["chat_id"] + or message_id != record["event"]["message_id"]): + return None + identity: dict[str, str] = {} + if not self._source_verified(record, sender_identity=identity): + return None + return identity.get("bot_display_name") @staticmethod def _group_text_command_input(message: Mapping[str, Any], observation: Mapping[str, Any]) -> str | None: @@ -569,7 +593,7 @@ def _deliver(self, path: Path, record: dict[str, Any], phase: str, text: str, *, kwargs = dict(project=self.runtime_root, config_path=config, message_id=record["event"]["message_id"], text=text, runner=self._reply_runner, finalize_reactions=phase not in {"admission", "progress"} and not (phase == "terminal" and record.get("commission_resources")), - source_membership_verifier=lambda: self._source_verified(record)) + source_authority_verifier=lambda profile, chat, message: self._source_authority(record, profile, chat, message)) progress = record["deliveries"].get("progress") or {} stream = record.get("stream") or {} edit = phase_state.get("edit_from") @@ -665,7 +689,8 @@ def before_send(_intent: str) -> dict[str, bool]: message_id=record["event"]["message_id"], text=text, previous_text=_presentation_text(stream.get("confirmed_text") or progress["text"]), attempt=stream.get("confirmed_attempt") or progress["attempt"], - runner=self._reply_runner, source_membership_verifier=lambda: self._source_verified(record), + runner=self._reply_runner, + source_authority_verifier=lambda profile, chat, message: self._source_authority(record, profile, chat, message), before_send=before_send, delivery_attempt_recorder=lambda value: confirmed.update(value)) stream["blocker"] = result.get("blocker") if result.get("blocker") == "provider_update_edit_limit": diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 7a6db17dc1..027265e7fa 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -279,8 +279,17 @@ def unavailable(**kwargs): assert result["status"] == "completed" transport.reconcile() assert len(store.list_sessions()) == 1 - final = ("notes-app", result["response"]["message"]) - assert provider.writes[-1] == final and provider.writes.count(final) == 1 + delivered = json.loads(path.read_text()) + terminal = delivered["deliveries"]["terminal"] + assert delivered["status"] == "delivered" and terminal["verified"] is True + # Recovery can finish a draft by editing its existing message. + # Provider-visible content, not creation history, proves the final. + final = provider.messages[terminal["attempt"]["message_ref"]] + assert final["chat_id"] == event["chat_id"] + assert final["sender"] == {"id": provider.profile_apps["notes-app"], "sender_type": "app"} + content = json.loads(final["body"]["content"]) + assert content["zh_cn"]["content"][0][0]["text"] == result["response"]["message"] + assert sum(message.get("body") == final["body"] for message in provider.messages.values()) == 1 assert all(profile == "notes-app" for profile, _ in provider.writes) assert [m["text"] for m in store.messages(row["session_id"]) if m["role"] == "user"] == [event["content"]] packets = [json.loads(line) for line in ordinary[4].read_text().splitlines()] @@ -387,10 +396,10 @@ def observed(profile, **kwargs): if checking_source.is_set(): source_identity_observed.set() return observe(profile, **kwargs) - def verified(record): + def verified(record, **kwargs): checking_source.set() try: - return source_verified(record) + return source_verified(record, **kwargs) finally: checking_source.clear() monkeypatch.setattr(transport.bindings, "observe", observed) @@ -1191,10 +1200,10 @@ def test_completed_stop_inbox_name_cannot_replace_fresh_source_checks(completed_ entered, release = threading.Event(), threading.Event() original = transport._source_verified - def verify(row): + def verify(row, **kwargs): entered.set() assert release.wait(3) - return original(row) + return original(row, **kwargs) monkeypatch.setattr(transport, "_source_verified", verify) with ThreadPoolExecutor(max_workers=1) as workers: @@ -1461,9 +1470,9 @@ def test_completed_stop_reply_worker_waits_for_both_reads_and_does_not_send_afte original_verify = transport._source_verified original_sources, original_observe = transport._completed_stop_sources, transport.bindings.observe - def verify(value): + def verify(value, **kwargs): checking_source.set() - return original_verify(value) + return original_verify(value, **kwargs) def sources(*args): source_started.set() diff --git a/tests/test_lark_source_authority_reply.py b/tests/test_lark_source_authority_reply.py new file mode 100644 index 0000000000..c6d7efd0cc --- /dev/null +++ b/tests/test_lark_source_authority_reply.py @@ -0,0 +1,158 @@ +"""Source-bound send/recovery journeys through the real native File/Core owner.""" +import json +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest +from test_chat_ordinary_project import ordinary # noqa: F401 +from test_lark_private_conversations import ( # noqa: F401 + completed_group_stop, connect, connect_group, +) + +from loopx.extensions.lark.inbox_reply import reply_lark_event_inbox + + +def _deliver(transport, record, *, phase="admission"): + path = transport.root / f"{record['request_ref']}.json" + # Each test prepares this Inbox before counting the send/recovery itself. + # Metadata preparation is separate from its fresh source authority check. + config = transport.runtime_root / ".loopx/config/private-chat" / f"{record['source']['source_ref']}.json" + assert config.exists() + return transport._deliver(path, record, phase, "Original source result", inbox=lambda: config) + + +def test_send_then_lost_readback_recovers_with_one_fresh_source_authority_each_time(completed_group_stop): # noqa: F811 + _, provider, transport, root, _, record = completed_group_stop + transport._inbox(record) + provider.calls.clear() + provider.verify_replies = False + assert not _deliver(transport, record) + assert len(provider.writes) == 1 + before_send = next(i for i, args in enumerate(provider.calls) + if "+messages-reply" in args and "--dry-run" not in args) + assert sum("auth" in args for args in provider.calls[:before_send]) == 1 + assert record["deliveries"]["admission"]["attempt"]["message_ref"] == "om_out_0" + provider.verify_replies = True + provider.calls.clear() + assert _deliver(transport, record) + assert sum("auth" in args for args in provider.calls) == 1 + assert len(provider.writes) == 1 + assert provider.topic_writes == [("oc_community", root["message_id"], "Original source result")] + # A successful previous operation is not authority for this readback. + record["deliveries"]["admission"]["verified"] = False + provider.messages[root["message_id"]]["mentions"] = [] + provider.calls.clear() + assert not _deliver(transport, record) + assert sum("auth" in args for args in provider.calls) == 1 + assert len(provider.writes) == 1 + + +@pytest.mark.parametrize("private", [False, True]) +def test_ordinary_result_uses_the_same_fresh_app_proof_for_sender_and_source(ordinary, private): # noqa: F811 + _, runtime, provider, transport = connect(ordinary) if private else connect_group(ordinary) + try: + root = provider.event("notes-app", "status-root", "/status") if private else provider.topic("status-root", "/status") + assert transport.admit("notes-app", root)["status"] == "command_recorded" + record = next(json.loads(p.read_text()) for p in transport.root.glob("*.json")) + transport._inbox(record) + provider.calls.clear() + assert _deliver(transport, record) + assert sum("auth" in args for args in provider.calls) == 1 + assert provider.writes == [("notes-app", "Original source result")] + finally: + runtime.close() + + +@pytest.mark.parametrize("change", ["app", "bot", "owner", "revoke", "root", "sender", "chat", "parent", "thread"]) +def test_current_source_denial_never_becomes_a_send_or_receipt_intent(completed_group_stop, change): # noqa: F811 + _, provider, transport, root, event, record = completed_group_stop + transport._inbox(record) + if change == "app": + provider.profile_apps["notes-app"] = "cli_other" + elif change == "bot": + provider.bot_ready = False + elif change == "owner": + # Group authority is App-scoped, independent of a private owner login. + provider.owner_ready = False + assert _deliver(transport, record) + return + elif change == "revoke": + transport.bindings.disconnect(record["binding_id"], expected_revision=transport.bindings.read()["revision"]) + elif change == "root": + provider.messages[root["message_id"]]["mentions"] = [] + else: + field, value = {"sender": ("sender", {"id": "ou_other", "sender_type": "user"}), + "chat": ("chat_id", "oc_other"), "parent": ("parent_id", "om_other"), + "thread": ("thread_id", "omt_other")}[change] + provider.messages[event["message_id"]][field] = value + assert not _deliver(transport, record) + assert not provider.writes and not provider.reaction_creates + assert not record["deliveries"]["admission"].get("started") + + +def test_ordinary_source_read_must_join_before_late_revocation_can_authorize_a_send(ordinary, monkeypatch): # noqa: F811 + _, runtime, provider, transport = connect_group(ordinary) + entered, release = threading.Event(), threading.Event() + try: + root = provider.topic("late-root", "/status") + event = provider.topic("late-reply", "/status", root=root["message_id"]) + assert transport.admit("notes-app", event)["status"] == "command_recorded" + record = next(json.loads(p.read_text()) for p in transport.root.glob("*.json")) + config = transport._inbox(record) + original = transport._group_source_message + + def read(profile, message_id): + result = original(profile, message_id) + if message_id == root["message_id"]: + entered.set() + assert release.wait(3) + return result + + monkeypatch.setattr(transport, "_group_source_message", read) + with ThreadPoolExecutor(max_workers=1) as worker: + pending = worker.submit(transport._deliver, transport.root / f"{record['request_ref']}.json", + record, "admission", "Original source result", inbox=lambda: config) + try: + assert entered.wait(2) + transport.bindings.disconnect(record["binding_id"], expected_revision=transport.bindings.read()["revision"]) + assert not provider.writes + finally: + release.set() + assert not pending.result(timeout=3) + assert not provider.writes and not record["deliveries"]["admission"].get("started") + finally: + release.set() + runtime.close() + + +@pytest.mark.parametrize("field", ["profile", "chat", "message"]) +def test_a_foreign_inbox_route_cannot_borrow_the_original_source_proof(completed_group_stop, field): # noqa: F811 + _, provider, transport, _, event, record = completed_group_stop + args = ["notes-app", event["chat_id"], event["message_id"]] + args[["profile", "chat", "message"].index(field)] = "foreign" + provider.calls.clear() + assert transport._source_authority(record, *args) is None + assert not provider.calls and not provider.writes + + +def test_legacy_membership_callback_keeps_its_own_sender_identity_gate(completed_group_stop): # noqa: F811 + _, provider, transport, _, event, record = completed_group_stop + config = transport._inbox(record) + provider.bot_ready = False + calls = [] + result = reply_lark_event_inbox(project=transport.runtime_root, config_path=config, + message_id=event["message_id"], text="Original source result", execute=True, + runner=transport._reply_runner, source_membership_verifier=lambda: calls.append(True) or True) + assert not result["ok"] and not result["sender_identity_verified"] + assert not calls and not provider.writes + + +def test_a_boolean_is_not_a_verified_source_identity(completed_group_stop): # noqa: F811 + _, provider, transport, _, event, record = completed_group_stop + config = transport._inbox(record) + provider.calls.clear() + result = reply_lark_event_inbox(project=transport.runtime_root, config_path=config, + message_id=event["message_id"], text="Original source result", execute=True, + runner=transport._reply_runner, source_authority_verifier=lambda *_: True) + assert not result["ok"] and not result["sender_identity_verified"] + assert not provider.calls and not provider.writes From b548e95d3783601850731d2d487e9ace29c20796 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:54:04 +0800 Subject: [PATCH 2/2] fix(lark): preserve source refusal diagnostics during reply verification Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/extensions/lark/inbox_reply.py | 18 +++++++++++------- loopx/extensions/lark/private_conversations.py | 13 +++++++------ tests/test_lark_source_authority_reply.py | 6 +++++- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/loopx/extensions/lark/inbox_reply.py b/loopx/extensions/lark/inbox_reply.py index 05b3ddd333..3f9d74082b 100644 --- a/loopx/extensions/lark/inbox_reply.py +++ b/loopx/extensions/lark/inbox_reply.py @@ -33,10 +33,10 @@ ) CommandRunner = Callable[[Sequence[str]], Mapping[str, Any]] -# Internal transport callback: verify the current Core App/grant and exact -# profile/chat/source, returning that fresh App's display name only on success. -# This is not a caller-supplied boolean or a reusable identity observation. -SourceAuthorityVerifier = Callable[[str, str, str], str | None] +# Internal transport callback: return the freshly Core-verified App name and +# exact source verdict separately. A changed source is not an identity mismatch. +# Neither observation is reused by a later operation. +SourceAuthorityVerifier = Callable[[str, str, str], tuple[str | None, bool]] BOT_IDENTITY_VERIFY_ATTEMPTS = 3 @@ -240,9 +240,12 @@ def _verified_sender_and_source( if source_authority_verifier is not None: if source_membership_verifier is not None or not message_id: raise ValueError("one source-bound verification owner is required") - name = source_authority_verifier(profile, chat_id, message_id) + observation = source_authority_verifier(profile, chat_id, message_id) + if not isinstance(observation, tuple) or len(observation) != 2: + return False, False + name, source_verified = observation verified = isinstance(name, str) and bool(name) - return verified and name == expected_name, verified + return verified and name == expected_name, source_verified is True identity = _bot_identity_verified(runner=runner, base=base, expected_name=expected_name) if not identity: return False, False @@ -787,7 +790,8 @@ def reply_lark_event_inbox( Native Core callers may supply ``source_authority_verifier`` instead of the legacy membership callback. It must freshly verify the exact supplied - profile/chat/message and Core grants, returning the observed Bot name. + profile/chat/message and Core grants, returning the observed Bot name and + source verification result separately. The sender consumes that result once; retries invoke the callback again. Ordinary/legacy callers retain independent identity and membership checks. """ diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index 76d9b0a691..5a3a93977b 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -190,6 +190,8 @@ def rendered() -> Mapping[str, Any]: # Resolve has just verified this App for this invocation. Reuse # that observation for mention checks, never across verifications. observation = selected.get("observation") + if sender_identity is not None and observation is not None: + sender_identity["bot_display_name"] = str(observation.get("bot_display_name") or "") stop_root: Mapping[str, Any] | None = None completed_stop = False if group: @@ -315,7 +317,6 @@ def sources() -> tuple[Mapping[str, Any] | None, Mapping[str, Any] | None]: if sender_identity is not None: if observation is None: return None - sender_identity["bot_display_name"] = str(observation.get("bot_display_name") or "") return message except (KeyError, ValueError, OSError): pass @@ -324,15 +325,15 @@ def sources() -> tuple[Mapping[str, Any] | None, Mapping[str, Any] | None]: def _source_verified(self, record: dict[str, Any], *, sender_identity: dict[str, str] | None = None) -> bool: return self._source_message(record, render_content=False, sender_identity=sender_identity) is not None - def _source_authority(self, record: dict[str, Any], profile: str, chat_id: str, message_id: str) -> str | None: + def _source_authority(self, record: dict[str, Any], profile: str, chat_id: str, + message_id: str) -> tuple[str | None, bool]: """Verify one exact outbound operation; retain no identity or grant.""" if (profile != record["profile"] or chat_id != record["event"]["chat_id"] or message_id != record["event"]["message_id"]): - return None + return None, False identity: dict[str, str] = {} - if not self._source_verified(record, sender_identity=identity): - return None - return identity.get("bot_display_name") + verified = self._source_verified(record, sender_identity=identity) + return identity.get("bot_display_name"), verified @staticmethod def _group_text_command_input(message: Mapping[str, Any], observation: Mapping[str, Any]) -> str | None: diff --git a/tests/test_lark_source_authority_reply.py b/tests/test_lark_source_authority_reply.py index c6d7efd0cc..a2e86870d1 100644 --- a/tests/test_lark_source_authority_reply.py +++ b/tests/test_lark_source_authority_reply.py @@ -88,6 +88,10 @@ def test_current_source_denial_never_becomes_a_send_or_receipt_intent(completed_ assert not _deliver(transport, record) assert not provider.writes and not provider.reaction_creates assert not record["deliveries"]["admission"].get("started") + if change in {"root", "sender", "chat", "parent", "thread"}: + # A verified App with a changed source is not an App-name mismatch. + # Keeping that distinction also avoids an unrelated metadata refresh. + assert record["deliveries"]["admission"]["blocker"] == "lark_inbox_reply_sender_not_in_configured_chat" def test_ordinary_source_read_must_join_before_late_revocation_can_authorize_a_send(ordinary, monkeypatch): # noqa: F811 @@ -131,7 +135,7 @@ def test_a_foreign_inbox_route_cannot_borrow_the_original_source_proof(completed args = ["notes-app", event["chat_id"], event["message_id"]] args[["profile", "chat", "message"].index(field)] = "foreign" provider.calls.clear() - assert transport._source_authority(record, *args) is None + assert transport._source_authority(record, *args) == (None, False) assert not provider.calls and not provider.writes