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