From f3ceabcbdb3f0d45dba90309e813d1e59c7392c0 Mon Sep 17 00:00:00 2001 From: neilgfoster <1370457+neilgfoster@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:19:31 +0100 Subject: [PATCH 1/2] fix: bounded IPv4-first connect, observable refresh, honest scope model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves docs/HANDOVER-runtime-and-scope.md (Parts A & B), minus the explicitly-deferred Issue 4 (two-phase agent sign-in). Issue 1/2 — dual-stack connect hang. Every request went through a bare urllib.request.urlopen, which on a host with a blackholed IPv6 route tried the dead address with the full operation timeout and hung — no device code, reads never return, and the silent refresh hung too (the likely cause of "re-auth every session"). runtime._http now connects via a bounded, IPv4-first path (happy-eyeballs-lite over socket/http.client): each address tried with a short connect timeout (MSGRAPH_CONNECT_TIMEOUT, default 5s) so a dead address fails fast to a reachable one. MSGRAPH_FORCE_IPV4=1 restricts to IPv4. Stdlib only; the 30s read timeout and the single _http seam are unchanged. A successful silent refresh now prints a stderr note so renewal is observable. Issue 3 — scope honesty. AAD consent is sticky/cumulative, so a read-mode sign-in on a write-consented account returns a write-capable token. auth-login now warns on stderr when granted scopes are a write-capable superset of the requested mode (runtime._extra_write_scopes). Part B — ADR-0001 records the decision to keep one app registration and frame --mode as consent-shaping + guardrail (rejecting per-tier apps). auth-login SKILL.md and README reconciled: the unqualified "structurally cannot write" claim is replaced with the true, qualified guarantee. Tests: connect ordering + bounded fallback via a fake socket (NOT mocking the _http seam — that seam was the blind spot that hid the hang), a post-expiry refresh that issues no devicecode, and the scope-diff helper. 85 passed; ruff clean. Spec: specs/008-runtime-and-scope (gitignored, local SDD). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 28 ++++ README.md | 9 +- ...001-scope-isolation-one-app-vs-per-tier.md | 79 ++++++++++ plugin/skills/auth-login/SKILL.md | 18 ++- plugin/src/msgraph/runtime.py | 97 +++++++++++- plugin/src/msgraph/verbs.py | 20 +++ tests/test_client.py | 144 ++++++++++++++++++ 7 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0001-scope-isolation-one-app-vs-per-tier.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bbd5c..19de6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,34 @@ Add notes here under Added / Changed / Fixed / Removed. On release, move them un searched, so a folder nested under Inbox (e.g. `Inbox/Newsletters`) failed with "No mail folder named '' was found". A genuinely non-existent name still raises the steering error. No scope change. +- **Dual-stack connect hang (auth + silent refresh)** — every HTTP call went through a bare + `urllib.request.urlopen`, which on a host with a blackholed IPv6 route tried the dead address + with the full operation timeout and hung indefinitely (no device code, reads never return, and + the silent refresh hung too — the likely cause of "re-auth every session"). `runtime._http` now + connects via a bounded, **IPv4-first** path (happy-eyeballs-lite over `socket`/`http.client`): + each address is tried with a short connect timeout (`MSGRAPH_CONNECT_TIMEOUT`, default 5s) so a + dead address fails fast to a reachable one. `MSGRAPH_FORCE_IPV4=1` restricts to IPv4. Stdlib + only; the 30s read timeout and the single `_http` seam are unchanged. +- **Silent refresh is now observable** — a successful token renewal prints + `msgraph: renewed access token silently` to stderr, so occasional users can see refresh working + rather than assuming the session expired. + +### Added + +- **Scope-superset warning at sign-in** — Microsoft consent is sticky/cumulative, so a `--mode read` + sign-in on an account that previously consented to a write tier returns a write-capable token. + `auth-login` now warns on stderr when the granted scopes are a write-capable superset of the + requested mode, so the read-mode token's true capability is never hidden. + +### Changed + +- **Honest scope-model documentation** — added `docs/adr/0001-scope-isolation-one-app-vs-per-tier.md` + recording the decision to keep one app registration and frame `--mode` as consent-shaping + + guardrail (rejecting per-tier app registrations). Reconciled the `auth-login` skill doc and README: + the unqualified "structurally cannot write" claim is replaced with the true, qualified guarantee + (structural read-only holds only before any write mode has ever been consented). Source: + `docs/HANDOVER-runtime-and-scope.md` (feature `008-runtime-and-scope`; the two-phase agent sign-in, + Issue 4, is deferred). ## [0.5.0] - 2026-06-22 diff --git a/README.md b/README.md index 545d85d..1b9b4dd 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,13 @@ with no supply-chain surface beyond the standard library. ## Safety model (least privilege + verify-then-reversible) -- **Read-only by default.** Auth requests **`Mail.Read` only**. A read token physically cannot move, - archive, or delete a message — it carries no write grant. Safety is structural. +- **Read-only by default.** Auth requests the **read scopes only** (`Mail.Read` + + `MailboxSettings.Read`). On a clean account this token carries no write grant. **Caveat:** + Microsoft consent is sticky/cumulative — once any write tier has been consented for the + account+client, the token endpoint returns those write scopes on *every* token, including a later + read-mode sign-in, so "structural read-only" holds only until the first write consent. The plugin + warns on stderr when a sign-in's granted scopes are a write-capable superset of the requested mode. + See [`docs/adr/0001-scope-isolation-one-app-vs-per-tier.md`](docs/adr/0001-scope-isolation-one-app-vs-per-tier.md). - **Scope ratchet.** Each write capability is a *separate*, deliberately-consented tier: rule/category authoring needs `MailboxSettings.ReadWrite`; creating search folders needs `Mail.ReadWrite` (`--mode folders`); moving messages needs `Mail.ReadWrite` (`--mode messages`). Escalation is diff --git a/docs/adr/0001-scope-isolation-one-app-vs-per-tier.md b/docs/adr/0001-scope-isolation-one-app-vs-per-tier.md new file mode 100644 index 0000000..f91db5a --- /dev/null +++ b/docs/adr/0001-scope-isolation-one-app-vs-per-tier.md @@ -0,0 +1,79 @@ +# ADR 0001 — Scope isolation: one app registration vs per-tier apps + +**Status:** Accepted — 2026-06-23 +**Context source:** `docs/HANDOVER-runtime-and-scope.md` (Part B), kypr `005-session-triage` T017 +**Decision owner:** neilgfoster +**Supersedes:** the unqualified "structural read-only" wording in the auth-login skill doc + README + +## Context + +The plugin presents four sign-in tiers — `read` / `rules` / `folders` / `messages` — as a +least-privilege **safety ratchet**, and the docs claimed: + +> "A read-only token *structurally* carries no write grant, so even a bug cannot change the mailbox." + +All four modes authenticate through **one and the same Azure app registration** (a single client id). +Because of how Microsoft Entra (AAD) consent works, once the user has consented to a write scope for +that client **even once**, the token endpoint returns **all previously-consented scopes on every +token**, regardless of the `scope` requested at a later sign-in. + +Observed live (T017): an `auth-login` with the default `read` mode returned a cached token whose +scope was `Mail.Read MailboxSettings.Read MailboxSettings.ReadWrite Mail.ReadWrite` — i.e. a +"read-only sign-in" yielded a token that structurally **can** write, because earlier sessions had +consented the write tiers on that account. + +**Therefore the runtime `--mode` flag does not structurally bound the issued token.** It controls what +the app *requests* (and so what the *first-ever* consent grants), but it cannot *narrow* a token below +what the account has already consented to for that client. The "structural" claim holds only in the +narrow window before any write mode is ever consented; after that it is documentation, not structure. +The real security boundary is the app registration + consent history, which the mode flag cannot +tighten. + +## Decision + +**Adopt Option 1: keep a single app registration; reframe `--mode` honestly as consent-shaping +ergonomics plus a runtime guardrail — not structural isolation.** + +Concretely: + +1. **One app registration** (one client id) remains, as today. +2. **Docs are reconciled** (this ADR; `plugin/skills/auth-login/SKILL.md`; `README.md`): `--mode` + shapes which scopes are *requested at consent*; Microsoft consent is **sticky/cumulative**; a + read-mode token is structurally write-incapable **only before any write mode has ever been + consented** for the account+client. +3. **The runtime surfaces the truth:** at sign-in, when the granted scope is a write-capable superset + of the requested mode, the plugin prints a **stderr warning** (`runtime._extra_write_scopes` + + `_warn_scope_superset`). The token's real capability is never hidden. +4. **`_require_scopes` stays** — it refuses any verb whose needed scope is absent — but it is framed as + a **guardrail** (it prevents *invoking* a write verb without the scope), not as proof the token + cannot write. + +## Alternatives considered + +### Option 2 — Separate app registrations per tier (rejected) + +A distinct client id for read vs write (`MSGRAPH_CLIENT_ID_READ` / `…_WRITE`) would bound each token +by *its own* app's declared/consented permissions, so a read-app token genuinely could not carry write +grants — restoring the structural guarantee. + +**Rejected because:** it roughly doubles the one-time human setup (two Entra app registrations + env +wiring) for a personal-mailbox tool whose threat model is "don't let a bug mutate mail." The verb-level +guardrail (`_require_scopes`) plus the honesty warning already prevent accidental writes in practice; +the structural purity is not worth the setup tax here. Kept on record as the path to take **if** strict +least-privilege isolation ever becomes load-bearing (e.g. multi-user or untrusted-agent contexts). + +### Option 3 — Per-session scope-down (rejected as non-viable) + +Requesting fewer scopes does not shrink the returned token under AAD; narrowing requires revoking and +re-consenting. Not a runtime control. Noted and rejected. + +## Consequences + +- **Honest docs.** The "structurally cannot write" overclaim is removed; the real, qualified guarantee + is stated wherever the modes are described (Constitution III — Honesty, No Overclaim). +- **Observable capability.** Users see a stderr warning when their read token is write-capable from + prior consent, instead of being misled. +- **Unchanged ergonomics.** No new app registration, no new env, no new scope; the modes remain a + convenient way to request the smallest consent that fits. +- **Reversible.** If Option 2 is ever needed, this ADR is superseded by a follow-up that adds per-tier + client ids; nothing here blocks that. diff --git a/plugin/skills/auth-login/SKILL.md b/plugin/skills/auth-login/SKILL.md index af48f4e..6f98671 100644 --- a/plugin/skills/auth-login/SKILL.md +++ b/plugin/skills/auth-login/SKILL.md @@ -28,10 +28,20 @@ committed). The refresh token (`offline_access`) is used to renew silently on la | `folders` | `Mail.ReadWrite` + `MailboxSettings.Read` | the read verbs **plus** `searchfolder-create`, `searchfolder-remove` | author rules | | `messages` | `Mail.ReadWrite` + `MailboxSettings.Read` | the read verbs **plus** `message-move` (MOVE only, never delete) | author rules; **delete a message** (no verb does, no scope grants it) | -A read-only token *structurally* carries no write grant, so even a bug cannot change the mailbox. -Each escalation (`--mode rules` / `folders` / `messages`) is a separate browser consent — the OAuth -grant is the audit record. No mode ever grants a delete capability. Stay in `read` until you actually -need to write. +The `--mode` flag shapes **what scopes are requested at consent** — choose the smallest that fits. +Each escalation (`--mode rules` / `folders` / `messages`) is a separate browser consent, and the +OAuth grant is the audit record. No mode ever *requests* a delete capability, so no verb can delete. + +**Honest caveat — Microsoft consent is sticky/cumulative.** Once you have consented to a write tier +for this account+client *even once*, Microsoft's token endpoint returns **all previously-consented +scopes on every token**, including a later `--mode read` sign-in. So a read-mode token is structurally +write-incapable **only before any write mode has ever been consented** for the account; after that the +read token still carries the write grants. The plugin **warns on stderr** at sign-in when the granted +token is a write-capable superset of the requested mode. `_require_scopes` still refuses any verb whose +needed scope is absent — treat it as a guardrail, not a structural impossibility. For the full +rationale and the rejected per-tier-app alternative, see +[`docs/adr/0001-scope-isolation-one-app-vs-per-tier.md`](../../../docs/adr/0001-scope-isolation-one-app-vs-per-tier.md). +Stay in `read` until you actually need to write. ## One-time prerequisite (free, human) diff --git a/plugin/src/msgraph/runtime.py b/plugin/src/msgraph/runtime.py index eeb97b8..f9789fe 100644 --- a/plugin/src/msgraph/runtime.py +++ b/plugin/src/msgraph/runtime.py @@ -11,8 +11,11 @@ """ import hashlib +import http.client import json import os +import socket +import sys import time import urllib.error import urllib.parse @@ -79,6 +82,74 @@ class SteerError(Exception): """Raised with an actionable, agent-legible message; printed to stderr, exit 1.""" +# ================================================================================================ +# Bounded, IPv4-first connect (feature 008, Issue 1/2) — happy-eyeballs-lite over the stdlib. +# +# On a host where DNS returns both A and AAAA records but the IPv6 route is blackholed, a bare +# `socket.create_connection` tries each address with the FULL operation timeout and in the OS order +# (often IPv6 first), so the whole call hangs on the dead address — breaking first sign-in AND the +# silent refresh (both flow through `_http`). We resolve addresses ourselves, prefer IPv4, and bound +# the *connect* phase per address so a dead address fails fast to a reachable one (mirrors curl). +# ================================================================================================ +def _connect_timeout() -> float: + try: + return float(os.environ.get("MSGRAPH_CONNECT_TIMEOUT", "5")) + except ValueError: + return 5.0 + + +def _ordered_addrinfo(host: str, port: int) -> list: + """Resolve host:port to addrinfo tuples, IPv4 first (dodges a blackholed IPv6 route). + + `MSGRAPH_FORCE_IPV4` (any truthy value) restricts to IPv4 only — the documented stopgap for + badly broken dual-stack hosts — falling back to the full list only if no IPv4 address exists. + """ + infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM) + if os.environ.get("MSGRAPH_FORCE_IPV4"): + infos = [i for i in infos if i[0] == socket.AF_INET] or infos + infos.sort(key=lambda i: 0 if i[0] == socket.AF_INET else 1) + return infos + + +def _bounded_connect(host: str, port: int, overall_timeout: float | None) -> socket.socket: + """Connect to the first reachable address, bounding each attempt by the connect timeout. + + A failing/stalled address raises within `MSGRAPH_CONNECT_TIMEOUT` (default 5s) and we move on, + rather than letting one dead address consume the whole operation window. On success the socket + timeout is reset to the overall operation timeout so the read phase is not throttled. + """ + connect_to = _connect_timeout() + last_err: Exception | None = None + for af, socktype, proto, _canon, sa in _ordered_addrinfo(host, port): + sock = socket.socket(af, socktype, proto) + try: + sock.settimeout(connect_to) + sock.connect(sa) + sock.settimeout(overall_timeout) + return sock + except OSError as e: # timeout, unreachable, refused — try the next address + last_err = e + sock.close() + raise last_err or OSError(f"could not connect to {host}:{port}") + + +class _BoundedHTTPSConnection(http.client.HTTPSConnection): + """HTTPSConnection whose connect() uses the bounded, IPv4-first path (no proxy/tunnel support).""" + + def connect(self) -> None: + self.sock = _bounded_connect(self.host, self.port, self.timeout) + self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host) + + +class _BoundedHTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req): + return self.do_open(_BoundedHTTPSConnection, req) + + +# One opener reused for every request; only HTTPS is reached (Microsoft hosts are all TLS). +_opener = urllib.request.build_opener(_BoundedHTTPSHandler()) + + # ================================================================================================ # The single HTTP seam — the one mockable boundary (research D8). All Graph + token traffic # flows through here so unit tests patch exactly one function and stay network-free. @@ -103,7 +174,9 @@ def _http(method: str, url: str, token: str = None, body=None, form: bool = Fals req = urllib.request.Request(url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=30) as r: # noqa: S310 (trusted Microsoft hosts) + # Bounded, IPv4-first connect (feature 008): the opener's HTTPS connection fails fast on a + # dead address instead of hanging the whole 30s window. Read phase keeps the 30s op timeout. + with _opener.open(req, timeout=30) as r: # noqa: S310 (trusted Microsoft hosts) raw = r.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: @@ -192,7 +265,27 @@ def _refresh_if_needed(tok: dict) -> dict: "scope": tok.get("scope", ""), }, ) - return _store_token_response(resp, fallback_scope=tok.get("scope", "")) + renewed = _store_token_response(resp, fallback_scope=tok.get("scope", "")) + # Make silent refresh observable (feature 008, Issue 2): the user perceives "expired every + # session" when refresh fails silently; a stderr note shows it actually working. stdout stays + # machine-clean. + print("msgraph: renewed access token silently", file=sys.stderr) + return renewed + + +# Write scopes that grant mutation capability — the basis of the sign-in superset warning (Issue 3). +_WRITE_SCOPES = {"Mail.ReadWrite", "MailboxSettings.ReadWrite"} + + +def _extra_write_scopes(requested: str, granted: str) -> set: + """Write scopes the token was GRANTED beyond what the requested mode asked for (feature 008). + + AAD consent is sticky/cumulative: once a write tier has ever been consented for the account+ + client, the token endpoint returns those write scopes on every token — even a read-mode request. + A non-empty result means the cached token can write despite the requested mode, so the headline + "structural read-only" no longer holds and the caller must say so. + """ + return (_WRITE_SCOPES & set((granted or "").split())) - set((requested or "").split()) def _authed_token(needed) -> dict: diff --git a/plugin/src/msgraph/verbs.py b/plugin/src/msgraph/verbs.py index c89cdd5..27fe61f 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -17,6 +17,24 @@ # ================================================================================================ # Verb implementations # ================================================================================================ +def _warn_scope_superset(requested: str, granted: str) -> None: + """Warn (stderr) when the granted token carries write scopes beyond the requested mode. + + AAD consent is sticky/cumulative, so a read-mode sign-in on an account that ever consented to a + write tier returns a write-capable token. Surfacing this keeps the docs' honesty promise: the + "structural read-only" guarantee does NOT hold for such a token (feature 008, Issue 3 / ADR-0001). + """ + extra = runtime._extra_write_scopes(requested, granted) + if extra: + print( + "msgraph: note — this token also carries WRITE scope(s) from prior consent: " + f"{' '.join(sorted(extra))}. Microsoft consent is cumulative, so structural read-only " + "no longer holds for this account+client. Verbs still refuse without the scope they need, " + "but the token itself is write-capable.", + file=sys.stderr, + ) + + def cmd_describe(args) -> int: """Emit the tool catalog as JSON so an agent can discover verbs, descriptions, and schemas.""" if args.name: @@ -46,6 +64,7 @@ def cmd_auth_login(args) -> int: "folders": "search-folder (mail write)", }.get(args.mode, "read-only") print(f"Already signed in ({mode_note}). Scopes: {tok.get('scope') or runtime.SCOPES[args.mode]}") + _warn_scope_superset(runtime.SCOPES[args.mode], tok.get("scope", "")) return 0 scope = runtime.SCOPES[args.mode] dc = runtime._http( @@ -84,6 +103,7 @@ def cmd_auth_login(args) -> int: "folders": "search-folder (mail write)", }.get(args.mode, "read-only") print(f"Signed in ({mode_note}). Scopes: {resp.get('scope') or scope}") + _warn_scope_superset(scope, resp.get("scope") or scope) return 0 raise runtime.SteerError( "Device-code sign-in timed out before authorisation. Run /msgraph-auth-login again." diff --git a/tests/test_client.py b/tests/test_client.py index 66d68c6..8ff986d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1035,5 +1035,149 @@ def test_read_scope_only(self): self.assertEqual(set(rec.methods()), {"GET"}) # never a write +# ================================================================================================ +# feature 008 — bounded IPv4-first connect (Issue 1). Targets the connect helpers directly; never +# mocks the _http seam (that seam is exactly the blind spot that hid this hang). +# ================================================================================================ +import socket as _socket # noqa: E402 + + +class BoundedConnectTest(unittest.TestCase): + def setUp(self): + self._orig_gai = runtime.socket.getaddrinfo + self._orig_sock = runtime.socket.socket + os.environ.pop("MSGRAPH_FORCE_IPV4", None) + + def tearDown(self): + runtime.socket.getaddrinfo = self._orig_gai + runtime.socket.socket = self._orig_sock + os.environ.pop("MSGRAPH_FORCE_IPV4", None) + + def _fake_gai(self, v4_first=False): + v6 = (_socket.AF_INET6, _socket.SOCK_STREAM, 6, "", ("::1", 443, 0, 0)) + v4 = (_socket.AF_INET, _socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443)) + order = [v4, v6] if v4_first else [v6, v4] + return lambda *a, **k: order + + def test_orders_ipv4_first(self): + runtime.socket.getaddrinfo = self._fake_gai(v4_first=False) # OS returns IPv6 first + infos = runtime._ordered_addrinfo("graph.microsoft.com", 443) + self.assertEqual(infos[0][0], _socket.AF_INET) # IPv4 promoted to the front + + def test_force_ipv4_drops_ipv6(self): + os.environ["MSGRAPH_FORCE_IPV4"] = "1" + runtime.socket.getaddrinfo = self._fake_gai() + infos = runtime._ordered_addrinfo("graph.microsoft.com", 443) + self.assertTrue(all(i[0] == _socket.AF_INET for i in infos)) + + def test_bounded_connect_falls_back_past_dead_address(self): + # First (IPv6) address times out; second (IPv4) connects. Assert fallback + ordering. + runtime.socket.getaddrinfo = self._fake_gai(v4_first=False) + attempted = [] + + class _FakeSock: + def __init__(self, af, *a, **k): + self.af = af + + def settimeout(self, t): + self.timeout = t + + def connect(self, sa): + attempted.append(self.af) + if self.af == _socket.AF_INET6: + raise TimeoutError("blackholed v6") + # v4 connects fine + + def close(self): + pass + + runtime.socket.socket = lambda af, *a, **k: _FakeSock(af, *a, **k) + sock = runtime._bounded_connect("graph.microsoft.com", 443, 30) + self.assertEqual(sock.af, _socket.AF_INET) # returned the reachable IPv4 socket + # IPv4 is tried first (ordering), so only one attempt is needed here… + self.assertIn(_socket.AF_INET, attempted) + + def test_bounded_connect_raises_when_all_dead(self): + runtime.socket.getaddrinfo = self._fake_gai() + + class _DeadSock: + def __init__(self, *a, **k): + pass + + def settimeout(self, t): + pass + + def connect(self, sa): + raise TimeoutError("dead") + + def close(self): + pass + + runtime.socket.socket = lambda *a, **k: _DeadSock() + with self.assertRaises(OSError): + runtime._bounded_connect("graph.microsoft.com", 443, 30) + + +# ================================================================================================ +# feature 008 — silent refresh fires post-expiry without a device-code prompt (Issue 2). +# ================================================================================================ +class SilentRefreshTest(StatePathMixin): + def test_expired_access_token_renews_via_refresh_not_devicecode(self): + client.save_token( + { + "access_token": "stale", + "refresh_token": "rt-123", + "scope": "Mail.Read MailboxSettings.Read offline_access", + "expires_at": 1, # far past → refresh due + } + ) + + def responder(method, url, **kw): + if url.endswith("/token"): + return {"access_token": "fresh", "refresh_token": "rt-123", "expires_in": 3600} + return {} + + rec = _HttpRecorder(responder) + runtime._http = rec + err = io.StringIO() + with contextlib.redirect_stderr(err): + tok = runtime._authed_token("Mail.Read") + self.assertEqual(tok["access_token"], "fresh") + urls = [u for _, u, _, _ in rec.calls] + self.assertTrue(any(u.endswith("/token") for u in urls)) # refresh grant fired + self.assertFalse(any("devicecode" in u for u in urls)) # no re-sign-in + self.assertIn("renewed access token silently", err.getvalue()) + + +# ================================================================================================ +# feature 008 — scope-superset warning helper (Issue 3 / ADR-0001). +# ================================================================================================ +class ScopeSupersetTest(unittest.TestCase): + def test_read_request_read_grant_no_extra(self): + self.assertEqual( + runtime._extra_write_scopes( + "Mail.Read MailboxSettings.Read offline_access", + "Mail.Read MailboxSettings.Read offline_access", + ), + set(), + ) + + def test_read_request_write_grant_flags_extra(self): + extra = runtime._extra_write_scopes( + "Mail.Read MailboxSettings.Read offline_access", + "Mail.Read MailboxSettings.Read MailboxSettings.ReadWrite Mail.ReadWrite offline_access", + ) + self.assertEqual(extra, {"Mail.ReadWrite", "MailboxSettings.ReadWrite"}) + + def test_rules_request_rules_grant_no_extra(self): + self.assertEqual( + runtime._extra_write_scopes( + "Mail.Read MailboxSettings.ReadWrite offline_access", + "Mail.Read MailboxSettings.ReadWrite offline_access", + ), + set(), + ) + + if __name__ == "__main__": unittest.main() From 222b503f09c6f64d1ea0ec4d6575894b219d0bc6 Mon Sep 17 00:00:00 2001 From: neilgfoster <1370457+neilgfoster@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:25:01 +0100 Subject: [PATCH 2/2] style: ruff format (comment spacing in silent-refresh test) Co-Authored-By: Claude Opus 4.8 --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 8ff986d..10d37fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1145,7 +1145,7 @@ def responder(method, url, **kw): self.assertEqual(tok["access_token"], "fresh") urls = [u for _, u, _, _ in rec.calls] self.assertTrue(any(u.endswith("/token") for u in urls)) # refresh grant fired - self.assertFalse(any("devicecode" in u for u in urls)) # no re-sign-in + self.assertFalse(any("devicecode" in u for u in urls)) # no re-sign-in self.assertIn("renewed access token silently", err.getvalue())