From d845a7ea2e44a676b0ca62a6bd1d14a66b5d32a3 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:54:23 -0400 Subject: [PATCH 1/3] add: user file delivery tool --- scripts/whatsapp_bridge/bridge.mjs | 25 ++++ src/octopal/channels/telegram/handlers.py | 16 ++- src/octopal/channels/whatsapp/bridge.py | 7 ++ src/octopal/channels/whatsapp/runtime.py | 10 ++ src/octopal/runtime/octo/core.py | 6 + src/octopal/runtime/octo/router.py | 1 + src/octopal/tools/catalog.py | 30 +++++ src/octopal/tools/communication/__init__.py | 5 + src/octopal/tools/communication/send_file.py | 122 +++++++++++++++++++ src/octopal/tools/inventory.py | 6 + src/octopal/tools/profiles.py | 4 + tests/test_connector_tools.py | 1 + tests/test_send_file_to_user_tool.py | 105 ++++++++++++++++ tests/test_whatsapp_runtime.py | 31 +++++ 14 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/octopal/tools/communication/__init__.py create mode 100644 src/octopal/tools/communication/send_file.py create mode 100644 tests/test_send_file_to_user_tool.py diff --git a/scripts/whatsapp_bridge/bridge.mjs b/scripts/whatsapp_bridge/bridge.mjs index c395727..fdb1fc1 100644 --- a/scripts/whatsapp_bridge/bridge.mjs +++ b/scripts/whatsapp_bridge/bridge.mjs @@ -294,6 +294,31 @@ const server = http.createServer(async (req, res) => { rememberOutboundMessageId(result?.key?.id); return await jsonResponse(res, 200, { ok: true, to, length: text.length }); } + if (req.method === "POST" && url.pathname === "/send-file") { + const payload = await readJson(req); + const to = normalizeDirectJid(payload.to || ""); + const filePath = String(payload.path || "").trim(); + const caption = String(payload.caption || "").trim(); + if (!sock || !to || !filePath) { + return await jsonResponse(res, 400, { ok: false, error: "missing_to_or_path" }); + } + const absolutePath = path.resolve(filePath); + try { + const stat = await fs.stat(absolutePath); + if (!stat.isFile()) { + return await jsonResponse(res, 400, { ok: false, error: "path_is_not_file" }); + } + } catch { + return await jsonResponse(res, 400, { ok: false, error: "file_not_found" }); + } + const result = await sock.sendMessage(to, { + document: { url: absolutePath }, + fileName: path.basename(absolutePath), + caption, + }); + rememberOutboundMessageId(result?.key?.id); + return await jsonResponse(res, 200, { ok: true, to, path: absolutePath }); + } if (req.method === "POST" && url.pathname === "/react") { const payload = await readJson(req); const to = normalizeDirectJid(payload.to || payload.remoteJid || ""); diff --git a/src/octopal/channels/telegram/handlers.py b/src/octopal/channels/telegram/handlers.py index b670875..fdcfe28 100644 --- a/src/octopal/channels/telegram/handlers.py +++ b/src/octopal/channels/telegram/handlers.py @@ -15,7 +15,7 @@ from aiogram import Bot, Dispatcher from aiogram.exceptions import TelegramBadRequest from aiogram.filters import Command, CommandObject -from aiogram.types import CallbackQuery, Message, ReactionTypeEmoji +from aiogram.types import CallbackQuery, FSInputFile, Message, ReactionTypeEmoji from octopal.channels.telegram.access import is_allowed_chat, parse_allowed_chat_ids from octopal.channels.telegram.approvals import ApprovalManager @@ -156,6 +156,9 @@ async def _internal_send(chat_id: int, text: str) -> None: return await _enqueue_send(bot, chat_id, decision.text) + async def _internal_send_file(chat_id: int, file_path: str, caption: str | None = None) -> None: + await _send_file_safe(bot, chat_id, file_path, caption=caption) + async def _internal_progress_send( chat_id: int, state: str, @@ -188,11 +191,13 @@ async def _internal_typing_control(chat_id: int, active: bool) -> None: _publish_runtime_metrics() octo.internal_send = _internal_send + octo.internal_send_file = _internal_send_file octo.internal_progress_send = _internal_progress_send octo.internal_typing_control = _internal_typing_control # Re-initialize the Octo's default (Telegram) output hooks if needed octo._tg_send = _internal_send + octo._tg_send_file = _internal_send_file octo._tg_progress = _internal_progress_send octo._tg_typing = _internal_typing_control @@ -460,6 +465,15 @@ async def _send_message_safe(bot: Bot, chat_id: int, text: str, reply_to_message await bot.send_message(chat_id, sanitized, reply_to_message_id=reply_to_message_id) +async def _send_file_safe(bot: Bot, chat_id: int, file_path: str, caption: str | None = None) -> None: + clean_caption = sanitize_user_facing_text(strip_reaction_tags(caption or "")) or None + await bot.send_document( + chat_id, + document=FSInputFile(file_path), + caption=clean_caption, + ) + + async def _enqueue_send(bot: Bot, chat_id: int, text: str, reply_to_message_id: int | None = None) -> None: decision = resolve_user_delivery(text) if not decision.user_visible: diff --git a/src/octopal/channels/whatsapp/bridge.py b/src/octopal/channels/whatsapp/bridge.py index 4eeecbe..e64468b 100644 --- a/src/octopal/channels/whatsapp/bridge.py +++ b/src/octopal/channels/whatsapp/bridge.py @@ -148,6 +148,13 @@ def qr_terminal(self) -> dict[str, Any]: def send_message(self, to: str, text: str) -> dict[str, Any]: return self._request("POST", "/send", json={"to": to, "text": text}) + def send_file(self, to: str, file_path: str, *, caption: str | None = None) -> dict[str, Any]: + return self._request( + "POST", + "/send-file", + json={"to": to, "path": file_path, "caption": caption or ""}, + ) + def send_reaction( self, to: str, diff --git a/src/octopal/channels/whatsapp/runtime.py b/src/octopal/channels/whatsapp/runtime.py index e43065b..828a0de 100644 --- a/src/octopal/channels/whatsapp/runtime.py +++ b/src/octopal/channels/whatsapp/runtime.py @@ -74,6 +74,14 @@ async def _internal_send(chat_id: int, text: str) -> None: for chunk in _chunk_text(clean_text, limit=4000): self.bridge.send_message(to, chunk) + async def _internal_send_file(chat_id: int, file_path: str, caption: str | None = None) -> None: + to = self._number_by_chat_id.get(chat_id) + if not to: + logger.warning("Missing WhatsApp recipient mapping for file delivery", chat_id=chat_id) + return + clean_caption = sanitize_user_facing_text(caption or "") or None + self.bridge.send_file(to, file_path, caption=clean_caption) + async def _internal_progress_send( chat_id: int, state: str, @@ -88,9 +96,11 @@ async def _internal_typing_control(chat_id: int, active: bool) -> None: logger.debug("WhatsApp typing indicator not implemented", chat_id=chat_id, active=active) self.octo.internal_send = _internal_send + self.octo.internal_send_file = _internal_send_file self.octo.internal_progress_send = _internal_progress_send self.octo.internal_typing_control = _internal_typing_control self.octo._tg_send = _internal_send + self.octo._tg_send_file = _internal_send_file self.octo._tg_progress = _internal_progress_send self.octo._tg_typing = _internal_typing_control diff --git a/src/octopal/runtime/octo/core.py b/src/octopal/runtime/octo/core.py index cf87def..367333f 100644 --- a/src/octopal/runtime/octo/core.py +++ b/src/octopal/runtime/octo/core.py @@ -713,6 +713,7 @@ class Octo: mcp_manager: MCPManager | None = None connector_manager: ConnectorManager | None = None internal_send: callable | None = None + internal_send_file: callable | None = None internal_progress_send: callable | None = None internal_typing_control: callable | None = None _cleanup_task: asyncio.Task | None = None @@ -723,6 +724,7 @@ class Octo: _ws_active: bool = False _ws_owner: str | None = None _tg_send: callable | None = None + _tg_send_file: callable | None = None _tg_progress: callable | None = None _tg_typing: callable | None = None _spawn_limits: dict[str, int] | None = None @@ -826,6 +828,7 @@ def __post_init__(self): self._restore_worker_registry_state() self._thinking_count = 0 self._tg_send = self.internal_send + self._tg_send_file = self.internal_send_file self._tg_progress = self.internal_progress_send self._tg_typing = self.internal_typing_control @@ -869,6 +872,7 @@ def set_output_channel( self, is_ws: bool, send: callable | None = None, + send_file: callable | None = None, progress: callable | None = None, typing: callable | None = None, owner_id: str | None = None, @@ -894,12 +898,14 @@ def set_output_channel( self._ws_active = is_ws if is_ws: self.internal_send = send + self.internal_send_file = send_file self.internal_progress_send = progress self.internal_typing_control = typing self._ws_owner = owner_id or "ws-default" logger.info("Octo switched to WebSocket output channel") else: self.internal_send = self._tg_send + self.internal_send_file = self._tg_send_file self.internal_progress_send = self._tg_progress self.internal_typing_control = self._tg_typing self._ws_owner = None diff --git a/src/octopal/runtime/octo/router.py b/src/octopal/runtime/octo/router.py index be611cb..d411ea8 100644 --- a/src/octopal/runtime/octo/router.py +++ b/src/octopal/runtime/octo/router.py @@ -85,6 +85,7 @@ "get_worker_result", "get_worker_output_path", "stop_worker", + "send_file_to_user", # Octo must always be able to inspect and mutate its workspace. "fs_list", "fs_read", diff --git a/src/octopal/tools/catalog.py b/src/octopal/tools/catalog.py index 21a1bd9..255cf39 100644 --- a/src/octopal/tools/catalog.py +++ b/src/octopal/tools/catalog.py @@ -9,6 +9,7 @@ from octopal.channels import normalize_user_channel, user_channel_label from octopal.infrastructure.config.settings import load_settings +from octopal.tools.communication.send_file import send_file_to_user from octopal.runtime.memory.memchain import ( memchain_init, memchain_record, @@ -54,6 +55,35 @@ def get_tools(mcp_manager=None) -> list[ToolSpec]: tools = [ + ToolSpec( + name="send_file_to_user", + description="Send a local workspace file or a downloaded URL attachment to the active user channel.", + parameters={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Workspace-relative or workspace-absolute file path to send.", + }, + "url": { + "type": "string", + "description": "HTTP(S) URL to download into workspace/tmp before sending.", + }, + "filename": { + "type": "string", + "description": "Optional filename override when downloading from URL.", + }, + "caption": { + "type": "string", + "description": "Optional message caption to attach to the file.", + }, + }, + "additionalProperties": False, + }, + permission="self_control", + handler=send_file_to_user, + is_async=True, + ), ToolSpec( name="manage_canon", description="Manage canonical memory files (facts.md, decisions.md, failures.md). Only the Octo can use this.", diff --git a/src/octopal/tools/communication/__init__.py b/src/octopal/tools/communication/__init__.py new file mode 100644 index 0000000..7decc60 --- /dev/null +++ b/src/octopal/tools/communication/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from octopal.tools.communication.send_file import send_file_to_user + +__all__ = ["send_file_to_user"] diff --git a/src/octopal/tools/communication/send_file.py b/src/octopal/tools/communication/send_file.py new file mode 100644 index 0000000..f628446 --- /dev/null +++ b/src/octopal/tools/communication/send_file.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json +import mimetypes +import os +import uuid +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +import httpx + +from octopal.tools.filesystem.path_safety import WorkspacePathError, resolve_workspace_path + + +def _error(message: str) -> str: + return json.dumps({"status": "error", "message": message}, ensure_ascii=False) + + +def _infer_filename_from_url(url: str, content_type: str | None = None) -> str: + parsed = urlparse(url) + candidate = Path(unquote(parsed.path or "")).name.strip() + if candidate: + return candidate + extension = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip()) or ".bin" + return f"download{extension}" + + +def _sanitize_filename(filename: str) -> str: + cleaned = Path(str(filename or "").strip()).name.strip() + if not cleaned: + raise ValueError("filename is empty") + if cleaned in {".", ".."}: + raise ValueError("filename is invalid") + return cleaned + + +def _resolve_existing_workspace_file(base_dir: Path, raw_path: str) -> Path: + resolved = resolve_workspace_path(base_dir, raw_path, must_exist=True) + if not resolved.is_file(): + raise WorkspacePathError("path is not a file") + return resolved + + +async def _download_to_tmp( + *, + base_dir: Path, + url: str, + filename: str | None = None, +) -> Path: + tmp_dir = resolve_workspace_path(base_dir, "tmp/outbound_files") + tmp_dir.mkdir(parents=True, exist_ok=True) + + async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + inferred_name = _sanitize_filename( + filename or _infer_filename_from_url(url, response.headers.get("content-type")) + ) + final_name = f"{uuid.uuid4()}_{inferred_name}" + save_path = resolve_workspace_path(base_dir, f"tmp/outbound_files/{final_name}") + with open(save_path, "wb") as handle: + async for chunk in response.aiter_bytes(): + handle.write(chunk) + return save_path + + +async def send_file_to_user(args: dict[str, Any], ctx: dict[str, Any]) -> str: + octo = ctx.get("octo") + if octo is None: + return _error("send_file_to_user requires octo context") + + sender = getattr(octo, "internal_send_file", None) + if not callable(sender): + return _error("active user channel does not support file delivery") + + chat_id = int(ctx.get("chat_id", 0) or 0) + if chat_id <= 0: + return _error("send_file_to_user requires a valid chat_id") + + base_dir = ctx.get("base_dir") + if not isinstance(base_dir, Path): + return _error("send_file_to_user requires base_dir context") + + raw_path = str((args or {}).get("path", "") or "").strip() + raw_url = str((args or {}).get("url", "") or "").strip() + caption = str((args or {}).get("caption", "") or "").strip() or None + requested_filename = str((args or {}).get("filename", "") or "").strip() or None + + if bool(raw_path) == bool(raw_url): + return _error("provide exactly one of 'path' or 'url'") + + try: + if raw_path: + file_path = _resolve_existing_workspace_file(base_dir, raw_path) + source = "path" + else: + if urlparse(raw_url).scheme not in {"http", "https"}: + return _error("url must use http or https") + file_path = await _download_to_tmp(base_dir=base_dir, url=raw_url, filename=requested_filename) + source = "url" + await sender(chat_id, str(file_path), caption=caption) + except WorkspacePathError as exc: + return _error(f"unsafe file path: {exc}") + except httpx.HTTPStatusError as exc: + return _error(f"download failed with HTTP {exc.response.status_code}") + except ValueError as exc: + return _error(str(exc)) + except Exception as exc: + return _error(f"failed to send file: {exc}") + + relative_path = os.path.relpath(file_path, base_dir) + return json.dumps( + { + "status": "success", + "source": source, + "path": relative_path.replace("\\", "/"), + "filename": file_path.name, + "caption": caption, + }, + ensure_ascii=False, + ) diff --git a/src/octopal/tools/inventory.py b/src/octopal/tools/inventory.py index f2719d8..7560b1f 100644 --- a/src/octopal/tools/inventory.py +++ b/src/octopal/tools/inventory.py @@ -6,6 +6,12 @@ from octopal.tools.registry import ToolSpec _TOOL_METADATA_BY_NAME: dict[str, ToolMetadata] = { + "send_file_to_user": ToolMetadata( + category="communication", + risk="guarded", + profile_tags=("communication", "research", "coding", "ops"), + capabilities=("user_delivery", "artifact_delivery"), + ), "fs_list": ToolMetadata( category="filesystem", profile_tags=("coding", "ops"), diff --git a/src/octopal/tools/profiles.py b/src/octopal/tools/profiles.py index 628f9d7..c4bd234 100644 --- a/src/octopal/tools/profiles.py +++ b/src/octopal/tools/profiles.py @@ -42,6 +42,7 @@ class ToolProfile: "start_worker", "start_workers_parallel", "get_worker_result", + "send_file_to_user", ] ), ), @@ -67,6 +68,7 @@ class ToolProfile: "start_workers_parallel", "get_worker_result", "synthesize_worker_results", + "send_file_to_user", ] ), ), @@ -96,6 +98,7 @@ class ToolProfile: "artifact_collect", "test_run", "coverage_report", + "send_file_to_user", ] ), ), @@ -114,6 +117,7 @@ class ToolProfile: "get_worker_result", "synthesize_worker_results", "propose_knowledge", + "send_file_to_user", ] ), ), diff --git a/tests/test_connector_tools.py b/tests/test_connector_tools.py index 8ef8d92..149fde8 100644 --- a/tests/test_connector_tools.py +++ b/tests/test_connector_tools.py @@ -17,6 +17,7 @@ def test_catalog_includes_read_only_connector_status_tool() -> None: names = {tool.name for tool in tools} assert "connector_status" in names + assert "send_file_to_user" in names def test_connector_status_tool_reads_status_from_octo_context() -> None: diff --git a/tests/test_send_file_to_user_tool.py b/tests/test_send_file_to_user_tool.py new file mode 100644 index 0000000..b06c9b9 --- /dev/null +++ b/tests/test_send_file_to_user_tool.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import httpx + +from octopal.tools.communication.send_file import send_file_to_user + + +class _FakeOcto: + def __init__(self) -> None: + self.sent: list[dict[str, object]] = [] + + async def internal_send_file(self, chat_id: int, file_path: str, *, caption: str | None = None) -> None: + self.sent.append({"chat_id": chat_id, "file_path": file_path, "caption": caption}) + + +def test_send_file_to_user_sends_existing_workspace_file(tmp_path: Path) -> None: + octo = _FakeOcto() + target = tmp_path / "reports" / "summary.txt" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("hello", encoding="utf-8") + + payload = asyncio.run( + send_file_to_user( + {"path": "reports/summary.txt", "caption": "Here you go"}, + {"octo": octo, "chat_id": 42, "base_dir": tmp_path}, + ) + ) + data = json.loads(payload) + + assert data["status"] == "success" + assert data["source"] == "path" + assert octo.sent == [ + {"chat_id": 42, "file_path": str(target.resolve()), "caption": "Here you go"} + ] + + +def test_send_file_to_user_rejects_path_outside_workspace(tmp_path: Path) -> None: + octo = _FakeOcto() + + payload = asyncio.run( + send_file_to_user( + {"path": "../secret.txt"}, + {"octo": octo, "chat_id": 42, "base_dir": tmp_path}, + ) + ) + data = json.loads(payload) + + assert data["status"] == "error" + assert "unsafe file path" in data["message"] + assert octo.sent == [] + + +def test_send_file_to_user_downloads_url_before_sending(monkeypatch, tmp_path: Path) -> None: + octo = _FakeOcto() + + class _FakeStreamResponse: + headers = {"content-type": "text/plain"} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def raise_for_status(self) -> None: + return None + + async def aiter_bytes(self): + yield b"hello world" + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def stream(self, method: str, url: str): + assert method == "GET" + assert url == "https://example.com/files/report.txt" + return _FakeStreamResponse() + + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + + payload = asyncio.run( + send_file_to_user( + {"url": "https://example.com/files/report.txt", "caption": "Downloaded"}, + {"octo": octo, "chat_id": 7, "base_dir": tmp_path}, + ) + ) + data = json.loads(payload) + + assert data["status"] == "success" + assert data["source"] == "url" + sent_path = Path(str(octo.sent[0]["file_path"])) + assert sent_path.is_file() + assert sent_path.read_text(encoding="utf-8") == "hello world" + assert sent_path.parent == (tmp_path / "tmp" / "outbound_files") diff --git a/tests/test_whatsapp_runtime.py b/tests/test_whatsapp_runtime.py index bb36dc2..92e7790 100644 --- a/tests/test_whatsapp_runtime.py +++ b/tests/test_whatsapp_runtime.py @@ -12,12 +12,17 @@ class _FakeBridgeController: def __init__(self, settings) -> None: self.settings = settings self.sent: list[tuple[str, str]] = [] + self.sent_files: list[dict] = [] self.reactions: list[dict] = [] def send_message(self, to: str, text: str) -> dict: self.sent.append((to, text)) return {"ok": True} + def send_file(self, to: str, file_path: str, *, caption: str | None = None) -> dict: + self.sent_files.append({"to": to, "file_path": file_path, "caption": caption}) + return {"ok": True} + def send_reaction( self, to: str, @@ -303,3 +308,29 @@ async def scenario() -> None: assert runtime.bridge.sent == [("+15551234567", "All done.")] asyncio.run(scenario()) + + +def test_whatsapp_runtime_internal_send_file_uses_bridge(monkeypatch, tmp_path: Path) -> None: + fake_octo = _FakeOcto() + monkeypatch.setattr(whatsapp_runtime_module, "build_octo", lambda settings: fake_octo) + monkeypatch.setattr(whatsapp_runtime_module, "WhatsAppBridgeController", _FakeBridgeController) + monkeypatch.setattr(whatsapp_runtime_module, "update_component_gauges", lambda *args, **kwargs: None) + monkeypatch.setattr(whatsapp_runtime_module, "update_last_message", lambda *args, **kwargs: None) + + runtime = WhatsAppRuntime(_make_settings(mode="personal", allowed_numbers="+15551234567")) + runtime.attach_octo_output() + chat_id = whatsapp_runtime_module.whatsapp_chat_id("+15551234567") + runtime._number_by_chat_id[chat_id] = "+15551234567" + + file_path = tmp_path / "artifact.txt" + file_path.write_text("artifact", encoding="utf-8") + + async def scenario() -> None: + assert fake_octo.internal_send_file is not None + await fake_octo.internal_send_file(chat_id, str(file_path), caption="Take this") + + asyncio.run(scenario()) + + assert runtime.bridge.sent_files == [ + {"to": "+15551234567", "file_path": str(file_path), "caption": "Take this"} + ] From 9b66d659d5f87f0269fd69cf532064871e7591e2 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:55:27 -0400 Subject: [PATCH 2/3] chore: ignore communication pycache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f14b176..95e877e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ config.json /src/octopal/cli/wizard/__pycache__ /src/octopal/infrastructure/connectors/__pycache__ /src/octopal/tools/connectors/__pycache__ +/src/octopal/tools/communication/__pycache__ /src/octopal/mcp_servers/__pycache__ From afe6a9a5e14b537ddc66202449e05588ec4edc35 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:58:56 -0400 Subject: [PATCH 3/3] harden: keep user delivery tools octo-only --- src/octopal/runtime/workers/agent_worker.py | 4 ++ src/octopal/runtime/workers/runtime.py | 7 ++- src/octopal/tools/catalog.py | 2 +- tests/test_runtime_mcp_integration.py | 48 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/octopal/runtime/workers/agent_worker.py b/src/octopal/runtime/workers/agent_worker.py index fcaa147..a0bdd05 100644 --- a/src/octopal/runtime/workers/agent_worker.py +++ b/src/octopal/runtime/workers/agent_worker.py @@ -185,6 +185,10 @@ async def execute_agent_task(worker: Worker, workspace_root: Path, worker_dir: P filtered_tools = apply_tool_policy_pipeline( available_tools, [ + ToolPolicyPipelineStep( + label="worker.user_communication_denylist", + policy=ToolPolicy(deny=["send_file_to_user"]), + ), ToolPolicyPipelineStep( label="worker.available_tools", policy=ToolPolicy(allow=list(spec.available_tools or [])), diff --git a/src/octopal/runtime/workers/runtime.py b/src/octopal/runtime/workers/runtime.py index 19f3073..6d0e822 100644 --- a/src/octopal/runtime/workers/runtime.py +++ b/src/octopal/runtime/workers/runtime.py @@ -35,6 +35,7 @@ from octopal.utils import utc_now logger = structlog.get_logger(__name__) +_WORKER_BLOCKED_TOOL_NAMES = {"send_file_to_user"} # Constants _MAX_RECOVERY_ATTEMPTS = 1 @@ -98,7 +99,11 @@ async def run_task( if not granted: return WorkerResult(summary="Permission denied for worker task") - requested_tool_names = list(task_request.tools or template.available_tools) + requested_tool_names = [ + str(tool_name) + for tool_name in (task_request.tools or template.available_tools) + if str(tool_name) not in _WORKER_BLOCKED_TOOL_NAMES + ] has_requested_mcp_tools = any(str(tool_name).startswith("mcp_") for tool_name in requested_tool_names) if self.mcp_manager: try: diff --git a/src/octopal/tools/catalog.py b/src/octopal/tools/catalog.py index 255cf39..18d73ef 100644 --- a/src/octopal/tools/catalog.py +++ b/src/octopal/tools/catalog.py @@ -57,7 +57,7 @@ def get_tools(mcp_manager=None) -> list[ToolSpec]: tools = [ ToolSpec( name="send_file_to_user", - description="Send a local workspace file or a downloaded URL attachment to the active user channel.", + description="Send a local workspace file or a downloaded URL attachment to the active user channel. Only the Octo can use this.", parameters={ "type": "object", "properties": { diff --git a/tests/test_runtime_mcp_integration.py b/tests/test_runtime_mcp_integration.py index 01b8cbb..1cb4355 100644 --- a/tests/test_runtime_mcp_integration.py +++ b/tests/test_runtime_mcp_integration.py @@ -12,6 +12,54 @@ from octopal.tools.registry import ToolSpec +def test_runtime_blocks_user_communication_tools_for_workers(tmp_path: Path) -> None: + template = WorkerTemplateRecord( + id="worker", + name="Worker", + description="Test worker", + system_prompt="Do work", + available_tools=["fs_read", "send_file_to_user"], + required_permissions=["filesystem_read", "self_control"], + model=None, + max_thinking_steps=3, + default_timeout_seconds=30, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + class _Store: + def get_worker_template(self, worker_id: str): + return template + + class _Policy: + def grant_capabilities(self, capabilities): + return capabilities + + runtime = WorkerRuntime( + store=_Store(), + policy=_Policy(), + workspace_dir=tmp_path, + launcher=object(), + mcp_manager=None, + settings=Settings(), + ) + + captured: dict[str, object] = {} + + async def _fake_run(spec, approval_requester=None): + captured["spec"] = spec + return WorkerResult(summary="ok") + + runtime.run = _fake_run # type: ignore[method-assign] + + request = TaskRequest(worker_id="worker", task="hello") + asyncio.run(runtime.run_task(request)) + + spec = captured["spec"] + assert "fs_read" in spec.available_tools + assert "send_file_to_user" not in spec.available_tools + + def test_runtime_does_not_auto_inject_global_mcp_tools(tmp_path: Path) -> None: template = WorkerTemplateRecord( id="worker",