Skip to content

security(state): create state.db and WAL sidecars at 0600, not process umask - #59716

Open
JoaoMarcos44 wants to merge 4 commits into
NousResearch:mainfrom
JoaoMarcos44:security/harden-state-db-mode
Open

security(state): create state.db and WAL sidecars at 0600, not process umask#59716
JoaoMarcos44 wants to merge 4 commits into
NousResearch:mainfrom
JoaoMarcos44:security/harden-state-db-mode

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #59706

Problem

SessionDB.__init__ (hermes_state.py) creates state.db via a bare sqlite3.connect(), which opens the file at the process umask (0644 under the common 022 default) rather than an explicit 0600. On the default posture this is shielded by ~/.hermes's 0700 mode, but that makes the directory the only protection -- it becomes load-bearing the instant HERMES_HOME_MODE is widened or managed mode's 0750 group-readable directory is in play, exposing full conversation history to other local accounts. WAL mode's -wal/-shm sidecars have the same problem and aren't covered by any existing chmod.

Fix

hermes_state.py:

def _create_owner_only(path: Path) -> None:
    """Pre-create *path* as an empty owner-only (0600) file if it doesn't
    exist yet, so sqlite3.connect() opens an already-secured file instead of
    creating one at the process umask (0644 under the common 022 default).
    No-op on Windows (mode bits aren't meaningful there) and if another
    process wins the create race -- that process already set the same mode.
    """
    try:
        fd = os.open(str(path), os.O_CREAT | os.O_EXCL, 0o600)
        os.close(fd)
    except FileExistsError:
        pass
    except OSError:
        pass


def _secure_wal_files(db_path: Path) -> None:
    """chmod the -wal/-shm sidecar files to 0600 if WAL mode created them."""
    for suffix in ("-wal", "-shm"):
        sidecar = Path(str(db_path) + suffix)
        try:
            if sidecar.exists():
                os.chmod(sidecar, 0o600)
        except OSError:
            pass

Called from SessionDB.__init__: _create_owner_only(self.db_path) right before the sqlite3.connect() that creates the file, and _secure_wal_files(self.db_path) right after apply_wal_with_fallback() engages WAL mode.

An already-existing state.db (restored from a backup, or created by an older Hermes version) is left untouched -- O_EXCL only sets the mode on first creation, never chmods an existing file. This mirrors the TOCTOU-safe pattern already used for auth.json in hermes_cli/auth.py.

Update: both hardening functions now also no-op in managed (NixOS) or container mode, mirroring hermes_cli.config._secure_file's own checks (is_managed() / _is_container()). The NixOS module deliberately runs HERMES_HOME at 0750 so interactive users in the hermes group can share session state with the gateway service, and containers with volume-mounted state often need the gateway/dashboard -- running as different UIDs -- to both reach it. Forcing 0600 would silently break both documented sharing setups, so a new _skip_state_db_hardening() gate (lazy-imports hermes_cli.config to avoid a hard dependency from this lower-level module) short-circuits both functions when either check is true.

Testing

New tests/test_state_db_file_mode.py (POSIX-only, skipped on Windows same as the existing auth.json TOCTOU tests):

  • fresh state.db lands at 0600 under umask 0022
  • -wal/-shm sidecars land at 0600 when WAL mode creates them
  • a pre-existing state.db at a different mode (e.g. 0640, restored from backup) is left untouched, not silently rewritten
  • managed mode (is_managed() == True) and container mode (HERMES_CONTAINER=1) both skip hardening, leaving the file at the process umask's mode

Full tests/test_hermes_state.py suite + the new tests run clean (327 passed in the latest full regression run). Windows run: mode-bit tests skip (mode bits are ignored there), matching the pre-existing suite exactly -- no regression.

Scope

Out of scope for this PR, tracked separately: the HERMES_HOME_MODE warning (companion issue/PR), and a handful of config.yaml writers that bypass save_config()'s permission enforcement. Both are real but independent fixes.

…s umask

SessionDB.__init__ created state.db via a bare sqlite3.connect(), which
opens the file at the process umask (0644 under the common 022 default)
with no explicit chmod. On the default posture this is only shielded by
~/.hermes's 0700 mode; if that parent is ever widened (HERMES_HOME_MODE
override, managed mode's 0750), state.db's own bits become load-bearing
and expose full conversation history to other local accounts.

Pre-create state.db via os.open(O_CREAT | O_EXCL, 0o600) before
sqlite3.connect() ever touches it, so there's no window where the file
exists at a wider mode. Also chmod the -wal/-shm sidecars to 0600 once
WAL mode creates them -- SQLite creates those itself at the process
umask and they don't inherit the main file's mode.

An already-existing state.db (restored from backup, or created by an
older Hermes version) is left untouched -- O_EXCL only sets the mode on
first creation, matching the existing auth.json TOCTOU-safe pattern in
hermes_cli/auth.py.
@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 sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state P3 Low — cosmetic, nice to have labels Jul 6, 2026
0600 would silently break the NixOS module's 0750 group-shared HERMES_HOME
and volume-mounted container state, both of which intentionally rely on
group/other read access between the gateway and interactive users. Mirrors
hermes_cli.config._secure_file's own managed/container checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing the SessionDB creation path and preserving the managed/container sharing exemptions.

Problems

  • The hardening currently covers only SessionDB. tools/async_delegation.py:83-91 independently opens $HERMES_HOME/state.db with bare sqlite3.connect() and enables WAL. Its persistence path is reachable with a fresh home (tests/tools/test_async_delegation.py:247-250), so this can still create state.db and WAL sidecars at the process umask.
  • The added tests cover SessionDB only and do not exercise that direct state-db writer.

Suggested changes

  • Apply the same creation and sidecar policy to every state.db writer, including tools/async_delegation.py:_connect, preserving the proposed managed/container bypass.
  • Add a fresh-HERMES_HOME async-delegation regression test under umask 0o022.

This is an automated hermes-sweeper review.

@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-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 15, 2026
JoaoMarcos44 and others added 2 commits July 20, 2026 02:55
tools/async_delegation.py:_connect() opens the same state.db as
SessionDB via a bare sqlite3.connect(), bypassing the owner-only
(0600) hardening added for SessionDB. Apply the same
_create_owner_only / _secure_wal_files policy here, reusing
hermes_state's helpers (managed/container skip included).

Addresses teknium1's review on NousResearch#59716.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit 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.

state.db is created 0644 (process umask), not 0600 -- the 0700 parent dir is its only protection

3 participants