Merge remote-tracking branch 'origin/dev'

# Conflicts:
#	routes/document_routes.py
This commit is contained in:
pewdiepie-archdaemon
2026-07-01 10:11:22 +00:00
44 changed files with 3498 additions and 856 deletions
+39 -15
View File
@@ -1384,6 +1384,9 @@ def _build_system_prompt(
# the trusted system role. Bound up front so the insert block below can
# always check it.
_skills_message = None
_email_style_message = None
_integ_message = None
_mcp_desc_message = None
if active_document:
set_active_document(active_document.id)
_doc_raw = active_document.current_content or ""
@@ -1614,9 +1617,9 @@ def _build_system_prompt(
from src.settings import load_settings as _load_settings
_style = (_load_settings().get("email_writing_style", "") or "").strip()
if _style:
# Hardcoded identity/style rules stay in the trusted system prompt.
agent_prompt += (
"\n\n📧 EMAIL WRITING STYLE AND IDENTITY — FOLLOW FOR ANY EMAIL DRAFT OR SEND:\n"
f"{_style}\n\n"
"\n\n"
"Hard identity rule: write as the user/mailbox owner only. Do not sign as, speak as, "
"or imply you are the recipient, original sender, quoted sender, spouse, assistant, "
"company, or any other third party. If a signature is needed, use only the name/signature "
@@ -1625,6 +1628,12 @@ def _build_system_prompt(
"For English emails, default to Hi [Name] or Hiya from the saved style rather than Hey. "
"If the saved style specifies Best/newline/name, use that sign-off when a sign-off is natural."
)
# User-editable style text is untrusted — wrap it so a malicious
# style value cannot inject system-role instructions.
_email_style_message = untrusted_context_message(
"email writing style",
"EMAIL WRITING STYLE AND IDENTITY — FOLLOW FOR ANY EMAIL DRAFT OR SEND:\n" + _style,
)
except Exception:
pass
@@ -1752,6 +1761,25 @@ def _build_system_prompt(
except Exception as _sk_err:
logger.debug(f"skill injection failed (non-fatal): {_sk_err}")
# Integration descriptions — user-editable fields, must not be in system role.
if not suppress_local_context:
try:
from src.integrations import get_integrations_prompt
_integ_prompt = get_integrations_prompt()
if _integ_prompt:
_integ_message = untrusted_context_message("integrations", _integ_prompt)
except Exception as _integ_err:
logger.debug(f"Integration prompt injection skipped: {_integ_err}")
# MCP tool descriptions — sourced from external servers, must not be in system role.
if mcp_mgr:
try:
_mcp_desc = mcp_mgr.get_tool_descriptions_for_prompt(mcp_disabled_map or {})
if _mcp_desc:
_mcp_desc_message = untrusted_context_message("MCP tools", _mcp_desc)
except Exception as _mcp_err:
logger.debug(f"MCP description injection skipped: {_mcp_err}")
agent_msg = {"role": "system", "content": agent_prompt}
insert_idx = 0
for i, msg in enumerate(messages):
@@ -1791,6 +1819,15 @@ def _build_system_prompt(
if _email_message:
merged.insert(last_user_idx, _email_message)
last_user_idx += 1
if _email_style_message:
merged.insert(last_user_idx, _email_style_message)
last_user_idx += 1
if _integ_message:
merged.insert(last_user_idx, _integ_message)
last_user_idx += 1
if _mcp_desc_message:
merged.insert(last_user_idx, _mcp_desc_message)
last_user_idx += 1
if _skills_message:
merged.insert(last_user_idx, _skills_message)
last_user_idx += 1
@@ -1897,19 +1934,6 @@ def _build_base_prompt(
# Skill index is a soft enhancement — never fail prompt assembly on it.
logger.debug(f"Skill-index injection skipped: {_e}")
# Inject integration descriptions
if not suppress_local_context:
from src.integrations import get_integrations_prompt
integ_prompt = get_integrations_prompt()
if integ_prompt:
agent_prompt += "\n\n" + integ_prompt
# Inject MCP tool descriptions
if mcp_mgr:
mcp_desc = mcp_mgr.get_tool_descriptions_for_prompt(mcp_disabled_map or {})
if mcp_desc:
agent_prompt += mcp_desc
return agent_prompt, skill_index_block
+6 -4
View File
@@ -14,6 +14,7 @@ Sub-modules:
import logging
from collections import namedtuple
from src.tool_security import BUILTIN_EMAIL_TOOLS
from src.tool_utils import _truncate, get_mcp_manager, set_mcp_manager
logger = logging.getLogger(__name__)
@@ -86,9 +87,10 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
"manage_endpoints", "manage_mcp", "manage_webhooks",
"manage_tokens", "manage_documents", "manage_settings",
"manage_notes", "manage_calendar",
"resolve_contact", "manage_contact", "list_email_accounts", "send_email", "list_emails",
"read_email", "reply_to_email", "bulk_email", "archive_email",
"delete_email", "mark_email_read",
"resolve_contact", "manage_contact",
# Email tool names come from BUILTIN_EMAIL_TOOLS (unioned below)
# so the fence regex, dispatch, and non-admin blocklist all cover
# the same set.
# Cookbook tools (LLM serving + downloads). Without these
# entries, native function calls to e.g. list_served_models
# are rejected as "Unknown function call" before reaching
@@ -105,7 +107,7 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
# Generic loopback to any UI-button endpoint (cookbook,
# gallery, email folders, etc.) — agent uses this when
# there's no named tool wrapper for the action.
"app_api"}
"app_api"} | BUILTIN_EMAIL_TOOLS
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
+9 -1
View File
@@ -14,6 +14,7 @@ import logging
from typing import Optional, Dict
from src.tool_utils import get_mcp_manager, _parse_tool_args
from src.tool_security import BUILTIN_EMAIL_TOOLS
logger = logging.getLogger(__name__)
@@ -706,7 +707,14 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"tasks": ["manage_tasks"],
"notes": ["manage_notes"],
"calendar": ["manage_calendar"],
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
# The full built-in email tool set, in BOTH spellings: the
# qualified mcp__email__* names drive MCP schema hiding, the
# bare names drive function-schema hiding, and the runtime
# gate accepts either — deriving from BUILTIN_EMAIL_TOOLS
# keeps the toggle covering every tool the email server
# exposes instead of a hand-picked subset.
"email": sorted(BUILTIN_EMAIL_TOOLS)
+ [f"mcp__email__{t}" for t in sorted(BUILTIN_EMAIL_TOOLS)],
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog)
}
+43 -3
View File
@@ -186,6 +186,21 @@ class WriteFileTool:
lines = content.split("\n", 1)
raw_path = lines[0].strip()
body = lines[1] if len(lines) > 1 else ""
# Decode JSON-object args (the fenced inline-args shape
# ```write_file {"path": "...", "content": "..."}```), matching
# ReadFileTool above. Without this the whole JSON string becomes the
# path and the file is written under a garbage name. This is the live
# path: there is no filesystem MCP server, so write_file always runs
# here via _direct_fallback, not through _build_mcp_args.
_stripped = content.strip()
if _stripped.startswith("{"):
try:
_a = json.loads(_stripped)
if isinstance(_a, dict) and "path" in _a:
raw_path = str(_a.get("path", "")).strip()
body = str(_a.get("content", ""))
except (json.JSONDecodeError, TypeError, ValueError):
pass
try:
path = _resolve_tool_path(raw_path)
except ValueError as e:
@@ -288,11 +303,26 @@ class GlobTool:
base = os.path.abspath(root)
if not os.path.isdir(base):
return None, f"glob: {root}: not a directory"
rbase = os.path.realpath(base)
norm_pat = pattern.replace("\\", "/")
# Fast path: literal pattern (no wildcards) → direct path lookup.
if not any(c in norm_pat for c in "*?["):
cand = os.path.normpath(os.path.join(base, norm_pat))
if os.path.exists(cand):
cand = os.path.realpath(os.path.join(base, norm_pat))
# Keep the literal lookup inside the search root. os.path.join
# lets an absolute pattern (or one containing ../) escape `base`,
# which would turn glob into an existence/path oracle for
# arbitrary host files — bypassing the workspace/allowlist
# confinement that _resolve_search_root applies to the root.
# An escaping literal falls through to the walk, which only ever
# yields paths under base.
nbase = os.path.normcase(rbase)
try:
inside = cand == rbase or os.path.commonpath(
[os.path.normcase(cand), nbase]
) == nbase
except ValueError:
inside = False
if inside and os.path.exists(cand):
return [cand], None
# Literal not at exact path — fall through to walk so
# e.g. "foo.py" still matches at any depth (like rglob).
@@ -333,7 +363,13 @@ class GlobTool:
class GrepTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
from src.tool_execution import (
_SENSITIVE_FILE_PATTERNS,
_is_sensitive_path,
_resolve_tool_path,
_resolve_search_root,
_truncate,
)
args: Dict[str, Any] = {}
_s = (content or "").strip()
if _s.startswith("{"):
@@ -369,6 +405,8 @@ class GrepTool:
cmd.append("--ignore-case")
if glob_pat:
cmd += ["--glob", glob_pat]
for _pat in _SENSITIVE_FILE_PATTERNS:
cmd += ["--glob", f"!*{_pat}*"]
for _d in _CODENAV_SKIP_DIRS:
cmd += ["--glob", f"!**/{_d}/**"]
cmd += ["--regexp", pattern, root]
@@ -399,6 +437,8 @@ class GrepTool:
for fp in file_iter:
if len(hits) >= max_hits:
break
if _is_sensitive_path(os.path.realpath(fp)):
continue
try:
with open(fp, "r", encoding="utf-8", errors="strict") as f:
for i, line in enumerate(f, 1):
+11 -3
View File
@@ -6,7 +6,7 @@ Reusable document actions callable from both REST routes and the task scheduler.
import logging
import re
from datetime import datetime
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -77,12 +77,20 @@ async def run_document_tidy(owner: str) -> str:
deleted = 0
kept = 0
survivors = [] # docs that pass the junk rules, considered for dedup
now = datetime.utcnow()
now = datetime.now(timezone.utc)
for doc in docs:
created = doc.created_at
if created and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
# Skip freshly created documents to avoid deleting them while the user is actively editing
if created and (now - created).total_seconds() < 900: # 15 minutes
survivors.append(doc)
continue
content = (doc.current_content or "").strip()
title = (doc.title or "").strip().lower()
created = doc.created_at
is_fresh_empty = (
not content
and created is not None
+62
View File
@@ -0,0 +1,62 @@
"""Policy checks for explicit host Docker access from a container."""
import os
import stat
from collections.abc import Mapping
HOST_DOCKER_ENV_VAR = "ODYSSEUS_ENABLE_HOST_DOCKER"
HOST_DOCKER_SOCKET_PATH = "/var/run/docker.sock"
HOST_DOCKER_ACCESS_HINT = (
"Local Docker daemon access is disabled inside the Odysseus container; a "
"Docker CLI alone is not enough. Default Docker Compose intentionally does "
"not mount the host Docker socket. Raw socket access is high-trust and can "
"grant broad control over the host Docker daemon. If you accept that risk, "
"enable docker/host-docker.yml. Remote server Docker workflows over SSH "
"remain preferred."
)
def running_in_container(
dockerenv_path: str = "/.dockerenv",
cgroup_path: str = "/proc/1/cgroup",
) -> bool:
if os.path.exists(dockerenv_path):
return True
try:
with open(cgroup_path, "r", encoding="utf-8") as handle:
contents = handle.read()
except OSError:
return False
return any(token in contents for token in ("docker", "containerd", "kubepods"))
def host_docker_access_enabled(
socket_path: str = HOST_DOCKER_SOCKET_PATH,
*,
environ: Mapping[str, str] | None = None,
) -> bool:
env = os.environ if environ is None else environ
if env.get(HOST_DOCKER_ENV_VAR, "").strip().lower() != "true":
return False
try:
mode = os.stat(socket_path).st_mode
except OSError:
return False
return stat.S_ISSOCK(mode)
def local_docker_available(
*,
cli_available: bool,
in_container: bool | None = None,
environ: Mapping[str, str] | None = None,
socket_path: str = HOST_DOCKER_SOCKET_PATH,
) -> bool:
if not cli_available:
return False
containerized = running_in_container() if in_container is None else in_container
if not containerized:
return True
return host_docker_access_enabled(socket_path, environ=environ)
+86 -21
View File
@@ -316,6 +316,83 @@ def _lookup_known(model: str) -> Optional[int]:
return best_ctx
def _model_ctx_from_entry(m: dict) -> Optional[int]:
"""Extract a positive context window from one /models catalog entry.
Checks the common top-level fields first, then a nested meta/model_extra
object. Returns None when no positive window is reported.
"""
if not isinstance(m, dict):
return None
for field in (
"context_length",
"context_window",
"max_model_len",
"max_context_length",
"max_seq_len",
):
val = m.get(field)
if val and isinstance(val, (int, float)) and val > 0:
return int(val)
meta = m.get("meta") or m.get("model_extra") or {}
if isinstance(meta, dict):
# n_ctx is the actual serving context (set via -c flag in llama.cpp)
for field in ("n_ctx", "context_length", "context_window", "max_model_len"):
val = meta.get(field)
if val and isinstance(val, (int, float)) and val > 0:
return int(val)
return None
# Per-endpoint cache of the {model_id: context_length} map parsed from a
# proxy/api catalog. api/proxy endpoints skip the /models download on every
# lookup because a large catalog is expensive; caching the whole map lets us
# pay that download at most once per endpoint instead of once per model.
_catalog_ctx_cache: Dict[str, Dict[str, int]] = {}
def _proxy_catalog_context(endpoint_url: str, model: str) -> Optional[int]:
"""Context window for a model read from the endpoint's /models catalog.
Fetches the catalog once per endpoint and caches the full id->context map,
so an api/proxy endpoint serving a model that isn't in KNOWN_CONTEXT_WINDOWS
(e.g. a new OpenRouter model) still reports its real window instead of the
bare default. Returns None when the catalog can't be read or doesn't list a
positive window for the model.
"""
cat = _catalog_ctx_cache.get(endpoint_url)
if cat is None:
from src.endpoint_resolver import build_models_url
try:
r = httpx.get(build_models_url(endpoint_url), timeout=REQUEST_TIMEOUT)
except Exception as e:
logger.debug(f"Failed to fetch proxy catalog for context length: {e}")
return None
if not r.is_success:
return None
cat = {}
try:
for m in (r.json().get("data") or []):
mid = m.get("id") if isinstance(m, dict) else None
ctx = _model_ctx_from_entry(m) if mid else None
if mid and ctx:
cat[mid] = ctx
except Exception as e:
logger.debug(f"Failed to parse proxy catalog for context length: {e}")
return None
_catalog_ctx_cache[endpoint_url] = cat
if model in cat:
return cat[model]
# Catalog ids may carry a provider prefix (e.g. "openai/gpt-4o") while the
# session stores the bare id; match on the trailing segment as a fallback.
base = model.split("/")[-1]
for mid, ctx in cat.items():
if mid.split("/")[-1] == base:
return ctx
return None
def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
"""Query the model API for context length. Returns (context_length, known) where
``known`` is False only for the bare DEFAULT_CONTEXT fallback."""
@@ -330,6 +407,14 @@ def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
if known:
logger.info(f"Using known context window for {model}: {known}")
return known, True
# Not in the known table: read the real window from the catalog (cached
# once per endpoint) instead of capping every unknown model at the
# default — that under-reported large windows on aggregators like
# OpenRouter (issue #4886).
api_ctx = _proxy_catalog_context(endpoint_url, model)
if api_ctx:
logger.info(f"Proxy catalog reports context window for {model}: {api_ctx}")
return api_ctx, True
return DEFAULT_CONTEXT, False
# Try llama.cpp /slots endpoint first — reports actual serving context
@@ -370,27 +455,7 @@ def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
for m in models_list:
mid = m.get("id", "")
if mid == model or mid.split("/")[-1] == model.split("/")[-1]:
for field in (
"context_length",
"context_window",
"max_model_len",
"max_context_length",
"max_seq_len",
):
val = m.get(field)
if val and isinstance(val, (int, float)) and val > 0:
api_ctx = int(val)
break
if not api_ctx:
meta = m.get("meta") or m.get("model_extra") or {}
if isinstance(meta, dict):
# n_ctx is the actual serving context (set via -c flag in llama.cpp)
for field in ("n_ctx", "context_length", "context_window", "max_model_len"):
val = meta.get(field)
if val and isinstance(val, (int, float)) and val > 0:
api_ctx = int(val)
break
api_ctx = _model_ctx_from_entry(m)
break
except Exception as e:
logger.debug(f"Failed to query context length for {model}: {e}")
+93 -3
View File
@@ -21,7 +21,12 @@ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user
from src.tool_security import (
BUILTIN_EMAIL_TOOLS,
email_tool_policy_names,
is_public_blocked_tool,
owner_is_admin_or_single_user,
)
from src.tool_policy import ToolPolicy
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
from src.tool_utils import _truncate, get_mcp_manager
@@ -390,8 +395,42 @@ _MCP_ARG_PARSERS: Dict[str, Callable[[str], Dict[str, str]]] = {
}
# Primary argument key(s) for the legacy line-parsed tools. When a fenced
# block's content is a JSON object carrying one of these keys, it's structured
# inline args (the relaxed parser's ```web_search {"query": "..."}``` shape) —
# use the object directly instead of letting the line-based parsers wrap the
# whole JSON string as the query/url/path/prompt. Keyed off membership only
# (the primary key never changes), so this can't drift; an unrecognized object
# safely falls through to the line-based parser, i.e. the previous behavior.
#
# IMPORTANT — this only covers the MCP path. _build_mcp_args is reached via
# _call_mcp_tool only for _MCP_TOOL_MAP tools (so an entry outside that map is
# dead, as manage_memory was). And of these, only generate_image has a live MCP
# server today; web_search/web_fetch/read_file/write_file have none, so they run
# via _direct_fallback -> TOOL_HANDLERS, whose handlers decode JSON themselves
# (see ReadFileTool/WriteFileTool/WebSearchTool/WebFetchTool). The entries here
# are kept as defense-in-depth for if/when those servers are added. The live
# fix for each server-less tool lives in its handler. test_write_file_inline_
# json_args and test_mcp_json_primary_keys_are_all_live pin both halves.
_MCP_JSON_PRIMARY_KEYS: Dict[str, tuple] = {
"web_search": ("query", "queries"),
"web_fetch": ("url",),
"read_file": ("path",),
"write_file": ("path",),
"generate_image": ("prompt",),
}
def _build_mcp_args(tool: str, content: str) -> Dict:
"""Convert fenced-block text content to structured MCP arguments."""
primaries = _MCP_JSON_PRIMARY_KEYS.get(tool)
if primaries and content.strip().startswith("{"):
try:
decoded = json.loads(content.strip())
except (json.JSONDecodeError, TypeError):
decoded = None
if isinstance(decoded, dict) and any(k in decoded for k in primaries):
return decoded
parser = _MCP_ARG_PARSERS.get(tool)
return parser(content) if parser else {}
@@ -596,6 +635,12 @@ async def _execute_tool_block_impl(
tool = block.tool_type
content = block.content
# The block/disable gates below must match every policy-equivalent
# spelling of the tool name (bare email names alias their mcp__email__
# form — see email_tool_policy_names), not just the spelling the model
# happened to emit.
policy_names = email_tool_policy_names(tool)
# Misformatted tool call detection: model put JSON inside ```python``` (or
# similar) without naming the tool. Common with MiniMax-style outputs.
# Return a helpful error so the model retries with the correct format.
@@ -623,13 +668,13 @@ async def _execute_tool_block_impl(
pass
# Reject tools that the user has disabled for this request
if disabled_tools and tool in disabled_tools:
if disabled_tools and not policy_names.isdisjoint(disabled_tools):
desc = f"{tool}: BLOCKED"
result = {"error": f"Tool '{tool}' is disabled by user.", "exit_code": 1}
logger.info(f"Tool blocked by user: {tool}")
return desc, result
if tool_policy and tool_policy.blocks(tool):
if tool_policy and any(tool_policy.blocks(name) for name in policy_names):
desc = f"{tool}: BLOCKED"
result = {
"error": f"Execution of tool '{tool}' is forbade by the active guide-only policy.",
@@ -823,6 +868,51 @@ async def _execute_tool_block_impl(
elif tool == "vault_unlock":
desc = "vault_unlock"
result = await do_vault_unlock(content, owner=owner)
elif tool in BUILTIN_EMAIL_TOOLS:
# Bare email tool name from fenced-block models (e.g. Ollama) — route to MCP email server.
# Non-admin owners never reach here: BUILTIN_EMAIL_TOOLS ⊆ NON_ADMIN_BLOCKED_TOOLS,
# so is_public_blocked_tool() above already rejected them.
mcp = get_mcp_manager()
qualified = f"mcp__email__{tool}"
desc = f"email: {tool}"
if mcp:
_raw = content.strip()
args = {}
_args_error = None
if _raw:
# A non-empty body is always meant to be the call's arguments,
# and every email tool takes a JSON object. Anything that
# isn't one is a correctable error — NOT a silent empty-args
# call, which would read the DEFAULT mailbox/folder instead of
# the one the model meant (#3966 class). Only an EMPTY body
# keeps the no-arg path (e.g. ```list_email_accounts```).
try:
parsed = json.loads(_raw)
except (json.JSONDecodeError, TypeError) as _je:
# Covers both `{account: "work"}` (looks like JSON, bad)
# and `account: work` (not JSON at all).
_args_error = (
f"'{tool}' arguments are not valid JSON ({_je}). "
'Send a JSON object, e.g. {"account": "work"} — '
"keys and string values need double quotes."
)
else:
if isinstance(parsed, dict):
args = parsed
else:
_args_error = (
f"'{tool}' arguments must be a JSON object, "
'e.g. {"uid": "..."} — got a JSON array/value instead.'
)
if _args_error is not None:
result = {"error": _args_error, "exit_code": 1}
else:
if owner:
args = dict(args)
args[_EMAIL_MCP_OWNER_ARG] = owner
result = await mcp.call_tool(qualified, args)
else:
result = {"error": "MCP manager not available", "exit_code": 1}
elif tool.startswith("mcp__"):
# MCP tool dispatch
mcp = get_mcp_manager()
+123 -6
View File
@@ -10,9 +10,10 @@ import bisect
import json
import logging
import re
from typing import List, Optional
from typing import List, Optional, Tuple
from src.agent_tools import ToolBlock, TOOL_TAGS
from src.tool_security import BUILTIN_EMAIL_TOOLS
logger = logging.getLogger(__name__)
@@ -20,12 +21,63 @@ logger = logging.getLogger(__name__)
# Regex patterns
# ---------------------------------------------------------------------------
# Pattern 1: ```bash ... ``` fenced code blocks
# Pattern 1: ```bash ... ``` fenced code blocks. The tag may be followed by a
# newline (classic form) or by inline JSON args on the same line
# (```list_email_accounts {}). The same-line part is captured separately
# (group 2) and judged by _fenced_tool_call below — the regex alone only
# requires it to start with { or [; anything else after the tag is a Markdown
# info string (```python title="example.py") and the fence never matches.
# (?![\w-]) keeps the alternation from prefix-matching longer fence tags:
# without it, ```python3 would match as tool "python" with content "3\n..."
# and execute as code.
_TOOL_BLOCK_RE = re.compile(
r"```(" + "|".join(TOOL_TAGS) + r")\s*\n([\s\S]*?)```",
r"```(" + "|".join(TOOL_TAGS) + r")(?![\w-])"
r"[ \t]*([{\[][^\n]*?)?[ \t]*(?=\r?\n|```)\r?\n?([\s\S]*?)```",
re.IGNORECASE,
)
# Tags whose fenced content is raw code, not JSON args. Same-line text after
# these tags is Markdown fence metadata on a real language (```bash {title=
# "setup"}), never inline tool args — only the classic tag-then-newline form
# executes for them.
_CODE_FENCE_TAGS = frozenset({"bash", "python"})
def _fenced_tool_call(m) -> Optional[Tuple[str, str]]:
"""Classify a Pattern-1 fence match: (tag, content) when it is an
executable tool call, None when the fence must stay display text.
Shared by parse_tool_blocks and strip_tool_blocks so the execute and
display decisions can never disagree: a fence that doesn't execute is
never stripped, and vice versa.
Same-line text after the tag only counts as inline tool args when the
tag's tool takes JSON args (not a code tag) AND the text is valid
standalone JSON. ```bash {title="setup"} and ```python {"x": 1} are
fence attributes on real languages, and {title="x"} on any tag is
metadata, not arguments — all of those stay visible and inert.
"""
tag = m.group(1).lower()
inline = (m.group(2) or "").strip()
body = (m.group(3) or "").strip()
if not inline:
return tag, body
if tag in _CODE_FENCE_TAGS:
return None
# Inline args may continue onto following lines (a JSON object opened on
# the tag line); the combined text must parse as JSON or nothing runs.
content = f"{inline}\n{body}" if body else inline
try:
json.loads(content)
except (ValueError, TypeError):
return None
return tag, content
def _strip_executed_fence(m) -> str:
"""re.sub callback: remove only fences that parse as tool calls."""
return "" if _fenced_tool_call(m) is not None else m.group(0)
# Pattern 2: [TOOL_CALL] ... [/TOOL_CALL] blocks (some models use this format)
# Matches: {tool => "shell", args => {--command "ls -la"}} etc.
_TOOL_CALL_RE = re.compile(
@@ -114,6 +166,13 @@ _TOOL_CODE_RE = re.compile(
_TOOL_CODE_OPEN_RE = re.compile(r"<tool_code>\s*\{", re.IGNORECASE)
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*</tool_code>", re.IGNORECASE)
# Pattern 4b: Gemma-style <|tool_call|> call:tool_name{args} <tool_call|>
_GEMMA_TOOL_CALL_RE = re.compile(
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*(\{[\s\S]*?\})\s*<\|?tool_call\|?>",
re.IGNORECASE,
)
# Pattern 5: DeepSeek DSML markup leaking into content. When deepseek
# models can't emit structured tool_calls (e.g. we sent no tool schemas
# that round, or the API didn't parse them), they fall back to raw
@@ -791,6 +850,40 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
return ToolBlock(tool_name, content.strip())
return None
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
tool_name = tool_name.strip().lower().replace("-", "_")
body = body.strip()
if not body:
return None
# Replace custom Gemma string delimiters with standard quotes
body = body.replace('<|"|>', '"').replace('<|"', '"').replace('"|>', '"')
# Try standard JSON parsing
params = {}
try:
params = json.loads(body)
if not isinstance(params, dict):
params = {}
except json.JSONDecodeError:
# Try unquoted keys repair: e.g. {query: "..."} -> {"query": "..."}
try:
repaired = re.sub(r'([{,]\s*)(\w+)\s*:', r'\1"\2":', body)
params = json.loads(repaired)
if not isinstance(params, dict):
params = {}
except Exception:
# Simple regex key-value extraction fallback
params = {}
for m in re.finditer(r'(\w+)\s*:\s*["\']?(.*?)["\']?(?=\s*,\s*\w+\s*:|\s*\})', body):
k = m.group(1)
v = m.group(2).strip()
params[k] = v
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block(tool_name, json.dumps(params))
def _iter_delimited(text, open_re, close_re):
"""Yield ``(match_start, inner_start, inner_end, match_end)`` for each
@@ -934,9 +1027,20 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
# Pattern 1: fenced code blocks (skipped when `skip_fenced` — see docstring).
if not skip_fenced:
for m in _TOOL_BLOCK_RE.finditer(text):
tag = m.group(1).lower()
content = m.group(2).strip()
call = _fenced_tool_call(m)
if call is None:
continue
tag, content = call
if not content:
# An empty fence is still an unambiguous call for the email
# tools — ```list_email_accounts``` with no body is a shape
# local models really emit for no-arg tools. Dispatch with
# empty args and let the tool's own validation answer;
# silently dropping the call left models concluding email was
# broken. Other tags (bash, python, ...) keep skipping: empty
# content is nothing to run.
if tag in BUILTIN_EMAIL_TOOLS:
blocks.append(ToolBlock(tag, ""))
continue
# If a code block's content is an <invoke> XML call (some models wrap
# tool calls in ```python or ```xml fences), parse the invoke instead.
@@ -1023,6 +1127,15 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if block:
blocks.append(block)
# Pattern 4b: Gemma-style <|tool_call|> blocks
if not blocks:
for m in _GEMMA_TOOL_CALL_RE.finditer(text):
tool_name = m.group(1)
body = m.group(2)
block = _parse_gemma_tool_call(tool_name, body)
if block:
blocks.append(block)
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
if not blocks and not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(text)
@@ -1056,7 +1169,10 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
# Normalize DSML first so its markup gets stripped by the <invoke>
# / <tool_call> removers below instead of leaking to the user.
text = _normalize_dsml(text)
cleaned = text if skip_fenced else _TOOL_BLOCK_RE.sub('', text)
# Keep the executed-vs-illustrative fence distinction (only strip fences
# that actually dispatched; leave example fences from native models inert
# but visible), then remove [TOOL_CALL]{...}[/TOOL_CALL] markup.
cleaned = text if skip_fenced else _TOOL_BLOCK_RE.sub(_strip_executed_fence, text)
# Forward-only removal mirrors parse_tool_blocks: _strip_delimited pairs each
# opener with a later closer and stops when none is reachable, so untrusted
# output can't drive the O(n^2) lazy-rescan (ReDoS); see _iter_delimited.
@@ -1065,6 +1181,7 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
if not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(cleaned)
if raw_web_json:
+5 -4
View File
@@ -14,6 +14,7 @@ from typing import Optional
from src.agent_tools import ToolBlock, TOOL_TAGS
from src.tool_parsing import _TOOL_NAME_MAP
from src.tool_security import BUILTIN_EMAIL_TOOLS
logger = logging.getLogger(__name__)
@@ -1223,15 +1224,15 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
return None
tool_type = _TOOL_NAME_MAP.get(name, name)
_BUILTIN_EMAIL_TOOLS = {"list_email_accounts", "send_email", "list_emails", "read_email", "reply_to_email",
"archive_email", "delete_email", "mark_email_read", "bulk_email", "download_attachment"}
# Some models emit valid JSON that isn't an object (e.g. a bare array
# ["ls -la"], string, or number) as function arguments. Most local tools keep
# the legacy empty-object coercion for stream robustness, but email MCP tools
# must fail closed so a malformed call cannot read the default mailbox.
# Uses the shared BUILTIN_EMAIL_TOOLS (single source of truth) so the
# fail-closed set can't drift from the dispatch/blocklist sets.
if not isinstance(args, dict):
if tool_type.startswith("mcp__email__") or name in _BUILTIN_EMAIL_TOOLS:
if tool_type.startswith("mcp__email__") or name in BUILTIN_EMAIL_TOOLS:
logger.warning(f"Non-object email function call arguments for {name}: {args!r}; rejecting")
return None
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
@@ -1242,7 +1243,7 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
content = json.dumps(args) if args else "{}"
return ToolBlock(tool_type, content)
# Email tools are implemented as MCP — route them to email
if name in _BUILTIN_EMAIL_TOOLS:
if name in BUILTIN_EMAIL_TOOLS:
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
if tool_type not in TOOL_TAGS:
logger.warning(f"Unknown function call: {name}")
+70 -7
View File
@@ -8,10 +8,36 @@ from typing import Optional, Set
logger = logging.getLogger(__name__)
# Every tool exposed by the built-in email MCP server
# (mcp_servers/email_server.py). Single source of truth: the fence tags
# (TOOL_TAGS), bare-name dispatch (tool_execution), native-call mapping
# (tool_schemas), and the non-admin blocklist below all derive from this set,
# so a tool added to the email server can't become reachable under its bare
# name without also being blocked for non-admins.
BUILTIN_EMAIL_TOOLS = frozenset({
"list_email_accounts",
"list_emails",
"read_email",
"search_emails",
"send_email",
"reply_to_email",
"draft_email",
"draft_email_reply",
"ai_draft_email_reply",
"archive_email",
"delete_email",
"mark_email_read",
"bulk_email",
"download_attachment",
})
# Tools regular/public users must not execute directly. These either expose
# server/runtime access, sensitive user data, external messaging, persistent
# state changes, or generic loopback/integration surfaces.
NON_ADMIN_BLOCKED_TOOLS = {
# state changes, or generic loopback/integration surfaces. All email tools are
# included (SECURITY.md: email/MCP capabilities are privileged admin
# functionality).
NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
"bash",
"python",
"manage_bg_jobs",
@@ -34,10 +60,6 @@ NON_ADMIN_BLOCKED_TOOLS = {
"manage_settings",
"api_call",
"app_api",
"send_email",
"reply_to_email",
"list_emails",
"read_email",
"resolve_contact",
"manage_contact",
"manage_calendar",
@@ -74,8 +96,20 @@ PLAN_MODE_READONLY_TOOLS = {
"search_chats",
"list_models",
"list_sessions",
# Read-only email tools. list_email_accounts must be here because the
# bare/qualified alias gate in execute_tool_block works both ways: it has
# a native function schema, so plan mode's schema-derived bare denylist
# contains it — and without this allowlist entry that bare entry would
# also block the qualified mcp__email__list_email_accounts call that the
# MCP read-only filter deliberately allows.
"list_email_accounts",
"list_emails",
"read_email",
# Explicitly read-only rather than allowed-by-omission: this PR makes
# every BUILTIN_EMAIL_TOOLS name fence-taggable, so each one must be
# classified — see the plan-mode partition test in
# tests/test_email_registry_sync.py.
"search_emails",
"list_served_models",
"list_downloads",
"list_cached_models",
@@ -109,7 +143,14 @@ _PLAN_MODE_KNOWN_MUTATORS = {
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
"manage_calendar", "api_call", "app_api", "ui_control",
"send_email", "reply_to_email", "bulk_email", "delete_email",
"archive_email", "mark_email_read", "download_model", "serve_model",
"archive_email", "mark_email_read",
# The draft tools create documents and download_attachment writes to
# disk — mutating. They have no native schemas (yet), so without these
# static entries plan-mode safety for their bare fence tags would depend
# entirely on the MCP read-only inventory being present and current.
"draft_email", "draft_email_reply", "ai_draft_email_reply",
"download_attachment",
"download_model", "serve_model",
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
"generate_image", "edit_image", "trigger_research", "manage_research",
# Shell is never read-only-safe; block it explicitly so it stays out of plan
@@ -151,6 +192,28 @@ def plan_mode_disabled_tools() -> Set[str]:
return (all_names | _PLAN_MODE_KNOWN_MUTATORS) - PLAN_MODE_READONLY_TOOLS
def email_tool_policy_names(tool_name: str) -> frozenset:
"""All policy-equivalent spellings of a tool name.
A bare built-in email tool name and its MCP-qualified mcp__email__<name>
form dispatch to the same email server tool, but policy sources spell
them either way — plan mode and the MCP settings toggle write qualified
names into denylists, chat-level toggles write bare ones. Every gate must
match against the full alias set, or a call in one spelling slips past a
denylist entry written in the other. Non-email names alias only to
themselves.
"""
if not isinstance(tool_name, str):
return frozenset((tool_name,))
if tool_name in BUILTIN_EMAIL_TOOLS:
return frozenset((tool_name, f"mcp__email__{tool_name}"))
if tool_name.startswith("mcp__email__"):
bare = tool_name[len("mcp__email__"):]
if bare in BUILTIN_EMAIL_TOOLS:
return frozenset((tool_name, bare))
return frozenset((tool_name,))
def is_public_blocked_tool(tool_name: Optional[str]) -> bool:
"""Return True when a non-admin/public user must not execute this tool.