CVE-2026-49959
Description
Hermes WebUI versions prior to 0.51.311 allow authenticated users to execute arbitrary commands by manipulating Git configuration files.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Hermes WebUI versions prior to 0.51.311 allow authenticated users to execute arbitrary commands by manipulating Git configuration files.
Vulnerability
Hermes WebUI versions before 0.51.311 are vulnerable to remote code execution. This vulnerability allows authenticated attackers to execute arbitrary commands by placing malicious executable Git configuration within a workspace repository's .git/config file. The exploitation occurs through Git subprocess invocations in api/workspace_git.py, leveraging vectors such as core.fsmonitor during git status, protocol.ext.allow with ext:: remotes during git fetch, credential.helper, core.askPass, core.gitProxy, or inherited environment variables like GIT_SSH_COMMAND [4].
Exploitation
An attacker must first authenticate to the Hermes WebUI. Once authenticated, they can create or modify a workspace repository and inject malicious Git configuration into the .git/config file. This malicious configuration can then be triggered by common Git operations like git status or git fetch, leading to the execution of arbitrary commands on the host system [4].
Impact
Successful exploitation of this vulnerability allows an authenticated attacker to execute arbitrary commands on the host running the Hermes WebUI application. This can lead to a full compromise of the host system, depending on the privileges of the application's user account [4].
Mitigation
Hermes WebUI version 0.51.311 and later contain fixes for this vulnerability. The release date for this version is June 9, 2026 [2]. No workarounds are disclosed in the available references.
AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <0.51.311
Patches
1938ac9f55b53Release v0.51.311 — Release KA (brick-wave: workspace Git RCE hardening #3769 + stale-snapshot sidebar visibility #3770) (#3776)
9 files changed · +633 −31
api/models.py+86 −16 modified@@ -2550,13 +2550,19 @@ def _prefer_fuller_snapshots_for_sidebar(sessions: list[dict]) -> list[dict]: newest_visible_ts = max(_session_sort_timestamp(session) for session in visible) snapshot_ts = _session_sort_timestamp(best_snapshot) - # Keep the active continuation visible when it has newer activity than - # the archived snapshot. A fuller snapshot can still be older than a - # continuation that contains the latest turns after compression. + snapshot_id = str(best_snapshot.get('session_id') or '') + if not snapshot_id: + continue + + snapshot_ids_to_show.add(snapshot_id) + # If the continuation is newer, keep it visible too. That means the + # lineage is split-brain-ish: the snapshot has more transcript rows, but + # the continuation may still contain the newest post-compression turn. + # Showing both is less tidy than hiding one, but it preserves every + # reachable message. Tidy and wrong is how users start doubting reality. if newest_visible_ts > snapshot_ts: continue - snapshot_ids_to_show.add(str(best_snapshot.get('session_id'))) continuation_ids_to_hide.update( str(session.get('session_id')) for session in visible @@ -2583,28 +2589,88 @@ def _strip_sidebar_internal_flags(sessions: list[dict]) -> None: session.pop('_show_pre_compression_snapshot', None) -def _row_may_need_sidecar_metadata_refresh(session: dict) -> bool: +def _row_may_need_sidecar_metadata_refresh( + session: dict, + *, + stale_snapshot_ids: set[str] | None = None, +) -> bool: """Return True when a row needs canonical sidecar runtime/snapshot metadata. Compression lineage fields are enriched from state.db in one batched query later in all_sessions(). Loading hundreds of lineage sidecars on every /api/sessions poll turns the sidebar into molasses, so keep this refresh - limited to the few rows whose transient runtime or snapshot state is not - cheaply available from state.db. + limited to rows with transient runtime state, missing snapshot sidebar + metadata, or a stale snapshot candidate that can affect the visibility + decision for its lineage. """ is_runtime_row = bool( session.get('active_stream_id') or session.get('has_pending_user_message') or session.get('pending_user_message') ) - snapshot_missing_sidebar_metadata = bool( - session.get('pre_compression_snapshot') - and ( - session.get('message_count') is None - or session.get('last_message_at') is None - ) - ) - return is_runtime_row or snapshot_missing_sidebar_metadata + if is_runtime_row: + return True + if not session.get('pre_compression_snapshot'): + return False + if session.get('message_count') is None or session.get('last_message_at') is None: + return True + sid = str(session.get('session_id') or '') + return bool(sid and stale_snapshot_ids and sid in stale_snapshot_ids) + + +def _sidecar_mtime_after_index_timestamp(session: dict) -> bool: + sid = str(session.get('session_id') or '') + if not sid or not is_safe_session_id(sid): + return False + try: + sidecar_mtime = (SESSION_DIR / f'{sid}.json').stat().st_mtime + except OSError: + return False + indexed_ts = _session_sort_timestamp(session) + return sidecar_mtime > indexed_ts + 0.001 + + +def _stale_snapshot_metadata_refresh_ids(sessions: list[dict]) -> set[str]: + """Return pre-compression snapshots worth a sidecar metadata refresh. + + Most snapshot rows can be decided from the index: either their indexed count + already beats the visible continuation, or they are normal older snapshots + that should remain hidden. Only stat candidate sidecars when a hidden + snapshot has a visible continuation in the same lineage and its indexed + metadata would otherwise fail to expose it. + """ + sessions_by_id = { + str(session.get('session_id')): session + for session in sessions + if session.get('session_id') + } + groups: dict[str, list[dict]] = {} + for session in sessions: + sid = str(session.get('session_id') or '') + source = session.get('source_tag') or session.get('source') + if source == 'cron' or sid.startswith('cron_'): + continue + root = _sidebar_lineage_root_id(session, sessions_by_id) + groups.setdefault(root, []).append(session) + + refresh_ids: set[str] = set() + for group in groups.values(): + visible = [session for session in group if not session.get('pre_compression_snapshot')] + snapshots = [session for session in group if session.get('pre_compression_snapshot')] + if not visible or not snapshots: + continue + if any(_has_live_sidebar_state(session) for session in visible): + continue + best_visible_count = max(_sidebar_message_count(session) for session in visible) + for snapshot in snapshots: + sid = str(snapshot.get('session_id') or '') + if not sid: + continue + if _sidebar_message_count(snapshot) > best_visible_count: + continue + if _sidecar_mtime_after_index_timestamp(snapshot): + refresh_ids.add(sid) + return refresh_ids def _refresh_index_rows_from_sidecar_metadata(sessions: list[dict]) -> list[dict]: @@ -2616,8 +2682,12 @@ def _refresh_index_rows_from_sidecar_metadata(sessions: list[dict]) -> list[dict historical transcript. """ out: list[dict] = [] + stale_snapshot_ids = _stale_snapshot_metadata_refresh_ids(sessions) for session in sessions: - if not _row_may_need_sidecar_metadata_refresh(session): + if not _row_may_need_sidecar_metadata_refresh( + session, + stale_snapshot_ids=stale_snapshot_ids, + ): out.append(session) continue sid = session.get('session_id')
api/routes.py+46 −5 modified@@ -2866,16 +2866,57 @@ def _message_window_for_display(messages, msg_limit=None, msg_before=None) -> tu return window, start_idx +def _webui_sidecar_lineage_messages_for_display(session, *, max_hops: int = 20) -> list: + """Return WebUI sidecar messages stitched across compression snapshots. + + WebUI compression continuations persist the archived transcript in a parent + sidecar marked ``pre_compression_snapshot`` and keep subsequent turns in the + child sidecar. Opening the child alone makes older turns look lost. Stitch + only those snapshot parents for display; ordinary forks also carry + ``parent_session_id`` but must remain independent conversations. + """ + segments = [] + current = session + seen = {str(getattr(session, "session_id", "") or "")} + for _ in range(max(0, int(max_hops))): + parent_id = str(getattr(current, "parent_session_id", "") or "").strip() + if not parent_id or parent_id in seen or not is_safe_session_id(parent_id): + break + parent = Session.load(parent_id) + if not parent or not getattr(parent, "pre_compression_snapshot", False): + break + segments.append(parent) + seen.add(parent_id) + current = parent + + if not segments: + return list(getattr(session, "messages", []) or []) + + merged = [] + for segment in reversed(segments): + merged = merge_session_messages_append_only( + merged, + getattr(segment, "messages", []) or [], + truncation_watermark=getattr(segment, "truncation_watermark", None), + ) + return merge_session_messages_append_only( + merged, + getattr(session, "messages", []) or [], + truncation_watermark=getattr(session, "truncation_watermark", None), + ) + + def _merged_session_messages_for_display(session, cli_messages=None) -> list: """Return the message coordinate space exposed by ``GET /api/session``. Messaging sessions can have a WebUI sidecar transcript plus messages from - the Agent/CLI store. The frontend computes fork keep-counts against this - merged display list, so branch/fork must slice the same list rather than - the sidecar-only ``session.messages`` array. + the Agent/CLI store. WebUI compression continuations can have an archived + snapshot parent plus a child continuation sidecar. The frontend computes + fork keep-counts against this merged display list, so branch/fork must slice + the same list rather than the sidecar-only ``session.messages`` array. """ cli_messages = list(cli_messages or []) - sidecar_messages = list(getattr(session, "messages", []) or []) + sidecar_messages = _webui_sidecar_lineage_messages_for_display(session) if cli_messages: if sidecar_messages and sidecar_messages != cli_messages: if len(sidecar_messages) >= len(cli_messages): @@ -5358,7 +5399,7 @@ def handle_get(handler, parsed) -> bool: _all_msgs = _merged_session_messages_for_display(s, cli_messages) else: _all_msgs = merge_session_messages_append_only( - s.messages, + _webui_sidecar_lineage_messages_for_display(s), state_db_messages, truncation_watermark=getattr(s, "truncation_watermark", None), )
api/workspace_git.py+32 −3 modified@@ -33,9 +33,37 @@ "GIT_CONFIG_SYSTEM", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GIT_SSH", + "GIT_SSH_COMMAND", ) _GIT_ENV_SCRUB_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_") _HERMES_BRANCH_SWITCH_STASH_PREFIX = "hermes-webui branch switch" +_GIT_HARDENED_CONFIG = ( + # Workspace Git operations can run against repositories provided by agents, + # restored sessions, or mounted workspaces. Keep repo-local configuration + # from turning read/status/fetch calls into host command execution. + ("core.fsmonitor", "false"), + # Force the unmodified system ssh binary rather than clearing it — an empty + # value would break legitimate ssh fetches, while "ssh" overrides any + # repo-local core.sshCommand that points at an attacker helper. + ("core.sshCommand", "ssh"), + ("core.askPass", ""), + ("credential.helper", ""), + ("protocol.ext.allow", "never"), + # Neutralize repo-local core.gitProxy, which specifies an external proxy + # command reachable on `git fetch` against a git:// remote. + ("core.gitProxy", ""), +) + + +def _hardened_git_argv(args: list[str]) -> list[str]: + argv = ["git"] + for key, value in _GIT_HARDENED_CONFIG: + argv.extend(["-c", f"{key}={value}"]) + argv.extend(args) + return argv def workspace_git_destructive_enabled() -> bool: @@ -56,6 +84,7 @@ def _clean_git_env(extra: dict[str, str] | None = None) -> dict[str, str]: for key in list(env): if key.startswith(_GIT_ENV_SCRUB_PREFIXES): env.pop(key, None) + env["GIT_TERMINAL_PROMPT"] = "0" return env @@ -140,7 +169,7 @@ def _run_git( run_env = _clean_git_env(env) try: result = subprocess.run( - ["git", *args], + _hardened_git_argv(args), cwd=str(cwd), shell=False, capture_output=True, @@ -1047,12 +1076,12 @@ def _selected_temp_index_env(ctx: GitContext, specs: list[str]) -> tuple[dict[st def _selected_files(ctx: GitContext, paths: Iterable[str]) -> tuple[list[str], list[str], list[dict]]: requested = _clean_paths(paths) requested_specs = [_repo_rel(ctx, path) for path in requested] - workspace_paths = [_workspace_rel(ctx, spec) or path for spec, path in zip(requested_specs, requested)] + workspace_paths = [_workspace_rel(ctx, spec) or path for spec, path in zip(requested_specs, requested, strict=True)] status = git_status(ctx.workspace) by_path = {f["path"]: f for f in status.get("files", [])} specs: list[str] = [] selected = [] - for path, repo_rel in zip(workspace_paths, requested_specs): + for path, repo_rel in zip(workspace_paths, requested_specs, strict=True): state = by_path.get(path) if not state: continue
CHANGELOG.md+8 −0 modified@@ -3,6 +3,14 @@ ## [Unreleased] +## [v0.51.311] — 2026-06-07 — Release KA (brick-wave — workspace Git RCE hardening + stale-snapshot sidebar visibility) + +### Security +- **Workspace Git operations now run with hardened Git config and non-interactive authentication defaults.** A malicious or restored workspace repository could turn read-only `git status` / default-enabled `git fetch --prune` into host command execution via repository-local executable config (`core.fsmonitor`, `core.askPass`, `credential.helper`, `protocol.ext.allow=always`). WebUI now passes hardened `-c` overrides on every workspace Git subprocess to ignore that repo-local executable config, removes inherited `GIT_ASKPASS` / `SSH_ASKPASS` / `GIT_SSH` / `GIT_SSH_COMMAND` env overrides, and sets `GIT_TERMINAL_PROMPT=0` so private HTTPS remotes fail fast instead of blocking on an interactive prompt. Private HTTPS remotes that rely on a stored credential helper should use an SSH remote or another externally authenticated transport from WebUI. (#3769, @Hinotoi-agent) + +### Fixed +- **The sidebar no longer hides fuller pre-compression snapshots when `_index.json` is stale.** Snapshot rows now refresh their sidecar metadata before lineage visibility chooses between an archived parent and its continuation, and `GET /api/session` stitches the archived snapshot parent into the merged display transcript, so conversations whose parent sidecar received later transcript rows do not appear to lose messages after compaction. Adds regression coverage for a stale `_index.json` hiding the fuller pre-compression sidecar. (#3770, @ai-ag2026) + ## [v0.51.310] — 2026-06-07 — Release JZ (stage-3760 — long-press project chips to delete on touch) ### Fixed
docs/workspace-git.md+9 −2 modified@@ -61,8 +61,11 @@ second timeout. Remote operations such as fetch, pull, and push use a 60 second Before any Git subprocess starts, WebUI removes inherited `GIT_DIR`, `GIT_WORK_TREE`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, and injected -`GIT_CONFIG_KEY_*` / `GIT_CONFIG_VALUE_*` values from the environment. Those variables can redirect -Git to a different repository or inject config, so WebUI does not trust them from the parent process. +`GIT_CONFIG_KEY_*` / `GIT_CONFIG_VALUE_*` values from the environment. It also removes inherited +`GIT_ASKPASS`, `SSH_ASKPASS`, `GIT_SSH`, and `GIT_SSH_COMMAND` values, then sets +`GIT_TERMINAL_PROMPT=0` so remote authentication failures fail fast instead of blocking on an +interactive prompt. Those variables can redirect Git to a different repository, inject config, or run +helper commands, so WebUI does not trust them from the parent process. `GIT_INDEX_FILE` is the intentional exception. Selected-file commits use a temporary index so WebUI can commit only the requested files, then remove the temporary index afterward. @@ -90,3 +93,7 @@ from general Git failures. If a hook fails, the API returns a structured Git error instead of hiding the failure. Other classified failures include authentication errors, missing upstream branches, conflicts, dirty worktrees, invalid refs, missing Git binaries, and timeouts. + +Repository-local credential helpers and askpass commands are disabled for workspace Git operations. +Private HTTPS remotes that depend on a stored credential helper may fail to fetch, pull, or push from +WebUI; use an SSH remote or another externally authenticated transport for those workflows.
tests/test_session_import_cli_fallback_model.py+1 −1 modified@@ -396,5 +396,5 @@ def test_messaging_session_loader_prefers_longer_sidecar_transcript(): assert old not in handler assert "_all_msgs = _merged_session_messages_for_display(s, cli_messages)" in handler src = (REPO / "api" / "routes.py").read_text(encoding="utf-8") - assert "sidecar_messages = list(getattr(session, \"messages\", []) or [])" in src + assert "sidecar_messages = _webui_sidecar_lineage_messages_for_display(session)" in src assert "len(sidecar_messages) > len(cli_messages)" in src
tests/test_session_index.py+186 −4 modified@@ -477,12 +477,189 @@ def test_fuller_pre_compression_snapshot_replaces_shorter_visible_segment(monkey assert rows[0]["pre_compression_snapshot"] is True -def test_newer_continuation_beats_older_fuller_snapshot(monkeypatch): - """Do not hide a newer continuation behind an older fuller snapshot. +def test_stale_index_fuller_pre_compression_snapshot_uses_sidecar_metadata(monkeypatch): + """A stale index must not hide the fuller pre-compression sidecar. + + Compression can leave _index.json with the snapshot's old count/timestamp + while the sidecar later contains more transcript rows than the visible + continuation. all_sessions() must refresh snapshot metadata before deciding + whether to hide it, or the sidebar makes messages look lost. + """ + snapshot = Session( + session_id="stale_full_parent", + title="Long Conversation", + messages=[ + {"role": "user", "content": "first", "timestamp": 100.0}, + {"role": "assistant", "content": "second", "timestamp": 101.0}, + {"role": "user", "content": "latest user", "timestamp": 300.0}, + {"role": "assistant", "content": "latest answer", "timestamp": 301.0}, + ], + pre_compression_snapshot=True, + parent_session_id="root_sid", + updated_at=301.0, + ) + continuation = Session( + session_id="stale_short_child", + title="Long Conversation", + messages=[ + {"role": "user", "content": "first", "timestamp": 100.0}, + {"role": "assistant", "content": "second", "timestamp": 101.0}, + ], + parent_session_id="stale_full_parent", + updated_at=250.0, + ) + snapshot.save(touch_updated_at=False) + continuation.save(touch_updated_at=False) + _write_index_file( + models.SESSION_INDEX_FILE, + [ + { + "session_id": "stale_full_parent", + "title": "Long Conversation", + "message_count": 2, + "created_at": 100.0, + "updated_at": 200.0, + "last_message_at": 200.0, + "pinned": False, + "archived": False, + "pre_compression_snapshot": True, + "parent_session_id": "root_sid", + }, + { + "session_id": "stale_short_child", + "title": "Long Conversation", + "message_count": 2, + "created_at": 250.0, + "updated_at": 250.0, + "last_message_at": 250.0, + "pinned": False, + "archived": False, + "parent_session_id": "stale_full_parent", + }, + ], + ) + monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None) + + rows = models.all_sessions() + + assert [row["session_id"] for row in rows] == ["stale_full_parent"] + assert rows[0]["message_count"] == 4 + assert rows[0]["last_message_at"] == 301.0 + assert rows[0]["pre_compression_snapshot"] is True + + +def test_indexed_fuller_pre_compression_snapshot_does_not_refresh_sidecar(monkeypatch): + """A truthful index row must not read the snapshot sidecar on every poll.""" + snapshot = Session( + session_id="indexed_full_parent", + title="Long Conversation", + messages=[ + {"role": "user", "content": "first", "timestamp": 100.0}, + {"role": "assistant", "content": "second", "timestamp": 101.0}, + {"role": "user", "content": "latest user", "timestamp": 300.0}, + {"role": "assistant", "content": "latest answer", "timestamp": 301.0}, + ], + pre_compression_snapshot=True, + parent_session_id="root_sid", + updated_at=301.0, + ) + continuation = Session( + session_id="indexed_short_child", + title="Long Conversation", + messages=[ + {"role": "user", "content": "first", "timestamp": 100.0}, + {"role": "assistant", "content": "second", "timestamp": 101.0}, + ], + parent_session_id="indexed_full_parent", + updated_at=250.0, + ) + snapshot.save(touch_updated_at=False) + continuation.save(touch_updated_at=False) + _write_index_file( + models.SESSION_INDEX_FILE, + [ + { + "session_id": "indexed_full_parent", + "title": "Long Conversation", + "message_count": 4, + "created_at": 100.0, + "updated_at": 301.0, + "last_message_at": 301.0, + "pinned": False, + "archived": False, + "pre_compression_snapshot": True, + "parent_session_id": "root_sid", + }, + { + "session_id": "indexed_short_child", + "title": "Long Conversation", + "message_count": 2, + "created_at": 250.0, + "updated_at": 250.0, + "last_message_at": 250.0, + "pinned": False, + "archived": False, + "parent_session_id": "indexed_full_parent", + }, + ], + ) + monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None) + + with patch.object(Session, "load_metadata_only", side_effect=AssertionError("truthful snapshot index should not refresh sidecar")): + rows = models.all_sessions() + + assert [row["session_id"] for row in rows] == ["indexed_full_parent"] + assert rows[0]["message_count"] == 4 + + +def test_orphan_pre_compression_snapshot_does_not_refresh_sidecar(monkeypatch): + """Snapshot refresh stays scoped to lineages with a visible continuation.""" + _write_index_file( + models.SESSION_INDEX_FILE, + [ + { + "session_id": "orphan_snapshot", + "title": "Archived Segment", + "message_count": 3, + "created_at": 100.0, + "updated_at": 100.0, + "last_message_at": 100.0, + "pinned": False, + "archived": False, + "pre_compression_snapshot": True, + "parent_session_id": "root_sid", + }, + ], + ) + (models.SESSION_DIR / "orphan_snapshot.json").write_text( + json.dumps( + { + "session_id": "orphan_snapshot", + "title": "Archived Segment", + "messages": [{"role": "user", "content": "sidecar"}], + "message_count": 99, + "updated_at": 999.0, + "pre_compression_snapshot": True, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None) + + with patch.object(Session, "load_metadata_only", side_effect=AssertionError("orphan snapshots must not refresh sidecars")): + rows = models.all_sessions() + + assert [row["session_id"] for row in rows] == ["orphan_snapshot"] + assert rows[0]["message_count"] == 3 + + +def test_newer_continuation_stays_visible_alongside_older_fuller_snapshot(monkeypatch): + """Do not hide either side when recency and completeness disagree. Compression snapshots can have a higher message count while still being older than the continuation that contains the latest user-visible turns. - The sidebar should keep the newer continuation visible in that case. + The sidebar should keep the newer continuation visible and also expose the + fuller snapshot so neither side of a split lineage looks lost. """ snapshot = Session( session_id="older_full_parent", @@ -514,9 +691,14 @@ def test_newer_continuation_beats_older_fuller_snapshot(monkeypatch): rows = models.all_sessions() - assert [row["session_id"] for row in rows] == ["newer_short_child"] + assert [row["session_id"] for row in rows] == [ + "newer_short_child", + "older_full_parent", + ] assert rows[0]["pre_compression_snapshot"] is False assert rows[0]["message_count"] == 2 + assert rows[1]["pre_compression_snapshot"] is True + assert rows[1]["message_count"] == 4 def test_all_sessions_uses_sidecar_metadata_for_runtime_rows_when_index_message_count_is_stale(monkeypatch):
tests/test_session_lineage_full_transcript.py+90 −0 modified@@ -3,6 +3,7 @@ from __future__ import annotations import sqlite3 +from types import SimpleNamespace import api.models as models import api.routes as routes @@ -184,3 +185,92 @@ def test_cli_continuation_session_opens_nonempty(monkeypatch, tmp_path): messages = models.get_cli_session_messages('child-session') assert [message['content'] for message in messages] == ['parent turn', 'child reply'] + + +def test_webui_continuation_session_opens_with_snapshot_parent_messages(monkeypatch): + """Opening a WebUI compression child should expose the archived parent transcript.""" + parent = SimpleNamespace( + session_id="parent-webui", + parent_session_id=None, + pre_compression_snapshot=True, + truncation_watermark=None, + messages=[ + {"role": "user", "content": "make the LLM settings table", "timestamp": 1.0}, + {"role": "assistant", "content": "LLM Settings Table", "timestamp": 2.0}, + ], + ) + child = SimpleNamespace( + session_id="child-webui", + parent_session_id="parent-webui", + pre_compression_snapshot=False, + truncation_watermark=None, + messages=[ + {"role": "user", "content": "continue after compression", "timestamp": 3.0}, + {"role": "assistant", "content": "child reply", "timestamp": 4.0}, + ], + tool_calls=[], + active_stream_id=None, + pending_user_message=None, + pending_attachments=[], + pending_started_at=None, + context_length=0, + threshold_tokens=0, + last_prompt_tokens=0, + model="openai/gpt-5", + profile="default", + ) + child.compact = lambda: {"session_id": "child-webui", "title": "Child", "model": "openai/gpt-5"} + + captured = {} + monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: child) + monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda s: None) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda sid: {}) + monkeypatch.setattr(routes, "_is_messaging_session_record", lambda s: False) + monkeypatch.setattr(routes, "get_state_db_session_messages", lambda sid, profile=None: []) + monkeypatch.setattr(routes.Session, "load", lambda sid: parent if sid == "parent-webui" else None) + monkeypatch.setattr(routes, "_resolve_effective_session_model_for_display", lambda s: getattr(s, "model", None)) + monkeypatch.setattr(routes, "_resolve_effective_session_model_provider_for_display", lambda s: None) + monkeypatch.setattr(routes, "_merge_cli_sidebar_metadata", lambda raw, meta: raw) + monkeypatch.setattr(routes, "redact_session_data", lambda raw: raw) + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: captured.setdefault("payload", payload)) + + class Handler: + pass + + class Parsed: + path = "/api/session" + query = "session_id=child-webui" + + routes.handle_get(Handler(), Parsed()) + + contents = [m["content"] for m in captured["payload"]["session"]["messages"]] + assert contents == [ + "make the LLM settings table", + "LLM Settings Table", + "continue after compression", + "child reply", + ] + + +def test_webui_fork_session_does_not_stitch_non_snapshot_parent(monkeypatch): + """A normal fork's parent_session_id is provenance, not a transcript stitch request.""" + parent = SimpleNamespace( + session_id="parent-fork", + parent_session_id=None, + pre_compression_snapshot=False, + truncation_watermark=None, + messages=[{"role": "user", "content": "parent should stay separate", "timestamp": 1.0}], + ) + child = SimpleNamespace( + session_id="child-fork", + parent_session_id="parent-fork", + pre_compression_snapshot=False, + truncation_watermark=None, + messages=[{"role": "user", "content": "fork child only", "timestamp": 2.0}], + ) + + monkeypatch.setattr(routes.Session, "load", lambda sid: parent if sid == "parent-fork" else None) + + assert [m["content"] for m in routes._webui_sidecar_lineage_messages_for_display(child)] == [ + "fork child only", + ]
tests/test_workspace_git.py+175 −0 modified@@ -1,11 +1,13 @@ import json import pathlib import subprocess +import threading import types import uuid import urllib.error import urllib.parse import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from io import BytesIO import pytest @@ -898,6 +900,169 @@ def racing_safe_resolve(root, requested): assert victim.read_text(encoding="utf-8") == "outside victim\n" +def test_git_status_ignores_repo_local_fsmonitor_command(tmp_path): + import os + import sys + + if os.name == "nt": + pytest.skip("executable fsmonitor helper setup is POSIX-only") + + from api.workspace_git import git_status + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + marker = tmp_path / "fsmonitor-ran" + helper = tmp_path / "fsmonitor_helper.py" + helper.write_text( + "#!/usr/bin/env python3\n" + "import pathlib, sys\n" + "pathlib.Path(sys.argv[1]).write_text('fsmonitor executed', encoding='utf-8')\n" + "print('')\n", + encoding="utf-8", + ) + helper.chmod(0o755) + _git(repo, "config", "core.fsmonitor", f"{sys.executable} {helper} {marker}") + + status = git_status(repo) + + assert status["is_git"] is True + assert not marker.exists() + + +def test_git_fetch_blocks_repo_local_ext_transport_execution(tmp_path): + import os + import sys + + if os.name == "nt": + pytest.skip("ext transport helper setup is POSIX-only") + + from api.workspace_git import GitWorkspaceError, git_fetch + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + marker = tmp_path / "ext-transport-ran" + helper = tmp_path / "ext_helper.py" + helper.write_text( + "#!/usr/bin/env python3\n" + "import pathlib, sys\n" + "pathlib.Path(sys.argv[1]).write_text('ext executed: ' + ' '.join(sys.argv[2:]), encoding='utf-8')\n" + "sys.exit(1)\n", + encoding="utf-8", + ) + helper.chmod(0o755) + _git(repo, "config", "protocol.ext.allow", "always") + _git(repo, "remote", "add", "origin", f"ext::{sys.executable} {helper} {marker} %S foo") + + with pytest.raises(GitWorkspaceError) as exc: + git_fetch(repo) + + assert exc.value.code == "git_failed" + assert not marker.exists() + + +def test_git_fetch_blocks_repo_local_credential_helper_execution(tmp_path): + import os + import sys + + if os.name == "nt": + pytest.skip("executable credential helper setup is POSIX-only") + + from api.workspace_git import GitWorkspaceError, git_fetch + + class AuthRequiredHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="hermes-test"') + self.end_headers() + + def log_message(self, format, *args): + del format, args + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + marker = tmp_path / "credential-helper-ran" + helper = tmp_path / "credential_helper.py" + helper.write_text( + "#!/usr/bin/env python3\n" + "import pathlib, sys\n" + "pathlib.Path(sys.argv[1]).write_text('credential helper executed', encoding='utf-8')\n", + encoding="utf-8", + ) + helper.chmod(0o755) + + server = ThreadingHTTPServer(("127.0.0.1", 0), AuthRequiredHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/repo.git" + _git(repo, "remote", "add", "origin", url) + _git(repo, "config", "credential.helper", f"!{sys.executable} {helper} {marker}") + + with pytest.raises(GitWorkspaceError) as exc: + git_fetch(repo) + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + assert exc.value.code == "auth_failed" + assert not marker.exists() + + +def test_git_fetch_blocks_repo_local_askpass_execution(tmp_path): + import os + import sys + + if os.name == "nt": + pytest.skip("executable askpass helper setup is POSIX-only") + + from api.workspace_git import GitWorkspaceError, git_fetch + + class AuthRequiredHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="hermes-test"') + self.end_headers() + + def log_message(self, format, *args): + del format, args + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + marker = tmp_path / "askpass-ran" + helper = tmp_path / "askpass_helper.py" + helper.write_text( + "#!/usr/bin/env python3\n" + "import pathlib, sys\n" + "pathlib.Path(sys.argv[1]).write_text('askpass executed', encoding='utf-8')\n" + "print('pw')\n", + encoding="utf-8", + ) + helper.chmod(0o755) + + server = ThreadingHTTPServer(("127.0.0.1", 0), AuthRequiredHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/repo.git" + _git(repo, "remote", "add", "origin", url) + _git(repo, "config", "core.askPass", f"{sys.executable} {helper} {marker}") + + with pytest.raises(GitWorkspaceError) as exc: + git_fetch(repo) + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + assert exc.value.code == "auth_failed" + assert not marker.exists() + + def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch): from api.workspace_git import _clean_git_env @@ -909,6 +1074,11 @@ def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeyp monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.sshCommand") monkeypatch.setenv("GIT_CONFIG_VALUE_0", "ssh -i /tmp/evil-key") monkeypatch.setenv("GIT_CONFIG_PARAMETERS", "'core.sshCommand=ssh -i /tmp/evil-key'") + monkeypatch.setenv("GIT_ASKPASS", "/tmp/evil-askpass") + monkeypatch.setenv("SSH_ASKPASS", "/tmp/evil-ssh-askpass") + monkeypatch.setenv("GIT_SSH", "/tmp/evil-ssh") + monkeypatch.setenv("GIT_SSH_COMMAND", "ssh -i /tmp/evil-key") + monkeypatch.setenv("GIT_TERMINAL_PROMPT", "1") env = _clean_git_env({"GIT_INDEX_FILE": "/tmp/hermes-index"}) @@ -920,6 +1090,11 @@ def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeyp assert "GIT_CONFIG_KEY_0" not in env assert "GIT_CONFIG_VALUE_0" not in env assert "GIT_CONFIG_PARAMETERS" not in env + assert "GIT_ASKPASS" not in env + assert "SSH_ASKPASS" not in env + assert "GIT_SSH" not in env + assert "GIT_SSH_COMMAND" not in env + assert env["GIT_TERMINAL_PROMPT"] == "0" assert env["GIT_INDEX_FILE"] == "/tmp/hermes-index"
Vulnerability mechanics
Root cause
"The application improperly sanitizes Git configuration settings, allowing malicious executables to be invoked."
Attack vector
An authenticated attacker can create or modify a workspace repository's `.git/config` file to include malicious executable Git configurations. This can be achieved through various Git subprocess invocations, such as `core.fsmonitor`, `protocol.ext.allow`, `credential.helper`, `core.askPass`, `core.gitProxy`, or by leveraging inherited environment variables like `GIT_SSH_COMMAND`. When Git operations like `git status` or `git fetch` are performed on this repository, the malicious configurations are executed, leading to arbitrary command execution on the host system [ref_id=1].
Affected code
The vulnerability lies within the `api/workspace_git.py` file, which handles Git operations for workspaces. The tests in `tests/test_workspace_git.py` demonstrate how various Git configurations, such as `core.fsmonitor`, `protocol.ext.allow`, `credential.helper`, and `core.askPass`, were previously susceptible to executing arbitrary commands. The patch modifies the logic related to Git environment variable scrubbing and configuration handling to prevent these executions.
What the fix does
The patch hardens workspace Git configuration execution against repository-local RCE by ensuring that Git subprocess invocations do not execute arbitrary commands. Specifically, it prevents the execution of malicious `core.fsmonitor` helpers and blocks the use of `protocol.ext.allow` with `ext::` remotes, `credential.helper`, `core.askPass`, and `core.gitProxy` configurations that could lead to command execution [ref_id=1]. It also scrubs potentially dangerous environment variables like `GIT_SSH_COMMAND` before invoking Git subprocesses [ref_id=1].
Preconditions
- authThe attacker must be authenticated to the application.
- inputThe attacker must be able to place malicious executable Git configuration in a workspace repository's .git/config file.
Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.