feat(gateway): per-user profile isolation for a single bot (per_user_profiles) - #65571
feat(gateway): per-user profile isolation for a single bot (per_user_profiles)#65571otopba wants to merge 3 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for building on the existing profile seams; the sender-derived-home gap is real on current main (gateway/run.py:17631-17642). I found three blocking gaps in the proposed isolation contract.
Problems
gateway/run.py:13879-13888scopes_run_background_taskonly formultiplex_profiles. This PR updates foreground scope selection but not that sibling path, so a per-user/backgroundtask runs in the shared base home. Commit8091c4405added this wrapper specifically to preserve multiplex profile scope.- The documentation says workspace files isolate, but
_profile_home_only_scopeonly changes a ContextVar.hermes_constants.py:23-30deliberately leavesos.environunchanged; gateway startup bridgesterminal.cwdto process-globalTERMINAL_CWD(gateway/run.py:1625-1684), whichtools/terminal_tool.py:1394-1400consumes. Cloned profiles therefore retain a shared terminal workspace. gateway/user_profiles.py:108-111truncates the digest away whenper_user_profile_prefixis 64 valid characters, mapping every sender to one profile.
Suggested changes
- Scope background tasks through
_profile_scope_for_sourceand add coverage. - Define and test whether terminal workspaces are isolated; otherwise narrow the feature claim to HERMES_HOME state.
- Bound the prefix or reserve digest space before truncation.
Automated hermes-sweeper review.
| if len(name) > _MAX_LEN: | ||
| # Pathological prefix/platform lengths — fall back to a fully hashed tail. | ||
| digest = hashlib.sha1(f"{plat_seg}:{uid}".encode("utf-8")).hexdigest()[:16] | ||
| name = f"{prefix_seg}-{digest}"[:_MAX_LEN] |
There was a problem hiding this comment.
A valid 64-character configured prefix makes this slice discard the digest entirely, so every sender resolves to the same profile name. Validate the prefix against the available suffix budget, or construct the identifier so a sender-specific digest is always retained.
There was a problem hiding this comment.
Fixed in 6868f1e1d. The fallback now reserves room for -<digest> before truncating and bounds only the prefix, so the sender-specific digest is always retained:
digest = hashlib.sha1(f"{plat_seg}:{uid}".encode("utf-8")).hexdigest()[:16]
prefix_bounded = prefix_seg[: _MAX_LEN - len(digest) - 1].rstrip("-_") or "u"
name = f"{prefix_bounded}-{digest}"A 64-char prefix now yields <prefix[:47]>-<16-hex> (≤64, valid), so distinct senders can no longer collapse onto one profile. Added test_long_prefix_keeps_sender_digest asserting two distinct senders under a 64-char prefix stay distinct and each name still passes validate_profile_name.
|
Thanks for the thorough review. Status on the three gaps: 3. Prefix truncation — fixed & pushed ( 1. Background-task scope — confirmed, not yet pushed. Verified on the current branch: 2. Terminal workspace isolation — agreed, needs a decision. Will follow up on (1) once the executor seam is confirmed. |
…profiles) Opt-in gateway.per_user_profiles derives a profile from the message SENDER and runs each turn under that user's own HERMES_HOME, so skills, cron jobs.json, and native MEMORY.md/USER.md isolate per user while service credentials stay shared (home-only scope). Reuses the existing profile machinery (_profile_runtime_scope seam, agent:<profile> session-key namespace); lazy race-safe provisioning seeded from a template; adapter resolution and session recovery made per-user-aware so replies deliver and resumable sessions aren't dropped. Default off — single-profile gateways are byte-identical. 45 tests. Related: NousResearch#21574 (per-user identity RFC), NousResearch#63746 (cron.run_as_creator). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er digest
The fully-hashed fallback in derive_user_profile_name did
`f"{prefix_seg}-{digest}"[:_MAX_LEN]`. A valid 64-char prefix filled the
whole budget, so the `[:_MAX_LEN]` slice discarded `-{digest}` entirely and
every sender collapsed onto one profile — defeating per-user isolation.
Reserve room for `-<digest>` before truncating and bound only the prefix, so
the sender-specific digest is always retained. Add a regression test asserting
distinct senders under a 64-char prefix stay distinct and valid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
689fd6d to
463cb54
Compare
# Conflicts: # gateway/run.py
|
Status check: this one merges cleanly into current @teknium1 — you left a review here on 18 Jul and I replied the next morning; it's been quiet since. Ready for another pass whenever you have time, and happy to rework anything if the direction needs changing. |
What does this PR do?
Adds an opt-in
gateway.per_user_profiles(default off) that isolates theworkspace of each user talking to a single bot. When enabled, the gateway
derives a profile from the message sender (
<prefix>-<platform>-<uid>) andruns each turn under that user's own
HERMES_HOME, so skills, cronjobs.json,native
MEMORY.md/USER.md, and workspace files isolate per user — whileservice credentials stay shared.
Why: a single gateway serves every user of one bot from one
HERMES_HOME.Conversation history is already isolated per chat and memory providers per user,
but the shared workspace is not: through
skill_manage,cronjob, or the nativememorytool, one user can read, tamper with, or delete another user's skills,scheduled jobs, and memory files.
multiplex_profilesdoesn't solve this — itstamps a profile per bot credential (
_make_profile_message_handler; twoprofiles can't poll the same token), so every user of one bot lands in the
defaultprofile. Isolating N users would need N bot tokens.This is the minimal isolation primitive requested by the per-user identity
RFC (#21574): reuse the existing profile machinery, keyed by sender instead of
by adapter. Role/permission tiers (RBAC) are intentionally out of scope and layer
on top of a per-user identity→home that now exists.
Related Issue
Related: #21574 (per-user identity & isolation RFC), #63746 (
cron.run_as_creator— threads sender identity into cron; complementary), #11448 (per-user memory
dirs — subsumed for the single-bot case, its migration logic still useful).
Type of Change
Design decisions worth reviewer attention
already uses —
_resolve_profile_home_for_source, a per-turn home scope, andthe
agent:<profile>:…session-key namespace. The feature is essentially "anew way to name the profile (from the sender) + lazy provisioning + a
shared-secret scope variant."
_profile_home_only_scopethat overridesget_hermes_home()but does notinstall a per-profile secret scope (and
set_multiplex_activestays False), soget_secretkeeps reading the process-globalos.environ. A per-user.envwould be empty and break every credentialed tool; the template's
.envisdropped at provision time so no stale secret sits in each user dir.
<prefix>-<platform>-<uid>is used only whensanitizing the uid was lossless — the uid was already a non-empty lowercase
profile-id charset (e.g. a numeric platform id), compared against the
case-preserving uid. Otherwise the raw uid is SHA1-hashed. This closes three
collision classes that each silently merge two distinct senders into one
profile: character drop (
a.b/ab→ab), case fold (Bob/bob→bob),and empty/degenerate segments (whitespace uid →
Noneshared default;punctuation-only
!!!→ hashed, neveru-<platform>-). Platform is part of theid so a Telegram and a SpaceChat uid never collide. All pinned by tests.
u-…profile is a workspacenamespace, not a bot-routing key, so adapter resolution (
_authorization_adapter/
_adapter_for_source) is made per-user-aware: a derived profile falls throughto the single shared bot adapter instead of the multiplex fail-closed
None(which would drop every reply). Session recovery
(
_recovered_row_allowed_for_active_profile) compares the recovered row againstthe requested key's profile, not the operator's active profile, so a user's
own differently-keyed resumable row isn't wrongly rejected under per-user keys.
message (
create_profile(clone_config=True), seeded fromper_user_profile_template, default the active profile) under a per-name lockwith a double-checked
profile_exists; concurrent first messages resolve to oneprofile (
FileExistsErrorswallowed).multiplex_profiles. A configuredprofile_routesmatch still wins over per-user derivation (pin a group to ashared profile; every DM user still gets their own). If both flags are on,
multiplex — with its stronger per-profile credential isolation — takes
precedence for the scope.
copy seeded once, so later edits to the base
config.yaml/SOUL.md/ modeldon't reach already-provisioned users. The managed overlay (re-applied every
turn) is the lever for anything that must stay operator-authoritative; a re-seed
is the alternative.
stop a user from running the terminal tool or admin slash commands in their own
sandbox. Pair with
allow_admin_from/user_allowed_commandsand a restrictedtoolset. (Noted in the docs.)
Changes Made
gateway/user_profiles.py(new) —derive_user_profile_name(collision-safe,platform-namespaced, hashed fallback),
is_user_profile_name, andensure_user_profile(lazy, race-safe provisioning; cleans up a partially-builtprofile on failure, evicts the per-name lock, strips the seeded
.env).gateway/config.py—per_user_profiles/per_user_profile_template/per_user_profile_prefixfields, withfrom_dict/to_dictand yaml-loadparity (top-level + nested
gateway.*) mirroringmultiplex_profiles.gateway/run.py—_profile_home_only_scope(home override without secretswap);
_profile_scope_for_source(picks nullcontext / home-only / full scope);_run_agent,_prepare_profile_scoped_inbound_message_text, and the session-info scope now route through it;
_profile_name_for_sourcederives a per-userprofile when no route matches (suppressed under multiplex);
_resolve_profile_home_for_sourceprovisions a derived profile on first contact;_session_key_for_sourceand theagent.max_turnsenv bridge honor the per-userhome too.
gateway/session.py—_resolve_profile_for_keynamespaces by the per-userprofile (fallback stays
None/shared for an unresolved sender);_recovered_row_allowed_for_active_profilecompares against the requested key'sprofile so per-user session recovery isn't wrongly blocked.
gateway/authz_mixin.py—_authorization_adapterfalls through to the sharedbot adapter for a per-user (home-only) profile instead of fail-closing.
tests/gateway/test_per_user_profiles.py(new) — 45 tests (derivation +collision hardening, provisioning + partial-failure cleanup + lock eviction,
config round-trip, session-key isolation, runner wiring, adapter resolution &
session recovery, multiplex precedence, home-only scope keeps secrets shared).
tests/gateway/test_profile_resolution.py— fixture now sets a concreteper_user_profiles=False(a bare MagicMock attr is truthy and would flipderivation on).
website/docs/user-guide/multi-profile-gateways.md,cli-config.yaml.example— document the feature, the shared-secret + drift caveats, and composition.
How to Test
pytest tests/gateway/test_per_user_profiles.py tests/gateway/test_profile_resolution.py tests/gateway/test_profile_routing.py tests/gateway/test_multiplex_*.py tests/gateway/test_config.py tests/gateway/test_agent_cache.py tests/gateway/test_async_session_store.py tests/gateway/test_runtime_env_reload_config_authority.py tests/gateway/test_unauthorized_dm_behavior.py -q— passes (45 new + existing green).gateway: { per_user_profiles: true }, starthermes gateway run, DM the bot from two Telegram accounts. Each gets~/.hermes/profiles/u-telegram-<uid>/on first message; a skill/cron job/MEMORY.mdentry created by one is not visible to the other; both still answer (shared model key).nullcontext).Checklist
Code
Documentation & Housekeeping
cli-config.yaml.examplefor the new keys🤖 Generated with Claude Code