When the configured cloud browser provider fails to create a session (seen live:
Browser Use gateway HTTP 429 RATE_LIMIT_EXCEEDED), the session layer already falls
back to local Chromium and marks the session fallback_from_cloud, which keeps the
built-in browser_* tools working. browser_exec read only cdp_url from that session,
found none (local sessions launch lazily), and failed the whole call with "returned
no CDP endpoint, so Browser Use mode cannot drive it".
On a fallback session, resolve the packaged Chromium's CDP endpoint the same way the
no-provider path does, and log the provider failure once.
The salvaged route declined the provider in browser_use_cli, but the
managed-Chromium resolver still asked _get_session_info for the plain
cache key, and with a cloud provider configured that key builds a CLOUD
session — the private-URL call still created a Browser Use cloud browser.
- the sidecar call uses the `::local` cache key, so _get_session_info
builds local Chromium (the same key browser_navigate's sidecar uses);
- it runs under its own harness daemon (BU_NAME `_local_<name>`): the
daemon reads BU_CDP_* once at spawn, so reusing the cloud route's
daemon would relay the private URL to the cloud browser;
- a follow-up call with no URL literal stays on the route the previous
call took, like browser_navigate's _last_session_key.
Review caught that `_hybrid_routes_locally` resolves DNS (`_url_is_private` ->
`socket.getaddrinfo`) and is not memoised, so evaluating it separately for the
pre-flight and again for the backend choice lets a host whose answer changes
between the two lookups split them: the first lookup looks private, which
relaxes the private-address floor, and the second looks public, which clears
`force_local_sidecar` — so the call executes against the CLOUD provider with the
private URL in it. That is the leak this change exists to prevent.
`_code_url_verdict(code)` now makes ONE pass over the URL literals and returns
`_CodeUrlVerdict(blocked, local_sidecar)`; `browser_exec` consumes the returned
verdict instead of re-deriving it, so both decisions rest on the same captured
evidence. This is the discipline `browser_navigate` already applies — it derives
its safety relaxation from the session key (`_is_local_sidecar_key`) rather than
re-running the predicate. `_blocked_url_in_code` is gone rather than kept as a
wrapper: a caller that recomputed the route after it would reintroduce exactly
this bug.
Tests: `test_rebinding_host_cannot_split_verdict_from_route` stubs
`socket.getaddrinfo` to answer private then public, counts predicate evaluations
and asserts both that they are one per URL and that the call never reaches the
cloud provider — RED against the previous commit ("evaluated 2 times"), green
after. `test_public_then_private_answer_is_refused_not_leaked` pins the other
direction, where the safety check sees the private answer and refuses (the
over-block is the safe side). No live DNS in either. 10 existing cases moved to
the renamed helper. Full browser suite: 68 files, 830 passed, 0 failed.
With a cloud browser provider configured (the picker's `nous`, or any
browserbase/browser-use key), `browser.auto_local_for_private_urls` is on by
default and `browser_navigate` routes private/loopback/LAN URLs to a LOCAL
Chromium sidecar so the cloud provider never sees them. `browser_exec`'s
pre-flight URL scan did not consult that decision, so *every* LAN URL was
refused with "Blocked: URL targets a private or internal address" — and in
Browser Use mode (`browser.backend` unset, the default) `browser_exec` is the
only browser tool, so the documented hybrid workflow was unreachable.
Relaxing the pre-flight alone would have been wrong: the exec call resolves ONE
CDP endpoint for the whole code blob (`_resolve_backend_cdp`), so an allowed
private URL would then have been executed against the configured cloud browser
— exactly the leak the guard exists to prevent. Verdict and route now move
together:
* Extract `_hybrid_routes_locally(url)` in browser_tool as the single source of
truth for the predicate; `_navigation_session_key` reads it too, so the
navigate and exec paths cannot drift apart.
* `evaluate_url_safety(url, *, auto_local=False)` forwards the relaxation to
`_url_policy_error` (the cloud-metadata floor stays unconditional).
* `_blocked_url_in_code` passes `auto_local=_hybrid_routes_locally(url)`, the
same verdict the navigation path reaches for itself.
* `browser_exec` computes `local_sidecar` from the URLs in the code and threads
it through `_route_backend` → `_resolve_backend_cdp(force_local_sidecar=...)`,
which declines the cloud provider so the local engine serves the call. One
endpoint per call means a mixed call runs wholly local — the safe direction.
Scope is deliberately narrow: no cloud provider, a CDP override, or Camofox all
leave `_hybrid_routes_locally` False, so those paths are byte-for-byte unchanged.
The two operator overrides (BU_CDP_*/`browser.cdp_url`) still outrank it.
Tests: 9 new cases in tests/tools/test_browser_use_cli.py covering the URL
verdict (LAN, loopback, public, IMDS still blocked, hybrid off, no provider) and
the endpoint the verdict depends on (LAN-only and mixed calls must never reach
the provider; public-only still must). Full browser suite: 663 passed.
The notice re-checked store/HEAD inline; tools/checkpoint_manager.py already
exposes _store_has_head() for exactly that. Also pin the notice test to
"across 1 project(s)" so a count of 11 cannot satisfy it.
#135595 made destructive_root_rm check every rm operand, not only the
first. That loop also accepted a bare `//` token later on the line, so a
JS/JSONC comment after the command matched as a delete rooted at `/`:
rm -rf node_modules dist // clean -> critical (was clean)
"rm -rf *": "deny", // Block recursive deletes -> critical (was clean)
The second line is the permission example in
igorwarzocha/opencode-workflows' opencode-config skill, which went from
caution to dangerous (no override) on main.
A bare `//` after the first operand is now skipped; the operands after
it are still checked (`rm -rf build // /etc` stays critical) and a
first-operand `//`, `//etc` or `/` stays the root.
#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.
- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
`Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
these quotes alone and only strips the two that are genuinely redundant, which this commit
strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
Review of this PR: the inline_shell_exec downgrade assumed the runtime never
expands !`cmd` in a community hub skill. The runtime gate
(agent.skill_preprocessing._is_community_hub_skill) matches only the hub lock
entry's own directory, so a nested SKILL.md inside a community bundle is listed
as its own skill and expands when skills.inline_shell is on; so does the same
skill seen through another profile's external_dirs, or any read with an
unreadable lock. With the downgrade, such a bundle installed without --force.
inline_shell_exec stays high for community installs. The sudo/bind-all doc
notes and every other rule change are unchanged. Corpus cost: 4 of the 33
skills this PR made installable stay blocked (index 83 -> 71 instead of 68).
The A/B over the index sample showed two shapes the earlier relaxations let through:
base64 -d into tar (an embedded payload the scanner never sees) and
curl ... | sudo -E bash (base never matched it; sudo_usage in Markdown was the only
finding). Both now fire.
A random sample of our own hermes-index blocked ~15% of third-party skills; most
blocks were pattern precision bugs, not threats:
- provider token shapes (AKIA, ghp_, github_pat_, sk-, sk-ant-, glpat-, BEGIN PRIVATE KEY)
are scoped case-sensitive: the table compiles with IGNORECASE, so `[0-9A-Z]{16}`
matched the placeholder `AKIAxxxxxxxxxxxxxxxx`
- destructive_root_rm exempts the Dockerfile package-cache cleanup
(/var/lib/apt/lists, /var/cache/{apt,apk,yum,dnf}); traversal out of them, a later
`/` or `/etc` operand, and `--no-preserve-root /` still fire
- base64_decode_pipe fires when the decoded bytes reach an interpreter at any later
stage (or inside eval/source), not on `| jq` / `| grep`; plugin_guard's
is_data_decode step-down is now redundant and is removed (it also stepped down
`base64 -d x | sh | grep`)
- html_comment_injection needs injection phrasing, not a bare ignore/system/hidden
- setuid_setgid is critical only in executing shapes (set*id( call, cap_set[ug]id,
chmod u+s / 4755); the bare word is a medium note
- deception_hide's guidance exemption covers diagnosis/diagnostic (diagnos\w*)
- community scans: inline_shell_exec is informational (preprocess_skill_content never
expands it for a community hub lock entry), and sudo_usage / bind_all_interfaces in
Markdown docs are notes; scripts keep full severity
SCANNER_VERSION bumps to skills-guard-v9 so cached verdicts rescan.
skills_guard's base64_decode_pipe required the pipe right after the
flag, so `base64 -d payload.b64 | sh`, `base64 --decode < p | bash`,
`base64 -di p | python3` and `openssl base64 -d | sh` produced no
finding. plugin_guard's is_data_decode stepped the finding down to
medium when the FIRST consumer was not an interpreter, so
`base64 -d | gunzip | sh` scored like a data decode into grep.
Widen the pattern to those decode forms (not `||`), and have
is_data_decode read the decode off that same pattern and require every
later pipe stage to be a non-interpreter.
AWS access key IDs are all-caps by spec, but THREAT_PATTERNS compiles
with re.IGNORECASE, so a case-folded AKIA+16 byte collision inside a
base64-encoded asset fired one unoverridable critical and hard-blocked
the whole plugin install (#132155). Scope the pattern case-sensitive
with an inline (?-i:...) group — same technique as the hardcoded_secret
carve-out (#116221).
The bare phrase `curl | sh` in documentation prose scored critical, and a
critical on a community source is a dangerous verdict that --force cannot
override, so a skill whose docs name the install method could not be installed
at all. curl with no URL fetches nothing and a real download-and-execute names
its source, so requiring one non-pipe character after the command costs no
coverage. `curl_pipe_python` carried the same optional-operand prefix.
Both scanner versions bump because cached verdicts are keyed on them: without
the bump an already-scanned skill or plugin keeps its dangerous verdict and
stays blocked.
- _fetch_file_bytes: a DNS-poisoned / NXDOMAIN raw.githubusercontent.com raises
SSRFConnectionBlocked (a ValueError), which escaped the httpx.HTTPError suppress and
failed the install before the Contents API fallback. Any transport failure on the raw
attempt now falls back, and latches raw unusable on the source so a dropping firewall
costs one 15 s timeout, not one per file.
- ensure_bundled_skill: a leftover dest is refused if any entry in it is a symlink or
junction (copytree with dirs_exist_ok followed them out of skills/), and only missing
files are copied (a user-edited file was overwritten). The canonical-path "present"
check also requires the SKILL.md frontmatter name to match; a dir holding another
skill's SKILL.md is refused, as on main.
- Docstring: a restore over leftover user files reads as user-modified, so update keeps it.
`hermes-index rate_limited` (26 events / 9 installs in 14 days): GitHubSource.fetch
paid one Contents API call per file in the skill folder on top of repo + tree,
so an anonymous user (60/h) could never install a skill with ~58+ files and
four average skills drained the hour. Live on origin/main, anonymous:
skills-sh/remotion-dev/skills/remotion-best-practices cost 143 API calls and
failed rate_limited.
A file fetch pinned to the tree's commit sha now reads
raw.githubusercontent.com/<repo>/<sha>/<path> first: the same immutable bytes,
outside the REST limit. A raw miss (private repo, raw outage) falls back to the
Contents API as before. The same install now costs 2 API calls and succeeds
anonymously; anthropics/skills/pdf drops from 16 to 2.
Post-fix telemetry still showed `hermes skills install llm-wiki` failing as
failure_class=filesystem_error, source=hub. Two restore paths failed for good:
- A skill dir left behind without its SKILL.md (a manual delete that kept user
files, or a copy that died partway) made copytree raise EEXIST on every retry.
The restore now copies around the leftovers (dirs_exist_ok); a dir with no
SKILL.md is no skill, and user files in it are kept.
- A skill present behind a symlinked category (which the loader follows, so it
works) was missed by the rglob scan, so the restore ran into the containment
guard and failed. The canonical SKILL.md now counts as present.
A restore that still fails (read-only dir, a symlinked category with the skill
missing) was recorded as source=hub, because the failed exit returned no bundle
and _record_skill_install fell back to "hub". The bundled branch now marks the
attempt origin=bundled, so every bundled-path outcome, raised or returned,
records source=bundled.
text_to_speech swept every path it synthesized into on failure, including
an output_path that already held a user's file. When the target already
exists, synthesize into a hidden sibling instead; the delivery step still
os.replace()s it over the target on success (overwrite contract kept),
and a failure sweeps only the sibling.
A stop racing CapturedCDP.call() can close the supervisor loop before the
queued _send runs; run_coroutine_threadsafe's future then never resolves and
fut.result(timeout=None) hangs the consumer thread forever. Poll the unbounded
wait and raise CapturedCDPInvalid once the loop is closed. Fold the salvaged
validation tests into the dispatch file (one fixture, no F811), and tighten
them: the deadline test stalls inside the result grace, socket close asserts
CapturedCDPInvalid, the async validator asserts the refusal message.
The connection-card / manage_connections install of an OAuth catalog entry runs the probe on a
worker thread; `mcp_oauth.start` then rebuilt the worker's error as a bare RuntimeError from its
text, dropping the `failure_class` `_probe_single_server` had tagged. Every pre-authorization
failure (empty or schemeless URL, unreachable host, 404 endpoint, refused client registration)
was recorded as `exception:RuntimeError` -> `exception`.
- run_worker classifies the original exception with probe_failure_class and stores the class on
the flow; start() re-tags the RuntimeError it raises with it.
- probe_failure_class reads InvalidMcpUrlError (not http(s)://host) as config_invalid.
- _require_rendered_remote tags an unset ${VAR} in url/headers as missing_credentials.
- the "server responded but no token" refusal is auth_required.
- a connect_failed message that does not name the server gets a one-line next step
("Could not connect to 'n8n-official' (Not Found). Check its URL ...").
n8n-official is the only catalog entry whose URL is a user-supplied `${VAR}`, so it is the only
one where these user-side errors happen before authorization.
The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (fb9fc8a3dd): a LEFT_CORE row installs the plugin for every home that was
using it (a Spotify login in auth.json, or the spotify toolset in platform_toolsets) on
`hermes update` / first start; the toolset name, tool names, schemas, env vars and stored login
(providers.spotify) are unchanged, so nobody re-authenticates.
The one user-visible change is the login command: plugins cannot extend `hermes auth`, so the
plugin registers `hermes spotify login|status|logout`. LeftCoreFeature gains a `cli` field and
moved_command_hint() turns `hermes auth [status|logout] spotify`, and `hermes spotify` while the
plugin is not loaded, into a line naming the new command and the install/enable step for this
home. The dashboard's "needs login" hint names the new command too.
Core drops the Spotify auth constants/dispatch, the CONFIGURABLE_TOOLSETS row and post-setup
step, the core `spotify` entries in the shared-metrics toolset enums, its tests, and the docs
(en + zh-Hans) now point at `hermes plugins install spotify` / `hermes spotify login`.
The TurnRunner lane registers a gateway notify callback for every inbound
turn with no platform branch (run_turn_runner._run_conversation_with_approval),
so on webhook/msgraph_webhook the 'no notifier' discriminator can never fire:
is_ask stays True, the adapter renders no send_exec_approval surface, the
inbound lane is POST -> 202 with no reader, and the turn blocks in
_await_gateway_decision for the full approval timeout before failing closed
— the residual stall of #100532 that the notifier derivation missed.
A registered notifier only means 'someone can answer' on api_server, whose
turn paths choose whether to register one. Add _NOTIFIER_BLIND_APPROVAL_PLATFORMS
({webhook, msgraph_webhook}) and drop is_ask there regardless of registration;
api_server keeps the notifier test. New tests drive the gate with a registered
notifier on both platforms (the suite previously had no
HERMES_SESSION_PLATFORM=webhook case at all).
start_gateway() sets HERMES_EXEC_ASK=1 process-wide, so an api_server/webhook
turn inside a gateway still reads as 'ask' in _presence() even though
_is_gateway_approval_context() already excluded the platform. The unattended
branch then never runs: a smart ESCALATE (or any manual-mode dangerous
command) falls through to the interactive transport, and on a session with no
gateway notify callback — non-streaming /v1/chat/completions, webhook,
msgraph_webhook — the card parks as pending_approval that no client can ever
answer, while approvals.unattended_mode is never consulted (#100532).
Drop is_ask for unattended-platform sessions without a registered notify
callback, so the unattended branch resolves instantly from
approvals.unattended_mode before the guardian call runs. /v1/runs and
streaming chat completions register their callback and keep the ask path.
The persist / restore / carry logic for session YOLO had grown three copies
(the CLI mixin, tui_gateway, the gateway runner), each with its own key choice,
ordering and error handling. tools/approval_yolo.py now owns it:
- toggle_session_yolo: flip or set, persist BEFORE the live flip (a racing
restore never revives an OFF), stored-copy-aware so a toggle after a restart
turns it OFF; a failed write logs a warning and never blocks the flip.
- restore_session_yolo: re-arm a persisted flag in a fresh process; skipped
under the frozen process --yolo.
- transfer_session_yolo: move the live flag to a continuation id.
- with_session_yolo: a row being created records a live flag (lazy first row,
compression child, /branch).
Each surface keeps only its storage: the session row for CLI/TUI/Desktop, the
routing entry for the gateway. Removed: HermesCLI._transfer_session_yolo /
_persist_session_yolo, GatewayRunner._restore_session_yolo, the TUI-only
restore helper, and the TUI's post-publish set_session_yolo carry (the
compression publish now writes the child row with the flag).
Closes a gap the unification exposed: a CLI manual /compress that rotated the
session dropped YOLO (the next dangerous command prompted, and a --resume of
the continuation came back with it off).