Commit graph

2109 commits

Author SHA1 Message Date
Shannon Sands
bf7639138e Use read-only config loader and honor HERMES_IGNORE_USER_CONFIG in delegation config 2026-07-07 17:17:38 -07:00
Shannon Sands
0263f1d12e Fix delegation config precedence 2026-07-07 17:17:38 -07:00
teknium1
838d50495f fix(mcp): guard POSIX-only kill primitives in stdio watchdog for the Windows footgun linter
signal.SIGKILL / os.killpg don't exist on Windows. The watchdog is only
spawned on POSIX (wrap site gates on os.name), but guard via getattr with
a plain terminate/kill fallback so an accidental Windows import can't
AttributeError.
2026-07-07 15:16:00 -07:00
harjoth
ea0b42c43a Handle minimal MCP server fakes 2026-07-07 15:16:00 -07:00
harjoth
6c731fe591 Recycle idle MCP stdio servers 2026-07-07 15:16:00 -07:00
teknium1
86c5febdd1 fix(mcp): watchdog wrap after OSV preflight + forward SIGTERM to child group
Two fixes on top of the salvaged parent-death watchdog:
- Apply the watchdog wrap AFTER the OSV malware preflight so the check
  inspects the real npx/uvx package instead of the python wrapper
  (the wrap previously made the preflight a silent no-op for every
  stdio server).
- The real server runs in its own process group under the watchdog, so
  the graceful-shutdown killpg no longer reached it; the watchdog now
  forwards SIGTERM/SIGINT to the child's group, keeping wedged servers
  killable on clean shutdown.
2026-07-07 15:16:00 -07:00
Sage
5089c84dbf fix(mcp): reap orphaned stdio MCP children on ungraceful parent death
A stdio MCP server (e.g. `npx -y mcp-remote <url>`) is spawned as a direct
child of the Hermes process. Existing teardown (MCPServerTask.shutdown() /
_kill_orphaned_mcp_children()) reaps it correctly on a clean exit, but a
kill -9 / crash / force-quit of the Hermes process skips that path entirely
-- the child (and its own descendants, e.g. mcp-remote's spawned node
process) is orphaned and keeps running. Repeated ungraceful restarts pile up
N orphaned processes racing to hold the same upstream SSE session, producing
errors like 'Invalid request parameters' on legitimate reconnects.

macOS/Linux have no portable equivalent of prctl(PR_SET_PDEATHSIG) at the
Python subprocess level, so this adds a thin supervisor
(tools/mcp_stdio_watchdog.py) that:
  - execs the real command as its own child in its own process group
  - passes stdin/stdout/stderr through untouched (MCP stdio protocol
    talks directly over those streams)
  - polls the original spawning PID with the same orphan-detection
    algorithm already proven in tui_gateway/slash_worker.py (ppid
    comparison + psutil creation-time guard against PID reuse)
  - SIGTERM-then-SIGKILL's the child's process group the moment the
    original parent is gone

Wired into _run_stdio via a new _wrap_command_with_watchdog() helper,
POSIX-only (matches the existing killpg-based cleanup's platform scope),
fails open (any error resolving pid/create-time falls back to the
unwrapped command) so this can never be the reason a working MCP server
stops starting.

Verified: reproduced the exact orphan scenario standalone (fake parent
process spawns watchdog + fake long-running MCP child, kill -9 the fake
parent, confirm the watchdog reaps the child within its poll window with
zero leaked processes). Updated test_mcp_tool_issue_948.py's resolved-path
assertion to check the watchdog-wrapped command instead of the raw
resolved binary. Full test_mcp_tool.py + test_mcp_stability.py +
test_mcp_tool_issue_948.py suite: 232 passed. Full -k mcp sweep across the
whole test tree: 1003 passed, 2 skipped, 0 failed.
2026-07-07 15:16:00 -07:00
teknium1
4638f3b433 fix(mcp): widen #59349 handshake bound to HTTP transports + cancel abandoned start() task
Sibling sites of the same bug class as the salvaged stdio fix:
- SSE, streamable-HTTP (new + deprecated API) initialize() calls are now
  bounded by the same connect_timeout, so an endpoint that accepts the
  connection but never answers the handshake cannot park the run() task
  forever.
- start() now cancels its ensure_future'd run() task when the caller's
  connect timeout cancels start() itself — the orphaned-task leak was
  the root mechanism behind #59349, and this closes the class for any
  future pre-ready hang.
2026-07-07 15:16:00 -07:00
rainbowgits
1f6836cd81 fix(mcp): bound stdio initialize handshake to stop subprocess/FD leak
A stdio MCP server that never completes `initialize` (e.g. emits a
non-JSON-RPC frame and then blocks on stdin) leaks a child process plus its
stdio pipes/pidfd on every discovery-retry cycle — unbounded, until the
gateway hits EMFILE and every new open()/spawn fails (#59349).

Root cause (confirmed by instrumenting the live repro, and different from the
issue's own hypothesis): the spawned child IS captured in `new_pids`, so the
report's "new_pids empty at finally" guess is not it. The real cause is that
`session.initialize()` hangs forever on the garbage stream. `connect_timeout`
only bounds the caller's `.result()` wait on the foreground thread — it does
NOT cancel the `_run_stdio` coroutine on the background MCP loop. So the
coroutine is stuck at `await session.initialize()` permanently, its cleanup
`finally` never runs, the child is never reaped, and it stays invisible to the
orphan-reaper (whose `_orphan_stdio_pids` set never gets populated).

Fix: wrap `session.initialize()` in `asyncio.wait_for(..., connect_timeout)`
so a stalled handshake fails instead of hanging. The TimeoutError unwinds
through the SDK context managers (closing the child's stdin -> EOF -> exit)
and lets the existing `finally` reap any straggler. Cross-platform — no
signals/pgid/proc.

Scope: stdio only. The HTTP path has the same `await session.initialize()`
shape but spawns no subprocess (so it can't cause this leak) and already has
httpx transport timeouts.

Verified: the reporter's repro goes from unbounded growth to draining to zero;
added a hermetic regression test (fake transport whose `initialize()` hangs,
asserts the connect is bounded by connect_timeout) that fails on the pre-fix
code and passes on the fix; 566 existing MCP tests pass; ruff clean.

Repro confirmed on macOS (pipe FDs); the Linux-specific pidfd growth in the
report should be equivalent — the reporter offered to validate on Linux.

Closes #59349
2026-07-07 15:16:00 -07:00
teknium1
743c116fb2 fix(mcp): unify reconnect orphan reaping + move off the event loop
Merge the two cherry-picked reap call sites into one unscoped sweep at
the top of _run_stdio (the unscoped sweep is a superset of the
per-server one), and run it via asyncio.to_thread so the 2s
SIGTERM->SIGKILL escalation cannot stall the shared MCP event loop.
2026-07-07 15:16:00 -07:00
yoma
f99e9f0d27 fix(mcp): reap stdio orphans before reconnect 2026-07-07 15:16:00 -07:00
liuhao1024
086596ca2b fix(mcp): reap orphaned subprocesses before spawning new ones on retry
When an MCP stdio subprocess fails to connect (token expiry, port
contention, timeout), the run() reconnect loop retries with backoff.
Each retry calls _run_stdio() which spawns a new process pair, but the
previous failed pair was only detected as orphaned (added to
_orphan_stdio_pids) — never actually killed.  This caused rapid zombie
accumulation: 5 failed attempts × 2 procs each = 10 orphans competing
for the same port.

Add a _kill_orphaned_mcp_children() call at the top of _run_stdio(),
before the _snapshot_child_pids() baseline, so any orphans from prior
failed attempts are reaped before a new subprocess is spawned.

Fixes #57355
2026-07-07 15:16:00 -07:00
teknium1
117f49b7d4 fix(approval): wire gateway notify round-trip into the plugin escalation gate
_run_approval_gate's gateway branch only queued via submit_pending, so
plugin-escalated approvals never sent the interactive embed+buttons on
Discord/Telegram/Slack (#59413) - the user was never notified and the
action stayed silently blocked. Mirror check_dangerous_command's path:
when a session notify callback is registered, run the blocking
_await_gateway_decision round-trip (redacted payload, once/session/
always persistence, deny/timeout produce definitive BLOCKED outcomes);
fall back to submit_pending only when no callback exists.

Fixes #59413.
2026-07-07 15:14:30 -07:00
doncazper
36308f0667 feat(plugins): pass approve rule keys to approval gate 2026-07-07 15:14:30 -07:00
Teknium
0e04d14209
feat(sessions): trace export + HF upload via 'sessions export --format trace' (#60507)
* feat(trace): upload sessions to HF Agent Trace Viewer

Salvage trace upload as a smaller CLI-first feature: deterministic Claude Code JSONL export, fail-closed redaction, lazy Hugging Face dependency, and no gateway slash-command wiring.

* chore(trace): drop external porting references from docstrings

Describe the trace-upload design in Hermes' own terms.

* feat(sessions): fold trace upload into 'sessions export --format trace'

Integrates the HF Agent Trace Viewer exporter (PR #36145) onto the
unified export surface instead of a separate 'hermes trace' subcommand:

- --format trace: Claude Code JSONL to stdout/file, or one
  <id>.trace.jsonl per session for filtered bulk export; defaults to
  the most recent session when no --session-id/filters given.
- --upload pushes to the user's private HF traces dataset (--public to
  opt out of private); reads HF_TOKEN with guided setup when missing.
- traces are secret-redacted by default (force mode); --no-redact opts
  out after review; redaction failure blocks export (fail closed).
- hermes_cli/trace.py + subcommands/trace.py removed; agent/trace_upload.py
  is the single engine. Docs EN + zh-Hans; 4 new CLI tests.
2026-07-07 15:12:49 -07:00
Eugeniusz Gilewski
a1e6ea7d71 fix(tools): keep shell snapshots owner-only
BaseEnvironment writes shell snapshots and cwd metadata through the process
umask. With a common 022 umask, snapshot files containing exported environment
state landed at mode 0644 even though they can include env-carried credentials
from the parent process.

Set umask 077 only around Hermes metadata writes: the initial snapshot
bootstrap and the post-command snapshot/cwd refresh. User commands still run
under the caller's original umask, while Hermes-owned snapshot and cwd files
are created owner-only.

This intentionally does not copy the source PR's global orphan sweep; deleting
all matching /tmp snapshot files could interfere with concurrent Hermes
processes. The security-critical local disclosure fix is the file mode clamp.

This is salvageable because the source report still identifies a concrete
credential-disclosure path, but the safe subset is smaller than the original
proposal: clamp only the Hermes-owned snapshot writes and leave process-wide
cleanup, user command umask, and concurrent sessions alone.

Salvages source PR: https://github.com/NousResearch/hermes-agent/pull/20056
Related issue: https://github.com/NousResearch/hermes-agent/issues/48441

Co-authored-by: Andrew Homeyer <andrew@hndl.app>
2026-07-07 05:22:42 -07:00
teknium1
c6a3d412d4 fix(skills): widen call-time skills-dir resolution to skill_manager_tool
Same bug class as skills_tool: module-level SKILLS_DIR pinned at import
under the launch HERMES_HOME makes skill_manage() write/edit against the
wrong profile in long-lived multi-profile runtimes. Apply the same
_skills_dir() call-time resolution (honoring explicit test patches of
SKILLS_DIR) to _containing_skills_root, _resolve_skill_dir,
_find_skill_in_other_profiles, and create-result path reporting.

Refs #40677
2026-07-07 05:14:00 -07:00
JP Lew
f8723c4781 fix(skills): resolve skills dir from active profile 2026-07-07 05:14:00 -07:00
teknium1
ce038a0e05 fix(schema): preserve multi-type arrays as anyOf instead of dropping branches
Port from anomalyco/opencode#31877: JSON Schema type arrays like
["number","string"] (common in MCP tool schemas) were collapsed to the
first non-null type, silently dropping every other branch. Several
tool-call backends reject the array form outright — llama.cpp's grammar
generator and Gemini via OpenAI-compatible transports (e.g. GitHub
Copilot proxying to Gemini) 400 on it.

_sanitize_node now mirrors @ai-sdk/google: a single non-null type stays
type:X (+nullable if null was present), multiple non-null types become
an anyOf of single-type schemas so no branch is lost, and an all-null
array becomes type:null. Single-null collapse is unchanged.

Verified nested (object props, array items) survive the full sanitize
pipeline — combinator stripping is top-level-only and nullable-union
collapse only fires on single-survivor unions, so multi-type anyOf is
left intact.
2026-07-07 02:52:17 -07:00
ooiuuii
b8ce583e05 fix(discord): bound REST response reads
Refs NousResearch/hermes-agent#54745
2026-07-07 02:40:04 -07:00
Teknium
8fc1cb754b
fix: repair URL authority whitespace before web fetches (#46363)
Port from openclaw/openclaw#91950: normalize LLM-generated URLs like 'https:// docs.example' before web tool safety checks while preserving path and query encoding semantics.
2026-07-07 02:39:36 -07:00
Teknium
fc02b1c276 refactor(cli): simplify safe-mode startup wiring
Since safe mode already landed on main via #45488, reduce this branch to cleanup: centralize env setup, remove duplicated comments, and tighten tests.
2026-07-07 02:32:32 -07:00
Teknium
07d93413e5
fix: default memory null target to memory store (#46356)
Port from nearai/ironclaw#4547: treat a JSON null memory target as omitted so strict providers that fill optional fields with null use the documented default target instead of failing validation.
2026-07-07 02:10:43 -07:00
hellno
d7348bf24b fix(interrupt): run user-approved commands from a clean interrupt slate
A user-approved terminal/execute_code command could be SIGINT-killed
(exit 130 + "[Command interrupted]") by a stale interrupt bit that landed
on the execution thread during the blocking approval-wait, while the
result still carried the "...approved by the user." note. The terminal
tool runs sequentially inline on the execution thread, and nothing
cleared or re-checked the bit between approval-grant and env.execute.

Clear the current thread's interrupt bit once before an approved command
spawns its child (terminal foreground; execute_code local + remote), and
enrich the note to "...approved by the user, then interrupted." on a
genuine post-start interrupt instead of implying success. A genuine
interrupt arriving after execution starts (or during a retry backoff)
still SIGINTs the command; non-approved commands keep current behavior.

Adds regression tests covering stale-bit-clears, genuine-interrupt-still-
kills, the retry-backoff window, natural-exit-130 (not mislabeled), and
execute_code local + remote.
2026-07-06 04:58:42 -07:00
Teknium
27f74b26c5
fix(web): correct 'disabled plugin' diagnosis for web backends (#59573)
When a bundled web provider (firecrawl, tavily, exa, ...) is listed in
plugins.disabled, its provider never registers and the web_search/
web_extract dispatchers emitted the misleading "No web extract provider
configured. Set web.extract_backend to ..." — even though the backend was
configured correctly. The real fix is to re-enable the plugin.

- web_tools.py + web_search_registry.py: when the configured backend names
  a disabled bundled web plugin, both dispatchers now point the user at the
  actual cause (re-enable the plugin) instead of a wrong config hint.
- plugins_cmd.py cmd_enable: enabling by canonical key now also clears the
  manifest-name alias (web-firecrawl) from plugins.disabled, so the
  suggested command actually re-enables the plugin ('explicit disable wins'
  matches on the name too).
- plugins_cmd.py cmd_toggle / _run_composite_ui / _run_composite_fallback:
  the interactive 'hermes plugins' menu now persists the canonical key
  (web/firecrawl), never the bare manifest name — the drift that put the
  offending entry in plugins.disabled in the first place.

Follow-up to #59518 (which fixed web credential resolution, a different
cause). Fixes the disabled-plugin symptom reported after that PR.
2026-07-06 04:38:17 -07:00
liuhao1024
026ab4737d fix(web): use get_env_value for Firecrawl config resolution
The Firecrawl provider used os.getenv() to read FIRECRAWL_API_KEY and
FIRECRAWL_API_URL, which only checks the process environment.  When
values are supplied through Hermes's ~/.hermes/.env config mechanism
(via hermes_cli.config.get_env_value), they are not guaranteed to be
present in os.environ for every gateway/tool execution path.

Switch to get_env_value() which checks both os.environ and the .env
file, matching the pattern used by other providers (nous_subscription,
setup, discord adapter).

Fixes #40190
2026-07-06 02:42:24 -07:00
Frowtek
a88e0fd2ab fix(file-sync): re-deliver deferred Ctrl+C via raise_signal, not os.kill (Windows hard-kill)
_sync_back_once defers a SIGINT that lands mid-sync, then re-delivers it once the
sync completes so the user's Ctrl+C isn't lost. It did so with
os.kill(os.getpid(), signal.SIGINT). That is not graceful on Windows: os.kill
only treats CTRL_C_EVENT(0)/CTRL_BREAK_EVENT(1) as console events; any other
value (SIGINT == 2) routes to TerminateProcess(sig), so a Ctrl+C during a
remote-backend (ssh/daytona/modal) sync-back hard-kills the whole CLI session
(exit code 2) on Windows instead of raising KeyboardInterrupt.

Use signal.raise_signal(signal.SIGINT) (3.8+), which invokes the restored
handler through C raise() on every platform. Verified on Windows: raise_signal
runs the handler (graceful) while os.kill(getpid, SIGINT) TerminateProcess-es
the process. Adds a cross-platform regression test that runs on Windows too (it
stubs the locked sync body, so unlike test_file_sync_back.py it needs no fcntl).
2026-07-06 01:55:58 -07:00
Ben
590a19332e fix(skills): don't request Brotli for the centralized skills index
The Skills Hub 'Browse Hub' landing page and index-backed search render
empty on fresh deployments (e.g. Fly.io VPS agents) with no stale cache.

Root cause: the centralized index at /docs/api/skills-index.json is a
large body (~34MB, tens of MB compressed) served with Content-Encoding:
br. httpx's streaming Brotli decoder — backed by brotlicffi 1.2.0.1,
which is pinned so aiohttp can decode Discord attachments — trips over
its own output_buffer_limit on a payload this size and raises:

  DecodingError("brotli: decoder process called with data when
  'can_accept_more_data()' is False")

_load_hermes_index() catches that (DecodingError is an httpx.HTTPError
subclass) and silently falls back to the on-disk cache. On a fresh box
that cache never existed, so HermesIndexSource.is_available is False,
the index contributes 0 skills, and the hub landing page — which is
built solely from an empty-query index search — is blank. Existing
installs only appear to work because they serve a (possibly weeks-)stale
cached index instead.

Fix: request 'gzip, deflate' on the index fetch so httpx never
negotiates the broken Brotli path, and retry once with 'identity' if a
DecodingError still occurs (defends against a proxy that ignores the
header). Falls through to the stale cache only when both attempts fail.

Verified on a live staging VPS agent: index_available flips False->True
and the featured landing list repopulates from 0 to 12.

Also un-freezes already-deployed images: skills added after an image was
built (e.g. the 'unbroker' optional skill) become reachable again via
the index, which is the whole point of the centralized catalog.
2026-07-05 22:21:05 -07:00
teknium1
43a4256320 fix(mcp): wake stale cached servers on session startup + AUTHOR_MAP
register_mcp_servers now nudges cached entries whose session is None
via _signal_reconnect, so a new agent session recovers a parked server
immediately instead of waiting up to _PARKED_RETRY_INTERVAL for the
next self-probe (#50170). Gate-check idea credit: @izumi0uu (#50184),
@LeonSGP43 (#37772), @Tranquil-Flow (#37899).
2026-07-05 21:48:36 -07:00
teknium1
756dd75fbe fix(mcp): iteration-bound the session-ready poll so frozen-clock tests can't spin forever
_wait_for_server_session_ready used a time.monotonic deadline; the
circuit-breaker tests freeze monotonic, turning the loop into an
infinite spin (300s SIGKILL in CI-parity runs). Bound by iteration
count instead.
2026-07-05 21:48:36 -07:00
Marin Pesa
27beeb1830 fix: reconnect stale MCP sessions before retry 2026-07-05 21:48:36 -07:00
Teknium
a124d16764
perf: cut first-turn time-to-first-token by ~80% (all platforms) (#59332)
Four independent pre-request stalls sat on the critical path between
prompt submission and the first streamed token, measured with cProfile
against a live process:

1. Discord capability detection (~2.0s, worst 5s): get_tool_definitions
   -> _get_dynamic_schema made a BLOCKING https call to discord.com
   inside AIAgent.__init__ for any user with DISCORD_BOT_TOKEN set, on
   every platform, every cold process. Now non-blocking: memory cache ->
   24h disk cache -> permissive default + one background detection that
   seeds the disk cache for the next process. The permissive default is
   pinned per-process so tool schemas never flip mid-conversation
   (prompt-cache safety); it mirrors the existing detection-failure
   fallback (all actions exposed, 403s enriched at call time).

2. Ollama /api/show probe (~0.3s): get_model_context_length step 5e
   POSTed to <base_url>/api/show for KNOWN providers (openrouter etc.),
   got a 404, and never cached the miss - so every fresh process paid a
   full HTTP round-trip. Known non-Ollama providers now skip the probe;
   local/custom/unknown endpoints keep the exact previous behavior.

3. env_probe subprocess sweep (~0.5s): the Python-toolchain probe ran
   4-8 subprocess calls inside the FIRST system prompt build. Now warmed
   off-thread during agent init; the prompt build hits the cache (same
   lock, so a mid-flight warm just joins instead of recomputing).

4. tools.mcp_tool import (~0.4s): the between-turns MCP refresh in
   build_turn_context imported the whole mcp package even with zero MCP
   servers configured. MCP tools can only exist if tools.mcp_tool was
   already imported (discovery/reload paths), so gate the import on
   sys.modules membership - no behavior change for MCP users.

CLI additionally pre-imports run_agent + openai off-thread during the
idle banner window (same pattern as the /model picker prewarm), hiding
the remaining ~1.5s of module imports while the user types. Fixes 1-4
apply to every interaction layer (CLI, gateway, TUI, desktop, cron).

Measured cold first turn (submit -> request dispatched, openrouter,
discord token set): 4.3s before -> 0.9s after CLI prewarm (~80%); the
agent-side non-import cost drops 2.9s -> 0.36s (init) + 0.27s (turn
prologue).
2026-07-05 21:37:33 -07:00
teknium1
e8b0e38a2e docs+test(mcp): document skip_preflight and cover the bypass with a test
Docs harvested from PR #56251 by @huangdihd (duplicate of #55203,
submitted two days later, better documented). Test added by us.
2026-07-05 21:36:19 -07:00
kaishi00
549def3a21 fix(mcp): add skip_preflight config option for servers serving HTML on GET
Some MCP servers (e.g. Spring Boot apps with a React SPA) serve their
frontend on any unmatched GET route. The MCP endpoint works perfectly
via POST (JSON-RPC), but a GET to /mcp falls through to the SPA
controller and returns text/html. Hermes's preflight content-type probe
sees HTML instead of application/json or text/event-stream and refuses
to connect.

This adds a per-server  config option that
bypasses the content-type probe, letting the SDK connect directly via
POST where it works fine.

```yaml
mcp_servers:
  stirling-pdf:
    url: http://localhost:8090/mcp
    headers:
      X-API-KEY: <key>
    skip_preflight: true
```

Related: #52460 (OAuth redirect preflight), #51600 (skip probe on mcp add),
#40366 (skip probe on reconnect — already merged).
2026-07-05 21:36:19 -07:00
Guillaume Nodet
32c1c47eef fix(mcp): add POST probe fallback in preflight content-type check
Some MCP servers (e.g. DocuSeal) serve their web UI on HEAD/GET but
speak Streamable HTTP only via POST.  The preflight probe now tries a
lightweight JSON-RPC `initialize` POST before rejecting endpoints
whose HEAD/GET returns a non-MCP content type (e.g. `text/html`).

If the POST returns `application/json` or `text/event-stream` with a
2xx status, the endpoint is accepted.  Otherwise the original rejection
behaviour is preserved.

Adds 5 new test cases covering the POST probe path:
- POST rescues HTML HEAD with JSON response
- POST rescues HTML HEAD with event-stream response
- POST still rejects when it also returns HTML
- POST still rejects on non-2xx status
- POST not attempted when HEAD already returns valid MCP content type
2026-07-05 21:36:19 -07:00
yoma
2ea03d8c6b fix(mcp): park after initial connect failures 2026-07-05 19:10:31 -07:00
teknium1
e412316b81 fix(mcp): self-probe parked servers so they can actually revive (#57129)
Parking deregisters the server's tools, which removes the only paths
that could ever set _reconnect_event (circuit-breaker half-open probe
and _signal_reconnect both live inside registered tool handlers). A
parked server was therefore unrevivable short of a manual /mcp reload —
the park comment's promised breaker wake could never fire.

Make the parked wait a timed wait: every _PARKED_RETRY_INTERVAL (300s)
the run task wakes and attempts one revival probe, re-parking on
failure instead of burning the full 5-retry budget each cycle. Explicit
reconnect requests still wake it immediately. Idea credit: @Hellbayne
(PR #38881, earliest never-abandon proposal), reconciled with the
park design from #53599.
2026-07-05 19:10:31 -07:00
nicha16
cdbdcd6432 fix(mcp): re-register tools after a parked server is revived
_discover_tools only filled self._tools; registry registration happened
only in _discover_and_register_server (initial start) and _refresh_tools.
After parking deregistered a server's tools, a revival rebuilt the
transport but published zero tools — a phantom recovery.

Register freshly discovered tools whenever _ready is set and the
registry entry list is empty. Extracted from PR #54139 by @nicha16
(the remainder of that PR reverses the park design and is not taken).
2026-07-05 19:10:31 -07:00
liuhao1024
e334700809 fix(mcp): reset reconnect retry counter after successful session establishment
The local retries variable in MCPServerTask.run() accumulated across
transient disconnections — each transport exception incremented it, but
only clean transport returns (auth recovery / manual refresh) or
park-wake reset it. Five transient blips over a long-uptime gateway
would permanently park the MCP server.

Promote retries to instance attribute _reconnect_retries and reset it
at all 4 session-establishment sites in _run_stdio / _run_http, so only
consecutive failures without successful reconnection count toward the
parking budget.

Fixes #57604
2026-07-05 19:10:31 -07:00
Teknium
e2fe529efb
feat(approvals): user-defined deny rules that block commands even under yolo (#59164)
Adds approvals.deny to config.yaml — a list of fnmatch globs matched
against terminal commands. A match blocks unconditionally, BEFORE the
--yolo / /yolo / approvals.mode=off bypass, making it the user-editable
counterpart to the code-shipped hardline blocklist.

- Checked in both command gates (check_dangerous_command and
  check_all_command_guards), after the hardline floor and sudo-stdin
  guard, before the yolo bypass and permanent allowlist.
- Matching runs over the same normalized/deobfuscated command variants
  as the dangerous-pattern detector, case-insensitive.
- Opt-in: empty/absent list is a no-op; behavior unchanged.

Supersedes the trust-engine approach from #21500 with a minimal
config-native design: the only capability the existing stack lacked
was deny-that-beats-yolo. Allow already exists (command_allowlist),
ask already exists (session approvals).
2026-07-05 14:48:40 -07:00
Teknium
0823230545
fix(computer-use): sanitize env on the 4 remaining cua-driver spawn sites (#59165)
PR #58889 fixed the CLI-fallback transport; review of that fix found the
same leak class at four sibling spawn sites of the third-party cua-driver
binary:

- _resolve_mcp_invocation (cua-driver manifest): no env= at all — full
  parent environment inherited
- cua_driver_update_check (check-update --json): telemetry env but no
  secret sanitization
- doctor._drive_health_report (<binary> mcp Popen): telemetry env only
- permissions._run (every macOS/Linux permission probe): telemetry env only

All now route through _sanitize_subprocess_env(cua_driver_child_env()),
matching the sanctioned MCP spawn and the #53503/#55709/#58889 strip-by-
default policy for non-terminal spawns. Sanitization degrades gracefully
(falls back to the telemetry env) so doctor/permission probes never break
on an import error.

4 regression tests covering each site.
2026-07-05 14:48:20 -07:00
teknium1
f514132ff3 fix: disclose mid-line clamp in truncation hint
When a single line exceeds the entire char budget, its tail is
unreachable via offset pagination (offsets are line-granular). Tell
the model so it doesn't assume it saw the full line.
2026-07-05 14:42:05 -07:00
teknium1
25f0cecf5e Port from nearai/ironclaw#5029: graceful char-budget truncation for read_file
read_file previously hard-rejected any read whose formatted output exceeded
the ~100K char safety limit, returning an error with zero content. A file
with few but very long lines (logs, wide CSV rows, minified data) sails past
the line-count limit and then trips the char guard, so the model gets nothing
and must guess a smaller limit — wasting a full round-trip.

Now the read is trimmed to the last complete line that fits the budget and
returns the partial content plus truncated_by="bytes" and a next_offset, so
the model paginates forward instead of starting over. A single line larger
than the whole budget is clamped on a code-point boundary (never empty) and
the cursor still advances. Applies at both read paths (normal + extracted
documents).

Adapted from IronClaw's Rust dual line/byte cap to hermes's Python tool-layer
char guard, which is the single uniform chokepoint over the gutter-rendered
content for every backend.
2026-07-05 14:42:05 -07:00
teknium1
3167dbaee2 fix(docker): widen docker_network to file/code-exec paths + guard container reuse
Follow-up to the salvaged toggle commit:

- file_tools.py / code_execution_tool.py: carry docker_network in their
  container_config dicts so those environment-creation paths honor the
  lockdown instead of silently defaulting back to bridge (the probe/exec
  asymmetry class reported on #46358).
- docker.py: cross-process reuse now inspects HostConfig.NetworkMode when
  docker_network=false and removes a mismatched (networked) container
  before starting a fresh air-gapped one. Fails closed when inspect fails.
  Default-network config never churns containers, so operators using
  docker_extra_args --network=none are unaffected.
- tests: AST invariant that every container_config site carrying
  docker_run_as_host_user also carries docker_network, plus three reuse
  guard tests (reject bridge under lockdown / keep matching none /
  no inspect when network enabled).
- docs: configuration.md gains terminal.docker_network + env var row.
2026-07-05 14:41:51 -07:00
Teknium
cd2b360d64 feat: add Docker terminal network toggle
Port from qwibitai/nanoclaw#2713: expose Hermes' existing Docker network isolation primitive through terminal config so operators can opt out of container egress.
2026-07-05 14:41:51 -07:00
kshitijk4poor
74cc9ee3f0 Revert "Merge pull request #58698 from kshitijk4poor/feat/pre-tool-call-approve-escalation"
This reverts commit 368e5f197e, reversing
changes made to abf9638f4e.
2026-07-06 02:36:52 +05:30
srojk34
f10851e3fd fix(computer-use): sanitize subprocess env in cua-driver CLI fallback transport
_CuaDriverSession._call_tool_via_cli() (the EAGAIN/silent-empty MCP
fallback transport) invokes `cua-driver call <tool> <json>` via
subprocess.run() with no env= argument, so the third-party cua-driver
binary inherits the full, unsanitized parent environment. The primary
MCP spawn site (_lifecycle_coro) already applies
_sanitize_subprocess_env(cua_driver_child_env()) before opening the
stdio client, per the same policy #53503/#55709 established for other
subprocess spawn points — this fallback path, added alongside the
EAGAIN/silent-empty-capture hardening, missed it.

Fix: apply the same env=_sanitize_subprocess_env(cua_driver_child_env())
to the subprocess.run() call in _call_tool_via_cli(), mirroring the
sanctioned spawn site exactly (telemetry policy applied first, then
Hermes-managed secrets filtered).
2026-07-05 13:57:20 -07:00
Teknium
eab208db70
feat(hooks): spill oversized hook-injected context to disk (#20468)
Port from openai/codex#21069 ("Spill large hook outputs from context").

Both shell hooks and Python plugins can return {"context": "..."} from
pre_llm_call, which gets appended to the current turn's user message on
every subsequent API call. A plugin that emits a large blob inflates
every turn and blows out the prompt cache prefix.

- tools/hook_output_spill.py: shared helper that writes oversized
  context to $HERMES_HOME/hook_outputs/<session_id>/<uuid>.txt and
  returns a head/tail preview plus the saved path. Never raises.
- agent/turn_context.py: apply the cap at the pre_llm_call aggregation
  site (moved here from run_agent.py since the original PR), covering
  both Python plugins and shell hooks.
- agent/shell_hooks.py: reserve output_spill as a sub-key under hooks:
  so the config block doesn't emit unknown-hook-event warnings.
- Docs: document the cap + config in build-a-hermes-plugin.md.

Config (behaviour-preserving when absent):
  hooks.output_spill: enabled/max_chars/preview_head/preview_tail/directory

Tests: 14 unit tests; shell_hooks (56) and plugins (100) suites green.
E2E validated with isolated HERMES_HOME (spill, passthrough, traversal
sanitisation, reserved-key skip).
2026-07-05 13:51:26 -07:00
Teknium
55e3ee1ab8
fix: remove dead f-string prefixes via ruff F541 (216 sites) (#52336)
ruff check --fix --select F541 . on current main. Pure prefix removals;
adjacent-string concatenations keep the f only on interpolating fragments.
No string content or live placeholder altered.
2026-07-05 13:42:46 -07:00
teknium1
e01f58ff1f feat(mcp): adopt mcp__server__tool naming convention
Port from anomalyco/opencode#33533. Native MCP tools now register as
mcp__<server>__<tool> (double-underscore delimiter) instead of
mcp_<server>_<tool>, aligning with the convention used by Claude Code,
Codex, and OpenCode.

The double-underscore delimiter disambiguates the server/tool boundary
even when either component contains underscores (the single-underscore
form was ambiguous, which is why is_mcp_tool_parallel_safe already had to
track provenance in a side-map). It also unifies native registration with
the Anthropic-OAuth wire form (_MCP_TOOL_PREFIX = 'mcp__'), so the
single->double promotion that path performed is now a no-op for native
tools while still handling legacy replayed names.

- tools/mcp_tool.py: add MCP_TOOL_NAME_PREFIX + mcp_prefixed_tool_name()
  helper; route _convert_mcp_schema, utility schemas, refresh stale-set,
  and the parallel-safe prefix gate through it
- agent/transports/codex_event_projector.py: mirror convention in the
  deterministic call_id input for MCP server-executed tool calls
- tests: update produced-name assertions to the new convention
2026-07-05 13:40:21 -07:00