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
35 changes: 31 additions & 4 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,43 @@ primary_region = "iad"
[[services]]
protocol = "tcp"
internal_port = 9119
# No public ports per R2 §5 — the admin web UI is operator-only via
# `flyctl proxy 9119:9119 -a kora-runtime`. Control-plane traffic is
# substrate-mediated; this service block exists only to declare the
# internal port so the TCP healthcheck can reach the dashboard.
# No public ports per R2 §5 (control-plane) — the admin web UI is
# operator-only via `flyctl proxy 9119:9119 -a kora-runtime`.
# Control-plane traffic is substrate-mediated; this service block
# exists only to declare the internal port so the TCP healthcheck
# can reach the dashboard.

[[services.tcp_checks]]
interval = "30s"
timeout = "5s"
grace_period = "30s"

# Webhook ingress — PUBLIC port 9118 (KR-D-DAEMON ST3).
# Authorized by the R2 §5 amendment in
# kora_docs/00_canonical_current_state/r2_amendments.md (2026-05-22):
# "control-plane stays internal-only; HMAC-verified webhook plane may
# be public on a dedicated port." HMAC is the auth boundary; per-IP
# rate limiting (slowapi) caps flood blast radius.
[[services]]
protocol = "tcp"
internal_port = 9118

[[services.ports]]
port = 443
handlers = ["tls", "http"]

[[services.ports]]
port = 80
handlers = ["http"]
force_https = true

[[services.http_checks]]
interval = "30s"
timeout = "5s"
grace_period = "30s"
method = "get"
path = "/healthz"

[deploy]
release_command = "hermes doctor"

Expand Down
9 changes: 6 additions & 3 deletions kora_cli/listeners/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Daemon listeners — KR-D-DAEMON ST2.
"""Daemon listeners — KR-D-DAEMON ST2 + ST3.

Importing this package triggers import of each listener module, each
of which calls ``register_daemon_listener`` at module-import time so
the daemon coordinator finds them when it walks ``LISTENER_REGISTRY``.

Order matters for startup (FIFO) — heartbeat first (pure asyncio, no
external dependencies), then web (binds uvicorn on 9119 internal),
then mcp (routes already mounted on the web app by import time; the
listener startup just affirms the bearer token is configured).
then mcp (routes mounted on the web app by import time; the
listener startup just affirms the bearer token is configured), then
webhooks (second uvicorn on 9118 PUBLIC — KR-D-DAEMON ST3, per the
R2 §5 amendment).
"""

from __future__ import annotations
Expand All @@ -16,3 +18,4 @@
from kora_cli.listeners import heartbeat # noqa: F401
from kora_cli.listeners import web # noqa: F401
from kora_cli.listeners import mcp # noqa: F401
from kora_cli.listeners import webhooks # noqa: F401
124 changes: 124 additions & 0 deletions kora_cli/listeners/webhook_dead_letter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Webhook dead-letter logging (KR-D-DAEMON ST3).

Records inbound webhook verification failures in a way the operator
can audit. Per the bucket spec the persistence target was
``kora_operation_ledger``, but that table's schema (per migration
0093) requires ``work_attempt_id`` (FK to ``work_attempts``,
NOT NULL) + ``workspace_id`` + ``ticket_id`` + ``tool_name`` — all
tied to Sea_Ticket dispatch. A webhook arrives with none of those.
Adding an ``op_kind`` column + relaxing the FKs is a substrate-side
schema change, out of scope for this ST.

# What this module does today (scaffold)

Structured log line at WARNING level with a stable
``[kora.webhook.dead_letter]`` prefix + the metadata operators need
to investigate (source, reason, request_id, header summary, peer
IP, timestamp). NO body content — bodies could carry user data, and
the spec is explicit: headers-only.

Operators investigate via the existing log-streaming surface
(OPS-PANEL chain-event tail, or ``flyctl logs``). When the substrate
team ships either:
- a ``webhook_dead_letters`` table, OR
- a permissive ``kora_operation_ledger`` shape that admits non-
work-attempt rows, OR
- a ``kora.webhook.dead_letter`` chain-event vocab literal
… this module's body extends to write durable records too. The
log-line API is the stable seam.

# Why structured logging instead of `kora__append_event` today

Calling ``kora__append_event`` requires a substrate-side vocab
literal under PG's CHECK constraint on ``event_log.event_type``.
``kora.webhook.dead_letter`` is not in the
``foundation/0159_*`` vocab migration as of feature/phase2-upgrades
HEAD. A vocab-migration PR is the cleanest substrate-side path;
the runtime adapts when it lands.
"""

from __future__ import annotations

import logging
import time
from typing import Mapping, Optional

logger = logging.getLogger(__name__)


# Header allow-list — only these are logged, even if the request
# carries more. Keeps the dead-letter record compact + ensures we
# never accidentally log Authorization / Cookie / signature secrets.
_LOGGED_HEADERS = frozenset(
[
"content-type",
"content-length",
"user-agent",
"x-slack-request-timestamp",
"x-forwarded-for",
"x-real-ip",
# We deliberately log the PRESENCE of signature headers but
# truncate the value to first 12 chars (see _summarize_headers).
"x-slack-signature",
"x-purelymail-signature",
]
)


def _summarize_headers(headers: Mapping[str, str]) -> dict:
"""Filter to allow-list + truncate signature values."""
out: dict[str, str] = {}
for name, value in headers.items():
lname = name.lower()
if lname not in _LOGGED_HEADERS:
continue
if "signature" in lname and value:
# Log presence + a short prefix to support
# "which-version-of-the-secret" triage without leaking
# the full HMAC.
out[lname] = value[:12] + "...(truncated)"
else:
out[lname] = value
return out


def emit_webhook_dead_letter(
*,
source: str,
reason: str,
headers: Mapping[str, str],
peer_ip: Optional[str] = None,
request_id: Optional[str] = None,
body_bytes: Optional[int] = None,
now: Optional[float] = None,
) -> None:
"""Record a webhook dead-letter event.

Args:
source: One of ``"slack"`` / ``"email"`` — identifies which
verifier rejected the request.
reason: Machine-readable code from
:class:`VerificationOutcome.reason` (e.g.
``"slack_signature_mismatch"``).
headers: Mapping of header name → value. Only the allow-list
is logged; signature values are truncated.
peer_ip: Best-effort client IP (FastAPI ``request.client.host``).
request_id: Optional correlation id (e.g.
``X-Request-ID`` header) — propagated for downstream triage.
body_bytes: Size of the request body in bytes. Captured for
operator visibility into "was it a junk-empty hit or a
crafted forgery attempt". NEVER captures body content.
now: Injectable timestamp for tests. Defaults to ``time.time()``.
"""
ts = now if now is not None else time.time()
logger.warning(
"[kora.webhook.dead_letter] source=%s reason=%s ts=%.3f "
"peer_ip=%s request_id=%s body_bytes=%s headers=%s",
source,
reason,
ts,
peer_ip or "-",
request_id or "-",
body_bytes if body_bytes is not None else "-",
_summarize_headers(headers),
)
144 changes: 144 additions & 0 deletions kora_cli/listeners/webhook_signing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""HMAC verification helpers for inbound webhooks (KR-D-DAEMON ST3).

Pure functions — no FastAPI, no logging, no side effects. Lets the
verifiers be unit-tested with vector inputs + reused across routes.

# Slack

Slack signs requests with a v0 scheme:
- header ``X-Slack-Signature``: ``v0=<HMAC-SHA256-hex>``
- header ``X-Slack-Request-Timestamp``: unix seconds
- base string: ``v0:<timestamp>:<raw_body>``
- HMAC-SHA256 with the app's signing secret over the base string
- reject if timestamp older than ``SLACK_TIMESTAMP_TOLERANCE_SECONDS``
(5 minutes, replay protection)

# Purelymail

The actual Purelymail webhook signing scheme is NOT well-documented
publicly. The bucket spec asked us to research + flag any divergence
from "shared secret HMAC-SHA256 over body". Implementation here is
the conservative default — operator MUST verify against Purelymail's
current docs at integration time + adjust header name / encoding as
needed.

Default assumption:
- header ``X-Purelymail-Signature``: ``sha256=<HMAC-SHA256-hex>``
OR a plain hex digest (we accept both for forward-compat)
- HMAC-SHA256 with the shared secret over the raw body bytes

Flag pinned in the PR body + the R2 amendment doc — when Feature 3
lands real handler logic, this verifier may need a one-line update
to match Purelymail's actual scheme.
"""

from __future__ import annotations

import hashlib
import hmac
import time
from dataclasses import dataclass


SLACK_TIMESTAMP_TOLERANCE_SECONDS: int = 5 * 60 # 5 minutes


@dataclass(frozen=True, slots=True)
class VerificationOutcome:
"""Result of verifying a webhook signature.

``ok`` is the bottom-line accept/reject. ``reason`` is a short
machine-readable code suitable for inclusion in the dead-letter
record (no user content; no secrets).
"""

ok: bool
reason: str


# ---------------------------------------------------------------------------
# Slack
# ---------------------------------------------------------------------------


def verify_slack_signature(
*,
signing_secret: str,
raw_body: bytes,
signature_header: str | None,
timestamp_header: str | None,
now: float | None = None,
tolerance_seconds: int = SLACK_TIMESTAMP_TOLERANCE_SECONDS,
) -> VerificationOutcome:
"""Verify a Slack Events webhook signature.

Returns ``VerificationOutcome(ok=False, reason="...")`` on any
failure path — the caller maps to the appropriate HTTP status
(401 for signature mismatch, 408 for timestamp drift).

``now`` is injectable for tests; defaults to ``time.time()``.
"""
if not signing_secret:
return VerificationOutcome(False, "slack_secret_unset")
if not signature_header:
return VerificationOutcome(False, "slack_signature_missing")
if not timestamp_header:
return VerificationOutcome(False, "slack_timestamp_missing")

try:
ts = int(timestamp_header)
except (TypeError, ValueError):
return VerificationOutcome(False, "slack_timestamp_malformed")

now_ts = now if now is not None else time.time()
if abs(now_ts - ts) > tolerance_seconds:
return VerificationOutcome(False, "slack_timestamp_too_old")

# Slack's v0 scheme.
if not signature_header.startswith("v0="):
return VerificationOutcome(False, "slack_signature_bad_scheme")
expected_digest = signature_header[len("v0=") :]

base_string = f"v0:{ts}:".encode("utf-8") + raw_body
computed = hmac.new(
signing_secret.encode("utf-8"), base_string, hashlib.sha256
).hexdigest()

if not hmac.compare_digest(computed, expected_digest):
return VerificationOutcome(False, "slack_signature_mismatch")
return VerificationOutcome(True, "ok")


# ---------------------------------------------------------------------------
# Purelymail (default assumption — see module docstring)
# ---------------------------------------------------------------------------


def verify_purelymail_signature(
*,
signing_secret: str,
raw_body: bytes,
signature_header: str | None,
) -> VerificationOutcome:
"""Verify an inbound-email webhook signature using the conservative
default scheme (HMAC-SHA256 over raw body).

Accepts both ``sha256=<hex>`` and bare hex digest in the header
for forward compatibility — Purelymail's exact format may vary.
"""
if not signing_secret:
return VerificationOutcome(False, "purelymail_secret_unset")
if not signature_header:
return VerificationOutcome(False, "purelymail_signature_missing")

presented = signature_header.strip()
if presented.startswith("sha256="):
presented = presented[len("sha256=") :]

computed = hmac.new(
signing_secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()

if not hmac.compare_digest(computed, presented):
return VerificationOutcome(False, "purelymail_signature_mismatch")
return VerificationOutcome(True, "ok")
Loading