diff --git a/kora_cli/audit/jsonl_sink.py b/kora_cli/audit/jsonl_sink.py index a2990cbc6680..8b9daa7672ec 100644 --- a/kora_cli/audit/jsonl_sink.py +++ b/kora_cli/audit/jsonl_sink.py @@ -98,6 +98,20 @@ # ships the emission for operator visibility via the audit # panel + alerts panel without invoking LLM. "probe.wake_requested", + # KR-EMAIL-OUTBOUND-COMPOSE-TOOL — outbound counterpart to the + # email-to-sea_ticket intent seam. Emitted by + # ``kora__send_email_to_operator`` (the reasoning-loop-callable + # outbound tool) for every invocation, with ``details`` + # capturing ``status`` (``sent`` / ``rejected`` / + # ``smtp_failure``), rejection reason if applicable, subject / + # body / attachment sizes (no body content), and + # ``smtp_message_id`` on success. Recipient is always the + # operator (pinned to ``KORA_EMAIL_JOSHUA_ADDRESS``) — never + # caller-controllable, so the audit row doesn't need a + # recipient field. Future KR-FE-EMAIL-INTENT-LOG-PANEL bucket + # can render both inbound + outbound seams in the same + # cockpit panel. + "tool.email_to_operator_sent", # KR-INTENT-EMAIL-TO-SEA-TICKET — operator-driven Sea_Ticket # creation from inbound email. Emitted from the email-inbound # handler when intent recognition runs on a Joshua-authored diff --git a/kora_cli/listeners/mcp_tools.py b/kora_cli/listeners/mcp_tools.py index 0ae9847ac101..32b4ccdd0930 100644 --- a/kora_cli/listeners/mcp_tools.py +++ b/kora_cli/listeners/mcp_tools.py @@ -1929,6 +1929,160 @@ async def _dispatch_send_test_alert( ) +# =========================================================================== +# KR-EMAIL-OUTBOUND-COMPOSE-TOOL — operator-pinned email tool +# =========================================================================== +# +# Distinct from ``kora__send_email`` (Tool 11 above) in one critical +# defensive way: the recipient is PINNED to +# ``KORA_EMAIL_JOSHUA_ADDRESS`` by the executor — never caller- +# controlled. This pinning is what makes the tool safe to expose to +# Kora's own reasoning loop (see ``kora_cli/reasoning/tool_registry.py`` +# for the prior allowlist's deliberate exclusion of caller-recipient +# email tools, and the scope-expansion rationale). +# +# Operator R3 Q8a's outbound use case: "Kora, email me that pdf etc." +# =========================================================================== + + +SEND_EMAIL_TO_OPERATOR_TOOL: Dict[str, Any] = { + "name": "kora__send_email_to_operator", + "description": ( + "Compose and send an email to Joshua (the operator). Use " + "when the response is too long, structured, or attachment-" + "heavy for Slack DM. **Recipient is always Joshua's " + "verified address** (pinned by the executor — you cannot " + "specify other recipients). Attachments are " + "{filename, content_path} dicts where content_path is a " + "local file path Kora has read access to; total combined " + "size capped by KORA_EMAIL_OUTBOUND_MAX_ATTACH_MB " + "(default 20 MB). Sends per hour capped by " + "KORA_EMAIL_OUTBOUND_HOURLY_CAP (default 5). On reject/" + "failure the tool returns a structured result with a " + "wire-stable ``reason`` code; consider falling back to a " + "Slack DM." + ), + "inputSchema": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": ( + "Email subject. Recommend prefixing with " + "'[Kora]' for inbox filtering." + ), + }, + "body": { + "type": "string", + "minLength": 1, + "description": ( + "Email body. Plain text or markdown; sent " + "verbatim as text/plain in v1." + ), + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": {"type": "string", "minLength": 1}, + "content_path": {"type": "string", "minLength": 1}, + }, + "required": ["filename", "content_path"], + "additionalProperties": False, + }, + "description": ( + "Optional list of attachments. " + "content_path is a local file path Kora has " + "read access to. Total <= 20 MB by default." + ), + }, + }, + "required": ["subject", "body"], + "additionalProperties": False, + }, + "requires_cap_gate": True, + "dev_only": False, +} + + +class SendEmailToOperatorResult(BaseModel): + """Pydantic projection of the tool's result dict — used by the + reasoning engine to serialize into the ``tool_result`` block.""" + + status: str + smtp_message_id: Optional[str] = None + sent_at: Optional[str] = None + attachment_count: Optional[int] = None + attachment_total_bytes: Optional[int] = None + reason: Optional[str] = None + error: Optional[str] = None + detail: Optional[Dict[str, Any]] = None + + +async def _execute_send_email_to_operator( + *, + subject: str, + body: str, + attachments: Optional[List[Dict[str, Any]]], + caller: Caller, +) -> SendEmailToOperatorResult: + from kora_cli.tools.email_to_operator import send_email_to_operator + + # Caller-session correlation: use the actor_kind as the + # baseline correlation key when the engine hasn't provided a + # finer-grained session id. The reasoning-engine call site + # passes a real session id in via a different path (the engine + # is responsible for that wiring when it cares); MCP-external + # callers correlate by actor_kind. + caller_session_id = f"mcp:{caller.actor_kind}" + raw = await send_email_to_operator( + subject=subject, + body=body, + attachments=attachments, + caller_session_id=caller_session_id, + ) + + _emit_audit( + tool="kora__send_email_to_operator", + caller=caller, + args={ + "subject_chars": len(subject or ""), + "body_chars": len(body or ""), + "attachment_count": len(attachments or []), + }, + result=( + f"status={raw.get('status')} " + f"reason={raw.get('reason')} " + f"message_id={raw.get('smtp_message_id')}" + ), + ) + + return SendEmailToOperatorResult( + status=raw.get("status", "unknown"), + smtp_message_id=raw.get("smtp_message_id"), + sent_at=raw.get("sent_at"), + attachment_count=raw.get("attachment_count"), + attachment_total_bytes=raw.get("attachment_total_bytes"), + reason=raw.get("reason"), + error=raw.get("error"), + detail=raw.get("detail"), + ) + + +async def _dispatch_send_email_to_operator( + params: Dict[str, Any], caller: Caller +) -> BaseModel: + return await _execute_send_email_to_operator( + subject=params.get("subject", ""), + body=params.get("body", ""), + attachments=params.get("attachments"), + caller=caller, + ) + + ST2_TOOL_DESCRIPTORS: List[Dict[str, Any]] = [ REQUEST_STATE_TRANSITION_TOOL, CREATE_SEA_TICKET_TOOL, @@ -1943,6 +2097,8 @@ async def _dispatch_send_test_alert( REQUEST_STOP_TOOL, # KR-ALERT-NOTIFY ST2 — dev-only test alert tool SEND_TEST_ALERT_TOOL, + # KR-EMAIL-OUTBOUND-COMPOSE-TOOL — operator-pinned email tool + SEND_EMAIL_TO_OPERATOR_TOOL, ] @@ -1964,4 +2120,6 @@ async def _dispatch_send_test_alert( "kora__request_stop": _dispatch_request_stop, # KR-ALERT-NOTIFY ST2 addition "kora__send_test_alert": _dispatch_send_test_alert, + # KR-EMAIL-OUTBOUND-COMPOSE-TOOL — operator-pinned send + "kora__send_email_to_operator": _dispatch_send_email_to_operator, } diff --git a/kora_cli/reasoning/tool_registry.py b/kora_cli/reasoning/tool_registry.py index 8f5e6391aca5..5f2786af192c 100644 --- a/kora_cli/reasoning/tool_registry.py +++ b/kora_cli/reasoning/tool_registry.py @@ -9,7 +9,7 @@ # Security boundary -Reasoning-tools are **READ-ONLY only** in v1. Kora cannot +Reasoning-tools are **READ-ONLY** by default. Kora cannot initiate state changes from her own reasoning loop: - No ``kora__request_state_transition`` (would let Kora @@ -22,19 +22,34 @@ not appropriate for reasoning). - No ``kora__send_slack_dm`` / ``kora__send_email`` (Kora already responds via the Slack DM channel; meta-sends would - be confusing + Loop-risky). - -Mutating tools remain available to OTHER agents via ``/mcp`` (with -capability gating per KR-MCP-RUNTIME-SURFACE ST2). The architectural -distinction: **Kora REASONS in her DM thread; AGENTS DRIVE her via -MCP.** Reasoning-tools are for Kora to LOOK at her own state to -answer Joshua; mutating-tools are how other agents tell her what -to DO. - -If the operator later needs a reasoning-mutating tool (e.g. "Kora -can self-pause if she detects she's stuck"), that's a deliberate -allowlist expansion + a separate threat-model review. v1 ships -read-only. + be confusing + Loop-risky, AND the caller-controlled + recipient list creates a mass-send risk vector). + +# Deliberate scope expansion: kora__send_email_to_operator + +KR-EMAIL-OUTBOUND-COMPOSE-TOOL adds ONE mutating tool to the +reasoning allowlist: ``kora__send_email_to_operator``. The +exclusion above for ``kora__send_email`` was driven by two +concerns — Loop-risk + mass-send-risk-from-caller-controlled- +recipient. ``kora__send_email_to_operator`` neutralizes the mass- +send concern by **pinning the recipient** to +``KORA_EMAIL_JOSHUA_ADDRESS`` in the executor itself; the caller +cannot specify other recipients. Loop-risk is addressed by the +tool's own hourly-cap default (``KORA_EMAIL_OUTBOUND_HOURLY_CAP`` += 5; configurable) plus operator R3 Q8a explicitly asking for +this surface ("Kora, email me that pdf"). + +This is the deliberate expansion the original docstring +anticipated: "If the operator later needs a reasoning-mutating +tool... that's a deliberate allowlist expansion + a separate +threat-model review." The R3 walkthrough was the review. + +Other mutating tools remain available to OTHER agents via +``/mcp`` (with capability gating per KR-MCP-RUNTIME-SURFACE ST2). +The architectural distinction: **Kora REASONS in her DM thread; +AGENTS DRIVE her via MCP.** Reasoning-tools are for Kora to LOOK +at her own state or send a single operator-pinned email; +mutating-tools are how other agents tell her what to DO. # Schema conversion: MCP camelCase → Anthropic snake_case @@ -71,19 +86,41 @@ # Allowlist (HARDCODED v1) # --------------------------------------------------------------------------- -# The 5 read-only tools Kora can call mid-reasoning. Names match -# ``mcp_tools.TOOL_DESCRIPTORS`` entries exactly. Order is the -# advertised order in the Anthropic ``tools`` array (cosmetic; Claude -# picks tools by name not position). +# Read-only tools Kora can call mid-reasoning + one operator-pinned +# mutating tool (kora__send_email_to_operator, KR-EMAIL-OUTBOUND- +# COMPOSE-TOOL — see module docstring for the scope-expansion +# rationale). Names match ``mcp_tools.TOOL_DESCRIPTORS`` / +# ``ST2_TOOL_DESCRIPTORS`` entries exactly. Order is the advertised +# order in the Anthropic ``tools`` array (cosmetic; Claude picks +# tools by name not position). REASONING_TOOL_ALLOWLIST: List[str] = [ "kora__get_operational_state", "kora__get_health_rollup", "kora__get_recent_ledger_entries", "kora__list_active_sea_tickets", "kora__get_recent_chain_events", + # KR-EMAIL-OUTBOUND-COMPOSE-TOOL — operator-pinned email send. + # Mutating but recipient-locked; see module docstring. + "kora__send_email_to_operator", ] +# Tools in the allowlist that are MUTATING + take the (params, +# caller) ST2 dispatcher signature. Engine-side reasoning calls +# get a synthetic Caller below; the dispatcher's own audit +# emission attributes the call to that synthetic actor_kind. +_REASONING_MUTATING_TOOLS: frozenset[str] = frozenset( + {"kora__send_email_to_operator"} +) + +# Synthetic actor_kind used when the reasoning engine invokes a +# mutating tool from the allowlist. Distinct from "anonymous" so +# the audit trail attributes the action correctly, and distinct +# from any real MCP caller in mcp_callers.yaml so external +# callers can't impersonate it. +_REASONING_SELF_ACTOR_KIND = "kora_reasoning_self" + + # --------------------------------------------------------------------------- # Descriptor extraction — converts MCP shape → Anthropic shape # --------------------------------------------------------------------------- @@ -133,10 +170,18 @@ def get_reasoning_available_tools() -> List[Dict[str, Any]]: """ # Lazy import — keeps non-reasoning paths fast + breaks any # circular import risk between mcp_tools and the reasoning - # engine. - from kora_cli.listeners.mcp_tools import TOOL_DESCRIPTORS - - by_name = {desc["name"]: desc for desc in TOOL_DESCRIPTORS} + # engine. Pull from BOTH descriptor lists (ST1 read-only + + # ST2 mutating) so the operator-pinned email tool is + # advertised to Claude alongside the read tools. + from kora_cli.listeners.mcp_tools import ( + ST2_TOOL_DESCRIPTORS, + TOOL_DESCRIPTORS, + ) + + by_name = { + desc["name"]: desc + for desc in (*TOOL_DESCRIPTORS, *ST2_TOOL_DESCRIPTORS) + } out: List[Dict[str, Any]] = [] for name in REASONING_TOOL_ALLOWLIST: @@ -196,10 +241,31 @@ async def execute_reasoning_tool( """ if name not in REASONING_TOOL_ALLOWLIST: raise ReasoningToolNotAllowed( - f"tool {name!r} is not in the reasoning allowlist " - f"(read-only tools only). Available: " - f"{REASONING_TOOL_ALLOWLIST}" + f"tool {name!r} is not in the reasoning allowlist. " + f"Available: {REASONING_TOOL_ALLOWLIST}" + ) + + # Mutating-tool path (KR-EMAIL-OUTBOUND-COMPOSE-TOOL): route + # through ST2_TOOL_DISPATCH with a synthetic Caller. The + # synthetic caller's allowed_caps contains only the single + # tool being invoked, so even if a future executor adds a + # ``caller.allows(other_tool)`` check it'll fail closed. + if name in _REASONING_MUTATING_TOOLS: + from kora_cli.listeners.mcp_caller_auth import Caller + from kora_cli.listeners.mcp_tools import ST2_TOOL_DISPATCH + + st2_dispatcher = ST2_TOOL_DISPATCH.get(name) + if st2_dispatcher is None: + raise ReasoningToolNotAllowed( + f"tool {name!r} is in the reasoning mutating " + f"allowlist but has no ST2 dispatcher — " + f"mcp_tools.ST2_TOOL_DISPATCH drift" + ) + synthetic = Caller( + actor_kind=_REASONING_SELF_ACTOR_KIND, + allowed_caps=frozenset({name}), ) + return await st2_dispatcher(tool_input, synthetic) # Resolve the executor via mcp_tools.TOOL_DISPATCH. The # dispatcher takes a single ``params`` dict and calls the diff --git a/kora_cli/tools/__init__.py b/kora_cli/tools/__init__.py new file mode 100644 index 000000000000..a781abef7b6a --- /dev/null +++ b/kora_cli/tools/__init__.py @@ -0,0 +1,10 @@ +"""Kora reasoning-callable tools — KR-EMAIL-OUTBOUND-COMPOSE-TOOL et al. + +Modules in this package host the orchestration backing tools that +Kora invokes from her own reasoning loop (via the dispatcher in +``kora_cli/reasoning/tool_registry.py``). The MCP tool descriptors ++ JSON-RPC dispatchers live in ``kora_cli/listeners/mcp_tools.py`` +— this package owns the actual behavior (file IO, rate limits, +audit emission) so the MCP wrapper stays a thin JSON-shape +adapter. +""" diff --git a/kora_cli/tools/email_to_operator.py b/kora_cli/tools/email_to_operator.py new file mode 100644 index 000000000000..70646b4aa8d1 --- /dev/null +++ b/kora_cli/tools/email_to_operator.py @@ -0,0 +1,538 @@ +"""Outbound email composer — KR-EMAIL-OUTBOUND-COMPOSE-TOOL. + +R3 Q8a operator use case: *"the other way mostly, like 'Kora, +email me that pdf etc.'"*. Companion to KR-INTENT-EMAIL-TO-SEA-TICKET +(#176, inbound direction) — this module gives Kora's reasoning +engine a tool to compose and send email to the operator when the +response is too long, too formatted, or too attachment-heavy for +Slack DM. + +# Security posture — recipient pinned + +Recipient is ALWAYS ``KORA_EMAIL_JOSHUA_ADDRESS``. The tool does +NOT accept a ``to`` argument from the caller (this is the +critical defensive difference vs. the existing +``kora__send_email`` MCP tool, which accepts caller-controlled +recipients and is gated to other agents via cap_matrix). With +recipient pinned by the executor itself there is no mass-send +risk, which is why this tool — unlike the existing +``kora__send_email`` — is safe to expose to Kora's own reasoning +loop (see ``kora_cli/reasoning/tool_registry.py`` allowlist +docstring for the prior exclusion's reasoning). + +# Caps + tunables + + * ``KORA_EMAIL_OUTBOUND_HOURLY_CAP`` — int, default 5. Sliding- + window cap on successful sends per process. Zero disables. + Lower than the INTENT-TO-SEA-TICKET cap (10) because outbound + SMTP is a more visible operator action than a Sea_Ticket row; + operator should be deliberate about Kora-initiated emails. + * ``KORA_EMAIL_OUTBOUND_MAX_ATTACH_MB`` — int, default 20 (MB). + Total combined attachment size cap. Below Purelymail's 25 MB + SMTP ceiling for safety margin. + * ``KORA_EMAIL_JOSHUA_ADDRESS`` — recipient. Already used by + PR #173 (inbound identity check) + PR #176 (intent gate). + * ``KORA_PUREMAIL_SMTP_USERNAME`` — from_addr; resolved by the + daemon-singleton PurelymailClient. + +# Fail-soft + +Every step is wrapped so the tool always returns a structured +result dict (never raises). The reasoning engine sees the +``status`` field and can adapt (e.g. retry via Slack on +``smtp_failure`` / ``hourly_cap_exceeded``). +""" + +from __future__ import annotations + +import logging +import mimetypes +import os +from collections import deque +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Deque, Dict, List, Optional + +from kora_cli.audit.jsonl_sink import emit_audit +from kora_cli.clients.purelymail_types import Attachment + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Env vars + defaults +# --------------------------------------------------------------------------- + + +RECIPIENT_ENV = "KORA_EMAIL_JOSHUA_ADDRESS" +HOURLY_CAP_ENV = "KORA_EMAIL_OUTBOUND_HOURLY_CAP" +MAX_ATTACH_MB_ENV = "KORA_EMAIL_OUTBOUND_MAX_ATTACH_MB" +SMTP_USERNAME_ENV = "KORA_PUREMAIL_SMTP_USERNAME" + +DEFAULT_HOURLY_CAP = 5 +DEFAULT_MAX_ATTACH_MB = 20 +HOURLY_WINDOW = timedelta(hours=1) +SUBJECT_MAX_CHARS = 200 + +# Status enum the executor returns. Wire-stable; consumers (the +# reasoning engine + the future cockpit panel) branch on this. +STATUS_SENT = "sent" +STATUS_REJECTED = "rejected" +STATUS_SMTP_FAILURE = "smtp_failure" + +# Rejection reasons — also wire-stable. +REASON_RECIPIENT_UNSET = "recipient_env_unset" +REASON_SMTP_FROM_UNSET = "smtp_from_env_unset" +REASON_CLIENT_UNAVAILABLE = "purelymail_client_unavailable" +REASON_SUBJECT_TOO_LONG = "subject_too_long" +REASON_SUBJECT_EMPTY = "subject_empty" +REASON_BODY_EMPTY = "body_empty" +REASON_ATTACHMENT_TOO_LARGE = "attachment_too_large" +REASON_ATTACHMENT_MISSING_FILE = "attachment_missing_file" +REASON_ATTACHMENT_UNREADABLE = "attachment_unreadable" +REASON_HOURLY_CAP_EXCEEDED = "hourly_cap_exceeded" + + +# --------------------------------------------------------------------------- +# Sliding-window hourly cap (module-level state) +# --------------------------------------------------------------------------- + + +_recent_send_timestamps: Deque[datetime] = deque() + + +def _resolve_hourly_cap() -> int: + raw = os.environ.get(HOURLY_CAP_ENV, "").strip() + if not raw: + return DEFAULT_HOURLY_CAP + try: + value = int(raw) + except ValueError: + logger.warning( + "[kora.tool.email_to_operator] malformed %s=%r — falling " + "back to default %d", + HOURLY_CAP_ENV, + raw, + DEFAULT_HOURLY_CAP, + ) + return DEFAULT_HOURLY_CAP + if value < 0: + logger.warning( + "[kora.tool.email_to_operator] %s=%d negative — falling " + "back to default %d", + HOURLY_CAP_ENV, + value, + DEFAULT_HOURLY_CAP, + ) + return DEFAULT_HOURLY_CAP + return value + + +def _resolve_max_attach_bytes() -> int: + raw = os.environ.get(MAX_ATTACH_MB_ENV, "").strip() + if not raw: + return DEFAULT_MAX_ATTACH_MB * 1024 * 1024 + try: + mb = int(raw) + except ValueError: + logger.warning( + "[kora.tool.email_to_operator] malformed %s=%r — falling " + "back to default %d MB", + MAX_ATTACH_MB_ENV, + raw, + DEFAULT_MAX_ATTACH_MB, + ) + return DEFAULT_MAX_ATTACH_MB * 1024 * 1024 + if mb <= 0: + logger.warning( + "[kora.tool.email_to_operator] %s=%d must be > 0 — falling " + "back to default %d MB", + MAX_ATTACH_MB_ENV, + mb, + DEFAULT_MAX_ATTACH_MB, + ) + return DEFAULT_MAX_ATTACH_MB * 1024 * 1024 + return mb * 1024 * 1024 + + +def _hourly_cap_allows(now: Optional[datetime] = None) -> bool: + cap = _resolve_hourly_cap() + if cap == 0: + return True + current = now or datetime.now(timezone.utc) + cutoff = current - HOURLY_WINDOW + while _recent_send_timestamps and _recent_send_timestamps[0] < cutoff: + _recent_send_timestamps.popleft() + return len(_recent_send_timestamps) < cap + + +def _record_send(now: Optional[datetime] = None) -> None: + _recent_send_timestamps.append(now or datetime.now(timezone.utc)) + + +def _reset_rate_limiter_for_tests() -> None: + """Test-only: clear the module-level deque. Production code + MUST NOT call this.""" + _recent_send_timestamps.clear() + + +# --------------------------------------------------------------------------- +# Attachment ingestion +# --------------------------------------------------------------------------- + + +def _guess_mime(filename: str) -> tuple[str, str]: + """Return ``(maintype, subtype)`` for a filename. Defaults to + ``application/octet-stream`` when the MIME type can't be + inferred — covers extensionless or unusual artifacts cleanly. + """ + guessed, _ = mimetypes.guess_type(filename) + if not guessed or "/" not in guessed: + return ("application", "octet-stream") + maintype, subtype = guessed.split("/", 1) + return (maintype, subtype) + + +def _read_attachments( + raw_attachments: List[Dict[str, Any]], + max_total_bytes: int, +) -> tuple[Optional[List[Attachment]], Optional[Dict[str, Any]]]: + """Read attachment files off disk + build :class:`Attachment` list. + + Returns ``(attachments, error_dict_or_None)``. On any per-file + failure the function aborts (does NOT partial-send) and + returns a rejection-shaped error dict the caller can fold into + the result. + + Total size cap is enforced as bytes accumulate — a 50-MB + second file aborts the read before reading it, not after. + """ + if not raw_attachments: + return ([], None) + + out: List[Attachment] = [] + total = 0 + for idx, entry in enumerate(raw_attachments): + filename = (entry or {}).get("filename") + path_str = (entry or {}).get("content_path") + if not isinstance(filename, str) or not filename.strip(): + return (None, { + "reason": REASON_ATTACHMENT_MISSING_FILE, + "attachment_index": idx, + "detail": "attachment missing 'filename'", + }) + if not isinstance(path_str, str) or not path_str.strip(): + return (None, { + "reason": REASON_ATTACHMENT_MISSING_FILE, + "attachment_index": idx, + "filename": filename, + "detail": "attachment missing 'content_path'", + }) + path = Path(path_str) + if not path.is_file(): + return (None, { + "reason": REASON_ATTACHMENT_MISSING_FILE, + "attachment_index": idx, + "filename": filename, + "content_path": str(path), + }) + try: + size = path.stat().st_size + except OSError as exc: + return (None, { + "reason": REASON_ATTACHMENT_UNREADABLE, + "attachment_index": idx, + "filename": filename, + "detail": f"stat failed: {exc!r}", + }) + if total + size > max_total_bytes: + return (None, { + "reason": REASON_ATTACHMENT_TOO_LARGE, + "attachment_index": idx, + "filename": filename, + "size_bytes": size, + "total_so_far_bytes": total, + "max_total_bytes": max_total_bytes, + }) + try: + content = path.read_bytes() + except OSError as exc: + return (None, { + "reason": REASON_ATTACHMENT_UNREADABLE, + "attachment_index": idx, + "filename": filename, + "detail": f"read failed: {exc!r}", + }) + total += len(content) + maintype, subtype = _guess_mime(filename) + out.append( + Attachment( + filename=filename, + content=content, + maintype=maintype, + subtype=subtype, + ) + ) + return (out, None) + + +# --------------------------------------------------------------------------- +# Audit emission +# --------------------------------------------------------------------------- + + +def _safe_audit( + *, + details: Dict[str, Any], + caller_session_id: Optional[str], +) -> None: + """Wrap :func:`emit_audit` so an audit-write failure can't + blow up the tool path. Best-effort, log on miss, never raise.""" + try: + emit_audit( + "tool.email_to_operator_sent", + details, + caller_session_id=caller_session_id, + source="reasoning", + ) + except Exception as exc: + logger.warning( + "[kora.tool.email_to_operator.audit_failed] %r — " + "tool continues", + exc, + ) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + +def _reject( + *, + reason: str, + detail: Optional[Dict[str, Any]] = None, + audit_details: Dict[str, Any], + caller_session_id: Optional[str], +) -> Dict[str, Any]: + audit_payload = dict(audit_details) + audit_payload["status"] = STATUS_REJECTED + audit_payload["rejection_reason"] = reason + if detail: + audit_payload["rejection_detail"] = detail + _safe_audit(details=audit_payload, caller_session_id=caller_session_id) + out: Dict[str, Any] = {"status": STATUS_REJECTED, "reason": reason} + if detail: + out["detail"] = detail + return out + + +async def send_email_to_operator( + *, + subject: str, + body: str, + attachments: Optional[List[Dict[str, Any]]] = None, + caller_session_id: Optional[str] = None, + purelymail_client: Optional[Any] = None, +) -> Dict[str, Any]: + """Compose and send an email to the operator. Recipient is + PINNED to ``KORA_EMAIL_JOSHUA_ADDRESS`` — caller cannot + override. + + Args: + subject: Email subject. ≤200 chars, non-empty after trim. + body: Email body. Plain text or markdown (sent verbatim + as ``body_text``; HTML rendering is a follow-on). + attachments: Optional list of + ``{"filename": str, "content_path": str}`` dicts. The + executor reads each file off disk. + caller_session_id: Optional correlation key threaded into + the audit row (engine passes its own per-respond session + id). + purelymail_client: Override for tests; production passes + ``None`` and the executor resolves + :func:`current_purelymail_client`. + + Returns one of: + ``{"status": "sent", "smtp_message_id": str, "sent_at": str, + "attachment_count": int, "attachment_total_bytes": int}`` + ``{"status": "rejected", "reason": str, "detail": dict | None}`` + ``{"status": "smtp_failure", "error": str}`` + + Never raises — every failure path is captured as a result + dict so the reasoning engine can adapt. + """ + audit_details: Dict[str, Any] = { + "subject_chars": len(subject or ""), + "body_chars": len(body or ""), + "attachment_count": len(attachments or []), + } + + # 1. Recipient env present? + recipient = os.environ.get(RECIPIENT_ENV, "").strip() + if not recipient: + return _reject( + reason=REASON_RECIPIENT_UNSET, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + + # 2. Subject validation. + subject_trim = (subject or "").strip() + if not subject_trim: + return _reject( + reason=REASON_SUBJECT_EMPTY, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + if len(subject_trim) > SUBJECT_MAX_CHARS: + return _reject( + reason=REASON_SUBJECT_TOO_LONG, + detail={ + "subject_chars": len(subject_trim), + "max_chars": SUBJECT_MAX_CHARS, + }, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + + # 3. Body validation. + if not (body or "").strip(): + return _reject( + reason=REASON_BODY_EMPTY, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + + # 4. Hourly cap. + if not _hourly_cap_allows(): + cap = _resolve_hourly_cap() + return _reject( + reason=REASON_HOURLY_CAP_EXCEEDED, + detail={"hourly_cap": cap}, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + + # 5. Attachment read + size cap. + max_total_bytes = _resolve_max_attach_bytes() + attached, attach_err = _read_attachments( + attachments or [], max_total_bytes + ) + if attach_err is not None: + return _reject( + reason=attach_err["reason"], + detail={k: v for k, v in attach_err.items() if k != "reason"}, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + attached_list: List[Attachment] = attached or [] + total_bytes = sum(len(a.content) for a in attached_list) + audit_details["attachment_total_bytes"] = total_bytes + + # 6. Resolve from_addr + the live PurelymailClient. + from_addr = os.environ.get(SMTP_USERNAME_ENV, "").strip() + if not from_addr: + return _reject( + reason=REASON_SMTP_FROM_UNSET, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + + client = purelymail_client or _resolve_purelymail_client() + if client is None: + return _reject( + reason=REASON_CLIENT_UNAVAILABLE, + audit_details=audit_details, + caller_session_id=caller_session_id, + ) + + # 7. Send. PurelymailClient enforces its own per/total + # attachment-byte caps + recipient-count cap; failures bubble + # back as exceptions which we capture as ``smtp_failure``. + try: + result = await client.send_email( + from_addr=from_addr, + to=[recipient], + subject=subject_trim, + body_text=body, + body_html=None, + in_reply_to=None, + attachments=attached_list or None, + caller_actor_kind="kora_reasoning_self", + ) + except Exception as exc: + logger.warning( + "[kora.tool.email_to_operator.smtp_failure] %r — " + "result returned to reasoning engine", + exc, + ) + failure_payload = { + **audit_details, + "status": STATUS_SMTP_FAILURE, + "error": f"{type(exc).__name__}", + } + _safe_audit( + details=failure_payload, caller_session_id=caller_session_id + ) + return { + "status": STATUS_SMTP_FAILURE, + "error": f"{type(exc).__name__}", + } + + # 8. Record + audit success. + smtp_message_id = getattr(result, "message_id", None) + sent_at_dt = getattr(result, "sent_at", None) + sent_at_str: Optional[str] = None + if isinstance(sent_at_dt, datetime): + sent_at_str = sent_at_dt.strftime("%Y-%m-%dT%H:%M:%SZ") + # PurelymailClient may return SendResult(status="failed") for + # retried SMTP rejections; surface that as smtp_failure rather + # than sent. + status = getattr(result, "status", None) + if status != "ok": + failure_payload = { + **audit_details, + "status": STATUS_SMTP_FAILURE, + "smtp_status": status, + "error": getattr(result, "error", None), + } + _safe_audit( + details=failure_payload, caller_session_id=caller_session_id + ) + return { + "status": STATUS_SMTP_FAILURE, + "error": getattr(result, "error", None) or "smtp_status_not_ok", + } + + _record_send() + success_payload = { + **audit_details, + "status": STATUS_SENT, + "smtp_message_id": smtp_message_id, + "sent_at": sent_at_str, + } + _safe_audit( + details=success_payload, caller_session_id=caller_session_id + ) + return { + "status": STATUS_SENT, + "smtp_message_id": smtp_message_id, + "sent_at": sent_at_str, + "attachment_count": len(attached_list), + "attachment_total_bytes": total_bytes, + } + + +def _resolve_purelymail_client() -> Optional[Any]: + """Lazy import + read the daemon's live PurelymailClient + singleton. Returns ``None`` when the listener isn't wired + (caller treats as ``REASON_CLIENT_UNAVAILABLE``). + """ + try: + from kora_cli.listeners.purelymail_client_listener import ( + current_purelymail_client, + ) + except Exception: + return None + return current_purelymail_client() diff --git a/kora_docs/00_canonical_current_state/kora_system_prompt.md b/kora_docs/00_canonical_current_state/kora_system_prompt.md index 5e62640eac2b..32778dde0db9 100644 --- a/kora_docs/00_canonical_current_state/kora_system_prompt.md +++ b/kora_docs/00_canonical_current_state/kora_system_prompt.md @@ -102,8 +102,10 @@ said. NOT to perform thoughtfulness. To be USEFUL. ## Tool use -You have five read-only tools available. Call them when the -answer depends on live state Joshua doesn't see directly: +You have five read-only tools + one operator-pinned outbound +tool available. Call them when the answer depends on live state +Joshua doesn't see directly, or when your response is too long +or attachment-heavy for Slack DM: - **`kora__get_operational_state`** — your current primary state (BOOTING / READY / ACTIVE / PAUSED / STOPPED), @@ -123,6 +125,17 @@ answer depends on live state Joshua doesn't see directly: - **`kora__get_recent_chain_events`** — recent `kora.*` chain events. Use when Joshua asks about your audit trail / what events you've emitted recently. +- **`kora__send_email_to_operator`** *(outbound)* — compose + and send an email to Joshua's verified address. Use when + Joshua asks for a PDF, a report, a long-form write-up, or + any other response that would be uncomfortable as a Slack + DM. The recipient is pinned to Joshua's address (you can't + specify other recipients); attachments are local file paths + Kora has read access to. The tool returns a structured + result; if it returns `status: rejected` or `smtp_failure`, + fall back to a Slack DM that explains what happened. Capped + at 5 sends/hour by default — don't burn the cap on routine + responses. ### How you use tools @@ -147,19 +160,23 @@ answer depends on live state Joshua doesn't see directly: ### The mutation boundary -You CANNOT mutate state through reasoning. There is no -`kora__request_state_transition` / `kora__create_sea_ticket` / -`kora__send_slack_dm` available in your reasoning surface — that's -a deliberate security boundary. **Kora REASONS in her DM thread; -AGENTS DRIVE her via MCP.** - -If Joshua asks you to do something that requires mutation — -"pause yourself" / "create a ticket for X" / "send a message -to Y" — explain that you can't initiate that from reasoning, -and suggest the operator-driven path (the equivalent -`kora_control` command, the `sea__create_ticket` substrate -flow, etc.). Don't pretend you can; don't apologize at length; -just name what you can't do + what the right channel is. +You CANNOT mutate substrate state through reasoning, with one +narrow exception. There is no `kora__request_state_transition` +/ `kora__create_sea_ticket` / `kora__send_slack_dm` available in +your reasoning surface — that's a deliberate security boundary. +**Kora REASONS in her DM thread; AGENTS DRIVE her via MCP.** + +The exception is `kora__send_email_to_operator` — Joshua R3 Q8a +asked for "Kora, email me that pdf" specifically, and the tool's +recipient pinning + hourly cap make the scope expansion safe. + +If Joshua asks you to do something that requires substrate +mutation — "pause yourself" / "create a ticket for X" / "send a +message to Z (not me)" — explain that you can't initiate that +from reasoning, and suggest the operator-driven path (the +equivalent `kora_control` command, the `sea__create_ticket` +substrate flow, etc.). Don't pretend you can; don't apologize at +length; just name what you can't do + what the right channel is. ## When you don't have an answer diff --git a/tests/kora_cli/reasoning/test_anthropic_engine_tool_use.py b/tests/kora_cli/reasoning/test_anthropic_engine_tool_use.py index 9ef795a7857c..7af62cf8b926 100644 --- a/tests/kora_cli/reasoning/test_anthropic_engine_tool_use.py +++ b/tests/kora_cli/reasoning/test_anthropic_engine_tool_use.py @@ -128,7 +128,10 @@ def _oauth(monkeypatch): # --------------------------------------------------------------------------- -def test_allowlist_has_5_read_only_tools(): +def test_allowlist_has_5_read_only_plus_operator_pinned_email(): + """Post KR-EMAIL-OUTBOUND-COMPOSE-TOOL: 5 read-only tools + 1 + operator-pinned mutating tool (kora__send_email_to_operator, + recipient locked to KORA_EMAIL_JOSHUA_ADDRESS by the executor).""" assert sorted(REASONING_TOOL_ALLOWLIST) == sorted( [ "kora__get_operational_state", @@ -136,20 +139,34 @@ def test_allowlist_has_5_read_only_tools(): "kora__get_recent_ledger_entries", "kora__list_active_sea_tickets", "kora__get_recent_chain_events", + "kora__send_email_to_operator", ] ) -def test_allowlist_has_no_mutating_tools(): - """Security boundary — Kora cannot mutate state via reasoning.""" - mutating = [ +def test_allowlist_excludes_caller_controlled_mutating_tools(): + """Security boundary — Kora cannot invoke tools that accept + caller-controlled recipients (mass-send risk) or substrate + state mutations from her own reasoning loop. + + The lone allowlisted mutating tool + ``kora__send_email_to_operator`` pins recipient to + KORA_EMAIL_JOSHUA_ADDRESS in the executor (see + KR-EMAIL-OUTBOUND-COMPOSE-TOOL); other mutating tools stay + excluded. + """ + forbidden = [ "kora__request_state_transition", "kora__create_sea_ticket", "kora__send_webhook_test_event", "kora__send_slack_dm", - "kora__send_email", + "kora__send_email", # caller-controlled recipients + "kora__request_pause", + "kora__request_resume", + "kora__request_stop", + "kora__send_test_alert", ] - for m in mutating: + for m in forbidden: assert m not in REASONING_TOOL_ALLOWLIST, ( f"{m} should NOT be reachable from reasoning" ) @@ -160,7 +177,9 @@ def test_get_reasoning_available_tools_anthropic_shape(): (snake_case per Anthropic API, NOT MCP's camelCase `inputSchema`).""" tools = get_reasoning_available_tools() - assert len(tools) == 5 + # 5 read-only + 1 operator-pinned outbound + # (KR-EMAIL-OUTBOUND-COMPOSE-TOOL). + assert len(tools) == 6 for tool in tools: assert set(tool.keys()) == {"name", "description", "input_schema"} assert tool["name"].startswith("kora__") @@ -178,7 +197,7 @@ def test_get_reasoning_available_tools_returns_fresh_list(): b = get_reasoning_available_tools() assert a is not b a.clear() - assert len(b) == 5 + assert len(b) == 6 @pytest.mark.asyncio @@ -528,7 +547,8 @@ async def test_non_empty_registry_passes_tools_param( call_kwargs = client.messages.create.await_args.kwargs assert "tools" in call_kwargs tools = call_kwargs["tools"] - assert len(tools) == 5 + # 5 read-only + kora__send_email_to_operator (KR-EMAIL-OUTBOUND-COMPOSE-TOOL) + assert len(tools) == 6 assert all("input_schema" in t for t in tools) diff --git a/tests/kora_cli/tools/__init__.py b/tests/kora_cli/tools/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/kora_cli/tools/test_email_to_operator.py b/tests/kora_cli/tools/test_email_to_operator.py new file mode 100644 index 000000000000..c96d2cab39ea --- /dev/null +++ b/tests/kora_cli/tools/test_email_to_operator.py @@ -0,0 +1,526 @@ +"""Tests for KR-EMAIL-OUTBOUND-COMPOSE-TOOL. + +Covers: + Recipient pinning + validation: + - happy path sends to KORA_EMAIL_JOSHUA_ADDRESS only + - recipient env unset → rejected with recipient_env_unset + - subject too long → rejected with subject_too_long + - subject empty → rejected with subject_empty + - body empty → rejected with body_empty + + Attachments: + - valid attachments are read and forwarded to PurelymailClient + - missing file → rejected with attachment_missing_file + - total bytes > cap → rejected with attachment_too_large + + Caps + rate limit: + - hourly cap of 3 → 4th send rejected with hourly_cap_exceeded + - cap of 0 disables limit + - malformed cap env warns + falls back to default + + SMTP failures: + - SMTP raise → status=smtp_failure, audit emitted + - SendResult status="failed" → status=smtp_failure + + Audit: + - every invocation emits one tool.email_to_operator_sent row + - audit details exclude body content; include sizes only + + Tool registry integration: + - tool name appears in get_reasoning_available_tools() + - execute_reasoning_tool routes to ST2 dispatcher with synthetic Caller + - reasoning loop never sees other-recipient send_email tools +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from kora_cli.clients.purelymail_types import Attachment, SendResult +from kora_cli.tools.email_to_operator import ( + DEFAULT_HOURLY_CAP, + DEFAULT_MAX_ATTACH_MB, + HOURLY_CAP_ENV, + MAX_ATTACH_MB_ENV, + RECIPIENT_ENV, + REASON_ATTACHMENT_MISSING_FILE, + REASON_ATTACHMENT_TOO_LARGE, + REASON_BODY_EMPTY, + REASON_CLIENT_UNAVAILABLE, + REASON_HOURLY_CAP_EXCEEDED, + REASON_RECIPIENT_UNSET, + REASON_SMTP_FROM_UNSET, + REASON_SUBJECT_EMPTY, + REASON_SUBJECT_TOO_LONG, + SMTP_USERNAME_ENV, + STATUS_REJECTED, + STATUS_SENT, + STATUS_SMTP_FAILURE, + SUBJECT_MAX_CHARS, + _hourly_cap_allows, + _record_send, + _reset_rate_limiter_for_tests, + send_email_to_operator, +) + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Per-test env isolation + audit-log redirect + rate-limiter reset.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("KORA_HOME", str(tmp_path)) + monkeypatch.setattr( + "kora_constants.get_kora_home", lambda: tmp_path, raising=False + ) + monkeypatch.setenv( + "KORA_AUDIT_LOG_PATH", str(tmp_path / "audit.jsonl") + ) + monkeypatch.setenv(RECIPIENT_ENV, "joshua@stormhavenenterprises.com") + monkeypatch.setenv(SMTP_USERNAME_ENV, "kora@stormhavenenterprises.com") + monkeypatch.delenv(HOURLY_CAP_ENV, raising=False) + monkeypatch.delenv(MAX_ATTACH_MB_ENV, raising=False) + _reset_rate_limiter_for_tests() + yield + _reset_rate_limiter_for_tests() + + +def _read_audit(tmp_path) -> list: + path = tmp_path / "audit.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines() if line] + + +def _ok_send_result(message_id: str = "") -> SendResult: + return SendResult( + status="ok", + message_id=message_id, + smtp_code=250, + sent_at=datetime.now(timezone.utc), + retry_count=0, + ) + + +def _failed_send_result() -> SendResult: + return SendResult( + status="failed", + message_id="", + error="smtp_relay_refused", + smtp_code=550, + sent_at=datetime.now(timezone.utc), + retry_count=1, + ) + + +def _fake_pm_client(send_result=None) -> MagicMock: + client = MagicMock() + client.send_email = AsyncMock(return_value=send_result or _ok_send_result()) + return client + + +# =========================================================================== +# Happy path + recipient pinning +# =========================================================================== + + +@pytest.mark.asyncio +async def test_happy_path_sends_to_pinned_recipient_only(tmp_path): + client = _fake_pm_client() + result = await send_email_to_operator( + subject="[Kora] morning summary", + body="here's the rundown", + purelymail_client=client, + ) + assert result["status"] == STATUS_SENT + assert result["smtp_message_id"] == "" + assert result["attachment_count"] == 0 + + client.send_email.assert_awaited_once() + kw = client.send_email.await_args.kwargs + # Recipient pinned — exactly the env value, no extras. + assert kw["to"] == ["joshua@stormhavenenterprises.com"] + assert kw["from_addr"] == "kora@stormhavenenterprises.com" + assert kw["subject"] == "[Kora] morning summary" + assert kw["body_text"] == "here's the rundown" + assert kw["attachments"] is None + assert kw["caller_actor_kind"] == "kora_reasoning_self" + + entries = _read_audit(tmp_path) + assert len(entries) == 1 + assert entries[0]["seam"] == "tool.email_to_operator_sent" + assert entries[0]["details"]["status"] == STATUS_SENT + assert entries[0]["details"]["smtp_message_id"] == "" + # Body content must NEVER appear in audit. + assert "here's the rundown" not in json.dumps(entries[0]) + assert "body_chars" in entries[0]["details"] + + +# =========================================================================== +# Validation rejections +# =========================================================================== + + +@pytest.mark.asyncio +async def test_recipient_env_unset_rejected(monkeypatch): + monkeypatch.delenv(RECIPIENT_ENV, raising=False) + client = _fake_pm_client() + result = await send_email_to_operator( + subject="x", body="y", purelymail_client=client + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_RECIPIENT_UNSET + client.send_email.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_smtp_from_unset_rejected(monkeypatch): + monkeypatch.delenv(SMTP_USERNAME_ENV, raising=False) + client = _fake_pm_client() + result = await send_email_to_operator( + subject="x", body="y", purelymail_client=client + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_SMTP_FROM_UNSET + client.send_email.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_subject_too_long_rejected(): + client = _fake_pm_client() + long_subject = "x" * (SUBJECT_MAX_CHARS + 5) + result = await send_email_to_operator( + subject=long_subject, body="y", purelymail_client=client + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_SUBJECT_TOO_LONG + assert result["detail"]["subject_chars"] == SUBJECT_MAX_CHARS + 5 + client.send_email.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_subject_empty_rejected(): + client = _fake_pm_client() + result = await send_email_to_operator( + subject=" ", body="y", purelymail_client=client + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_SUBJECT_EMPTY + + +@pytest.mark.asyncio +async def test_body_empty_rejected(): + client = _fake_pm_client() + result = await send_email_to_operator( + subject="x", body=" \n ", purelymail_client=client + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_BODY_EMPTY + + +@pytest.mark.asyncio +async def test_purelymail_client_unavailable_rejected(monkeypatch): + monkeypatch.setattr( + "kora_cli.tools.email_to_operator._resolve_purelymail_client", + lambda: None, + ) + result = await send_email_to_operator( + subject="x", body="y", purelymail_client=None + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_CLIENT_UNAVAILABLE + + +# =========================================================================== +# Attachments +# =========================================================================== + + +@pytest.mark.asyncio +async def test_attachments_read_and_forwarded(tmp_path): + pdf_path = tmp_path / "report.pdf" + pdf_path.write_bytes(b"%PDF-1.4 fake pdf content") + txt_path = tmp_path / "notes.txt" + txt_path.write_bytes(b"hello world") + client = _fake_pm_client() + + result = await send_email_to_operator( + subject="weekly", + body="see attached", + attachments=[ + {"filename": "report.pdf", "content_path": str(pdf_path)}, + {"filename": "notes.txt", "content_path": str(txt_path)}, + ], + purelymail_client=client, + ) + assert result["status"] == STATUS_SENT + assert result["attachment_count"] == 2 + assert result["attachment_total_bytes"] == 25 + 11 + + kw = client.send_email.await_args.kwargs + sent_attachments = kw["attachments"] + assert len(sent_attachments) == 2 + assert isinstance(sent_attachments[0], Attachment) + assert sent_attachments[0].filename == "report.pdf" + assert sent_attachments[0].content == b"%PDF-1.4 fake pdf content" + assert sent_attachments[0].maintype == "application" + assert sent_attachments[0].subtype == "pdf" + assert sent_attachments[1].maintype == "text" + assert sent_attachments[1].subtype == "plain" + + +@pytest.mark.asyncio +async def test_attachment_missing_file_rejected(tmp_path): + client = _fake_pm_client() + result = await send_email_to_operator( + subject="x", + body="y", + attachments=[ + { + "filename": "ghost.pdf", + "content_path": str(tmp_path / "does-not-exist.pdf"), + } + ], + purelymail_client=client, + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_ATTACHMENT_MISSING_FILE + client.send_email.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attachment_total_size_rejected(tmp_path, monkeypatch): + # Cap at 1 MB; one 600 KB + one 600 KB = 1.2 MB → second fails. + monkeypatch.setenv(MAX_ATTACH_MB_ENV, "1") + first = tmp_path / "a.bin" + first.write_bytes(b"x" * (600 * 1024)) + second = tmp_path / "b.bin" + second.write_bytes(b"y" * (600 * 1024)) + client = _fake_pm_client() + result = await send_email_to_operator( + subject="x", + body="y", + attachments=[ + {"filename": "a.bin", "content_path": str(first)}, + {"filename": "b.bin", "content_path": str(second)}, + ], + purelymail_client=client, + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_ATTACHMENT_TOO_LARGE + assert result["detail"]["max_total_bytes"] == 1024 * 1024 + client.send_email.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attachment_missing_filename_field_rejected(tmp_path): + f = tmp_path / "f" + f.write_bytes(b"x") + result = await send_email_to_operator( + subject="x", + body="y", + attachments=[{"content_path": str(f)}], # missing filename + purelymail_client=_fake_pm_client(), + ) + assert result["status"] == STATUS_REJECTED + assert result["reason"] == REASON_ATTACHMENT_MISSING_FILE + + +# =========================================================================== +# Hourly rate cap +# =========================================================================== + + +@pytest.mark.asyncio +async def test_hourly_cap_blocks_excess_sends(monkeypatch): + monkeypatch.setenv(HOURLY_CAP_ENV, "3") + client = _fake_pm_client() + + for i in range(3): + result = await send_email_to_operator( + subject=f"x{i}", body="y", purelymail_client=client + ) + assert result["status"] == STATUS_SENT + + overflow = await send_email_to_operator( + subject="overflow", body="y", purelymail_client=client + ) + assert overflow["status"] == STATUS_REJECTED + assert overflow["reason"] == REASON_HOURLY_CAP_EXCEEDED + assert overflow["detail"]["hourly_cap"] == 3 + assert client.send_email.await_count == 3 # not 4 + + +def test_hourly_cap_zero_disables(monkeypatch): + monkeypatch.setenv(HOURLY_CAP_ENV, "0") + for _ in range(50): + _record_send() + assert _hourly_cap_allows() is True + + +def test_hourly_cap_malformed_falls_back_to_default(monkeypatch, caplog): + import logging + + monkeypatch.setenv(HOURLY_CAP_ENV, "garbage") + with caplog.at_level(logging.WARNING): + _hourly_cap_allows() + assert any(HOURLY_CAP_ENV in r.message for r in caplog.records) + + +# =========================================================================== +# SMTP failures +# =========================================================================== + + +@pytest.mark.asyncio +async def test_smtp_exception_returns_smtp_failure(tmp_path): + client = MagicMock() + client.send_email = AsyncMock(side_effect=RuntimeError("smtp gone")) + result = await send_email_to_operator( + subject="x", body="y", purelymail_client=client + ) + assert result["status"] == STATUS_SMTP_FAILURE + assert result["error"] == "RuntimeError" + + entries = _read_audit(tmp_path) + assert entries[0]["details"]["status"] == STATUS_SMTP_FAILURE + assert entries[0]["details"]["error"] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_smtp_status_failed_returns_smtp_failure(tmp_path): + client = _fake_pm_client(send_result=_failed_send_result()) + result = await send_email_to_operator( + subject="x", body="y", purelymail_client=client + ) + assert result["status"] == STATUS_SMTP_FAILURE + assert result["error"] == "smtp_relay_refused" + + +# =========================================================================== +# Audit shape +# =========================================================================== + + +@pytest.mark.asyncio +async def test_audit_records_sizes_not_content(tmp_path): + client = _fake_pm_client() + body = "the quick brown fox" * 100 + result = await send_email_to_operator( + subject="audit shape test", + body=body, + purelymail_client=client, + ) + assert result["status"] == STATUS_SENT + entries = _read_audit(tmp_path) + assert len(entries) == 1 + details = entries[0]["details"] + assert details["body_chars"] == len(body) + assert details["subject_chars"] == len("audit shape test") + # No body content stored verbatim anywhere. + serialized = json.dumps(entries[0]) + assert "quick brown fox" not in serialized + + +@pytest.mark.asyncio +async def test_audit_records_rejection_reason(tmp_path): + client = _fake_pm_client() + await send_email_to_operator( + subject="", body="y", purelymail_client=client + ) + entries = _read_audit(tmp_path) + assert entries[0]["details"]["status"] == STATUS_REJECTED + assert entries[0]["details"]["rejection_reason"] == REASON_SUBJECT_EMPTY + + +# =========================================================================== +# Tool registry + dispatch integration +# =========================================================================== + + +def test_tool_name_advertised_in_reasoning_available_tools(): + from kora_cli.reasoning.tool_registry import get_reasoning_available_tools + + tools = get_reasoning_available_tools() + names = [t["name"] for t in tools] + assert "kora__send_email_to_operator" in names + # Other-recipient send_email MUST NOT be in the reasoning surface + # (only the operator-pinned variant). + assert "kora__send_email" not in names + + +def test_other_mutating_tools_still_excluded_from_reasoning(): + """Defense-in-depth: the deliberate exclusions from the original + docstring must hold (only kora__send_email_to_operator was + deliberately added; nothing else).""" + from kora_cli.reasoning.tool_registry import REASONING_TOOL_ALLOWLIST + + forbidden = { + "kora__request_state_transition", + "kora__create_sea_ticket", + "kora__send_webhook_test_event", + "kora__send_slack_dm", + "kora__send_email", + "kora__request_pause", + "kora__request_resume", + "kora__request_stop", + "kora__send_test_alert", + } + assert forbidden.isdisjoint(REASONING_TOOL_ALLOWLIST) + + +@pytest.mark.asyncio +async def test_execute_reasoning_tool_dispatches_with_synthetic_caller( + monkeypatch, tmp_path +): + """execute_reasoning_tool must route kora__send_email_to_operator + through ST2_TOOL_DISPATCH with a synthetic Caller whose + actor_kind identifies the reasoning loop.""" + from kora_cli.reasoning.tool_registry import execute_reasoning_tool + + captured = {} + + async def fake_dispatcher(params, caller): + captured["params"] = params + captured["caller"] = caller + from kora_cli.listeners.mcp_tools import SendEmailToOperatorResult + + return SendEmailToOperatorResult(status="sent") + + from kora_cli.listeners.mcp_tools import ST2_TOOL_DISPATCH + + monkeypatch.setitem( + ST2_TOOL_DISPATCH, + "kora__send_email_to_operator", + fake_dispatcher, + ) + + result = await execute_reasoning_tool( + name="kora__send_email_to_operator", + tool_input={"subject": "x", "body": "y"}, + ) + assert result.status == "sent" + assert captured["params"] == {"subject": "x", "body": "y"} + assert captured["caller"].actor_kind == "kora_reasoning_self" + # The synthetic caller's allowed_caps is scoped to JUST the tool + # being called — so no other tool can be invoked via this caller + # if a future executor adds a caller.allows() check. + assert captured["caller"].allowed_caps == frozenset( + {"kora__send_email_to_operator"} + ) + + +@pytest.mark.asyncio +async def test_execute_reasoning_tool_rejects_unknown_tool(): + from kora_cli.reasoning.tool_registry import ( + ReasoningToolNotAllowed, + execute_reasoning_tool, + ) + + with pytest.raises(ReasoningToolNotAllowed): + await execute_reasoning_tool( + name="kora__delete_everything", tool_input={} + )