feat(msteams): opt-in canonical /api/messages Activity ingress (triton#247) - #248
Merged
Conversation
…n#247) Generalize the Teams adapter into triton's single, host-agnostic Microsoft Activity ingress (the "unify" decision). An Azure Bot's canonical messaging endpoint is POST /api/messages — the path Teams, M365 Copilot Chat, WebChat and Copilot Studio channels all use. handle_webhook is already transport- generic (reads the Authorization header + raw body, not the route name), so serving it there reaches every Activity channel, not just /msteams/webhook. - Opt-in via `inbound.canonical_path` (default off), single-claimant: the path is fixed and axum's Router::merge panics on an overlap, so triton-bin refuses a second claimant with a named error (exit 2) instead of crashing the process — and REST/MCP/A2A with it — at startup. - Auth is unchanged: the canonical route reuses handle_webhook, so Bot Framework JWT verification runs before the body is parsed (fail-closed). Adaptive Card version stays 1.4: the transpiler emits no property above AC 1.3, and a host capped below the declared version renders fallbackText instead of the card — a downgrade on exactly the non-Teams surfaces this ingress serves. Bump it only when a 1.5-only feature is actually emitted. Scope reshaped by the Phase-0 spike + crew review: the issue's expectReplies / dev-None-auth paths target the A2A-orchestrator/skill model the spike found flattens cards, not the Azure-Bot channel path that actually carries them — so they are cut, not built as dead code. Tests: canonical path dispatches; forged JWT on /api/messages → 401 (auth enforced); /api/messages absent (404) unless opted in while /msteams/webhook still works. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 tasks
jrosskopf
added a commit
that referenced
this pull request
Sep 5, 2026
…#261) A public inbound path answers an unauthenticated probe with 401 and audits it, all before any rate-limit token is consumed — the bucket is deliberately taken only after auth so a sprayer can't burn it. A background scanner therefore wrote one audit line per probe, and the 1024-entry ring buffer evicted every real entry within minutes: the history an operator tails at /v1/audit was gone exactly when they needed it. Pre-existing on /msteams/webhook; #248's canonical /api/messages made the heavily-scanned path available. Anonymous rejections now coalesce into one line per window per protocol, carrying the count of what it stands for. Fixed in `Dispatcher::record_rejection` — the ADR-6 audit pivot — so all seven chat adapters and the HTTP trio inherit it with no adapter change. Three properties the design turns on: * **Suppress the line, never the counter.** `metrics.record_dispatch` / `record_audit` stay unconditional, ahead of the window, so the exact rejection count survives at full per-tool granularity. Coalescing costs per-request repetition in the log and the buffer, nothing else. * **The first rejection still emits immediately, with its reason.** #219 exists because refusals were undiagnosable; a window that emitted at its END would reintroduce that for every operator staring at a 401. The count rides out on the next emission instead. Accepted trade: a flood that stops mid-window leaves its tail unreported in the log — the metric still has it. * **The key is `protocol`, not `(protocol, tool)`.** The obvious finer key is unbounded: the REST adapter audits a pre-auth rejection under `Path(name)` (the URL segment the caller picks) and MCP under a name off the JSON-RPC body, so keying on it lets `/v1/tools/<random>` mint one window per probe — a memory-growth DoS introduced by the very fix meant to blunt the scanner. `protocol` is a closed set. An IDENTIFIED subject being refused is never coalesced: that is the security signal, and a scanner sharing its protocol must not mask it. The `subject == "-"` convention `record_rejection` already documents is what separates the two. `suppressed` is `Option<u64>`, skipped when absent, so a line that swallowed nothing stays byte-identical for existing audit consumers; a coalesced one also carries `status_detail: rejections_coalesced` since at protocol granularity it names one tool but may span several routes. Window from `TRITON_AUDIT_REJECT_WINDOW_SECS` (default 60, `0` disables), wired in triton-bin so triton-core stays env-free; a junk value falls back to the default rather than failing boot. Tests, red first: seven no-mock integration tests against the spawned binary over real TCP — coalescing, the unchanged 401 on every probe, the /v1/audit tail, distinct attacker-chosen paths NOT multiplying lines, identified rejections never coalesced (both halves on one protocol), metrics staying lossless, the window reopening, and the msteams webhook inheriting it all through the pivot. Plus four unit tests for the window. The two load-bearing tests were verified to bite: keying on (protocol, tool) reddens only the distinct-paths test; coalescing identified rejections reddens only the identified test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes part of #247 (Activity Protocol adapter — the Microsoft Copilot/Teams ingress).
What
Generalizes
triton-chat-msteamsinto triton's single, host-agnostic Microsoft Activity ingress (the "unify" decision from #247): the adapter now optionally serves the canonical Bot Framework pathPOST /api/messagesin addition to the legacy/{name}/webhook. That is the endpoint an Azure Bot points at, and the path Teams, M365 Copilot Chat, WebChat and Copilot Studio channels all POST to.handle_webhookwas already transport-generic (reads theAuthorizationheader + raw body, not the route name), so one route reaches every Activity channel.Design (post crew review)
inbound.canonical_path(default off). The path is fixed andaxum::Router::mergepanics on an overlapping route, so a second claimant would crash the process — and its REST/MCP/A2A listeners — at startup.triton-binnow refuses a second claimant with a named error (exit 2), matching the existing NFR-S-4 guards in the same function.handle_webhook, so Bot Framework JWT verification (RS256, pinned kid,aud, serviceUrl allowlist) runs before the body is parsed. Forged bearers → 401, no dispatch.input_widgetuses onlylabel/isRequired), and a host capped below the declared version rendersfallbackTextinstead of the card — a downgrade on exactly the non-Teams surfaces this ingress serves. Bump only when a 1.5-only feature is actually emitted. (An earlier 1.4→1.5 +fallbackTextchange was reverted after the crew review flagged the downgrade risk and a notification-preview leak.)Scope reshaped by the spike
The Phase-0 spike found Copilot Studio's external-connect path (A2A JSON-RPC) flattens Adaptive Cards to text; the rich-card path is Activity Protocol over an Azure Bot channel → Teams (which this adapter already does). So the issue's
expectReplies/ dev-None-auth items — which target the A2A-orchestrator/skill model — are cut, not built as dead code.Tests
canonical_api_messages_path_dispatches— opted-in/api/messagesdispatches identically to the legacy route.api_messages_enforces_auth— forged JWT on/api/messages→ 401, no outbound reply.api_messages_absent_unless_opted_in—/api/messages→ 404 by default;/msteams/webhookstill works.Full suite green: 33 msteams lib tests + 29 msteams integration tests; clippy clean on
triton-chat-msteams+triton-bin.Follow-ups (not in this PR)
datazoo-agent-templateopts in (TRITON_MSTEAMS_CANONICAL_PATH, default on) — lands with the vendor bump./msteams/webhook; filing separately.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.