Repository navigation
fix-forward #3772 (tsk-lwkv4t): guest agent chat reply can post into any channel; same-name agents share one session slug #3940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| ### Fixed | ||
|
|
||
| - Agent chat session reply endpoint now enforces channel membership: a registry agent can only reply in channels where its slug is listed as a member. Requests targeting a foreign channel (via `channel_id` or `trace_id`) return 403 `{"error": "not a member of this channel"}`. | ||
| - Registry agent slug collision prevention: when two active registry agents have canonical IDs that strip to the same slug, only the first-registered agent may use the session; subsequent agents receive 403 `{"error": "handle collides with another registry agent"}`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,29 @@ async def _resolve_slug(request: Request) -> str | JSONResponse: | |
| return JSONResponse( | ||
| {"error": "handle collides with a local agent"}, status_code=403 | ||
| ) | ||
| # Two registry agents whose canonical_ids strip to the same slug must not | ||
| # share a session. Check for any OTHER active registry entry with the same | ||
| # derived slug. Allow the oldest (first registered) agent to proceed; reject | ||
| # any newer agents that collide. | ||
| registry = getattr(request.app.state, "agent_registry", None) | ||
| if registry is not None: | ||
| try: | ||
| active_agents = await registry.list_all(status="active") | ||
| except Exception: | ||
| active_agents = [] | ||
| # list_all returns agents ordered by id (oldest first). Find the first | ||
| # agent with this slug; if it's not the current agent, reject. | ||
| for other in active_agents: | ||
| other_cid = other.get("canonical_id") | ||
| if other_cid: | ||
| other_slug = _CANONICAL_AGENT_ID_RE.sub("", other_cid) | ||
| if other_slug == slug: | ||
| if other_cid != cid: | ||
| return JSONResponse( | ||
| {"error": "handle collides with another registry agent"}, status_code=403 | ||
| ) | ||
| # This is the first agent with this slug; allow it. | ||
| break | ||
| return slug | ||
|
|
||
|
|
||
|
|
@@ -126,6 +149,30 @@ async def event_generator(): | |
| ) | ||
|
|
||
|
|
||
| async def _resolve_target_channel(request: Request, body: dict, slug: str) -> str | None: | ||
| """Determine the target channel for a reply, mirroring _handle_reply logic. | ||
|
|
||
| Returns the channel_id if one can be determined, else None (falls back to | ||
| the agent's own DM in the bridge). | ||
| """ | ||
| # Explicit channel_id in body takes precedence. | ||
| channel_id = body.get("channel_id") | ||
| if channel_id: | ||
| return channel_id | ||
| # Fall back to the channel of the message named by trace_id. | ||
| trace_id = body.get("trace_id") | ||
| if trace_id: | ||
| chat_messages = getattr(request.app.state, "chat_messages", None) | ||
| if chat_messages is not None: | ||
| try: | ||
| orig = await chat_messages.get_message(trace_id) | ||
| except Exception: | ||
| orig = None | ||
| if orig and orig.get("channel_id"): | ||
| return orig["channel_id"] | ||
| return None | ||
|
|
||
|
|
||
| @router.post("/api/agents/self/chat/reply") | ||
| async def self_chat_reply(request: Request): | ||
| """Record a reply from the calling agent under its own slug. | ||
|
|
@@ -144,5 +191,19 @@ async def self_chat_reply(request: Request): | |
| return JSONResponse({"error": "invalid JSON"}, status_code=400) | ||
| if not isinstance(body, dict): | ||
| return JSONResponse({"error": "invalid JSON"}, status_code=400) | ||
|
|
||
| # Fix 1: enforce channel membership before accepting the reply. | ||
| target_channel_id = await _resolve_target_channel(request, body, slug) | ||
| if target_channel_id: | ||
| chat_channels = getattr(request.app.state, "chat_channels", None) | ||
| if chat_channels is not None: | ||
| channel = await chat_channels.get_channel(target_channel_id) | ||
| if channel: | ||
| members = channel.get("members") or [] | ||
| if slug not in members: | ||
|
Comment on lines
+200
to
+203
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject an unknown target channel before recording the reply. When 🤖 Prompt for AI Agents
|
||
| return JSONResponse( | ||
| {"error": "not a member of this channel"}, status_code=403 | ||
| ) | ||
|
|
||
| await bridge.record_reply(slug, body) | ||
| return JSONResponse({"accepted": True}, status_code=202) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 7633
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 22193
Fail closed when the registry lookup raises.
When
registry.list_all(status="active")raises, theexcept Exceptionbranch setsactive_agents = []. The collision loop then does not run, and_resolve_slugreturns the slug. A later agent whose canonical_id strips to the same slug as an active agent is admitted. The collision check exists to reject that case, so this fallback removes the protection it was added to provide.The admitted agent can take over the first agent's session.
BridgeSessionRegistry.subscribe(slug)calls_get_or_create(slug), pushes_DISCONNECTinto the current subscriber's queue, and replacessession.queuewith a new queue. Later calls toenqueue_user_message(slug, ...)then write to the new queue. The first agent's stream ends, and user messages for that slug can reach the later agent.The takeover needs an active subscription on the first agent. Without one, the later agent receives a new empty session and no earlier stream is affected.
🛡️ Suggested fix
📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.16.10)
[warning] 50-50: Do not catch blind exception:
Exception(BLE001)
🤖 Prompt for AI Agents