fix: batch v0.50.231 — macOS symlink bypass, workspace panel, fenced code leak#1194
Merged
nesquena-hermes merged 6 commits intomasterfrom Apr 28, 2026
Merged
fix: batch v0.50.231 — macOS symlink bypass, workspace panel, fenced code leak#1194nesquena-hermes merged 6 commits intomasterfrom
nesquena-hermes merged 6 commits intomasterfrom
Conversation
The blocked-roots set was a tuple of literal paths (`/etc`, `/var`, ...).
On macOS those are symlinks to `/private/etc`, `/private/var`, `/private/tmp`.
Every caller resolves user input via `Path(input).resolve()` first — so
the candidate became `/private/etc` while the blocked entry was the literal
`/etc`. `/private/etc.relative_to(/etc)` raises ValueError, the candidate
falls through every blocked-root check, and the route handler accepts
`/etc` as a registered workspace.
This was directly observable as the recurring pre-existing macOS test
failure `test_workspace_add_rejects_system_paths` (POST /api/workspaces/add
with `/etc` returned 200 instead of 400). It was also a real security gap:
`/private/var/log` could be registered as a workspace on macOS, letting
agents read system logs through the carved-out trust path.
Fix: `_workspace_blocked_roots()` now returns BOTH the literal and
symlink-resolved canonical forms, deduped. `Path('/etc').resolve()` is
`/private/etc` on macOS and `/etc` on Linux — so the set self-canonicalises
per platform without any OS-specific code.
A naive "resolve everything" change would also block legit user-tmp paths
under `/private/var/folders/<hash>/T/` — pytest's `tmp_path_factory`
writes there on macOS, and `test_workspace_add_allows_external_valid_paths`
explicitly registers tmp dirs as workspaces. Carve-out via a new
`_USER_TMP_PREFIXES` tuple covering `/var/folders`, `/private/var/folders`,
`/var/tmp`, `/private/var/tmp` — checked first in
`_is_blocked_system_path()` so user-tmp paths short-circuit to "not
blocked" even though their parent (`/var`) is. Linux behaviour is
unchanged: literal == resolved, no extra entries, no blocked paths
pass that wouldn't have before.
Updated all six in-tree call sites to use the new `_is_blocked_system_path`
helper instead of inlining the loop:
- _trusted_workspace_roots (api/workspace.py:321)
- resolve_trusted_workspace home sanity + block check (api/workspace.py:453,462)
- validate_workspace_to_add (api/workspace.py:514)
- safe_resolve_ws symlink-target check (api/workspace.py:553)
- list_dir symlink-target check (api/workspace.py:577)
- _handle_workspace_add pre-create guard in api/routes.py:3208
Tests:
- 26 new tests in test_workspace_blocked_roots_macos.py covering:
- Set canonicalisation (literal + resolved both present)
- /etc / /private/etc both blocked across platforms
- /var/log security gap on macOS now closed
- /var/folders, /private/var/folders, /var/tmp user-tmp carve-outs
- All non-symlink roots and their subpaths still blocked
- test_batch_fixes.test_etc_still_blocked: relaxed substring match to
accept the new bare-string-in-tuple form.
- Full suite locally: 2664 passed, 47 skipped, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… (#ws-persist)
When a user has the workspace panel open and refreshes the page after
clicking New Conversation (or on any empty/no-session boot), the panel
was unconditionally closed and the toggle button disabled — leaving them
with no way to reopen it except clicking a past conversation first.
Root cause: three issues in boot.js working together:
1. syncWorkspacePanelState() had a hard rule:
if(!S.session) → _setWorkspacePanelMode('closed')
This always closed the panel when there was no active session, ignoring
whether the user had explicitly opened it.
2. The ephemeral-session guard path (refresh after New Conversation with
no messages) called syncWorkspacePanelState() without first restoring
the panel preference from localStorage — so _workspacePanelMode was
still 'closed' going in, and the rule above left it closed.
3. The no-saved-session path (truly fresh load) had the same omission.
Fixes:
1. syncWorkspacePanelState(): when S.session is null and mode is 'browse',
call syncWorkspacePanelUI() instead of force-closing. Only 'preview'
mode (which requires an active session for the file content) is closed.
2. Both the ephemeral guard and no-saved-session paths now read panelPref
from localStorage and set _workspacePanelMode='browse' before calling
syncWorkspacePanelState() — matching exactly what the session-restore
path already does.
3. canBrowse in syncWorkspacePanelUI() now includes S._profileDefaultWorkspace
so the toggle button stays enabled when a profile workspace is configured
(allows user to reopen the panel via button click).
4. openWorkspacePanel() guard updated to allow browse mode when
S._profileDefaultWorkspace is set (same condition as canBrowse).
Affected paths:
- Reload after clicking New Conversation (no messages sent yet)
- First-ever page load with workspace pref='open'
- Any reload where no session is stored in localStorage
Test updated: test_boot_sets_profile_default_workspace_in_settings_block
now searches for the specific assignment expression
(S._profileDefaultWorkspace=s.default_workspace) rather than any use of
the identifier — our new uses of _profileDefaultWorkspace further down
in the file were pushing the first-find far away from the settings block.
Static regression tests asserting the four invariants that prevent the
workspace panel from being silently force-closed on empty-session and
no-session boot paths:
1. syncWorkspacePanelState force-closes only 'preview' mode without a
session — 'browse' mode runs through syncWorkspacePanelUI() so the
panel renders rather than vanishes.
2. Both the empty-session guard path (#1182) and the no-saved-session
path read 'hermes-webui-workspace-panel-pref' from localStorage
before calling syncWorkspacePanelState().
3. canBrowse in syncWorkspacePanelUI() includes
S._profileDefaultWorkspace so the toggle button stays enabled when
a profile workspace is configured.
4. openWorkspacePanel('browse') early-return guard also includes
S._profileDefaultWorkspace so the toggle button can actually open
the panel.
These tests would have caught the original bug introduced when the
empty-session guard was added in #1182 without the corresponding panel
pref restoration.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Root cause: In renderMd(), fenced code blocks were rendered to <pre><code> HTML early (right after the fence stash restore), then exposed to subsequent markdown passes (lists, headings, tables, etc.). When a fenced block contained list-like markers (e.g. diff hunks with - removed lines, + added lines, or bash prompts starting with -), the list regex would inject <ul><li> tags INSIDE the <pre><code> block, breaking the </pre> closing tag and corrupting all subsequent message rendering. Fix: Split the fence stash into two phases: - Inline backticks (\x00F): restored before bold/italic (unchanged behavior) - Fenced blocks (\x00P): rendered to <pre><code> inside the stash callback, kept stashed until AFTER all markdown passes, then restored just before _pre_stash. This ensures diff/patch/list content inside code blocks is never misinterpreted as markdown. Also preserves compatibility with upstream's rawPreStash (\x00R) which protects existing <pre> blocks from streaming HTML. Tests: 14 new tests covering diff blocks, bash prompts, list markers, inline backticks, and headings after fenced blocks. All 2667 tests pass. Fixes #1154
JKJameson
pushed a commit
to JKJameson/hermes-webui
that referenced
this pull request
Apr 29, 2026
…code leak (nesquena#1194) Batch release v0.50.231 — 3 fixes. ## PRs included | PR | Author | Fix | |---|---|---| | nesquena#1186 | @nesquena (Claude Code) | macOS `/etc` symlink bypass in workspace blocked-roots | | nesquena#1187 | @nesquena-hermes | Workspace panel stuck closed after empty-session reload | | nesquena#1190 | @bergeouss | Fenced code content leaking into markdown passes (nesquena#1154) | All three PRs were independently reviewed and approved by @nesquena. ## Test results **2729 passed, 2 skipped** (2 macOS-only tests correctly skipped on Linux). Browser QA: **21/21**. ## Key fix notes **nesquena#1186:** `_workspace_blocked_roots()` now returns both literal and `Path.resolve()` forms of each blocked root. macOS symlinks (`/etc → /private/etc`) previously let a resolved candidate slip past the literal check. New `_is_blocked_system_path()` helper with `/var/folders` and `/var/tmp` carve-outs for pytest temp dirs. **nesquena#1187:** Regression from nesquena#1182 — `syncWorkspacePanelState()` force-closed on any no-session state. Now only closes in `'preview'` mode. Both boot paths restore localStorage panel pref before sync. **nesquena#1190:** Fenced code blocks are now stashed as `\x00P<n>\x00` tokens through ALL markdown passes (list/heading/table regexes), restored at the very end. Previously, diff hunks and markdown headings inside code blocks triggered those regexes, injecting `<ul>/<li>/<h>` tags that broke `</pre>` closure.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch release v0.50.231 — 3 fixes.
PRs included
/etcsymlink bypass in workspace blocked-rootsAll three PRs were independently reviewed and approved by @nesquena.
Test results
2729 passed, 2 skipped (2 macOS-only tests correctly skipped on Linux). Browser QA: 21/21.
Key fix notes
#1186:
_workspace_blocked_roots()now returns both literal andPath.resolve()forms of each blocked root. macOS symlinks (/etc → /private/etc) previously let a resolved candidate slip past the literal check. New_is_blocked_system_path()helper with/var/foldersand/var/tmpcarve-outs for pytest temp dirs.#1187: Regression from #1182 —
syncWorkspacePanelState()force-closed on any no-session state. Now only closes in'preview'mode. Both boot paths restore localStorage panel pref before sync.#1190: Fenced code blocks are now stashed as
\x00P<n>\x00tokens through ALL markdown passes (list/heading/table regexes), restored at the very end. Previously, diff hunks and markdown headings inside code blocks triggered those regexes, injecting<ul>/<li>/<h>tags that broke</pre>closure.