fix(terminal): create session snapshot owner-only to stop secret leak - #41278
fix(terminal): create session snapshot owner-only to stop secret leak#41278agentswe wants to merge 1 commit into
Conversation
## What does this PR do? The local terminal backend captures a login-shell snapshot via `export -p > /tmp/hermes-snap-<id>.sh` so env vars persist across the spawn-per-call model. `export -p` dumps every exported variable, and the snapshot's bash subprocess inherits the host environment: the provider blocklist strips model API keys, but SUDO_PASSWORD is not blocklisted and the general AWS credential chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN) is intentionally passed through, plus any user-set secrets. The file was created with the shell's inherited default umask (typically 022 -> mode 0644), so on a shared/multi-user host any other local user could read it and harvest those secrets for the whole (potentially long-lived gateway) session. The same dump is rewritten after every command, and the file only goes away at cleanup(). I went with `umask 077` rather than a Python-side chmod because the snapshot is written by the shell on the remote side for the container/ssh backends too — chmod in Python only reaches the local backend, whereas the umask travels with the bootstrap script and fixes every backend uniformly. Setting it once at the top of the init bootstrap covers first creation; the per-command re-dump is wrapped in a `(umask 077; ...)` subshell so a snapshot recreated mid-session can't regress to world-readable while keeping the umask change off the user's own command. The cwd marker file gets the same treatment for consistency. ## Related Issue N/A ## Type of Change - [x] 🔒 Security fix ## Changes Made - `tools/environments/base.py`: prepend `umask 077` to the `init_session` bootstrap so the snapshot and cwd files are created mode 0600; wrap the per-command snapshot/cwd re-dump in `_wrap_command` in a `(umask 077; ...)` subshell so the restriction survives mid-session recreation without affecting the user's command. - `tests/tools/test_snapshot_permissions.py`: new tests asserting the wrapper string is umask-protected (and that the umask does not leak into the user command), plus end-to-end checks that the local backend's snapshot lands owner-only and stays that way after a command. - `scripts/release.py`: add my noreply email to `AUTHOR_MAP`. ## How to Test 1. `pytest tests/tools/test_snapshot_permissions.py tests/tools/test_base_environment.py -q` 2. To see the original exposure, revert the `umask 077` additions in `base.py` and re-run: the file-mode tests fail with the snapshot at `0o644` (group/other readable). With the fix they pass at `0o600`. 3. Manual: run a terminal command on the local backend and check `stat -f '%Lp' /tmp/hermes-snap-*.sh` (macOS) / `stat -c '%a'` (Linux) reports `600`. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [x] I've run the relevant tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture — N/A - [x] I've considered cross-platform impact (Windows, macOS) — the fix is a POSIX umask in the shell bootstrap; harmless no-op on the Windows/Git Bash path, and the on-disk mode tests are skipped on Windows - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary (PR #41278)
Verdict: Approved
Looks Good
- Security fix is well-scoped: adds
umask 077before snapshot/cwd file creation to prevent world-readable env dumps leaking secrets (SUDO_PASSWORD, AWS creds, etc.) - Tests pin both the wrapper-string contract (
_wrap_commandproduces(umask 077; ...)subshells) and the actual on-disk file mode viaLocalEnvironment - Tests verify umask is scoped to the subshell and does not leak into user commands
- Covers edge cases: snapshot re-dump mid-session, file mode after command execution
- AUTHOR_MAP entry added for release script
Reviewed by Hermes Agent
✅ Verification — clean security fixSolid fix for a real secret-exposure vector:
No issues found. LGTM. |
egilewski
left a comment
There was a problem hiding this comment.
Recommendation: needs rework before merge.
I checked this against current GitHub main f8adefdebf082047527a5fe628c0a4c6f3906a57, PR base e2cc24e3311da5575f9e0df256e14e62ff39dab2, and PR head f0ea72e5cdc6467890bfff02de856bf04cdfe371.
The security change itself validates. With a permissive 0o022 umask and synthetic AWS credentials, current main created the terminal snapshot and cwd files as 0644 while the synthetic secret was present in the snapshot. Replaying the PR's tools/environments/base.py plus tests/tools/test_snapshot_permissions.py hunks onto current main produced 0600 snapshot/cwd files on initial creation and kept the recreated snapshot at 0600 after a command, while the user command's own umask output stayed unchanged.
Validation:
git apply --check --exclude=scripts/release.py pr.diffpassed for the security files.git apply --check pr.difffailed atscripts/release.py:46, and GitHub reportsmergeable=false/mergeable_state=dirty.git merge-tree --write-tree f8adefdebf082047527a5fe628c0a4c6f3906a57 refs/remotes/upstream/pr/41278exited nonzero with broad stale-branch conflicts.python -B -m pytest -o addopts='' -p no:cacheprovider tests/tools/test_snapshot_permissions.py tests/tools/test_base_environment.py -qpassed on the current-main replay (22 passed).python -B -m compileall -q tools/environments/base.py tests/tools/test_snapshot_permissions.pypassed.coderabbit review --plain --base f8adefdebf082047527a5fe628c0a4c6f3906a57 --type uncommittedreturnedNo findings.
Please rebase and resolve/drop the stale scripts/release.py author-map hunk. The security patch looks good once replayed onto current main, but the submitted branch is not mergeable as-is.
Signed: GPT-5.5-xhigh in Codex
|
Thanks for the careful writeup and the tests, @agentswe — the analysis of the exposure ( We're going to pass on this one, though. Closing without merging. Appreciate the contribution. |
What does this PR do?
The local terminal backend captures a login-shell snapshot via
export -p > /tmp/hermes-snap-<id>.shso env vars persist across thespawn-per-call model.
export -pdumps every exported variable, and thesnapshot's bash subprocess inherits the host environment: the provider
blocklist strips model API keys, but SUDO_PASSWORD is not blocklisted and
the general AWS credential chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
/ AWS_SESSION_TOKEN) is intentionally passed through, plus any user-set
secrets. The file was created with the shell's inherited default umask
(typically 022 -> mode 0644), so on a shared/multi-user host any other
local user could read it and harvest those secrets for the whole
(potentially long-lived gateway) session. The same dump is rewritten after
every command, and the file only goes away at cleanup().
I went with
umask 077rather than a Python-side chmod because thesnapshot is written by the shell on the remote side for the container/ssh
backends too — chmod in Python only reaches the local backend, whereas the
umask travels with the bootstrap script and fixes every backend uniformly.
Setting it once at the top of the init bootstrap covers first creation; the
per-command re-dump is wrapped in a
(umask 077; ...)subshell so asnapshot recreated mid-session can't regress to world-readable while
keeping the umask change off the user's own command. The cwd marker file
gets the same treatment for consistency.
Related Issue
N/A
Type of Change
Changes Made
tools/environments/base.py: prependumask 077to theinit_sessionbootstrap so the snapshot and cwd files are created mode 0600; wrap the
per-command snapshot/cwd re-dump in
_wrap_commandin a(umask 077; ...)subshell so the restriction survives mid-session recreation without
affecting the user's command.
tests/tools/test_snapshot_permissions.py: new tests asserting thewrapper string is umask-protected (and that the umask does not leak into
the user command), plus end-to-end checks that the local backend's
snapshot lands owner-only and stays that way after a command.
scripts/release.py: add my noreply email toAUTHOR_MAP.How to Test
pytest tests/tools/test_snapshot_permissions.py tests/tools/test_base_environment.py -qumask 077additions inbase.pyand re-run: the file-mode tests fail with the snapshot at0o644(group/other readable). With the fix they pass at0o600.stat -f '%Lp' /tmp/hermes-snap-*.sh(macOS) /stat -c '%a'(Linux)reports
600.Checklist
Code
Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture — N/APOSIX umask in the shell bootstrap; harmless no-op on the Windows/Git Bash
path, and the on-disk mode tests are skipped on Windows