Bootstrap UmbraTrans agent communication workspace

This commit is contained in:
umbra audio
2026-09-24 16:09:23 -07:00
commit fc6c36331b
12 changed files with 1241 additions and 0 deletions

9
.env.example Normal file
View 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
View 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
View 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
View 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.

View 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
View 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
View File

@@ -0,0 +1,3 @@
from .server import main
raise SystemExit(main())

901
agent_comm/server.py Normal file
View 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())

View 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
View 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
View 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

8
uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "umbratrans-agent-comm"
version = "0.1.0"
source = { editable = "." }