- Add .gitea/workflows/ci.yml: Gitea Actions workflow for PR/push CI
- Add scripts/smoke_test.py: fast smoke tests (<30s) for core imports and CLI entrypoints
- Add tests/test_green_path_e2e.py: bare green-path e2e — terminal echo test
- Total CI runtime target: <5 minutes
- No API keys required for smoke/e2e stages
Closes#145
/assign @bezalel
- Add DAN-style patterns: do anything now, stay in character, token smuggling, etc.
- Add roleplaying override patterns: roleplay as, act as if, simulate being, etc.
- Add system prompt extraction patterns: repeat instructions, show prompt, etc.
- 10+ new patterns with full test coverage
- Zero regression on legitimate inputs
WHAT THIS IS
============
The Gitea client is the API foundation that every orchestration
module depends on — graph_store.py, knowledge_ingester.py, the
playbook engine, and tasks.py in timmy-home.
Until now it was 60 lines and 3 methods (get_file, create_file,
update_file). This made every orchestration module hand-roll its
own urllib calls with no retry, no pagination, and no error
handling.
WHAT CHANGED
============
Expanded from 60 → 519 lines. Still zero dependencies (pure stdlib).
File operations: get_file, create_file, update_file (unchanged API)
Issues: list, get, create, comment, find_unassigned
Pull Requests: list, get, create, review, get_diff
Branches: create, delete
Labels: list, add_to_issue
Notifications: list, mark_read
Repository: get_repo, list_org_repos
RELIABILITY
===========
- Retry with random jitter on 429/5xx (same pattern as SessionDB)
- Automatic pagination across multi-page results
- Defensive None handling on assignees/labels (audit bug fix)
- GiteaError exception with status_code/url attributes
- Token loading from ~/.timmy/gemini_gitea_token or env vars
WHAT IT FIXES
=============
- tasks.py crashed with TypeError when iterating None assignees
on issues created without setting one (Gitea returns null).
find_unassigned_issues() now uses 'or []' on the assignees
field, matching the same defensive pattern used in SessionDB.
- No module provided issue commenting, PR reviewing, branch
management, or label operations — the playbook engine could
describe these operations but not execute them.
BACKWARD COMPATIBILITY
======================
The three original methods (get_file, create_file, update_file)
maintain identical signatures. graph_store.py and
knowledge_ingester.py import and call them without changes.
TESTS
=====
27 new tests — all pass:
- Core HTTP (5): auth, params, body encoding, None filtering
- Retry (5): 429, 502, 503, non-retryable 404, max exhaustion
- Pagination (3): single page, multi-page, max_items
- Issues (4): list, comment, None assignees, label exclusion
- Pull requests (2): create, review
- Backward compat (4): signatures, constructor env fallback
- Token config (2): missing file, valid file
- Error handling (2): attributes, exception hierarchy
Signed-off-by: gemini <gemini@hermes.local>
Replace shell=True with list-based subprocess execution to prevent
command injection via malicious user input.
Changes:
- tools/transcription_tools.py: Use shlex.split() + shell=False
- tools/environments/docker.py: List-based commands with container ID validation
Fixes CVE-level vulnerability where malicious file paths or container IDs
could inject arbitrary commands.
CVSS: 9.8 (Critical)
Refs: V-001 in SECURITY_AUDIT_REPORT.md
ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys were not covered
by _PREFIX_PATTERNS, leaking in plain text via printenv or log output.
Salvaged from PR #3790 by @memosr. Tests rewritten with correct
assertions (original tests had vacuously true checks).
Co-authored-by: memosr <memosr@users.noreply.github.com>
1. matrix voice: _on_room_message_media unconditionally overwrote
media_urls with the image cache path (always None for non-images),
wiping the locally-cached voice path. Now only overrides when
cached_path is truthy.
2. cli_tools_command: /tools disable no longer prompts for confirmation
(input() removed in earlier commit to fix TUI hang), but tests still
expected the old Y/N prompt flow. Updated tests to match current
behavior (direct apply + session reset).
3. slack app_mention: connect() was refactored for multi-workspace
(creates AsyncWebClient per token), but test only mocked the old
self._app.client path. Added AsyncWebClient and acquire_scoped_lock
mocks.
4. website_policy: module-level _cached_policy from earlier tests caused
fast-path return of None. Added invalidate_cache() before assertion.
5. codex 401 refresh: already passing on current main (fixed by
intervening commit).
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
* feat(matrix): support native voice messages
* fix: skip matrix voice tests when matrix-nio not installed
---------
Co-authored-by: Carlos Alberto Pereira Gomes <carlosapgomes@users.noreply.github.com>
- measure status bar display width using prompt_toolkit cell widths
- trim rendered status text when fragments would overflow
- add a final single-fragment fallback to prevent wrapping
- update width assertions to validate display cells instead of len()
Adds lifecycle hooks to the base platform adapter so Discord (and future
platforms) can react to message processing events:
👀 when processing starts
✅ on successful completion (delivery confirmed)
❌ on failure, error, or cancellation
Implementation:
- base.py: on_processing_start/on_processing_complete hooks with
_run_processing_hook error isolation wrapper; delivery tracking
via _record_delivery closure for accurate success detection
- discord.py: _add_reaction/_remove_reaction helpers + hook overrides
- Tests for base hook lifecycle and Discord-specific reactions
Co-authored-by: alanwilhelm <alanwilhelm@users.noreply.github.com>
The ./hermes convenience script still used the legacy Fire-based
cli.main wrapper, which doesn't support subcommands (gateway, cron,
doctor, etc.). The installed 'hermes' command already uses
hermes_cli.main:main (argparse) — this aligns the launcher.
Salvaged from PR #2009 by gito369.
Adds Discord-style mention gating for Telegram groups:
- telegram.require_mention: gate group messages (default: false)
- telegram.mention_patterns: regex wake-word triggers
- telegram.free_response_chats: bypass gating for specific chats
When require_mention is enabled, group messages are accepted only for:
- slash commands
- replies to the bot
- @botusername mentions
- regex wake-word pattern matches
DMs remain unrestricted. @mention text is stripped before passing to
the agent. Invalid regex patterns are ignored with a warning.
Config bridges follow the existing Discord pattern (yaml → env vars).
Cherry-picked and adapted from PR #1977 by mcleay. Fixed ChatType
comparison to work without python-telegram-bot installed (uses string
matching instead of enum, consistent with other entity_type checks).
Co-authored-by: mcleay <mcleay@users.noreply.github.com>
Setup previously only printed a manual install hint for matrix-nio,
causing the gateway to crash with 'matrix-nio not installed' after
configuring Matrix. Now auto-installs matrix-nio (or matrix-nio[e2e]
when E2EE is enabled) using the same uv-first/pip-fallback pattern
as Daytona and Modal backends.
Also adds hermes-agent[matrix] to the [all] extra in pyproject.toml
and a regression test to keep it there.
Co-authored-by: Gutslabs <Gutslabs@users.noreply.github.com>
Co-authored-by: cutepawss <cutepawss@users.noreply.github.com>
When a command timed out, all captured output was discarded — the agent
only saw 'Command timed out after Xs' with zero context. Now returns
the buffered output followed by a timeout marker, matching the existing
interrupt path behavior.
Salvaged from PR #3286 by @binhnt92.
Co-authored-by: nguyen binh <binhnt92@users.noreply.github.com>
Adds WeCom as a gateway platform adapter using the AI Bot WebSocket
gateway for real-time bidirectional communication. No public endpoint
or new pip dependencies needed (uses existing aiohttp + httpx).
Features:
- WebSocket persistent connection with auto-reconnect (exponential backoff)
- DM and group messaging with configurable access policies
- Media upload/download with AES decryption for encrypted attachments
- Markdown rendering, quote context preservation
- Proactive + passive reply message modes
- Chunked media upload pipeline (512KB chunks)
Cherry-picked from PR #1898 by EvilRan with:
- Moved to current main (PR was 300 commits behind)
- Skipped base.py regressions (reply_to additions are good but belong
in a separate PR since they affect all platforms)
- Fixed test assertions to match current base class send() signature
(reply_to=None kwarg now explicit)
- All 16 integration points added surgically to current main
- No new pip dependencies (aiohttp + httpx already installed)
Fixes#1898
Co-authored-by: EvilRan <EvilRan@users.noreply.github.com>
Cron jobs configured with deliver labels from send_message(action='list')
like 'whatsapp:Alice (dm)' passed the label as a literal chat_id.
WhatsApp bridge failed with jidDecode error since 'Alice (dm)' isn't
a valid JID.
Now _resolve_delivery_target() strips display suffixes like ' (dm)' and
resolves human-friendly names via the channel directory before using
them. Raw IDs pass through unchanged when the directory has no match.
Fixes#1945.
Previously, when no API keys or provider credentials were found, Hermes
silently defaulted to OpenRouter + Claude Opus. This caused confusion
when users configured local servers (LM Studio, Ollama, etc.) with a
typo or unrecognized provider name — the system would silently route to
OpenRouter instead of telling them something was wrong.
Changes:
- resolve_provider() now raises AuthError when no credentials are found
instead of returning 'openrouter' as a silent fallback
- Added local server aliases: lmstudio, ollama, vllm, llamacpp → custom
- Removed hardcoded 'anthropic/claude-opus-4.6' fallback from gateway
and cron scheduler (they read from config.yaml instead)
- Updated cli-config.yaml.example with complete provider documentation
including all supported providers, aliases, and local server setup
Local inference servers (Ollama, llama.cpp, vLLM, LM Studio) don't
require API keys, but the auxiliary client's _resolve_custom_runtime()
rejected endpoints with empty keys — causing the auto-detection chain
to skip the user's local server entirely. This broke compression,
summarization, and memory flush for users running local models without
an OpenRouter/cloud API key.
The main CLI already had this fix (PR #2556, 'no-key-required'
placeholder), but the auxiliary client's resolution path was missed.
Two fixes:
- _resolve_custom_runtime(): use 'no-key-required' placeholder instead
of returning None when base_url is present but key is empty
- resolve_provider_client() custom branch: same placeholder fallback
for explicit_base_url without explicit_api_key
Updates 2 tests that expected the old (broken) behavior.
Three safety gaps in vision_analyze_tool:
1. Local files accepted without checking if they're actually images —
a renamed text file would get base64-encoded and sent to the model.
Now validates magic bytes (PNG, JPEG, GIF, BMP, WebP, SVG).
2. No website policy enforcement on image URLs — blocked domains could
be fetched via the vision tool. Now checks before download.
3. No redirect check — if an allowed URL redirected to a blocked domain,
the download would proceed. Now re-checks the final URL.
Fixed one test that needed _validate_image_url mocked to bypass DNS
resolution on the fake blocked.test domain (is_safe_url does DNS
checks that were added after the original PR).
Co-authored-by: GutSlabs <GutSlabs@users.noreply.github.com>
Validate category names in _create_skill() before using them as
filesystem path segments. Previously, categories like '../escape' or
'/tmp/pwned' could write skill files outside ~/.hermes/skills/.
Adds _validate_category() that rejects slashes, backslashes, absolute
paths, and non-alphanumeric characters (reuses existing VALID_NAME_RE).
Tests: 5 new tests for traversal, absolute paths, and valid categories.
Salvaged from PR #1939 by Gutslabs.
test_hooks.py (7 failures): Built-in boot-md hook was always loaded
by _register_builtin_hooks(), adding +1 to every expected hook count.
Mock out built-in registration in TestDiscoverAndLoad so tests isolate
user-hook discovery logic.
test_tool_token_estimation.py (2 failures): tiktoken is not in
core/[all] dependencies. The estimation function gracefully returns {}
when tiktoken is missing, but tests expected non-empty results. Added
skipif markers for tests that need tiktoken.
test_plugins_cmd.py (1 failure): bare 'hermes plugins' now dispatches
to cmd_toggle() (interactive curses UI) instead of cmd_list(). Updated
test to match the new behavior.
WhatsApp DMs can arrive with LID sender IDs even when
WHATSAPP_ALLOWED_USERS is configured with phone numbers. The allowlist
check now reads bridge session mapping files (lid-mapping-*.json) to
resolve phone↔LID aliases, matching users regardless of which
identifier format the message uses.
Both the Python gateway (_is_user_authorized) and the Node bridge
(allowlist.js) now share the same mapping-file-based resolution logic.
Co-authored-by: Frederico Ribeiro <fr@tecompanytea.com>
Adds Feishu (ByteDance's enterprise messaging platform) as a gateway
platform adapter with full feature parity: WebSocket + webhook transports,
message batching, dedup, rate limiting, rich post/card content parsing,
media handling (images/audio/files/video), group @mention gating,
reaction routing, and interactive card button support.
Cherry-picked from PR #1793 by penwyp with:
- Moved to current main (PR was 458 commits behind)
- Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to
_feishu_send_with_retry to avoid signature mismatch crash)
- Fixed import structure: aiohttp/websockets imported independently of
lark_oapi so they remain available when SDK is missing
- Fixed get_hermes_home import (hermes_constants, not hermes_cli.config)
- Added skip decorators for tests requiring lark_oapi SDK
- All 16 integration points added surgically to current main
New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu])
Fixes#1788
Co-authored-by: penwyp <penwyp@users.noreply.github.com>
* feat(skills): add memento-flashcards skill
* docs(skills): clarify memento-flashcards interaction model
* fix: use HERMES_HOME env var for profile-safe data path
---------
Co-authored-by: Magnus Ahmad <magnus.ahmad@gmail.com>
Adds a config option to suppress the header/footer text that wraps
cron job responses when delivered to messaging platforms.
Set cron.wrap_response: false in config.yaml for clean output without
the 'Cronjob Response: <name>' header and 'The agent cannot see this
message' footer. Default is true (preserves current behavior).
Replace per-request aiohttp.ClientSession() in every WhatsApp adapter
method with a single persistent self._http_session, matching the pattern
used by Mattermost, HomeAssistant, and SMS adapters.
Changes:
- Create self._http_session in connect(), close in disconnect()
- All bridge HTTP calls (send, edit, send-media, typing, get_chat_info,
poll_messages) now use the shared session
- Explicitly cancel _poll_task on disconnect() instead of relying
solely on self._running = False
- Health-check sessions in connect() remain ephemeral (persistent
session not yet created at that point)
- Remove per-method ImportError guards for aiohttp (always available
when gateway runs via [messaging] extras)
Salvaged from PR #1851 by Himess. The _poll_task storage was already
on main from PR #3267; this adds the disconnect cancellation and the
persistent session.
Tests: 4 new tests for session close, already-closed skip, poll task
cancellation, and done-task skip.
Extends the single fallback_model mechanism into an ordered chain.
When the primary model fails, Hermes tries each fallback provider in
sequence until one succeeds or the chain is exhausted.
Config format (new):
fallback_providers:
- provider: openrouter
model: anthropic/claude-sonnet-4
- provider: openai
model: gpt-4o
Legacy single-dict fallback_model format still works unchanged.
Key fix vs original PR: the call sites in the retry loop now use
_fallback_index < len(_fallback_chain) instead of the old one-shot
_fallback_activated guard, so the chain actually advances through
all configured providers.
Changes:
- run_agent.py: _fallback_chain list + _fallback_index replaces
one-shot _fallback_model; _try_activate_fallback() advances
through chain; failed provider resolution skips to next entry;
call sites updated to allow chain advancement
- cli.py: reads fallback_providers with legacy fallback_model compat
- gateway/run.py: same
- hermes_cli/config.py: fallback_providers: [] in DEFAULT_CONFIG
- tests: 12 new chain tests + 6 existing test fixtures updated
Co-authored-by: uzaylisak <uzaylisak@users.noreply.github.com>
The honcho check_fn only checked runtime session state, which isn't
set until the agent initializes. At banner time, honcho tools showed
as red/disabled even when properly configured.
Now checks configuration (enabled + api_key/base_url) as a fallback
when the session context isn't active yet. Fast path (session active)
unchanged; slow path (config check) only runs at banner time.
Adds 4 tests covering: session active, configured but no session,
not configured, and import failure graceful fallback.
Closes#1843.
When a connected MCP server sends a ToolListChangedNotification (per the
MCP spec), Hermes now automatically re-fetches the tool list, deregisters
removed tools, and registers new ones — without requiring a restart.
This enables MCP servers with dynamic toolsets (e.g. GitHub MCP with
GITHUB_DYNAMIC_TOOLSETS=1) to add/remove tools at runtime.
Changes:
- registry.py: add ToolRegistry.deregister() for nuke-and-repave refresh
- mcp_tool.py: extract _register_server_tools() from
_discover_and_register_server() as a shared helper for both initial
discovery and dynamic refresh
- mcp_tool.py: add _make_message_handler() and _refresh_tools() on
MCPServerTask, wired into all 3 ClientSession sites (stdio, new HTTP,
deprecated HTTP)
- Graceful degradation: silently falls back to static discovery when the
MCP SDK lacks notification types or message_handler support
- 8 new tests covering registration, refresh, handler dispatch, and
deregister
Salvaged from PR #1794 by shivvor2.