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
434 changes: 434 additions & 0 deletions kora_cli/clients/kora_control_writer.py

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions kora_cli/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import signal
import sys
import time
import uuid
from dataclasses import dataclass, field
from typing import Awaitable, Callable, List, Optional

Expand Down Expand Up @@ -149,6 +150,14 @@ def __init__(self) -> None:
# ``None`` until startup completes; read by ``get_status()`` for
# uptime computation. KR-D-DAEMON ST2 (kora__daemon_status).
self._startup_completed_at: Optional[float] = None
# KR-MCP-STOP-CONTROL ST2 — process-stable session id surfaced
# via get_status() so MCP callers can echo it back as the
# confirm_token on kora__request_stop. Re-generated on each
# coordinator construction; constant for the daemon's lifetime.
# Prevents a stale caller from replaying a stop request
# against a different daemon instance (their cached
# session_id won't match the new boot's value).
self._daemon_session_id: str = uuid.uuid4().hex

# ------------------------------------------------------------------
# Registration
Expand Down Expand Up @@ -313,8 +322,19 @@ def get_status(self) -> dict:
"uptime_seconds": uptime,
"shutdown_reason": self._shutdown_reason,
"listeners": listeners,
"daemon_session_id": self._daemon_session_id,
}

@property
def daemon_session_id(self) -> str:
"""Per-process stable session id (hex uuid4).

Echoed back by callers as the ``confirm_token`` on
``kora__request_stop`` to bind the stop request to a specific
daemon instance. Changes only across process restarts.
"""
return self._daemon_session_id

# ------------------------------------------------------------------
# Signal handling
# ------------------------------------------------------------------
Expand Down
35 changes: 29 additions & 6 deletions kora_cli/listeners/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ def _resolve_caller_dep(
ST2_TOOL_DISPATCH as _ST2_DISPATCH,
TOOL_DESCRIPTORS as _ST1_DESCRIPTORS,
TOOL_DISPATCH as _ST1_DISPATCH,
_ST2_ActorIdRequired, # noqa: F401
_ST2_DevOnlyError, # noqa: F401
_ST2_ToolInputError, # noqa: F401
)
Expand Down Expand Up @@ -213,6 +214,7 @@ def _execute_daemon_status() -> Dict[str, Any]:
"uptime_seconds": None,
"shutdown_reason": None,
"listeners": [],
"daemon_session_id": None,
}
return coord.get_status()

Expand Down Expand Up @@ -368,6 +370,22 @@ async def post_jsonrpc(
return _jsonrpc_error(
req_id, -32602, f"invalid params: {exc}"
)
except _ST2_ActorIdRequired as exc:
# KR-MCP-STOP-CONTROL ST2 — distinct -32001 code from
# capability_denied. The cap may be granted but the
# caller still lacks the actor_id field needed for
# substrate attribution. Operator-fix path is in the
# error message.
return _jsonrpc_error(
req_id,
-32001,
"actor_id_required_for_stop",
data={
"caller_actor_kind": caller.actor_kind,
"tool": tool_name,
"remediation": str(exc),
},
)
except Exception as exc:
logger.exception(
"[mcp] tool %s raised %r", tool_name, exc
Expand Down Expand Up @@ -397,12 +415,17 @@ def _jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:
return {"jsonrpc": "2.0", "id": req_id, "result": result}


def _jsonrpc_error(req_id: Any, code: int, message: str) -> Dict[str, Any]:
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": code, "message": message},
}
def _jsonrpc_error(
req_id: Any,
code: int,
message: str,
*,
data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
error: Dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": req_id, "error": error}


def _execute_daemon_status_text() -> str:
Expand Down
54 changes: 53 additions & 1 deletion kora_cli/listeners/mcp_caller_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
callers:
- token_hash: "sha256:abc123..." # sha256 of bearer token, hex
actor_kind: "claude_pm_isokron" # for audit logging
actor_id: "0fd2c8ee-..." # OPTIONAL — UUID in substrate
# actor_registry. Required for
# tools that write substrate
# rows attributed to the caller
# (e.g. kora__request_stop).
# Omit for read-only callers.
allowed_caps:
- kora__create_sea_ticket
- kora__request_state_transition
Expand Down Expand Up @@ -64,6 +70,7 @@
import hmac
import logging
import os
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
Expand All @@ -87,13 +94,24 @@ class Caller:
exact-match set of tool names the caller may invoke; a tool name
NOT in this set is denied at dispatch time.

``actor_id`` is the caller's UUID in substrate ``actor_registry`` —
populated when the caller needs substrate-write attribution (e.g.
``kora__request_stop`` writes a ``kora_control`` row, and the
``issue_kora_control`` SECDEF requires ``p_issuer_actor_id UUID``
that resolves in actor_registry, belongs to the workspace, and is
NOT ``actor_kind='kora'``). Optional + defaults to ``None`` —
callers that only invoke tools needing actor_kind attribution
don't need to populate it. Tools that require actor_id resolve to
``-32001 actor_id_required_for_<tool>`` when ``None``.

For Mode 1 (legacy env-token) callers, ``actor_kind`` is the
sentinel ``"anonymous"`` and ``allowed_caps`` is empty. Mode 1
callers cannot pass ``requires_cap_gate=True`` checks.
"""

actor_kind: str
allowed_caps: frozenset[str] = field(default_factory=frozenset)
actor_id: Optional[str] = None

def can_invoke(self, tool_name: str) -> bool:
"""Allow iff the tool is in ``allowed_caps`` (exact match)."""
Expand Down Expand Up @@ -230,8 +248,42 @@ def load_callers(
allowed_caps = frozenset(
c for c in allowed_caps_raw if isinstance(c, str)
)

# actor_id is optional. When present, it must parse as a UUID
# (substrate's actor_registry stores UUIDs). A malformed value
# is a config error — SKIP the caller fail-CLOSED rather than
# admit them with actor_id=None and risk silent attribution
# drift on substrate writes.
actor_id_raw = entry.get("actor_id")
actor_id: Optional[str] = None
if actor_id_raw is not None:
if not isinstance(actor_id_raw, str):
logger.warning(
"[mcp_caller_auth] %s caller[%d] actor_id must be a "
"string when present; skipping caller",
target,
idx,
)
continue
try:
# Normalize to canonical string form. uuid.UUID accepts
# both hyphenated + unhyphenated; we store the canonical
# form so downstream comparisons are byte-stable.
actor_id = str(uuid.UUID(actor_id_raw))
except ValueError:
logger.warning(
"[mcp_caller_auth] %s caller[%d] actor_id %r is not "
"a valid UUID; skipping caller",
target,
idx,
actor_id_raw,
)
continue

callers[token_hash] = Caller(
actor_kind=actor_kind, allowed_caps=allowed_caps
actor_kind=actor_kind,
allowed_caps=allowed_caps,
actor_id=actor_id,
)

_callers_cache[cache_key] = callers
Expand Down
Loading