Harden agent communication validation
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ __pycache__/
|
||||
.venv/
|
||||
.local/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Secrets and local configuration
|
||||
.env
|
||||
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: test lint format format-check check
|
||||
|
||||
test:
|
||||
/home/umbra/.local/bin/uv run python -m unittest discover -s agent_comm/tests -v
|
||||
|
||||
lint:
|
||||
/home/umbra/.local/bin/uv run ruff check .
|
||||
|
||||
format:
|
||||
/home/umbra/.local/bin/uv run ruff format .
|
||||
|
||||
format-check:
|
||||
/home/umbra/.local/bin/uv run ruff format --check .
|
||||
|
||||
check: test lint format-check
|
||||
/home/umbra/.local/bin/uv run python -m compileall -q agent_comm
|
||||
@@ -46,7 +46,10 @@ The project uses `uv` and a workspace-local virtual environment:
|
||||
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 ruff check .
|
||||
/home/umbra/.local/bin/uv run ruff format --check .
|
||||
/home/umbra/.local/bin/uv run python -m compileall -q agent_comm
|
||||
make check
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -34,12 +34,31 @@ VALID_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||
# 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)"
|
||||
r"client[_-]?secret|secret|credential|passphrase|private[_-]?key|session[_-]?id|"
|
||||
r"cookie|authorization|proxy[_-]?authorization|hash|checksum|fingerprint)"
|
||||
)
|
||||
_SECRET_ASSIGNMENT_RE = re.compile(
|
||||
rf"(?i)([\"']?\b{_SECRET_KEYS}\b[\"']?\s*(?::|=|\bis\b)\s*)"
|
||||
rf"(?i)([\"']?\b(?:[A-Za-z][A-Za-z0-9_-]*[_-])?{_SECRET_KEYS}\b[\"']?\s*(?::|=|\bis\b)\s*)"
|
||||
r"(\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)"
|
||||
)
|
||||
_PREFIXED_SECRET_ASSIGNMENT_RE = re.compile(
|
||||
r"(?i)\b[A-Za-z][A-Za-z0-9_-]*(?:password|passwd|token|secret|credential|passphrase|private[_-]?key)\b"
|
||||
r"\s*(?::|=|\bis\b)\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)"
|
||||
)
|
||||
_SECRET_BLOCK_RE = re.compile(
|
||||
r"(?im)^\s*(?:[A-Za-z][A-Za-z0-9_-]*[_-])?"
|
||||
r"(?:password|passwd|token|secret|credential|passphrase|private[_-]?key)\s*:\s*(?:[>|][+-]?|\r?\n)"
|
||||
)
|
||||
_PUTTY_KEY_RE = re.compile(r"(?im)^PuTTY-User-Key-File-[0-9]+:")
|
||||
_UNSAFE_INSTRUCTION_RE = re.compile(
|
||||
r"(?im)(?:^|\n)\s*(?:"
|
||||
r"sudo\b|su\b|rm\s+-[^\n]*r|chmod\s+[-+0-7]*777|"
|
||||
r"(?:ba)?sh\s+-c\b|python[0-9.]*\s+-c\b|perl\s+-e\b|"
|
||||
r"curl\b[^\n]*\|\s*(?:ba)?sh\b|wget\b[^\n]*\|\s*(?:ba)?sh\b|"
|
||||
r"systemctl\s+(?:start|stop|restart|enable|disable)\b|"
|
||||
r"git\s+(?:reset|clean)\b|iptables\b|nft\b|mount\b|umount\b"
|
||||
r")"
|
||||
)
|
||||
_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/]+@")
|
||||
@@ -88,6 +107,10 @@ class SecretContentError(MessageError):
|
||||
"""A message was refused because it contains secret-like content."""
|
||||
|
||||
|
||||
class UnsafeInstructionError(MessageError):
|
||||
"""A queued instruction contains an unrestricted shell command."""
|
||||
|
||||
|
||||
class MailboxError(RuntimeError):
|
||||
"""The configured mailbox is unavailable or unsafe."""
|
||||
|
||||
@@ -99,10 +122,13 @@ def utc_now() -> str:
|
||||
def contains_secret_pattern(value: str) -> bool:
|
||||
return bool(
|
||||
_SECRET_ASSIGNMENT_RE.search(value)
|
||||
or _PREFIXED_SECRET_ASSIGNMENT_RE.search(value)
|
||||
or _SECRET_BLOCK_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 _PUTTY_KEY_RE.search(value)
|
||||
or _JWT_RE.search(value)
|
||||
)
|
||||
|
||||
@@ -113,6 +139,24 @@ def reject_secret_content(**values: str) -> None:
|
||||
raise SecretContentError(f"{field} contains secret-like content")
|
||||
|
||||
|
||||
def reject_unsafe_instruction(value: str) -> None:
|
||||
if _UNSAFE_INSTRUCTION_RE.search(value):
|
||||
raise UnsafeInstructionError("instruction contains an unrestricted shell command")
|
||||
|
||||
|
||||
def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON object key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _strict_json_loads(value: str) -> Any:
|
||||
return json.loads(value, object_pairs_hook=_reject_duplicate_pairs)
|
||||
|
||||
|
||||
def redact_text(value: str) -> tuple[str, bool]:
|
||||
"""Redact common secret forms from a sanitized text field.
|
||||
|
||||
@@ -123,6 +167,9 @@ def redact_text(value: str) -> tuple[str, bool]:
|
||||
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 = _PREFIXED_SECRET_ASSIGNMENT_RE.sub("[REDACTED SECRET ASSIGNMENT]", redacted)
|
||||
redacted = _SECRET_BLOCK_RE.sub("[REDACTED SECRET BLOCK]", redacted)
|
||||
redacted = _PUTTY_KEY_RE.sub("[REDACTED PRIVATE KEY]", redacted)
|
||||
redacted = _URI_CREDENTIAL_RE.sub(r"\1[REDACTED]@", redacted)
|
||||
redacted = _JWT_RE.sub("[REDACTED TOKEN]", redacted)
|
||||
return redacted, redacted != value
|
||||
@@ -154,10 +201,12 @@ def _validate_timestamp(value: Any) -> str:
|
||||
|
||||
|
||||
def _validate_text(value: Any, field: str, limit: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
if type(value) is not str:
|
||||
raise MessageError(f"{field} must be a string")
|
||||
if "\x00" in value:
|
||||
raise MessageError(f"{field} contains NUL")
|
||||
if not value.strip():
|
||||
raise MessageError(f"{field} must not be empty")
|
||||
if len(value.encode("utf-8")) > limit:
|
||||
raise MessageError(f"{field} exceeds its size limit")
|
||||
return value
|
||||
@@ -194,7 +243,7 @@ def validate_message(value: Any) -> dict[str, Any]:
|
||||
_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"] != []:
|
||||
if type(value["attachments"]) is not list or value["attachments"] != []:
|
||||
raise MessageError("attachments are not supported")
|
||||
return dict(value)
|
||||
|
||||
@@ -204,12 +253,14 @@ def sanitize_message(value: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
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
|
||||
if title_changed or body_changed:
|
||||
raise SecretContentError("message contained secret-like content")
|
||||
if clean["kind"] == "instruction":
|
||||
reject_unsafe_instruction(clean["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
|
||||
return clean, False
|
||||
|
||||
|
||||
def _lstat(path: Path) -> os.stat_result:
|
||||
@@ -272,6 +323,9 @@ def _open_directory_chain(path: Path, *, create: bool = False) -> int:
|
||||
os.close(fd)
|
||||
fd = next_fd
|
||||
info = os.fstat(fd)
|
||||
if info.st_uid != os.geteuid():
|
||||
os.close(fd)
|
||||
raise MailboxError("mailbox directory is not owned by the server user")
|
||||
if info.st_mode & 0o077:
|
||||
os.close(fd)
|
||||
raise MailboxError("mailbox directory is not private")
|
||||
@@ -314,6 +368,8 @@ def _ensure_directory(path: Path, create: bool) -> None:
|
||||
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_uid != os.geteuid():
|
||||
raise MailboxError(f"mailbox directory is not owned by the server user: {path}")
|
||||
if info.st_mode & 0o077:
|
||||
raise MailboxError(f"mailbox directory is not private: {path}")
|
||||
|
||||
@@ -374,9 +430,9 @@ class MessageStore:
|
||||
for directory in self.directories.values():
|
||||
_ensure_directory(directory, True)
|
||||
else:
|
||||
_assert_no_symlink_components(self.root, allow_missing=False)
|
||||
_ensure_directory(self.root, False)
|
||||
for directory in self.directories.values():
|
||||
_assert_no_symlink_components(directory, allow_missing=False)
|
||||
_ensure_directory(directory, False)
|
||||
|
||||
def _open_directory(self, name: str) -> int:
|
||||
if name not in self.directories:
|
||||
@@ -473,6 +529,10 @@ class MessageStore:
|
||||
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_uid != os.geteuid():
|
||||
raise MailboxError("message file is not owned by the server user")
|
||||
if info.st_nlink != 1:
|
||||
raise MailboxError("message file has multiple hard links")
|
||||
if info.st_mode & 0o077:
|
||||
raise MailboxError("message file is not private")
|
||||
if info.st_size > MAX_FILE_BYTES:
|
||||
@@ -491,6 +551,8 @@ class MessageStore:
|
||||
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")
|
||||
if opened.st_uid != os.geteuid() or opened.st_nlink != 1:
|
||||
raise MailboxError("message file ownership or link count is unsafe")
|
||||
with os.fdopen(file_fd, "rb") as handle:
|
||||
file_fd = -1
|
||||
raw = handle.read(MAX_FILE_BYTES + 1)
|
||||
@@ -500,21 +562,22 @@ class MessageStore:
|
||||
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:
|
||||
value = _strict_json_loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise MessageError("message is not valid UTF-8 JSON") from exc
|
||||
return sanitize_message(value)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
def _health_snapshot(self) -> tuple[bool, bool, int]:
|
||||
root_private, root_read, root_write = _directory_info(self.root)
|
||||
directories: dict[str, dict[str, bool]] = {}
|
||||
counts: dict[str, int] = {}
|
||||
readable = root_private and root_read
|
||||
writable = root_private and root_write
|
||||
pending = 0
|
||||
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
|
||||
readable = False
|
||||
writable = False
|
||||
continue
|
||||
try:
|
||||
count = 0
|
||||
@@ -524,39 +587,39 @@ class MessageStore:
|
||||
count += 1
|
||||
if count >= MAX_INBOX_SCAN:
|
||||
break
|
||||
directories[name] = {"exists": True, "private": True, "readable": True, "writable": True}
|
||||
counts[name] = count
|
||||
if name in {"inbox", "outbox"}:
|
||||
pending += 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 readable, writable, pending
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
readable, writable, _ = self._health_snapshot()
|
||||
return readable and writable
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
readable, writable, pending = self._health_snapshot()
|
||||
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,
|
||||
"mailbox_readable": readable,
|
||||
"mailbox_writable": writable,
|
||||
"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":
|
||||
def send_report(
|
||||
self, *, title: str, body: str, correlation_id: str | None = None, kind: str = "report"
|
||||
) -> dict[str, Any]:
|
||||
if type(kind) is not str or kind not in {"report", "diagnostic"}:
|
||||
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)
|
||||
if title_changed or body_changed:
|
||||
raise SecretContentError("report contained secret-like content")
|
||||
reject_secret_content(title=clean_title, body=clean_body)
|
||||
message = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
@@ -572,12 +635,19 @@ class MessageStore:
|
||||
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}
|
||||
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)
|
||||
if text_changed:
|
||||
raise SecretContentError("acknowledgement contained secret-like content")
|
||||
reject_secret_content(text=clean_text)
|
||||
message = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
@@ -608,7 +678,9 @@ class MessageStore:
|
||||
if entry.name.endswith(".json"):
|
||||
candidates.append(entry.name)
|
||||
candidates.sort()
|
||||
for filename in candidates[:limit]:
|
||||
for filename in candidates:
|
||||
if len(messages) >= limit:
|
||||
break
|
||||
try:
|
||||
_safe_message_name(filename)
|
||||
message, _ = self._read_message_file(inbox_fd, filename)
|
||||
@@ -630,7 +702,7 @@ class MessageStore:
|
||||
"body": message["body"],
|
||||
}
|
||||
)
|
||||
except SecretContentError:
|
||||
except (SecretContentError, UnsafeInstructionError):
|
||||
# A rejected message is never returned. Remove the raw file
|
||||
# relative to the already-open directory descriptor.
|
||||
try:
|
||||
@@ -686,7 +758,7 @@ class AgentCommServer:
|
||||
"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"]},
|
||||
"kind": {"type": "string", "enum": ["diagnostic", "report"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -696,7 +768,9 @@ class AgentCommServer:
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"limit": {"type": "integer", "minimum": 1, "maximum": MAX_MESSAGES_PER_READ, "default": 20}},
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_MESSAGES_PER_READ, "default": 20}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -742,7 +816,9 @@ class AgentCommServer:
|
||||
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(
|
||||
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__)
|
||||
@@ -819,7 +895,7 @@ class AgentCommServer:
|
||||
if not line.strip():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(line)
|
||||
value = _strict_json_loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError
|
||||
return self.handle_json_rpc(value)
|
||||
@@ -868,7 +944,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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(
|
||||
"--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")
|
||||
@@ -889,7 +967,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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
|
||||
return 0 if status_value["mailbox_readable"] and status_value["mailbox_writable"] else 1
|
||||
AgentCommServer(store).serve()
|
||||
return 0
|
||||
except (MailboxError, MessageError) as exc:
|
||||
|
||||
@@ -62,7 +62,7 @@ class MessageStoreTests(unittest.TestCase):
|
||||
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",
|
||||
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)
|
||||
@@ -145,7 +145,10 @@ class MessageStoreTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(initialized["result"]["serverInfo"]["name"], "agent-comm")
|
||||
status = server.call_tool("agent_status", {})
|
||||
self.assertFalse(json.loads(status["content"][0]["text"])["shared"])
|
||||
status_value = json.loads(status["content"][0]["text"])
|
||||
self.assertTrue(status_value["mailbox_readable"])
|
||||
self.assertTrue(status_value["mailbox_writable"])
|
||||
self.assertNotIn("shared", status_value)
|
||||
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))
|
||||
@@ -169,6 +172,61 @@ class MessageStoreTests(unittest.TestCase):
|
||||
with self.assertRaises(SecretContentError):
|
||||
self.store.send_ack(correlation_id=correlation_id, text="access_token=example-only")
|
||||
|
||||
def test_extended_secret_forms_are_refused(self) -> None:
|
||||
bodies = (
|
||||
"db_password=example-only",
|
||||
"Authorization: Basic example-only",
|
||||
"-----BEGIN PGP PRIVATE KEY BLOCK-----\nexample-only\n-----END PGP PRIVATE KEY BLOCK-----",
|
||||
"PuTTY-User-Key-File-3: example-only",
|
||||
)
|
||||
for body in bodies:
|
||||
with self.subTest(body=body.split(":", 1)[0]):
|
||||
with self.assertRaises(SecretContentError):
|
||||
self.store.send_report(title="Diagnostic", body=body)
|
||||
|
||||
def test_unsafe_instruction_is_refused_and_removed(self) -> None:
|
||||
message = self.message(sender="mac-agent", body="sudo rm -rf /tmp/example")
|
||||
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_check_mode_rejects_public_directory(self) -> None:
|
||||
os.chmod(self.root, 0o755)
|
||||
try:
|
||||
with self.assertRaises(MailboxError):
|
||||
MessageStore(self.root, create=False)
|
||||
finally:
|
||||
os.chmod(self.root, 0o700)
|
||||
|
||||
def test_duplicate_json_keys_are_rejected(self) -> None:
|
||||
server = AgentCommServer(self.store)
|
||||
output = io.StringIO()
|
||||
server.serve(io.StringIO('{"jsonrpc":"2.0","jsonrpc":"2.0","id":1,"method":"ping"}\n'), output)
|
||||
self.assertEqual(json.loads(output.getvalue())["error"]["code"], -32700)
|
||||
|
||||
def test_uuid_v7_filename_is_supported(self) -> None:
|
||||
message = self.message(sender="mac-agent", body="A bounded instruction")
|
||||
message["id"] = "0190f3e2-3b7a-7b2a-9c1d-2e3f4a5b6c7d"
|
||||
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]["id"], message["id"])
|
||||
|
||||
def test_invalid_inbox_file_does_not_starve_valid_message(self) -> None:
|
||||
invalid = self.store.inbox / "00000000-0000-4000-8000-000000000000.json"
|
||||
invalid.write_text("{}", encoding="utf-8")
|
||||
os.chmod(invalid, 0o600)
|
||||
message = self.message(sender="mac-agent", body="A valid instruction")
|
||||
valid = self.store.inbox / f"{message['id']}.json"
|
||||
valid.write_text(json.dumps(message), encoding="utf-8")
|
||||
os.chmod(valid, 0o600)
|
||||
messages = self.store.read_instructions(limit=1)
|
||||
self.assertEqual(messages[0]["id"], message["id"])
|
||||
|
||||
def test_publication_refuses_to_overwrite_existing_message(self) -> None:
|
||||
message = self.message()
|
||||
self.store._write_message("outbox", message)
|
||||
|
||||
@@ -8,8 +8,18 @@ dependencies = []
|
||||
[project.scripts]
|
||||
agent-comm = "agent_comm.server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff==0.16.9"]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent_comm*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I"]
|
||||
|
||||
35
uv.lock
generated
35
uv.lock
generated
@@ -2,7 +2,42 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "umbratrans-agent-comm"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = "==0.16.9" }]
|
||||
|
||||
Reference in New Issue
Block a user