From 79fc92e9cb9ab57262a026ac14f29926fc53ad55 Mon Sep 17 00:00:00 2001 From: dusterbloom <32869278+dusterbloom@users.noreply.github.com> Date: Mon, 25 May 2026 03:38:11 -0700 Subject: [PATCH] fix(security): tighten .env file permissions to 0600 at all creation sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .env holds API keys and secrets. Multiple creation sites used `cp` / `touch` / `shutil.copy2` which obey the process umask — commonly 0o022, leaving the file at 0o644 (world-readable). Apply chmod 0o600 explicitly at every site that creates or copies .env. Sites covered: - docker/stage2-hook.sh: after the seed_one '.env' call, applied unconditionally (not just on first-seed) so a host-mounted .env with loose perms gets tightened on every container restart - hermes_cli/doctor.py: 'hermes doctor --fix' touches an empty .env when missing - hermes_cli/profiles.py: 'hermes profile create --clone' copies .env from the source profile; shutil.copy2 preserves source mode, so a source .env at 0o644 was being cloned into 0o644 - setup-hermes.sh: in-tree setup script's cp .env.example .env path, plus the already-exists branch (mirror of install.sh which already chmods 600 unconditionally on line 1442) scripts/install.sh was NOT changed — it already chmod 600's the .env unconditionally after the create/already-exists branches (line 1442). Salvaged from PR #25726 by @dusterbloom. The docker/entrypoint.sh portion of the original PR was dropped because main switched to an s6-overlay shim — the .env creation logic moved to stage2-hook.sh, which is where the chmod now lives. Closes #25497 (subset — install.sh + setup-hermes.sh) and #8448 (subset — install.sh only) as superseded. Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> --- docker/stage2-hook.sh | 8 ++++++++ hermes_cli/doctor.py | 7 +++++++ hermes_cli/profiles.py | 12 +++++++++++- setup-hermes.sh | 6 ++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 6a5bedc9f6d..64b1745d5ad 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -111,6 +111,14 @@ seed_one ".env" ".env.example" seed_one "config.yaml" "cli-config.yaml.example" seed_one "SOUL.md" "docker/SOUL.md" +# .env holds API keys and secrets — restrict to owner-only access. Applied +# unconditionally (not only on first-seed) so a host-mounted .env that was +# created with a permissive umask gets tightened on every container start. +if [ -f "$HERMES_HOME/.env" ]; then + chown hermes:hermes "$HERMES_HOME/.env" 2>/dev/null || true + chmod 600 "$HERMES_HOME/.env" 2>/dev/null || true +fi + # auth.json: bootstrap from env on first boot only. Same semantics as the # pre-s6 entrypoint — the [ ! -f ] guard is critical to avoid clobbering # rotated refresh tokens on container restart. diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 9cac0678cef..9bdbc77e7ef 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -569,6 +569,13 @@ def run_doctor(args): if should_fix: env_path.parent.mkdir(parents=True, exist_ok=True) env_path.touch() + # .env holds API keys — restrict to owner-only access from + # creation. touch() obeys umask which is commonly 0o022, + # leaving the file world-readable; tighten explicitly. + try: + os.chmod(str(env_path), 0o600) + except OSError: + pass check_ok(f"Created empty {_DHH}/.env") check_info("Run 'hermes setup' to configure API keys") fixed_count += 1 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index c4cb373bddc..ec315c7fdb1 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -723,7 +723,17 @@ def create_profile( for filename in _CLONE_CONFIG_FILES: src = source_dir / filename if src.exists(): - shutil.copy2(src, profile_dir / filename) + dst = profile_dir / filename + shutil.copy2(src, dst) + # Tighten .env to owner-only after copy. shutil.copy2 + # preserves source mode bits, but if the source's .env + # was loose (host umask 0o022 leaving 0o644), tighten + # explicitly so the clone doesn't inherit weak perms. + if filename == ".env": + try: + os.chmod(str(dst), 0o600) + except OSError: + pass # Clone installed skills from the source profile. The dashboard's # "clone from default" flow is expected to preserve both bundled diff --git a/setup-hermes.sh b/setup-hermes.sh index bdb8c1e9653..1706201055b 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -329,9 +329,15 @@ fi if [ ! -f ".env" ]; then if [ -f ".env.example" ]; then cp .env.example .env + # .env holds API keys — restrict to owner-only access (matches + # scripts/install.sh which already chmods 600 after creation). + chmod 600 .env 2>/dev/null || true echo -e "${GREEN}✓${NC} Created .env from template" fi else + # Tighten an existing .env's perms in case it was created elsewhere + # under a permissive umask. + chmod 600 .env 2>/dev/null || true echo -e "${GREEN}✓${NC} .env exists" fi