Skip to content

fix(workspace): close macOS /etc symlink bypass; add /var/folders user-tmp carve-out#1186

Closed
nesquena wants to merge 2 commits intomasterfrom
fix/blocked-roots-macos-symlinks
Closed

fix(workspace): close macOS /etc symlink bypass; add /var/folders user-tmp carve-out#1186
nesquena wants to merge 2 commits intomasterfrom
fix/blocked-roots-macos-symlinks

Conversation

@nesquena
Copy link
Copy Markdown
Owner

Summary

Closes the recurring macOS-only failure of tests/test_sprint3.py::test_workspace_add_rejects_system_paths AND a real security gap that came with it.

The bug

_workspace_blocked_roots() returned literal Path('/etc'), Path('/var'), etc. 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.

Two consequences:

  1. Test failure: POST /api/workspaces/add with /etc returned 200 instead of 400 on macOS.
  2. Security gap: /private/var/log could be registered as a workspace on macOS, letting agents read system logs through the carved-out trust path.

On Linux there's no symlink, literal == resolved, so the check worked correctly.

The fix

_workspace_blocked_roots() now returns BOTH the literal and 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" 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. New _USER_TMP_PREFIXES carve-out (/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 is.

Linux behaviour is unchanged: literal == resolved, no extra entries, no paths pass that wouldn't have before.

What changed

  • api/workspace.py_workspace_blocked_roots() returns canonical-deduped set; new _safe_resolve helper, _USER_TMP_PREFIXES carve-outs, and _is_blocked_system_path() consolidating the 6 inlined loops across the file.
  • api/routes.py_handle_workspace_add pre-create guard switched to the helper.
  • tests/test_workspace_blocked_roots_macos.py — 26 new tests locking the set canonicalisation, /etc-and-/private/etc-both-blocked invariant, /var/log security gap closure, and /var/folders carve-out behaviour.
  • tests/test_batch_fixes.pytest_etc_still_blocked substring match relaxed to accept the new bare-string-in-tuple form.

Result

Full suite: 2664 passed, 47 skipped, 0 failed on macOS. The pre-existing test_sprint3 failure is gone. CI on Linux continues to pass (verified by the unchanged behaviour for non-symlink roots).

🤖 Generated with Claude Code

nesquena and others added 2 commits April 27, 2026 16:55
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]>
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Good fix with solid test coverage. A few notes from review:

Security impact confirmed: The macOS symlink path (/etc/private/etc) was a real bypass — Path('/etc').resolve() on macOS returns /private/etc, so relative_to('/etc') always raised ValueError and the candidate fell through all blocked-root checks. This is a meaningful security gap, not just a test flake.

Fix approach looks correct: Returning both literal + resolved canonical forms from _workspace_blocked_roots() self-canonicalises per platform without OS-specific branches — clean.

_USER_TMP_PREFIXES carve-out: The /var/folders + /private/var/folders carve-out is necessary because macOS routes tempfile.mkdtemp() and pytest's tmp_path_factory through /private/var/folders/<hash>/T/. The check-first ordering in _is_blocked_system_path() (carve-out → blocked root) means valid tmp paths short-circuit correctly even though their parent /var is blocked.

Linux regression risk: Low — on Linux, Path('/etc').resolve() returns /etc, so the deduped set contains only the original entries. Existing tests continue to cover the Linux path.

One thing to verify: The safe_resolve_ws symlink-target check (mentioned in the commit as a call site updated to use the helper) — worth confirming it handles the case where a symlink inside an allowed workspace points to /private/etc on macOS. That path would now correctly be blocked by the resolved form.

Overall: the fix is correctly scoped, well-tested (26 new tests covering the key invariants), and the Linux behaviour is provably unchanged. 👍

nesquena-hermes added a commit that referenced this pull request Apr 28, 2026
…code leak (#1194)

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 #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>\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-hermes
Copy link
Copy Markdown
Collaborator

Merged as v0.50.231 via #1194. Thank you @nesquena!

@nesquena-hermes nesquena-hermes deleted the fix/blocked-roots-macos-symlinks 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.

2 participants