From fc6c36331b8206b12b726e1f089197156c9c39f0 Mon Sep 17 00:00:00 2001 From: umbra audio Date: Thu, 24 Sep 2026 16:09:23 -0700 Subject: [PATCH] Bootstrap UmbraTrans agent communication workspace --- .env.example | 9 + .gitignore | 13 + DATA-SEPARATION.md | 17 + README.md | 52 ++ agent-comm.local.example.json | 16 + agent_comm/__init__.py | 6 + agent_comm/__main__.py | 3 + agent_comm/server.py | 901 ++++++++++++++++++++++++++++ agent_comm/tests/test_agent_comm.py | 183 ++++++ opencode.json | 21 + pyproject.toml | 12 + uv.lock | 8 + 12 files changed, 1241 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DATA-SEPARATION.md create mode 100644 README.md create mode 100644 agent-comm.local.example.json create mode 100644 agent_comm/__init__.py create mode 100644 agent_comm/__main__.py create mode 100644 agent_comm/server.py create mode 100644 agent_comm/tests/test_agent_comm.py create mode 100644 opencode.json create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4d24675 --- /dev/null +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35d33a6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/DATA-SEPARATION.md b/DATA-SEPARATION.md new file mode 100644 index 0000000..70b614b --- /dev/null +++ b/DATA-SEPARATION.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a66a58c --- /dev/null +++ b/README.md @@ -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. diff --git a/agent-comm.local.example.json b/agent-comm.local.example.json new file mode 100644 index 0000000..2583c6a --- /dev/null +++ b/agent-comm.local.example.json @@ -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" + } + } + } + } +} diff --git a/agent_comm/__init__.py b/agent_comm/__init__.py new file mode 100644 index 0000000..5224136 --- /dev/null +++ b/agent_comm/__init__.py @@ -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" diff --git a/agent_comm/__main__.py b/agent_comm/__main__.py new file mode 100644 index 0000000..0a68a64 --- /dev/null +++ b/agent_comm/__main__.py @@ -0,0 +1,3 @@ +from .server import main + +raise SystemExit(main()) diff --git a/agent_comm/server.py b/agent_comm/server.py new file mode 100644 index 0000000..7c03d13 --- /dev/null +++ b/agent_comm/server.py @@ -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()) diff --git a/agent_comm/tests/test_agent_comm.py b/agent_comm/tests/test_agent_comm.py new file mode 100644 index 0000000..06ad567 --- /dev/null +++ b/agent_comm/tests/test_agent_comm.py @@ -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() diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..7d96089 --- /dev/null +++ b/opencode.json @@ -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" + } + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..839cddf --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1b23c49 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "umbratrans-agent-comm" +version = "0.1.0" +source = { editable = "." }