Skip to content

fix(security): enforce owner-only ACLs on Windows in _secure_file - #77527

Open
andrexibiza wants to merge 3 commits into
NousResearch:mainfrom
andrexibiza:fix/security-windows-acl-secure-files
Open

fix(security): enforce owner-only ACLs on Windows in _secure_file#77527
andrexibiza wants to merge 3 commits into
NousResearch:mainfrom
andrexibiza:fix/security-windows-acl-secure-files

Conversation

@andrexibiza

@andrexibiza andrexibiza commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Related #77462

What changed and why

Closes #77462 — the Windows at-rest ACL hole (conquest PR 1/26, from the red-team swarm).

_secure_file (hermes_cli/config.py:822) was a no-op on Windows: os.chmod(0o600) only toggles the read-only attribute and never restricts ACLs, so .env files written with an inherited ACL kept NT AUTHORITY\SYSTEM and BUILTIN\Administrators with full control — the "0600 at rest" posture was fictional on Windows. Live icacls on a Windows host confirmed (F) for SYSTEM/Administrators on secret files.

The fix adds a Windows branch invoking icacls (argv, never a shell) with /inheritance:r and /grant:r <user>:(F) — removing inherited entries and granting owner-only access, the ACL equivalent of POSIX 0600. Failures are swallowed like the POSIX branch (best-effort hardening). Managed-mode and container skips are preserved.

The mode-preservation branch in save_env_value (which keeps 0640 for Docker volume mounts) now also restricts ACLs on Windows: chmod cannot express POSIX group/other bits there, and a rotation on a pre-existing .env must not leave inherited SYSTEM/Administrators entries behind.

Scope: _secure_file guards the .env write path (save_env_value / save_env_value_secure). auth.json and state.db have their own at-rest protections (atomic O_EXCL 0600 write for auth.json; separate state.db handling) and are not changed here.

Why this matters to users

Before: on Windows, your API keys and tokens in ~/.hermes/.env were readable by every local admin, SYSTEM processes, and backup software — the file permissions looked private (0600) but were not. After: the file's ACL is restricted to your account, so a same-user or admin process can no longer silently read your credentials from disk.

How to test

On Windows: create a .env, run a rotation (save_env_value or hermes secrets ... token), then icacls ~/.hermes/.env — inherited Administrators/SYSTEM entries must be gone and only your account retains access. The new E2E tests assert exactly this with real icacls output.

Tests

  • test_secure_file_restricts_acls_on_windows — new-file ACL restriction (inherited Administrators removed, owner retained), real file + real icacls.
  • test_save_env_value_restricts_acls_on_existing_windows_env — mode-preservation rotation on an existing .env (same assertion) — the QA-follow-up regression.
  • test_secure_file_chmods_0600_on_posix — POSIX branch regression guard (runs on Linux CI).
  • test_secure_file_windows_acl_principal_not_spoofable_via_env — P1 regression: USERNAME/USER set to Everyone via the env writer cannot steer the icacls grant; the principal comes from the process token (GetUserNameW), asserted against real icacls output.

Platforms tested

Windows (native, this host) — both ACL tests pass. POSIX branch is regression-guarded for Linux CI.

Verification

tests/hermes_cli/test_config.py: 80 passed / 1 skipped (the sole failure, test_default_path, is a pre-existing HERMES_HOME env test proven identical on pristine main — this diff touches get_hermes_home zero lines). Ruff clean, git diff --check clean, Windows-footgun lint clean on changed lines. Rebased on current main (1b1975781f3); all required CI checks pass on the head. Independent QA critique: SAFE TO SHIP (both MAJOR follow-ups — mode-preservation bypass, message scope — fixed in 48c3f50; the P1 review finding — ACL principal must come from the process token, not mutable env vars — fixed in d11d9b58).

Part of #77462

Part of #77472

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard platform/windows Native Windows-specific behavior or breakage area/auth Authentication, OAuth, credential pools P3 Low — cosmetic, nice to have sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 3, 2026
@monerostar

Copy link
Copy Markdown
Contributor

Native Win11 verification (monerostar)

Host: Windows 11 (build 26200), Python 3.11.15. Checked out this branch against current upstream main.

chmod is a no-op for ACLs (bug confirmed)

Fresh temp .env with inherited ACL, then os.chmod(..., 0o600):

# BEFORE and AFTER chmod 0o600 — identical
NT AUTHORITY\SYSTEM:(I)(F)
BUILTIN\Administrators:(I)(F)
OWNER RIGHTS:(I)(F)

So main's _secure_file does not deliver a real 0600 posture on Windows. Live icacls matches the PR description / #77462.

This PR fixes it

After _secure_file(path) on the same file:

DUSTIN7600X\Admin:(F)

Inherited SYSTEM/Administrators entries removed; owner-only full control. Same result after save_env_value('DEMO_KEY', ...) on a HERMES_HOME .env.

Tests

pytest tests/hermes_cli/test_config.py -k "secure or acl or windows or chmod"
7 passed, 1 skipped (POSIX chmod branch skipped on win32)

Including test_secure_file_restricts_acls_on_windows and test_save_env_value_restricts_acls_on_existing_windows_env.

Notes

  • icacls via argv (no shell) is the right shape; best-effort swallow matches the POSIX branch contract.
  • Forcing ACL harden on Windows even when original_mode is preserved in save_env_value / remove_env_value closes the rotation hole the PR calls out — good catch.
  • Uses USERNAME/USER only; fine for interactive user profiles. (Service/LocalSystem edge cases are out of scope for this hole.)

LGTM — real at-rest ACL posture on Windows for secret files.

(Approve blocked for non-maintainer; treating this as a verified +1 with live evidence.)

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

One pull request addresses Issue #77462. Its diff targets the Windows _secure_file ACL gap for .env writes and existing-file rotations/removals, while the issue also names separate auth.json, state.db, and secret-cache paths not changed here.

Related pull requests

  • fix(security): enforce owner-only ACLs on Windows in _secure_file #77527 fixes — (+142/-9) — verify verdict: n/a. Adds Windows icacls enforcement that removes inheritance and grants owner-only access, applies it to preserved-mode .env rotation/removal paths, and adds native Windows plus POSIX regression tests; the discussion reports live Win11 confirmation and 7 focused tests passing, but no Verify verdict is recorded.

Suggested consolidation

keep open with a salvage path: retain the Windows ACL helper, .env rotation/removal coverage, and regression tests in #77527; separately address the issue's auth.json, state.db, and secret-cache scope rather than treating this partial-scope diff as a complete resolution.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I77462(["issue #77462 (open)"])
    P77527["PR #77527 (open)"]
    P77527 -->|fixes| I77462
    class I77462 open
    class P77527 open
    class P77527 target
    click I77462 "https://github.com/NousResearch/hermes-agent/issues/77462"
    click P77527 "https://github.com/NousResearch/hermes-agent/pull/77527"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 1 pull request and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 8 kB of PR diffs, 5 kB of issue/PR text, 2 kB of discussion (2 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

ZHJay added a commit to ZHJay/hermes-agent that referenced this pull request Aug 3, 2026
The helpers were tested; nothing proved main.ts called them. Reverting both
call sites and both imports in readDesktopConnectionConfig /
writeDesktopConnectionConfig left the whole suite green (947 passed / 2
skipped, tsc 0, eslint clean, e2e 1 passed 1 skipped) while connection.json
went back to 0644 — the user-visible fix this PR promises was untested.

The e2e spec could not catch it by construction: it asserts the ENCRYPTION
contract with a raw-bytes scan, and safeStorage keeps the token opaque
regardless of the file's mode, so a 0644 file passes that scan every time.
There was no mode assertion anywhere in e2e/.

Adds the missing third contract — unreadable by other local accounts — on all
three paths that can produce the file:

- write: assert the mode of the artifact test 1 already proves the app wrote.
- read, valid file: seed the app's own encrypted connection.json back to 0644
  and assert launch tightens it. Scoped to the MODE only, so it is independent
  of the still-deferred plaintext migration — the fixture's token is already
  ciphertext, so nothing re-encrypts, no NousResearch#62319 opt-in marker is involved, and
  no rotation guidance is owed.
- read, corrupt file: a truncated file still holds the token bytes and throws
  into the swallowing catch, so it would be the one file never tightened. This
  is the only test that distinguishes the chmod's placement relative to the
  parse.

Also moves the tighten above JSON.parse for exactly that reason, and pins the
cache invariant the placement depends on: the tighten must be a chmod, not a
rewrite, because it sits inside the function whose cache keys on mtimeMs.

Asserted as `mode & 0o077 === 0` rather than `=== 0o600` to avoid a
change-detector, and skipped on win32, where chmod maps to the read-only bit
and the fix deliberately no-ops (ACLs are PR NousResearch#77527).

Every assertion was mutation-tested: reverting the full wiring fails all three;
reverting only the write path fails only the write test; deleting only the
tighten-on-read fails only the two read tests; moving the tighten below the
parse fails only the corrupt test; making the tighten a rewrite instead of a
chmod fails the mtime assertions. Bundle greps confirmed each mutation reached
dist/electron-main.mjs before the run.
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Thank you for the native Win11 verification — the BEFORE/AFTER icacls output is exactly the proof this fix needed: chmod 0o600 is a no-op for ACLs on Windows (SYSTEM/Administrators keep (I)(F)), and the PR leaves only DUSTIN7600X\Admin:(F) with inheritance removed. 7 passed, 1 skipped on your box matches the POSIX-branch skip.

On the triage scope note (auth.json, state.db, secret-cache paths): agreed, this PR deliberately scopes to _secure_file + .env rotation/removal as the first ACL enforcement surface. The other at-rest paths are tracked separately in #77462 and follow the same icacls pattern — this diff is the reference implementation for the rest.

ZHJay added a commit to ZHJay/hermes-agent that referenced this pull request Aug 4, 2026
Copilot flagged that _open_launch_stderr_log() left an *existing* log's
mode alone: O_CREAT applies its mode argument only to a file it actually
creates, so a pre-existing 0644 log stayed 0644. Only fresh creates got
0600. Confirmed behaviourally against a temp HERMES_HOME under umask 022.

That is the case that matters. launch-stderr.log is the one artifact in
this PR with a fixed, guessable name (everything else is uuid4-named), so
under the documented HERMES_HOME_MODE=0701 traversal hatch it is the one
another local account can open by guess without a listable directory. An
older Hermes wrote it with a plain open() and left it 0644 on disk, so
every *upgrading* install kept exactly the exposure this PR claims to
close — contradicting the PR's own rationale for reconciling the profile
directory unconditionally ("that exposure is on disk today").

Reconciled through hermes_cli.config._secure_file rather than a
hand-rolled os.chmod, matching how the other three sites in this PR
delegate to _secure_dir: that helper is the single owner of the
owner-only file policy, it skips managed/NixOS installs and containers
where broader modes are deliberate, and it is where Windows ACL
enforcement lands (NousResearch#77527), so this inherits that instead of growing a
second implementation. The reconcile runs after the truncating open and
before any bytes are written, so the tighten lands while the file is
empty and no fresh Chromium stderr ever sits in a widely-readable file.
Safe against a running browser for the same reason the directory tighten
is: only group/other bits drop, the owner keeps rw, and POSIX checks the
mode at open() rather than on an already-open descriptor.

Also extends the managed/NixOS carve-out to the log's *creation*, which
the previous commit fixed for the directories and missed here. The log is
created lazily at runtime and is not in the module's systemd.tmpfiles
rules, so a hardcoded 0600 was the only thing setting its mode. Measured
under real managed conditions (2770 parent, UMask=0007): merge base 0o660,
this PR before 0o600, now 0o660. That regression is launch-breaking, not
cosmetic — the gateway and an interactive hostUsers CLI share one
$HERMES_HOME at two uids through the hermes group, every candidate binary
reuses this one log path, and an EACCES opening it makes launch_chrome_debug
report spawn-failed for every candidate (reproduced: launched=False).

Docstring fix: tools/computer_use/tool.py claimed "mode= ... is not masked
by umask", which is wrong — mkdir(mode=) IS subject to umask; it just
cannot widen the group/other bits from 0o700. Now matches the already
correct wording in tools/vision_tools.py.

Non-managed behaviour is unchanged. Re-measured all six scenarios from the
PR body (default, HERMES_SKIP_CHMOD=1, HERMES_CONTAINER=1,
HERMES_HOME_MODE=0701, managed with a pre-existing dir, managed with the
dir absent) across chrome-debug, cache/vision and cache/video: every
directory row is byte-identical to before this commit.

Tests: 6 new, all asserting the contract (no group/other bits; owner keeps
access) rather than a frozen octal. 4 of the 6 fail without this change.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data and removed sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working labels Aug 6, 2026

@monerostar monerostar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native Win11 live-verify (monerostar)

Host: Windows 11 build 26200 · CPython 3.11.15 · install tree %LOCALAPPDATA%\hermes\hermes-agent
PR tip: 9bcbbc562 · control: live main on same box

Bug still on main

_secure_file docstring still says no-op on Windows. Live call only leaves inherited ACLs in place. os.chmod(0o600) does not drop SYSTEM/Administrators.

Fixture: temp secret.env with normal inherited ACL, then call real _secure_file.

Tree has _secure_file_windows_acl Administrators after SYSTEM after owner after
main no yes (I)(F) yes (I)(F) yes
this PR yes gone gone DUSTIN7600X\Admin:(F) only

Tests

pytest tests/hermes_cli/test_config.py -o addopts= -k "secure_file_restricts_acls or save_env_value_restricts_acls"
# 2 passed (Windows ACL E2E + save_env_value rotation path)

Notes

  • icacls argv form looks right (no shell). Failures swallowed same as POSIX branch.
  • Doc first line still says "No-op on Windows" while the body describes the Win path. Cosmetic only; behavior matches the body + tests.
  • Multi-profile boxes write real .env under profile homes. Closing the inherited-Administrators hole matters here.

Looks good from this daily driver.

teknium1 pushed a commit that referenced this pull request Aug 13, 2026
`connection.json` under the desktop app's Electron `userData` was written with no
file mode, so it landed at the `0644` umask default — while its two
credential-bearing neighbours in the same directory, `desktop-installation.json`
and `native-oauth-tokens.json`, were already `0600`. That file holds the
safeStorage-encrypted gateway token plus the fields that are NOT encrypted: the
gateway URL and the SSH host, user, and key path.

- Route the single write choke point through a helper that creates the file
  owner-only and atomically.
- Tighten an already-existing `0644` file once per launch on the read path, so
  installs that already have one do not stay world-readable until the next save.
- Refuse to act on a path that is a symlink or not owned by the current user,
  matching the guards `desktop-installation.ts` already applies to its sibling.

The symlink guard alone turned out to be insufficient, and that is worth
recording: `writeSecretFileAtomic` tightens its *temp* path, so a symlink planted
at `connection.json.tmp` meant `writeFileSync` followed it, the guard correctly
bailed, and `renameSync` then moved the link onto `connection.json` permanently.
Measured, guard-only vs. as-landed:

    guards only          token leaked: true    config is a symlink: true   755
    guards + temp unlink token leaked: false   config is a symlink: false  600

So the temp path is unlinked before the write.

Issue #77486's headline claim — that a dashboard session token is persisted in
plaintext — does not hold against main. The token has been safeStorage-encrypted
since the desktop app reached mainline in 51c68d4, and `encryptDesktopSecret`
aborts with an actionable message rather than degrading to plaintext when
safeStorage is unavailable. The `{ encoding: 'plain', value }` literal does exist
at main.ts:7084, but only on the `persistToken: false` branch, whose sole caller
is the connection-test handler, which never writes. So no mainline path *writes*
a plaintext token. The commits that did contain a plaintext-writing fallback
(d3d1772, d208f2c) are not ancestors of main — they live only on
upstream/bb/gui-* and the desktop-pr20059-installers pre-release tag.

At-rest migration of legacy non-safeStorage payloads is deliberately NOT included.
An earlier revision of this branch implemented it and it was removed after review
reproduced two token-loss paths: it force-converts the opt-in plaintext choice
PR #62319 adds (silently reverting the user's decision, then destroying the token
on the next launch without the `--password-store=basic` flag), and it converts a
portable credential into a keychain-bound one with no consent — destroying the
only recoverable copy while not remediating the real exposure, since every
existing backup still holds the plaintext and the true remedy is rotation. It also
persisted raw `parsed`, bypassing `sanitizeConnectionProfiles`. A comment at the
read path records the three preconditions any future attempt needs.

`decryptDesktopSecret`'s non-safeStorage read fallback is untouched — it is what
lets a pre-release or hand-edited config work at all.

Windows still inherits the userData directory ACL rather than an explicit
owner-only one; mode bits are advisory there, so that half is deferred to
PR #77527 rather than growing a second ACL implementation here.

e2e: `at-rest-connection-token.spec.ts` asserts the at-rest contract
implementation-independently — the token's plaintext value (and its base64 form)
must not appear in a raw-bytes scan of any file under userData or HERMES_HOME,
AND the app must still put the exact original token on the wire after a restart,
so a fix that simply drops the token cannot pass. Proven non-vacuous by mutation:
writing `{ encoding: 'plain', value }` still fails the scan while the
file-exists and gateway-URL guards pass. The migration case is a documented
`test.fixme` naming its three blockers.

Electron project 928 -> 924 tests (-9 migration, +5 new guard and
mechanism-isolation). Two of those five exist because reverting either owner-only
mechanism alone initially scored zero failures — they were masking each other, so
either could have been deleted green.

(cherry picked from commit 6e01add)
teknium1 pushed a commit that referenced this pull request Aug 13, 2026
The helpers were tested; nothing proved main.ts called them. Reverting both
call sites and both imports in readDesktopConnectionConfig /
writeDesktopConnectionConfig left the whole suite green (947 passed / 2
skipped, tsc 0, eslint clean, e2e 1 passed 1 skipped) while connection.json
went back to 0644 — the user-visible fix this PR promises was untested.

The e2e spec could not catch it by construction: it asserts the ENCRYPTION
contract with a raw-bytes scan, and safeStorage keeps the token opaque
regardless of the file's mode, so a 0644 file passes that scan every time.
There was no mode assertion anywhere in e2e/.

Adds the missing third contract — unreadable by other local accounts — on all
three paths that can produce the file:

- write: assert the mode of the artifact test 1 already proves the app wrote.
- read, valid file: seed the app's own encrypted connection.json back to 0644
  and assert launch tightens it. Scoped to the MODE only, so it is independent
  of the still-deferred plaintext migration — the fixture's token is already
  ciphertext, so nothing re-encrypts, no #62319 opt-in marker is involved, and
  no rotation guidance is owed.
- read, corrupt file: a truncated file still holds the token bytes and throws
  into the swallowing catch, so it would be the one file never tightened. This
  is the only test that distinguishes the chmod's placement relative to the
  parse.

Also moves the tighten above JSON.parse for exactly that reason, and pins the
cache invariant the placement depends on: the tighten must be a chmod, not a
rewrite, because it sits inside the function whose cache keys on mtimeMs.

Asserted as `mode & 0o077 === 0` rather than `=== 0o600` to avoid a
change-detector, and skipped on win32, where chmod maps to the read-only bit
and the fix deliberately no-ops (ACLs are PR #77527).

Every assertion was mutation-tested: reverting the full wiring fails all three;
reverting only the write path fails only the write test; deleting only the
tighten-on-read fails only the two read tests; moving the tighten below the
parse fails only the corrupt test; making the tighten a rewrite instead of a
chmod fails the mtime assertions. Bundle greps confirmed each mutation reached
dist/electron-main.mjs before the run.

(cherry picked from commit 99cfc16)
teknium1 pushed a commit that referenced this pull request Aug 13, 2026
`connection.json` under the desktop app's Electron `userData` was written with no
file mode, so it landed at the `0644` umask default — while its two
credential-bearing neighbours in the same directory, `desktop-installation.json`
and `native-oauth-tokens.json`, were already `0600`. That file holds the
safeStorage-encrypted gateway token plus the fields that are NOT encrypted: the
gateway URL and the SSH host, user, and key path.

- Route the single write choke point through a helper that creates the file
  owner-only and atomically.
- Tighten an already-existing `0644` file once per launch on the read path, so
  installs that already have one do not stay world-readable until the next save.
- Refuse to act on a path that is a symlink or not owned by the current user,
  matching the guards `desktop-installation.ts` already applies to its sibling.

The symlink guard alone turned out to be insufficient, and that is worth
recording: `writeSecretFileAtomic` tightens its *temp* path, so a symlink planted
at `connection.json.tmp` meant `writeFileSync` followed it, the guard correctly
bailed, and `renameSync` then moved the link onto `connection.json` permanently.
Measured, guard-only vs. as-landed:

    guards only          token leaked: true    config is a symlink: true   755
    guards + temp unlink token leaked: false   config is a symlink: false  600

So the temp path is unlinked before the write.

Issue #77486's headline claim — that a dashboard session token is persisted in
plaintext — does not hold against main. The token has been safeStorage-encrypted
since the desktop app reached mainline in 51c68d4, and `encryptDesktopSecret`
aborts with an actionable message rather than degrading to plaintext when
safeStorage is unavailable. The `{ encoding: 'plain', value }` literal does exist
at main.ts:7084, but only on the `persistToken: false` branch, whose sole caller
is the connection-test handler, which never writes. So no mainline path *writes*
a plaintext token. The commits that did contain a plaintext-writing fallback
(d3d1772, d208f2c) are not ancestors of main — they live only on
upstream/bb/gui-* and the desktop-pr20059-installers pre-release tag.

At-rest migration of legacy non-safeStorage payloads is deliberately NOT included.
An earlier revision of this branch implemented it and it was removed after review
reproduced two token-loss paths: it force-converts the opt-in plaintext choice
PR #62319 adds (silently reverting the user's decision, then destroying the token
on the next launch without the `--password-store=basic` flag), and it converts a
portable credential into a keychain-bound one with no consent — destroying the
only recoverable copy while not remediating the real exposure, since every
existing backup still holds the plaintext and the true remedy is rotation. It also
persisted raw `parsed`, bypassing `sanitizeConnectionProfiles`. A comment at the
read path records the three preconditions any future attempt needs.

`decryptDesktopSecret`'s non-safeStorage read fallback is untouched — it is what
lets a pre-release or hand-edited config work at all.

Windows still inherits the userData directory ACL rather than an explicit
owner-only one; mode bits are advisory there, so that half is deferred to
PR #77527 rather than growing a second ACL implementation here.

e2e: `at-rest-connection-token.spec.ts` asserts the at-rest contract
implementation-independently — the token's plaintext value (and its base64 form)
must not appear in a raw-bytes scan of any file under userData or HERMES_HOME,
AND the app must still put the exact original token on the wire after a restart,
so a fix that simply drops the token cannot pass. Proven non-vacuous by mutation:
writing `{ encoding: 'plain', value }` still fails the scan while the
file-exists and gateway-URL guards pass. The migration case is a documented
`test.fixme` naming its three blockers.

Electron project 928 -> 924 tests (-9 migration, +5 new guard and
mechanism-isolation). Two of those five exist because reverting either owner-only
mechanism alone initially scored zero failures — they were masking each other, so
either could have been deleted green.

(cherry picked from commit 6e01add)
teknium1 pushed a commit that referenced this pull request Aug 13, 2026
The helpers were tested; nothing proved main.ts called them. Reverting both
call sites and both imports in readDesktopConnectionConfig /
writeDesktopConnectionConfig left the whole suite green (947 passed / 2
skipped, tsc 0, eslint clean, e2e 1 passed 1 skipped) while connection.json
went back to 0644 — the user-visible fix this PR promises was untested.

The e2e spec could not catch it by construction: it asserts the ENCRYPTION
contract with a raw-bytes scan, and safeStorage keeps the token opaque
regardless of the file's mode, so a 0644 file passes that scan every time.
There was no mode assertion anywhere in e2e/.

Adds the missing third contract — unreadable by other local accounts — on all
three paths that can produce the file:

- write: assert the mode of the artifact test 1 already proves the app wrote.
- read, valid file: seed the app's own encrypted connection.json back to 0644
  and assert launch tightens it. Scoped to the MODE only, so it is independent
  of the still-deferred plaintext migration — the fixture's token is already
  ciphertext, so nothing re-encrypts, no #62319 opt-in marker is involved, and
  no rotation guidance is owed.
- read, corrupt file: a truncated file still holds the token bytes and throws
  into the swallowing catch, so it would be the one file never tightened. This
  is the only test that distinguishes the chmod's placement relative to the
  parse.

Also moves the tighten above JSON.parse for exactly that reason, and pins the
cache invariant the placement depends on: the tighten must be a chmod, not a
rewrite, because it sits inside the function whose cache keys on mtimeMs.

Asserted as `mode & 0o077 === 0` rather than `=== 0o600` to avoid a
change-detector, and skipped on win32, where chmod maps to the read-only bit
and the fix deliberately no-ops (ACLs are PR #77527).

Every assertion was mutation-tested: reverting the full wiring fails all three;
reverting only the write path fails only the write test; deleting only the
tighten-on-read fails only the two read tests; moving the tighten below the
parse fails only the corrupt test; making the tighten a rewrite instead of a
chmod fails the mtime assertions. Bundle greps confirmed each mutation reached
dist/electron-main.mjs before the run.

(cherry picked from commit 99cfc16)
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The Windows ACL branch addresses the missing DACL hardening, but it trusts mutable USERNAME/USER values when selecting the account to receive full control. The authenticated dashboard environment writer accepts arbitrary custom keys, so a caller can set USERNAME=Everyone and then trigger another secret write; the next ACL command grants Everyone:(F), defeating owner-only protection. Resolve the account from the Windows access token or another OS-authoritative identity before merging.

  • [P1] Windows ACL principal is attacker-controlled through USERNAME/USER (hermes_cli/config.py:862)
    The new Windows hardening path reads USERNAME, falling back to USER, and interpolates that value into the /grant:r principal passed to icacls. Those names are writable through save_env_value, and PUT /api/env accepts arbitrary custom environment keys. A caller can save USERNAME=Everyone; save_env_value updates os.environ, so a later .env or config write reaches this helper with Everyone and grants Everyone:(F) full control after removing inheritance. That reverses the intended owner-only boundary and can expose API keys to other local principals. Passing argv avoids shell injection but does not make a mutable environment value a trusted identity.
    Remediation: Resolve the current Windows account from the process token (SID) or another OS-authoritative API, and pass that identity to icacls or a native ACL API; do not derive it from mutable environment variables. Add a regression that changes USERNAME through the env writer before a second write and verifies the grant still targets only the actual SID.

Security evidence:

  • trust boundary: An authenticated dashboard caller can submit arbitrary environment key/value pairs, and .env values are loaded into the process environment. The new Windows ACL helper consumes USERNAME/USER and sends that principal to icacls.
  • source/sink/invariant: The ACL grant principal must be the actual process owner; mutable configuration and environment data must never choose which Windows account receives full control.
  • current-main reproduction: Current main's _secure_file only calls os.chmod, which does not change a Windows file DACL. The PR branch introduces the icacls path and is the changed security behavior under review.
  • PR-head or patch-replay validation: The PR patch was replayed in the leased checkout at current main. Source review covers _secure_file and both environment rotation branches, and the focused configuration tests complete successfully.
  • positive/negative cases: A focused exact-interpreter probe showed USERNAME=Everyone becomes the icacls grant argument Everyone:(F), while an empty identity returns without invoking icacls.
  • residual bypass search: The helper is reached from the new Windows branch and the save/remove mode-preservation branches; the principal-selection path remains writable through USERNAME/USER, which is the published finding.
  • reviewer validation: The source-to-sink path and the env-writer-to-process-environment transition were independently traced and replayed. A token SID or native ACL API is required before relying on the owner-only invariant.

Not checked:

  • Windows ACL runtime validation
  • Ruff validation
  • CodeRabbit gate

Signed: GPT-5.6-luna-max in Codex

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Verification complete — P1 vulnerability fixed

Finding confirmed: The Windows ACL principal was derived from mutable USERNAME/USER environment variables. Since save_env_value updates os.environ and PUT /api/env accepts arbitrary custom keys, an authenticated dashboard caller could set USERNAME=Everyone and trigger a secret write; the next icacls invocation would grant Everyone:(F) after removing inheritance, reversing the owner-only boundary.

Fix applied: _secure_file_windows_acl now resolves the current Windows account from the process token via GetUserNameW (advapi32) — the authoritative OS identity. Environment variables are used only as a fallback if the authoritative lookup fails, preserving best-effort hardening on unusual configurations.

Regression test added: test_secure_file_windows_acl_principal_not_spoofable_via_env — spoofs USERNAME=Everyone in the environment, runs _secure_file_windows_acl, verifies the ACL still grants only the actual process owner (GetUserNameW result), not Everyone.

Tests passing:

  • test_secure_file_restricts_acls_on_windows (existing)
  • test_save_env_value_restricts_acls_on_existing_windows_env (existing)
  • test_secure_file_windows_acl_principal_not_spoofable_via_env (new regression)

All 8 Windows ACL/security tests pass. The test_default_path failure is a pre-existing environment-specific test (HERMES_HOME set to AppData/Local) unrelated to this fix.

Pushed to fork: andrexibiza:fix/security-windows-acl-secure-files (commit b974edd23cf)

andrexibiza and others added 2 commits August 14, 2026 07:37
_secure_file was a no-op on Windows: os.chmod(0o600) only toggles the
read-only attribute and never restricts ACLs, so files written with an
inherited ACL kept NT AUTHORITY\SYSTEM and BUILTIN\Administrators full
control — the '0600 at rest' posture was fictional on Windows (NousResearch#77462).

The Windows branch now invokes icacls (argv, never a shell) with
/inheritance:r and /grant:r <user>:(F), removing inherited entries and
granting owner-only access — the ACL equivalent of POSIX 0600. Failures
are swallowed like the POSIX branch (best-effort hardening). Managed-mode
and container skips are preserved.

The mode-preservation branch in save_env_value (Docker volume mounts keep
their 0640) now also restricts ACLs on Windows: chmod cannot express
POSIX group/other bits there, and a rotation on a pre-existing .env must
not leave the inherited SYSTEM/Administrators entries behind.

Scope: _secure_file guards the .env write path (save_env_value /
save_env_value_secure). auth.json and state.db have their own at-rest
protections (atomic O_EXCL 0600 write for auth.json; separate state.db
handling) and are not changed here.

Tests (E2E on Windows): real file + real icacls — new-file ACL
restriction (inherited Administrators removed, owner retained), and
mode-preservation rotation on an existing .env (same assertion).
POSIX branch regression-guarded (0600 chmod). Verified live on Windows:
75 passed / 1 skipped, ruff clean, diff --check clean, Windows-footgun
lint clean on the changed lines (pre-existing file-wide read_text-encoding
hits untouched — separate concern).

Closes NousResearch#77462 (conquest PR 1/26).
@andrexibiza
andrexibiza force-pushed the fix/security-windows-acl-secure-files branch from b974edd to d11d9b5 Compare August 14, 2026 12:57
@andrexibiza

Copy link
Copy Markdown
Contributor Author

P1 addressed on the current head (d11d9b58): _secure_file_windows_acl now resolves the ACL principal from the process token via GetUserNameW (advapi32) — not from USERNAME/USER — so a caller who writes USERNAME=Everyone through save_env_value / PUT /api/env cannot steer the icacls /grant:r principal. The env vars remain only as a fallback when the token lookup itself fails.

Regression: test_secure_file_windows_acl_principal_not_spoofable_via_env sets both USERNAME=Everyone and USER=Everyone, runs the helper, and asserts against real icacls output that Everyone is absent from the DACL while the actual process owner retains (F).

The branch was also rebased onto current main (1b1975781f3) — it was conflicting — and all required CI checks now pass on the head.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The patch improves Windows ACL handling, but the owner-only invariant remains bypassable through writable hardening opt-outs, post-publication ACL application, a mutable identity fallback, and preservation of explicit non-owner entries.

  • [P1] Dashboard-writable hardening opt-outs bypass Windows ACL protection
    The secret-file hardening helper returns early when HERMES_CONTAINER or HERMES_SKIP_CHMOD is active. The dashboard accepts arbitrary environment names and updates the process environment after saving, so a caller can enable one of these settings before rotating a provider secret and leave inherited ACLs in place. Reject HERMES_CONTAINER and HERMES_SKIP_CHMOD from dashboard writes or make the secret-file decision immutable; add a regression covering the setting write followed by rotation.

  • [P2] Atomic replacement exposes the new secret before ACL hardening
    Secret writes publish a replacement before applying Windows ACL hardening, so inherited access can apply during that interval and a termination there can leave the replacement unprotected. Apply the owner-only ACL before publication (or use a handle-based operation), verify it succeeds, and fail closed or retain the old file when setup fails.

  • [P2] Principal lookup failure falls back to mutable environment identity
    The process-token lookup falls back to writable USERNAME or USER. If token lookup fails, a caller can set a broad principal and cause the ACL to grant that principal, defeating owner-only protection. Remove the fallback and fail closed, or resolve a SID directly from the process token; add a lookup-failure test with spoofed environment values.

  • [P2] ACL hardening leaves explicit non-owner entries in place
    Disabling inheritance and replacing only the resolved user's grant does not remove explicit ACEs. An existing secret file with a broad explicit ACE can remain readable after hardening, contrary to the owner-only invariant. Construct and verify an explicit owner-only DACL, and add coverage for explicit broad principals.

Security evidence:

  • trust boundary: An authenticated dashboard environment write flows through the credential lifecycle into atomic secret-file replacement; the hardening helper is the sink expected to enforce owner-only access before other local principals can read the secret.
  • source/sink/invariant: Every newly created or rotated secret file must grant access only to the Hermes account; the changed flow can skip hardening, trust mutable identity fallback, preserve explicit entries, and harden after publication.
  • current-main reproduction: On current main, the Windows implementation only changes file mode/read-only state and does not remove inherited ACLs.
  • PR-head or patch-replay validation: The changed secret-write and ACL logic and its regression coverage were inspected; the normal environment-identity spoof is addressed, while the four bypasses above remain.
  • positive/negative cases: The change covers non-Windows mode protection, managed/container guards, process-token identity, inherited-entry removal, and the normal environment-spoof case.
  • residual bypass search: The write and dashboard environment flows still reach the hardening opt-outs; the ACL helper still has the mutable fallback, preserves explicit entries, and runs after publication.
  • reviewer validation: Source review confirms the token-lookup fallback and the writable opt-outs reach security-sensitive branches.

Not checked:

  • Windows ACL runtime
  • Replacement crash-window behavior
  • Token-lookup failure handling
  • Dashboard-writable hardening opt-outs
  • Explicit non-owner ACE handling

Signed: GPT-5.6-luna-max in Codex

@alt-glitch alt-glitch removed the sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data label Aug 14, 2026

@monerostar monerostar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Win11 here, tip d32c93e2e.

Main still has no Windows ACL helper. os.chmod(0o600) is a no-op. I wrote a temp secret, set USERNAME=Everyone, called _secure_file. icacls stayed NT AUTHORITY\SYSTEM:(I)(F) + BUILTIN\Administrators:(I)(F).

PR resolves the principal with GetUserNameW. Same spoof on this box: only DUSTIN7600X\Admin:(F). No Everyone. No Administrators.

pytest on the tip (-k "secure_file_restricts_acls or save_env_value_restricts_acls or windows_acl_principal"): 3 passed, 79 deselected.

Looks good. New evidence only. Earlier note was on 9bcbbc5625 before this token-principal fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(security): Windows at-rest ACL hole — _secure_file is a no-op on Windows, secrets readable by SYSTEM/Administrators

5 participants