902 lines
38 KiB
Python
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())
|