Skip to content

Make teams listen safe behind a relay - #119

Merged
sionsmith merged 7 commits into
osodevops:mainfrom
aberoham:up/webhooks
Oct 26, 2026
Merged

sionsmith merged 7 commits into
osodevops:mainfrom
aberoham:up/webhooks

Conversation

@aberoham

Copy link
Copy Markdown
Contributor

teams listen printed every notification posted to it without checking clientState, listened on every interface, had no size or time limits, and held Graph's response until stdout took the line. This makes it safe to run behind a tunnel or relay.

Behaviour changes:

  • The default bind is now 127.0.0.1. Use --bind 0.0.0.0 to accept other hosts.
  • --client-state (or TEAMS_CLI_CLIENT_STATE) drops notifications that don't match. Without it, startup warns that notifications are unauthenticated.
  • Notifications get 202 once queued, not 200 after printing. A full queue, or shutdown, returns 503.
  • Bodies over 1 MiB get 413. Headers and bodies have 10 s each; at most 64 connections are served.
  • Output lines keep every field Graph sends, add kind (change or lifecycle), and no longer include clientState.
  • Ctrl+C writes out everything already acknowledged; a second signal abandons it, and lost lines exit 1.
  • A port in use is CONFIG_ERROR (exit 10), not AUTH_FAILED (exit 3).
  • Logs are coloured only when stderr is a terminal and NO_COLOR is unset or empty.
  • subscribe create --lifecycle-url sets lifecycleNotificationUrl, which Graph requires for Teams subscriptions over an hour.

Review guide:

  • cbbf73a --lifecycle-url.
  • c95b22c the listener. The parts worth most attention are the filtering and response codes in src/listen/handler.rs.
  • decc906 shutdown. Check the acknowledgement accounting in src/listen/queue.rs.
  • 011fa39 log colour.

Testing: tests/listen.rs runs the real binary over sockets, including a stdout reader that stops reading (503, validation still answers) and SIGINT with a backlog. Also run locally against curl.

https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg

@sionsmith sionsmith left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Persist accepted notifications before returning 202

src/listen/handler.rs returns 202 after Queue::try_push, but src/listen/queue.rs stores the only pending copy in an in-memory channel. This adds a crash-loss window for notifications the sender considers delivered. Graceful shutdown accounting cannot recover from SIGKILL, a process crash, or a host restart.

Reproduced on PR head 011fa39 using the real binary: start listen --port 0 --client-state review-secret with stdout piped and deliberately unread; POST six valid notification collections, each with a 512 KiB resourceData field. All six requests return 202. Kill the process with SIGKILL and drain stdout: zero complete NDJSON lines (only a partial first line), leaving all six acknowledged notifications unavailable. Restart has no spool to recover.

Perplexity cross-check and the primary Microsoft documentation confirm that Graph treats a 2xx response as delivered, and recommends validating and persisting notifications before returning 202. A relay following this PR's response table also stops retrying after 202.

Please retain accepted events durably before acknowledgement and recover/replay pending events after restart. Test a blocked stdout consumer followed by forced process death and restart. Duplicate delivery can still occur around the output/checkpoint boundary; document that separately. If the intended product is instead a lossy development stream, make the durability limitation an explicit design decision and revise the relay-safety claims before merging.

Source: https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks#http-codes-and-retry-logic

The existing formatting, check, Clippy and test suites pass locally and in Linux/macOS/Windows CI; those successful checks do not cover this crash case. Leaving this PR unmerged on this finding.

aberoham and others added 7 commits October 26, 2026 22:41
Graph rejects Teams subscriptions longer than one hour unless the
create request carries lifecycleNotificationUrl, and subscribe create
had no way to set it, so any --expiration past one hour failed.

Add --lifecycle-url, sent as lifecycleNotificationUrl and omitted when
not given. The Subscription model now reads the field back so create
output shows it. No client-side expiration limit: Graph's 4xx body
already reaches the user verbatim through ApiError.

Tests: serialization with and without the field, and a help test for
the flag. Both failed before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
teams listen bound 0.0.0.0 and printed any POSTed JSON, so anyone who
could reach the port could inject notifications. It had no body size
or read deadline, unlimited connections, and wrote to stdout before
answering, so a slow reader stalled Graph's request. Output dropped
fields Graph sends (lifecycleEvent, subscriptionExpirationDateTime)
but printed clientState. A port in use exited 3 as an auth error.

- Bind 127.0.0.1 by default; --bind chooses another address, and
  --port 0 picks a free one.
- --client-state / TEAMS_CLI_CLIENT_STATE drops notifications with
  another or no clientState (SHA-256 then constant-time compare), with
  a warning that names neither value. Dropped items still get 202:
  an error only makes Graph resend them. Without it, warn at startup.
- 1 MiB body cap (413), 10s header and body deadlines (408), at most
  64 connections; more wait in the backlog.
- Output goes through an 8 MiB queue to a writer thread. A batch is
  queued whole and answered 202, or 503 when full. Validation never
  touches the queue. Ctrl+C waits up to 2s for queued output.
- Keep unknown fields via serde flatten, add kind (lifecycle/change),
  strip clientState.
- Bind failure is ConfigError (exit 10) naming the address.
- README "Running behind a relay", man page, changelog.

Tests: unit tests for the handler, queue and model; tests/listen.rs
runs the binary on 127.0.0.1:0 over real TCP: forged/missing
clientState not printed, lifecycle kinds, 5 MiB body 413 then alive,
stalled connections not blocking requests, a stalled stdout giving
503 while validation still works, port in use exiting 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
A notification answered 202 could still be waiting in the stdout
queue. On Ctrl+C the listener waited 2s, ignored whether the queue had
drained, and exited 0, so with a slow reader acknowledged notifications
were lost silently; handlers could also keep enqueueing meanwhile. A
closed stdout exited 0 from the writer thread, losing the rest too.

On Ctrl+C or SIGTERM the listener now stops accepting connections and
closes queue admission under the queue's lock, so handlers past
admission finish and later ones answer 503. It then waits for the
writer with no time limit. A second signal abandons the queue. The
writer counts lines written; if any acknowledged line is not written
(second signal, or a stdout write error, which also closes admission),
the count goes to stderr and the process exits 1 directly. Exit 1
(general) because no documented category fits a local output failure,
and the JSON error envelope would go to the stdout that failed.

Tests: queue unit tests for closed admission, full drain, aborted
drain and stdout failure counts; a handler test for 503 while shutting
down. Unix-only binary tests: SIGINT with a stalled reader, then
reading prints every acknowledged notification and exits 0; SIGINT
then SIGTERM exits 1 with the lost count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
Logging coloured stderr unless --no-color was given, so a redirected
log, `teams listen 2>listen.log` above all, filled with ANSI escape
codes. Colour now also requires stderr to be a terminal and NO_COLOR to
be unset or empty (no-color.org); --no-color still turns it off.

Tests: the rule as a unit test; the listen test that reads a dropped
notification's warning from piped stderr now finds no escape codes,
which fails against the previous setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
Graph validates lifecycleNotificationUrl as well as notificationUrl when
a subscription is created, and the README suggests
https://your-endpoint/lifecycle. The listener answered that path 404, so
following the README failed subscription creation. Answer validation and
accept notifications on /lifecycle like / and /webhook, with a unit and
a black-box test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
client_state_filters_forged_notifications failed three times locally,
under heavy concurrent load, with ENOTCONN on the first write after a
successful connect. About 650 stress runs and 300 instant-POST startups
did not reproduce it, so the panic now names the port and carries the
listener's stderr, so a recurrence shows what the listener was doing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sionsmith

Copy link
Copy Markdown
Contributor

Thanks @aberoham, this is thorough work. Here's what I verified and what I added.

Reproduced on main by running the real binary against curl:

  • It listened on *:port.
  • It printed a forged notification, plus the real clientState secret.
  • It read a 2 MiB body to the end before answering 400.
  • A port in use gave AUTH_FAILED (exit 3).
  • Redirected stderr carried ANSI codes.
  • subscriptionExpirationDateTime was dropped.

Verified on this branch. All of those are fixed:

  • It binds 127.0.0.1.
  • The forged post gets 202 but is dropped, with a warning that contains neither secret.
  • The genuine line keeps every Graph field, adds kind, and omits clientState.
  • 2 MiB gets 413, and the port in use gives CONFIG_ERROR (exit 10).

The response codes match Microsoft's webhook guidance:

  • 202 for accepted notifications, within 3 s.
  • Validation echoed as text/plain within 10 s.
  • A mismatched clientState is discarded but still answered 2xx so Graph doesn't retry it.
  • lifecycleNotificationUrl is required for Teams subscriptions over an hour.

Found and fixed (bd78cbe). The README suggested --lifecycle-url https://your-endpoint/lifecycle, but the listener answered /lifecycle with 404. Graph validates the lifecycle URL as well as the notification URL when a subscription is created (Learn: lifecycle events), so following the README would have failed subscribe create. /lifecycle is now served like / and /webhook, with a unit test and a black-box test. The new unit test fails without the route. I updated the README, man page (wrapped for mandoc -Tlint), CLAUDE.md and CHANGELOG to match.

Rebased onto main (after #124, #125, #128 and #129), merging the CHANGELOG sections.

Test flake, not reproduced. client_state_filters_forged_notifications failed 3 times locally, only under heavy concurrent cargo builds, with ENOTCONN on the first write right after a successful connect(). About 650 further runs of the listen test binary (up to 4 in parallel with CPU burners) and 300 instant-POST startups did not reproduce it, and CI has never hit it. Rather than add a retry that could hide a real problem, 770b375 makes that helper's panic name the port and include the listener's stderr, so a recurrence will show what the listener was doing.

fmt, clippy -D warnings, all tests, mandoc and CI on all three OSes are green.

@sionsmith
sionsmith merged commit 2bc3204 into osodevops:main Oct 26, 2026
6 checks passed
sionsmith added a commit to balcsida/ms-teams-cli that referenced this pull request Oct 26, 2026
…ommands

Bring the skills up to date with osodevops#119 and osodevops#125:
- automation: listen binds 127.0.0.1, takes the subscription's
  clientState through TEAMS_CLI_CLIENT_STATE, and answers /lifecycle;
  subscribe create gets --lifecycle-url. The lifetimes now match
  Graph's table (Teams messages and chats up to three days, presence one
  hour, 45 minutes minimum, lifecycle URL over an hour), replacing "at
  most one hour", and say what to do on each lifecycle event.
- setup: --redirect-uri for a bring-your-own app's browser login, what
  is saved to the profile, and what AADSTS50011, a "Sign-in failed"
  error and the 10-minute wait mean.

agent_skill_commands_parse runs every teams line in the skills' code
blocks through Cli::try_parse_from, so a renamed flag or a missing
argument fails the build instead of teaching agents a broken command.
AGENTS.md lists skills/ and adds it to the keep-in-sync rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sionsmith added a commit that referenced this pull request Oct 26, 2026
* feat(skills): add agent skills for setup, messaging, admin and automation

Four installable SKILL.md files under skills/, following the osodevops
skills convention (one focused skill per workflow surface, each with
agents/openai.yaml):

- teams-cli-setup: install, delegated sign-in, profiles, CI auth, the
  JSON envelope, exit codes and pagination
- teams-cli-messaging: find ids, read, send, reply, react, delete and
  restore, attachments and inline images, channel files, chats
- teams-cli-admin: teams, channels, members, tags, apps and tabs, with
  a confirmation procedure for destructive commands
- teams-cli-automation: presence, meetings, activity notifications,
  change-notification subscriptions, the local listener, and polling

Every command line in the skills was checked against the binary's
--help output. README gets an "Agent Skills" section with the
npx skills add install command; the docs index and CHANGELOG link to
the skills, and the attachments spec names the real skill file.

* feat(skills): cover listen hardening and --redirect-uri; test skill commands

Bring the skills up to date with #119 and #125:
- automation: listen binds 127.0.0.1, takes the subscription's
  clientState through TEAMS_CLI_CLIENT_STATE, and answers /lifecycle;
  subscribe create gets --lifecycle-url. The lifetimes now match
  Graph's table (Teams messages and chats up to three days, presence one
  hour, 45 minutes minimum, lifecycle URL over an hour), replacing "at
  most one hour", and say what to do on each lifecycle event.
- setup: --redirect-uri for a bring-your-own app's browser login, what
  is saved to the profile, and what AADSTS50011, a "Sign-in failed"
  error and the 10-minute wait mean.

agent_skill_commands_parse runs every teams line in the skills' code
blocks through Cli::try_parse_from, so a renamed flag or a missing
argument fails the build instead of teaching agents a broken command.
AGENTS.md lists skills/ and adds it to the keep-in-sync rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Sion Smith <sion.smith@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@sionsmith sionsmith mentioned this pull request Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants