Skip to content

fix: batch v0.50.231 — macOS symlink bypass, workspace panel, fenced code leak#1194

Merged
nesquena-hermes merged 6 commits intomasterfrom
stage/batch-231
Apr 28, 2026
Merged

fix: batch v0.50.231 — macOS symlink bypass, workspace panel, fenced code leak#1194
nesquena-hermes merged 6 commits intomasterfrom
stage/batch-231

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Batch release v0.50.231 — 3 fixes.

PRs included

PR Author Fix
#1186 @nesquena (Claude Code) macOS /etc symlink bypass in workspace blocked-roots
#1187 @nesquena-hermes Workspace panel stuck closed after empty-session reload
#1190 @bergeouss Fenced code content leaking into markdown passes (#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

#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.

#1187: Regression from #1182syncWorkspacePanelState() 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>\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.

nesquena and others added 6 commits April 28, 2026 00:39
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
@nesquena-hermes nesquena-hermes merged commit e61a405 into master Apr 28, 2026
@nesquena-hermes nesquena-hermes deleted the stage/batch-231 branch April 28, 2026 00:44
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants