Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

KR-EMAIL-OUTBOUND-COMPOSE-TOOL — Kora can email operator (R3 Q8a outbound)#179

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-EMAIL-OUTBOUND-COMPOSE-TOOL
May 24, 2026
Merged

KR-EMAIL-OUTBOUND-COMPOSE-TOOL — Kora can email operator (R3 Q8a outbound)#179
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-EMAIL-OUTBOUND-COMPOSE-TOOL

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

R3 Q8a's "the other way mostly, like 'Kora, email me that pdf etc.'" — the outbound counterpart to PR #176's inbound Sea_Ticket save. Kora's reasoning engine now has a tool to compose and send an email to the operator when the response is too long, structured, or attachment-heavy for Slack DM.

Bucket spec: 17_cc_bucket_prompts/KR-EMAIL-OUTBOUND-COMPOSE-TOOL_kora_emails_operator.md.

Cap_matrix coord — RESOLVED INLINE ✅

The spec's STOP-ASK condition ("cap_matrix.kora row needs substrate-team coordination") does not apply — cap_matrix is Kora-side config (per-caller allowed_caps in mcp_callers.yaml + per-tool requires_cap_gate flag), not a substrate-side table.

  • The new tool kora__send_email_to_operator ships with requires_cap_gate: True — external MCP callers are default-deny; operators can grant the cap per-caller via mcp_callers.yaml if they ever want to.
  • Kora's own reasoning loop bypasses cap_matrix entirely via the in-process REASONING_TOOL_ALLOWLIST in kora_cli/reasoning/tool_registry.py. This PR adds the tool to that allowlist alongside the 5 existing read-only tools.

No substrate-team coord needed. No IsoKron PM message required.

Security: why recipient pinning lets this enter the reasoning loop

The original tool_registry.py docstring deliberately excluded kora__send_email from the reasoning surface ("Kora already responds via the Slack DM channel; meta-sends would be confusing + Loop-risky") — the real underlying risk was caller-controlled recipients opening a mass-send vector. kora__send_email_to_operator neutralizes this: the executor itself pins recipient to KORA_EMAIL_JOSHUA_ADDRESS; the tool input schema has no to field. Loop-risk is addressed by KORA_EMAIL_OUTBOUND_HOURLY_CAP (default 5/hr).

The deliberate scope expansion is documented in the updated tool_registry.py module docstring + the system prompt's mutation-boundary section. The other mutating tools (request_state_transition, create_sea_ticket, send_slack_dm, send_email, request_pause/resume/stop, send_test_alert) stay excluded — see test_allowlist_excludes_caller_controlled_mutating_tools.

Env vars added

Env Default Purpose
KORA_EMAIL_OUTBOUND_HOURLY_CAP 5 Sliding-window per-hour cap on successful sends. 0 disables.
KORA_EMAIL_OUTBOUND_MAX_ATTACH_MB 20 Total combined attachment size cap. Below Purelymail's 25 MB SMTP ceiling for safety margin.
(existing) KORA_EMAIL_JOSHUA_ADDRESS Recipient — pinned by the executor; never caller-controlled. Reused from PR #173.
(existing) KORA_PUREMAIL_SMTP_USERNAME from_addr — read by the executor.

Sample audit entry

Success path:

{
  "emitted_at": "2026-05-23T22:14:09.412000+00:00",
  "seam": "tool.email_to_operator_sent",
  "details": {
    "subject_chars": 24,
    "body_chars": 1841,
    "attachment_count": 1,
    "attachment_total_bytes": 184132,
    "status": "sent",
    "smtp_message_id": "<20260523221409.kora@stormhavenenterprises.com>",
    "sent_at": "2026-05-23T22:14:09Z"
  },
  "caller_session_id": "mcp:kora_reasoning_self",
  "source": "reasoning"
}

Rejection path:

{
  "emitted_at": "...",
  "seam": "tool.email_to_operator_sent",
  "details": {
    "subject_chars": 220,
    "body_chars": 1841,
    "attachment_count": 0,
    "status": "rejected",
    "rejection_reason": "subject_too_long",
    "rejection_detail": {"subject_chars": 220, "max_chars": 200}
  },
  "caller_session_id": "mcp:kora_reasoning_self",
  "source": "reasoning"
}

Body content never appears in audit — only sizes. Recipient field absent (always operator).

Sample reasoning-engine invocation

When Joshua DMs "kora email me a summary of today's probe activity with the audit JSONL attached", Kora's reasoning loop would emit a tool_use block like:

{
  "type": "tool_use",
  "name": "kora__send_email_to_operator",
  "input": {
    "subject": "[Kora] Probe activity summary — 2026-05-23",
    "body": "Here's today's rundown:\n\n- 4 probes ran...\n\n(full audit attached)",
    "attachments": [
      {"filename": "audit-2026-05-23.jsonl", "content_path": "/var/lib/kora/audit-export.jsonl"}
    ]
  }
}

Tool result returned to Claude:

{
  "status": "sent",
  "smtp_message_id": "<...>",
  "sent_at": "2026-05-23T22:14:09Z",
  "attachment_count": 1,
  "attachment_total_bytes": 184132
}

Files

  • NEW kora_cli/tools/__init__.py + kora_cli/tools/email_to_operator.py (~480 lines)
  • NEW tests/kora_cli/tools/test_email_to_operator.py (22 tests)
  • MOD kora_cli/listeners/mcp_tools.py — new tool descriptor + dispatcher (thin wrapper around the pure-Python module)
  • MOD kora_cli/reasoning/tool_registry.py — add to allowlist + ST2 dispatch path with synthetic Caller + pull from both descriptor lists
  • MOD kora_cli/audit/jsonl_sink.py — new tool.email_to_operator_sent SeamName Literal entry
  • MOD kora_docs/00_canonical_current_state/kora_system_prompt.md — usage guidance + mutation-boundary exception note
  • MOD tests/kora_cli/reasoning/test_anthropic_engine_tool_use.py — bump 5→6 tool count assertions; broaden allowlist-exclusion test to cover all forbidden mutating tools

Test plan

  • 22 tools-module tests pass: recipient pinning, all validation rejections, attachments (happy + missing file + size cap), hourly cap (3 → 4th rejected), SMTP exception + SendResult-failed paths, audit shape (no body content), registry integration (tool advertised, synthetic Caller dispatch, other mutating tools still excluded).
  • Regression: 353 passed across tools + mcp_tools + audit + handlers + intent + reasoning.
  • ruff check clean on all changed files.

🤖 Generated with Claude Code

…(R3 Q8a outbound)

Completes the email-surface story: R3 Q8a's *"the other way
mostly, like 'Kora, email me that pdf etc.'"*. Companion to PR
#176 (inbound → Sea_Ticket).

New module
----------
`kora_cli/tools/email_to_operator.py`:
  * `send_email_to_operator(subject, body, attachments)` — pure
    Python orchestrator. Recipient PINNED to
    `KORA_EMAIL_JOSHUA_ADDRESS` (executor itself; never caller-
    controlled). Reads attachment files off disk into the
    existing `Attachment` shape; PurelymailClient handles SMTP +
    its own attachment caps.
  * Hourly cap module-level deque (mirrors KR-INTENT-EMAIL-TO-
    SEA-TICKET's pattern); default 5/hr.
  * Per-rejection wire-stable reason codes
    (recipient_env_unset / subject_too_long / etc.)
  * Audit-soft, fail-soft — every failure path returns a
    structured dict; the reasoning engine sees `status` +
    `reason` / `error` and adapts.

MCP wiring
----------
`kora_cli/listeners/mcp_tools.py`:
  * New ST2 tool `kora__send_email_to_operator` descriptor +
    `_dispatch_send_email_to_operator` that thinly wraps the
    pure-Python module. `requires_cap_gate: True` for external
    callers (default-deny via cap_matrix); Kora's reasoning loop
    bypasses cap_matrix via the reasoning-allowlist (below).

Reasoning-loop scope expansion
------------------------------
`kora_cli/reasoning/tool_registry.py`:
  * Added `kora__send_email_to_operator` to
    `REASONING_TOOL_ALLOWLIST` — the FIRST mutating tool to enter
    the reasoning surface. Module docstring documents the
    deliberate scope expansion: the original exclusion of
    `kora__send_email` was driven by mass-send-via-caller-
    recipient risk + Loop-risk; the operator-pinned variant
    neutralizes both (recipient locked, hourly cap).
  * `execute_reasoning_tool` now routes the allowlisted mutating
    tool through `ST2_TOOL_DISPATCH` with a synthetic
    `Caller(actor_kind="kora_reasoning_self",
    allowed_caps=frozenset({tool_name}))` — minimal cap so a
    future caller.allows() check fails closed for any other tool.
  * `get_reasoning_available_tools()` now pulls from both
    `TOOL_DESCRIPTORS` and `ST2_TOOL_DESCRIPTORS` so the new
    tool is advertised to Claude.

Audit
-----
New seam `tool.email_to_operator_sent` (next to
`probe.wake_requested` + `intent.email_to_sea_ticket`). One entry
per invocation with wire-stable `status` ∈ {sent, rejected,
smtp_failure}. Body content never in audit (only sizes); recipient
not needed (always operator).

System prompt
-------------
`kora_docs/00_canonical_current_state/kora_system_prompt.md` —
added the tool to the "Tool use" section with usage guidance + an
exception note in the mutation-boundary section.

Cap_matrix coord
----------------
Resolved inline. cap_matrix is Kora-side config (per-caller
`allowed_caps` in mcp_callers.yaml), NOT a substrate-side table.
External MCP callers see the new tool but cap-gated default-deny;
operator can grant the cap per-caller if needed. Kora's own
reasoning loop bypasses cap_matrix via the in-process
REASONING_TOOL_ALLOWLIST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit 62aa32b into feature/phase2-upgrades May 24, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-EMAIL-OUTBOUND-COMPOSE-TOOL branch May 24, 2026 04:27
rafe-walker added a commit that referenced this pull request May 24, 2026
…n completion) (#182)

Unified-operator-interface vision completion. Kora's investigation can now include 'I tried restart_machine on i-abc; result: success; new health state: ok' instead of just 'recommend you rotate the token.'

2nd mutating tool in REASONING_TOOL_ALLOWLIST (documented as 'Deliberate scope expansion #2'). Blast-radius bounded by 3 fail-CLOSED gates: per-probe env + envelope whitelist + per-probe executor target verification.

Zero envelopes ship enabled by default. fly restart_machine wired but KORA_PROBE_AUTOFIX_FLY_ENABLED defaults OFF. Executor re-reads env on EVERY call (mid-investigation env-flip closes the gate). supabase/vercel/sentry/doppler envelopes remain explicitly empty.

DM differentiation: enabled path includes 'tried/before/after' outcome; disabled path includes the exact manual flyctl command + how to enable. Operator-empathetic engineering.

Reason field recorded VERBATIM (unlike #179's body redaction) — operator triage of 'what did Kora decide and why' is primary use case.

New audit seam tool.probe_autofix_attempted with status enum {attempted, rejected, execution_failed}.

24 new autofix tests + 317 cross-bucket regression + ruff clean.
rafe-walker added a commit that referenced this pull request May 24, 2026
…eam in cockpit (#183)

Symmetric to PR #180. Surfaces tool.email_to_operator_sent audit seam from #179. Per-seam endpoint /api/outbound-email/recent + page OutboundEmailLogPage.tsx. 3-source drift-guard pin for status enum.

K-DG drift caught + privacy reality adopted: spec said 'subject CAN be shown' but #179 was MORE privacy-conscious than spec claimed — audit carries only subject_chars / body_chars (lengths), NOT subject string / body text / recipient. Stricter reality adopted. Pinned by test_privacy_response_never_contains_subject_or_body_text + test_page_does_not_render_subject_or_body_text.

Shared-utility extraction flagged: 5 overlapping helpers/components with PR #180 (Sparkline / SummaryChips / FilterChips / formatters / BadgeTone type). Recommend follow-on KR-FE-AUDIT-PANEL-KIT-REFACTOR (3-consumer threshold — covered by the next bucket).

23/23 new tests + 47/47 combined with #180 + tsc + vite build clean.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant