Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8416bc2142 | |||
| 48b5bc6038 | |||
| 4ff73fb32c | |||
| 73a88a02fe | |||
| f9c2565ab4 | |||
| ad5f973a8d | |||
| 0791efe2c3 | |||
| 934fbe3c06 | |||
| 6302e56e7c | |||
| 868b3c07e3 |
@@ -0,0 +1,422 @@
|
||||
# Hermes Agent v0.4.0 (v2026.3.23)
|
||||
|
||||
**Release Date:** March 23, 2026
|
||||
|
||||
> The biggest release yet — 300 merged PRs in one week. Streaming output, native browser tools, Skills Hub, plugin system, 7 new messaging platforms, MCP server management, @ context references, prompt caching, API server, and a sweeping reliability overhaul across every subsystem.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Streaming CLI output** — Real-time token streaming enabled by default in CLI mode with proper tool progress spinners during streaming ([#2251](https://github.com/NousResearch/hermes-agent/pull/2251), [#2340](https://github.com/NousResearch/hermes-agent/pull/2340), [#2161](https://github.com/NousResearch/hermes-agent/pull/2161))
|
||||
- **Native browser tools** — Full Browserbase-powered browser automation: navigate, click, type, screenshot, scrape — plus an interactive `/browser` CLI command ([#2270](https://github.com/NousResearch/hermes-agent/pull/2270), [#2273](https://github.com/NousResearch/hermes-agent/pull/2273))
|
||||
- **Skills Hub** — Discover, install, and manage skills from curated community taps with `/skills` commands ([#2235](https://github.com/NousResearch/hermes-agent/pull/2235))
|
||||
- **Plugin system** — TUI extension hooks for building custom CLIs on top of Hermes, plus `hermes plugins install/remove/list` commands and slash command registration for plugins ([#2333](https://github.com/NousResearch/hermes-agent/pull/2333), [#2337](https://github.com/NousResearch/hermes-agent/pull/2337), [#2359](https://github.com/NousResearch/hermes-agent/pull/2359))
|
||||
- **7 new messaging platforms** — Signal, DingTalk, SMS (Twilio), Mattermost, Matrix, WhatsApp bridge, and Webhook adapters join Telegram and Discord ([#2206](https://github.com/NousResearch/hermes-agent/pull/2206), [#1685](https://github.com/NousResearch/hermes-agent/pull/1685), [#1688](https://github.com/NousResearch/hermes-agent/pull/1688), [#1683](https://github.com/NousResearch/hermes-agent/pull/1683), [#2168](https://github.com/NousResearch/hermes-agent/pull/2168), [#2166](https://github.com/NousResearch/hermes-agent/pull/2166))
|
||||
- **@ context references** — Claude Code-style `@file` and `@url` context injection with tab completions ([#2343](https://github.com/NousResearch/hermes-agent/pull/2343), [#2482](https://github.com/NousResearch/hermes-agent/pull/2482))
|
||||
- **OpenAI-compatible API server** — Expose Hermes as an API endpoint with `/api/jobs` for cron management ([#1756](https://github.com/NousResearch/hermes-agent/pull/1756), [#2450](https://github.com/NousResearch/hermes-agent/pull/2450))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### Provider & Model Support
|
||||
- **GitHub Copilot provider** — Full OAuth auth, API routing, token validation, and documentation. Copilot context now correctly resolves to 400k ([#1924](https://github.com/NousResearch/hermes-agent/pull/1924), [#1896](https://github.com/NousResearch/hermes-agent/pull/1896), [#1879](https://github.com/NousResearch/hermes-agent/pull/1879) by @mchzimm, [#2507](https://github.com/NousResearch/hermes-agent/pull/2507))
|
||||
- **Claude Code OAuth provider** — Anthropic-native API mode with dynamic version detection for OAuth user-agent ([#2199](https://github.com/NousResearch/hermes-agent/pull/2199), [#1663](https://github.com/NousResearch/hermes-agent/pull/1663), [#1670](https://github.com/NousResearch/hermes-agent/pull/1670))
|
||||
- **Alibaba Cloud / DashScope provider** — Full integration with DashScope v1 runtime mode, model dot preservation, and 401 auth fixes ([#1673](https://github.com/NousResearch/hermes-agent/pull/1673), [#2332](https://github.com/NousResearch/hermes-agent/pull/2332), [#2459](https://github.com/NousResearch/hermes-agent/pull/2459))
|
||||
- **Kilo Code provider** — Added as first-class inference provider ([#1666](https://github.com/NousResearch/hermes-agent/pull/1666))
|
||||
- **OpenCode Zen and OpenCode Go providers** — New provider backends with custom endpoint support ([#1650](https://github.com/NousResearch/hermes-agent/pull/1650), [#2393](https://github.com/NousResearch/hermes-agent/pull/2393) by @0xbyt4)
|
||||
- **Multi-provider architecture** — Automatic fallback, OpenRouter routing backend, Mistral native tool calling, Google Gemini integration ([#2090](https://github.com/NousResearch/hermes-agent/pull/2090), [#2100](https://github.com/NousResearch/hermes-agent/pull/2100), [#2098](https://github.com/NousResearch/hermes-agent/pull/2098), [#2094](https://github.com/NousResearch/hermes-agent/pull/2094), [#2092](https://github.com/NousResearch/hermes-agent/pull/2092))
|
||||
- **Eager fallback to backup model** on rate-limit errors ([#1730](https://github.com/NousResearch/hermes-agent/pull/1730))
|
||||
- **Endpoint metadata** for custom model context and pricing; query local servers for actual context window size ([#1906](https://github.com/NousResearch/hermes-agent/pull/1906), [#2091](https://github.com/NousResearch/hermes-agent/pull/2091) by @dusterbloom)
|
||||
- **Context length detection overhaul** — models.dev integration, provider-aware resolution, fuzzy matching for custom endpoints, `/v1/props` for llama.cpp ([#2158](https://github.com/NousResearch/hermes-agent/pull/2158), [#2051](https://github.com/NousResearch/hermes-agent/pull/2051), [#2403](https://github.com/NousResearch/hermes-agent/pull/2403))
|
||||
- **Model catalog updates** — gpt-5.4-mini, gpt-5.4-nano, healer-alpha, haiku-4.5, minimax-m2.7, claude 4.6 at 1M context ([#1913](https://github.com/NousResearch/hermes-agent/pull/1913), [#1915](https://github.com/NousResearch/hermes-agent/pull/1915), [#1900](https://github.com/NousResearch/hermes-agent/pull/1900), [#2155](https://github.com/NousResearch/hermes-agent/pull/2155), [#2474](https://github.com/NousResearch/hermes-agent/pull/2474))
|
||||
- **Custom endpoint improvements** — config.yaml `model.base_url` support, custom endpoints use responses API via `api_mode` override, allow custom/local endpoints without API key, fail fast when explicit provider has no key ([#2330](https://github.com/NousResearch/hermes-agent/pull/2330), [#1651](https://github.com/NousResearch/hermes-agent/pull/1651), [#2556](https://github.com/NousResearch/hermes-agent/pull/2556), [#2445](https://github.com/NousResearch/hermes-agent/pull/2445), [#1994](https://github.com/NousResearch/hermes-agent/pull/1994), [#1998](https://github.com/NousResearch/hermes-agent/pull/1998))
|
||||
- Inject model and provider into system prompt ([#1929](https://github.com/NousResearch/hermes-agent/pull/1929))
|
||||
- Fix: prevent Anthropic token leaking to third-party `anthropic_messages` providers ([#2389](https://github.com/NousResearch/hermes-agent/pull/2389))
|
||||
- Fix: prevent Anthropic fallback from inheriting non-Anthropic `base_url` ([#2388](https://github.com/NousResearch/hermes-agent/pull/2388))
|
||||
- Fix: `auxiliary_is_nous` flag never resets — leaked Nous tags to other providers ([#1713](https://github.com/NousResearch/hermes-agent/pull/1713))
|
||||
- Fix: Anthropic `tool_choice 'none'` still allowed tool calls ([#1714](https://github.com/NousResearch/hermes-agent/pull/1714))
|
||||
- Fix: Mistral parser nested JSON fallback extraction ([#2335](https://github.com/NousResearch/hermes-agent/pull/2335))
|
||||
- Fix: MiniMax 401 auth error resolved by defaulting to `anthropic_messages` ([#2103](https://github.com/NousResearch/hermes-agent/pull/2103))
|
||||
- Fix: case-insensitive model family matching ([#2350](https://github.com/NousResearch/hermes-agent/pull/2350))
|
||||
- Fix: ignore placeholder provider keys in activation checks ([#2358](https://github.com/NousResearch/hermes-agent/pull/2358))
|
||||
- Fix: Copilot models response decoding and provider bootstrap error logging ([#2202](https://github.com/NousResearch/hermes-agent/pull/2202))
|
||||
- Fix: Preserve Ollama model:tag colons in context length detection ([#2149](https://github.com/NousResearch/hermes-agent/pull/2149))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **Streaming output** — CLI streaming with proper linebreak handling, iteration boundary prevention, and blank line stacking fixes ([#2251](https://github.com/NousResearch/hermes-agent/pull/2251), [#2340](https://github.com/NousResearch/hermes-agent/pull/2340), [#2258](https://github.com/NousResearch/hermes-agent/pull/2258), [#2413](https://github.com/NousResearch/hermes-agent/pull/2413), [#2473](https://github.com/NousResearch/hermes-agent/pull/2473))
|
||||
- **Context compression overhaul** — Structured summaries, iterative updates, token-budget tail protection, fallback model support ([#2323](https://github.com/NousResearch/hermes-agent/pull/2323), [#2128](https://github.com/NousResearch/hermes-agent/pull/2128), [#2224](https://github.com/NousResearch/hermes-agent/pull/2224), [#1727](https://github.com/NousResearch/hermes-agent/pull/1727))
|
||||
- **Context pressure warnings** for CLI and gateway ([#2159](https://github.com/NousResearch/hermes-agent/pull/2159))
|
||||
- **Prompt caching for gateway** — Cache AIAgent per session, keep assistant turns, fix session restore ([#2282](https://github.com/NousResearch/hermes-agent/pull/2282), [#2284](https://github.com/NousResearch/hermes-agent/pull/2284), [#2361](https://github.com/NousResearch/hermes-agent/pull/2361))
|
||||
- **Show reasoning/thinking blocks** when `show_reasoning` is enabled ([#2118](https://github.com/NousResearch/hermes-agent/pull/2118))
|
||||
- **Subagent delegation** for parallel task execution with thread safety ([#2119](https://github.com/NousResearch/hermes-agent/pull/2119), [#1672](https://github.com/NousResearch/hermes-agent/pull/1672), [#1778](https://github.com/NousResearch/hermes-agent/pull/1778))
|
||||
- **Pre-call sanitization and post-call tool guardrails** ([#1732](https://github.com/NousResearch/hermes-agent/pull/1732))
|
||||
- **Auto-recover** from provider-rejected `tool_choice` by retrying without ([#2174](https://github.com/NousResearch/hermes-agent/pull/2174))
|
||||
- **Rate limit handling** with exponential backoff retry ([#2071](https://github.com/NousResearch/hermes-agent/pull/2071))
|
||||
- Fix: prevent silent tool result loss during context compression ([#1993](https://github.com/NousResearch/hermes-agent/pull/1993))
|
||||
- Fix: handle empty/null function arguments in tool call recovery ([#2163](https://github.com/NousResearch/hermes-agent/pull/2163))
|
||||
- Fix: handle API refusal responses gracefully instead of crashing ([#2156](https://github.com/NousResearch/hermes-agent/pull/2156))
|
||||
- Fix: prevent stuck agent loop on malformed tool calls ([#2114](https://github.com/NousResearch/hermes-agent/pull/2114))
|
||||
- Fix: return JSON parse error to model instead of dispatching with empty args ([#2342](https://github.com/NousResearch/hermes-agent/pull/2342))
|
||||
- Fix: consecutive assistant message merge drops content on mixed types ([#1703](https://github.com/NousResearch/hermes-agent/pull/1703))
|
||||
- Fix: message role alternation violations in JSON recovery and error handler ([#1722](https://github.com/NousResearch/hermes-agent/pull/1722))
|
||||
- Fix: `compression_attempts` resets each iteration — allowed unlimited compressions ([#1723](https://github.com/NousResearch/hermes-agent/pull/1723))
|
||||
- Fix: `length_continue_retries` never resets — later truncations got fewer retries ([#1717](https://github.com/NousResearch/hermes-agent/pull/1717))
|
||||
- Fix: compressor summary role violated consecutive-role constraint ([#1720](https://github.com/NousResearch/hermes-agent/pull/1720), [#1743](https://github.com/NousResearch/hermes-agent/pull/1743))
|
||||
- Fix: correctly handle empty tool results ([#2201](https://github.com/NousResearch/hermes-agent/pull/2201))
|
||||
- Fix: crash on None entry in `tool_calls` list during Anthropic conversion ([#2209](https://github.com/NousResearch/hermes-agent/pull/2209) by @0xbyt4, [#2316](https://github.com/NousResearch/hermes-agent/pull/2316))
|
||||
- Fix: per-thread persistent event loops in worker threads ([#2214](https://github.com/NousResearch/hermes-agent/pull/2214) by @jquesnelle)
|
||||
- Fix: prevent 'event loop already running' when async tools run in parallel ([#2207](https://github.com/NousResearch/hermes-agent/pull/2207))
|
||||
- Fix: strip ANSI escape codes from terminal output before sending to model ([#2115](https://github.com/NousResearch/hermes-agent/pull/2115), [#2585](https://github.com/NousResearch/hermes-agent/pull/2585))
|
||||
- Fix: skip top-level `cache_control` on role:tool for OpenRouter ([#2391](https://github.com/NousResearch/hermes-agent/pull/2391))
|
||||
- Fix: delegate tool — save parent tool names before child construction mutates global ([#2083](https://github.com/NousResearch/hermes-agent/pull/2083) by @ygd58, [#1894](https://github.com/NousResearch/hermes-agent/pull/1894))
|
||||
- Fix: only strip last assistant message if empty string ([#2326](https://github.com/NousResearch/hermes-agent/pull/2326))
|
||||
|
||||
### Session & Memory
|
||||
- **Honcho long-term memory backend** integration ([#2276](https://github.com/NousResearch/hermes-agent/pull/2276))
|
||||
- **Per-session SQLite persistence** for gateway ([#2134](https://github.com/NousResearch/hermes-agent/pull/2134))
|
||||
- **`--resume` flag** for CLI session persistence across restarts + `/resume` and `/sessions` commands ([#2135](https://github.com/NousResearch/hermes-agent/pull/2135), [#2143](https://github.com/NousResearch/hermes-agent/pull/2143))
|
||||
- **Session search** and management slash commands ([#2198](https://github.com/NousResearch/hermes-agent/pull/2198))
|
||||
- **Auto session titles** and `.hermes.md` project config ([#1712](https://github.com/NousResearch/hermes-agent/pull/1712))
|
||||
- **Background memory/skill review** replaces inline nudges ([#2235](https://github.com/NousResearch/hermes-agent/pull/2235))
|
||||
- **SOUL.md** as primary agent identity instead of hardcoded default ([#1922](https://github.com/NousResearch/hermes-agent/pull/1922), [#1927](https://github.com/NousResearch/hermes-agent/pull/1927))
|
||||
- **Priority-based context file selection** + CLAUDE.md support ([#2301](https://github.com/NousResearch/hermes-agent/pull/2301))
|
||||
- Fix: concurrent memory writes silently drop entries — added file locking ([#1726](https://github.com/NousResearch/hermes-agent/pull/1726))
|
||||
- Fix: search all sources by default in `session_search` ([#1892](https://github.com/NousResearch/hermes-agent/pull/1892))
|
||||
- Fix: handle hyphenated FTS5 queries and preserve quoted literals ([#1776](https://github.com/NousResearch/hermes-agent/pull/1776))
|
||||
- Fix: skip corrupt lines in `load_transcript` instead of crashing ([#1744](https://github.com/NousResearch/hermes-agent/pull/1744))
|
||||
- Fix: normalize session keys to prevent case-sensitive duplicates ([#2157](https://github.com/NousResearch/hermes-agent/pull/2157))
|
||||
- Fix: prevent `session_search` crash when no sessions exist ([#2194](https://github.com/NousResearch/hermes-agent/pull/2194))
|
||||
- Fix: reset token counters on new session for accurate usage display ([#2101](https://github.com/NousResearch/hermes-agent/pull/2101) by @InB4DevOps)
|
||||
- Fix: prevent stale memory overwrites by flush agent ([#2687](https://github.com/NousResearch/hermes-agent/pull/2687))
|
||||
- Fix: remove synthetic error message injection, fix session resume after repeated failures ([#2303](https://github.com/NousResearch/hermes-agent/pull/2303))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### New Platform Adapters
|
||||
- **Signal Messenger** adapter ([#2206](https://github.com/NousResearch/hermes-agent/pull/2206)) with attachment handling fix ([#2400](https://github.com/NousResearch/hermes-agent/pull/2400)), group message filtering ([#2297](https://github.com/NousResearch/hermes-agent/pull/2297)), and Note to Self echo-back protection ([#2156](https://github.com/NousResearch/hermes-agent/pull/2156))
|
||||
- **DingTalk** adapter with gateway wiring and setup docs ([#1685](https://github.com/NousResearch/hermes-agent/pull/1685), [#1690](https://github.com/NousResearch/hermes-agent/pull/1690), [#1692](https://github.com/NousResearch/hermes-agent/pull/1692))
|
||||
- **SMS (Twilio)** adapter ([#1688](https://github.com/NousResearch/hermes-agent/pull/1688))
|
||||
- **Mattermost and Matrix** adapters with @-mention-only filter for Mattermost channels ([#1683](https://github.com/NousResearch/hermes-agent/pull/1683), [#2443](https://github.com/NousResearch/hermes-agent/pull/2443))
|
||||
- **WhatsApp bridge** adapter ([#2168](https://github.com/NousResearch/hermes-agent/pull/2168))
|
||||
- **Webhook** platform adapter for external event triggers ([#2166](https://github.com/NousResearch/hermes-agent/pull/2166))
|
||||
- **OpenAI-compatible API server** platform adapter with `/api/jobs` cron management endpoints ([#1756](https://github.com/NousResearch/hermes-agent/pull/1756), [#2450](https://github.com/NousResearch/hermes-agent/pull/2450), [#2456](https://github.com/NousResearch/hermes-agent/pull/2456))
|
||||
|
||||
### Telegram
|
||||
- Auto-detect HTML tags and use `parse_mode=HTML` in `send_message` ([#1709](https://github.com/NousResearch/hermes-agent/pull/1709))
|
||||
- Telegram group vision support + thread-based sessions ([#2153](https://github.com/NousResearch/hermes-agent/pull/2153))
|
||||
- MarkdownV2 support — strikethrough, spoiler, blockquotes, escape parentheses/braces/backslashes/backticks ([#2199](https://github.com/NousResearch/hermes-agent/pull/2199), [#2200](https://github.com/NousResearch/hermes-agent/pull/2200) by @llbn, [#2386](https://github.com/NousResearch/hermes-agent/pull/2386))
|
||||
- Auto-reconnect polling after network interruption ([#2517](https://github.com/NousResearch/hermes-agent/pull/2517))
|
||||
- Aggregate split text messages before dispatching ([#1674](https://github.com/NousResearch/hermes-agent/pull/1674))
|
||||
- Fix: streaming config bridge, not-modified, flood control ([#1782](https://github.com/NousResearch/hermes-agent/pull/1782), [#1783](https://github.com/NousResearch/hermes-agent/pull/1783))
|
||||
- Fix: edited_message event crashes ([#2074](https://github.com/NousResearch/hermes-agent/pull/2074))
|
||||
- Fix: retry 409 polling conflicts before giving up ([#2312](https://github.com/NousResearch/hermes-agent/pull/2312))
|
||||
- Fix: Telegram topic delivery via `platform:chat_id:thread_id` format ([#2455](https://github.com/NousResearch/hermes-agent/pull/2455))
|
||||
|
||||
### Discord
|
||||
- Document caching and text-file injection ([#2503](https://github.com/NousResearch/hermes-agent/pull/2503))
|
||||
- Persistent typing indicator for DMs ([#2468](https://github.com/NousResearch/hermes-agent/pull/2468))
|
||||
- Discord DM vision support — inline images + attachment analysis ([#2186](https://github.com/NousResearch/hermes-agent/pull/2186))
|
||||
- Persist thread participation across gateway restarts ([#1661](https://github.com/NousResearch/hermes-agent/pull/1661))
|
||||
- Fix: prevent gateway crash on non-ASCII guild names ([#2302](https://github.com/NousResearch/hermes-agent/pull/2302))
|
||||
- Fix: handle thread permission errors gracefully ([#2073](https://github.com/NousResearch/hermes-agent/pull/2073))
|
||||
- Fix: properly route slash event handling in threads ([#2460](https://github.com/NousResearch/hermes-agent/pull/2460))
|
||||
- Fix: remove bugged followup messages + remove `/ask` command ([#1836](https://github.com/NousResearch/hermes-agent/pull/1836))
|
||||
- Fix: handle graceful reconnection on WebSocket errors ([#2127](https://github.com/NousResearch/hermes-agent/pull/2127))
|
||||
- Fix: voice channel TTS not working when streaming enabled ([#2322](https://github.com/NousResearch/hermes-agent/pull/2322))
|
||||
|
||||
### Other Platforms
|
||||
- WhatsApp: outbound `send_message` routing ([#1769](https://github.com/NousResearch/hermes-agent/pull/1769) by @sai-samarth), LID format self-chat support ([#1667](https://github.com/NousResearch/hermes-agent/pull/1667)), `reply_prefix` config bridging fix ([#1923](https://github.com/NousResearch/hermes-agent/pull/1923)), restart on bridge child exit ([#2334](https://github.com/NousResearch/hermes-agent/pull/2334)), image/bridge improvements ([#2181](https://github.com/NousResearch/hermes-agent/pull/2181))
|
||||
- Matrix: duplicate messages and image caching for vision support ([#2520](https://github.com/NousResearch/hermes-agent/pull/2520)), correct `reply_to_message_id` parameter ([#1895](https://github.com/NousResearch/hermes-agent/pull/1895)), bare media types fix ([#1736](https://github.com/NousResearch/hermes-agent/pull/1736))
|
||||
- Mattermost: MIME types for media attachments ([#2329](https://github.com/NousResearch/hermes-agent/pull/2329))
|
||||
|
||||
### Gateway Core
|
||||
- **Multi-platform gateway** support (Discord + Telegram + all adapters) ([#2125](https://github.com/NousResearch/hermes-agent/pull/2125))
|
||||
- **Auto-reconnect** failed platforms with exponential backoff ([#2584](https://github.com/NousResearch/hermes-agent/pull/2584))
|
||||
- **Notify users when session auto-resets** ([#2519](https://github.com/NousResearch/hermes-agent/pull/2519))
|
||||
- **`/queue` command** to queue prompts without interrupting ([#2191](https://github.com/NousResearch/hermes-agent/pull/2191))
|
||||
- **Inject reply-to message context** for out-of-session replies ([#1662](https://github.com/NousResearch/hermes-agent/pull/1662))
|
||||
- **Replace bare text approval** with `/approve` and `/deny` commands ([#2002](https://github.com/NousResearch/hermes-agent/pull/2002))
|
||||
- **Support ignoring unauthorized gateway DMs** ([#1919](https://github.com/NousResearch/hermes-agent/pull/1919))
|
||||
- **Configurable approvals** in gateway + `/cost` command with live pricing ([#2180](https://github.com/NousResearch/hermes-agent/pull/2180))
|
||||
- Fix: prevent duplicate session-key collision in multi-platform gateway ([#2171](https://github.com/NousResearch/hermes-agent/pull/2171))
|
||||
- Fix: `/reset` in thread-mode resets global session instead of thread ([#2254](https://github.com/NousResearch/hermes-agent/pull/2254))
|
||||
- Fix: deliver MEDIA: files after streaming responses ([#2382](https://github.com/NousResearch/hermes-agent/pull/2382))
|
||||
- Fix: cap interrupt recursion depth to prevent resource exhaustion ([#1659](https://github.com/NousResearch/hermes-agent/pull/1659))
|
||||
- Fix: detect stopped processes and release stale locks on `--replace` ([#2406](https://github.com/NousResearch/hermes-agent/pull/2406), [#1908](https://github.com/NousResearch/hermes-agent/pull/1908))
|
||||
- Fix: PID-based wait with force-kill for gateway restart ([#1902](https://github.com/NousResearch/hermes-agent/pull/1902))
|
||||
- Fix: prevent `--replace` mode from killing the caller process ([#2185](https://github.com/NousResearch/hermes-agent/pull/2185))
|
||||
- Fix: `/model` shows active fallback model instead of config default ([#1660](https://github.com/NousResearch/hermes-agent/pull/1660))
|
||||
- Fix: `/title` command fails when session doesn't exist in SQLite yet ([#2379](https://github.com/NousResearch/hermes-agent/pull/2379) by @ten-jampa)
|
||||
- Fix: process `/queue`'d messages after agent completion ([#2469](https://github.com/NousResearch/hermes-agent/pull/2469))
|
||||
- Fix: strip orphaned `tool_results` + let `/reset` bypass running agent ([#2180](https://github.com/NousResearch/hermes-agent/pull/2180))
|
||||
- Fix: prevent agents from starting gateway outside systemd management ([#2617](https://github.com/NousResearch/hermes-agent/pull/2617))
|
||||
- Fix: prevent systemd restart storm on gateway connection failure ([#2327](https://github.com/NousResearch/hermes-agent/pull/2327))
|
||||
- Fix: include resolved node path in systemd unit ([#1767](https://github.com/NousResearch/hermes-agent/pull/1767) by @sai-samarth)
|
||||
- Fix: send error details to user in gateway outer exception handler ([#1966](https://github.com/NousResearch/hermes-agent/pull/1966))
|
||||
- Fix: improve gateway error handling for 429 usage limits and 500 context overflow ([#1839](https://github.com/NousResearch/hermes-agent/pull/1839))
|
||||
- Fix: add all missing platform allowlist env vars to startup warning check ([#2628](https://github.com/NousResearch/hermes-agent/pull/2628))
|
||||
- Fix: show startup banner with all env vars when `verbose_logging=true` ([#2298](https://github.com/NousResearch/hermes-agent/pull/2298))
|
||||
- Fix: webhook platform config loading from config.yaml ([#2328](https://github.com/NousResearch/hermes-agent/pull/2328))
|
||||
- Fix: media-group aggregation on rapid successive photo messages ([#2160](https://github.com/NousResearch/hermes-agent/pull/2160))
|
||||
- Fix: media delivery fails for file paths containing spaces ([#2621](https://github.com/NousResearch/hermes-agent/pull/2621))
|
||||
- Fix: Matrix and Mattermost never report as connected ([#1711](https://github.com/NousResearch/hermes-agent/pull/1711))
|
||||
- Fix: PII redaction config never read — missing yaml import ([#1701](https://github.com/NousResearch/hermes-agent/pull/1701))
|
||||
- Fix: NameError on skill slash commands ([#1697](https://github.com/NousResearch/hermes-agent/pull/1697))
|
||||
- Fix: persist watcher metadata in checkpoint for crash recovery ([#1706](https://github.com/NousResearch/hermes-agent/pull/1706))
|
||||
- Fix: pass `message_thread_id` in `send_image_file`, `send_document`, `send_video` ([#2339](https://github.com/NousResearch/hermes-agent/pull/2339))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
### Interactive CLI
|
||||
- **@ context completions** — Claude Code-style `@file`/`@url` references with tab completion ([#2482](https://github.com/NousResearch/hermes-agent/pull/2482), [#2343](https://github.com/NousResearch/hermes-agent/pull/2343))
|
||||
- **Persistent config bar** in prompt with model + provider info + `/statusbar` toggle ([#2240](https://github.com/NousResearch/hermes-agent/pull/2240), [#1917](https://github.com/NousResearch/hermes-agent/pull/1917))
|
||||
- **`/permission` command** for dynamic approval mode switching ([#2207](https://github.com/NousResearch/hermes-agent/pull/2207))
|
||||
- **`/browser` command** for interactive browser sessions ([#2273](https://github.com/NousResearch/hermes-agent/pull/2273))
|
||||
- **`/tools` disable/enable/list** slash commands with session reset ([#1652](https://github.com/NousResearch/hermes-agent/pull/1652))
|
||||
- **`/model` command** for runtime model switching with live API probe for custom endpoints ([#2110](https://github.com/NousResearch/hermes-agent/pull/2110), [#1645](https://github.com/NousResearch/hermes-agent/pull/1645), [#2078](https://github.com/NousResearch/hermes-agent/pull/2078))
|
||||
- **Real-time config reload** — config.yaml changes apply without restart ([#2210](https://github.com/NousResearch/hermes-agent/pull/2210))
|
||||
- **Kitty keyboard protocol** Shift+Enter handling for Ghostty/WezTerm (reverted due to prompt_toolkit crash) ([#2345](https://github.com/NousResearch/hermes-agent/pull/2345), [#2349](https://github.com/NousResearch/hermes-agent/pull/2349))
|
||||
- Fix: prevent 'Press ENTER to continue...' on exit ([#2555](https://github.com/NousResearch/hermes-agent/pull/2555))
|
||||
- Fix: flush stdout during agent loop to prevent macOS display freeze ([#1654](https://github.com/NousResearch/hermes-agent/pull/1654))
|
||||
- Fix: show human-readable error when `hermes setup` hits permissions error ([#2196](https://github.com/NousResearch/hermes-agent/pull/2196))
|
||||
- Fix: `/stop` command crash + UnboundLocalError in streaming media delivery ([#2463](https://github.com/NousResearch/hermes-agent/pull/2463))
|
||||
- Fix: resolve garbled ANSI escape codes in status printouts ([#2448](https://github.com/NousResearch/hermes-agent/pull/2448))
|
||||
- Fix: normalize toolset labels and use skin colors in banner ([#1912](https://github.com/NousResearch/hermes-agent/pull/1912))
|
||||
- Fix: update gold ANSI color to true-color format ([#2246](https://github.com/NousResearch/hermes-agent/pull/2246))
|
||||
- Fix: suppress spinner animation in non-TTY environments ([#2216](https://github.com/NousResearch/hermes-agent/pull/2216))
|
||||
- Fix: display provider and endpoint in API error messages ([#2266](https://github.com/NousResearch/hermes-agent/pull/2266))
|
||||
|
||||
### Setup & Configuration
|
||||
- **YAML-based config** with backward-compatible env var fallback ([#2172](https://github.com/NousResearch/hermes-agent/pull/2172))
|
||||
- **`${ENV_VAR}` substitution** in config.yaml ([#2684](https://github.com/NousResearch/hermes-agent/pull/2684))
|
||||
- **`custom_models.yaml`** for user-managed model additions ([#2214](https://github.com/NousResearch/hermes-agent/pull/2214))
|
||||
- **Merge nested YAML sections** instead of replacing ([#2213](https://github.com/NousResearch/hermes-agent/pull/2213))
|
||||
- Fix: log warning instead of silently swallowing config.yaml errors ([#2683](https://github.com/NousResearch/hermes-agent/pull/2683))
|
||||
- Fix: config.yaml provider key overrides env var silently ([#2272](https://github.com/NousResearch/hermes-agent/pull/2272))
|
||||
- Fix: `hermes update` use `.[all]` extras with fallback ([#1728](https://github.com/NousResearch/hermes-agent/pull/1728))
|
||||
- Fix: `hermes update` prompt before resetting working tree on stash conflicts ([#2390](https://github.com/NousResearch/hermes-agent/pull/2390))
|
||||
- Fix: add zprofile fallback and create zshrc on fresh macOS installs ([#2320](https://github.com/NousResearch/hermes-agent/pull/2320))
|
||||
- Fix: use git pull --rebase in update/install to avoid divergent branch error ([#2274](https://github.com/NousResearch/hermes-agent/pull/2274))
|
||||
- Fix: disabled toolsets re-enable themselves after `hermes tools` ([#2268](https://github.com/NousResearch/hermes-agent/pull/2268))
|
||||
- Fix: platform default toolsets silently override tool deselection ([#2624](https://github.com/NousResearch/hermes-agent/pull/2624))
|
||||
- Fix: honor bare YAML `approvals.mode: off` ([#2620](https://github.com/NousResearch/hermes-agent/pull/2620))
|
||||
- Fix: remove `ANTHROPIC_BASE_URL` env var to avoid collisions ([#1675](https://github.com/NousResearch/hermes-agent/pull/1675))
|
||||
- Fix: don't ask IMAP password if already in keyring or env ([#2212](https://github.com/NousResearch/hermes-agent/pull/2212))
|
||||
- Fix: prevent `/model` crash when provider list is empty ([#2209](https://github.com/NousResearch/hermes-agent/pull/2209))
|
||||
- Fix: OpenCode Zen/Go show OpenRouter models instead of their own ([#2277](https://github.com/NousResearch/hermes-agent/pull/2277))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### Browser Tools
|
||||
- **Native Hermes browser tools** — navigate, click, type, screenshot, scrape via Browserbase ([#2270](https://github.com/NousResearch/hermes-agent/pull/2270))
|
||||
- Fix: race condition in session creation orphans cloud sessions ([#1721](https://github.com/NousResearch/hermes-agent/pull/1721))
|
||||
- Fix: browser handlers TypeError on unexpected LLM params ([#1735](https://github.com/NousResearch/hermes-agent/pull/1735))
|
||||
- Fix: add `/browser` to COMMAND_REGISTRY for help + autocomplete ([#1814](https://github.com/NousResearch/hermes-agent/pull/1814))
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
- **MCP server management CLI** + OAuth 2.1 PKCE auth ([#2465](https://github.com/NousResearch/hermes-agent/pull/2465))
|
||||
- **Interactive MCP tool configuration** in `hermes tools` ([#1694](https://github.com/NousResearch/hermes-agent/pull/1694))
|
||||
- **Expose MCP servers as standalone toolsets** ([#1907](https://github.com/NousResearch/hermes-agent/pull/1907))
|
||||
- **Optional FastMCP skill** ([#2113](https://github.com/NousResearch/hermes-agent/pull/2113))
|
||||
- Fix: MCP-OAuth port mismatch, path traversal, and shared handler state ([#2552](https://github.com/NousResearch/hermes-agent/pull/2552))
|
||||
- Fix: preserve MCP tool registrations across session resets ([#2124](https://github.com/NousResearch/hermes-agent/pull/2124))
|
||||
- Fix: concurrent file access crash + duplicate MCP registration ([#2154](https://github.com/NousResearch/hermes-agent/pull/2154))
|
||||
- Fix: normalise MCP schemas + expand session list columns ([#2102](https://github.com/NousResearch/hermes-agent/pull/2102))
|
||||
- Fix: `tool_choice` `mcp_` prefix handling ([#1775](https://github.com/NousResearch/hermes-agent/pull/1775))
|
||||
|
||||
### Web Tools
|
||||
- **Configurable web backend** — Firecrawl/BeautifulSoup/Playwright ([#2256](https://github.com/NousResearch/hermes-agent/pull/2256))
|
||||
- **Parallel** as alternative web search/extract backend ([#1696](https://github.com/NousResearch/hermes-agent/pull/1696))
|
||||
- **Tavily** as web search/extract/crawl backend ([#1731](https://github.com/NousResearch/hermes-agent/pull/1731))
|
||||
- Fix: whitespace-only env vars bypass web backend detection ([#2341](https://github.com/NousResearch/hermes-agent/pull/2341))
|
||||
|
||||
### Other Tools
|
||||
- **Vision analysis tool** for image understanding with configurable timeout ([#2182](https://github.com/NousResearch/hermes-agent/pull/2182), [#2480](https://github.com/NousResearch/hermes-agent/pull/2480))
|
||||
- **Code execution tool** for containerized Python/Node/Bash execution ([#2299](https://github.com/NousResearch/hermes-agent/pull/2299))
|
||||
- **TTS tool** using OpenAI API with `base_url` support ([#2118](https://github.com/NousResearch/hermes-agent/pull/2118), [#2064](https://github.com/NousResearch/hermes-agent/pull/2064) by @hanai)
|
||||
- **STT (speech-to-text) tool** using Whisper API ([#2072](https://github.com/NousResearch/hermes-agent/pull/2072))
|
||||
- **IMAP email** reading and sending tools ([#2173](https://github.com/NousResearch/hermes-agent/pull/2173))
|
||||
- **RL training data generation tool** ([#2225](https://github.com/NousResearch/hermes-agent/pull/2225))
|
||||
- **Route-aware pricing estimates** ([#1695](https://github.com/NousResearch/hermes-agent/pull/1695))
|
||||
- Fix: chunk long messages in `send_message_tool` before platform dispatch ([#1646](https://github.com/NousResearch/hermes-agent/pull/1646))
|
||||
- Fix: make concurrent tool batching path-aware for file mutations ([#1914](https://github.com/NousResearch/hermes-agent/pull/1914))
|
||||
- Fix: tool result truncation on large outputs ([#2088](https://github.com/NousResearch/hermes-agent/pull/2088))
|
||||
- Fix: concurrent file writes safely with atomic operations ([#2086](https://github.com/NousResearch/hermes-agent/pull/2086))
|
||||
- Fix: improve fuzzy matching accuracy for file search + position calculation refactor ([#2096](https://github.com/NousResearch/hermes-agent/pull/2096), [#1681](https://github.com/NousResearch/hermes-agent/pull/1681))
|
||||
- Fix: `search_files` wrong line numbers for multi-line matches ([#2069](https://github.com/NousResearch/hermes-agent/pull/2069))
|
||||
- Fix: include pagination args in repeated search key ([#1824](https://github.com/NousResearch/hermes-agent/pull/1824) by @cutepawss)
|
||||
- Fix: strip ANSI escape codes from write_file and patch content ([#2532](https://github.com/NousResearch/hermes-agent/pull/2532))
|
||||
- Fix: expand tilde (~) in vision_analyze local file paths ([#2585](https://github.com/NousResearch/hermes-agent/pull/2585))
|
||||
- Fix: resource leak and double socket close in `code_execution_tool` ([#2381](https://github.com/NousResearch/hermes-agent/pull/2381))
|
||||
- Fix: resolve vision analysis race condition and path handling ([#2191](https://github.com/NousResearch/hermes-agent/pull/2191))
|
||||
- Fix: DM vision — handle multiple images and base64 fallback ([#2211](https://github.com/NousResearch/hermes-agent/pull/2211))
|
||||
- Fix: `model_supports_images` for custom `base_url` providers returns wrong value ([#2278](https://github.com/NousResearch/hermes-agent/pull/2278))
|
||||
- Fix: add missing 'messaging' toolset — couldn't enable/disable `send_message` ([#1718](https://github.com/NousResearch/hermes-agent/pull/1718))
|
||||
- Fix: prevent unavailable tool names from leaking into model schemas ([#2072](https://github.com/NousResearch/hermes-agent/pull/2072))
|
||||
- Fix: disabled toolsets re-enable themselves after `hermes tools` ([#2268](https://github.com/NousResearch/hermes-agent/pull/2268))
|
||||
- Fix: pass visited set by reference to prevent diamond dependency duplication ([#2311](https://github.com/NousResearch/hermes-agent/pull/2311))
|
||||
- Fix: Daytona sandbox lookup migrated from `find_one` to `get/list` ([#2063](https://github.com/NousResearch/hermes-agent/pull/2063) by @rovle)
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### Skills System
|
||||
- **Skills Hub** — discover, install, and manage skills from curated taps ([#2235](https://github.com/NousResearch/hermes-agent/pull/2235))
|
||||
- **Agent-created persistent skills** with caution-level findings allowed, dangerous skills ask instead of block ([#2116](https://github.com/NousResearch/hermes-agent/pull/2116), [#1840](https://github.com/NousResearch/hermes-agent/pull/1840), [#2446](https://github.com/NousResearch/hermes-agent/pull/2446))
|
||||
- **`--yes` flag** to bypass confirmation in `/skills install` and uninstall ([#1647](https://github.com/NousResearch/hermes-agent/pull/1647))
|
||||
- **Disabled skills respected** across banner, system prompt, and slash commands ([#1897](https://github.com/NousResearch/hermes-agent/pull/1897))
|
||||
- Fix: skills custom_tools import crash + sandbox file_tools integration ([#2239](https://github.com/NousResearch/hermes-agent/pull/2239))
|
||||
- Fix: agent-created skills with pip requirements crash on install ([#2145](https://github.com/NousResearch/hermes-agent/pull/2145))
|
||||
- Fix: race condition in `Skills.__init__` when `hub.yaml` missing ([#2242](https://github.com/NousResearch/hermes-agent/pull/2242))
|
||||
- Fix: validate skill metadata before install and block duplicates ([#2241](https://github.com/NousResearch/hermes-agent/pull/2241))
|
||||
- Fix: skills hub inspect/resolve — 4 bugs in inspect, redirects, discovery, tap list ([#2447](https://github.com/NousResearch/hermes-agent/pull/2447))
|
||||
- Fix: agent-created skills keep working after session reset ([#2121](https://github.com/NousResearch/hermes-agent/pull/2121))
|
||||
|
||||
### New Skills
|
||||
- **OCR-and-documents** — PDF/DOCX/XLS/PPTX/image OCR with optional GPU ([#2236](https://github.com/NousResearch/hermes-agent/pull/2236))
|
||||
- **Huggingface-hub** bundled skill ([#1921](https://github.com/NousResearch/hermes-agent/pull/1921))
|
||||
- **Sherlock OSINT** username search skill ([#1671](https://github.com/NousResearch/hermes-agent/pull/1671))
|
||||
- **Inference.sh** skill (terminal-based) ([#1686](https://github.com/NousResearch/hermes-agent/pull/1686))
|
||||
- **Meme-generation** — real image generator with Pillow ([#2344](https://github.com/NousResearch/hermes-agent/pull/2344))
|
||||
- **Bioinformatics** gateway skill — index to 400+ bio skills ([#2387](https://github.com/NousResearch/hermes-agent/pull/2387))
|
||||
- **Base blockchain** optional skill ([#1643](https://github.com/NousResearch/hermes-agent/pull/1643))
|
||||
- **3D-model-viewer** optional skill ([#2226](https://github.com/NousResearch/hermes-agent/pull/2226))
|
||||
- **Hermes-agent-setup** skill ([#1905](https://github.com/NousResearch/hermes-agent/pull/1905))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Security Hardening
|
||||
- **SSRF protection** for vision_tools and web_tools (hardened) ([#2679](https://github.com/NousResearch/hermes-agent/pull/2679))
|
||||
- **Shell injection prevention** in `_expand_path` via `~user` path suffix ([#2685](https://github.com/NousResearch/hermes-agent/pull/2685))
|
||||
- **Block untrusted browser-origin** API server access ([#2451](https://github.com/NousResearch/hermes-agent/pull/2451))
|
||||
- **Block sandbox backend creds** from subprocess env ([#1658](https://github.com/NousResearch/hermes-agent/pull/1658))
|
||||
- **Block @ references** from reading secrets outside workspace ([#2601](https://github.com/NousResearch/hermes-agent/pull/2601) by @Gutslabs)
|
||||
- **Require opt-in** for project plugin discovery ([#2215](https://github.com/NousResearch/hermes-agent/pull/2215))
|
||||
- **Malicious code pattern pre-exec scanner** for terminal_tool ([#2245](https://github.com/NousResearch/hermes-agent/pull/2245))
|
||||
- **Harden terminal safety** and sandbox file writes ([#1653](https://github.com/NousResearch/hermes-agent/pull/1653))
|
||||
- **PKCE verifier leak** fix, OAuth refresh Content-Type fix ([#1775](https://github.com/NousResearch/hermes-agent/pull/1775))
|
||||
- **Eliminate SQL string formatting** in `execute()` calls ([#2061](https://github.com/NousResearch/hermes-agent/pull/2061) by @dusterbloom)
|
||||
- **Harden jobs API** — input limits, field whitelist, startup check ([#2456](https://github.com/NousResearch/hermes-agent/pull/2456))
|
||||
- Fix: OAuth flag stale after refresh/fallback ([#1890](https://github.com/NousResearch/hermes-agent/pull/1890))
|
||||
- Fix: auxiliary client skips expired Codex JWT ([#2397](https://github.com/NousResearch/hermes-agent/pull/2397))
|
||||
|
||||
### Reliability
|
||||
- **Concurrent tool safety** — path-aware file mutation batching, thread locks on SessionDB methods, file locking for memory writes ([#1914](https://github.com/NousResearch/hermes-agent/pull/1914), [#1704](https://github.com/NousResearch/hermes-agent/pull/1704), [#1726](https://github.com/NousResearch/hermes-agent/pull/1726))
|
||||
- **Error recovery** — handle OpenRouter errors gracefully, guard print() calls against OSError ([#2112](https://github.com/NousResearch/hermes-agent/pull/2112), [#1668](https://github.com/NousResearch/hermes-agent/pull/1668))
|
||||
- **Redacting formatter** — safely handle non-string inputs, NameError fix when verbose_logging=True ([#2392](https://github.com/NousResearch/hermes-agent/pull/2392), [#1700](https://github.com/NousResearch/hermes-agent/pull/1700))
|
||||
- **ACP** — preserve session provider when switching models, persist sessions to disk, preserve leading whitespace in streaming chunks ([#2380](https://github.com/NousResearch/hermes-agent/pull/2380), [#2071](https://github.com/NousResearch/hermes-agent/pull/2071), [#2192](https://github.com/NousResearch/hermes-agent/pull/2192))
|
||||
- **API server** — persist ResponseStore to SQLite across restarts ([#2472](https://github.com/NousResearch/hermes-agent/pull/2472))
|
||||
- Fix: `fetch_nous_models` called with positional args — always TypeError ([#1699](https://github.com/NousResearch/hermes-agent/pull/1699))
|
||||
- Fix: `make_is_write_denied` robust to Path objects ([#1678](https://github.com/NousResearch/hermes-agent/pull/1678))
|
||||
- Fix: resolve merge conflict markers in cli.py breaking hermes startup ([#2347](https://github.com/NousResearch/hermes-agent/pull/2347))
|
||||
- Fix: `minisweagent_path.py` missing from wheel ([#2098](https://github.com/NousResearch/hermes-agent/pull/2098) by @JiwaniZakir)
|
||||
|
||||
### Cron System
|
||||
- **Cron job scheduling** for gateway ([#2140](https://github.com/NousResearch/hermes-agent/pull/2140))
|
||||
- **`[SILENT]` response** — cron agents can suppress delivery ([#1833](https://github.com/NousResearch/hermes-agent/pull/1833))
|
||||
- **Scale missed-job grace window** with schedule frequency ([#2449](https://github.com/NousResearch/hermes-agent/pull/2449))
|
||||
- **Recover recent one-shot jobs** ([#1918](https://github.com/NousResearch/hermes-agent/pull/1918))
|
||||
- Fix: normalize `repeat<=0` to None — cron jobs deleted after first run when LLM passes -1 ([#2612](https://github.com/NousResearch/hermes-agent/pull/2612) by @Mibayy)
|
||||
- Fix: Matrix added to scheduler delivery platform_map ([#2167](https://github.com/NousResearch/hermes-agent/pull/2167) by @buntingszn)
|
||||
- Fix: naive ISO timestamps stored without timezone — jobs fire at wrong time ([#1729](https://github.com/NousResearch/hermes-agent/pull/1729))
|
||||
- Fix: `get_due_jobs` reads `jobs.json` twice — race condition ([#1716](https://github.com/NousResearch/hermes-agent/pull/1716))
|
||||
- Fix: silent jobs return empty response for delivery skip ([#2442](https://github.com/NousResearch/hermes-agent/pull/2442))
|
||||
- Fix: stop injecting cron outputs into gateway session history ([#2313](https://github.com/NousResearch/hermes-agent/pull/2313))
|
||||
- Fix: close abandoned coroutine when `asyncio.run()` raises RuntimeError ([#2317](https://github.com/NousResearch/hermes-agent/pull/2317))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
- Fix: show full command in dangerous command approval prompt ([#1649](https://github.com/NousResearch/hermes-agent/pull/1649))
|
||||
- Fix: Telegram streaming message length overflow ([#1783](https://github.com/NousResearch/hermes-agent/pull/1783))
|
||||
- Fix: prevent `/model` crash when provider list is empty ([#2209](https://github.com/NousResearch/hermes-agent/pull/2209))
|
||||
- Fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill ([#2466](https://github.com/NousResearch/hermes-agent/pull/2466))
|
||||
- Fix: `dingtalk-stream` added to optional dependencies ([#2452](https://github.com/NousResearch/hermes-agent/pull/2452))
|
||||
- Fix: remove hardcoded `gemini-3-flash-preview` as default summary model ([#2464](https://github.com/NousResearch/hermes-agent/pull/2464))
|
||||
- Fix: remove post-compression file-read history injection ([#2226](https://github.com/NousResearch/hermes-agent/pull/2226))
|
||||
- Fix: truncated `AUXILIARY_WEB_EXTRACT_API_KEY` env var name ([#2309](https://github.com/NousResearch/hermes-agent/pull/2309))
|
||||
- Fix: update validator does not stop ([#2204](https://github.com/NousResearch/hermes-agent/pull/2204), [#2067](https://github.com/NousResearch/hermes-agent/pull/2067))
|
||||
- Fix: log disk warning check failures at debug level ([#2394](https://github.com/NousResearch/hermes-agent/pull/2394))
|
||||
- Fix: quiet mode with `--resume` now passes conversation_history ([#2357](https://github.com/NousResearch/hermes-agent/pull/2357))
|
||||
- Fix: unify resume logic in batch mode for consistent `--resume` behavior ([#2331](https://github.com/NousResearch/hermes-agent/pull/2331))
|
||||
- Fix: prevent unavailable tool names from leaking into model schemas ([#2072](https://github.com/NousResearch/hermes-agent/pull/2072))
|
||||
- Fix: remove `_is_special_key` hack and fix `/skills` path completion ([#2271](https://github.com/NousResearch/hermes-agent/pull/2271))
|
||||
- Fix: use home-relative state paths if XDG dirs don't exist ([#2325](https://github.com/NousResearch/hermes-agent/pull/2325))
|
||||
- Fix: inject model identity for Alibaba Coding Plan ([#2314](https://github.com/NousResearch/hermes-agent/pull/2314))
|
||||
- Fix: OpenClaw migration warns when API keys are skipped ([#1655](https://github.com/NousResearch/hermes-agent/pull/1655))
|
||||
- Fix: email `send_typing` metadata + ☤ Hermes staff symbol ([#1665](https://github.com/NousResearch/hermes-agent/pull/1665))
|
||||
- Fix: replace production `print()` calls with logger in rl_training_tool ([#2462](https://github.com/NousResearch/hermes-agent/pull/2462))
|
||||
- Fix: restore opencode-go provider config corrupted by secret redaction ([#2393](https://github.com/NousResearch/hermes-agent/pull/2393) by @0xbyt4)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Resolve all consistently failing tests ([#2488](https://github.com/NousResearch/hermes-agent/pull/2488))
|
||||
- Replace `FakePath` with `monkeypatch` for Python 3.12 compat ([#2444](https://github.com/NousResearch/hermes-agent/pull/2444))
|
||||
- Align Hermes setup and full-suite expectations ([#1710](https://github.com/NousResearch/hermes-agent/pull/1710))
|
||||
- Add tests for API server jobs API hardening ([#2456](https://github.com/NousResearch/hermes-agent/pull/2456))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- Comprehensive documentation update for recent features ([#1693](https://github.com/NousResearch/hermes-agent/pull/1693), [#2183](https://github.com/NousResearch/hermes-agent/pull/2183))
|
||||
- Alibaba Cloud and DingTalk setup guide ([#1687](https://github.com/NousResearch/hermes-agent/pull/1687), [#1692](https://github.com/NousResearch/hermes-agent/pull/1692))
|
||||
- SOUL.md as primary agent identity documentation ([#1927](https://github.com/NousResearch/hermes-agent/pull/1927))
|
||||
- Detailed skills documentation ([#2244](https://github.com/NousResearch/hermes-agent/pull/2244))
|
||||
- Honcho self-hosted / Docker configuration section ([#2475](https://github.com/NousResearch/hermes-agent/pull/2475))
|
||||
- Context length detection references in FAQ and quickstart ([#2179](https://github.com/NousResearch/hermes-agent/pull/2179))
|
||||
- Fix documentation inconsistencies across reference and user guides ([#1995](https://github.com/NousResearch/hermes-agent/pull/1995))
|
||||
- Fix MCP install commands — use uv, not bare pip ([#1909](https://github.com/NousResearch/hermes-agent/pull/1909))
|
||||
- Fix MDX build error in api-server.md ([#1787](https://github.com/NousResearch/hermes-agent/pull/1787))
|
||||
- Replace ASCII diagrams with Mermaid/lists ([#2402](https://github.com/NousResearch/hermes-agent/pull/2402))
|
||||
- Add missing gateway commands and correct examples ([#2329](https://github.com/NousResearch/hermes-agent/pull/2329))
|
||||
- Clarify self-hosted Firecrawl setup ([#1669](https://github.com/NousResearch/hermes-agent/pull/1669))
|
||||
- NeuTTS provider documentation ([#1903](https://github.com/NousResearch/hermes-agent/pull/1903))
|
||||
- Gemini OAuth provider implementation plan ([#2467](https://github.com/NousResearch/hermes-agent/pull/2467))
|
||||
- Discord Server Members Intent marked as required ([#2330](https://github.com/NousResearch/hermes-agent/pull/2330))
|
||||
- Align venv path to match installer (venv/ not .venv/) ([#2114](https://github.com/NousResearch/hermes-agent/pull/2114))
|
||||
- New skills added to hub index ([#2281](https://github.com/NousResearch/hermes-agent/pull/2281))
|
||||
- OCR-and-documents skill — split, merge, search examples ([#2461](https://github.com/NousResearch/hermes-agent/pull/2461))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** (Teknium) — 280 PRs
|
||||
|
||||
### Community Contributors
|
||||
- **@mchzimm** (to_the_max) — GitHub Copilot provider integration across Hermes ([#1879](https://github.com/NousResearch/hermes-agent/pull/1879))
|
||||
- **@jquesnelle** (Jeffrey Quesnelle) — Per-thread persistent event loops in worker threads ([#2214](https://github.com/NousResearch/hermes-agent/pull/2214))
|
||||
- **@llbn** (lbn) — Telegram MarkdownV2 support: strikethrough, spoiler, blockquotes, and escape fixes ([#2199](https://github.com/NousResearch/hermes-agent/pull/2199), [#2200](https://github.com/NousResearch/hermes-agent/pull/2200))
|
||||
- **@dusterbloom** — SQL injection prevention + local server context window querying ([#2061](https://github.com/NousResearch/hermes-agent/pull/2061), [#2091](https://github.com/NousResearch/hermes-agent/pull/2091))
|
||||
- **@0xbyt4** — Anthropic tool_calls None guard + OpenCode-Go provider config fix ([#2209](https://github.com/NousResearch/hermes-agent/pull/2209), [#2393](https://github.com/NousResearch/hermes-agent/pull/2393))
|
||||
- **@sai-samarth** (Saisamarth) — WhatsApp send_message routing + systemd node path fix ([#1769](https://github.com/NousResearch/hermes-agent/pull/1769), [#1767](https://github.com/NousResearch/hermes-agent/pull/1767))
|
||||
- **@Gutslabs** (Guts) — Block @ references from reading secrets outside workspace ([#2601](https://github.com/NousResearch/hermes-agent/pull/2601))
|
||||
- **@Mibayy** (Mibay) — Cron job repeat normalization fix ([#2612](https://github.com/NousResearch/hermes-agent/pull/2612))
|
||||
- **@ten-jampa** (Tenzin Jampa) — Gateway /title command session fix ([#2379](https://github.com/NousResearch/hermes-agent/pull/2379))
|
||||
- **@cutepawss** (lila) — File tools search pagination fix ([#1824](https://github.com/NousResearch/hermes-agent/pull/1824))
|
||||
- **@hanai** (Hanai) — OpenAI TTS base_url support ([#2064](https://github.com/NousResearch/hermes-agent/pull/2064))
|
||||
- **@rovle** (Lovre Pešut) — Daytona sandbox API migration ([#2063](https://github.com/NousResearch/hermes-agent/pull/2063))
|
||||
- **@buntingszn** (bunting szn) — Matrix cron delivery support ([#2167](https://github.com/NousResearch/hermes-agent/pull/2167))
|
||||
- **@InB4DevOps** — Token counter reset on new session ([#2101](https://github.com/NousResearch/hermes-agent/pull/2101))
|
||||
- **@JiwaniZakir** (Zakir Jiwani) — Missing file in wheel fix ([#2098](https://github.com/NousResearch/hermes-agent/pull/2098))
|
||||
- **@ygd58** (buray) — Delegate tool parent tool names fix ([#2083](https://github.com/NousResearch/hermes-agent/pull/2083))
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.3.17...v2026.3.23](https://github.com/NousResearch/hermes-agent/compare/v2026.3.17...v2026.3.23)
|
||||
@@ -301,7 +301,11 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
defaults["agent"]["max_turns"] = file_config["max_turns"]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load cli-config.yaml: %s", e)
|
||||
|
||||
|
||||
# Expand ${ENV_VAR} references in config values before bridging to env vars.
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
defaults = _expand_env_vars(defaults)
|
||||
|
||||
# Apply terminal config to environment variables (so terminal_tool picks them up)
|
||||
terminal_config = defaults.get("terminal", {})
|
||||
|
||||
|
||||
+7
-2
@@ -523,8 +523,13 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process config.yaml — falling back to .env / gateway.json values. "
|
||||
"Check %s for syntax errors. Error: %s",
|
||||
_home / "config.yaml",
|
||||
e,
|
||||
)
|
||||
|
||||
config = GatewayConfig.from_dict(gw_data)
|
||||
|
||||
|
||||
+43
-1
@@ -93,6 +93,9 @@ if _config_path.exists():
|
||||
import yaml as _yaml
|
||||
with open(_config_path, encoding="utf-8") as _f:
|
||||
_cfg = _yaml.safe_load(_f) or {}
|
||||
# Expand ${ENV_VAR} references before bridging to env vars.
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
_cfg = _expand_env_vars(_cfg)
|
||||
# Top-level simple values (fallback only — don't override .env)
|
||||
for _key, _val in _cfg.items():
|
||||
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
|
||||
@@ -525,6 +528,12 @@ class GatewayRunner:
|
||||
Synchronous worker — meant to be called via run_in_executor from
|
||||
an async context so it doesn't block the event loop.
|
||||
"""
|
||||
# Skip cron sessions — they run headless with no meaningful user
|
||||
# conversation to extract memories from.
|
||||
if old_session_id and old_session_id.startswith("cron_"):
|
||||
logger.debug("Skipping memory flush for cron session: %s", old_session_id)
|
||||
return
|
||||
|
||||
try:
|
||||
history = self.session_store.load_transcript(old_session_id)
|
||||
if not history or len(history) < 4:
|
||||
@@ -557,6 +566,23 @@ class GatewayRunner:
|
||||
if m.get("role") in ("user", "assistant") and m.get("content")
|
||||
]
|
||||
|
||||
# Read live memory state from disk so the flush agent can see
|
||||
# what's already saved and avoid overwriting newer entries.
|
||||
_current_memory = ""
|
||||
try:
|
||||
from tools.memory_tool import MEMORY_DIR
|
||||
for fname, label in [
|
||||
("MEMORY.md", "MEMORY (your personal notes)"),
|
||||
("USER.md", "USER PROFILE (who the user is)"),
|
||||
]:
|
||||
fpath = MEMORY_DIR / fname
|
||||
if fpath.exists():
|
||||
content = fpath.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
_current_memory += f"\n\n## Current {label}:\n{content}"
|
||||
except Exception:
|
||||
pass # Non-fatal — flush still works, just without the guard
|
||||
|
||||
# Give the agent a real turn to think about what to save
|
||||
flush_prompt = (
|
||||
"[System: This session is about to be automatically reset due to "
|
||||
@@ -568,6 +594,20 @@ class GatewayRunner:
|
||||
"2. If you discovered a reusable workflow or solved a non-trivial "
|
||||
"problem, consider saving it as a skill.\n"
|
||||
"3. If nothing is worth saving, that's fine — just skip.\n\n"
|
||||
)
|
||||
|
||||
if _current_memory:
|
||||
flush_prompt += (
|
||||
"IMPORTANT — here is the current live state of memory. Other "
|
||||
"sessions, cron jobs, or the user may have updated it since this "
|
||||
"conversation ended. Do NOT overwrite or remove entries unless "
|
||||
"the conversation above reveals something that genuinely "
|
||||
"supersedes them. Only add new information that is not already "
|
||||
"captured below."
|
||||
f"{_current_memory}\n\n"
|
||||
)
|
||||
|
||||
flush_prompt += (
|
||||
"Do NOT respond to the user. Just use the memory and skill_manage "
|
||||
"tools if needed, then stop.]"
|
||||
)
|
||||
@@ -904,7 +944,9 @@ class GatewayRunner:
|
||||
os.getenv(v)
|
||||
for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS",
|
||||
"WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS",
|
||||
"SIGNAL_ALLOWED_USERS", "EMAIL_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes")
|
||||
|
||||
@@ -12,4 +12,4 @@ Provides subcommands for:
|
||||
"""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__release_date__ = "2026.3.18"
|
||||
__release_date__ = "2026.3.23"
|
||||
|
||||
+21
-1
@@ -1172,6 +1172,26 @@ def _deep_merge(base: dict, override: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _expand_env_vars(obj):
|
||||
"""Recursively expand ``${VAR}`` references in config values.
|
||||
|
||||
Only string values are processed; dict keys, numbers, booleans, and
|
||||
None are left untouched. Unresolved references (variable not in
|
||||
``os.environ``) are kept verbatim so callers can detect them.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return re.sub(
|
||||
r"\${([^}]+)}",
|
||||
lambda m: os.environ.get(m.group(1), m.group(0)),
|
||||
obj,
|
||||
)
|
||||
if isinstance(obj, dict):
|
||||
return {k: _expand_env_vars(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_expand_env_vars(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize legacy root-level max_turns into agent.max_turns."""
|
||||
config = dict(config)
|
||||
@@ -1213,7 +1233,7 @@ def load_config() -> Dict[str, Any]:
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
|
||||
return _normalize_max_turns_config(config)
|
||||
return _expand_env_vars(_normalize_max_turns_config(config))
|
||||
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for memory flush stale-overwrite prevention (#2670).
|
||||
|
||||
Verifies that:
|
||||
1. Cron sessions are skipped (no flush for headless cron runs)
|
||||
2. Current memory state is injected into the flush prompt so the
|
||||
flush agent can see what's already saved and avoid overwrites
|
||||
3. The flush still works normally when memory files don't exist
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
|
||||
def _make_runner():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._honcho_managers = {}
|
||||
runner._honcho_configs = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner.adapters = {}
|
||||
runner.hooks = MagicMock()
|
||||
runner.session_store = MagicMock()
|
||||
return runner
|
||||
|
||||
|
||||
_TRANSCRIPT_4_MSGS = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
{"role": "user", "content": "remember my name is Alice"},
|
||||
{"role": "assistant", "content": "Got it, Alice!"},
|
||||
]
|
||||
|
||||
|
||||
class TestCronSessionBypass:
|
||||
"""Cron sessions should never trigger a memory flush."""
|
||||
|
||||
def test_cron_session_skipped(self):
|
||||
runner = _make_runner()
|
||||
runner._flush_memories_for_session("cron_job123_20260323_120000")
|
||||
# session_store.load_transcript should never be called
|
||||
runner.session_store.load_transcript.assert_not_called()
|
||||
|
||||
def test_cron_session_with_honcho_key_skipped(self):
|
||||
runner = _make_runner()
|
||||
runner._flush_memories_for_session("cron_daily_20260323", "some-honcho-key")
|
||||
runner.session_store.load_transcript.assert_not_called()
|
||||
|
||||
def test_non_cron_session_proceeds(self):
|
||||
"""Non-cron sessions should still attempt the flush."""
|
||||
runner = _make_runner()
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner._flush_memories_for_session("session_abc123")
|
||||
runner.session_store.load_transcript.assert_called_once_with("session_abc123")
|
||||
|
||||
|
||||
class TestMemoryInjection:
|
||||
"""The flush prompt should include current memory state from disk."""
|
||||
|
||||
def test_memory_content_injected_into_flush_prompt(self, tmp_path):
|
||||
"""When memory files exist, their content appears in the flush prompt."""
|
||||
runner = _make_runner()
|
||||
runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS
|
||||
|
||||
tmp_agent = MagicMock()
|
||||
memory_dir = tmp_path / "memories"
|
||||
memory_dir.mkdir()
|
||||
(memory_dir / "MEMORY.md").write_text("Agent knows Python\n§\nUser prefers dark mode")
|
||||
(memory_dir / "USER.md").write_text("Name: Alice\n§\nTimezone: PST")
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=tmp_agent),
|
||||
# Intercept `from tools.memory_tool import MEMORY_DIR` inside the function
|
||||
patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=memory_dir)}),
|
||||
):
|
||||
runner._flush_memories_for_session("session_123")
|
||||
|
||||
tmp_agent.run_conversation.assert_called_once()
|
||||
call_kwargs = tmp_agent.run_conversation.call_args.kwargs
|
||||
flush_prompt = call_kwargs.get("user_message", "")
|
||||
|
||||
# Verify both memory sections appear in the prompt
|
||||
assert "Agent knows Python" in flush_prompt
|
||||
assert "User prefers dark mode" in flush_prompt
|
||||
assert "Name: Alice" in flush_prompt
|
||||
assert "Timezone: PST" in flush_prompt
|
||||
# Verify the stale-overwrite warning is present
|
||||
assert "Do NOT overwrite or remove entries" in flush_prompt
|
||||
assert "current live state of memory" in flush_prompt
|
||||
|
||||
def test_flush_works_without_memory_files(self, tmp_path):
|
||||
"""When no memory files exist, flush still runs without the guard."""
|
||||
runner = _make_runner()
|
||||
runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS
|
||||
|
||||
tmp_agent = MagicMock()
|
||||
empty_dir = tmp_path / "no_memories"
|
||||
empty_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=tmp_agent),
|
||||
patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=empty_dir)}),
|
||||
):
|
||||
runner._flush_memories_for_session("session_456")
|
||||
|
||||
# Should still run, just without the memory guard section
|
||||
tmp_agent.run_conversation.assert_called_once()
|
||||
flush_prompt = tmp_agent.run_conversation.call_args.kwargs.get("user_message", "")
|
||||
assert "Do NOT overwrite or remove entries" not in flush_prompt
|
||||
assert "Review the conversation above" in flush_prompt
|
||||
|
||||
def test_empty_memory_files_no_injection(self, tmp_path):
|
||||
"""Empty memory files should not trigger the guard section."""
|
||||
runner = _make_runner()
|
||||
runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS
|
||||
|
||||
tmp_agent = MagicMock()
|
||||
memory_dir = tmp_path / "memories"
|
||||
memory_dir.mkdir()
|
||||
(memory_dir / "MEMORY.md").write_text("")
|
||||
(memory_dir / "USER.md").write_text(" \n ") # whitespace only
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=tmp_agent),
|
||||
patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=memory_dir)}),
|
||||
):
|
||||
runner._flush_memories_for_session("session_789")
|
||||
|
||||
tmp_agent.run_conversation.assert_called_once()
|
||||
flush_prompt = tmp_agent.run_conversation.call_args.kwargs.get("user_message", "")
|
||||
# No memory content → no guard section
|
||||
assert "current live state of memory" not in flush_prompt
|
||||
|
||||
|
||||
class TestFlushPromptStructure:
|
||||
"""Verify the flush prompt retains its core instructions."""
|
||||
|
||||
def test_core_instructions_present(self):
|
||||
"""The flush prompt should still contain the original guidance."""
|
||||
runner = _make_runner()
|
||||
runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS
|
||||
|
||||
tmp_agent = MagicMock()
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=tmp_agent),
|
||||
# Make the import fail gracefully so we test without memory files
|
||||
patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=Path("/nonexistent"))}),
|
||||
):
|
||||
runner._flush_memories_for_session("session_struct")
|
||||
|
||||
flush_prompt = tmp_agent.run_conversation.call_args.kwargs.get("user_message", "")
|
||||
assert "automatically reset" in flush_prompt
|
||||
assert "Save any important facts" in flush_prompt
|
||||
assert "consider saving it as a skill" in flush_prompt
|
||||
assert "Do NOT respond to the user" in flush_prompt
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from hermes_cli.config import _expand_env_vars, load_config
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
|
||||
class TestExpandEnvVars:
|
||||
def test_simple_substitution(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("MY_KEY", "secret123")
|
||||
assert _expand_env_vars("${MY_KEY}") == "secret123"
|
||||
|
||||
def test_missing_var_kept_verbatim(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.delenv("UNDEFINED_VAR_XYZ", raising=False)
|
||||
assert _expand_env_vars("${UNDEFINED_VAR_XYZ}") == "${UNDEFINED_VAR_XYZ}"
|
||||
|
||||
def test_no_placeholder_unchanged(self):
|
||||
assert _expand_env_vars("plain-value") == "plain-value"
|
||||
|
||||
def test_dict_recursive(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("TOKEN", "tok-abc")
|
||||
result = _expand_env_vars({"key": "${TOKEN}", "other": "literal"})
|
||||
assert result == {"key": "tok-abc", "other": "literal"}
|
||||
|
||||
def test_nested_dict(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("API_KEY", "sk-xyz")
|
||||
result = _expand_env_vars({"model": {"api_key": "${API_KEY}"}})
|
||||
assert result["model"]["api_key"] == "sk-xyz"
|
||||
|
||||
def test_list_items(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("VAL", "hello")
|
||||
result = _expand_env_vars(["${VAL}", "literal", 42])
|
||||
assert result == ["hello", "literal", 42]
|
||||
|
||||
def test_non_string_values_untouched(self):
|
||||
assert _expand_env_vars(42) == 42
|
||||
assert _expand_env_vars(3.14) == 3.14
|
||||
assert _expand_env_vars(True) is True
|
||||
assert _expand_env_vars(None) is None
|
||||
|
||||
def test_multiple_placeholders_in_one_string(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("HOST", "localhost")
|
||||
mp.setenv("PORT", "5432")
|
||||
assert _expand_env_vars("${HOST}:${PORT}") == "localhost:5432"
|
||||
|
||||
def test_dict_keys_not_expanded(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("KEY", "value")
|
||||
result = _expand_env_vars({"${KEY}": "no-expand-key"})
|
||||
assert "${KEY}" in result
|
||||
|
||||
|
||||
class TestLoadConfigExpansion:
|
||||
def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"model:\n"
|
||||
" api_key: ${GOOGLE_API_KEY}\n"
|
||||
"platforms:\n"
|
||||
" telegram:\n"
|
||||
" token: ${TELEGRAM_BOT_TOKEN}\n"
|
||||
"plain: no-substitution\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
|
||||
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config["model"]["api_key"] == "gsk-test-key"
|
||||
assert config["platforms"]["telegram"]["token"] == "1234567:ABC-token"
|
||||
assert config["plain"] == "no-substitution"
|
||||
|
||||
def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
|
||||
config_yaml = "model:\n api_key: ${NOT_SET_XYZ_123}\n"
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
|
||||
|
||||
|
||||
class TestLoadCliConfigExpansion:
|
||||
"""Verify that load_cli_config() also expands ${VAR} references."""
|
||||
|
||||
def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" api_key: ${TEST_VISION_KEY_XYZ}\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
|
||||
# Patch the hermes home so load_cli_config finds our test config
|
||||
monkeypatch.setattr("cli._hermes_home", tmp_path)
|
||||
|
||||
from cli import load_cli_config
|
||||
config = load_cli_config()
|
||||
|
||||
assert config["auxiliary"]["vision"]["api_key"] == "vis-key-123"
|
||||
|
||||
def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" api_key: ${UNSET_CLI_VAR_ABC}\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
|
||||
monkeypatch.setattr("cli._hermes_home", tmp_path)
|
||||
|
||||
from cli import load_cli_config
|
||||
config = load_cli_config()
|
||||
|
||||
assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}"
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Comprehensive tests for ANSI escape sequence stripping (ECMA-48).
|
||||
|
||||
The strip_ansi function in tools/ansi_strip.py is the source-level fix for
|
||||
ANSI codes leaking into the model's context via terminal/execute_code output.
|
||||
It must strip ALL terminal escape sequences while preserving legitimate text.
|
||||
"""
|
||||
|
||||
from tools.ansi_strip import strip_ansi
|
||||
|
||||
|
||||
class TestStripAnsiBasicSGR:
|
||||
"""Select Graphic Rendition — the most common ANSI sequences."""
|
||||
|
||||
def test_reset(self):
|
||||
assert strip_ansi("\x1b[0m") == ""
|
||||
|
||||
def test_color(self):
|
||||
assert strip_ansi("\x1b[31;1m") == ""
|
||||
|
||||
def test_truecolor_semicolon(self):
|
||||
assert strip_ansi("\x1b[38;2;255;0;0m") == ""
|
||||
|
||||
def test_truecolor_colon_separated(self):
|
||||
"""Modern terminals use colon-separated SGR params."""
|
||||
assert strip_ansi("\x1b[38:2:255:0:0m") == ""
|
||||
assert strip_ansi("\x1b[48:2:0:255:0m") == ""
|
||||
|
||||
|
||||
class TestStripAnsiCSIPrivateMode:
|
||||
"""CSI sequences with ? prefix (DEC private modes)."""
|
||||
|
||||
def test_cursor_show_hide(self):
|
||||
assert strip_ansi("\x1b[?25h") == ""
|
||||
assert strip_ansi("\x1b[?25l") == ""
|
||||
|
||||
def test_alt_screen(self):
|
||||
assert strip_ansi("\x1b[?1049h") == ""
|
||||
assert strip_ansi("\x1b[?1049l") == ""
|
||||
|
||||
def test_bracketed_paste(self):
|
||||
assert strip_ansi("\x1b[?2004h") == ""
|
||||
|
||||
|
||||
class TestStripAnsiCSIIntermediate:
|
||||
"""CSI sequences with intermediate bytes (space, etc.)."""
|
||||
|
||||
def test_cursor_shape(self):
|
||||
assert strip_ansi("\x1b[0 q") == ""
|
||||
assert strip_ansi("\x1b[2 q") == ""
|
||||
assert strip_ansi("\x1b[6 q") == ""
|
||||
|
||||
|
||||
class TestStripAnsiOSC:
|
||||
"""Operating System Command sequences."""
|
||||
|
||||
def test_bel_terminator(self):
|
||||
assert strip_ansi("\x1b]0;title\x07") == ""
|
||||
|
||||
def test_st_terminator(self):
|
||||
assert strip_ansi("\x1b]0;title\x1b\\") == ""
|
||||
|
||||
def test_hyperlink_preserves_text(self):
|
||||
assert strip_ansi(
|
||||
"\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\"
|
||||
) == "click"
|
||||
|
||||
|
||||
class TestStripAnsiDECPrivate:
|
||||
"""DEC private / Fp escape sequences."""
|
||||
|
||||
def test_save_restore_cursor(self):
|
||||
assert strip_ansi("\x1b7") == ""
|
||||
assert strip_ansi("\x1b8") == ""
|
||||
|
||||
def test_keypad_modes(self):
|
||||
assert strip_ansi("\x1b=") == ""
|
||||
assert strip_ansi("\x1b>") == ""
|
||||
|
||||
|
||||
class TestStripAnsiFe:
|
||||
"""Fe (C1 as 7-bit) escape sequences."""
|
||||
|
||||
def test_reverse_index(self):
|
||||
assert strip_ansi("\x1bM") == ""
|
||||
|
||||
def test_reset_terminal(self):
|
||||
assert strip_ansi("\x1bc") == ""
|
||||
|
||||
def test_index_and_newline(self):
|
||||
assert strip_ansi("\x1bD") == ""
|
||||
assert strip_ansi("\x1bE") == ""
|
||||
|
||||
|
||||
class TestStripAnsiNF:
|
||||
"""nF (character set selection) sequences."""
|
||||
|
||||
def test_charset_selection(self):
|
||||
assert strip_ansi("\x1b(A") == ""
|
||||
assert strip_ansi("\x1b(B") == ""
|
||||
assert strip_ansi("\x1b(0") == ""
|
||||
|
||||
|
||||
class TestStripAnsiDCS:
|
||||
"""Device Control String sequences."""
|
||||
|
||||
def test_dcs(self):
|
||||
assert strip_ansi("\x1bP+q\x1b\\") == ""
|
||||
|
||||
|
||||
class TestStripAnsi8BitC1:
|
||||
"""8-bit C1 control characters."""
|
||||
|
||||
def test_8bit_csi(self):
|
||||
assert strip_ansi("\x9b31m") == ""
|
||||
assert strip_ansi("\x9b38;2;255;0;0m") == ""
|
||||
|
||||
def test_8bit_standalone(self):
|
||||
assert strip_ansi("\x9c") == ""
|
||||
assert strip_ansi("\x9d") == ""
|
||||
assert strip_ansi("\x90") == ""
|
||||
|
||||
|
||||
class TestStripAnsiRealWorld:
|
||||
"""Real-world contamination scenarios from bug reports."""
|
||||
|
||||
def test_colored_shebang(self):
|
||||
"""The original reported bug: shebang corrupted by color codes."""
|
||||
assert strip_ansi(
|
||||
"\x1b[32m#!/usr/bin/env python3\x1b[0m\nprint('hello')"
|
||||
) == "#!/usr/bin/env python3\nprint('hello')"
|
||||
|
||||
def test_stacked_sgr(self):
|
||||
assert strip_ansi(
|
||||
"\x1b[1m\x1b[31m\x1b[42mhello\x1b[0m"
|
||||
) == "hello"
|
||||
|
||||
def test_ansi_mid_code(self):
|
||||
assert strip_ansi(
|
||||
"def foo(\x1b[33m):\x1b[0m\n return 42"
|
||||
) == "def foo():\n return 42"
|
||||
|
||||
|
||||
class TestStripAnsiPassthrough:
|
||||
"""Clean content must pass through unmodified."""
|
||||
|
||||
def test_plain_text(self):
|
||||
assert strip_ansi("normal text") == "normal text"
|
||||
|
||||
def test_empty(self):
|
||||
assert strip_ansi("") == ""
|
||||
|
||||
def test_none(self):
|
||||
assert strip_ansi(None) is None
|
||||
|
||||
def test_whitespace_preserved(self):
|
||||
assert strip_ansi("line1\nline2\ttab") == "line1\nline2\ttab"
|
||||
|
||||
def test_unicode_safe(self):
|
||||
assert strip_ansi("emoji 🎉 and ñ café") == "emoji 🎉 and ñ café"
|
||||
|
||||
def test_backslash_in_code(self):
|
||||
code = "path = 'C:\\\\Users\\\\test'"
|
||||
assert strip_ansi(code) == code
|
||||
|
||||
def test_square_brackets_in_code(self):
|
||||
"""Array indexing must not be confused with CSI."""
|
||||
code = "arr[0] = arr[31]"
|
||||
assert strip_ansi(code) == code
|
||||
@@ -309,3 +309,6 @@ class TestSearchHints:
|
||||
raw = search_tool(pattern="foo", offset=50, limit=50)
|
||||
assert "[Hint:" in raw
|
||||
assert "offset=100" in raw
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Tests for SSRF protection in url_safety module."""
|
||||
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.url_safety import is_safe_url, _is_blocked_ip
|
||||
|
||||
import ipaddress
|
||||
import pytest
|
||||
|
||||
|
||||
class TestIsSafeUrl:
|
||||
def test_public_url_allowed(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert is_safe_url("https://example.com/image.png") is True
|
||||
|
||||
def test_localhost_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("127.0.0.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://localhost:8080/secret") is False
|
||||
|
||||
def test_loopback_ip_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("127.0.0.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://127.0.0.1/admin") is False
|
||||
|
||||
def test_private_10_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("10.0.0.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://internal-service.local/api") is False
|
||||
|
||||
def test_private_172_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("172.16.0.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://private.corp/data") is False
|
||||
|
||||
def test_private_192_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("192.168.1.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://router.local") is False
|
||||
|
||||
def test_link_local_169_254_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("169.254.169.254", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://169.254.169.254/latest/meta-data/") is False
|
||||
|
||||
def test_metadata_google_internal_blocked(self):
|
||||
assert is_safe_url("http://metadata.google.internal/computeMetadata/v1/") is False
|
||||
|
||||
def test_ipv6_loopback_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(10, 1, 6, "", ("::1", 0, 0, 0)),
|
||||
]):
|
||||
assert is_safe_url("http://[::1]:8080/") is False
|
||||
|
||||
def test_dns_failure_blocked(self):
|
||||
"""DNS failures now fail closed — block the request."""
|
||||
with patch("socket.getaddrinfo", side_effect=socket.gaierror("Name resolution failed")):
|
||||
assert is_safe_url("https://nonexistent.example.com") is False
|
||||
|
||||
def test_empty_url_blocked(self):
|
||||
assert is_safe_url("") is False
|
||||
|
||||
def test_no_hostname_blocked(self):
|
||||
assert is_safe_url("http://") is False
|
||||
|
||||
def test_public_ip_allowed(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert is_safe_url("https://example.com") is True
|
||||
|
||||
# ── New tests for hardened SSRF protection ──
|
||||
|
||||
def test_cgnat_100_64_blocked(self):
|
||||
"""100.64.0.0/10 (CGNAT/Shared Address Space) is NOT covered by
|
||||
ipaddress.is_private — must be blocked explicitly."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("100.64.0.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://some-cgnat-host.example/") is False
|
||||
|
||||
def test_cgnat_100_127_blocked(self):
|
||||
"""Upper end of CGNAT range (100.127.255.255)."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("100.127.255.254", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://tailscale-peer.example/") is False
|
||||
|
||||
def test_multicast_blocked(self):
|
||||
"""Multicast addresses (224.0.0.0/4) not caught by is_private."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("224.0.0.251", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://mdns-host.local/") is False
|
||||
|
||||
def test_multicast_ipv6_blocked(self):
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(10, 1, 6, "", ("ff02::1", 0, 0, 0)),
|
||||
]):
|
||||
assert is_safe_url("http://[ff02::1]/") is False
|
||||
|
||||
def test_ipv4_mapped_ipv6_loopback_blocked(self):
|
||||
"""::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(10, 1, 6, "", ("::ffff:127.0.0.1", 0, 0, 0)),
|
||||
]):
|
||||
assert is_safe_url("http://[::ffff:127.0.0.1]/") is False
|
||||
|
||||
def test_ipv4_mapped_ipv6_metadata_blocked(self):
|
||||
"""::ffff:169.254.169.254 — IPv4-mapped IPv6 cloud metadata."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(10, 1, 6, "", ("::ffff:169.254.169.254", 0, 0, 0)),
|
||||
]):
|
||||
assert is_safe_url("http://[::ffff:169.254.169.254]/") is False
|
||||
|
||||
def test_unspecified_address_blocked(self):
|
||||
"""0.0.0.0 — unspecified address, can bind to all interfaces."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("0.0.0.0", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://0.0.0.0/") is False
|
||||
|
||||
def test_unexpected_error_fails_closed(self):
|
||||
"""Unexpected exceptions should block, not allow."""
|
||||
with patch("tools.url_safety.urlparse", side_effect=ValueError("bad url")):
|
||||
assert is_safe_url("http://evil.com/") is False
|
||||
|
||||
def test_metadata_goog_blocked(self):
|
||||
assert is_safe_url("http://metadata.goog/computeMetadata/v1/") is False
|
||||
|
||||
def test_ipv6_unique_local_blocked(self):
|
||||
"""fc00::/7 — IPv6 unique local addresses."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(10, 1, 6, "", ("fd12::1", 0, 0, 0)),
|
||||
]):
|
||||
assert is_safe_url("http://[fd12::1]/internal") is False
|
||||
|
||||
def test_non_cgnat_100_allowed(self):
|
||||
"""100.0.0.1 is NOT in CGNAT range (100.64.0.0/10), should be allowed."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("100.0.0.1", 0)),
|
||||
]):
|
||||
# 100.0.0.1 is a global IP, not in CGNAT range
|
||||
assert is_safe_url("http://legit-host.example/") is True
|
||||
|
||||
|
||||
class TestIsBlockedIp:
|
||||
"""Direct tests for the _is_blocked_ip helper."""
|
||||
|
||||
@pytest.mark.parametrize("ip_str", [
|
||||
"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1",
|
||||
"169.254.169.254", "0.0.0.0", "224.0.0.1", "255.255.255.255",
|
||||
"100.64.0.1", "100.100.100.100", "100.127.255.254",
|
||||
"::1", "fe80::1", "fc00::1", "fd12::1", "ff02::1",
|
||||
"::ffff:127.0.0.1", "::ffff:169.254.169.254",
|
||||
])
|
||||
def test_blocked_ips(self, ip_str):
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked"
|
||||
|
||||
@pytest.mark.parametrize("ip_str", [
|
||||
"8.8.8.8", "93.184.216.34", "1.1.1.1", "100.0.0.1",
|
||||
"2606:4700::1", "2001:4860:4860::8888",
|
||||
])
|
||||
def test_allowed_ips(self, ip_str):
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed"
|
||||
@@ -33,17 +33,30 @@ class TestValidateImageUrl:
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
|
||||
def test_valid_http_url(self):
|
||||
assert _validate_image_url("http://cdn.example.org/photo.png") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("http://cdn.example.org/photo.png") is True
|
||||
|
||||
def test_valid_url_without_extension(self):
|
||||
"""CDN endpoints that redirect to images should still pass."""
|
||||
assert _validate_image_url("https://cdn.example.com/abcdef123") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://cdn.example.com/abcdef123") is True
|
||||
|
||||
def test_valid_url_with_query_params(self):
|
||||
assert _validate_image_url("https://img.example.com/pic?w=200&h=200") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://img.example.com/pic?w=200&h=200") is True
|
||||
|
||||
def test_localhost_url_blocked_by_ssrf(self):
|
||||
"""localhost URLs are now blocked by SSRF protection."""
|
||||
assert _validate_image_url("http://localhost:8080/image.png") is False
|
||||
|
||||
def test_valid_url_with_port(self):
|
||||
assert _validate_image_url("http://localhost:8080/image.png") is True
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
|
||||
def test_valid_url_with_path_only(self):
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
|
||||
@@ -343,6 +343,8 @@ def test_browser_navigate_allows_when_shared_file_missing(monkeypatch, tmp_path)
|
||||
async def test_web_extract_short_circuits_blocked_url(monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
# Allow test URLs past SSRF check so website policy is what gets tested
|
||||
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True)
|
||||
monkeypatch.setattr(
|
||||
web_tools,
|
||||
"check_website_access",
|
||||
@@ -389,6 +391,9 @@ def test_check_website_access_fails_open_on_malformed_config(tmp_path, monkeypat
|
||||
async def test_web_extract_blocks_redirected_final_url(monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
# Allow test URLs past SSRF check so website policy is what gets tested
|
||||
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True)
|
||||
|
||||
def fake_check(url):
|
||||
if url == "https://allowed.test":
|
||||
return None
|
||||
@@ -428,6 +433,8 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch):
|
||||
|
||||
# web_crawl_tool checks for Firecrawl env before website policy
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key")
|
||||
# Allow test URLs past SSRF check so website policy is what gets tested
|
||||
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True)
|
||||
monkeypatch.setattr(
|
||||
web_tools,
|
||||
"check_website_access",
|
||||
@@ -457,6 +464,8 @@ async def test_web_crawl_blocks_redirected_final_url(monkeypatch):
|
||||
|
||||
# web_crawl_tool checks for Firecrawl env before website policy
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key")
|
||||
# Allow test URLs past SSRF check so website policy is what gets tested
|
||||
monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True)
|
||||
|
||||
def fake_check(url):
|
||||
if url == "https://allowed.test":
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Strip ANSI escape sequences from subprocess output.
|
||||
|
||||
Used by terminal_tool, code_execution_tool, and process_registry to clean
|
||||
command output before returning it to the model. This prevents ANSI codes
|
||||
from entering the model's context — which is the root cause of models
|
||||
copying escape sequences into file writes.
|
||||
|
||||
Covers the full ECMA-48 spec: CSI (including private-mode ``?`` prefix,
|
||||
colon-separated params, intermediate bytes), OSC (BEL and ST terminators),
|
||||
DCS/SOS/PM/APC string sequences, nF multi-byte escapes, Fp/Fe/Fs
|
||||
single-byte escapes, and 8-bit C1 control characters.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_ANSI_ESCAPE_RE = re.compile(
|
||||
r"\x1b"
|
||||
r"(?:"
|
||||
r"\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]" # CSI sequence
|
||||
r"|\][\s\S]*?(?:\x07|\x1b\\)" # OSC (BEL or ST terminator)
|
||||
r"|[PX^_][\s\S]*?(?:\x1b\\)" # DCS/SOS/PM/APC strings
|
||||
r"|[\x20-\x2f]+[\x30-\x7e]" # nF escape sequences
|
||||
r"|[\x30-\x7e]" # Fp/Fe/Fs single-byte
|
||||
r")"
|
||||
r"|\x9b[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]" # 8-bit CSI
|
||||
r"|\x9d[\s\S]*?(?:\x07|\x9c)" # 8-bit OSC
|
||||
r"|[\x80-\x9f]", # Other 8-bit C1 controls
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Fast-path check — skip full regex when no escape-like bytes are present.
|
||||
_HAS_ESCAPE = re.compile(r"[\x1b\x80-\x9f]")
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape sequences from text.
|
||||
|
||||
Returns the input unchanged (fast path) when no ESC or C1 bytes are
|
||||
present. Safe to call on any string — clean text passes through
|
||||
with negligible overhead.
|
||||
"""
|
||||
if not text or not _HAS_ESCAPE.search(text):
|
||||
return text
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
@@ -577,6 +577,12 @@ def execute_code(
|
||||
server_sock = None # prevent double close in finally
|
||||
rpc_thread.join(timeout=3)
|
||||
|
||||
# Strip ANSI escape sequences so the model never sees terminal
|
||||
# formatting — prevents it from copying escapes into file writes.
|
||||
from tools.ansi_strip import strip_ansi
|
||||
stdout_text = strip_ansi(stdout_text)
|
||||
stderr_text = strip_ansi(stderr_text)
|
||||
|
||||
# Build response
|
||||
result: Dict[str, Any] = {
|
||||
"status": status,
|
||||
|
||||
@@ -433,9 +433,13 @@ class ShellFileOperations(FileOperations):
|
||||
slash_idx = rest.find('/')
|
||||
username = rest[:slash_idx] if slash_idx >= 0 else rest
|
||||
if username and re.fullmatch(r'[a-zA-Z0-9._-]+', username):
|
||||
expand_result = self._exec(f"echo {path}")
|
||||
# Only expand ~username (not the full path) to avoid shell
|
||||
# injection via path suffixes like "~user/$(malicious)".
|
||||
expand_result = self._exec(f"echo ~{username}")
|
||||
if expand_result.exit_code == 0 and expand_result.stdout.strip():
|
||||
return expand_result.stdout.strip()
|
||||
user_home = expand_result.stdout.strip()
|
||||
suffix = path[1 + len(username):] # e.g. "/rest/of/path"
|
||||
return user_home + suffix
|
||||
|
||||
return path
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional
|
||||
from tools.file_operations import ShellFileOperations
|
||||
@@ -13,17 +12,6 @@ from agent.redact import redact_sensitive_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Regex to match ANSI escape sequences (CSI codes, OSC codes, simple escapes).
|
||||
# Models occasionally copy these from terminal output into file content.
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][A-B012]|\x1b[=>]")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape sequences from text destined for file writes."""
|
||||
if not text or "\x1b" not in text:
|
||||
return text
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
_EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS}
|
||||
|
||||
@@ -301,7 +289,6 @@ def notify_other_tool_call(task_id: str = "default"):
|
||||
def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
|
||||
"""Write content to a file."""
|
||||
try:
|
||||
content = _strip_ansi(content)
|
||||
file_ops = _get_file_ops(task_id)
|
||||
result = file_ops.write_file(path, content)
|
||||
return json.dumps(result.to_dict(), ensure_ascii=False)
|
||||
@@ -325,13 +312,10 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
|
||||
return json.dumps({"error": "path required"})
|
||||
if old_string is None or new_string is None:
|
||||
return json.dumps({"error": "old_string and new_string required"})
|
||||
old_string = _strip_ansi(old_string)
|
||||
new_string = _strip_ansi(new_string)
|
||||
result = file_ops.patch_replace(path, old_string, new_string, replace_all)
|
||||
elif mode == "patch":
|
||||
if not patch:
|
||||
return json.dumps({"error": "patch content required"})
|
||||
patch = _strip_ansi(patch)
|
||||
result = file_ops.patch_v4a(patch)
|
||||
else:
|
||||
return json.dumps({"error": f"Unknown mode: {mode}"})
|
||||
|
||||
@@ -426,12 +426,14 @@ class ProcessRegistry:
|
||||
|
||||
def poll(self, session_id: str) -> dict:
|
||||
"""Check status and get new output for a background process."""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
|
||||
session = self.get(session_id)
|
||||
if session is None:
|
||||
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
||||
|
||||
with session._lock:
|
||||
output_preview = session.output_buffer[-1000:] if session.output_buffer else ""
|
||||
output_preview = strip_ansi(session.output_buffer[-1000:]) if session.output_buffer else ""
|
||||
|
||||
result = {
|
||||
"session_id": session.id,
|
||||
@@ -450,12 +452,14 @@ class ProcessRegistry:
|
||||
|
||||
def read_log(self, session_id: str, offset: int = 0, limit: int = 200) -> dict:
|
||||
"""Read the full output log with optional pagination by lines."""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
|
||||
session = self.get(session_id)
|
||||
if session is None:
|
||||
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
||||
|
||||
with session._lock:
|
||||
full_output = session.output_buffer
|
||||
full_output = strip_ansi(session.output_buffer)
|
||||
|
||||
lines = full_output.splitlines()
|
||||
total_lines = len(lines)
|
||||
@@ -486,6 +490,7 @@ class ProcessRegistry:
|
||||
dict with status ("exited", "timeout", "interrupted", "not_found")
|
||||
and output snapshot.
|
||||
"""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
from tools.terminal_tool import _interrupt_event
|
||||
|
||||
default_timeout = int(os.getenv("TERMINAL_TIMEOUT", "180"))
|
||||
@@ -513,7 +518,7 @@ class ProcessRegistry:
|
||||
result = {
|
||||
"status": "exited",
|
||||
"exit_code": session.exit_code,
|
||||
"output": session.output_buffer[-2000:],
|
||||
"output": strip_ansi(session.output_buffer[-2000:]),
|
||||
}
|
||||
if timeout_note:
|
||||
result["timeout_note"] = timeout_note
|
||||
@@ -522,7 +527,7 @@ class ProcessRegistry:
|
||||
if _interrupt_event.is_set():
|
||||
result = {
|
||||
"status": "interrupted",
|
||||
"output": session.output_buffer[-1000:],
|
||||
"output": strip_ansi(session.output_buffer[-1000:]),
|
||||
"note": "User sent a new message -- wait interrupted",
|
||||
}
|
||||
if timeout_note:
|
||||
@@ -533,7 +538,7 @@ class ProcessRegistry:
|
||||
|
||||
result = {
|
||||
"status": "timeout",
|
||||
"output": session.output_buffer[-1000:],
|
||||
"output": strip_ansi(session.output_buffer[-1000:]),
|
||||
}
|
||||
if timeout_note:
|
||||
result["timeout_note"] = timeout_note
|
||||
|
||||
@@ -1163,6 +1163,11 @@ def terminal_tool(
|
||||
)
|
||||
output = output[:head_chars] + truncated_notice + output[-tail_chars:]
|
||||
|
||||
# Strip ANSI escape sequences so the model never sees terminal
|
||||
# formatting — prevents it from copying escapes into file writes.
|
||||
from tools.ansi_strip import strip_ansi
|
||||
output = strip_ansi(output)
|
||||
|
||||
# Redact secrets from command output (catches env/printenv leaking keys)
|
||||
from agent.redact import redact_sensitive_text
|
||||
output = redact_sensitive_text(output.strip()) if output else ""
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""URL safety checks — blocks requests to private/internal network addresses.
|
||||
|
||||
Prevents SSRF (Server-Side Request Forgery) where a malicious prompt or
|
||||
skill could trick the agent into fetching internal resources like cloud
|
||||
metadata endpoints (169.254.169.254), localhost services, or private
|
||||
network hosts.
|
||||
|
||||
Limitations (documented, not fixable at pre-flight level):
|
||||
- DNS rebinding (TOCTOU): an attacker-controlled DNS server with TTL=0
|
||||
can return a public IP for the check, then a private IP for the actual
|
||||
connection. Fixing this requires connection-level validation (e.g.
|
||||
Python's Champion library or an egress proxy like Stripe's Smokescreen).
|
||||
- Redirect-based bypass in vision_tools is mitigated by an httpx event
|
||||
hook that re-validates each redirect target. Web tools use third-party
|
||||
SDKs (Firecrawl/Tavily) where redirect handling is on their servers.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hostnames that should always be blocked regardless of IP resolution
|
||||
_BLOCKED_HOSTNAMES = frozenset({
|
||||
"metadata.google.internal",
|
||||
"metadata.goog",
|
||||
})
|
||||
|
||||
# 100.64.0.0/10 (CGNAT / Shared Address Space, RFC 6598) is NOT covered by
|
||||
# ipaddress.is_private — it returns False for both is_private and is_global.
|
||||
# Must be blocked explicitly. Used by carrier-grade NAT, Tailscale/WireGuard
|
||||
# VPNs, and some cloud internal networks.
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Return True if the IP should be blocked for SSRF protection."""
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
if ip.is_multicast or ip.is_unspecified:
|
||||
return True
|
||||
# CGNAT range not covered by is_private
|
||||
if ip in _CGNAT_NETWORK:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_safe_url(url: str) -> bool:
|
||||
"""Return True if the URL target is not a private/internal address.
|
||||
|
||||
Resolves the hostname to an IP and checks against private ranges.
|
||||
Fails closed: DNS errors and unexpected exceptions block the request.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").strip().lower()
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
# Block known internal hostnames
|
||||
if hostname in _BLOCKED_HOSTNAMES:
|
||||
logger.warning("Blocked request to internal hostname: %s", hostname)
|
||||
return False
|
||||
|
||||
# Try to resolve and check IP
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
# DNS resolution failed — fail closed. If DNS can't resolve it,
|
||||
# the HTTP client will also fail, so blocking loses nothing.
|
||||
logger.warning("Blocked request — DNS resolution failed for: %s", hostname)
|
||||
return False
|
||||
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
ip_str = sockaddr[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if _is_blocked_ip(ip):
|
||||
logger.warning(
|
||||
"Blocked request to private/internal address: %s -> %s",
|
||||
hostname, ip_str,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
# Fail closed on unexpected errors — don't let parsing edge cases
|
||||
# become SSRF bypass vectors
|
||||
logger.warning("Blocked request — URL safety check error for %s: %s", url, exc)
|
||||
return False
|
||||
+28
-2
@@ -69,7 +69,12 @@ def _validate_image_url(url: str) -> bool:
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
|
||||
return True # Allow all well-formed HTTP/HTTPS URLs for flexibility
|
||||
# Block private/internal addresses to prevent SSRF
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(url):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _download_image(image_url: str, destination: Path, max_retries: int = 3) -> Path:
|
||||
@@ -92,12 +97,33 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
|
||||
# Create parent directories if they don't exist
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def _ssrf_redirect_guard(response):
|
||||
"""Re-validate each redirect target to prevent redirect-based SSRF.
|
||||
|
||||
Without this, an attacker can host a public URL that 302-redirects
|
||||
to http://169.254.169.254/ and bypass the pre-flight is_safe_url check.
|
||||
|
||||
Must be async because httpx.AsyncClient awaits event hooks.
|
||||
"""
|
||||
if response.is_redirect and response.next_request:
|
||||
redirect_url = str(response.next_request.url)
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(redirect_url):
|
||||
raise ValueError(
|
||||
f"Blocked redirect to private/internal address: {redirect_url}"
|
||||
)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Download the image with appropriate headers using async httpx
|
||||
# Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum)
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
# SSRF: event_hooks validates each redirect target against private IP ranges
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
event_hooks={"response": [_ssrf_redirect_guard]},
|
||||
) as client:
|
||||
response = await client.get(
|
||||
image_url,
|
||||
headers={
|
||||
|
||||
+148
-118
@@ -46,6 +46,7 @@ import httpx
|
||||
from firecrawl import Firecrawl
|
||||
from agent.auxiliary_client import async_call_llm
|
||||
from tools.debug_helpers import DebugSession
|
||||
from tools.url_safety import is_safe_url
|
||||
from tools.website_policy import check_website_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -861,136 +862,155 @@ async def web_extract_tool(
|
||||
try:
|
||||
logger.info("Extracting content from %d URL(s)", len(urls))
|
||||
|
||||
# Dispatch to the configured backend
|
||||
backend = _get_backend()
|
||||
|
||||
if backend == "parallel":
|
||||
results = await _parallel_extract(urls)
|
||||
elif backend == "tavily":
|
||||
logger.info("Tavily extract: %d URL(s)", len(urls))
|
||||
raw = _tavily_request("extract", {
|
||||
"urls": urls,
|
||||
"include_images": False,
|
||||
})
|
||||
results = _normalize_tavily_documents(raw, fallback_url=urls[0] if urls else "")
|
||||
else:
|
||||
# ── Firecrawl extraction ──
|
||||
# Determine requested formats for Firecrawl v2
|
||||
formats: List[str] = []
|
||||
if format == "markdown":
|
||||
formats = ["markdown"]
|
||||
elif format == "html":
|
||||
formats = ["html"]
|
||||
# ── SSRF protection — filter out private/internal URLs before any backend ──
|
||||
safe_urls = []
|
||||
ssrf_blocked: List[Dict[str, Any]] = []
|
||||
for url in urls:
|
||||
if not is_safe_url(url):
|
||||
ssrf_blocked.append({
|
||||
"url": url, "title": "", "content": "",
|
||||
"error": "Blocked: URL targets a private or internal network address",
|
||||
})
|
||||
else:
|
||||
# Default: request markdown for LLM-readiness and include html as backup
|
||||
formats = ["markdown", "html"]
|
||||
safe_urls.append(url)
|
||||
|
||||
# Always use individual scraping for simplicity and reliability
|
||||
# Batch scraping adds complexity without much benefit for small numbers of URLs
|
||||
results: List[Dict[str, Any]] = []
|
||||
# Dispatch only safe URLs to the configured backend
|
||||
if not safe_urls:
|
||||
results = []
|
||||
else:
|
||||
backend = _get_backend()
|
||||
|
||||
from tools.interrupt import is_interrupted as _is_interrupted
|
||||
for url in urls:
|
||||
if _is_interrupted():
|
||||
results.append({"url": url, "error": "Interrupted", "title": ""})
|
||||
continue
|
||||
if backend == "parallel":
|
||||
results = await _parallel_extract(safe_urls)
|
||||
elif backend == "tavily":
|
||||
logger.info("Tavily extract: %d URL(s)", len(safe_urls))
|
||||
raw = _tavily_request("extract", {
|
||||
"urls": safe_urls,
|
||||
"include_images": False,
|
||||
})
|
||||
results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "")
|
||||
else:
|
||||
# ── Firecrawl extraction ──
|
||||
# Determine requested formats for Firecrawl v2
|
||||
formats: List[str] = []
|
||||
if format == "markdown":
|
||||
formats = ["markdown"]
|
||||
elif format == "html":
|
||||
formats = ["html"]
|
||||
else:
|
||||
# Default: request markdown for LLM-readiness and include html as backup
|
||||
formats = ["markdown", "html"]
|
||||
|
||||
# Website policy check — block before fetching
|
||||
blocked = check_website_access(url)
|
||||
if blocked:
|
||||
logger.info("Blocked web_extract for %s by rule %s", blocked["host"], blocked["rule"])
|
||||
results.append({
|
||||
"url": url, "title": "", "content": "",
|
||||
"error": blocked["message"],
|
||||
"blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]},
|
||||
})
|
||||
continue
|
||||
# Always use individual scraping for simplicity and reliability
|
||||
# Batch scraping adds complexity without much benefit for small numbers of URLs
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
logger.info("Scraping: %s", url)
|
||||
scrape_result = _get_firecrawl_client().scrape(
|
||||
url=url,
|
||||
formats=formats
|
||||
)
|
||||
from tools.interrupt import is_interrupted as _is_interrupted
|
||||
for url in safe_urls:
|
||||
if _is_interrupted():
|
||||
results.append({"url": url, "error": "Interrupted", "title": ""})
|
||||
continue
|
||||
|
||||
# Process the result - properly handle object serialization
|
||||
metadata = {}
|
||||
title = ""
|
||||
content_markdown = None
|
||||
content_html = None
|
||||
|
||||
# Extract data from the scrape result
|
||||
if hasattr(scrape_result, 'model_dump'):
|
||||
# Pydantic model - use model_dump to get dict
|
||||
result_dict = scrape_result.model_dump()
|
||||
content_markdown = result_dict.get('markdown')
|
||||
content_html = result_dict.get('html')
|
||||
metadata = result_dict.get('metadata', {})
|
||||
elif hasattr(scrape_result, '__dict__'):
|
||||
# Regular object with attributes
|
||||
content_markdown = getattr(scrape_result, 'markdown', None)
|
||||
content_html = getattr(scrape_result, 'html', None)
|
||||
|
||||
# Handle metadata - convert to dict if it's an object
|
||||
metadata_obj = getattr(scrape_result, 'metadata', {})
|
||||
if hasattr(metadata_obj, 'model_dump'):
|
||||
metadata = metadata_obj.model_dump()
|
||||
elif hasattr(metadata_obj, '__dict__'):
|
||||
metadata = metadata_obj.__dict__
|
||||
elif isinstance(metadata_obj, dict):
|
||||
metadata = metadata_obj
|
||||
else:
|
||||
metadata = {}
|
||||
elif isinstance(scrape_result, dict):
|
||||
# Already a dictionary
|
||||
content_markdown = scrape_result.get('markdown')
|
||||
content_html = scrape_result.get('html')
|
||||
metadata = scrape_result.get('metadata', {})
|
||||
|
||||
# Ensure metadata is a dict (not an object)
|
||||
if not isinstance(metadata, dict):
|
||||
if hasattr(metadata, 'model_dump'):
|
||||
metadata = metadata.model_dump()
|
||||
elif hasattr(metadata, '__dict__'):
|
||||
metadata = metadata.__dict__
|
||||
else:
|
||||
metadata = {}
|
||||
|
||||
# Get title from metadata
|
||||
title = metadata.get("title", "")
|
||||
|
||||
# Re-check final URL after redirect
|
||||
final_url = metadata.get("sourceURL", url)
|
||||
final_blocked = check_website_access(final_url)
|
||||
if final_blocked:
|
||||
logger.info("Blocked redirected web_extract for %s by rule %s", final_blocked["host"], final_blocked["rule"])
|
||||
# Website policy check — block before fetching
|
||||
blocked = check_website_access(url)
|
||||
if blocked:
|
||||
logger.info("Blocked web_extract for %s by rule %s", blocked["host"], blocked["rule"])
|
||||
results.append({
|
||||
"url": final_url, "title": title, "content": "", "raw_content": "",
|
||||
"error": final_blocked["message"],
|
||||
"blocked_by_policy": {"host": final_blocked["host"], "rule": final_blocked["rule"], "source": final_blocked["source"]},
|
||||
"url": url, "title": "", "content": "",
|
||||
"error": blocked["message"],
|
||||
"blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]},
|
||||
})
|
||||
continue
|
||||
|
||||
# Choose content based on requested format
|
||||
chosen_content = content_markdown if (format == "markdown" or (format is None and content_markdown)) else content_html or content_markdown or ""
|
||||
try:
|
||||
logger.info("Scraping: %s", url)
|
||||
scrape_result = _get_firecrawl_client().scrape(
|
||||
url=url,
|
||||
formats=formats
|
||||
)
|
||||
|
||||
results.append({
|
||||
"url": final_url,
|
||||
"title": title,
|
||||
"content": chosen_content,
|
||||
"raw_content": chosen_content,
|
||||
"metadata": metadata # Now guaranteed to be a dict
|
||||
})
|
||||
# Process the result - properly handle object serialization
|
||||
metadata = {}
|
||||
title = ""
|
||||
content_markdown = None
|
||||
content_html = None
|
||||
|
||||
except Exception as scrape_err:
|
||||
logger.debug("Scrape failed for %s: %s", url, scrape_err)
|
||||
results.append({
|
||||
"url": url,
|
||||
"title": "",
|
||||
"content": "",
|
||||
"raw_content": "",
|
||||
"error": str(scrape_err)
|
||||
})
|
||||
# Extract data from the scrape result
|
||||
if hasattr(scrape_result, 'model_dump'):
|
||||
# Pydantic model - use model_dump to get dict
|
||||
result_dict = scrape_result.model_dump()
|
||||
content_markdown = result_dict.get('markdown')
|
||||
content_html = result_dict.get('html')
|
||||
metadata = result_dict.get('metadata', {})
|
||||
elif hasattr(scrape_result, '__dict__'):
|
||||
# Regular object with attributes
|
||||
content_markdown = getattr(scrape_result, 'markdown', None)
|
||||
content_html = getattr(scrape_result, 'html', None)
|
||||
|
||||
# Handle metadata - convert to dict if it's an object
|
||||
metadata_obj = getattr(scrape_result, 'metadata', {})
|
||||
if hasattr(metadata_obj, 'model_dump'):
|
||||
metadata = metadata_obj.model_dump()
|
||||
elif hasattr(metadata_obj, '__dict__'):
|
||||
metadata = metadata_obj.__dict__
|
||||
elif isinstance(metadata_obj, dict):
|
||||
metadata = metadata_obj
|
||||
else:
|
||||
metadata = {}
|
||||
elif isinstance(scrape_result, dict):
|
||||
# Already a dictionary
|
||||
content_markdown = scrape_result.get('markdown')
|
||||
content_html = scrape_result.get('html')
|
||||
metadata = scrape_result.get('metadata', {})
|
||||
|
||||
# Ensure metadata is a dict (not an object)
|
||||
if not isinstance(metadata, dict):
|
||||
if hasattr(metadata, 'model_dump'):
|
||||
metadata = metadata.model_dump()
|
||||
elif hasattr(metadata, '__dict__'):
|
||||
metadata = metadata.__dict__
|
||||
else:
|
||||
metadata = {}
|
||||
|
||||
# Get title from metadata
|
||||
title = metadata.get("title", "")
|
||||
|
||||
# Re-check final URL after redirect
|
||||
final_url = metadata.get("sourceURL", url)
|
||||
final_blocked = check_website_access(final_url)
|
||||
if final_blocked:
|
||||
logger.info("Blocked redirected web_extract for %s by rule %s", final_blocked["host"], final_blocked["rule"])
|
||||
results.append({
|
||||
"url": final_url, "title": title, "content": "", "raw_content": "",
|
||||
"error": final_blocked["message"],
|
||||
"blocked_by_policy": {"host": final_blocked["host"], "rule": final_blocked["rule"], "source": final_blocked["source"]},
|
||||
})
|
||||
continue
|
||||
|
||||
# Choose content based on requested format
|
||||
chosen_content = content_markdown if (format == "markdown" or (format is None and content_markdown)) else content_html or content_markdown or ""
|
||||
|
||||
results.append({
|
||||
"url": final_url,
|
||||
"title": title,
|
||||
"content": chosen_content,
|
||||
"raw_content": chosen_content,
|
||||
"metadata": metadata # Now guaranteed to be a dict
|
||||
})
|
||||
|
||||
except Exception as scrape_err:
|
||||
logger.debug("Scrape failed for %s: %s", url, scrape_err)
|
||||
results.append({
|
||||
"url": url,
|
||||
"title": "",
|
||||
"content": "",
|
||||
"raw_content": "",
|
||||
"error": str(scrape_err)
|
||||
})
|
||||
|
||||
# Merge any SSRF-blocked results back in
|
||||
if ssrf_blocked:
|
||||
results = ssrf_blocked + results
|
||||
|
||||
response = {"results": results}
|
||||
|
||||
@@ -1173,6 +1193,11 @@ async def web_crawl_tool(
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
url = f'https://{url}'
|
||||
|
||||
# SSRF protection — block private/internal addresses
|
||||
if not is_safe_url(url):
|
||||
return json.dumps({"results": [{"url": url, "title": "", "content": "",
|
||||
"error": "Blocked: URL targets a private or internal network address"}]}, ensure_ascii=False)
|
||||
|
||||
# Website policy check
|
||||
blocked = check_website_access(url)
|
||||
if blocked:
|
||||
@@ -1258,6 +1283,11 @@ async def web_crawl_tool(
|
||||
instructions_text = f" with instructions: '{instructions}'" if instructions else ""
|
||||
logger.info("Crawling %s%s", url, instructions_text)
|
||||
|
||||
# SSRF protection — block private/internal addresses
|
||||
if not is_safe_url(url):
|
||||
return json.dumps({"results": [{"url": url, "title": "", "content": "",
|
||||
"error": "Blocked: URL targets a private or internal network address"}]}, ensure_ascii=False)
|
||||
|
||||
# Website policy check — block before crawling
|
||||
blocked = check_website_access(url)
|
||||
if blocked:
|
||||
|
||||
Reference in New Issue
Block a user