Files
UmbraTrans/agent_comm/server.py
2026-09-24 16:09:23 -07:00

902 lines
38 KiB
Python

from __future__ import annotations
import argparse
import ctypes
import errno
import json
import logging
import os
import re
import stat
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, TextIO
MAX_BODY_BYTES = 64 * 1024
MAX_FILE_BYTES = 128 * 1024
MAX_TITLE_CHARS = 200
MAX_ACK_CHARS = 2_000
MAX_MESSAGES_PER_READ = 100
MAX_INBOX_SCAN = 1_000
MAX_RPC_BYTES = 256 * 1024
SCHEMA_VERSION = 1
SERVER_VERSION = "0.1.0"
AT_FDCWD = -100
RENAME_NOREPLACE = 1
VALID_SENDERS = frozenset({"oldbox", "mac-agent"})
VALID_KINDS = frozenset({"diagnostic", "report", "instruction", "ack"})
MESSAGE_ID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$")
VALID_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
# These patterns are intentionally conservative. They cover common secret-bearing
# assignments and private-key blocks without hiding ordinary authentication prose.
_SECRET_KEYS = (
r"(?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|"
r"client[_-]?secret|secret|authorization|proxy[_-]?authorization|hash|checksum|fingerprint)"
)
_SECRET_ASSIGNMENT_RE = re.compile(
rf"(?i)([\"']?\b{_SECRET_KEYS}\b[\"']?\s*(?::|=|\bis\b)\s*)"
r"(\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)"
)
_AUTH_HEADER_RE = re.compile(r"(?i)\b(?:authorization|proxy[_-]?authorization)\s*[:=]\s*(?:bearer|basic)\s+\S+")
_BEARER_RE = re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9._~+/=-]+")
_URI_CREDENTIAL_RE = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^/\s:@]+:)[^@\s/]+@")
_PRIVATE_KEY_RE = re.compile(
r"-----BEGIN [^-]*(?:PRIVATE|SECRET) KEY(?: BLOCK)?-----.*?"
r"-----END [^-]*(?:PRIVATE|SECRET) KEY(?: BLOCK)?-----",
re.IGNORECASE | re.DOTALL,
)
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
def _rename_noreplace(
old_name: str,
new_name: str,
*,
old_dir_fd: int,
new_dir_fd: int,
) -> None:
"""Atomically rename without replacing an existing destination.
Python 3.12 does not expose renameat2 even on platforms that support it, so
use libc directly and translate its errno values to the standard exceptions.
"""
libc = ctypes.CDLL(None, use_errno=True)
renameat2 = getattr(libc, "renameat2", None)
if renameat2 is None:
raise MailboxError("atomic no-replace rename is unavailable")
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
renameat2.restype = ctypes.c_int
old_bytes = os.fsencode(old_name)
new_bytes = os.fsencode(new_name)
if renameat2(old_dir_fd, old_bytes, new_dir_fd, new_bytes, RENAME_NOREPLACE) != 0:
error = ctypes.get_errno()
if error == errno.EEXIST:
raise FileExistsError(error, os.strerror(error), new_name)
if error == errno.ENOSYS:
raise MailboxError("atomic no-replace rename is unavailable")
raise OSError(error, os.strerror(error), new_name)
class MessageError(ValueError):
"""A message or mailbox operation failed validation."""
class SecretContentError(MessageError):
"""A message was refused because it contains secret-like content."""
class MailboxError(RuntimeError):
"""The configured mailbox is unavailable or unsafe."""
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def contains_secret_pattern(value: str) -> bool:
return bool(
_SECRET_ASSIGNMENT_RE.search(value)
or _AUTH_HEADER_RE.search(value)
or _BEARER_RE.search(value)
or _URI_CREDENTIAL_RE.search(value)
or _PRIVATE_KEY_RE.search(value)
or _JWT_RE.search(value)
)
def reject_secret_content(**values: str) -> None:
for field, value in values.items():
if contains_secret_pattern(value):
raise SecretContentError(f"{field} contains secret-like content")
def redact_text(value: str) -> tuple[str, bool]:
"""Redact common secret forms from a sanitized text field.
This is defense in depth, not a replacement for keeping secrets out of the
queue. The original value is never logged or returned.
"""
redacted = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", value)
redacted = _AUTH_HEADER_RE.sub("[REDACTED AUTH HEADER]", redacted)
redacted = _BEARER_RE.sub(r"\1[REDACTED]", redacted)
redacted = _SECRET_ASSIGNMENT_RE.sub(r"\1[REDACTED]", redacted)
redacted = _URI_CREDENTIAL_RE.sub(r"\1[REDACTED]@", redacted)
redacted = _JWT_RE.sub("[REDACTED TOKEN]", redacted)
return redacted, redacted != value
def _canonical_uuid(value: Any, field: str) -> str:
if not isinstance(value, str):
raise MessageError(f"{field} must be a UUID string")
try:
parsed = uuid.UUID(value)
except (ValueError, AttributeError) as exc:
raise MessageError(f"{field} must be a UUID string") from exc
canonical = str(parsed)
if value != canonical:
raise MessageError(f"{field} must use canonical UUID form")
return canonical
def _validate_timestamp(value: Any) -> str:
if not isinstance(value, str):
raise MessageError("created_at must be an ISO-8601 string")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise MessageError("created_at must be an ISO-8601 string") from exc
if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(None):
raise MessageError("created_at must be UTC")
return value
def _validate_text(value: Any, field: str, limit: int) -> str:
if not isinstance(value, str):
raise MessageError(f"{field} must be a string")
if "\x00" in value:
raise MessageError(f"{field} contains NUL")
if len(value.encode("utf-8")) > limit:
raise MessageError(f"{field} exceeds its size limit")
return value
def validate_message(value: Any) -> dict[str, Any]:
"""Validate one version-1 message without changing it."""
if not isinstance(value, dict):
raise MessageError("message must be a JSON object")
required = {
"schema_version",
"id",
"created_at",
"sender",
"kind",
"title",
"body",
"attachments",
}
allowed = required | {"correlation_id"}
if not required.issubset(value) or not set(value).issubset(allowed):
raise MessageError("message has missing or unsupported fields")
value = dict(value)
value.setdefault("correlation_id", None)
if type(value["schema_version"]) is not int or value["schema_version"] != SCHEMA_VERSION:
raise MessageError("unsupported schema version")
_canonical_uuid(value["id"], "id")
_validate_timestamp(value["created_at"])
if not isinstance(value["sender"], str) or value["sender"] not in VALID_SENDERS:
raise MessageError("invalid sender")
if not isinstance(value["kind"], str) or value["kind"] not in VALID_KINDS:
raise MessageError("invalid kind")
if value["correlation_id"] is not None:
_canonical_uuid(value["correlation_id"], "correlation_id")
_validate_text(value["title"], "title", MAX_TITLE_CHARS)
_validate_text(value["body"], "body", MAX_BODY_BYTES)
if value["attachments"] != []:
raise MessageError("attachments are not supported")
return dict(value)
def sanitize_message(value: dict[str, Any]) -> tuple[dict[str, Any], bool]:
clean = validate_message(value)
reject_secret_content(title=clean["title"], body=clean["body"])
title, title_changed = redact_text(clean["title"])
body, body_changed = redact_text(clean["body"])
clean["title"] = title
clean["body"] = body
# Redaction must not make a message exceed its advertised bounds.
_validate_text(clean["title"], "title", MAX_TITLE_CHARS)
_validate_text(clean["body"], "body", MAX_BODY_BYTES)
return clean, title_changed or body_changed
def _lstat(path: Path) -> os.stat_result:
try:
return os.lstat(path)
except FileNotFoundError as exc:
raise MailboxError(f"mailbox path is missing: {path}") from exc
except OSError as exc:
raise MailboxError(f"cannot inspect mailbox path: {path}") from exc
def _assert_no_symlink_components(path: Path, allow_missing: bool = False) -> None:
if not path.is_absolute():
raise MailboxError("mailbox path must be absolute")
current = Path(path.anchor)
for part in path.parts[1:]:
current /= part
try:
info = os.lstat(current)
except FileNotFoundError:
if allow_missing:
return
raise MailboxError(f"mailbox path is missing: {current}")
except OSError as exc:
raise MailboxError(f"cannot inspect mailbox path: {current}") from exc
if stat.S_ISLNK(info.st_mode):
raise MailboxError(f"symlink path components are refused: {current}")
def _open_directory_chain(path: Path, *, create: bool = False) -> int:
"""Open a directory by descriptor without following symlink components."""
if not path.is_absolute():
raise MailboxError("mailbox path must be absolute")
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(path.anchor or "/", flags)
except OSError as exc:
raise MailboxError("cannot open mailbox root") from exc
try:
for part in path.parts[1:]:
try:
next_fd = os.open(part, flags, dir_fd=fd)
except FileNotFoundError:
if not create:
raise MailboxError("mailbox directory is missing")
try:
os.mkdir(part, mode=0o700, dir_fd=fd)
except FileExistsError:
pass
try:
next_fd = os.open(part, flags, dir_fd=fd)
except OSError as exc:
raise MailboxError("cannot open mailbox directory") from exc
except OSError as exc:
raise MailboxError("cannot open mailbox directory") from exc
info = os.fstat(next_fd)
if not stat.S_ISDIR(info.st_mode):
os.close(next_fd)
raise MailboxError("mailbox path component is not a directory")
os.close(fd)
fd = next_fd
info = os.fstat(fd)
if info.st_mode & 0o077:
os.close(fd)
raise MailboxError("mailbox directory is not private")
return fd
except Exception:
try:
os.close(fd)
except OSError:
pass
raise
def _directory_info(path: Path) -> tuple[bool, bool, bool]:
try:
fd = _open_directory_chain(path, create=False)
except (MailboxError, OSError):
return False, False, False
try:
info = os.fstat(fd)
private = (info.st_mode & 0o077) == 0
return private, os.access(path, os.R_OK), os.access(path, os.W_OK)
finally:
os.close(fd)
def _ensure_directory(path: Path, create: bool) -> None:
_assert_no_symlink_components(path, allow_missing=create)
try:
info = os.lstat(path)
except FileNotFoundError:
if not create:
raise MailboxError(f"mailbox directory is missing: {path}")
try:
path.mkdir(mode=0o700)
os.chmod(path, 0o700)
except OSError as exc:
raise MailboxError(f"cannot create mailbox directory: {path}") from exc
return
except OSError as exc:
raise MailboxError(f"cannot inspect mailbox directory: {path}") from exc
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise MailboxError(f"mailbox path is not a directory: {path}")
if info.st_mode & 0o077:
raise MailboxError(f"mailbox directory is not private: {path}")
def _fsync_directory_fd(fd: int) -> None:
try:
os.fsync(fd)
except OSError:
pass
def _safe_message_name(value: str) -> str:
if not isinstance(value, str) or not MESSAGE_ID_RE.fullmatch(value):
raise MessageError("invalid message filename")
return value
class MessageStore:
"""Atomic, bounded, non-secret JSON mailbox storage."""
def __init__(
self,
root: str | Path,
*,
create: bool = True,
deployment_mode: str = "local",
nas_mount: str | Path | None = None,
nas_confirmed: bool = False,
) -> None:
expanded = os.path.expanduser(str(root))
if not os.path.isabs(expanded):
raise MailboxError("AGENT_COMM_ROOT must be an absolute path")
self.root = Path(os.path.abspath(expanded))
if self.root == Path("/"):
raise MailboxError("refusing to use the filesystem root as mailbox")
if deployment_mode not in {"local", "nas"}:
raise MailboxError("deployment mode must be local or nas")
self.deployment_mode = deployment_mode
self.nas_mount = Path(os.path.abspath(os.path.expanduser(str(nas_mount)))) if nas_mount else None
self.nas_confirmed = bool(nas_confirmed)
if self.deployment_mode == "nas":
if self.nas_mount is None or not self.nas_confirmed:
raise MailboxError("NAS mode requires an explicitly confirmed mount")
if not self.nas_mount.is_dir() or not os.path.ismount(self.nas_mount):
raise MailboxError("confirmed NAS mount is not currently mounted")
try:
self.root.relative_to(self.nas_mount)
except ValueError as exc:
raise MailboxError("mailbox root is outside the confirmed NAS mount") from exc
self.directory_names = ("inbox", "outbox", "processed", "archive")
self.directories = {name: self.root / name for name in self.directory_names}
self.inbox = self.directories["inbox"]
self.outbox = self.directories["outbox"]
self.processed = self.directories["processed"]
self.archive = self.directories["archive"]
if create:
_ensure_directory(self.root, True)
for directory in self.directories.values():
_ensure_directory(directory, True)
else:
_assert_no_symlink_components(self.root, allow_missing=False)
for directory in self.directories.values():
_assert_no_symlink_components(directory, allow_missing=False)
def _open_directory(self, name: str) -> int:
if name not in self.directories:
raise MessageError("unknown mailbox directory")
return _open_directory_chain(self.directories[name], create=False)
def _write_message(self, directory_name: str, message: dict[str, Any]) -> tuple[str, str, bool]:
clean, redacted = sanitize_message(message)
directory_fd = self._open_directory(directory_name)
filename = f"{clean['id']}.json"
_safe_message_name(filename)
temporary_name = f".agent-comm-{uuid.uuid4().hex}.tmp"
file_fd: int | None = None
try:
try:
os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
except FileNotFoundError:
pass
else:
raise MessageError("message ID already exists")
payload = json.dumps(clean, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
if len(payload) > MAX_FILE_BYTES:
raise MessageError("encoded message exceeds its size limit")
file_fd = os.open(
temporary_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o600,
dir_fd=directory_fd,
)
with os.fdopen(file_fd, "wb") as handle:
file_fd = None
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
try:
# renameat2 with NOREPLACE is an atomic, no-clobber publication.
# Unlike os.replace, it refuses to overwrite an existing message.
_rename_noreplace(
temporary_name,
filename,
old_dir_fd=directory_fd,
new_dir_fd=directory_fd,
)
except FileExistsError as exc:
raise MessageError("message ID already exists") from exc
temporary_name = ""
_fsync_directory_fd(directory_fd)
except MessageError:
raise
except OSError as exc:
raise MailboxError("cannot atomically write message") from exc
finally:
if file_fd is not None:
os.close(file_fd)
if temporary_name:
try:
os.unlink(temporary_name, dir_fd=directory_fd)
except FileNotFoundError:
pass
os.close(directory_fd)
return clean["id"], str(self.directories[directory_name] / filename), redacted
def _move_inbox_message(self, source_name: str, target_directory: str) -> None:
"""Atomically move one validated inbox message without overwriting."""
inbox_fd = self._open_directory("inbox")
target_fd = self._open_directory(target_directory)
try:
_safe_message_name(source_name)
try:
os.stat(source_name, dir_fd=inbox_fd, follow_symlinks=False)
except FileNotFoundError as exc:
raise MailboxError("inbox message disappeared") from exc
try:
_rename_noreplace(
source_name,
source_name,
old_dir_fd=inbox_fd,
new_dir_fd=target_fd,
)
except FileExistsError as exc:
raise MailboxError("processed message already exists") from exc
_fsync_directory_fd(target_fd)
_fsync_directory_fd(inbox_fd)
finally:
os.close(inbox_fd)
os.close(target_fd)
def _read_message_file(self, directory_fd: int, filename: str) -> tuple[dict[str, Any], bool]:
_safe_message_name(filename)
try:
info = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
except FileNotFoundError as exc:
raise MailboxError("message file disappeared") from exc
if not stat.S_ISREG(info.st_mode):
raise MailboxError("message file is not regular")
if info.st_mode & 0o077:
raise MailboxError("message file is not private")
if info.st_size > MAX_FILE_BYTES:
raise MessageError("message file exceeds its size limit")
try:
file_fd = os.open(
filename,
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
dir_fd=directory_fd,
)
except OSError as exc:
raise MailboxError("cannot open message file") from exc
try:
opened = os.fstat(file_fd)
if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino):
raise MailboxError("message file changed during validation")
if not stat.S_ISREG(opened.st_mode) or opened.st_mode & 0o077:
raise MailboxError("message file is not a private regular file")
with os.fdopen(file_fd, "rb") as handle:
file_fd = -1
raw = handle.read(MAX_FILE_BYTES + 1)
finally:
if file_fd >= 0:
os.close(file_fd)
if len(raw) > MAX_FILE_BYTES:
raise MessageError("message file exceeds its size limit")
try:
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise MessageError("message is not valid UTF-8 JSON") from exc
return sanitize_message(value)
def status(self) -> dict[str, Any]:
root_private, root_read, root_write = _directory_info(self.root)
directories: dict[str, dict[str, bool]] = {}
counts: dict[str, int] = {}
for name in self.directory_names:
try:
directory_fd = self._open_directory(name)
except (MailboxError, OSError):
directories[name] = {"exists": False, "private": False, "readable": False, "writable": False}
counts[name] = 0
continue
try:
count = 0
with os.scandir(directory_fd) as entries:
for entry in entries:
if entry.name.endswith(".json") and not entry.is_symlink():
count += 1
if count >= MAX_INBOX_SCAN:
break
directories[name] = {"exists": True, "private": True, "readable": True, "writable": True}
counts[name] = count
finally:
os.close(directory_fd)
pending = counts.get("inbox", 0) + counts.get("outbox", 0)
healthy = (
root_private
and root_read
and root_write
and all(item["private"] and item["readable"] and item["writable"] for item in directories.values())
)
return {
"server_version": SERVER_VERSION,
"deployment_mode": self.deployment_mode,
"shared": self.deployment_mode == "nas",
"nas_mount_confirmed": self.nas_confirmed,
"mailbox_path": str(self.root),
"root": {"exists": root_private or root_read or root_write, "private": root_private, "readable": root_read, "writable": root_write},
"directories": directories,
"pending_messages": pending,
"counts": counts,
"healthy": healthy,
"last_health_check": utc_now(),
}
def send_report(self, *, title: str, body: str, correlation_id: str | None = None, kind: str = "report") -> dict[str, Any]:
if not isinstance(kind, str) or kind not in VALID_KINDS or kind == "ack":
raise MessageError("invalid report kind")
checked_title = _validate_text(title, "title", MAX_TITLE_CHARS)
checked_body = _validate_text(body, "body", MAX_BODY_BYTES)
reject_secret_content(title=checked_title, body=checked_body)
clean_title, title_changed = redact_text(checked_title)
clean_body, body_changed = redact_text(checked_body)
reject_secret_content(title=clean_title, body=clean_body)
message = {
"schema_version": SCHEMA_VERSION,
"id": str(uuid.uuid4()),
"created_at": utc_now(),
"sender": "oldbox",
"kind": kind,
"correlation_id": correlation_id,
"title": clean_title,
"body": clean_body,
"attachments": [],
}
if correlation_id is not None:
message["correlation_id"] = _canonical_uuid(correlation_id, "correlation_id")
message_id, path, write_redacted = self._write_message("outbox", message)
return {"id": message_id, "path": path, "queued": True, "redacted": title_changed or body_changed or write_redacted}
def send_ack(self, *, correlation_id: str, text: str) -> dict[str, Any]:
checked_text = _validate_text(text, "text", MAX_ACK_CHARS)
reject_secret_content(text=checked_text)
clean_text, text_changed = redact_text(checked_text)
reject_secret_content(text=clean_text)
message = {
"schema_version": SCHEMA_VERSION,
"id": str(uuid.uuid4()),
"created_at": utc_now(),
"sender": "oldbox",
"kind": "ack",
"correlation_id": _canonical_uuid(correlation_id, "correlation_id"),
"title": "Acknowledgement",
"body": clean_text,
"attachments": [],
}
message_id, path, write_redacted = self._write_message("outbox", message)
return {"id": message_id, "path": path, "queued": True, "redacted": text_changed or write_redacted}
def read_instructions(self, *, limit: int = 20) -> list[dict[str, Any]]:
if type(limit) is not int or not 1 <= limit <= MAX_MESSAGES_PER_READ:
raise MessageError("limit is out of range")
inbox_fd = self._open_directory("inbox")
processed_fd = self._open_directory("processed")
messages: list[dict[str, Any]] = []
try:
candidates: list[str] = []
with os.scandir(inbox_fd) as entries:
for entry in entries:
if len(candidates) >= MAX_INBOX_SCAN:
break
if entry.name.endswith(".json"):
candidates.append(entry.name)
candidates.sort()
for filename in candidates[:limit]:
try:
_safe_message_name(filename)
message, _ = self._read_message_file(inbox_fd, filename)
if message["id"] + ".json" != filename:
raise MessageError("message ID does not match filename")
if message["sender"] != "mac-agent":
raise MessageError("inbox message sender is not trusted")
if message["kind"] != "instruction":
raise MessageError("inbox message is not an instruction")
self._move_inbox_message(filename, "processed")
messages.append(
{
"id": message["id"],
"created_at": message["created_at"],
"sender": message["sender"],
"kind": message["kind"],
"correlation_id": message["correlation_id"],
"title": message["title"],
"body": message["body"],
}
)
except SecretContentError:
# A rejected message is never returned. Remove the raw file
# relative to the already-open directory descriptor.
try:
os.unlink(filename, dir_fd=inbox_fd)
except OSError:
logging.getLogger(__name__).error("Could not remove rejected inbox message")
continue
except (MailboxError, MessageError, OSError) as exc:
logging.getLogger(__name__).warning("Skipped invalid inbox message: %s", type(exc).__name__)
continue
return messages
finally:
os.close(inbox_fd)
os.close(processed_fd)
class AgentCommServer:
"""Minimal MCP JSON-RPC server; it never executes queued instructions."""
def __init__(self, store: MessageStore) -> None:
self.store = store
@staticmethod
def _response(request_id: Any, result: Any) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id, "result": result}
@staticmethod
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
@staticmethod
def _tool_result(value: Any, *, is_error: bool = False) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, sort_keys=True)}],
"isError": is_error,
}
def tool_definitions(self) -> list[dict[str, Any]]:
return [
{
"name": "agent_status",
"description": "Return non-secret mailbox health and message counts.",
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {}},
},
{
"name": "send_report",
"description": "Queue a bounded, sanitized report as an atomic outbox message.",
"inputSchema": {
"type": "object",
"additionalProperties": False,
"required": ["title", "body"],
"properties": {
"title": {"type": "string", "maxLength": MAX_TITLE_CHARS},
"body": {"type": "string", "maxLength": MAX_BODY_BYTES},
"correlation_id": {"type": ["string", "null"]},
"kind": {"type": "string", "enum": ["diagnostic", "report", "instruction"]},
},
},
},
{
"name": "read_instructions",
"description": "Read and move a bounded number of sanitized Mac instructions; never execute them.",
"inputSchema": {
"type": "object",
"additionalProperties": False,
"properties": {"limit": {"type": "integer", "minimum": 1, "maximum": MAX_MESSAGES_PER_READ, "default": 20}},
},
},
{
"name": "send_ack",
"description": "Queue a short acknowledgement for a message correlation ID.",
"inputSchema": {
"type": "object",
"additionalProperties": False,
"required": ["correlation_id", "text"],
"properties": {
"correlation_id": {"type": "string"},
"text": {"type": "string", "maxLength": MAX_ACK_CHARS},
},
},
},
]
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if not isinstance(arguments, dict):
return self._tool_result({"error": "arguments must be an object"}, is_error=True)
try:
if name == "agent_status":
if arguments:
raise MessageError("agent_status takes no arguments")
return self._tool_result(self.store.status())
if name == "send_report":
if set(arguments) - {"title", "body", "correlation_id", "kind"}:
raise MessageError("unsupported send_report argument")
if "title" not in arguments or "body" not in arguments:
raise MessageError("title and body are required")
return self._tool_result(
self.store.send_report(
title=arguments["title"],
body=arguments["body"],
correlation_id=arguments.get("correlation_id"),
kind=arguments.get("kind", "report"),
)
)
if name == "read_instructions":
if set(arguments) - {"limit"}:
raise MessageError("unsupported read_instructions argument")
return self._tool_result({"messages": self.store.read_instructions(limit=arguments.get("limit", 20))})
if name == "send_ack":
if set(arguments) != {"correlation_id", "text"}:
raise MessageError("send_ack requires correlation_id and text")
return self._tool_result(self.store.send_ack(correlation_id=arguments["correlation_id"], text=arguments["text"]))
return self._tool_result({"error": "unknown tool"}, is_error=True)
except (MailboxError, MessageError, OSError, ValueError) as exc:
logging.getLogger(__name__).warning("Tool %s failed: %s", name, type(exc).__name__)
return self._tool_result({"error": "operation rejected or mailbox unavailable"}, is_error=True)
@staticmethod
def _safe_request_id(value: Any) -> str | int | None:
if value is None:
return None
if type(value) is int and -(2**53) < value < 2**53:
return value
if isinstance(value, str) and VALID_REQUEST_ID_RE.fullmatch(value):
return value
return None
def handle_json_rpc(self, message: dict[str, Any]) -> dict[str, Any] | None:
missing = object()
raw_id = message.get("id", missing)
request_id = None if raw_id is missing else self._safe_request_id(raw_id)
if raw_id is not missing and request_id is None and raw_id is not None:
return self._error(None, -32600, "Invalid request id")
if message.get("jsonrpc") != "2.0":
return self._error(request_id, -32600, "Invalid JSON-RPC version")
method = message.get("method")
if not isinstance(method, str):
return self._error(request_id, -32600, "Invalid method")
if method == "initialize":
params = message.get("params") or {}
requested = params.get("protocolVersion") if isinstance(params, dict) else None
supported = {"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}
protocol = requested if requested in supported else "2024-11-05"
return self._response(
request_id,
{
"protocolVersion": protocol,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "agent-comm", "version": SERVER_VERSION},
},
)
if method in {"notifications/initialized", "notifications/cancelled"}:
return None
if method == "ping":
return self._response(request_id, {})
if method == "tools/list":
return self._response(request_id, {"tools": self.tool_definitions()})
if method == "resources/list":
return self._response(request_id, {"resources": []})
if method == "prompts/list":
return self._response(request_id, {"prompts": []})
if method == "tools/call":
params = message.get("params") or {}
name = params.get("name") if isinstance(params, dict) else None
arguments = params.get("arguments", {}) if isinstance(params, dict) else {}
if not isinstance(name, str) or not isinstance(arguments, dict):
return self._error(request_id, -32602, "Invalid tools/call parameters")
return self._response(request_id, self.call_tool(name, arguments))
if request_id is not None:
return self._error(request_id, -32601, "Method not found")
return None
def serve(self, input_stream: TextIO = sys.stdin, output_stream: TextIO = sys.stdout) -> None:
binary_stream = getattr(input_stream, "buffer", None)
def process(line: bytes | str) -> dict[str, Any] | None:
if isinstance(line, bytes):
if len(line) > MAX_RPC_BYTES:
return self._error(None, -32600, "Request too large")
try:
line = line.decode("utf-8")
except UnicodeDecodeError:
return self._error(None, -32700, "Parse error")
elif len(line.encode("utf-8")) > MAX_RPC_BYTES:
return self._error(None, -32600, "Request too large")
if not line.strip():
return None
try:
value = json.loads(line)
if not isinstance(value, dict):
raise ValueError
return self.handle_json_rpc(value)
except (json.JSONDecodeError, ValueError):
return self._error(None, -32700, "Parse error")
except Exception:
return self._error(None, -32603, "Internal error")
if binary_stream is not None:
while True:
line = binary_stream.readline(MAX_RPC_BYTES + 1)
if not line:
break
if len(line) > MAX_RPC_BYTES:
while line and not line.endswith(b"\n"):
line = binary_stream.readline(MAX_RPC_BYTES + 1)
response = self._error(None, -32600, "Request too large")
else:
response = process(line)
if response is not None:
output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
output_stream.flush()
return
while True:
line = input_stream.readline(MAX_RPC_BYTES + 1)
if not line:
break
if len(line.encode("utf-8")) > MAX_RPC_BYTES:
while line and not line.endswith("\n"):
line = input_stream.readline(MAX_RPC_BYTES + 1)
response = self._error(None, -32600, "Request too large")
else:
response = process(line)
if response is not None:
output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
output_stream.flush()
def _configured_root() -> str | None:
return os.environ.get("AGENT_COMM_ROOT")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Local stdio MCP server for sanitized agent messages")
parser.add_argument("--root", default=_configured_root(), help="absolute mailbox root (or set AGENT_COMM_ROOT)")
parser.add_argument("--mode", choices=("local", "nas"), default=os.environ.get("AGENT_COMM_MODE"))
parser.add_argument("--nas-mount", default=os.environ.get("AGENT_COMM_NAS_MOUNT"))
parser.add_argument("--nas-confirmed", action="store_true", default=os.environ.get("AGENT_COMM_NAS_CONFIRMED") == "1")
parser.add_argument("--check", action="store_true", help="perform read-only mailbox checks")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.WARNING, stream=sys.stderr, format="agent-comm: %(levelname)s: %(message)s")
if not args.root:
print("agent-comm startup failed: AGENT_COMM_ROOT must be set explicitly", file=sys.stderr)
return 1
if not args.mode:
print("agent-comm startup failed: AGENT_COMM_MODE must be explicitly set to local or nas", file=sys.stderr)
return 1
try:
store = MessageStore(
args.root,
create=not args.check,
deployment_mode=args.mode,
nas_mount=args.nas_mount,
nas_confirmed=args.nas_confirmed,
)
if args.check:
status_value = store.status()
print(json.dumps(status_value, ensure_ascii=False, sort_keys=True))
return 0 if status_value["healthy"] else 1
AgentCommServer(store).serve()
return 0
except (MailboxError, MessageError) as exc:
print(f"agent-comm startup failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())