diff --git a/README.md b/README.md
index 4c91f1cf..4978a30c 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
## 🏗️ Architecture
-
+
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py
index 9da349d1..066549dc 100644
--- a/agent/memory/summarizer.py
+++ b/agent/memory/summarizer.py
@@ -462,13 +462,12 @@ class MemoryFlushManager:
daily_content=daily_content or "(no recent daily records)",
)
from agent.protocol.models import LLMRequest
- # Scale max_tokens based on input size to avoid truncating large MEMORY.md
- input_chars = len(memory_content) + len(daily_content)
- dream_max_tokens = max(2000, min(input_chars, 8000))
+ # No output cap: the prompt already keeps MEMORY.md concise (~50
+ # items), so a hard max_tokens would only risk truncating a large
+ # rewrite. Let the model use its default output budget.
request = LLMRequest(
messages=[{"role": "user", "content": user_msg}],
temperature=0.3,
- max_tokens=dream_max_tokens,
stream=False,
system=_dream_system_prompt(),
)
diff --git a/agent/skills/service.py b/agent/skills/service.py
index a34a546f..95cfb9bb 100644
--- a/agent/skills/service.py
+++ b/agent/skills/service.py
@@ -34,6 +34,27 @@ class SkillService:
"""
self.manager = skill_manager
+ def _safe_skill_dir(self, name: str) -> str:
+ """Derive and validate the skill directory path.
+
+ Ensures the resolved path stays within the custom_dir root,
+ preventing path traversal via names like ``../escaped``.
+
+ :raises ValueError: if the name would escape the skills root.
+ """
+ if not name or not name.strip():
+ raise ValueError("skill name is required")
+ # Reject obvious traversal components.
+ if ".." in name or name.startswith("/") or name.startswith("\\"):
+ raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
+ skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
+ root = os.path.realpath(self.manager.custom_dir)
+ if not skill_dir.startswith(root + os.sep) and skill_dir != root:
+ raise ValueError(
+ f"skill name {name!r} resolves outside the skills directory"
+ )
+ return skill_dir
+
# ------------------------------------------------------------------
# query
# ------------------------------------------------------------------
@@ -107,7 +128,7 @@ class SkillService:
if not files:
raise ValueError("skill files list is empty")
- skill_dir = os.path.join(self.manager.custom_dir, name)
+ skill_dir = self._safe_skill_dir(name)
tmp_dir = skill_dir + ".tmp"
if os.path.exists(tmp_dir):
@@ -146,7 +167,7 @@ class SkillService:
raise ValueError("package url is required")
url = files[0]["url"]
- skill_dir = os.path.join(self.manager.custom_dir, name)
+ skill_dir = self._safe_skill_dir(name)
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "package.zip")
@@ -217,7 +238,7 @@ class SkillService:
if not name:
raise ValueError("skill name is required")
- skill_dir = os.path.join(self.manager.custom_dir, name)
+ skill_dir = self._safe_skill_dir(name)
if os.path.exists(skill_dir):
shutil.rmtree(skill_dir)
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py
index 19878f6d..170d2175 100644
--- a/agent/tools/vision/vision.py
+++ b/agent/tools/vision/vision.py
@@ -17,11 +17,14 @@ Provider resolution:
"""
import base64
+import ipaddress
import os
+import socket
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
+from urllib.parse import urlparse
import requests
@@ -654,6 +657,40 @@ class Vision(BaseTool):
return api_base
return api_base.rstrip("/") + "/v1"
+ @staticmethod
+ def _validate_url_safe(url: str) -> None:
+ """Reject URLs that target private/loopback/link-local addresses (SSRF guard).
+
+ Resolves the hostname to its IP address(es) and blocks any that fall
+ into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
+ schemes, or hosts that fail DNS resolution.
+
+ Raises:
+ ValueError: if the URL targets a disallowed address.
+ """
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
+
+ hostname = parsed.hostname
+ if not hostname:
+ raise ValueError("URL has no hostname")
+
+ try:
+ # Resolve all addresses for the hostname.
+ addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
+ except socket.gaierror:
+ raise ValueError(f"Cannot resolve hostname: {hostname}")
+
+ for family, _, _, _, sockaddr in addr_infos:
+ ip_str = sockaddr[0]
+ ip = ipaddress.ip_address(ip_str)
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
+ raise ValueError(
+ f"URL resolves to a non-public address ({ip_str}), "
+ f"request blocked for security"
+ )
+
def _build_image_content(self, image: str) -> dict:
"""
Build the image_url content block.
@@ -661,6 +698,7 @@ class Vision(BaseTool):
so every bot backend can consume them without extra downloads.
"""
if image.startswith(("http://", "https://")):
+ self._validate_url_safe(image)
return self._download_to_data_url(image)
if not os.path.isfile(image):
diff --git a/channel/chat_channel.py b/channel/chat_channel.py
index 9104a38e..e2a65c5b 100644
--- a/channel/chat_channel.py
+++ b/channel/chat_channel.py
@@ -519,7 +519,10 @@ class ChatChannel(Channel):
def cancel_session(self, session_id):
with self.lock:
if session_id in self.sessions:
- for future in self.futures[session_id]:
+ # futures[session_id] is only created in consume() when a task is
+ # dispatched, so it may be absent if cancel happens right after
+ # produce() but before the first dispatch. Default to [].
+ for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
def cancel_all_session(self):
with self.lock:
for session_id in self.sessions:
- for future in self.futures[session_id]:
+ for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
diff --git a/channel/web/chat.html b/channel/web/chat.html
index 73719f71..a77eb2a1 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -1198,6 +1198,66 @@
+
+