VYPR
Medium severity5.0NVD Advisory· Published Jun 9, 2026· Updated Jun 9, 2026

CVE-2026-49958

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

Patches

2
4580f584964d

Release v0.51.303 — Release JS (stage-p1a — cron toggle + config var expansion + git-discard hardening) (#3756)

https://github.com/nesquena/hermes-webuinesquena-hermesJun 7, 2026via nvd-ref
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
     
    
158b4154ffe5

Merge 4406597aea545a959454a56fbca0e18af61ed492 into 32d46f44503df91d0c2493950e298f10f8b35afe

https://github.com/nesquena/hermes-webuiHinotobiJun 6, 2026via nvd-ref
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

News mentions

0

No linked articles in our index yet.