CVE-2026-49958
Description
Hermes WebUI before version 0.51.303 contains a time-of-check time-of-use (TOCTOU) race condition vulnerability in the git_discard function within api/workspace_git.py that allows attackers to delete files outside the configured workspace boundary by replacing a validated path component with a symlink after validation but before deletion. Attackers can substitute a workspace-controlled path component with a symlink pointing to an external directory between the safe_resolve_ws() validation step and the subsequent Path.unlink() or shutil.rmtree() deletion call, causing the delete operation to follow the symlink and remove arbitrary files outside the workspace.
Affected products
1- Range: <0.51.303
Patches
24580f584964dRelease v0.51.303 — Release JS (stage-p1a — cron toggle + config var expansion + git-discard hardening) (#3756)
5 files changed · +117 −8
api/config.py+14 −2 modified@@ -249,6 +249,18 @@ def _discover_python(agent_dir: Path) -> str: _HERMES_FOUND = False # ── Config file (reloadable -- supports profile switching) ────────────────── + +def _expand_env_vars(obj): + """Recursively expand ${VAR} references in config values using os.environ.""" + if isinstance(obj, str): + return re.sub(r"\${([^}]+)}", lambda m: os.environ.get(m.group(1), m.group(0)), obj) + if isinstance(obj, dict): + return {k: _expand_env_vars(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_expand_env_vars(item) for item in obj] + return obj + + _cfg_cache = {} _cfg_lock = threading.Lock() _cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded @@ -372,7 +384,7 @@ def reload_config() -> None: if config_path.exists(): loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) if isinstance(loaded, dict): - _cfg_cache.update(loaded) + _cfg_cache.update(_expand_env_vars(loaded)) try: _cfg_mtime = Path(config_path).stat().st_mtime except OSError: @@ -399,7 +411,7 @@ def _load_yaml_config_file(config_path: Path) -> dict: return {} try: loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) - return loaded if isinstance(loaded, dict) else {} + return _expand_env_vars(loaded) if isinstance(loaded, dict) else {} except Exception: logger.debug("Failed to parse yaml config from %s", config_path) return {}
api/workspace_git.py+10 −4 modified@@ -9,7 +9,6 @@ import difflib import os -import shutil import subprocess import tempfile import threading @@ -18,7 +17,7 @@ from pathlib import Path from typing import Iterable -from api.workspace import safe_resolve_ws +from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored GIT_TIMEOUT = 5 @@ -978,9 +977,16 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked raise GitWorkspaceError("Untracked files require delete_untracked=true") target = safe_resolve_ws(ctx.workspace, workspace_rel) if target.is_dir(): - shutil.rmtree(target) + rmtree_anchored(ctx.workspace, target) else: - target.unlink(missing_ok=True) + try: + unlink_anchored(ctx.workspace, target) + except FileNotFoundError: + # Preserve the previous Path.unlink(missing_ok=True) + # behavior for benign races where another process + # removes the untracked file after git_status() has + # reported it but before this discard reaches unlink. + pass continue _run_git(ctx, ["restore", "--worktree", "--", repo_rel], check=True) return git_status(workspace)
CHANGELOG.md+11 −0 modified@@ -3,6 +3,17 @@ ## [Unreleased] +## [v0.51.303] — 2026-06-06 — Release JS (stage-p1a — low-risk fixes: cron toggle, config var expansion, git-discard hardening) + +### Fixed +- **Clicking an already-open cron run row now collapses it instead of re-fetching.** `_loadRunContent()` only ever expanded a run's output, so tapping an open row issued a pointless re-fetch. It now toggles — an open row collapses (clearing its expansion state and resetting the toggle button) and returns early. (#3732, @mysoul12138) + +### Added +- **`config.yaml` now expands `${VAR}` references against the environment at load time in the WebUI.** hermes-agent already supported `${ENV_VAR}` substitution, but the WebUI's own config loader stored the raw dict, leaving literal `${...}` strings in values. Both WebUI config load paths now recursively expand `${VAR}` from `os.environ`; an unset variable is left untouched (`${VAR}` preserved). (#3736, @Carry00) + +### Security +- **`git_discard(delete_untracked=true)` now deletes untracked files through the anchored workspace helpers, closing a validation-to-use symlink-swap window.** The discard previously validated the path with `safe_resolve_ws` and then deleted with raw `shutil.rmtree` / `Path.unlink`, so a workspace-controlled path component swapped to a symlink between validation and deletion could escape the workspace. Untracked deletes now go through `rmtree_anchored` / `unlink_anchored` (rejecting a swapped component at delete time) while preserving the prior tolerance for a benign concurrent-removal race. (#3702, @Hinotoi-agent) + ## [v0.51.302] — 2026-06-06 — Release JR (stage-brick — mobile/iOS breakage + large-session perf hotfixes) ### Fixed
static/panels.js+13 −2 modified@@ -725,9 +725,20 @@ async function _loadRunContent(jobId, filename, runId){ const body = document.querySelector(`#${runId} .detail-run-body`); if (!body) return; const item = document.getElementById(runId); - if (!item.classList.contains('open')) { - item.classList.add('open'); + if (item.classList.contains('open')) { + // Already open → collapse and return (toggle behaviour) + item.classList.remove('open'); + body.classList.remove('expanded'); + _cronExpansionSet(_cronRunExpandKey(jobId, filename), false); + const btn = item ? item.querySelector('.detail-expand-toggle') : null; + if (btn) { + btn.textContent = '▾'; + btn.title = (t('cron_expand_output') || 'Expand output'); + btn.setAttribute('aria-label', btn.title); + } + return; } + item.classList.add('open'); body.classList.toggle('expanded', _cronExpansionGet(_cronRunExpandKey(jobId, filename))); body.innerHTML = `<span style="opacity:.5">${esc(t('loading'))}</span>`; try {
tests/test_workspace_git.py+69 −0 modified@@ -292,6 +292,33 @@ def test_git_status_reports_untracked_files_inside_directories(tmp_path): assert not (nested / "a.txt").exists() +def test_git_discard_untracked_file_tolerates_concurrent_missing_file(tmp_path, monkeypatch): + import api.workspace_git as workspace_git + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + transient = repo / "transient.txt" + transient.write_text("gone soon\n", encoding="utf-8") + + original_unlink_anchored = workspace_git.unlink_anchored + raced = {"seen": False} + + def remove_before_unlink(root, target): + if target == transient: + raced["seen"] = True + transient.unlink() + return original_unlink_anchored(root, target) + + monkeypatch.setattr(workspace_git, "unlink_anchored", remove_before_unlink) + + status = workspace_git.git_discard(repo, ["transient.txt"], delete_untracked=True) + + assert raced["seen"] is True + assert not transient.exists() + assert status["totals"]["changed"] == 0 + + def test_git_status_reports_ignored_files_without_counting_them_as_changed(tmp_path): from api.workspace_git import git_status @@ -829,6 +856,48 @@ def test_git_routes_selected_commit_and_structured_error(cleanup_test_sessions): assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"] +def test_git_discard_untracked_delete_uses_anchored_unlink_after_validation_race(tmp_path, monkeypatch): + import os + import shutil + + import api.workspace_git as workspace_git + from api.workspace import safe_resolve_ws as real_safe_resolve_ws + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("tracked\n", encoding="utf-8") + _commit_all(repo) + + (repo / "d").mkdir() + (repo / "d" / "f").write_text("workspace untracked\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + victim = outside / "f" + victim.write_text("outside victim\n", encoding="utf-8") + + state = {"calls": 0, "swapped": False} + + def racing_safe_resolve(root, requested): + target = real_safe_resolve_ws(root, requested) + if requested == "d/f": + state["calls"] += 1 + # git_discard validates once for the Git pathspec and once immediately + # before deletion. Race the second validation-to-use window. + if requested == "d/f" and state["calls"] == 2 and not state["swapped"]: + shutil.rmtree(repo / "d") + os.symlink(outside, repo / "d") + state["swapped"] = True + return target + + monkeypatch.setattr(workspace_git, "safe_resolve_ws", racing_safe_resolve) + + with pytest.raises(ValueError, match="Path traversal blocked"): + workspace_git.git_discard(repo, ["d/f"], delete_untracked=True) + + assert state["swapped"] is True + assert victim.exists() + assert victim.read_text(encoding="utf-8") == "outside victim\n" + + def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch): from api.workspace_git import _clean_git_env
158b4154ffe5Merge 4406597aea545a959454a56fbca0e18af61ed492 into 32d46f44503df91d0c2493950e298f10f8b35afe
2 files changed · +79 −4
api/workspace_git.py+10 −4 modified@@ -9,7 +9,6 @@ import difflib import os -import shutil import subprocess import tempfile import threading @@ -18,7 +17,7 @@ from pathlib import Path from typing import Iterable -from api.workspace import safe_resolve_ws +from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored GIT_TIMEOUT = 5 @@ -978,9 +977,16 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked raise GitWorkspaceError("Untracked files require delete_untracked=true") target = safe_resolve_ws(ctx.workspace, workspace_rel) if target.is_dir(): - shutil.rmtree(target) + rmtree_anchored(ctx.workspace, target) else: - target.unlink(missing_ok=True) + try: + unlink_anchored(ctx.workspace, target) + except FileNotFoundError: + # Preserve the previous Path.unlink(missing_ok=True) + # behavior for benign races where another process + # removes the untracked file after git_status() has + # reported it but before this discard reaches unlink. + pass continue _run_git(ctx, ["restore", "--worktree", "--", repo_rel], check=True) return git_status(workspace)
tests/test_workspace_git.py+69 −0 modified@@ -292,6 +292,33 @@ def test_git_status_reports_untracked_files_inside_directories(tmp_path): assert not (nested / "a.txt").exists() +def test_git_discard_untracked_file_tolerates_concurrent_missing_file(tmp_path, monkeypatch): + import api.workspace_git as workspace_git + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("one\n", encoding="utf-8") + _commit_all(repo) + transient = repo / "transient.txt" + transient.write_text("gone soon\n", encoding="utf-8") + + original_unlink_anchored = workspace_git.unlink_anchored + raced = {"seen": False} + + def remove_before_unlink(root, target): + if target == transient: + raced["seen"] = True + transient.unlink() + return original_unlink_anchored(root, target) + + monkeypatch.setattr(workspace_git, "unlink_anchored", remove_before_unlink) + + status = workspace_git.git_discard(repo, ["transient.txt"], delete_untracked=True) + + assert raced["seen"] is True + assert not transient.exists() + assert status["totals"]["changed"] == 0 + + def test_git_status_reports_ignored_files_without_counting_them_as_changed(tmp_path): from api.workspace_git import git_status @@ -829,6 +856,48 @@ def test_git_routes_selected_commit_and_structured_error(cleanup_test_sessions): assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"] +def test_git_discard_untracked_delete_uses_anchored_unlink_after_validation_race(tmp_path, monkeypatch): + import os + import shutil + + import api.workspace_git as workspace_git + from api.workspace import safe_resolve_ws as real_safe_resolve_ws + + repo = _init_repo(tmp_path / "repo") + (repo / "tracked.txt").write_text("tracked\n", encoding="utf-8") + _commit_all(repo) + + (repo / "d").mkdir() + (repo / "d" / "f").write_text("workspace untracked\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + victim = outside / "f" + victim.write_text("outside victim\n", encoding="utf-8") + + state = {"calls": 0, "swapped": False} + + def racing_safe_resolve(root, requested): + target = real_safe_resolve_ws(root, requested) + if requested == "d/f": + state["calls"] += 1 + # git_discard validates once for the Git pathspec and once immediately + # before deletion. Race the second validation-to-use window. + if requested == "d/f" and state["calls"] == 2 and not state["swapped"]: + shutil.rmtree(repo / "d") + os.symlink(outside, repo / "d") + state["swapped"] = True + return target + + monkeypatch.setattr(workspace_git, "safe_resolve_ws", racing_safe_resolve) + + with pytest.raises(ValueError, match="Path traversal blocked"): + workspace_git.git_discard(repo, ["d/f"], delete_untracked=True) + + assert state["swapped"] is True + assert victim.exists() + assert victim.read_text(encoding="utf-8") == "outside victim\n" + + def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch): from api.workspace_git import _clean_git_env
Vulnerability mechanics
Root cause
"The git_discard function in api/workspace_git.py has a TOCTOU race condition allowing arbitrary file deletion."
Attack vector
An attacker with local access and low privileges can exploit this vulnerability. The attacker must trigger the `git_discard` function with `delete_untracked=True` and provide a path that can be manipulated. By replacing a validated path component with a symbolic link pointing outside the workspace between the `safe_resolve_ws()` validation and the file deletion call, the attacker can cause arbitrary files to be deleted [ref_id=2]. This is a time-of-check time-of-use (TOCTOU) race condition [CWE-367].
Affected code
The vulnerability exists in the `git_discard` function within `api/workspace_git.py`. Specifically, the code path handling `delete_untracked=True` was susceptible to a race condition between the `safe_resolve_ws()` validation and the subsequent `Path.unlink()` or `shutil.rmtree()` calls [ref_id=1]. The fix modifies this section to use `rmtree_anchored` and `unlink_anchored` instead [patch_id=5390391].
What the fix does
The patch addresses the TOCTOU race condition by ensuring that untracked file deletions are routed through anchored helper functions, `rmtree_anchored` and `unlink_anchored` [patch_id=5390391]. These anchored functions re-validate the path against the workspace root at the time of deletion, preventing the deletion operation from following a symlink that was swapped in after the initial validation. This closes the window where an attacker could substitute a workspace-controlled path component with a symlink to an external directory before deletion occurs [ref_id=1].
Preconditions
- authAttacker must have low privileges.
- inputAttacker must be able to trigger the git_discard function with delete_untracked=True and control a path component.
Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/nesquena/hermes-webui/commit/4580f584964d640b95c4ffc9245a21ab926bec73nvd
- github.com/nesquena/hermes-webui/pull/3702nvd
- github.com/nesquena/hermes-webui/pull/3756nvd
- github.com/nesquena/hermes-webui/releases/tag/v0.51.303nvd
- www.vulncheck.com/advisories/hermes-webui-toctou-race-condition-via-git-discardnvd
News mentions
0No linked articles in our index yet.