Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<name>' 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

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions docs/adr/0001-scope-isolation-one-app-vs-per-tier.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 14 additions & 4 deletions plugin/skills/auth-login/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
97 changes: 95 additions & 2 deletions plugin/src/msgraph/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions plugin/src/msgraph/verbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."
Expand Down
Loading
Loading