Skip to content

fix(security): re-resolve checkpoint/sticker-cache paths per call - #56315

Closed
srojk34 wants to merge 1 commit into
NousResearch:mainfrom
srojk34:fix/checkpoint-sticker-cache-profile-leak
Closed

srojk34 wants to merge 1 commit into
NousResearch:mainfrom
srojk34:fix/checkpoint-sticker-cache-profile-leak

Conversation

@srojk34

@srojk34 srojk34 commented Jul 1, 2026

Copy link
Copy Markdown

Summary

tools/checkpoint_manager.py::CHECKPOINT_BASE and gateway/sticker_cache.py::CACHE_PATH are both resolved once at import time via get_hermes_home(), which reads a context-local ContextVar (_HERMES_HOME_OVERRIDE) set per-request under the multiplexed gateway (multiple profiles served from one process, e.g. the desktop tui_gateway). Freezing the resolved path at import time pins every later checkpoint/cache read-write to whichever profile's HERMES_HOME happened to be active the first time the module was imported — the same bug class already fixed for cache dirs, tools/skills_hub.py, gateway/rich_sent_store.py, and (in a companion PR from this same audit) the Anthropic OAuth file / Nous Portal auth.json / sessions.json index.

CheckpointManager is documented as "owned by AIAgent" (one instance per agent), but its methods (list_checkpoints, diff, restore, _take) all read the frozen module constant directly instead of resolving through the instance — so under the multiplexed gateway, a profile's CheckpointManager can silently read/write the code-editing checkpoint (shadow-git snapshot) store of a different profile: wrong checkpoints listed, wrong snapshot restored, or a profile's file-edit history landing in another profile's store.

gateway/sticker_cache.py's leak is lower-stakes (a cache of vision-generated Telegram sticker descriptions) but the identical bug shape — bundled here since it's a one-line-per-site version of the same fix.

Changes

  • tools/checkpoint_manager.py: added _resolve_checkpoint_base(). Updated _store_path()'s default fallback and all 4 CheckpointManager methods that previously called _store_path(CHECKPOINT_BASE) directly (now _store_path(), relying on the updated default), plus the 5 module-level functions (prune_checkpoints, maybe_auto_prune_checkpoints, store_status, clear_all, clear_legacy) that used checkpoint_base or CHECKPOINT_BASE (now checkpoint_base or _resolve_checkpoint_base()). Preserves the existing test seam — tests/tools/test_checkpoint_manager.py has many call sites that monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", ...), which the resolver still honors when the constant has been changed from its import-time default (same pattern as gateway/platforms/base.py::_resolve_cache_dir).
  • gateway/sticker_cache.py: added _resolve_cache_path(), called from _load_cache()/_save_cache(). Same test-seam-preserving pattern (tests/gateway/test_sticker_cache.py patches CACHE_PATH directly).
  • tests/test_profile_isolation_runtime.py (the existing profile-isolation regression suite): added TestCheckpointManagerPathResolution and TestStickerCachePathResolution, each proving the resolved path actually changes between two distinct profile overrides, plus "monkeypatched constant still wins" regression tests for both resolvers.

Test plan

  • pytest tests/tools/test_checkpoint_manager.py tests/gateway/test_sticker_cache.py -q — 94 passed
  • pytest tests/test_windows_subprocess_no_window_flags.py -q — 15 passed (sanity check, unaffected)
  • pytest tests/test_profile_isolation_runtime.py -q — 15 passed (10 pre-existing + 5 new)
  • ruff check on all changed files — clean

tools/checkpoint_manager.py's CHECKPOINT_BASE and gateway/sticker_cache.py's
CACHE_PATH are resolved once at import time via get_hermes_home(), which is
a context-local ContextVar under the multiplexed gateway (multiple profiles
sharing one process). Freezing the path at import time pins every later
checkpoint/cache read-write to whichever profile's HERMES_HOME was active
when the module was first imported -- the same bug class already fixed for
cache dirs, skills_hub, rich_sent_store, and (this session) the OAuth/auth.json/
sessions.json paths.

CheckpointManager is "owned by AIAgent" per-instance, but its methods read
the frozen module constant directly instead of taking the store root from
the instance, so a profile's CheckpointManager can read/write code-edit
checkpoints into a different profile's store.

Add a per-call resolver for each path, following the established "respect
an existing test monkeypatch of the constant, otherwise re-resolve through
get_hermes_home()" pattern so the extensive existing test seams in
tests/tools/test_checkpoint_manager.py and tests/gateway/test_sticker_cache.py
keep working unmodified.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state P2 Medium — degraded but workaround exists labels Jul 1, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — profile-scoped path resolution for sticker cache and checkpoint manager. The re-resolve-through-get_hermes_home() pattern correctly honors the active profile override under the multiplexed gateway. Test seam (monkeypatch detection) is preserved. The approach mirrors the existing cache-dir profile-isolation fix.

@egilewski

Copy link
Copy Markdown

looks mergeable

Security evidence:

  • trust boundary: this is the single-process multi-profile runtime boundary where get_hermes_home() is context-local, so import-time path constants can route one profile's checkpoint or sticker-cache state into another profile's home.
  • source/sink/invariant: the source is the active set_hermes_home_override() profile context; the sinks are checkpoint store operations and Telegram sticker-cache read/write calls; the invariant is that each call resolves storage under the active profile unless a test or caller explicitly supplies a path override.
  • current-main reproduction: on current main, importing tools.checkpoint_manager and gateway.sticker_cache under profile A and then switching the active override to profile B still left checkpoint and sticker-cache paths pinned to profile A.
  • PR-head or patch-replay validation: on the PR head, the same probe resolves tools.checkpoint_manager._store_path() to profile B's checkpoints/store and writes gateway.sticker_cache data to profile B's sticker_cache.json.
  • positive/negative cases: the new profile-isolation tests cover both profile switching and explicit monkeypatch override preservation, while the existing checkpoint-manager and sticker-cache suites still pass unchanged.
  • residual bypass search: I checked the changed modules for remaining production reads of CHECKPOINT_BASE or CACHE_PATH; the remaining references are the mutable compatibility constants, import-default sentinels, resolver comparisons, comments, and existing tests.
  • reviewer validation: CodeRabbit completed with no findings in the clean-pass flow.

The patch is focused, merges cleanly into current main, and preserves the existing test seams for direct CHECKPOINT_BASE and CACHE_PATH monkeypatches while fixing the runtime profile-isolation behavior.

Signed: GPT-5.5-xhigh in Codex

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing this through the multiplexed profile runtime. The premise is confirmed on current main: get_hermes_home() honors the context-local override (hermes_constants.py:71-73), while CHECKPOINT_BASE and CACHE_PATH are frozen at import (tools/checkpoint_manager.py:72, gateway/sticker_cache.py:20). The proposed per-call resolution reaches the relevant checkpoint and sticker-cache sinks.

Problems

  • The added tests currently assert resolver outputs only. They do not exercise a real checkpoint operation through _take() / list_checkpoints() (tools/checkpoint_manager.py:693,875) or a sticker-cache write through _save_cache() (gateway/sticker_cache.py:39-50) after switching profile overrides.

Suggested changes

  • Add two-profile end-to-end regressions that verify checkpoint and sticker-cache data are physically read/written under the active profile, while retaining the monkeypatch compatibility checks.

This is an automated hermes-sweeper review.

active profile — otherwise one profile's CheckpointManager instance can
read/write code-edit checkpoints into a different profile's store under
the multiplexed gateway."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a two-profile checkpoint operation test in addition to this resolver assertion: persist under profile A, switch the same manager to B, then verify B reads/writes only B's store. The affected production sinks are _take() and list_checkpoints().

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
teknium1 added a commit that referenced this pull request Sep 11, 2026
… and schema paths under multiplex

Under `gateway.multiplex_profiles` one gateway process serves every profile
under ~/.hermes/profiles/NAME/; each routed turn runs with a context-local
HERMES_HOME override while `os.environ` still holds the DEFAULT profile's
values. Anything evaluated once at import, or memoised in a single unkeyed
module slot, therefore freezes the LAUNCH profile's value and leaks it into
every other profile's turns. This lands the tools-side half of that class:

- tools/process_registry.py, tools/environments/{modal,singularity}.py:
  `_checkpoint_path()` / `_snapshot_store()` resolve `get_hermes_home()` at
  call time (same seam as `tools/skills_tool._skills_dir`, so the existing
  `monkeypatch.setattr(CHECKPOINT_PATH)` test sites keep working). Completes
  the checkpoint_manager / sticker_cache half cherry-picked from #56315.
- plugins/platforms/feishu/feishu_comment_rules.py: `_MtimeCache` is now
  path-keyed (accepts a Path or a zero-arg resolver, one (mtime, data) slot
  per resolved path) with `invalidate()`; `_rules_file()` / `_pairing_file()`
  resolve the routed profile's files. Proposed in #63962.
- tools/tool_output_limits.py, tools/browser_tool.py, tools/browser_camofox.py:
  the process-lifetime config caches are dicts keyed by `hermes_home_key()`;
  the `_X_resolved` flags and the lifecycle reset keep their shape.
  tools/file_tools.py drops its private `file_read_max_chars` memo and reads
  the already mtime+path-cached `load_config_readonly()`.
- hermes_time.py: `get_timezone_name()`; when `is_multiplex_active()` the
  env `HERMES_TIMEZONE` (bridged from the default profile's config at gateway
  startup) is ignored in favour of the routed profile's config.yaml. Both
  sandbox TZ sites (code_execution_env/_tool) now use it.
- tools/cronjob_tools.py, tools/tts_tool.py, tools/skill_manager_tool.py:
  the static schema text is profile-neutral and `dynamic_schema_overrides=`
  rebuilds the `display_hermes_home()` / create-dir hint per
  `get_definitions()`, so a routed profile's model sees its own paths.

Refs #95685.

Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
(cherry picked from commit 6d3fc6b07b3155c6196b1fd61a829283f1d7855c)
teknium1 added a commit that referenced this pull request Sep 11, 2026
… and schema paths under multiplex

Under `gateway.multiplex_profiles` one gateway process serves every profile
under ~/.hermes/profiles/NAME/; each routed turn runs with a context-local
HERMES_HOME override while `os.environ` still holds the DEFAULT profile's
values. Anything evaluated once at import, or memoised in a single unkeyed
module slot, therefore freezes the LAUNCH profile's value and leaks it into
every other profile's turns. This lands the tools-side half of that class:

- tools/process_registry.py, tools/environments/{modal,singularity}.py:
  `_checkpoint_path()` / `_snapshot_store()` resolve `get_hermes_home()` at
  call time (same seam as `tools/skills_tool._skills_dir`, so the existing
  `monkeypatch.setattr(CHECKPOINT_PATH)` test sites keep working). Completes
  the checkpoint_manager / sticker_cache half cherry-picked from #56315.
- plugins/platforms/feishu/feishu_comment_rules.py: `_MtimeCache` is now
  path-keyed (accepts a Path or a zero-arg resolver, one (mtime, data) slot
  per resolved path) with `invalidate()`; `_rules_file()` / `_pairing_file()`
  resolve the routed profile's files. Proposed in #63962.
- tools/tool_output_limits.py, tools/browser_tool.py, tools/browser_camofox.py:
  the process-lifetime config caches are dicts keyed by `hermes_home_key()`;
  the `_X_resolved` flags and the lifecycle reset keep their shape.
  tools/file_tools.py drops its private `file_read_max_chars` memo and reads
  the already mtime+path-cached `load_config_readonly()`.
- hermes_time.py: `get_timezone_name()`; when `is_multiplex_active()` the
  env `HERMES_TIMEZONE` (bridged from the default profile's config at gateway
  startup) is ignored in favour of the routed profile's config.yaml. Both
  sandbox TZ sites (code_execution_env/_tool) now use it.
- tools/cronjob_tools.py, tools/tts_tool.py, tools/skill_manager_tool.py:
  the static schema text is profile-neutral and `dynamic_schema_overrides=`
  rebuilds the `display_hermes_home()` / create-dir hint per
  `get_definitions()`, so a routed profile's model sees its own paths.

Refs #95685.

Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
(cherry picked from commit 6d3fc6b07b3155c6196b1fd61a829283f1d7855c)
teknium1 added a commit that referenced this pull request Sep 11, 2026
… and schema paths under multiplex

Under `gateway.multiplex_profiles` one gateway process serves every profile
under ~/.hermes/profiles/NAME/; each routed turn runs with a context-local
HERMES_HOME override while `os.environ` still holds the DEFAULT profile's
values. Anything evaluated once at import, or memoised in a single unkeyed
module slot, therefore freezes the LAUNCH profile's value and leaks it into
every other profile's turns. This lands the tools-side half of that class:

- tools/process_registry.py, tools/environments/{modal,singularity}.py:
  `_checkpoint_path()` / `_snapshot_store()` resolve `get_hermes_home()` at
  call time (same seam as `tools/skills_tool._skills_dir`, so the existing
  `monkeypatch.setattr(CHECKPOINT_PATH)` test sites keep working). Completes
  the checkpoint_manager / sticker_cache half cherry-picked from #56315.
- plugins/platforms/feishu/feishu_comment_rules.py: `_MtimeCache` is now
  path-keyed (accepts a Path or a zero-arg resolver, one (mtime, data) slot
  per resolved path) with `invalidate()`; `_rules_file()` / `_pairing_file()`
  resolve the routed profile's files. Proposed in #63962.
- tools/tool_output_limits.py, tools/browser_tool.py, tools/browser_camofox.py:
  the process-lifetime config caches are dicts keyed by `hermes_home_key()`;
  the `_X_resolved` flags and the lifecycle reset keep their shape.
  tools/file_tools.py drops its private `file_read_max_chars` memo and reads
  the already mtime+path-cached `load_config_readonly()`.
- hermes_time.py: `get_timezone_name()`; when `is_multiplex_active()` the
  env `HERMES_TIMEZONE` (bridged from the default profile's config at gateway
  startup) is ignored in favour of the routed profile's config.yaml. Both
  sandbox TZ sites (code_execution_env/_tool) now use it.
- tools/cronjob_tools.py, tools/tts_tool.py, tools/skill_manager_tool.py:
  the static schema text is profile-neutral and `dynamic_schema_overrides=`
  rebuilds the `display_hermes_home()` / create-dir hint per
  `get_definitions()`, so a routed profile's model sees its own paths.

Refs #95685.

Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
(cherry picked from commit 6d3fc6b07b3155c6196b1fd61a829283f1d7855c)
@teknium1

Copy link
Copy Markdown
Collaborator

Landed on main in #108453. Cherry-picked with authorship preserved as e70db09 — tools/process_registry.py::_checkpoint_path, tools/checkpoint_manager.py::_resolve_checkpoint_base and gateway/sticker_cache.py::_resolve_cache_path resolve per call, with the existing monkeypatch.setattr(<CONSTANT>) test seams kept. adf2355 extends the same pattern to the modal/singularity snapshot stores and the tool caches. Thanks, @srojk34.

@teknium1 teknium1 closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants