Bootstrap UmbraTrans agent communication workspace
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Non-secret development settings only.
|
||||||
|
UMBRA_ENV=development
|
||||||
|
UMBRA_WORKSPACE_ROOT=/home/umbra/src/UmbraTrans
|
||||||
|
UMBRA_DEVELOPMENT_DATA_ROOT=/home/umbra/src/UmbraTrans/.local/data/development
|
||||||
|
AGENT_COMM_MODE=nas
|
||||||
|
AGENT_COMM_NAS_MOUNT=/run/user/1002/gvfs
|
||||||
|
AGENT_COMM_NAS_CONFIRMED=1
|
||||||
|
AGENT_COMM_ROOT=/run/user/1002/gvfs/smb-share:server=nas2.local,share=netbackup/UmbraTrans/agent-mailbox
|
||||||
|
# Production values are intentionally not defined in this development template.
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Python and local state
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.local/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Secrets and local configuration
|
||||||
|
.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.token
|
||||||
|
agent-mailbox-local/
|
||||||
17
DATA-SEPARATION.md
Normal file
17
DATA-SEPARATION.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Development and production data separation
|
||||||
|
|
||||||
|
The development workspace is `/home/umbra/src/UmbraTrans`. Development-only
|
||||||
|
state belongs below `.local/data/development` and is ignored by Git.
|
||||||
|
|
||||||
|
Production PostgreSQL, backup, and service paths must not be used by the
|
||||||
|
development application. In particular:
|
||||||
|
|
||||||
|
- Development configuration uses `UMBRA_ENV=development`.
|
||||||
|
- Development data is local to the workspace and is not a PostgreSQL data directory.
|
||||||
|
- Production database access, logical backups, and service credentials remain
|
||||||
|
separate operator-managed paths.
|
||||||
|
- No live PostgreSQL data directory or running SQLite database is copied as a backup.
|
||||||
|
- Backup and mailbox locations on NAS2 require a separately confirmed mount and
|
||||||
|
permission check.
|
||||||
|
|
||||||
|
This document describes boundaries only; it contains no credentials or tokens.
|
||||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# UmbraTrans local bootstrap workspace
|
||||||
|
|
||||||
|
This workspace contains the unprivileged `agent-comm` server requested by the Oldbox handoff. It does not contain the UmbraTrans application source: no application bundle was present locally. A staged archive, if present, is left untouched.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `agent_comm/` — Python 3.11+ standard-library, stdio-only MCP server.
|
||||||
|
- `agent_comm/tests/` — dependency-free unit tests.
|
||||||
|
- `opencode.json` — non-secret OpenCode registration for the confirmed NAS2 mailbox.
|
||||||
|
- `agent-comm.local.example.json` — non-secret local fallback registration.
|
||||||
|
- `.env.example` — non-secret environment template.
|
||||||
|
- `DATA-SEPARATION.md` — development/production boundary.
|
||||||
|
|
||||||
|
## Confirmed NAS2 mailbox
|
||||||
|
|
||||||
|
The oldbox desktop account mounted NAS2 `netbackup` through its user GVfs session and confirmed the `UmbraTrans` directory readable, searchable, and writable. The server requires the explicit NAS deployment mode, the enclosing GVfs mount, and an affirmative mount confirmation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/run/user/1002/gvfs/smb-share:server=nas2.local,share=netbackup/UmbraTrans/agent-mailbox
|
||||||
|
```
|
||||||
|
|
||||||
|
Run read-only health checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/umbra/src/UmbraTrans
|
||||||
|
AGENT_COMM_MODE=nas \
|
||||||
|
AGENT_COMM_NAS_MOUNT=/run/user/1002/gvfs \
|
||||||
|
AGENT_COMM_NAS_CONFIRMED=1 \
|
||||||
|
AGENT_COMM_ROOT='/run/user/1002/gvfs/smb-share:server=nas2.local,share=netbackup/UmbraTrans/agent-mailbox' \
|
||||||
|
/home/umbra/.local/bin/uv run python -m agent_comm --check
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user GVfs session is absent, do not guess another path or broaden share permissions. Use the private local fallback documented in `agent-comm.local.example.json`, or have a privileged operator restore the confirmed user mount.
|
||||||
|
|
||||||
|
The server speaks newline-delimited MCP JSON-RPC over stdin/stdout. It opens no network socket and never executes queued instructions.
|
||||||
|
|
||||||
|
## OpenCode registration
|
||||||
|
|
||||||
|
`opencode.json` contains the exact NAS configuration and paths only. It must never contain passwords, tokens, private keys, or other secrets. A Mac-side reader still needs separate configuration and verification before an outbox message can be considered received.
|
||||||
|
|
||||||
|
## Development workflow
|
||||||
|
|
||||||
|
The project uses `uv` and a workspace-local virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/umbra/src/UmbraTrans
|
||||||
|
/home/umbra/.local/bin/uv sync --python /usr/bin/python3
|
||||||
|
/home/umbra/.local/bin/uv run python -m unittest discover -s agent_comm/tests -v
|
||||||
|
/home/umbra/.local/bin/uv run python -m compileall -q agent_comm
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests cover schema and size validation, atomic no-overwrite writes, restrictive modes, path traversal, symlink refusal, secret-pattern redaction, inbox processing, and read-only initialization.
|
||||||
16
agent-comm.local.example.json
Normal file
16
agent-comm.local.example.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"agent-comm": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["/usr/bin/python3", "-m", "agent_comm"],
|
||||||
|
"environment": {
|
||||||
|
"PYTHONPATH": "/home/umbra/src/UmbraTrans",
|
||||||
|
"AGENT_COMM_MODE": "local",
|
||||||
|
"AGENT_COMM_ROOT": "/home/umbra/src/UmbraTrans/agent-mailbox-local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
agent_comm/__init__.py
Normal file
6
agent_comm/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Secure, stdio-only local agent communication server."""
|
||||||
|
|
||||||
|
from .server import AgentCommServer, MailboxError, MessageError, MessageStore, SecretContentError
|
||||||
|
|
||||||
|
__all__ = ["AgentCommServer", "MailboxError", "MessageError", "MessageStore", "SecretContentError"]
|
||||||
|
__version__ = "0.1.0"
|
||||||
3
agent_comm/__main__.py
Normal file
3
agent_comm/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .server import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
901
agent_comm/server.py
Normal file
901
agent_comm/server.py
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
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())
|
||||||
183
agent_comm/tests/test_agent_comm.py
Normal file
183
agent_comm/tests/test_agent_comm.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agent_comm.server import (
|
||||||
|
MAX_BODY_BYTES,
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
MAX_RPC_BYTES,
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
AgentCommServer,
|
||||||
|
MailboxError,
|
||||||
|
MessageError,
|
||||||
|
MessageStore,
|
||||||
|
SecretContentError,
|
||||||
|
redact_text,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStoreTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name) / "mailbox"
|
||||||
|
self.store = MessageStore(self.root)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def message(self, *, sender: str = "oldbox", body: str = "sanitized report") -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"id": "12345678-1234-4234-8234-123456789abc",
|
||||||
|
"created_at": utc_now(),
|
||||||
|
"sender": sender,
|
||||||
|
"kind": "report" if sender == "oldbox" else "instruction",
|
||||||
|
"correlation_id": None,
|
||||||
|
"title": "Test message",
|
||||||
|
"body": body,
|
||||||
|
"attachments": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_layout_is_private(self) -> None:
|
||||||
|
for path in [self.root, *(self.root / name for name in ("inbox", "outbox", "processed", "archive"))]:
|
||||||
|
self.assertTrue(path.is_dir())
|
||||||
|
self.assertEqual(stat.S_IMODE(path.stat().st_mode) & 0o077, 0)
|
||||||
|
|
||||||
|
def test_send_report_is_atomic_and_private(self) -> None:
|
||||||
|
result = self.store.send_report(title="Status", body="all clear")
|
||||||
|
target = Path(result["path"])
|
||||||
|
self.assertTrue(target.is_file())
|
||||||
|
self.assertEqual(stat.S_IMODE(target.stat().st_mode) & 0o077, 0)
|
||||||
|
self.assertEqual(json.loads(target.read_text(encoding="utf-8"))["body"], "all clear")
|
||||||
|
self.assertFalse(list(self.store.outbox.glob("*.tmp")))
|
||||||
|
|
||||||
|
def test_secret_pattern_is_refused_fail_closed(self) -> None:
|
||||||
|
with self.assertRaises(SecretContentError):
|
||||||
|
self.store.send_report(
|
||||||
|
title="Diagnostic",
|
||||||
|
body="password=example-only\nAuthorization: Bearer example-only\n{\"token\": \"example-only\"}\nnormal authentication prose",
|
||||||
|
)
|
||||||
|
redacted, changed = redact_text("Authorization: Basic example-only\nnormal authentication prose")
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertNotIn("example-only", redacted)
|
||||||
|
self.assertIn("normal authentication prose", redacted)
|
||||||
|
|
||||||
|
def test_redact_text_preserves_non_secret_prose(self) -> None:
|
||||||
|
value, changed = redact_text("The password authentication path is probably used.")
|
||||||
|
self.assertEqual(value, "The password authentication path is probably used.")
|
||||||
|
self.assertFalse(changed)
|
||||||
|
|
||||||
|
def test_size_limits_and_schema_validation(self) -> None:
|
||||||
|
with self.assertRaises(MessageError):
|
||||||
|
self.store.send_report(title="x", body="x" * (MAX_BODY_BYTES + 1))
|
||||||
|
invalid = self.message()
|
||||||
|
invalid["attachments"] = ["/etc/passwd"]
|
||||||
|
with self.assertRaises(MessageError):
|
||||||
|
self.store._write_message("outbox", invalid)
|
||||||
|
invalid = self.message()
|
||||||
|
invalid["id"] = "../escape"
|
||||||
|
with self.assertRaises(MessageError):
|
||||||
|
self.store._write_message("outbox", invalid)
|
||||||
|
invalid = self.message()
|
||||||
|
invalid["schema_version"] = 2
|
||||||
|
with self.assertRaises(MessageError):
|
||||||
|
self.store._write_message("outbox", invalid)
|
||||||
|
|
||||||
|
def test_inbox_messages_are_validated_and_moved(self) -> None:
|
||||||
|
message = self.message(sender="mac-agent", body="A sanitized instruction")
|
||||||
|
path = self.store.inbox / f"{message['id']}.json"
|
||||||
|
path.write_text(json.dumps(message), encoding="utf-8")
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
messages = self.store.read_instructions()
|
||||||
|
self.assertEqual(messages[0]["body"], "A sanitized instruction")
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
self.assertTrue((self.store.processed / path.name).is_file())
|
||||||
|
|
||||||
|
def test_incoming_secret_is_refused_and_removed(self) -> None:
|
||||||
|
message = self.message(sender="mac-agent", body="token=example-only")
|
||||||
|
path = self.store.inbox / f"{message['id']}.json"
|
||||||
|
path.write_text(json.dumps(message), encoding="utf-8")
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
self.assertEqual(self.store.read_instructions(), [])
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
self.assertFalse((self.store.processed / path.name).exists())
|
||||||
|
|
||||||
|
def test_symlink_paths_are_refused(self) -> None:
|
||||||
|
outside = Path(self.temp_dir.name) / "outside.json"
|
||||||
|
outside.write_text("{}", encoding="utf-8")
|
||||||
|
link = self.store.inbox / "12345678-1234-4234-8234-123456789abc.json"
|
||||||
|
os.symlink(outside, link)
|
||||||
|
self.assertEqual(self.store.read_instructions(), [])
|
||||||
|
self.assertTrue(outside.exists())
|
||||||
|
root_link = Path(self.temp_dir.name) / "root-link"
|
||||||
|
os.symlink(self.root, root_link)
|
||||||
|
with self.assertRaises(MailboxError):
|
||||||
|
MessageStore(root_link)
|
||||||
|
|
||||||
|
def test_check_mode_does_not_create_missing_root(self) -> None:
|
||||||
|
missing = Path(self.temp_dir.name) / "missing"
|
||||||
|
with self.assertRaises(MailboxError):
|
||||||
|
MessageStore(missing, create=False)
|
||||||
|
self.assertFalse(missing.exists())
|
||||||
|
|
||||||
|
def test_encoded_file_limit(self) -> None:
|
||||||
|
self.assertLess(MAX_FILE_BYTES, MAX_BODY_BYTES * 4)
|
||||||
|
path = self.store.inbox / "bad.json"
|
||||||
|
path.write_bytes(b"x" * (MAX_FILE_BYTES + 1))
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
self.assertEqual(self.store.read_instructions(), [])
|
||||||
|
|
||||||
|
def test_nas_mode_requires_confirmed_mount(self) -> None:
|
||||||
|
with self.assertRaises(MailboxError):
|
||||||
|
MessageStore(self.root, deployment_mode="nas", nas_mount="/definitely/not/a/mount", nas_confirmed=True)
|
||||||
|
|
||||||
|
def test_mcp_initialize_status_and_invalid_id(self) -> None:
|
||||||
|
server = AgentCommServer(self.store)
|
||||||
|
initialized = server.handle_json_rpc(
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}}
|
||||||
|
)
|
||||||
|
self.assertEqual(initialized["result"]["serverInfo"]["name"], "agent-comm")
|
||||||
|
status = server.call_tool("agent_status", {})
|
||||||
|
self.assertFalse(json.loads(status["content"][0]["text"])["shared"])
|
||||||
|
invalid = server.handle_json_rpc({"jsonrpc": "2.0", "id": {"secret": "example-only"}, "method": "ping"})
|
||||||
|
self.assertIsNone(invalid["id"])
|
||||||
|
self.assertNotIn("example-only", json.dumps(invalid))
|
||||||
|
|
||||||
|
def test_stdio_input_is_bounded_and_malformed_requests_are_rejected(self) -> None:
|
||||||
|
server = AgentCommServer(self.store)
|
||||||
|
output = io.StringIO()
|
||||||
|
server.serve(io.StringIO("x" * (MAX_RPC_BYTES + 1) + "\n"), output)
|
||||||
|
response = json.loads(output.getvalue())
|
||||||
|
self.assertEqual(response["error"]["code"], -32600)
|
||||||
|
output = io.StringIO()
|
||||||
|
server.serve(io.StringIO('{"jsonrpc":"1.0","id":1,"method":"ping"}\n'), output)
|
||||||
|
self.assertEqual(json.loads(output.getvalue())["error"]["code"], -32600)
|
||||||
|
|
||||||
|
def test_send_ack_is_validated(self) -> None:
|
||||||
|
correlation_id = "12345678-1234-4234-8234-123456789abc"
|
||||||
|
result = self.store.send_ack(correlation_id=correlation_id, text="Received; no action taken.")
|
||||||
|
value = json.loads(Path(result["path"]).read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(value["kind"], "ack")
|
||||||
|
self.assertEqual(value["correlation_id"], correlation_id)
|
||||||
|
with self.assertRaises(SecretContentError):
|
||||||
|
self.store.send_ack(correlation_id=correlation_id, text="access_token=example-only")
|
||||||
|
|
||||||
|
def test_publication_refuses_to_overwrite_existing_message(self) -> None:
|
||||||
|
message = self.message()
|
||||||
|
self.store._write_message("outbox", message)
|
||||||
|
with self.assertRaises(MessageError):
|
||||||
|
self.store._write_message("outbox", message)
|
||||||
|
target = self.store.outbox / f"{message['id']}.json"
|
||||||
|
self.assertEqual(json.loads(target.read_text(encoding="utf-8"))["id"], message["id"])
|
||||||
|
self.assertFalse(list(self.store.outbox.glob("*.tmp")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
21
opencode.json
Normal file
21
opencode.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"agent-comm": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"/home/umbra/src/UmbraTrans/.venv/bin/python",
|
||||||
|
"-m",
|
||||||
|
"agent_comm"
|
||||||
|
],
|
||||||
|
"environment": {
|
||||||
|
"AGENT_COMM_MODE": "nas",
|
||||||
|
"AGENT_COMM_NAS_MOUNT": "/run/user/1002/gvfs",
|
||||||
|
"AGENT_COMM_NAS_CONFIRMED": "1",
|
||||||
|
"AGENT_COMM_ROOT": "/run/user/1002/gvfs/smb-share:server=nas2.local,share=netbackup/UmbraTrans/agent-mailbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "umbratrans-agent-comm"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Local stdio-only sanitized agent communication server"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
agent-comm = "agent_comm.server:main"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = true
|
||||||
Reference in New Issue
Block a user