HERMES-GIELIE/api
nesquena-hermes 7c48c37629
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)

Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.

Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>

* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix

* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)

Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)

A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:

- open_anchored_fd(): opens the (already symlink-resolved) target
  component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
  Every component must be a real non-symlink entry, so a component swapped to a
  symlink mid-flight is refused. No /proc dependency. Used by read_file_content
  (read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
  fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
  intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
  O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
  writes, anchored against the TRUE workspace root (not the mutable extraction
  dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
  symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
  create — no new race protection but no regression vs the prior path-based code.

Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.

* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback

Codex round-3 review found three residual issues in the anchored openat-walk:

1. (CORE) The workspace ROOT itself could be swapped to a symlink after
   resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
   open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
   a raced root symlink is refused. Verified: root-swap race now blocked.

2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
   containment check, so a raced symlink component could make the server create
   dirs outside the workspace before the anchored file create rejected. Removed
   the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
   already creates intermediates via anchored mkdirat) and replaced the two
   base-dir mkdirs (upload target dir + archive extraction root) with a new
   make_anchored_dir() that walks from the true workspace root via
   openat+O_NOFOLLOW + mkdir(dir_fd=).

3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
   os.readlink(dir_fd=), which would brick workspace browsing on platforms
   without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
   anchored branch and a path-based fallback branch (prior behaviour) sharing one
   _process() entry builder. open_anchored_create_fd()'s Windows fallback now also
   creates parent dirs.

Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.

* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP

CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.

Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 16:56:30 -07:00
..
__init__.py
agent_health.py fix: probe remote gateway via HERMES_API_URL before local fallback (#3312) 2026-06-01 19:38:00 +00:00
agent_sessions.py fix: keep webui mirrored sessions out of cli filter 2026-05-28 00:51:12 +02:00
auth.py fix: harden WebUI bugfix sweep 2026-05-28 13:38:50 -04:00
background.py feat(commands): /background, /btw slash commands + undo button + reasoning chip 2026-04-24 01:24:51 +00:00
clarify.py feat: add distinct attention request sound 2026-05-30 19:40:40 +00:00
commands.py fix(security): drop unsafe-eval + add jsdelivr to CSP, sanitize plugin error 2026-05-11 17:53:02 +00:00
compression_anchor.py fix(sidebar): keep newer continuation visible over older snapshot 2026-05-28 13:38:06 -06:00
config.py fix(config): extend #3429 URI-scheme fix to backend normalization + model-key matching (#3436, @b3nw) 2026-06-02 21:04:02 +00:00
dashboard_probe.py fix: preserve IPv6 dashboard link brackets 2026-05-19 21:35:10 -04:00
extensions.py Opus pre-release follow-ups for PR #1445 2026-05-02 03:49:40 +00:00
gateway_chat.py fix(gateway): carry WebUI delivery context on gateway-backed path too (Codex MUST-FIX on #3324) 2026-06-02 19:42:14 +00:00
gateway_watcher.py release: v0.50.241 (#1293) 2026-04-29 19:54:07 -07:00
goals.py fix(i18n): localize /goal runtime status strings 2026-05-10 15:21:24 +08:00
helpers.py fix(todos): hydrate cold-load state from session snapshot 2026-06-02 17:33:49 +00:00
kanban_bridge.py perf(http): enable HTTP/1.1 keep-alive 2026-05-24 18:26:56 +00:00
metering.py fix: show TPS in assistant message headers 2026-05-04 21:26:43 +00:00
models.py fix: deduplicate legacy messages in merge_session_messages_append_only (#3393, @thanhtoantnt) 2026-06-02 19:20:23 +00:00
oauth.py fix(oauth): serialize Anthropic env fallback reads 2026-05-07 02:47:19 +00:00
onboarding.py Classify onboarding DNS probe failures consistently 2026-05-15 08:51:30 +08:00
passkeys.py feat: support passkey-only auth 2026-05-25 00:14:38 +00:00
paths.py fix(profiles): avoid config import cycle 2026-06-01 05:05:16 +00:00
plugins.py fix(#2622): plugin card UX — legible Open button, single badge, visible toggle + reject protocol-relative tab.path 2026-06-02 03:58:02 +00:00
profiles.py fix(profiles): TOCTOU-safe _SKILLS_STATS_CACHE read via .get() (Opus pre-release nit on #3341) 2026-06-01 18:38:39 +00:00
providers.py fix: honor shared OpenCode key at runtime 2026-05-30 02:18:06 +00:00
request_diagnostics.py fix: add request wedge diagnostics 2026-05-08 15:37:08 +00:00
rollback.py v0.50.255: Opus follow-ups (4 fixes) + CHANGELOG 2026-05-01 17:19:53 +00:00
routes.py fix(todos): hydrate cold-load state from session snapshot 2026-06-02 17:33:49 +00:00
run_journal.py fix: filter interrupted recovery control text from visible transcript (#3321) 2026-06-01 20:07:16 +00:00
runner_client.py harden: runner client rejects non-http(s) base_url + no redirect-follow (#3073) 2026-05-31 17:49:26 +00:00
runtime_adapter.py Stage 398: PR #2696 — feat(runtime): add runner-local adapter selection (RuntimeAdapter slice 4c, feature-flagged) 2026-05-21 17:43:54 +00:00
session_discoverability.py Merge PR #3048 2026-05-28 17:47:33 +00:00
session_events.py sync session list from server events 2026-05-20 08:18:56 -06:00
session_lifecycle.py fix: align WebUI memory commits with CLI boundaries 2026-05-17 05:04:57 -06:00
session_ops.py fix(session): preserve sidecar truncation boundary 2026-05-25 21:21:15 +08:00
session_recovery.py fix(session): rebuild missing startup index 2026-05-20 23:43:30 +08:00
skill_usage.py fix: remove webui-side writer to avoid conflict with agent 2026-05-27 20:57:32 +08:00
startup.py fix: P0 hotfixes — API regression, code block parser, chmod override 2026-05-01 12:10:48 +00:00
state_sync.py fix: use Optional[T] for default None parameters in state_sync.py 2026-06-01 17:20:17 +00:00
streaming.py fix: strip @provider: prefix from auxiliary title_generation model id (#3430, @pamnard) 2026-06-02 21:14:49 +00:00
system_health.py feat: add VPS resource health panel 2026-05-05 17:30:56 +00:00
terminal.py fix(terminal): harden supervisor lifecycle and eliminate timeout race conditions 2026-05-25 20:45:17 +05:00
todo_state.py fix(todos): hydrate cold-load state from session snapshot 2026-06-02 17:33:49 +00:00
turn_journal.py fix: handle Windows media and turn journal edge cases 2026-05-30 11:15:35 +02:00
updates.py fix(updates): re-exec correctly in both source and frozen builds 2026-06-02 17:33:49 +00:00
upload.py Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451) 2026-06-02 16:56:30 -07:00
usage.py fix: display canonical cache hit percentage 2026-05-19 02:27:12 -06:00
workspace.py Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451) 2026-06-02 16:56:30 -07:00
workspace_git.py fix(workspace): tighten git subprocess trust boundary 2026-05-20 11:02:45 +00:00
worktrees.py Harden worktree removal safeguards 2026-05-13 09:49:15 +08:00