Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
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
14 changes: 14 additions & 0 deletions kora_cli/audit/jsonl_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions kora_cli/listeners/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
]


Expand All @@ -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,
}
116 changes: 91 additions & 25 deletions kora_cli/reasoning/tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions kora_cli/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Loading