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