fix(email): enforce MCP account owner scope (#5234)

This commit is contained in:
RaresKeY
2026-07-05 14:13:56 +02:00
committed by GitHub
parent c8169ad7a9
commit 3592285db7
2 changed files with 149 additions and 16 deletions
+33 -11
View File
@@ -58,6 +58,11 @@ def _uid_fetch_rows(data) -> list:
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict _ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
_MCP_OWNER_ARG = "_odysseus_owner" _MCP_OWNER_ARG = "_odysseus_owner"
_CURRENT_OWNER: ContextVar[str | None] = ContextVar("email_mcp_owner", default=None) _CURRENT_OWNER: ContextVar[str | None] = ContextVar("email_mcp_owner", default=None)
_OWNER_ENV_KEYS = ("ODYSSEUS_MCP_EMAIL_OWNER", "ODYSSEUS_EMAIL_OWNER")
_OWNER_SCOPE_ERROR = (
"Error: email MCP requires an authenticated owner or ODYSSEUS_MCP_EMAIL_OWNER "
"when owner-scoped email accounts are configured."
)
def _clean_header_value(value) -> str: def _clean_header_value(value) -> str:
@@ -71,13 +76,29 @@ def _db_path() -> Path:
return Path(APP_DB) return Path(APP_DB)
def _configured_owner() -> str | None:
for key in _OWNER_ENV_KEYS:
owner = os.environ.get(key, "").strip()
if owner:
return owner
return None
def _current_owner() -> str: def _current_owner() -> str:
owner = _CURRENT_OWNER.get() owner = _CURRENT_OWNER.get()
return str(owner or "").strip() return str(owner or _configured_owner() or "").strip()
def _account_owner(row: dict) -> str:
return str(row.get("owner") or "").strip()
def _has_owner_scoped_accounts(rows: list[dict]) -> bool:
return any(_account_owner(r) for r in rows)
def _account_visible_to_owner(row: dict, owner: str) -> bool: def _account_visible_to_owner(row: dict, owner: str) -> bool:
row_owner = str(row.get("owner") or "").strip() row_owner = _account_owner(row)
if row_owner == owner: if row_owner == owner:
return True return True
if row_owner: if row_owner:
@@ -96,8 +117,7 @@ def _filter_accounts_for_owner(rows: list[dict]) -> list[dict]:
if owner: if owner:
return [r for r in rows if _account_visible_to_owner(r, owner)] return [r for r in rows if _account_visible_to_owner(r, owner)]
owners = {str(r.get("owner") or "").strip() for r in rows if str(r.get("owner") or "").strip()} if _has_owner_scoped_accounts(rows):
if len(owners) > 1:
return [] return []
return rows return rows
@@ -106,8 +126,7 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
if _current_owner(): if _current_owner():
return False return False
rows = rows if rows is not None else _read_accounts_from_db() rows = rows if rows is not None else _read_accounts_from_db()
owners = {str(r.get("owner") or "").strip() for r in rows if str(r.get("owner") or "").strip()} return _has_owner_scoped_accounts(rows)
return len(owners) > 1
def _load_email_writing_style() -> str: def _load_email_writing_style() -> str:
@@ -274,6 +293,8 @@ def _load_config(account: str | None = None) -> dict:
} }
raw_rows = _read_accounts_from_db() raw_rows = _read_accounts_from_db()
if _mcp_owner_required(raw_rows):
raise ValueError(_OWNER_SCOPE_ERROR)
rows = _filter_accounts_for_owner(raw_rows) rows = _filter_accounts_for_owner(raw_rows)
row = _resolve_account_from_rows(rows, account) row = _resolve_account_from_rows(rows, account)
if _current_owner() and raw_rows and not rows: if _current_owner() and raw_rows and not rows:
@@ -1193,10 +1214,14 @@ def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, b
UI. This closes the auto-send hole that let earlier models invent UI. This closes the auto-send hole that let earlier models invent
signatures and ship them to real recipients without confirmation.""" signatures and ship them to real recipients without confirmation."""
if _read_agent_email_confirm_setting(): if _read_agent_email_confirm_setting():
# Even confirmation-first sends must resolve the selected account now.
# Otherwise a caller could stage a pending draft against another
# owner's account selector before browser approval handles it.
cfg = _load_config(account)
return _stash_agent_draft( return _stash_agent_draft(
to=to, subject=subject, body=body, to=to, subject=subject, body=body,
in_reply_to=in_reply_to, references=references, in_reply_to=in_reply_to, references=references,
cc=cc, bcc=bcc, account=account, cc=cc, bcc=bcc, account=cfg.get("account_id") or account,
) )
send_account, cfg = _resolve_send_config(account) send_account, cfg = _resolve_send_config(account)
msg = EmailMessage() msg = EmailMessage()
@@ -2142,10 +2167,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
try: try:
all_db_accounts = _read_accounts_from_db() all_db_accounts = _read_accounts_from_db()
if _mcp_owner_required(all_db_accounts): if _mcp_owner_required(all_db_accounts):
return [TextContent( return [TextContent(type="text", text=_OWNER_SCOPE_ERROR)]
type="text",
text="Error: email MCP requires an authenticated owner when multiple email account owners are configured.",
)]
if name == "list_email_accounts": if name == "list_email_accounts":
rows = _filter_accounts_for_owner(all_db_accounts) rows = _filter_accounts_for_owner(all_db_accounts)
+116 -5
View File
@@ -16,7 +16,20 @@ pytest.importorskip("mcp")
import mcp_servers.email_server as es import mcp_servers.email_server as es
def _init_accounts_db(path): @pytest.fixture(autouse=True)
def _clear_mcp_email_owner_env(monkeypatch):
for key in es._OWNER_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
es._ACCOUNT_CACHE.clear()
yield
es._ACCOUNT_CACHE.clear()
def _init_accounts_db(path, rows=None):
rows = rows or [
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
("acct-bob", "bob", "Bob Mail", 1, "bob@example.com", "bob@example.com", "bob@example.com", "2026-01-02"),
]
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
conn.execute( conn.execute(
""" """
@@ -50,10 +63,7 @@ def _init_accounts_db(path):
VALUES (?, ?, ?, ?, 1, 'imap.example.com', 993, ?, '', 1, VALUES (?, ?, ?, ?, 1, 'imap.example.com', 993, ?, '', 1,
'smtp.example.com', 465, 'ssl', ?, '', ?, ?) 'smtp.example.com', 465, 'ssl', ?, '', ?, ?)
""", """,
[ rows,
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
("acct-bob", "bob", "Bob Mail", 1, "bob@example.com", "bob@example.com", "bob@example.com", "2026-01-02"),
],
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -106,6 +116,37 @@ async def test_mcp_email_requires_owner_when_multiple_account_owners_exist(tmp_p
assert "requires an authenticated owner" in out[0].text assert "requires an authenticated owner" in out[0].text
@pytest.mark.asyncio
async def test_mcp_email_requires_owner_when_any_account_owner_exists(tmp_path, monkeypatch):
db_path = tmp_path / "app.db"
_init_accounts_db(
db_path,
rows=[
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
],
)
monkeypatch.setattr(es, "APP_DB", str(db_path))
out = await es.call_tool("list_email_accounts", {})
assert "requires an authenticated owner" in out[0].text
assert "Alice Mail" not in out[0].text
@pytest.mark.asyncio
async def test_mcp_email_configured_owner_filters_accounts(tmp_path, monkeypatch):
db_path = tmp_path / "app.db"
_init_accounts_db(db_path)
monkeypatch.setattr(es, "APP_DB", str(db_path))
monkeypatch.setenv("ODYSSEUS_MCP_EMAIL_OWNER", "alice")
out = await es.call_tool("list_email_accounts", {})
text = out[0].text
assert "Alice Mail" in text
assert "Bob Mail" not in text
def test_mcp_email_scoped_owner_without_visible_account_skips_legacy_fallback(tmp_path, monkeypatch): def test_mcp_email_scoped_owner_without_visible_account_skips_legacy_fallback(tmp_path, monkeypatch):
db_path = tmp_path / "app.db" db_path = tmp_path / "app.db"
settings_path = tmp_path / "settings.json" settings_path = tmp_path / "settings.json"
@@ -137,11 +178,81 @@ def test_mcp_email_scoped_owner_without_visible_account_skips_legacy_fallback(tm
es._ACCOUNT_CACHE.clear() es._ACCOUNT_CACHE.clear()
@pytest.mark.asyncio
async def test_mcp_email_owner_cannot_use_other_owner_account_for_list_read_send_or_draft(tmp_path, monkeypatch):
import src.constants as constants
db_path = tmp_path / "app.db"
scheduled_path = tmp_path / "scheduled_emails.db"
_init_accounts_db(db_path)
monkeypatch.setattr(es, "APP_DB", str(db_path))
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(scheduled_path))
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
calls = [
("list_emails", {"account": "Bob Mail"}),
("read_email", {"uid": "1", "account": "Bob Mail"}),
("send_email", {
"to": "recipient@example.com",
"subject": "Blocked",
"body": "Do not stage.",
"account": "Bob Mail",
}),
("draft_email", {
"to": "recipient@example.com",
"subject": "Blocked",
"body": "Do not draft.",
"account": "Bob Mail",
}),
]
for tool_name, args in calls:
out = await es.call_tool(tool_name, {**args, "_odysseus_owner": "alice"})
assert "Email account not found for selector" in out[0].text, tool_name
assert "Bob Mail" not in out[0].text or "Available accounts" in out[0].text
assert not scheduled_path.exists()
@pytest.mark.asyncio
async def test_mcp_send_email_stages_with_visible_owner_account_id(tmp_path, monkeypatch):
import src.constants as constants
app_db_path = tmp_path / "app.db"
scheduled_path = tmp_path / "scheduled_emails.db"
_init_accounts_db(app_db_path)
monkeypatch.setattr(es, "APP_DB", str(app_db_path))
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(scheduled_path))
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
out = await es.call_tool(
"send_email",
{
"to": "recipient@example.com",
"subject": "Review",
"body": "Please review.",
"account": "Alice Mail",
"_odysseus_owner": "alice",
},
)
assert "Draft staged for approval" in out[0].text
conn = sqlite3.connect(scheduled_path)
try:
row = conn.execute(
"SELECT owner, status, account_id FROM scheduled_emails"
).fetchone()
finally:
conn.close()
assert row == ("alice", "agent_draft", "acct-alice")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_send_email_stages_owner_scoped_pending_draft(tmp_path, monkeypatch): async def test_mcp_send_email_stages_owner_scoped_pending_draft(tmp_path, monkeypatch):
import src.constants as constants import src.constants as constants
db_path = tmp_path / "scheduled_emails.db" db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(es, "APP_DB", str(tmp_path / "missing-app.db"))
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(db_path)) monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(db_path))
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True) monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)